94e9dffaef
The job_status_history table was already being populated on every
transition; the detail page just rendered a flat list with date only.
Replaced it with a proper vertical timeline:
- Card title moved from 'Aşama Geçmişi' to 'Akış Geçmişi' since we
now include side-trips (revision requests), not just forward steps.
- Vertical guide line with a coloured node per entry: emerald for
a normal step completion, rose for a revision request. Spotting a
bounced prova in the history is a glance.
- Revision rows get an inline 'Düzeltme talebi' pill; the '[Düzeltme
talebi]' prefix is stripped from the visible note so the actual
feedback text reads cleanly.
- Always rendered (with an empty-state line) so the card position
doesn't move around as the case progresses.
333 lines
12 KiB
TypeScript
333 lines
12 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 { DueBadge } from "@/components/due-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_LOCATION_LABELS,
|
||
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">
|
||
{(() => {
|
||
const name = [patient?.firstName, patient?.lastName].filter(Boolean).join(" ");
|
||
return name || `Hasta ${job.patientCode}`;
|
||
})()}
|
||
</h1>
|
||
<p className="text-muted-foreground text-sm">
|
||
{patient && (patient.firstName || patient.lastName) && (
|
||
<>
|
||
<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">
|
||
<div className="flex items-center gap-2">
|
||
<DueBadge job={job} />
|
||
<Badge variant="secondary" className="text-sm">
|
||
{JOB_STATUS_LABELS[job.status]}
|
||
</Badge>
|
||
</div>
|
||
<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")
|
||
: <span className="text-muted-foreground">Lab tarafından belirlenecek</span>}
|
||
</Info>
|
||
<Info label="Mevcut Aşama">
|
||
{job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—"}
|
||
</Info>
|
||
<Info label="Şu An">
|
||
{job.status === "pending"
|
||
? "Klinikte (lab teslim alacak)"
|
||
: job.status === "delivered"
|
||
? "Hasta'ya teslim edildi"
|
||
: job.status === "cancelled"
|
||
? "İptal"
|
||
: JOB_LOCATION_LABELS[job.location ?? "at_lab"]}
|
||
</Info>
|
||
<div className="md:col-span-2">
|
||
<p className="text-muted-foreground mb-1 text-xs font-medium uppercase tracking-wide">
|
||
Dişler ({job.teeth?.length ?? job.memberCount})
|
||
</p>
|
||
<p className="font-mono text-sm">
|
||
{job.teeth && job.teeth.length > 0
|
||
? job.teeth.join(", ")
|
||
: `${job.memberCount} üye (diş listesi yok)`}
|
||
</p>
|
||
</div>
|
||
<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-2">
|
||
<Info label="Ad Soyad">
|
||
{[patient.firstName, patient.lastName].filter(Boolean).join(" ") || "—"}
|
||
</Info>
|
||
<Info label="Hasta Kodu">
|
||
<span className="font-mono">{patient.patientCode}</span>
|
||
</Info>
|
||
{patient.notes && (
|
||
<div className="md:col-span-2">
|
||
<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>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Akış Geçmişi</CardTitle>
|
||
<CardDescription>
|
||
İşin aşama transition'ları, kim yaptı ve hangi notla.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{history.length === 0 ? (
|
||
<p className="text-muted-foreground text-sm">
|
||
Henüz aşama tamamlanmadı.
|
||
</p>
|
||
) : (
|
||
<ol className="relative space-y-4 border-l-2 border-border pl-6">
|
||
{history.map((h) => {
|
||
const isRevision = h.note?.startsWith("[Düzeltme talebi]");
|
||
return (
|
||
<li key={h.$id} className="relative">
|
||
<span
|
||
className={`absolute -left-[1.85rem] mt-1.5 size-3 rounded-full ring-2 ring-background ${
|
||
isRevision ? "bg-rose-500" : "bg-emerald-500"
|
||
}`}
|
||
aria-hidden
|
||
/>
|
||
<div className="flex flex-wrap items-baseline gap-2">
|
||
<span className="font-medium">
|
||
{JOB_STEP_LABELS[h.step]}
|
||
</span>
|
||
{isRevision && (
|
||
<span className="rounded bg-rose-100 px-1.5 py-0.5 text-xs font-medium text-rose-700 dark:bg-rose-950 dark:text-rose-300">
|
||
Düzeltme talebi
|
||
</span>
|
||
)}
|
||
<span className="text-muted-foreground text-xs tabular-nums">
|
||
{dateFormatter.format(new Date(h.completedAt))}
|
||
</span>
|
||
</div>
|
||
{h.note && (
|
||
<p className="text-muted-foreground mt-1 whitespace-pre-wrap text-sm">
|
||
{h.note.replace(/^\[Düzeltme talebi\]\s*/, "")}
|
||
</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>
|
||
);
|
||
}
|