File size: 4,802 Bytes
de1e3fc 9122959 de1e3fc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | 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",
};
// Admin modal: a case with its subject dog (all photos) + persisted matches, and a run/re-run
// matching action. Shared by the All Cases table and the owner-detail view.
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>
);
}
|