PawTrace / frontend /src /components /CaseDetailModal.tsx
Elliott Duke
Site polish: hero illustration, About rewrite, copy and styling pass
9122959
Raw
History Blame Contribute Delete
4.8 kB
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>
);
}