feat(patients): detail page with full job history
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.
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { Archive, ArchiveRestore, Loader2, Pencil } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -83,11 +84,17 @@ function PatientRow({ row }: { row: Patient }) {
|
||||
|
||||
return (
|
||||
<TableRow className={row.archived ? "opacity-60" : ""}>
|
||||
<TableCell className="font-mono text-xs">{row.patientCode}</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<Link href={`/patients/${row.$id}`} className="hover:underline">
|
||||
{row.patientCode}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/patients/${row.$id}`} className="hover:underline">
|
||||
{[row.firstName, row.lastName].filter(Boolean).join(" ") || (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground max-w-[280px] truncate">
|
||||
{row.notes || "—"}
|
||||
|
||||
@@ -2,7 +2,7 @@ import "server-only";
|
||||
|
||||
import { Query } from "node-appwrite";
|
||||
|
||||
import { DATABASE_ID, TABLES, type Patient } from "./schema";
|
||||
import { DATABASE_ID, TABLES, type Job, type Patient } from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { toPlain } from "./serialize";
|
||||
|
||||
@@ -44,3 +44,47 @@ export async function getPatient(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every job linked to this patient — by explicit patientId on newer jobs,
|
||||
* or by matching patientCode on legacy rows that pre-date the relation
|
||||
* (we still want to surface that history).
|
||||
*/
|
||||
export async function listPatientJobs(
|
||||
patientId: string,
|
||||
patientCode: string,
|
||||
clinicTenantId: string,
|
||||
): Promise<Job[]> {
|
||||
const { tablesDB } = createAdminClient();
|
||||
const [byId, byCode] = await Promise.all([
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.jobs,
|
||||
queries: [
|
||||
Query.equal("clinicTenantId", clinicTenantId),
|
||||
Query.equal("patientId", patientId),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(200),
|
||||
],
|
||||
}),
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.jobs,
|
||||
queries: [
|
||||
Query.equal("clinicTenantId", clinicTenantId),
|
||||
Query.equal("patientCode", patientCode),
|
||||
Query.orderDesc("$createdAt"),
|
||||
Query.limit(200),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const seen = new Set<string>();
|
||||
const merged: Job[] = [];
|
||||
for (const row of [...byId.rows, ...byCode.rows] as unknown as Job[]) {
|
||||
if (seen.has(row.$id)) continue;
|
||||
seen.add(row.$id);
|
||||
merged.push(row);
|
||||
}
|
||||
merged.sort((a, b) => (a.$createdAt < b.$createdAt ? 1 : -1));
|
||||
return toPlain(merged);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user