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:
kovakmedya
2026-05-21 21:54:35 +03:00
parent 5fbc0a3c95
commit ee9c0015a5
15 changed files with 937 additions and 8 deletions
+44 -1
View File
@@ -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 (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-wrap items-start justify-between gap-3">
@@ -93,9 +100,14 @@ export default async function JobDetailPage({
{counterpartLabel}: {counterpart?.companyName ?? "—"}
</p>
<h1 className="text-2xl font-bold tracking-tight">
Hasta {job.patientCode}
{patient ? `${patient.firstName} ${patient.lastName}` : `Hasta ${job.patientCode}`}
</h1>
<p className="text-muted-foreground text-sm">
{patient && (
<>
<span className="font-mono">{job.patientCode}</span> ·{" "}
</>
)}
{PROSTHETIC_TYPE_LABELS[job.prostheticType]} · {job.memberCount} üye
</p>
</div>
@@ -180,6 +192,37 @@ export default async function JobDetailPage({
</Card>
</div>
{patient && (
<Card>
<CardHeader>
<CardTitle>Hasta Bilgileri</CardTitle>
<CardDescription>
Bu alan yalnızca kliniğinize görünür laboratuvar hasta kodu
dışında bir veri görmez.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 text-sm md:grid-cols-3">
<Info label="Ad Soyad">
{patient.firstName} {patient.lastName}
</Info>
<Info label="Telefon">{patient.phone || "—"}</Info>
<Info label="Doğum Tarihi">
{patient.dateOfBirth
? dateFormatter.format(new Date(patient.dateOfBirth))
: "—"}
</Info>
{patient.notes && (
<div className="md:col-span-3">
<p className="text-muted-foreground mb-1 text-xs font-medium uppercase tracking-wide">
Notlar
</p>
<p className="whitespace-pre-wrap text-sm">{patient.notes}</p>
</div>
)}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Taranan Dosyalar ve Görseller</CardTitle>
@@ -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>
+14 -2
View File
@@ -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>
)}
@@ -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<HTMLFormElement>(null);
useEffect(() => {
if (state.ok) {
toast.success("Hasta eklendi.");
formRef.current?.reset();
} else if (state.error) {
toast.error(state.error);
}
}, [state]);
return (
<form ref={formRef} action={action} className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="patientCode">
Protokol No <span className="text-muted-foreground text-xs">(opsiyonel)</span>
</Label>
<Input
id="patientCode"
name="patientCode"
maxLength={50}
placeholder="Boş bırakırsanız sistem üretir"
style={{ textTransform: "uppercase" }}
className="font-mono"
/>
{state.fieldErrors?.patientCode && (
<p className="text-destructive text-xs">{state.fieldErrors.patientCode}</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label htmlFor="firstName">Ad *</Label>
<Input id="firstName" name="firstName" required maxLength={100} />
{state.fieldErrors?.firstName && (
<p className="text-destructive text-xs">{state.fieldErrors.firstName}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="lastName">Soyad *</Label>
<Input id="lastName" name="lastName" required maxLength={100} />
{state.fieldErrors?.lastName && (
<p className="text-destructive text-xs">{state.fieldErrors.lastName}</p>
)}
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="phone">Telefon</Label>
<Input
id="phone"
name="phone"
type="tel"
maxLength={30}
placeholder="+90 555 123 45 67"
autoComplete="tel"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dateOfBirth">Doğum Tarihi</Label>
<Input id="dateOfBirth" name="dateOfBirth" type="date" />
</div>
<div className="grid gap-2">
<Label htmlFor="notes">Notlar</Label>
<Textarea
id="notes"
name="notes"
rows={3}
maxLength={2000}
placeholder="Alerji, anamnez, vs."
/>
</div>
<Button type="submit" disabled={pending}>
{pending ? (
<>
<Loader2 className="size-4 animate-spin" />
Ekleniyor...
</>
) : (
<>
<UserPlus className="size-4" />
Hasta ekle
</>
)}
</Button>
</form>
);
}
@@ -0,0 +1,237 @@
"use client";
import { useActionState, useEffect, useState, useTransition } from "react";
import { Archive, ArchiveRestore, Loader2, Pencil } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import {
archivePatientAction,
updatePatientAction,
} from "@/lib/appwrite/patient-actions";
import {
initialPatientActionState,
initialPatientFormState,
} from "@/lib/appwrite/patient-types";
import type { Patient } from "@/lib/appwrite/schema";
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
export function PatientsTable({ rows }: { rows: Patient[] }) {
if (rows.length === 0) {
return (
<p className="text-muted-foreground py-6 text-center text-sm">
Henüz hasta yok. Sağdaki formdan ekleyebilirsiniz.
</p>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Kod</TableHead>
<TableHead>Ad Soyad</TableHead>
<TableHead>Telefon</TableHead>
<TableHead>Doğum</TableHead>
<TableHead>Durum</TableHead>
<TableHead className="text-right">İşlem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((p) => (
<PatientRow key={p.$id} row={p} />
))}
</TableBody>
</Table>
);
}
function PatientRow({ row }: { row: Patient }) {
const [archiveState, archiveAction, archivePending] = useActionState(
archivePatientAction,
initialPatientActionState,
);
const [, startTransition] = useTransition();
const [editOpen, setEditOpen] = useState(false);
useEffect(() => {
if (archiveState.ok) {
toast.success(row.archived ? "Hasta aktifleştirildi." : "Hasta arşivlendi.");
} else if (archiveState.error) {
toast.error(archiveState.error);
}
}, [archiveState, row.archived]);
return (
<TableRow className={row.archived ? "opacity-60" : ""}>
<TableCell className="font-mono text-xs">{row.patientCode}</TableCell>
<TableCell className="font-medium">
{row.firstName} {row.lastName}
</TableCell>
<TableCell className="text-muted-foreground">{row.phone || "—"}</TableCell>
<TableCell className="text-muted-foreground">
{row.dateOfBirth ? dateFormatter.format(new Date(row.dateOfBirth)) : "—"}
</TableCell>
<TableCell>
{row.archived ? (
<Badge variant="outline">Arşiv</Badge>
) : (
<Badge variant="secondary">Aktif</Badge>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1.5">
<Button size="sm" variant="outline" onClick={() => setEditOpen(true)}>
<Pencil className="size-4" />
</Button>
<form
action={(fd) => {
startTransition(() => archiveAction(fd));
}}
>
<input type="hidden" name="id" value={row.$id} />
<Button type="submit" size="sm" variant="outline" disabled={archivePending}>
{archivePending ? (
<Loader2 className="size-4 animate-spin" />
) : row.archived ? (
<ArchiveRestore className="size-4" />
) : (
<Archive className="size-4" />
)}
</Button>
</form>
</div>
<EditPatientDialog row={row} open={editOpen} onOpenChange={setEditOpen} />
</TableCell>
</TableRow>
);
}
function EditPatientDialog({
row,
open,
onOpenChange,
}: {
row: Patient;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [state, action, pending] = useActionState(
updatePatientAction,
initialPatientFormState,
);
useEffect(() => {
if (state.ok) {
toast.success("Hasta güncellendi.");
onOpenChange(false);
} else if (state.error) {
toast.error(state.error);
}
}, [state, onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Hasta Bilgileri</DialogTitle>
<DialogDescription>
Protokol kodu ({row.patientCode}) değiştirilemez geçmiş işlerle bağı kopar.
</DialogDescription>
</DialogHeader>
<form action={action} className="grid gap-4">
<input type="hidden" name="id" value={row.$id} />
<input type="hidden" name="patientCode" value={row.patientCode} />
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label htmlFor={`first-${row.$id}`}>Ad *</Label>
<Input
id={`first-${row.$id}`}
name="firstName"
defaultValue={row.firstName}
required
maxLength={100}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`last-${row.$id}`}>Soyad *</Label>
<Input
id={`last-${row.$id}`}
name="lastName"
defaultValue={row.lastName}
required
maxLength={100}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor={`phone-${row.$id}`}>Telefon</Label>
<Input
id={`phone-${row.$id}`}
name="phone"
type="tel"
defaultValue={row.phone ?? ""}
maxLength={30}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`dob-${row.$id}`}>Doğum Tarihi</Label>
<Input
id={`dob-${row.$id}`}
name="dateOfBirth"
type="date"
defaultValue={row.dateOfBirth ? row.dateOfBirth.slice(0, 10) : ""}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`notes-${row.$id}`}>Notlar</Label>
<Textarea
id={`notes-${row.$id}`}
name="notes"
rows={3}
defaultValue={row.notes ?? ""}
maxLength={2000}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">
Vazgeç
</Button>
</DialogClose>
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Pencil className="size-4" />}
Kaydet
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { listPatients } from "@/lib/appwrite/patient-queries";
import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard";
import { PatientForm } from "./components/patient-form";
import { PatientsTable } from "./components/patients-table";
export const metadata = {
title: "DLS — Hastalar",
};
export default async function PatientsPage() {
let ctx;
try {
ctx = await requireTenant();
requireTenantKind(ctx, ["clinic"]);
} catch {
redirect("/dashboard");
}
const rows = await listPatients(ctx.tenantId, { includeArchived: true });
return (
<div className="flex-1 space-y-6 px-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">Hastalar</h1>
<p className="text-muted-foreground text-sm">
Kliniğinizin hasta defteri. Laboratuvar tarafında yalnızca hasta kodunu görür kişisel veriler sizinle kalır.
</p>
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
<Card>
<CardHeader>
<CardTitle>Hasta Listesi</CardTitle>
<CardDescription>
{rows.length === 0 ? "Henüz hasta yok." : `${rows.length} kayıt`}
</CardDescription>
</CardHeader>
<CardContent>
<PatientsTable rows={rows} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Hasta Ekle</CardTitle>
<CardDescription>
Protokol numarası varsa girin, yoksa sistem 6 haneli benzersiz kod üretir.
</CardDescription>
</CardHeader>
<CardContent>
<PatientForm />
</CardContent>
</Card>
</div>
</div>
);
}
+3
View File
@@ -8,6 +8,7 @@ import {
Package,
Send,
Settings,
Users,
Wallet,
} from "lucide-react";
import Link from "next/link";
@@ -48,6 +49,8 @@ function buildNavGroups(kind: ShellCompany["kind"]): NavGroup[] {
if (isLab) {
operationsItems.push({ title: "Ürünler", url: "/products", icon: Package });
} else {
operationsItems.push({ title: "Hastalar", url: "/patients", icon: Users });
}
return [
+33 -3
View File
@@ -13,6 +13,7 @@ import {
type Connection,
type Job,
type JobStep,
type Patient,
} from "./schema";
import { createAdminClient } from "./server";
import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard";
@@ -39,6 +40,7 @@ function flattenErrors(err: z.ZodError): Record<string, string> {
function pickFields(formData: FormData) {
return {
labTenantId: String(formData.get("labTenantId") ?? "").trim(),
patientId: String(formData.get("patientId") ?? "").trim(),
patientCode: String(formData.get("patientCode") ?? "").trim(),
prostheticType: String(formData.get("prostheticType") ?? "").trim(),
memberCount: String(formData.get("memberCount") ?? ""),
@@ -89,6 +91,33 @@ export async function createJobAction(
const { tablesDB } = createAdminClient();
// If a patientId is supplied, verify it belongs to this clinic and inherit
// its patientCode so the lab side always sees a stable identifier.
let patientCode = parsed.data.patientCode;
if (parsed.data.patientId) {
try {
const patientRow = (await tablesDB.getRow(
DATABASE_ID,
TABLES.patients,
parsed.data.patientId,
)) as unknown as Patient;
if (patientRow.clinicTenantId !== ctx.tenantId) {
return {
ok: false,
error: "Bu hasta size ait değil.",
fieldErrors: { patientId: "Yetki yok." },
};
}
patientCode = patientRow.patientCode;
} catch {
return {
ok: false,
error: "Hasta bulunamadı.",
fieldErrors: { patientId: "Hasta kaydı yok." },
};
}
}
// Verify the chosen lab is an approved connection of this clinic
const connRes = await tablesDB.listRows({
databaseId: DATABASE_ID,
@@ -118,7 +147,8 @@ export async function createJobAction(
clinicTenantId: ctx.tenantId,
labTenantId: parsed.data.labTenantId,
createdBy: ctx.user.id,
patientCode: parsed.data.patientCode,
patientId: parsed.data.patientId,
patientCode,
prostheticType: parsed.data.prostheticType,
memberCount: parsed.data.memberCount,
color: parsed.data.color,
@@ -136,12 +166,12 @@ export async function createJobAction(
action: "create",
entityType: "job",
entityId: created.$id,
changes: { labTenantId: parsed.data.labTenantId, patientCode: parsed.data.patientCode },
changes: { labTenantId: parsed.data.labTenantId, patientCode },
});
await createNotification({
tenantId: parsed.data.labTenantId,
jobId: created.$id,
message: `${ctx.settings?.companyName ?? "Bir klinik"} yeni iş yayınladı (${parsed.data.patientCode}).`,
message: `${ctx.settings?.companyName ?? "Bir klinik"} yeni iş yayınladı (${patientCode}).`,
});
revalidatePath("/jobs/outbound");
revalidatePath("/dashboard");
+259
View File
@@ -0,0 +1,259 @@
"use server";
import { revalidatePath } from "next/cache";
import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite";
import { z } from "zod";
import { logAudit } from "./audit";
import { DATABASE_ID, TABLES, type Patient } from "./schema";
import { createAdminClient } from "./server";
import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard";
import type {
PatientActionState,
PatientFormState,
} from "./patient-types";
import { patientSchema } from "@/lib/validation/patient";
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string {
if (e instanceof AppwriteException) return e.message || fallback;
return process.env.NODE_ENV !== "production" && e instanceof Error
? `${fallback} (${e.message})`
: fallback;
}
function flattenErrors(err: z.ZodError): Record<string, string> {
const out: Record<string, string> = {};
for (const issue of err.issues) {
const key = issue.path.join(".");
if (key && !out[key]) out[key] = issue.message;
}
return out;
}
function pickFields(formData: FormData) {
return {
patientCode: String(formData.get("patientCode") ?? "").trim(),
firstName: String(formData.get("firstName") ?? "").trim(),
lastName: String(formData.get("lastName") ?? "").trim(),
phone: String(formData.get("phone") ?? "").trim(),
dateOfBirth: String(formData.get("dateOfBirth") ?? "").trim(),
notes: String(formData.get("notes") ?? "").trim(),
};
}
function patientPermissions(clinicTenantId: string): string[] {
return [
Permission.read(Role.team(clinicTenantId)),
Permission.update(Role.team(clinicTenantId, "owner")),
Permission.update(Role.team(clinicTenantId, "admin")),
Permission.update(Role.team(clinicTenantId, "member")),
Permission.delete(Role.team(clinicTenantId, "owner")),
Permission.delete(Role.team(clinicTenantId, "admin")),
];
}
function generateCode(): string {
let out = "";
for (let i = 0; i < 6; i++) {
out += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)];
}
return out;
}
async function reserveUniqueCode(clinicTenantId: string): Promise<string> {
const { tablesDB } = createAdminClient();
for (let attempt = 0; attempt < 8; attempt++) {
const candidate = generateCode();
const existing = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.patients,
queries: [
Query.equal("clinicTenantId", clinicTenantId),
Query.equal("patientCode", candidate),
Query.limit(1),
],
});
if (existing.total === 0) return candidate;
}
throw new Error("PATIENT_CODE_GENERATION_FAILED");
}
export async function createPatientAction(
_prev: PatientFormState,
formData: FormData,
): Promise<PatientFormState> {
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin", "member"]);
requireTenantKind(ctx, ["clinic"]);
} catch {
return { ok: false, error: "Hasta kaydı yalnızca klinik hesapları için." };
}
const parsed = patientSchema.safeParse(pickFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
const { tablesDB } = createAdminClient();
// If user supplied a code, make sure it isn't already in use within this clinic.
let code = parsed.data.patientCode;
if (code) {
const dup = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.patients,
queries: [
Query.equal("clinicTenantId", ctx.tenantId),
Query.equal("patientCode", code),
Query.limit(1),
],
});
if (dup.total > 0) {
return {
ok: false,
error: "Bu protokol no kayıtlı.",
fieldErrors: { patientCode: "Bu kod başka bir hasta için kullanılmış." },
};
}
} else {
code = await reserveUniqueCode(ctx.tenantId);
}
try {
const created = await tablesDB.createRow(
DATABASE_ID,
TABLES.patients,
ID.unique(),
{
clinicTenantId: ctx.tenantId,
createdBy: ctx.user.id,
patientCode: code,
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
phone: parsed.data.phone,
dateOfBirth: parsed.data.dateOfBirth,
notes: parsed.data.notes,
archived: false,
},
patientPermissions(ctx.tenantId),
);
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "create",
entityType: "patient",
entityId: created.$id,
changes: { patientCode: code, firstName: parsed.data.firstName },
});
revalidatePath("/patients");
return { ok: true, patientId: created.$id };
} catch (e) {
return { ok: false, error: appwriteError(e, "Hasta eklenemedi.") };
}
}
export async function updatePatientAction(
_prev: PatientFormState,
formData: FormData,
): Promise<PatientFormState> {
const id = String(formData.get("id") ?? "").trim();
if (!id) return { ok: false, error: "Hasta bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin", "member"]);
requireTenantKind(ctx, ["clinic"]);
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
const parsed = patientSchema.safeParse(pickFields(formData));
if (!parsed.success) {
return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) };
}
try {
const { tablesDB } = createAdminClient();
const row = (await tablesDB.getRow(
DATABASE_ID,
TABLES.patients,
id,
)) as unknown as Patient;
if (row.clinicTenantId !== ctx.tenantId) {
return { ok: false, error: "Bu hastayı düzenleme yetkiniz yok." };
}
// patientCode is intentionally NOT updatable here — re-keying historical
// jobs would be confusing. Only firstName/lastName/phone/dateOfBirth/notes.
await tablesDB.updateRow(DATABASE_ID, TABLES.patients, id, {
firstName: parsed.data.firstName,
lastName: parsed.data.lastName,
phone: parsed.data.phone,
dateOfBirth: parsed.data.dateOfBirth,
notes: parsed.data.notes,
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "patient",
entityId: id,
changes: parsed.data,
});
} catch (e) {
return { ok: false, error: appwriteError(e, "Güncellenemedi.") };
}
revalidatePath("/patients");
return { ok: true, patientId: id };
}
export async function archivePatientAction(
_prev: PatientActionState,
formData: FormData,
): Promise<PatientActionState> {
const id = String(formData.get("id") ?? "").trim();
if (!id) return { ok: false, error: "Hasta bulunamadı." };
let ctx;
try {
ctx = await requireTenant();
requireRole(ctx, ["owner", "admin"]);
requireTenantKind(ctx, ["clinic"]);
} catch {
return { ok: false, error: "Yetkiniz yok." };
}
try {
const { tablesDB } = createAdminClient();
const row = (await tablesDB.getRow(
DATABASE_ID,
TABLES.patients,
id,
)) as unknown as Patient;
if (row.clinicTenantId !== ctx.tenantId) {
return { ok: false, error: "Yetkiniz yok." };
}
await tablesDB.updateRow(DATABASE_ID, TABLES.patients, id, {
archived: !row.archived,
});
await logAudit({
tenantId: ctx.tenantId,
userId: ctx.user.id,
action: "update",
entityType: "patient",
entityId: id,
changes: { archived: !row.archived },
});
} catch (e) {
return { ok: false, error: appwriteError(e, "İşlem başarısız.") };
}
revalidatePath("/patients");
return { ok: true };
}
+46
View File
@@ -0,0 +1,46 @@
import "server-only";
import { Query } from "node-appwrite";
import { DATABASE_ID, TABLES, type Patient } from "./schema";
import { createAdminClient } from "./server";
import { toPlain } from "./serialize";
export async function listPatients(
clinicTenantId: string,
options: { includeArchived?: boolean } = {},
): Promise<Patient[]> {
const { tablesDB } = createAdminClient();
const queries = [
Query.equal("clinicTenantId", clinicTenantId),
Query.orderDesc("$createdAt"),
Query.limit(500),
];
if (!options.includeArchived) {
queries.push(Query.notEqual("archived", true));
}
const result = await tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.patients,
queries,
});
return toPlain(result.rows as unknown as Patient[]);
}
export async function getPatient(
patientId: string,
clinicTenantId: string,
): Promise<Patient | null> {
try {
const { tablesDB } = createAdminClient();
const row = (await tablesDB.getRow(
DATABASE_ID,
TABLES.patients,
patientId,
)) as unknown as Patient;
if (row.clinicTenantId !== clinicTenantId) return null;
return toPlain(row);
} catch {
return null;
}
}
+15
View File
@@ -0,0 +1,15 @@
export type PatientFormState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
patientId?: string;
};
export const initialPatientFormState: PatientFormState = { ok: false };
export type PatientActionState = {
ok: boolean;
error?: string;
};
export const initialPatientActionState: PatientActionState = { ok: false };
+14
View File
@@ -9,6 +9,7 @@ export const TABLES = {
tenantSettings: "tenant_settings",
profiles: "profiles",
connections: "connections",
patients: "patients",
jobs: "jobs",
jobFiles: "job_files",
jobStatusHistory: "job_status_history",
@@ -81,11 +82,24 @@ export type ProstheticType =
| "e_max"
| "diger";
export interface Patient extends Row {
clinicTenantId: string;
createdBy: string;
patientCode: string;
firstName: string;
lastName: string;
phone?: string;
dateOfBirth?: string;
notes?: string;
archived?: boolean;
}
export interface Job extends Row {
clinicTenantId: string;
labTenantId: string;
createdBy: string;
patientCode: string;
patientId?: string;
prostheticType: ProstheticType;
memberCount: number;
color?: string;
+5
View File
@@ -11,6 +11,11 @@ const PROSTHETIC_TYPES = [
export const createJobSchema = z.object({
labTenantId: z.string().min(1, "Laboratuvar seçin."),
patientId: z
.string()
.trim()
.optional()
.transform((v) => (v ? v : undefined)),
patientCode: z.string().trim().min(1, "Hasta kodu zorunlu.").max(50),
prostheticType: z.enum(PROSTHETIC_TYPES, { message: "Protez türü seçin." }),
memberCount: z
+31
View File
@@ -0,0 +1,31 @@
import { z } from "zod";
export const patientSchema = z.object({
patientCode: z
.string()
.trim()
.max(50)
.optional()
.transform((v) => (v ? v.toUpperCase() : undefined)),
firstName: z.string().trim().min(1, "Ad zorunlu.").max(100),
lastName: z.string().trim().min(1, "Soyad zorunlu.").max(100),
phone: z
.string()
.trim()
.max(30)
.optional()
.transform((v) => (v ? v : undefined)),
dateOfBirth: z
.string()
.trim()
.optional()
.transform((v) => (v ? new Date(v).toISOString() : undefined)),
notes: z
.string()
.trim()
.max(2000)
.optional()
.transform((v) => (v ? v : undefined)),
});
export type PatientInput = z.infer<typeof patientSchema>;
+1
View File
@@ -22,6 +22,7 @@ const PROTECTED_PREFIXES = [
"/settings",
"/jobs",
"/products",
"/patients",
"/finance",
"/connections",
"/notifications",