Files
lab/src/app/(dashboard)/jobs/[jobId]/page.tsx
T
kovakmedya 95f2d065b4 feat(pricing): tooth-based selection, lab-owned pricing, clinic-specific overrides
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.
2026-05-21 22:04:26 +03:00

297 lines
10 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 { 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>
);
}