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
+29 -2
View File
@@ -17,6 +17,14 @@ export type JobPriceQuote = {
currency: string;
source: "clinic_custom" | "clinic_discount" | "catalog";
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;
};
@@ -77,14 +85,25 @@ export async function calculateJobPrice(opts: {
const labSettings = labSettingsRes.rows[0] as unknown as TenantSettings | undefined;
const fallbackCurrency = labSettings?.defaultCurrency ?? "TRY";
const catalogUnitPrice = catalog?.unitPrice;
const catalogTotal =
catalogUnitPrice !== undefined ? catalogUnitPrice * opts.teethCount : undefined;
if (override?.customUnitPrice !== undefined && override.customUnitPrice !== null) {
const unit = override.customUnitPrice;
const amount = unit * opts.teethCount;
return {
amount: unit * opts.teethCount,
amount,
currency: override.currency || catalog?.currency || fallbackCurrency,
source: "clinic_custom",
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
) {
const discounted = catalog.unitPrice * (1 - override.discountPercent / 100);
const amount = discounted * opts.teethCount;
return {
amount: discounted * opts.teethCount,
amount,
currency: override.currency || catalog.currency || fallbackCurrency,
source: "clinic_discount",
unitPrice: discounted,
catalogUnitPrice: catalog.unitPrice,
catalogTotal,
savings: catalogTotal !== undefined ? catalogTotal - amount : undefined,
discountPercent: override.discountPercent,
teethCount: opts.teethCount,
};
}
@@ -110,5 +134,8 @@ export async function calculateJobPrice(opts: {
currency: catalog.currency || fallbackCurrency,
source: "catalog",
unitPrice: catalog.unitPrice,
catalogUnitPrice: catalog.unitPrice,
catalogTotal,
teethCount: opts.teethCount,
};
}