ee9c0015a5
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.
287 lines
9.9 KiB
TypeScript
287 lines
9.9 KiB
TypeScript
import Link from "next/link";
|
||
import { notFound, redirect } from "next/navigation";
|
||
import { Query } from "node-appwrite";
|
||
|
||
import { Badge } from "@/components/ui/badge";
|
||
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,
|
||
JOB_STEP_LABELS,
|
||
JOB_STEP_ORDER,
|
||
PROSTHETIC_TYPE_LABELS,
|
||
} from "@/lib/appwrite/job-types";
|
||
import { createAdminClient } from "@/lib/appwrite/server";
|
||
import { DATABASE_ID, TABLES, type Job, type TenantSettings } from "@/lib/appwrite/schema";
|
||
import { requireTenant } from "@/lib/appwrite/tenant-guard";
|
||
import { JobActionsPanel } from "./components/job-actions-panel";
|
||
import { JobFilesPanel } from "./components/job-files-panel";
|
||
|
||
export const metadata = {
|
||
title: "DLS — İş Detay",
|
||
};
|
||
|
||
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
|
||
function formatMoney(amount: number, currency: string) {
|
||
try {
|
||
return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount);
|
||
} catch {
|
||
return `${amount.toFixed(2)} ${currency}`;
|
||
}
|
||
}
|
||
|
||
export default async function JobDetailPage({
|
||
params,
|
||
}: {
|
||
params: Promise<{ jobId: string }>;
|
||
}) {
|
||
const { jobId } = await params;
|
||
|
||
let ctx;
|
||
try {
|
||
ctx = await requireTenant();
|
||
} catch {
|
||
redirect("/onboarding");
|
||
}
|
||
|
||
const { tablesDB } = createAdminClient();
|
||
let job: Job;
|
||
try {
|
||
const row = await tablesDB.getRow(DATABASE_ID, TABLES.jobs, jobId);
|
||
job = toPlain(row as unknown as Job);
|
||
} catch {
|
||
notFound();
|
||
}
|
||
|
||
if (job.clinicTenantId !== ctx.tenantId && job.labTenantId !== ctx.tenantId) {
|
||
notFound();
|
||
}
|
||
|
||
const counterpartId =
|
||
job.clinicTenantId === ctx.tenantId ? job.labTenantId : job.clinicTenantId;
|
||
const counterpartLabel = job.clinicTenantId === ctx.tenantId ? "Laboratuvar" : "Klinik";
|
||
|
||
const counterpartRes = await tablesDB.listRows({
|
||
databaseId: DATABASE_ID,
|
||
tableId: TABLES.tenantSettings,
|
||
queries: [Query.equal("tenantId", counterpartId), Query.limit(1)],
|
||
});
|
||
const counterpart = counterpartRes.rows[0] as unknown as TenantSettings | undefined;
|
||
|
||
const [history, files] = await Promise.all([
|
||
listJobHistory(jobId),
|
||
listJobFiles(jobId),
|
||
]);
|
||
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">
|
||
<div className="flex flex-col gap-1">
|
||
<p className="text-muted-foreground text-sm">
|
||
{counterpartLabel}: {counterpart?.companyName ?? "—"}
|
||
</p>
|
||
<h1 className="text-2xl font-bold tracking-tight">
|
||
{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>
|
||
<div className="flex flex-col items-end gap-3">
|
||
<Badge variant="secondary" className="text-sm">
|
||
{JOB_STATUS_LABELS[job.status]}
|
||
</Badge>
|
||
<JobActionsPanel job={job} side={side} kind={ctx.kind} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-6 lg:grid-cols-3">
|
||
<Card className="lg:col-span-2">
|
||
<CardHeader>
|
||
<CardTitle>İş Bilgileri</CardTitle>
|
||
<CardDescription>{dateFormatter.format(new Date(job.$createdAt))}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="grid gap-4 text-sm md:grid-cols-2">
|
||
<Info label="Renk">{job.color || "—"}</Info>
|
||
<Info label="Termin">
|
||
{job.dueDate ? dateFormatter.format(new Date(job.dueDate)) : "—"}
|
||
</Info>
|
||
<Info label="Fiyat">
|
||
{typeof job.price === "number"
|
||
? formatMoney(job.price, job.currency || "TRY")
|
||
: "—"}
|
||
</Info>
|
||
<Info label="Mevcut Aşama">
|
||
{job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—"}
|
||
</Info>
|
||
<div className="md:col-span-2">
|
||
<p className="text-muted-foreground mb-1 text-xs font-medium uppercase tracking-wide">
|
||
Açıklama
|
||
</p>
|
||
<p className="whitespace-pre-wrap text-sm">{job.description || "—"}</p>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Aşamalar</CardTitle>
|
||
<CardDescription>Ölçü → Alt Yapı → Üst Yapı → Cila/Bitim</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ol className="space-y-3">
|
||
{JOB_STEP_ORDER.map((step, idx) => {
|
||
const done = currentStepIdx > idx || job.status === "delivered";
|
||
const active = currentStepIdx === idx && job.status !== "delivered";
|
||
return (
|
||
<li key={step} className="flex items-center gap-3">
|
||
<span
|
||
className={
|
||
done
|
||
? "bg-primary text-primary-foreground"
|
||
: active
|
||
? "bg-primary/15 text-primary"
|
||
: "bg-muted text-muted-foreground"
|
||
}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: "50%",
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
{idx + 1}
|
||
</span>
|
||
<span className={active ? "font-medium" : ""}>{JOB_STEP_LABELS[step]}</span>
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
<p className="text-muted-foreground mt-4 text-xs">
|
||
Aşama güncelleme ve dosya yükleme sonraki sürümde.
|
||
</p>
|
||
</CardContent>
|
||
</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>
|
||
<CardDescription>
|
||
Hem klinik hem laboratuvar dosya yükleyip indirebilir.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<JobFilesPanel jobId={job.$id} files={files} />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{history.length > 0 && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Aşama Geçmişi</CardTitle>
|
||
<CardDescription>Tamamlanan aşamaların kaydı.</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ol className="space-y-3">
|
||
{history.map((h) => (
|
||
<li key={h.$id} className="border-l-2 border-primary/30 pl-4">
|
||
<div className="flex flex-wrap items-baseline gap-2">
|
||
<span className="font-medium">{JOB_STEP_LABELS[h.step]}</span>
|
||
<span className="text-muted-foreground text-xs">
|
||
{dateFormatter.format(new Date(h.completedAt))}
|
||
</span>
|
||
</div>
|
||
{h.note && (
|
||
<p className="text-muted-foreground mt-1 whitespace-pre-wrap text-sm">
|
||
{h.note}
|
||
</p>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
<div>
|
||
<Button asChild variant="outline">
|
||
<Link href={ctx.kind === "clinic" ? "/jobs/outbound" : "/jobs/inbound"}>
|
||
← Listeye dön
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Info({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<div>
|
||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||
{label}
|
||
</p>
|
||
<p className="mt-0.5 text-sm">{children}</p>
|
||
</div>
|
||
);
|
||
}
|