feat(jobs/new): live price quote with discount breakdown for the clinic

Clinics couldn't see what an order would cost them before publishing.
Added a server-priced quote box that updates as the lab, prosthetic type
and tooth selection change. Three rendered states:

  1. Discounted (custom unit price or discountPercent):
       Katalog (4 × 1.500 ₺)      6.000 ₺
       Klinik indirimi (%15)       -900 ₺
       ──────────────────────
       Toplam                    5.100 ₺

  2. Plain catalog match (no clinic override):
       4 × 1.500 ₺               6.000 ₺
       'Katalog fiyatı uygulanıyor'

  3. No catalog row for the chosen type:
       'Fiyat işe başlanırken laboratuvar tarafından belirlenecek'

Bits to make this work
  - calculateJobPrice now returns catalogUnitPrice, catalogTotal, savings
    and teethCount alongside the final amount, so the client can render
    a breakdown without reaching for any other table.
  - POST /api/pricing/quote endpoint guards on clinic kind + an approved
    connection before exposing pricing — no leaking catalog data to
    unconnected clinics, and no per-lab discount snooping.
  - NewJobForm's lab and prosthetic Selects became controlled (hidden
    inputs mirror state to the form action), and a debounced effect
    (250ms) re-fetches the quote when any of {lab, type, teeth.length}
    changes. AbortController cancels in-flight requests when inputs change
    again.
This commit is contained in:
kovakmedya
2026-05-21 22:52:31 +03:00
parent 067e4af440
commit 0dea028845
3 changed files with 303 additions and 8 deletions
@@ -3,7 +3,7 @@
import { useActionState, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Loader2, Send } from "lucide-react";
import { Loader2, Send, Sparkles, TrendingDown } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -39,6 +39,26 @@ const PROSTHETIC_TYPES: ProstheticType[] = [
type PatientOption = { id: string; code: string; label: string };
type Quote = {
amount: number;
currency: string;
source: "clinic_custom" | "clinic_discount" | "catalog";
unitPrice: number;
catalogUnitPrice?: number;
catalogTotal?: number;
savings?: number;
discountPercent?: number;
teethCount: number;
};
function formatMoney(amount: number, currency: string): string {
try {
return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount);
} catch {
return `${amount.toFixed(2)} ${currency}`;
}
}
export function NewJobForm({
labs,
patients,
@@ -54,6 +74,10 @@ export function NewJobForm({
patients.length > 0 ? patients[0].id : NONE_PATIENT,
);
const [teeth, setTeeth] = useState<string[]>([]);
const [labTenantId, setLabTenantId] = useState<string>(labs[0]?.tenantId ?? "");
const [prostheticType, setProstheticType] = useState<ProstheticType | "">("");
const [quote, setQuote] = useState<Quote | null>(null);
const [quoteLoading, setQuoteLoading] = useState(false);
const patientById = useMemo(
() => new Map(patients.map((p) => [p.id, p])),
@@ -70,12 +94,48 @@ export function NewJobForm({
}
}, [state, router]);
// Live price quote — debounced to a single fetch per 250ms when inputs settle.
useEffect(() => {
if (!labTenantId || !prostheticType || teeth.length === 0) {
setQuote(null);
setQuoteLoading(false);
return;
}
setQuoteLoading(true);
const ctrl = new AbortController();
const timer = setTimeout(() => {
fetch("/api/pricing/quote", {
method: "POST",
signal: ctrl.signal,
headers: { "content-type": "application/json" },
body: JSON.stringify({
labTenantId,
prostheticType,
teethCount: teeth.length,
}),
})
.then((r) => r.json())
.then((d) => {
setQuote(d?.quote ?? null);
setQuoteLoading(false);
})
.catch(() => {
if (!ctrl.signal.aborted) setQuoteLoading(false);
});
}, 250);
return () => {
ctrl.abort();
clearTimeout(timer);
};
}, [labTenantId, prostheticType, teeth.length]);
return (
<form action={action} className="grid gap-5">
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="labTenantId">Laboratuvar *</Label>
<Select name="labTenantId" required defaultValue={labs[0]?.tenantId}>
<input type="hidden" name="labTenantId" value={labTenantId} />
<Select value={labTenantId} onValueChange={setLabTenantId}>
<SelectTrigger id="labTenantId">
<SelectValue placeholder="Bir laboratuvar seçin" />
</SelectTrigger>
@@ -156,7 +216,11 @@ export function NewJobForm({
<div className="grid gap-2">
<Label htmlFor="prostheticType">Protez Türü *</Label>
<Select name="prostheticType" required>
<input type="hidden" name="prostheticType" value={prostheticType} />
<Select
value={prostheticType}
onValueChange={(v) => setProstheticType(v as ProstheticType)}
>
<SelectTrigger id="prostheticType">
<SelectValue placeholder="Tür seçin" />
</SelectTrigger>
@@ -189,9 +253,14 @@ export function NewJobForm({
{state.fieldErrors?.teeth && (
<p className="text-destructive text-xs">{state.fieldErrors.teeth}</p>
)}
<p className="text-muted-foreground text-xs">
Fiyat laboratuvarın katalog ve klinik indirimine göre otomatik hesaplanır siz girmiyorsunuz.
</p>
</div>
<div className="md:col-span-2">
<PriceQuoteCard
quote={quote}
loading={quoteLoading}
hasInputs={Boolean(labTenantId && prostheticType && teeth.length > 0)}
/>
</div>
<div className="grid gap-2 md:col-span-2">
@@ -224,3 +293,129 @@ export function NewJobForm({
</form>
);
}
function PriceQuoteCard({
quote,
loading,
hasInputs,
}: {
quote: Quote | null;
loading: boolean;
hasInputs: boolean;
}) {
if (!hasInputs) {
return (
<div className="bg-muted/30 text-muted-foreground rounded-md border p-3 text-xs">
Tahmini fiyat için laboratuvar, protez türü ve dişleri seçin.
</div>
);
}
if (loading) {
return (
<div className="bg-muted/30 text-muted-foreground flex items-center gap-2 rounded-md border p-3 text-xs">
<Loader2 className="size-3.5 animate-spin" />
Fiyat hesaplanıyor...
</div>
);
}
if (!quote) {
return (
<div className="bg-muted/30 rounded-md border p-3 text-xs">
<p className="text-foreground font-medium">
Bu protez türü laboratuvarın katalogunda yok.
</p>
<p className="text-muted-foreground mt-1">
Fiyat işe başlanırken laboratuvar tarafından belirlenecek. İşi şimdi yayınlayabilirsiniz.
</p>
</div>
);
}
const hasDiscount =
typeof quote.savings === "number" && quote.savings > 0 && quote.catalogTotal !== undefined;
return (
<div className="bg-muted/30 rounded-md border p-3 text-sm">
<div className="text-muted-foreground mb-2 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide">
<Sparkles className="size-3.5" />
Tahmini Fiyat
</div>
{hasDiscount && quote.catalogTotal !== undefined ? (
<div className="space-y-1.5">
<Row
label={`Katalog (${quote.teethCount} diş × ${formatMoney(quote.catalogUnitPrice ?? 0, quote.currency)})`}
value={formatMoney(quote.catalogTotal, quote.currency)}
muted
/>
<Row
label={
quote.source === "clinic_discount" && quote.discountPercent
? `Klinik indirimi (%${quote.discountPercent})`
: "Klinik özel fiyatı"
}
value={`- ${formatMoney(quote.savings!, quote.currency)}`}
tone="positive"
icon={<TrendingDown className="size-3.5" />}
/>
<div className="border-border my-1 border-t" />
<Row
label="Toplam"
value={formatMoney(quote.amount, quote.currency)}
strong
/>
</div>
) : (
<div className="space-y-1.5">
<Row
label={`${quote.teethCount} diş × ${formatMoney(quote.unitPrice, quote.currency)}`}
value={formatMoney(quote.amount, quote.currency)}
strong
/>
{quote.source === "catalog" && (
<p className="text-muted-foreground text-xs">Katalog fiyatı uygulanıyor.</p>
)}
{quote.source === "clinic_custom" && (
<p className="text-muted-foreground text-xs">Sizin için özel fiyat tanımlı.</p>
)}
</div>
)}
</div>
);
}
function Row({
label,
value,
muted = false,
strong = false,
tone,
icon,
}: {
label: string;
value: string;
muted?: boolean;
strong?: boolean;
tone?: "positive" | "negative";
icon?: React.ReactNode;
}) {
const toneClass =
tone === "positive"
? "text-emerald-600 dark:text-emerald-400"
: tone === "negative"
? "text-rose-600 dark:text-rose-400"
: "";
return (
<div
className={`flex items-center justify-between text-xs ${
muted ? "text-muted-foreground" : ""
} ${strong ? "text-sm font-semibold" : ""} ${toneClass}`}
>
<span className="flex items-center gap-1.5">
{icon}
{label}
</span>
<span className="tabular-nums">{value}</span>
</div>
);
}