88a42c9d06
Clinics had no way to look up 'what have we made for this patient
before'. The patient row showed in the list and edit dialog, but no
deeper page. Added /patients/[id] with the patient header, notes card
and a 'İş Geçmişi' table that's chronological.
- listPatientJobs(patientId, patientCode, clinicTenantId) merges two
queries — explicit patientId match (new jobs) and patientCode
match (legacy rows from before we had the relation). Dedupes by
$id and sorts createdAt desc. Returns plain.
- /patients/[patientId]/page.tsx (clinic-only via requireTenantKind):
notFound on missing/foreign rows, header shows code + full name +
Arşivlenmiş badge, 'Bu hastaya yeni iş' shortcut into /jobs/new,
history table with date + type + member count + status badge +
due badge + a 'Aç' button per row.
- Patient list rows now link both the protocol code and the name
cells to /patients/[$id] so clinics can click straight in. The
edit/archive controls stay on the row trailing edge as before.
169 lines
5.2 KiB
TypeScript
169 lines
5.2 KiB
TypeScript
import Link from "next/link";
|
||
import { notFound, redirect } from "next/navigation";
|
||
import { ArrowLeft, Plus } from "lucide-react";
|
||
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import { DueBadge } from "@/components/due-badge";
|
||
import {
|
||
JOB_STATUS_LABELS,
|
||
PROSTHETIC_TYPE_LABELS,
|
||
} from "@/lib/appwrite/job-types";
|
||
import { getPatient, listPatientJobs } from "@/lib/appwrite/patient-queries";
|
||
import type { JobStatus } from "@/lib/appwrite/schema";
|
||
import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard";
|
||
|
||
export const metadata = {
|
||
title: "DLS — Hasta",
|
||
};
|
||
|
||
const dateFormatter = new Intl.DateTimeFormat("tr-TR", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
});
|
||
|
||
function statusVariant(s: JobStatus): "default" | "secondary" | "outline" | "destructive" {
|
||
if (s === "delivered") return "default";
|
||
if (s === "sent" || s === "in_progress") return "secondary";
|
||
if (s === "cancelled") return "destructive";
|
||
return "outline";
|
||
}
|
||
|
||
export default async function PatientDetailPage({
|
||
params,
|
||
}: {
|
||
params: Promise<{ patientId: string }>;
|
||
}) {
|
||
const { patientId } = await params;
|
||
|
||
let ctx;
|
||
try {
|
||
ctx = await requireTenant();
|
||
requireTenantKind(ctx, ["clinic"]);
|
||
} catch {
|
||
redirect("/dashboard");
|
||
}
|
||
|
||
const patient = await getPatient(patientId, ctx.tenantId);
|
||
if (!patient) notFound();
|
||
|
||
const jobs = await listPatientJobs(patient.$id, patient.patientCode, ctx.tenantId);
|
||
|
||
const fullName =
|
||
[patient.firstName, patient.lastName].filter(Boolean).join(" ") ||
|
||
`Hasta ${patient.patientCode}`;
|
||
|
||
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 font-mono">
|
||
{patient.patientCode}
|
||
</p>
|
||
<h1 className="text-2xl font-bold tracking-tight">{fullName}</h1>
|
||
{patient.archived && (
|
||
<Badge variant="outline" className="w-fit">
|
||
Arşivlenmiş
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
<Button asChild>
|
||
<Link href="/jobs/new">
|
||
<Plus className="size-4" />
|
||
Bu hastaya yeni iş
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
|
||
{patient.notes && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Notlar</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="text-muted-foreground whitespace-pre-wrap text-sm">
|
||
{patient.notes}
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>İş Geçmişi</CardTitle>
|
||
<CardDescription>
|
||
{jobs.length === 0
|
||
? "Bu hastaya ait iş kaydı yok."
|
||
: `${jobs.length} iş`}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{jobs.length === 0 ? (
|
||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||
Henüz bu hasta için iş gönderilmemiş.
|
||
</p>
|
||
) : (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Tarih</TableHead>
|
||
<TableHead>Tür</TableHead>
|
||
<TableHead>Üye</TableHead>
|
||
<TableHead>Durum</TableHead>
|
||
<TableHead>Termin</TableHead>
|
||
<TableHead className="text-right">Detay</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{jobs.map((j) => (
|
||
<TableRow key={j.$id}>
|
||
<TableCell className="text-muted-foreground text-xs">
|
||
{dateFormatter.format(new Date(j.$createdAt))}
|
||
</TableCell>
|
||
<TableCell className="text-muted-foreground">
|
||
{PROSTHETIC_TYPE_LABELS[j.prostheticType] ?? j.prostheticType}
|
||
</TableCell>
|
||
<TableCell className="tabular-nums">{j.memberCount}</TableCell>
|
||
<TableCell>
|
||
<Badge variant={statusVariant(j.status)}>
|
||
{JOB_STATUS_LABELS[j.status]}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
<DueBadge job={j} />
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<Button asChild size="sm" variant="outline">
|
||
<Link href={`/jobs/${j.$id}`}>Aç</Link>
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div>
|
||
<Button asChild variant="outline">
|
||
<Link href="/patients">
|
||
<ArrowLeft className="size-4" />
|
||
Hasta listesine dön
|
||
</Link>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|