+
+
+
+ + 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 (
+
+ );
+}
diff --git a/src/app/(dashboard)/patients/components/patients-table.tsx b/src/app/(dashboard)/patients/components/patients-table.tsx
new file mode 100644
index 0000000..7646d05
--- /dev/null
+++ b/src/app/(dashboard)/patients/components/patients-table.tsx
@@ -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 (
+
+ Henüz hasta yok. Sağdaki formdan ekleyebilirsiniz.
+
+ );
+ }
+
+ return (
+
+
+
+ Kod
+ Ad Soyad
+ Telefon
+ Doğum
+ Durum
+ İşlem
+
+
+
+ {rows.map((p) => (
+
+ ))}
+
+
+ );
+}
+
+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 (
+
+ {row.patientCode}
+
+ {row.firstName} {row.lastName}
+
+ {row.phone || "—"}
+
+ {row.dateOfBirth ? dateFormatter.format(new Date(row.dateOfBirth)) : "—"}
+
+
+ {row.archived ? (
+ Arşiv
+ ) : (
+ Aktif
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+ );
+}
diff --git a/src/app/(dashboard)/patients/page.tsx b/src/app/(dashboard)/patients/page.tsx
new file mode 100644
index 0000000..5d4d90c
--- /dev/null
+++ b/src/app/(dashboard)/patients/page.tsx
@@ -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 (
+
+
+
Hastalar
+
+ Kliniğinizin hasta defteri. Laboratuvar tarafında yalnızca hasta kodunu görür — kişisel veriler sizinle kalır.
+
+
+
+
+
+
+ Hasta Listesi
+
+ {rows.length === 0 ? "Henüz hasta yok." : `${rows.length} kayıt`}
+
+
+
+
+
+
+
+
+
+ Hasta Ekle
+
+ Protokol numarası varsa girin, yoksa sistem 6 haneli benzersiz kod üretir.
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx
index 8efbb7a..b55074a 100644
--- a/src/components/app-sidebar.tsx
+++ b/src/components/app-sidebar.tsx
@@ -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 [
diff --git a/src/lib/appwrite/job-actions.ts b/src/lib/appwrite/job-actions.ts
index d121858..1086190 100644
--- a/src/lib/appwrite/job-actions.ts
+++ b/src/lib/appwrite/job-actions.ts
@@ -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 {
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");
diff --git a/src/lib/appwrite/patient-actions.ts b/src/lib/appwrite/patient-actions.ts
new file mode 100644
index 0000000..a8225e0
--- /dev/null
+++ b/src/lib/appwrite/patient-actions.ts
@@ -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 {
+ const out: Record = {};
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 };
+}
diff --git a/src/lib/appwrite/patient-queries.ts b/src/lib/appwrite/patient-queries.ts
new file mode 100644
index 0000000..7d33450
--- /dev/null
+++ b/src/lib/appwrite/patient-queries.ts
@@ -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 {
+ 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 {
+ 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;
+ }
+}
diff --git a/src/lib/appwrite/patient-types.ts b/src/lib/appwrite/patient-types.ts
new file mode 100644
index 0000000..421fc6e
--- /dev/null
+++ b/src/lib/appwrite/patient-types.ts
@@ -0,0 +1,15 @@
+export type PatientFormState = {
+ ok: boolean;
+ error?: string;
+ fieldErrors?: Record;
+ patientId?: string;
+};
+
+export const initialPatientFormState: PatientFormState = { ok: false };
+
+export type PatientActionState = {
+ ok: boolean;
+ error?: string;
+};
+
+export const initialPatientActionState: PatientActionState = { ok: false };
diff --git a/src/lib/appwrite/schema.ts b/src/lib/appwrite/schema.ts
index 6ed1a8e..7a1fb23 100644
--- a/src/lib/appwrite/schema.ts
+++ b/src/lib/appwrite/schema.ts
@@ -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;
diff --git a/src/lib/validation/job.ts b/src/lib/validation/job.ts
index da9ff19..5a281c0 100644
--- a/src/lib/validation/job.ts
+++ b/src/lib/validation/job.ts
@@ -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
diff --git a/src/lib/validation/patient.ts b/src/lib/validation/patient.ts
new file mode 100644
index 0000000..c449bce
--- /dev/null
+++ b/src/lib/validation/patient.ts
@@ -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;
diff --git a/src/middleware.ts b/src/middleware.ts
index f7b4571..c3d32f2 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -22,6 +22,7 @@ const PROTECTED_PREFIXES = [
"/settings",
"/jobs",
"/products",
+ "/patients",
"/finance",
"/connections",
"/notifications",