From ee9c0015a5cac6869859c9573657102b55dd2131 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 21 May 2026 21:54:35 +0300 Subject: [PATCH] feat(patients): clinic-side patient registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clinics get a real patient ledger. Labs see only patientCode — no name, phone, date of birth, or notes ever cross the team boundary. Data model - New table 'patients' (clinicTenantId, patientCode, firstName, lastName, phone?, dateOfBirth?, notes?, archived). Unique index on (clinicTenantId, patientCode) so each clinic gets its own code space. Fulltext index on (firstName, lastName) for future patient search. Row permissions Role.team(clinicTenantId) only — labs literally cannot read the rows. - jobs.patientId attribute (optional) + key index, references the patient row when one exists. patientCode stays denormalised on jobs so labs keep a stable identifier without joining patients. Server - createPatientAction: clinic-only, requireTenantKind guard. Protocol no is optional; if absent we generate a 6-char unique code (re-roll on collision, 8 attempts). Duplicate protocol no within a clinic is rejected with a friendly error. - updatePatientAction: edits name/phone/dob/notes. patientCode is explicitly NOT mutable — re-keying historical jobs would be confusing. - archivePatientAction: toggle, preserves history. - listPatients / getPatient queries return plain objects via toPlain. UI - /patients page (clinic-only, sidebar nav 'Hastalar', middleware protected): table + add form + edit dialog + archive. - /jobs/new: patient Select replaces the bare patientCode input. Picking a patient locks the patientCode field to that patient's code; falling back to 'Hasta listesinde yok — kodu manuel gir' keeps the old free- text flow. - createJobAction validates patientId ownership and overwrites patientCode with the patient's code on the server, so a manipulated form can't desync the two. - /jobs/[jobId] (clinic side only): adds a 'Hasta Bilgileri' card with name/phone/dob/notes and uses the patient's full name as the page title. Lab side is unchanged — code only. The protocol-no / generated-code split matches what the user asked for: existing patient management software's protocol number flows in directly, otherwise the system mints one. --- src/app/(dashboard)/jobs/[jobId]/page.tsx | 45 ++- .../jobs/new/components/new-job-form.tsx | 68 ++++- src/app/(dashboard)/jobs/new/page.tsx | 16 +- .../patients/components/patient-form.tsx | 109 ++++++++ .../patients/components/patients-table.tsx | 237 ++++++++++++++++ src/app/(dashboard)/patients/page.tsx | 60 ++++ src/components/app-sidebar.tsx | 3 + src/lib/appwrite/job-actions.ts | 36 ++- src/lib/appwrite/patient-actions.ts | 259 ++++++++++++++++++ src/lib/appwrite/patient-queries.ts | 46 ++++ src/lib/appwrite/patient-types.ts | 15 + src/lib/appwrite/schema.ts | 14 + src/lib/validation/job.ts | 5 + src/lib/validation/patient.ts | 31 +++ src/middleware.ts | 1 + 15 files changed, 937 insertions(+), 8 deletions(-) create mode 100644 src/app/(dashboard)/patients/components/patient-form.tsx create mode 100644 src/app/(dashboard)/patients/components/patients-table.tsx create mode 100644 src/app/(dashboard)/patients/page.tsx create mode 100644 src/lib/appwrite/patient-actions.ts create mode 100644 src/lib/appwrite/patient-queries.ts create mode 100644 src/lib/appwrite/patient-types.ts create mode 100644 src/lib/validation/patient.ts diff --git a/src/app/(dashboard)/jobs/[jobId]/page.tsx b/src/app/(dashboard)/jobs/[jobId]/page.tsx index 5eab726..26144b5 100644 --- a/src/app/(dashboard)/jobs/[jobId]/page.tsx +++ b/src/app/(dashboard)/jobs/[jobId]/page.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { listJobFiles } from "@/lib/appwrite/job-file-queries"; import { listJobHistory } from "@/lib/appwrite/job-history-queries"; +import { getPatient } from "@/lib/appwrite/patient-queries"; import { toPlain } from "@/lib/appwrite/serialize"; import { JOB_STATUS_LABELS, @@ -85,6 +86,12 @@ export default async function JobDetailPage({ const currentStepIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1; const side = job.clinicTenantId === ctx.tenantId ? "clinic" : "lab"; + // Patient record only resolves on the clinic side — labs see the code only. + const patient = + side === "clinic" && job.patientId + ? await getPatient(job.patientId, ctx.tenantId) + : null; + return (
@@ -93,9 +100,14 @@ export default async function JobDetailPage({ {counterpartLabel}: {counterpart?.companyName ?? "—"}

- Hasta {job.patientCode} + {patient ? `${patient.firstName} ${patient.lastName}` : `Hasta ${job.patientCode}`}

+ {patient && ( + <> + {job.patientCode} ·{" "} + + )} {PROSTHETIC_TYPE_LABELS[job.prostheticType]} · {job.memberCount} üye

@@ -180,6 +192,37 @@ export default async function JobDetailPage({
+ {patient && ( + + + Hasta Bilgileri + + Bu alan yalnızca kliniğinize görünür — laboratuvar hasta kodu + dışında bir veri görmez. + + + + + {patient.firstName} {patient.lastName} + + {patient.phone || "—"} + + {patient.dateOfBirth + ? dateFormatter.format(new Date(patient.dateOfBirth)) + : "—"} + + {patient.notes && ( +
+

+ Notlar +

+

{patient.notes}

+
+ )} +
+
+ )} + Taranan Dosyalar ve Görseller diff --git a/src/app/(dashboard)/jobs/new/components/new-job-form.tsx b/src/app/(dashboard)/jobs/new/components/new-job-form.tsx index 0062bc4..4890f48 100644 --- a/src/app/(dashboard)/jobs/new/components/new-job-form.tsx +++ b/src/app/(dashboard)/jobs/new/components/new-job-form.tsx @@ -1,6 +1,7 @@ "use client"; -import { useActionState, useEffect } from "react"; +import { useActionState, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { Loader2, Send } from "lucide-react"; import { toast } from "sonner"; @@ -24,6 +25,8 @@ import { import type { JobCounterpart } from "@/lib/appwrite/job-queries"; import type { ProstheticType } from "@/lib/appwrite/schema"; +const NONE_PATIENT = "__none__"; + const PROSTHETIC_TYPES: ProstheticType[] = [ "metal_porselen", "zirkonyum", @@ -33,15 +36,28 @@ const PROSTHETIC_TYPES: ProstheticType[] = [ "diger", ]; +type PatientOption = { id: string; code: string; label: string }; + export function NewJobForm({ labs, + patients, defaultCurrency, }: { labs: JobCounterpart[]; + patients: PatientOption[]; defaultCurrency: string; }) { const router = useRouter(); const [state, action, pending] = useActionState(createJobAction, initialJobFormState); + const [patientId, setPatientId] = useState( + patients.length > 0 ? patients[0].id : NONE_PATIENT, + ); + + const patientById = useMemo( + () => new Map(patients.map((p) => [p.id, p])), + [patients], + ); + const selectedPatient = patientId !== NONE_PATIENT ? patientById.get(patientId) : undefined; useEffect(() => { if (state.ok) { @@ -74,14 +90,62 @@ export function NewJobForm({ )} +
+
+ + + + Yeni hasta ekle + +
+ + + {state.fieldErrors?.patientId && ( +

{state.fieldErrors.patientId}

+ )} +
+
- + {state.fieldErrors?.patientCode && (

{state.fieldErrors.patientCode}

diff --git a/src/app/(dashboard)/jobs/new/page.tsx b/src/app/(dashboard)/jobs/new/page.tsx index f5a670d..dc47a76 100644 --- a/src/app/(dashboard)/jobs/new/page.tsx +++ b/src/app/(dashboard)/jobs/new/page.tsx @@ -4,6 +4,7 @@ import { redirect } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { listApprovedLabsForClinic } from "@/lib/appwrite/job-queries"; +import { listPatients } from "@/lib/appwrite/patient-queries"; import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard"; import { NewJobForm } from "./components/new-job-form"; @@ -20,7 +21,10 @@ export default async function NewJobPage() { redirect("/dashboard"); } - const labs = await listApprovedLabsForClinic(ctx.tenantId); + const [labs, patients] = await Promise.all([ + listApprovedLabsForClinic(ctx.tenantId), + listPatients(ctx.tenantId, { includeArchived: false }), + ]); const defaultCurrency = ctx.settings?.defaultCurrency ?? "TRY"; return ( @@ -55,7 +59,15 @@ export default async function NewJobPage() { - + ({ + id: p.$id, + code: p.patientCode, + label: `${p.firstName} ${p.lastName}`, + }))} + defaultCurrency={defaultCurrency} + /> )} diff --git a/src/app/(dashboard)/patients/components/patient-form.tsx b/src/app/(dashboard)/patients/components/patient-form.tsx new file mode 100644 index 0000000..79ea77c --- /dev/null +++ b/src/app/(dashboard)/patients/components/patient-form.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useActionState, useEffect, useRef } from "react"; +import { Loader2, UserPlus } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { createPatientAction } from "@/lib/appwrite/patient-actions"; +import { initialPatientFormState } from "@/lib/appwrite/patient-types"; + +export function PatientForm() { + const [state, action, pending] = useActionState( + createPatientAction, + initialPatientFormState, + ); + const formRef = useRef(null); + + useEffect(() => { + if (state.ok) { + toast.success("Hasta eklendi."); + formRef.current?.reset(); + } else if (state.error) { + toast.error(state.error); + } + }, [state]); + + return ( +
+
+ + + {state.fieldErrors?.patientCode && ( +

{state.fieldErrors.patientCode}

+ )} +
+ +
+
+ + + {state.fieldErrors?.firstName && ( +

{state.fieldErrors.firstName}

+ )} +
+
+ + + {state.fieldErrors?.lastName && ( +

{state.fieldErrors.lastName}

+ )} +
+
+ +
+ + +
+ +
+ + +
+ +
+ +