Files
lab/src/app/(dashboard)/jobs/[jobId]/page.tsx
T
kovakmedya 94e9dffaef feat(jobs): step-by-step timeline on the detail page
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.
2026-05-22 16:05:07 +03:00

333 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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&apos;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>
);
}