From 88a42c9d064e9645daf12cd84e7b964aebf5d5d0 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Fri, 22 May 2026 16:10:20 +0300 Subject: [PATCH] feat(patients): detail page with full job history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clinics had no way to look up 'what have we made for this patient before'. The patient row showed in the list and edit dialog, but no deeper page. Added /patients/[id] with the patient header, notes card and a 'İş Geçmişi' table that's chronological. - listPatientJobs(patientId, patientCode, clinicTenantId) merges two queries — explicit patientId match (new jobs) and patientCode match (legacy rows from before we had the relation). Dedupes by $id and sorts createdAt desc. Returns plain. - /patients/[patientId]/page.tsx (clinic-only via requireTenantKind): notFound on missing/foreign rows, header shows code + full name + Arşivlenmiş badge, 'Bu hastaya yeni iş' shortcut into /jobs/new, history table with date + type + member count + status badge + due badge + a 'Aç' button per row. - Patient list rows now link both the protocol code and the name cells to /patients/[$id] so clinics can click straight in. The edit/archive controls stay on the row trailing edge as before. --- .../(dashboard)/patients/[patientId]/page.tsx | 168 ++++++++++++++++++ .../patients/components/patients-table.tsx | 15 +- src/lib/appwrite/patient-queries.ts | 46 ++++- 3 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 src/app/(dashboard)/patients/[patientId]/page.tsx diff --git a/src/app/(dashboard)/patients/[patientId]/page.tsx b/src/app/(dashboard)/patients/[patientId]/page.tsx new file mode 100644 index 0000000..7faae1c --- /dev/null +++ b/src/app/(dashboard)/patients/[patientId]/page.tsx @@ -0,0 +1,168 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { ArrowLeft, Plus } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { DueBadge } from "@/components/due-badge"; +import { + JOB_STATUS_LABELS, + PROSTHETIC_TYPE_LABELS, +} from "@/lib/appwrite/job-types"; +import { getPatient, listPatientJobs } from "@/lib/appwrite/patient-queries"; +import type { JobStatus } from "@/lib/appwrite/schema"; +import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard"; + +export const metadata = { + title: "DLS — Hasta", +}; + +const dateFormatter = new Intl.DateTimeFormat("tr-TR", { + day: "2-digit", + month: "2-digit", + year: "numeric", +}); + +function statusVariant(s: JobStatus): "default" | "secondary" | "outline" | "destructive" { + if (s === "delivered") return "default"; + if (s === "sent" || s === "in_progress") return "secondary"; + if (s === "cancelled") return "destructive"; + return "outline"; +} + +export default async function PatientDetailPage({ + params, +}: { + params: Promise<{ patientId: string }>; +}) { + const { patientId } = await params; + + let ctx; + try { + ctx = await requireTenant(); + requireTenantKind(ctx, ["clinic"]); + } catch { + redirect("/dashboard"); + } + + const patient = await getPatient(patientId, ctx.tenantId); + if (!patient) notFound(); + + const jobs = await listPatientJobs(patient.$id, patient.patientCode, ctx.tenantId); + + const fullName = + [patient.firstName, patient.lastName].filter(Boolean).join(" ") || + `Hasta ${patient.patientCode}`; + + return ( +
+
+
+

+ {patient.patientCode} +

+

{fullName}

+ {patient.archived && ( + + Arşivlenmiş + + )} +
+ +
+ + {patient.notes && ( + + + Notlar + + +

+ {patient.notes} +

+
+
+ )} + + + + İş Geçmişi + + {jobs.length === 0 + ? "Bu hastaya ait iş kaydı yok." + : `${jobs.length} iş`} + + + + {jobs.length === 0 ? ( +

+ Henüz bu hasta için iş gönderilmemiş. +

+ ) : ( + + + + Tarih + Tür + Üye + Durum + Termin + Detay + + + + {jobs.map((j) => ( + + + {dateFormatter.format(new Date(j.$createdAt))} + + + {PROSTHETIC_TYPE_LABELS[j.prostheticType] ?? j.prostheticType} + + {j.memberCount} + + + {JOB_STATUS_LABELS[j.status]} + + + + + + + + + + ))} + +
+ )} +
+
+ +
+ +
+
+ ); +} diff --git a/src/app/(dashboard)/patients/components/patients-table.tsx b/src/app/(dashboard)/patients/components/patients-table.tsx index 19ee04e..ba0c419 100644 --- a/src/app/(dashboard)/patients/components/patients-table.tsx +++ b/src/app/(dashboard)/patients/components/patients-table.tsx @@ -1,6 +1,7 @@ "use client"; import { useActionState, useEffect, useState, useTransition } from "react"; +import Link from "next/link"; import { Archive, ArchiveRestore, Loader2, Pencil } from "lucide-react"; import { toast } from "sonner"; @@ -83,11 +84,17 @@ function PatientRow({ row }: { row: Patient }) { return ( - {row.patientCode} + + + {row.patientCode} + + - {[row.firstName, row.lastName].filter(Boolean).join(" ") || ( - - )} + + {[row.firstName, row.lastName].filter(Boolean).join(" ") || ( + + )} + {row.notes || "—"} diff --git a/src/lib/appwrite/patient-queries.ts b/src/lib/appwrite/patient-queries.ts index 7d33450..e9e41dc 100644 --- a/src/lib/appwrite/patient-queries.ts +++ b/src/lib/appwrite/patient-queries.ts @@ -2,7 +2,7 @@ import "server-only"; import { Query } from "node-appwrite"; -import { DATABASE_ID, TABLES, type Patient } from "./schema"; +import { DATABASE_ID, TABLES, type Job, type Patient } from "./schema"; import { createAdminClient } from "./server"; import { toPlain } from "./serialize"; @@ -44,3 +44,47 @@ export async function getPatient( return null; } } + +/** + * Every job linked to this patient — by explicit patientId on newer jobs, + * or by matching patientCode on legacy rows that pre-date the relation + * (we still want to surface that history). + */ +export async function listPatientJobs( + patientId: string, + patientCode: string, + clinicTenantId: string, +): Promise { + const { tablesDB } = createAdminClient(); + const [byId, byCode] = await Promise.all([ + tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.jobs, + queries: [ + Query.equal("clinicTenantId", clinicTenantId), + Query.equal("patientId", patientId), + Query.orderDesc("$createdAt"), + Query.limit(200), + ], + }), + tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.jobs, + queries: [ + Query.equal("clinicTenantId", clinicTenantId), + Query.equal("patientCode", patientCode), + Query.orderDesc("$createdAt"), + Query.limit(200), + ], + }), + ]); + const seen = new Set(); + const merged: Job[] = []; + for (const row of [...byId.rows, ...byCode.rows] as unknown as Job[]) { + if (seen.has(row.$id)) continue; + seen.add(row.$id); + merged.push(row); + } + merged.sort((a, b) => (a.$createdAt < b.$createdAt ? 1 : -1)); + return toPlain(merged); +}