diff --git a/app/[locale]/(app)/checkups/[id]/page.tsx b/app/[locale]/(app)/checkups/[id]/page.tsx index 1ecee41..39b1d80 100644 --- a/app/[locale]/(app)/checkups/[id]/page.tsx +++ b/app/[locale]/(app)/checkups/[id]/page.tsx @@ -6,6 +6,7 @@ import { Link } from "@/i18n/navigation"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { Button } from "@/components/ui/button"; import { CheckupForm } from "../checkup-form"; +import { DeleteCheckupButton } from "../delete-checkup-button"; const str = (v: string | number | null) => (v == null ? "" : String(v)); @@ -68,6 +69,7 @@ export default async function CheckupPage({ {tImaging("title")} + diff --git a/app/[locale]/(app)/checkups/actions.ts b/app/[locale]/(app)/checkups/actions.ts index 33ce901..c8ff5a3 100644 --- a/app/[locale]/(app)/checkups/actions.ts +++ b/app/[locale]/(app)/checkups/actions.ts @@ -87,3 +87,26 @@ export async function saveCheckupAction( revalidatePath(`/${locale}/queue`); return redirect({ href: `/${locale}/queue`, locale }); } + +// ── Soft-delete ─────────────────────────────────────────────────────────────── +export async function deleteCheckupAction(formData: FormData): Promise { + const session = await getServerSession(); + if (!isClinical(session?.role)) return; + + const id = Number(formData.get("id")); + if (!Number.isFinite(id)) return; + + const supabase = await createSupabaseServerClient(); + const { error } = await supabase.from("checkups").update({ deleted: true }).eq("id", id); + if (error) return; + + await supabase.rpc("log_audit", { + p_action: "checkup.delete", + p_entity: "checkups", + p_entity_id: String(id), + }); + + const locale = await getLocale(); + revalidatePath(`/${locale}/queue`); + return redirect({ href: `/${locale}/queue`, locale }); +} diff --git a/app/[locale]/(app)/checkups/delete-checkup-button.tsx b/app/[locale]/(app)/checkups/delete-checkup-button.tsx new file mode 100644 index 0000000..bdbd02c --- /dev/null +++ b/app/[locale]/(app)/checkups/delete-checkup-button.tsx @@ -0,0 +1,32 @@ +"use client"; + +/** + * Delete control for a checkup. A plain `
` can't confirm before + * submitting, so this client component intercepts submit, asks via + * window.confirm(), and cancels (preventDefault) if the user declines. + */ + +import type { FormEvent } from "react"; +import { useTranslations } from "next-intl"; + +import { Button } from "@/components/ui/button"; +import { deleteCheckupAction } from "./actions"; + +export function DeleteCheckupButton({ checkupId }: { checkupId: number }) { + const t = useTranslations("checkups"); + + function onSubmit(e: FormEvent) { + if (!window.confirm(t("confirmDelete"))) { + e.preventDefault(); + } + } + + return ( + + + + + ); +} diff --git a/app/[locale]/(app)/patients/[id]/page.tsx b/app/[locale]/(app)/patients/[id]/page.tsx new file mode 100644 index 0000000..0390c85 --- /dev/null +++ b/app/[locale]/(app)/patients/[id]/page.tsx @@ -0,0 +1,129 @@ +// WARNING: Do NOT add `'use cache'` — reads cookies() via the server client. + +/** + * Patient detail — Server Component. Shows the customer's profile (with the + * province/ward codes resolved to readable names) and their checkup history, + * newest first. Clinical-role gated by the patients layout. + */ + +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { Link } from "@/i18n/navigation"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { Button } from "@/components/ui/button"; + +const STATUS_STYLE: Record = { + waiting: "bg-muted text-foreground", + in_progress: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-200", + done: "bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200", +}; + +export default async function PatientDetailPage({ + params, +}: { + params: Promise<{ locale: string; id: string }>; +}) { + const { locale, id } = await params; + const t = await getTranslations("patients"); + const tCheckups = await getTranslations("checkups"); + const customerId = Number(id); + if (!Number.isFinite(customerId)) notFound(); + + const supabase = await createSupabaseServerClient(); + + const { data: customer } = await supabase + .from("customers") + .select("id, first_name, last_name, dob, gender, phone, cccd, province_code, ward_code, address_detail") + .eq("id", customerId) + .eq("deleted", false) + .maybeSingle(); + + if (!customer) notFound(); + + const [{ data: province }, { data: ward }, { data: checkups }] = await Promise.all([ + customer.province_code + ? supabase.from("provinces").select("name").eq("code", customer.province_code).maybeSingle() + : Promise.resolve({ data: null }), + customer.ward_code + ? supabase.from("wards").select("name").eq("code", customer.ward_code).maybeSingle() + : Promise.resolve({ data: null }), + supabase + .from("checkups") + .select("id, checkup_date, queue_number, status, doctor_id, diagnosis") + .eq("customer_id", customerId) + .eq("deleted", false) + .order("checkup_date", { ascending: false }) + .order("id", { ascending: false }) + .limit(50), + ]); + + const rows = checkups ?? []; + const doctorIds = [...new Set(rows.map((r) => r.doctor_id).filter((d): d is number => d != null))]; + const { data: doctors } = doctorIds.length + ? await supabase.from("doctors").select("id, last_name, first_name").in("id", doctorIds) + : { data: [] }; + const docName = new Map((doctors ?? []).map((d) => [d.id, `${d.last_name} ${d.first_name}`])); + + const address = [customer.address_detail, ward?.name, province?.name].filter(Boolean).join(", "); + const fullName = `${customer.last_name} ${customer.first_name}`; + + return ( +
+
+
+

{t("detailTitle", { name: fullName })}

+

+ {[ + customer.dob, + customer.gender ? t(`gender.${customer.gender}`) : null, + customer.phone, + customer.cccd, + address || null, + ] + .filter(Boolean) + .join(" · ") || "—"} +

+
+ +
+ +

{t("history")}

+ {rows.length === 0 ? ( +

{t("noHistory")}

+ ) : ( +
    + {rows.map((c) => ( +
  • + +
    +

    + {c.checkup_date} + {c.queue_number != null ? ` · #${c.queue_number}` : ""} +

    +

    + {[c.doctor_id ? (docName.get(c.doctor_id) ?? "—") : null, c.diagnosis] + .filter(Boolean) + .join(" · ") || "—"} +

    +
    + + {tCheckups(`status.${c.status}`)} + + +
  • + ))} +
+ )} +
+ ); +} diff --git a/app/[locale]/(app)/patients/page.tsx b/app/[locale]/(app)/patients/page.tsx index 6371014..ea58fcc 100644 --- a/app/[locale]/(app)/patients/page.tsx +++ b/app/[locale]/(app)/patients/page.tsx @@ -35,6 +35,34 @@ export default async function PatientsPage({ }; const patients = (data ?? []) as PatientRow[]; + // Recently seen: most recent distinct customers by their latest checkup. + const { data: recentCheckups } = await supabase + .from("checkups") + .select("customer_id") + .eq("deleted", false) + .order("checkup_date", { ascending: false }) + .order("id", { ascending: false }) + .limit(50); + + const seenIds = new Set(); + const recentIds: number[] = []; + for (const row of recentCheckups ?? []) { + if (!seenIds.has(row.customer_id)) { + seenIds.add(row.customer_id); + recentIds.push(row.customer_id); + } + if (recentIds.length >= 8) break; + } + + const { data: recentCustomers } = recentIds.length + ? await supabase.from("customers").select("id, last_name, first_name").in("id", recentIds).eq("deleted", false) + : { data: [] }; + + const recentNames = new Map((recentCustomers ?? []).map((c) => [c.id, `${c.last_name} ${c.first_name}`])); + const recentlySeen = recentIds + .filter((cid) => recentNames.has(cid)) + .map((cid) => ({ id: cid, name: recentNames.get(cid)! })); + return (
@@ -46,6 +74,26 @@ export default async function PatientsPage({
+ {recentlySeen.length > 0 && ( +
+

+ {t("recentlySeen")} +

+
+ {recentlySeen.map((p) => ( + + {p.name} + + ))} +
+
+ )} +