feat(patients): clinic-side patient registry
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.
This commit is contained in:
@@ -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<string>(
|
||||
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({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<Label htmlFor="patientId">Hasta</Label>
|
||||
<Link
|
||||
href="/patients"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline-offset-4 hover:underline"
|
||||
>
|
||||
+ Yeni hasta ekle
|
||||
</Link>
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
name="patientId"
|
||||
value={patientId === NONE_PATIENT ? "" : patientId}
|
||||
/>
|
||||
<Select value={patientId} onValueChange={setPatientId}>
|
||||
<SelectTrigger id="patientId">
|
||||
<SelectValue placeholder="Hasta seçin" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{patients.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.label}{" "}
|
||||
<span className="text-muted-foreground ml-1 font-mono text-xs">
|
||||
{p.code}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={NONE_PATIENT}>
|
||||
Hasta listesinde yok — kodu manuel gir
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{state.fieldErrors?.patientId && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.patientId}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="patientCode">Hasta Kodu *</Label>
|
||||
<Label htmlFor="patientCode">
|
||||
Hasta Kodu *{" "}
|
||||
{selectedPatient && (
|
||||
<span className="text-muted-foreground text-xs">(seçili hastadan)</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id="patientCode"
|
||||
name="patientCode"
|
||||
required
|
||||
maxLength={50}
|
||||
placeholder="Örn. 000892"
|
||||
value={selectedPatient?.code ?? undefined}
|
||||
defaultValue={selectedPatient ? undefined : ""}
|
||||
readOnly={Boolean(selectedPatient)}
|
||||
style={{ textTransform: "uppercase" }}
|
||||
className="font-mono"
|
||||
/>
|
||||
{state.fieldErrors?.patientCode && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.patientCode}</p>
|
||||
|
||||
@@ -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() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<NewJobForm labs={labs} defaultCurrency={defaultCurrency} />
|
||||
<NewJobForm
|
||||
labs={labs}
|
||||
patients={patients.map((p) => ({
|
||||
id: p.$id,
|
||||
code: p.patientCode,
|
||||
label: `${p.firstName} ${p.lastName}`,
|
||||
}))}
|
||||
defaultCurrency={defaultCurrency}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user