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 7726d2b..478d0d7 100644 --- a/src/app/(dashboard)/jobs/new/components/new-job-form.tsx +++ b/src/app/(dashboard)/jobs/new/components/new-job-form.tsx @@ -28,16 +28,14 @@ import type { ProstheticType } from "@/lib/appwrite/schema"; const NONE_PATIENT = "__none__"; -const PROSTHETIC_TYPES: ProstheticType[] = [ - "metal_porselen", - "zirkonyum", - "implant_ustu_zirkonyum", - "gecici", - "e_max", - "diger", -]; - type PatientOption = { id: string; code: string; label: string }; +type ProstheticOption = { + id: string; + name: string; + type: string; + unitPrice: number; + currency?: string; +}; type Quote = { amount: number; @@ -62,10 +60,12 @@ function formatMoney(amount: number, currency: string): string { export function NewJobForm({ labs, patients, - defaultCurrency: _defaultCurrency, + prostheticsByLab, + defaultCurrency, }: { labs: JobCounterpart[]; patients: PatientOption[]; + prostheticsByLab: Record; defaultCurrency: string; }) { const router = useRouter(); @@ -75,10 +75,13 @@ export function NewJobForm({ ); const [teeth, setTeeth] = useState([]); const [labTenantId, setLabTenantId] = useState(labs[0]?.tenantId ?? ""); - const [prostheticType, setProstheticType] = useState(""); + const [prostheticId, setProstheticId] = useState(""); const [quote, setQuote] = useState(null); const [quoteLoading, setQuoteLoading] = useState(false); + const labProsthetics = prostheticsByLab[labTenantId] ?? []; + const selectedProsthetic = labProsthetics.find((p) => p.id === prostheticId); + const patientById = useMemo( () => new Map(patients.map((p) => [p.id, p])), [patients], @@ -94,9 +97,15 @@ export function NewJobForm({ } }, [state, router]); + // Reset prosthetic selection when the lab changes so we never carry the + // previous lab's catalog ID over. + useEffect(() => { + setProstheticId(""); + }, [labTenantId]); + // Live price quote — debounced to a single fetch per 250ms when inputs settle. useEffect(() => { - if (!labTenantId || !prostheticType || teeth.length === 0) { + if (!prostheticId || teeth.length === 0) { setQuote(null); setQuoteLoading(false); return; @@ -108,11 +117,7 @@ export function NewJobForm({ method: "POST", signal: ctrl.signal, headers: { "content-type": "application/json" }, - body: JSON.stringify({ - labTenantId, - prostheticType, - teethCount: teeth.length, - }), + body: JSON.stringify({ prostheticId, teethCount: teeth.length }), }) .then((r) => r.json()) .then((d) => { @@ -127,7 +132,7 @@ export function NewJobForm({ ctrl.abort(); clearTimeout(timer); }; - }, [labTenantId, prostheticType, teeth.length]); + }, [prostheticId, teeth.length]); return (
@@ -215,25 +220,38 @@ export function NewJobForm({
- - - + - {state.fieldErrors?.prostheticType && ( -

{state.fieldErrors.prostheticType}

+ {selectedProsthetic && ( +

+ Kategori: {PROSTHETIC_TYPE_LABELS[selectedProsthetic.type as ProstheticType] ?? selectedProsthetic.type} +

+ )} + {state.fieldErrors?.prostheticId && ( +

{state.fieldErrors.prostheticId}

)}
@@ -259,7 +277,7 @@ export function NewJobForm({ 0)} + hasInputs={Boolean(prostheticId && teeth.length > 0)} /> diff --git a/src/app/(dashboard)/jobs/new/page.tsx b/src/app/(dashboard)/jobs/new/page.tsx index dc47a76..4ff4574 100644 --- a/src/app/(dashboard)/jobs/new/page.tsx +++ b/src/app/(dashboard)/jobs/new/page.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { listApprovedLabsForClinic } from "@/lib/appwrite/job-queries"; import { listPatients } from "@/lib/appwrite/patient-queries"; +import { listActiveProsthetics } from "@/lib/appwrite/prosthetic-queries"; import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard"; import { NewJobForm } from "./components/new-job-form"; @@ -25,6 +26,26 @@ export default async function NewJobPage() { listApprovedLabsForClinic(ctx.tenantId), listPatients(ctx.tenantId, { includeArchived: false }), ]); + + // Active catalog products for each connected lab — clinic picks one of + // these in the form instead of a free-text prosthetic type. + const prostheticsByLab: Record< + string, + { id: string; name: string; type: string; unitPrice: number; currency?: string }[] + > = {}; + await Promise.all( + labs.map(async (l) => { + const list = await listActiveProsthetics(l.tenantId); + prostheticsByLab[l.tenantId] = list.map((p) => ({ + id: p.$id, + name: p.name, + type: p.type, + unitPrice: p.unitPrice, + currency: p.currency, + })); + }), + ); + const defaultCurrency = ctx.settings?.defaultCurrency ?? "TRY"; return ( @@ -55,7 +76,8 @@ export default async function NewJobPage() { İş Bilgileri - Hasta kodu, protez türü ve diğer detayları girin. Dosya yüklemesi sonraki sürümde. + Hasta, ürün, diş seçimi ve detaylar. Fiyat laboratuvarın katalog ve + klinik indiriminize göre otomatik hesaplanır. @@ -64,8 +86,11 @@ export default async function NewJobPage() { patients={patients.map((p) => ({ id: p.$id, code: p.patientCode, - label: `${p.firstName} ${p.lastName}`, + label: + [p.firstName, p.lastName].filter(Boolean).join(" ") || + `Hasta ${p.patientCode}`, }))} + prostheticsByLab={prostheticsByLab} defaultCurrency={defaultCurrency} /> diff --git a/src/app/api/pricing/quote/route.ts b/src/app/api/pricing/quote/route.ts index 6ab1e49..408ea01 100644 --- a/src/app/api/pricing/quote/route.ts +++ b/src/app/api/pricing/quote/route.ts @@ -1,20 +1,16 @@ 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 { calculateJobPriceForProsthetic } from "@/lib/appwrite/pricing"; +import { + DATABASE_ID, + TABLES, + type Connection, + type Prosthetic, +} from "@/lib/appwrite/schema"; import { createAdminClient } from "@/lib/appwrite/server"; import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard"; -const PROSTHETIC_TYPES = new Set([ - "metal_porselen", - "zirkonyum", - "implant_ustu_zirkonyum", - "gecici", - "e_max", - "diger", -]); - export async function POST(request: Request) { let ctx; try { @@ -24,37 +20,40 @@ export async function POST(request: Request) { return NextResponse.json({ ok: false, error: "Yetki yok." }, { status: 401 }); } - let body: { - labTenantId?: string; - prostheticType?: string; - teethCount?: number; - }; + let body: { prostheticId?: 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 prostheticId = String(body.prostheticId ?? "").trim(); const teethCount = Number(body.teethCount); - - if (!labTenantId || !PROSTHETIC_TYPES.has(prostheticType as ProstheticType)) { + if (!prostheticId || !Number.isFinite(teethCount) || teethCount <= 0) { return NextResponse.json({ ok: true, quote: null }); } - if (!Number.isFinite(teethCount) || teethCount <= 0) { + + const { tablesDB } = createAdminClient(); + + let prosthetic: Prosthetic; + try { + const row = await tablesDB.getRow(DATABASE_ID, TABLES.prosthetics, prostheticId); + prosthetic = row as unknown as Prosthetic; + } catch { + return NextResponse.json({ ok: true, quote: null }); + } + if (prosthetic.archived) { 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(); + // lab that owns this product — keeps catalog data private to connected pairs. const connRes = await tablesDB.listRows({ databaseId: DATABASE_ID, tableId: TABLES.connections, queries: [ Query.equal("clinicTenantId", ctx.tenantId), - Query.equal("labTenantId", labTenantId), + Query.equal("labTenantId", prosthetic.tenantId), Query.equal("status", "approved"), Query.limit(1), ], @@ -63,10 +62,9 @@ export async function POST(request: Request) { return NextResponse.json({ ok: true, quote: null }); } - const quote = await calculateJobPrice({ - labTenantId, + const quote = await calculateJobPriceForProsthetic({ + prosthetic, clinicTenantId: ctx.tenantId, - prostheticType: prostheticType as ProstheticType, teethCount, }); return NextResponse.json({ ok: true, quote }); diff --git a/src/lib/appwrite/job-actions.ts b/src/lib/appwrite/job-actions.ts index a00a13a..369f462 100644 --- a/src/lib/appwrite/job-actions.ts +++ b/src/lib/appwrite/job-actions.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { logAudit } from "./audit"; import { syncFinanceForJob } from "./finance-sync"; import { createNotification } from "./notification-helpers"; -import { calculateJobPrice } from "./pricing"; +import { calculateJobPriceForProsthetic } from "./pricing"; import { DATABASE_ID, TABLES, @@ -15,6 +15,7 @@ import { type Job, type JobStep, type Patient, + type Prosthetic, } from "./schema"; import { createAdminClient } from "./server"; import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard"; @@ -43,7 +44,7 @@ function pickFields(formData: FormData) { labTenantId: String(formData.get("labTenantId") ?? "").trim(), patientId: String(formData.get("patientId") ?? "").trim(), patientCode: String(formData.get("patientCode") ?? "").trim(), - prostheticType: String(formData.get("prostheticType") ?? "").trim(), + prostheticId: String(formData.get("prostheticId") ?? "").trim(), teeth: formData.getAll("teeth").map((v) => String(v).trim()).filter(Boolean), color: String(formData.get("color") ?? "").trim(), description: String(formData.get("description") ?? "").trim(), @@ -137,12 +138,43 @@ export async function createJobAction( }; } + // Resolve the chosen catalog product. It must belong to the selected lab + // and still be active — anything else is rejected with a clear error. + let prosthetic: Prosthetic; + try { + const row = await tablesDB.getRow( + DATABASE_ID, + TABLES.prosthetics, + parsed.data.prostheticId, + ); + prosthetic = row as unknown as Prosthetic; + } catch { + return { + ok: false, + error: "Seçilen ürün bulunamadı.", + fieldErrors: { prostheticId: "Ürün bulunamadı." }, + }; + } + if (prosthetic.tenantId !== parsed.data.labTenantId) { + return { + ok: false, + error: "Seçilen ürün bu laboratuvara ait değil.", + fieldErrors: { prostheticId: "Ürün bu lab'a ait değil." }, + }; + } + if (prosthetic.archived) { + return { + ok: false, + error: "Seçilen ürün arşivlenmiş.", + fieldErrors: { prostheticId: "Bu ürün artık aktif değil." }, + }; + } + try { // Server-side price calculation — clinic never sets the price. - const quote = await calculateJobPrice({ - labTenantId: parsed.data.labTenantId, + const quote = await calculateJobPriceForProsthetic({ + prosthetic, clinicTenantId: ctx.tenantId, - prostheticType: parsed.data.prostheticType, teethCount: parsed.data.teeth.length, }); @@ -156,7 +188,8 @@ export async function createJobAction( createdBy: ctx.user.id, patientId: parsed.data.patientId, patientCode, - prostheticType: parsed.data.prostheticType, + prostheticType: prosthetic.type, + prostheticId: prosthetic.$id, memberCount: parsed.data.teeth.length, teeth: parsed.data.teeth, color: parsed.data.color, diff --git a/src/lib/appwrite/pricing.ts b/src/lib/appwrite/pricing.ts index 0694e45..282142c 100644 --- a/src/lib/appwrite/pricing.ts +++ b/src/lib/appwrite/pricing.ts @@ -28,6 +28,87 @@ export type JobPriceQuote = { discountPercent?: number; }; +/** + * Price an explicit catalog row for a clinic. Use this when the clinic picked + * a specific product from the lab catalog — the catalog row is the source of + * truth for the unit price, clinic_pricing only adds an override. + */ +export async function calculateJobPriceForProsthetic(opts: { + prosthetic: Prosthetic; + clinicTenantId: string; + teethCount: number; +}): Promise { + if (opts.teethCount <= 0) return null; + const { tablesDB } = createAdminClient(); + const [pricingRes, labSettingsRes] = await Promise.all([ + tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.clinicPricing, + queries: [ + Query.equal("labTenantId", opts.prosthetic.tenantId), + Query.equal("clinicTenantId", opts.clinicTenantId), + Query.equal("prostheticType", opts.prosthetic.type), + Query.limit(1), + ], + }), + tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.tenantSettings, + queries: [Query.equal("tenantId", opts.prosthetic.tenantId), Query.limit(1)], + }), + ]); + const override = pricingRes.rows[0] as unknown as ClinicPricing | undefined; + const labSettings = labSettingsRes.rows[0] as unknown as TenantSettings | undefined; + const fallbackCurrency = labSettings?.defaultCurrency ?? "TRY"; + const catalogUnitPrice = opts.prosthetic.unitPrice; + const catalogTotal = catalogUnitPrice * opts.teethCount; + + if (override?.customUnitPrice !== undefined && override.customUnitPrice !== null) { + const unit = override.customUnitPrice; + const amount = unit * opts.teethCount; + return { + amount, + currency: override.currency || opts.prosthetic.currency || fallbackCurrency, + source: "clinic_custom", + unitPrice: unit, + catalogUnitPrice, + catalogTotal, + savings: catalogTotal > amount ? catalogTotal - amount : undefined, + teethCount: opts.teethCount, + }; + } + + if ( + override?.discountPercent !== undefined && + override.discountPercent !== null && + override.discountPercent > 0 + ) { + const discounted = catalogUnitPrice * (1 - override.discountPercent / 100); + const amount = discounted * opts.teethCount; + return { + amount, + currency: override.currency || opts.prosthetic.currency || fallbackCurrency, + source: "clinic_discount", + unitPrice: discounted, + catalogUnitPrice, + catalogTotal, + savings: catalogTotal - amount, + discountPercent: override.discountPercent, + teethCount: opts.teethCount, + }; + } + + return { + amount: catalogTotal, + currency: opts.prosthetic.currency || fallbackCurrency, + source: "catalog", + unitPrice: catalogUnitPrice, + catalogUnitPrice, + catalogTotal, + teethCount: opts.teethCount, + }; +} + /** * Resolves the price a clinic should be charged for a job, given the lab's * catalog and any clinic-specific pricing overrides. diff --git a/src/lib/appwrite/schema.ts b/src/lib/appwrite/schema.ts index 638f449..94ad14b 100644 --- a/src/lib/appwrite/schema.ts +++ b/src/lib/appwrite/schema.ts @@ -100,6 +100,7 @@ export interface Job extends Row { patientCode: string; patientId?: string; prostheticType: ProstheticType; + prostheticId?: string; memberCount: number; teeth?: string[]; color?: string; diff --git a/src/lib/validation/job.ts b/src/lib/validation/job.ts index 460d7a5..0559794 100644 --- a/src/lib/validation/job.ts +++ b/src/lib/validation/job.ts @@ -19,7 +19,7 @@ export const createJobSchema = z.object({ .optional() .transform((v) => (v ? v : undefined)), patientCode: z.string().trim().min(1, "Hasta kodu zorunlu.").max(50), - prostheticType: z.enum(PROSTHETIC_TYPES, { message: "Protez türü seçin." }), + prostheticId: z.string().min(1, "Ürün seçin."), teeth: z .array(z.string().regex(FDI_TOOTH, "Geçersiz diş numarası")) .min(1, "En az 1 diş seçin.")