| import { useCallback, useEffect, useState } from "react"; |
| import { api } from "../api"; |
| import type { AdminCaseDetail } from "../types"; |
| import MatchCard from "./MatchCard"; |
|
|
| const TYPE_BADGE: Record<string, string> = { |
| lost: "bg-red-100 text-red-700", |
| found: "bg-accent-100 text-accent-700", |
| }; |
|
|
| |
| |
| export default function CaseDetailModal({ |
| caseId, |
| onClose, |
| onChanged, |
| }: { |
| caseId: number; |
| onClose: () => void; |
| onChanged?: () => void; |
| }) { |
| const [data, setData] = useState<AdminCaseDetail | null>(null); |
| const [error, setError] = useState<string | null>(null); |
| const [running, setRunning] = useState(false); |
|
|
| const load = useCallback(() => { |
| return api |
| .getCaseDetail(caseId) |
| .then(setData) |
| .catch((e) => setError(e instanceof Error ? e.message : "Failed to load")); |
| }, [caseId]); |
|
|
| useEffect(() => { |
| setData(null); |
| setError(null); |
| load(); |
| }, [load]); |
|
|
| async function runMatch() { |
| setRunning(true); |
| setError(null); |
| try { |
| await api.runCaseMatch(caseId); |
| await load(); |
| onChanged?.(); |
| } catch (e) { |
| setError(e instanceof Error ? e.message : "Matching failed"); |
| } finally { |
| setRunning(false); |
| } |
| } |
|
|
| const c = data?.case; |
| const dog = data?.dog; |
|
|
| return ( |
| <div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-30" role="dialog" onClick={onClose}> |
| <div className="card max-w-3xl w-full max-h-[85vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}> |
| {error && <p className="text-red-600 text-sm mb-2">{error}</p>} |
| {!data ? ( |
| <p className="text-gray-500">Loading…</p> |
| ) : ( |
| <> |
| <div className="flex items-start justify-between mb-1"> |
| <div> |
| <h3 className="font-bold text-lg"> |
| <span className={`badge ${TYPE_BADGE[c!.type]} mr-2`}>{c!.type}</span> |
| {dog?.profile.name ?? "—"} |
| </h3> |
| <p className="text-xs text-gray-500"> |
| {c!.type === "lost" ? "Reported by " : "Found by "} |
| {c!.person?.name || c!.finder_name || c!.finder_email || "—"} |
| {" · "}ZIP {c!.event_zip} · {c!.event_date} · {c!.status} |
| </p> |
| </div> |
| <button className="btn-secondary" onClick={onClose}> |
| Close |
| </button> |
| </div> |
| |
| {dog && ( |
| <div className="mt-3"> |
| <p className="text-sm font-medium text-gray-700 mb-1"> |
| {c!.type === "lost" ? "Lost dog" : "Found dog"} · {dog.photos.length} photo(s) |
| </p> |
| <div className="grid grid-cols-3 sm:grid-cols-4 gap-2"> |
| {dog.photos.map((p) => ( |
| <img |
| key={p.id} |
| src={p.url || p.thumb_url || "/paw-placeholder.svg"} |
| alt={dog.profile.name} |
| className="w-full h-28 object-contain rounded-md bg-gray-100" |
| /> |
| ))} |
| {dog.photos.length === 0 && ( |
| <p className="text-gray-500 text-sm col-span-full">No photos.</p> |
| )} |
| </div> |
| </div> |
| )} |
| |
| <div className="mt-4 flex items-center justify-between"> |
| <h4 className="font-semibold"> |
| Matches{" "} |
| <span className="text-sm font-normal text-gray-500"> |
| ({c!.type === "lost" ? "candidate found dogs" : "candidate lost dogs"}) |
| </span> |
| </h4> |
| <button className="btn-primary" disabled={running} onClick={runMatch}> |
| {running ? "Running…" : data.matches.length ? "Re-run matching" : "Run matching"} |
| </button> |
| </div> |
| |
| {data.matches.length === 0 ? ( |
| <p className="text-gray-500 text-sm mt-2"> |
| No matches yet. Run matching to find candidates; if none appear, widen the search or the |
| dog may not be in the system. |
| </p> |
| ) : ( |
| <div className="space-y-3 mt-3"> |
| {data.matches.map((m) => ( |
| <MatchCard |
| key={m.id} |
| match={m} |
| queryPhotos={dog?.photos} |
| queryLabel={dog ? `${dog.profile.name} (this case)` : undefined} |
| /> |
| ))} |
| </div> |
| )} |
| </> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|