From 0dea028845318dd750ef082cb05dd773d9b26544 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 21 May 2026 22:52:31 +0300 Subject: [PATCH] feat(jobs/new): live price quote with discount breakdown for the clinic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../jobs/new/components/new-job-form.tsx | 207 +++++++++++++++++- src/app/api/pricing/quote/route.ts | 73 ++++++ src/lib/appwrite/pricing.ts | 31 ++- 3 files changed, 303 insertions(+), 8 deletions(-) create mode 100644 src/app/api/pricing/quote/route.ts diff --git a/src/app/(dashboard)/jobs/new/components/new-job-form.tsx b/src/app/(dashboard)/jobs/new/components/new-job-form.tsx index 8814fcd..7726d2b 100644 --- a/src/app/(dashboard)/jobs/new/components/new-job-form.tsx +++ b/src/app/(dashboard)/jobs/new/components/new-job-form.tsx @@ -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([]); + const [labTenantId, setLabTenantId] = useState(labs[0]?.tenantId ?? ""); + const [prostheticType, setProstheticType] = useState(""); + const [quote, setQuote] = useState(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 (
- + + +