95f2d065b4
What changed
- jobs.teeth (FDI string[]). memberCount becomes a derived field (teeth.length).
A new TeethChart component renders the full permanent dentition as a
16-column grid for each arch with click-toggle selection.
- /jobs/new: removed the price + currency inputs and the manual memberCount
field. Clinics now pick teeth via the chart; the form blocks submission
until at least one tooth is selected.
- createJobAction calls a new calculateJobPrice() helper that walks the
pricing cascade and writes price + currency on the job server-side. A
clinic-supplied price hidden field would now be ignored — the field
isn't even in the schema.
Pricing cascade (calculateJobPrice, lib/appwrite/pricing.ts)
1. clinic_pricing row matching (lab, clinic, type) with customUnitPrice
→ use that flat unit price.
2. clinic_pricing row with discountPercent → catalog unitPrice × (1-d).
3. lab's prosthetics catalog row matching type (not archived).
4. nothing → price stays null; lab can still set it manually later.
Clinic-specific overrides (clinic_pricing table)
- Unique on (labTenantId, clinicTenantId, prostheticType) so each
combination has at most one rule.
- Row permissions: read by both teams (transparency for clinic), write
only by lab — clinic can see the discount they're getting but cannot
edit it.
- setClinicPricingAction validates an approved connection exists before
creating/updating, and rejects requests where neither customUnitPrice
nor discountPercent is set.
- clearClinicPricingAction wipes a rule (catalog price re-applies).
UI
- /connections 'Bağlantılarım' table gets a new column showing the active
pricing rules per counterpart. Lab side has a 'Fiyatlandırma' button
that opens a dialog (PROSTHETIC_TYPE × customPrice|discountPercent form
+ list of active rules with delete). Clinic side is read-only.
- Job detail: 'Fiyat' field now shows 'Lab tarafından belirlenecek' when
null, instead of a literal —. Adds a 'Dişler' info block listing the
selected FDI numbers.
297 lines
10 KiB
TypeScript
297 lines
10 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")
|
||
: <span className="text-muted-foreground">Lab tarafından belirlenecek</span>}
|
||
</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">
|
||
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-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>
|
||
);
|
||
}
|