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:
@@ -3,7 +3,7 @@
|
|||||||
import { useActionState, useEffect, useMemo, useState } from "react";
|
import { useActionState, useEffect, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Loader2, Send } from "lucide-react";
|
import { Loader2, Send, Sparkles, TrendingDown } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -39,6 +39,26 @@ const PROSTHETIC_TYPES: ProstheticType[] = [
|
|||||||
|
|
||||||
type PatientOption = { id: string; code: string; label: string };
|
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({
|
export function NewJobForm({
|
||||||
labs,
|
labs,
|
||||||
patients,
|
patients,
|
||||||
@@ -54,6 +74,10 @@ export function NewJobForm({
|
|||||||
patients.length > 0 ? patients[0].id : NONE_PATIENT,
|
patients.length > 0 ? patients[0].id : NONE_PATIENT,
|
||||||
);
|
);
|
||||||
const [teeth, setTeeth] = useState<string[]>([]);
|
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(
|
const patientById = useMemo(
|
||||||
() => new Map(patients.map((p) => [p.id, p])),
|
() => new Map(patients.map((p) => [p.id, p])),
|
||||||
@@ -70,12 +94,48 @@ export function NewJobForm({
|
|||||||
}
|
}
|
||||||
}, [state, router]);
|
}, [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 (
|
return (
|
||||||
<form action={action} className="grid gap-5">
|
<form action={action} className="grid gap-5">
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<div className="grid gap-2 md:col-span-2">
|
<div className="grid gap-2 md:col-span-2">
|
||||||
<Label htmlFor="labTenantId">Laboratuvar *</Label>
|
<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">
|
<SelectTrigger id="labTenantId">
|
||||||
<SelectValue placeholder="Bir laboratuvar seçin" />
|
<SelectValue placeholder="Bir laboratuvar seçin" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -156,7 +216,11 @@ export function NewJobForm({
|
|||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="prostheticType">Protez Türü *</Label>
|
<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">
|
<SelectTrigger id="prostheticType">
|
||||||
<SelectValue placeholder="Tür seçin" />
|
<SelectValue placeholder="Tür seçin" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -189,9 +253,14 @@ export function NewJobForm({
|
|||||||
{state.fieldErrors?.teeth && (
|
{state.fieldErrors?.teeth && (
|
||||||
<p className="text-destructive text-xs">{state.fieldErrors.teeth}</p>
|
<p className="text-destructive text-xs">{state.fieldErrors.teeth}</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-muted-foreground text-xs">
|
</div>
|
||||||
Fiyat laboratuvarın katalog ve klinik indirimine göre otomatik hesaplanır — siz girmiyorsunuz.
|
|
||||||
</p>
|
<div className="md:col-span-2">
|
||||||
|
<PriceQuoteCard
|
||||||
|
quote={quote}
|
||||||
|
loading={quoteLoading}
|
||||||
|
hasInputs={Boolean(labTenantId && prostheticType && teeth.length > 0)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 md:col-span-2">
|
<div className="grid gap-2 md:col-span-2">
|
||||||
@@ -224,3 +293,129 @@ export function NewJobForm({
|
|||||||
</form>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
|
import { calculateJobPrice } from "@/lib/appwrite/pricing";
|
||||||
|
import { DATABASE_ID, TABLES, type Connection, type ProstheticType } from "@/lib/appwrite/schema";
|
||||||
|
import { createAdminClient } from "@/lib/appwrite/server";
|
||||||
|
import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard";
|
||||||
|
|
||||||
|
const PROSTHETIC_TYPES = new Set<ProstheticType>([
|
||||||
|
"metal_porselen",
|
||||||
|
"zirkonyum",
|
||||||
|
"implant_ustu_zirkonyum",
|
||||||
|
"gecici",
|
||||||
|
"e_max",
|
||||||
|
"diger",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
let ctx;
|
||||||
|
try {
|
||||||
|
ctx = await requireTenant();
|
||||||
|
requireTenantKind(ctx, ["clinic"]);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ ok: false, error: "Yetki yok." }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: {
|
||||||
|
labTenantId?: string;
|
||||||
|
prostheticType?: string;
|
||||||
|
teethCount?: number;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ ok: false, error: "Geçersiz istek." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const labTenantId = String(body.labTenantId ?? "").trim();
|
||||||
|
const prostheticType = String(body.prostheticType ?? "").trim();
|
||||||
|
const teethCount = Number(body.teethCount);
|
||||||
|
|
||||||
|
if (!labTenantId || !PROSTHETIC_TYPES.has(prostheticType as ProstheticType)) {
|
||||||
|
return NextResponse.json({ ok: true, quote: null });
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(teethCount) || teethCount <= 0) {
|
||||||
|
return NextResponse.json({ ok: true, quote: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only return a quote if the clinic actually has an approved bond with the
|
||||||
|
// chosen lab — keeps catalog pricing private to connected pairs.
|
||||||
|
const { tablesDB } = createAdminClient();
|
||||||
|
const connRes = await tablesDB.listRows({
|
||||||
|
databaseId: DATABASE_ID,
|
||||||
|
tableId: TABLES.connections,
|
||||||
|
queries: [
|
||||||
|
Query.equal("clinicTenantId", ctx.tenantId),
|
||||||
|
Query.equal("labTenantId", labTenantId),
|
||||||
|
Query.equal("status", "approved"),
|
||||||
|
Query.limit(1),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (!(connRes.rows[0] as unknown as Connection | undefined)) {
|
||||||
|
return NextResponse.json({ ok: true, quote: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const quote = await calculateJobPrice({
|
||||||
|
labTenantId,
|
||||||
|
clinicTenantId: ctx.tenantId,
|
||||||
|
prostheticType: prostheticType as ProstheticType,
|
||||||
|
teethCount,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true, quote });
|
||||||
|
}
|
||||||
@@ -17,6 +17,14 @@ export type JobPriceQuote = {
|
|||||||
currency: string;
|
currency: string;
|
||||||
source: "clinic_custom" | "clinic_discount" | "catalog";
|
source: "clinic_custom" | "clinic_discount" | "catalog";
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
|
/** Lab's listed catalog price before any clinic override, if a catalog row exists. */
|
||||||
|
catalogUnitPrice?: number;
|
||||||
|
/** catalogUnitPrice × teethCount; useful for showing the clinic the saving. */
|
||||||
|
catalogTotal?: number;
|
||||||
|
/** catalogTotal - amount; positive when the clinic is getting a discount. */
|
||||||
|
savings?: number;
|
||||||
|
/** teethCount passed in, echoed back so the client can format breakdowns. */
|
||||||
|
teethCount: number;
|
||||||
discountPercent?: number;
|
discountPercent?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,14 +85,25 @@ export async function calculateJobPrice(opts: {
|
|||||||
const labSettings = labSettingsRes.rows[0] as unknown as TenantSettings | undefined;
|
const labSettings = labSettingsRes.rows[0] as unknown as TenantSettings | undefined;
|
||||||
|
|
||||||
const fallbackCurrency = labSettings?.defaultCurrency ?? "TRY";
|
const fallbackCurrency = labSettings?.defaultCurrency ?? "TRY";
|
||||||
|
const catalogUnitPrice = catalog?.unitPrice;
|
||||||
|
const catalogTotal =
|
||||||
|
catalogUnitPrice !== undefined ? catalogUnitPrice * opts.teethCount : undefined;
|
||||||
|
|
||||||
if (override?.customUnitPrice !== undefined && override.customUnitPrice !== null) {
|
if (override?.customUnitPrice !== undefined && override.customUnitPrice !== null) {
|
||||||
const unit = override.customUnitPrice;
|
const unit = override.customUnitPrice;
|
||||||
|
const amount = unit * opts.teethCount;
|
||||||
return {
|
return {
|
||||||
amount: unit * opts.teethCount,
|
amount,
|
||||||
currency: override.currency || catalog?.currency || fallbackCurrency,
|
currency: override.currency || catalog?.currency || fallbackCurrency,
|
||||||
source: "clinic_custom",
|
source: "clinic_custom",
|
||||||
unitPrice: unit,
|
unitPrice: unit,
|
||||||
|
catalogUnitPrice,
|
||||||
|
catalogTotal,
|
||||||
|
savings:
|
||||||
|
catalogTotal !== undefined && catalogTotal > amount
|
||||||
|
? catalogTotal - amount
|
||||||
|
: undefined,
|
||||||
|
teethCount: opts.teethCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,12 +115,17 @@ export async function calculateJobPrice(opts: {
|
|||||||
override.discountPercent > 0
|
override.discountPercent > 0
|
||||||
) {
|
) {
|
||||||
const discounted = catalog.unitPrice * (1 - override.discountPercent / 100);
|
const discounted = catalog.unitPrice * (1 - override.discountPercent / 100);
|
||||||
|
const amount = discounted * opts.teethCount;
|
||||||
return {
|
return {
|
||||||
amount: discounted * opts.teethCount,
|
amount,
|
||||||
currency: override.currency || catalog.currency || fallbackCurrency,
|
currency: override.currency || catalog.currency || fallbackCurrency,
|
||||||
source: "clinic_discount",
|
source: "clinic_discount",
|
||||||
unitPrice: discounted,
|
unitPrice: discounted,
|
||||||
|
catalogUnitPrice: catalog.unitPrice,
|
||||||
|
catalogTotal,
|
||||||
|
savings: catalogTotal !== undefined ? catalogTotal - amount : undefined,
|
||||||
discountPercent: override.discountPercent,
|
discountPercent: override.discountPercent,
|
||||||
|
teethCount: opts.teethCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,5 +134,8 @@ export async function calculateJobPrice(opts: {
|
|||||||
currency: catalog.currency || fallbackCurrency,
|
currency: catalog.currency || fallbackCurrency,
|
||||||
source: "catalog",
|
source: "catalog",
|
||||||
unitPrice: catalog.unitPrice,
|
unitPrice: catalog.unitPrice,
|
||||||
|
catalogUnitPrice: catalog.unitPrice,
|
||||||
|
catalogTotal,
|
||||||
|
teethCount: opts.teethCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user