From 95f2d065b40b7a5cdb7c08f7dd8204bcc516a3f4 Mon Sep 17 00:00:00 2001 From: kovakmedya Date: Thu, 21 May 2026 22:04:26 +0300 Subject: [PATCH] feat(pricing): tooth-based selection, lab-owned pricing, clinic-specific overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What changed - jobs.teeth (FDI string[]). memberCount becomes a derived field (teeth.length). A new TeethChart component renders the full permanent dentition as a 16-column grid for each arch with click-toggle selection. - /jobs/new: removed the price + currency inputs and the manual memberCount field. Clinics now pick teeth via the chart; the form blocks submission until at least one tooth is selected. - createJobAction calls a new calculateJobPrice() helper that walks the pricing cascade and writes price + currency on the job server-side. A clinic-supplied price hidden field would now be ignored — the field isn't even in the schema. Pricing cascade (calculateJobPrice, lib/appwrite/pricing.ts) 1. clinic_pricing row matching (lab, clinic, type) with customUnitPrice → use that flat unit price. 2. clinic_pricing row with discountPercent → catalog unitPrice × (1-d). 3. lab's prosthetics catalog row matching type (not archived). 4. nothing → price stays null; lab can still set it manually later. Clinic-specific overrides (clinic_pricing table) - Unique on (labTenantId, clinicTenantId, prostheticType) so each combination has at most one rule. - Row permissions: read by both teams (transparency for clinic), write only by lab — clinic can see the discount they're getting but cannot edit it. - setClinicPricingAction validates an approved connection exists before creating/updating, and rejects requests where neither customUnitPrice nor discountPercent is set. - clearClinicPricingAction wipes a rule (catalog price re-applies). UI - /connections 'Bağlantılarım' table gets a new column showing the active pricing rules per counterpart. Lab side has a 'Fiyatlandırma' button that opens a dialog (PROSTHETIC_TYPE × customPrice|discountPercent form + list of active rules with delete). Clinic side is read-only. - Job detail: 'Fiyat' field now shows 'Lab tarafından belirlenecek' when null, instead of a literal —. Adds a 'Dişler' info block listing the selected FDI numbers. --- .../components/clinic-pricing-dialog.tsx | 222 ++++++++++++++++++ .../components/connections-table.tsx | 84 ++++++- src/app/(dashboard)/connections/page.tsx | 43 +++- src/app/(dashboard)/jobs/[jobId]/page.tsx | 12 +- .../jobs/new/components/new-job-form.tsx | 53 +---- src/components/teeth-chart.tsx | 148 ++++++++++++ src/lib/appwrite/clinic-pricing-actions.ts | 200 ++++++++++++++++ src/lib/appwrite/clinic-pricing-queries.ts | 31 +++ src/lib/appwrite/clinic-pricing-types.ts | 14 ++ src/lib/appwrite/job-actions.ts | 20 +- src/lib/appwrite/pricing.ts | 114 +++++++++ src/lib/appwrite/schema.ts | 12 + src/lib/validation/clinic-pricing.ts | 66 ++++++ src/lib/validation/job.ts | 28 +-- 14 files changed, 971 insertions(+), 76 deletions(-) create mode 100644 src/app/(dashboard)/connections/components/clinic-pricing-dialog.tsx create mode 100644 src/components/teeth-chart.tsx create mode 100644 src/lib/appwrite/clinic-pricing-actions.ts create mode 100644 src/lib/appwrite/clinic-pricing-queries.ts create mode 100644 src/lib/appwrite/clinic-pricing-types.ts create mode 100644 src/lib/appwrite/pricing.ts create mode 100644 src/lib/validation/clinic-pricing.ts diff --git a/src/app/(dashboard)/connections/components/clinic-pricing-dialog.tsx b/src/app/(dashboard)/connections/components/clinic-pricing-dialog.tsx new file mode 100644 index 0000000..e9f4ab2 --- /dev/null +++ b/src/app/(dashboard)/connections/components/clinic-pricing-dialog.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useActionState, useEffect, useState } from "react"; +import { DollarSign, Loader2, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + clearClinicPricingAction, + setClinicPricingAction, +} from "@/lib/appwrite/clinic-pricing-actions"; +import { + initialClinicPricingActionState, + initialClinicPricingFormState, +} from "@/lib/appwrite/clinic-pricing-types"; +import { PROSTHETIC_TYPE_OPTIONS } from "@/lib/appwrite/prosthetic-types"; +import type { ClinicPricing, ProstheticType } from "@/lib/appwrite/schema"; + +const TYPE_LABELS = Object.fromEntries( + PROSTHETIC_TYPE_OPTIONS.map((o) => [o.value, o.label]), +) as Record; + +export function ClinicPricingDialog({ + clinicTenantId, + clinicName, + rows, + defaultCurrency, +}: { + clinicTenantId: string; + clinicName: string; + rows: ClinicPricing[]; + defaultCurrency: string; +}) { + const [open, setOpen] = useState(false); + + return ( + + + + + {clinicName} — Fiyatlandırma + + Protez türü bazında özel fiyat veya yüzdelik indirim tanımlayın. + Boş bırakırsanız katalog fiyatı uygulanır. + + + + + + {rows.length > 0 && ( +
+

+ Aktif kurallar +

+
    + {rows.map((r) => ( + + ))} +
+
+ )} + + + + + + +
+
+ ); +} + +function PricingForm({ + clinicTenantId, + defaultCurrency, +}: { + clinicTenantId: string; + defaultCurrency: string; +}) { + const [state, action, pending] = useActionState( + setClinicPricingAction, + initialClinicPricingFormState, + ); + + useEffect(() => { + if (state.ok) toast.success("Fiyatlandırma kaydedildi."); + else if (state.error) toast.error(state.error); + }, [state]); + + return ( +
+ +
+ + +
+
+
+ + + {state.fieldErrors?.customUnitPrice && ( +

{state.fieldErrors.customUnitPrice}

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

{state.fieldErrors.discountPercent}

+ )} +
+
+ + +
+
+

+ Özel fiyat girilirse katalogdan bağımsız uygulanır. Sadece indirim verirseniz katalog fiyatından oran düşülür. +

+ +
+ ); +} + +function PricingRow({ row }: { row: ClinicPricing }) { + const [state, action, pending] = useActionState( + clearClinicPricingAction, + initialClinicPricingActionState, + ); + + useEffect(() => { + if (state.ok) toast.success("Kural silindi."); + else if (state.error) toast.error(state.error); + }, [state]); + + return ( +
  • + + {TYPE_LABELS[row.prostheticType] ?? row.prostheticType} + + + {row.customUnitPrice !== undefined && row.customUnitPrice !== null ? ( + <>Özel fiyat: {row.customUnitPrice} {row.currency || "TRY"} + ) : row.discountPercent ? ( + <>İndirim: %{row.discountPercent} + ) : ( + "—" + )} + +
    + + +
    +
  • + ); +} + +type ProstheticTypeLabel = ProstheticType; +export type { ProstheticTypeLabel }; diff --git a/src/app/(dashboard)/connections/components/connections-table.tsx b/src/app/(dashboard)/connections/components/connections-table.tsx index e00ff49..44be4c8 100644 --- a/src/app/(dashboard)/connections/components/connections-table.tsx +++ b/src/app/(dashboard)/connections/components/connections-table.tsx @@ -27,6 +27,13 @@ import { import { deleteConnectionAction } from "@/lib/appwrite/connection-actions"; import { initialConnectionActionState } from "@/lib/appwrite/connection-types"; import type { ConnectionWithCounterpart } from "@/lib/appwrite/connection-queries"; +import type { ClinicPricing, TenantKind } from "@/lib/appwrite/schema"; +import { PROSTHETIC_TYPE_OPTIONS } from "@/lib/appwrite/prosthetic-types"; +import { ClinicPricingDialog } from "./clinic-pricing-dialog"; + +const TYPE_LABELS = Object.fromEntries( + PROSTHETIC_TYPE_OPTIONS.map((o) => [o.value, o.label]), +) as Record; const dateFormatter = new Intl.DateTimeFormat("tr-TR", { day: "2-digit", @@ -34,7 +41,17 @@ const dateFormatter = new Intl.DateTimeFormat("tr-TR", { year: "numeric", }); -export function ConnectionsTable({ rows }: { rows: ConnectionWithCounterpart[] }) { +export function ConnectionsTable({ + rows, + selfKind, + pricingByCounterpart = {}, + defaultCurrency = "TRY", +}: { + rows: ConnectionWithCounterpart[]; + selfKind: TenantKind | null; + pricingByCounterpart?: Record; + defaultCurrency?: string; +}) { if (rows.length === 0) { return (

    @@ -43,6 +60,8 @@ export function ConnectionsTable({ rows }: { rows: ConnectionWithCounterpart[] } ); } + const isLab = selfKind === "lab"; + return ( @@ -50,19 +69,40 @@ export function ConnectionsTable({ rows }: { rows: ConnectionWithCounterpart[] } Karşı taraf Tür Onay tarihi + {isLab ? "Fiyat kuralları" : "Sizin için kurallar"} İşlem - {rows.map((r) => ( - - ))} + {rows.map((r) => { + const counterpartId = isLab ? r.clinicTenantId : r.labTenantId; + const pricing = pricingByCounterpart[counterpartId] ?? []; + return ( + + ); + })}
    ); } -function ApprovedRow({ row }: { row: ConnectionWithCounterpart }) { +function ApprovedRow({ + row, + isLab, + pricing, + defaultCurrency, +}: { + row: ConnectionWithCounterpart; + isLab: boolean; + pricing: ClinicPricing[]; + defaultCurrency: string; +}) { const [state, action, pending] = useActionState( deleteConnectionAction, initialConnectionActionState, @@ -86,6 +126,8 @@ function ApprovedRow({ row }: { row: ConnectionWithCounterpart }) { ? "Klinik" : "—"; + const counterpartId = isLab ? row.clinicTenantId : row.labTenantId; + return ( {row.counterpart?.companyName ?? "—"} @@ -95,7 +137,38 @@ function ApprovedRow({ row }: { row: ConnectionWithCounterpart }) { {row.approvedAt ? dateFormatter.format(new Date(row.approvedAt)) : "—"} + + {pricing.length === 0 ? ( + Katalog fiyatı + ) : ( +

      + {pricing.slice(0, 3).map((p) => ( +
    • + + {TYPE_LABELS[p.prostheticType] ?? p.prostheticType} + + {": "} + {p.customUnitPrice !== undefined && p.customUnitPrice !== null + ? `${p.customUnitPrice} ${p.currency || defaultCurrency}` + : p.discountPercent + ? `%${p.discountPercent} indirim` + : "—"} +
    • + ))} + {pricing.length > 3 &&
    • + {pricing.length - 3} kural
    • } +
    + )} + +
    + {isLab && ( + + )} +
    ); diff --git a/src/app/(dashboard)/connections/page.tsx b/src/app/(dashboard)/connections/page.tsx index bfd22ca..6c1cf2c 100644 --- a/src/app/(dashboard)/connections/page.tsx +++ b/src/app/(dashboard)/connections/page.tsx @@ -1,11 +1,13 @@ import { redirect } from "next/navigation"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { listClinicPricing } from "@/lib/appwrite/clinic-pricing-queries"; import { listApprovedConnections, listPendingInbound, listPendingOutbound, } from "@/lib/appwrite/connection-queries"; +import type { ClinicPricing } from "@/lib/appwrite/schema"; import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { ConnectionCodeCard } from "./components/connection-code-card"; import { ConnectionRequestForm } from "./components/connection-request-form"; @@ -35,6 +37,35 @@ export default async function ConnectionsPage() { listPendingOutbound(ctx.tenantId, ctx.user.id), ]); + // Lab side: load pricing rules per approved clinic so the dialog can + // surface existing rules. Clinic side leaves this empty — it's display-only + // for them (see ConnectionsTable). + const pricingByCounterpart = new Map(); + if (isLab) { + const pricingLists = await Promise.all( + approved.map((c) => + listClinicPricing(ctx.tenantId, c.clinicTenantId).then( + (rows) => [c.clinicTenantId, rows] as const, + ), + ), + ); + for (const [id, rows] of pricingLists) pricingByCounterpart.set(id, rows); + } else { + // Clinic side: only its own pricing rules from each lab, read-only + const pricingLists = await Promise.all( + approved.map((c) => + listClinicPricing(c.labTenantId, ctx.tenantId).then( + (rows) => [c.labTenantId, rows] as const, + ), + ), + ); + for (const [id, rows] of pricingLists) pricingByCounterpart.set(id, rows); + } + const pricingObject: Record = Object.fromEntries( + pricingByCounterpart, + ); + const defaultCurrency = ctx.settings?.defaultCurrency ?? "TRY"; + return (
    @@ -90,10 +121,18 @@ export default async function ConnectionsPage() { Bağlantılarım - Onaylanmış aktif bağlantılar. + + Onaylanmış aktif bağlantılar. + {isLab && " Lab tarafı her klinik için özel fiyat/indirim tanımlayabilir."} + - +
    diff --git a/src/app/(dashboard)/jobs/[jobId]/page.tsx b/src/app/(dashboard)/jobs/[jobId]/page.tsx index 26144b5..d7cd44c 100644 --- a/src/app/(dashboard)/jobs/[jobId]/page.tsx +++ b/src/app/(dashboard)/jobs/[jobId]/page.tsx @@ -133,11 +133,21 @@ export default async function JobDetailPage({ {typeof job.price === "number" ? formatMoney(job.price, job.currency || "TRY") - : "—"} + : Lab tarafından belirlenecek} {job.currentStep ? JOB_STEP_LABELS[job.currentStep] : "—"} +
    +

    + Dişler ({job.teeth?.length ?? job.memberCount}) +

    +

    + {job.teeth && job.teeth.length > 0 + ? job.teeth.join(", ") + : `${job.memberCount} üye (diş listesi yok)`} +

    +

    Açıklama 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 4890f48..8814fcd 100644 --- a/src/app/(dashboard)/jobs/new/components/new-job-form.tsx +++ b/src/app/(dashboard)/jobs/new/components/new-job-form.tsx @@ -16,6 +16,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { TeethChart } from "@/components/teeth-chart"; import { Textarea } from "@/components/ui/textarea"; import { createJobAction } from "@/lib/appwrite/job-actions"; import { @@ -41,7 +42,7 @@ type PatientOption = { id: string; code: string; label: string }; export function NewJobForm({ labs, patients, - defaultCurrency, + defaultCurrency: _defaultCurrency, }: { labs: JobCounterpart[]; patients: PatientOption[]; @@ -52,6 +53,7 @@ export function NewJobForm({ const [patientId, setPatientId] = useState( patients.length > 0 ? patients[0].id : NONE_PATIENT, ); + const [teeth, setTeeth] = useState([]); const patientById = useMemo( () => new Map(patients.map((p) => [p.id, p])), @@ -152,22 +154,6 @@ export function NewJobForm({ )}

    -
    - - - {state.fieldErrors?.memberCount && ( -

    {state.fieldErrors.memberCount}

    - )} -
    -
    -
    -
    - - -
    -
    - - -
    +
    + + + {state.fieldErrors?.teeth && ( +

    {state.fieldErrors.teeth}

    + )} +

    + Fiyat laboratuvarın katalog ve klinik indirimine göre otomatik hesaplanır — siz girmiyorsunuz. +

    @@ -234,7 +207,7 @@ export function NewJobForm({
    - + )} +
    +
    + ); +} + +function ToothButton({ + tooth, + selected, + onClick, + disabled, +}: { + tooth: string; + selected: boolean; + onClick: () => void; + disabled: boolean; +}) { + return ( + + ); +} diff --git a/src/lib/appwrite/clinic-pricing-actions.ts b/src/lib/appwrite/clinic-pricing-actions.ts new file mode 100644 index 0000000..d8f9c9e --- /dev/null +++ b/src/lib/appwrite/clinic-pricing-actions.ts @@ -0,0 +1,200 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { AppwriteException, ID, Permission, Query, Role } from "node-appwrite"; +import { z } from "zod"; + +import { logAudit } from "./audit"; +import { + DATABASE_ID, + TABLES, + type ClinicPricing, + type Connection, +} from "./schema"; +import { createAdminClient } from "./server"; +import { + requireRole, + requireTenant, + requireTenantKind, +} from "./tenant-guard"; +import type { + ClinicPricingActionState, + ClinicPricingFormState, +} from "./clinic-pricing-types"; +import { clinicPricingSchema } from "@/lib/validation/clinic-pricing"; + +function appwriteError(e: unknown, fallback = "Beklenmeyen bir hata oluştu."): string { + if (e instanceof AppwriteException) return e.message || fallback; + return process.env.NODE_ENV !== "production" && e instanceof Error + ? `${fallback} (${e.message})` + : fallback; +} + +function flattenErrors(err: z.ZodError): Record { + const out: Record = {}; + for (const issue of err.issues) { + const key = issue.path.join("."); + if (key && !out[key]) out[key] = issue.message; + } + return out; +} + +function pricingPermissions(labTenantId: string, clinicTenantId: string): string[] { + return [ + Permission.read(Role.team(labTenantId)), + Permission.read(Role.team(clinicTenantId)), + Permission.update(Role.team(labTenantId, "owner")), + Permission.update(Role.team(labTenantId, "admin")), + Permission.delete(Role.team(labTenantId, "owner")), + Permission.delete(Role.team(labTenantId, "admin")), + ]; +} + +function pickFields(formData: FormData) { + return { + clinicTenantId: String(formData.get("clinicTenantId") ?? "").trim(), + prostheticType: String(formData.get("prostheticType") ?? "").trim(), + customUnitPrice: String(formData.get("customUnitPrice") ?? "").trim(), + discountPercent: String(formData.get("discountPercent") ?? "").trim(), + currency: String(formData.get("currency") ?? "").trim(), + }; +} + +export async function setClinicPricingAction( + _prev: ClinicPricingFormState, + formData: FormData, +): Promise { + let ctx; + try { + ctx = await requireTenant(); + requireRole(ctx, ["owner", "admin"]); + requireTenantKind(ctx, ["lab"]); + } catch { + return { ok: false, error: "Fiyatlandırma yalnızca laboratuvar hesapları için." }; + } + + const parsed = clinicPricingSchema.safeParse(pickFields(formData)); + if (!parsed.success) { + return { ok: false, error: "Form geçersiz.", fieldErrors: flattenErrors(parsed.error) }; + } + + const { tablesDB } = createAdminClient(); + + // Verify the lab actually has an approved connection with this clinic + const connRes = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.connections, + queries: [ + Query.equal("labTenantId", ctx.tenantId), + Query.equal("clinicTenantId", parsed.data.clinicTenantId), + Query.equal("status", "approved"), + Query.limit(1), + ], + }); + if (!(connRes.rows[0] as unknown as Connection | undefined)) { + return { + ok: false, + error: "Onaylı bir bağlantınız yok.", + fieldErrors: { clinicTenantId: "Bağlantı yok." }, + }; + } + + try { + const existing = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.clinicPricing, + queries: [ + Query.equal("labTenantId", ctx.tenantId), + Query.equal("clinicTenantId", parsed.data.clinicTenantId), + Query.equal("prostheticType", parsed.data.prostheticType), + Query.limit(1), + ], + }); + const row = existing.rows[0] as unknown as ClinicPricing | undefined; + + const payload = { + labTenantId: ctx.tenantId, + clinicTenantId: parsed.data.clinicTenantId, + prostheticType: parsed.data.prostheticType, + customUnitPrice: parsed.data.customUnitPrice, + discountPercent: parsed.data.discountPercent, + currency: parsed.data.currency, + createdBy: ctx.user.id, + }; + + if (row) { + await tablesDB.updateRow(DATABASE_ID, TABLES.clinicPricing, row.$id, payload); + await logAudit({ + tenantId: ctx.tenantId, + userId: ctx.user.id, + action: "update", + entityType: "clinic_pricing", + entityId: row.$id, + changes: payload, + }); + } else { + const created = await tablesDB.createRow( + DATABASE_ID, + TABLES.clinicPricing, + ID.unique(), + payload, + pricingPermissions(ctx.tenantId, parsed.data.clinicTenantId), + ); + await logAudit({ + tenantId: ctx.tenantId, + userId: ctx.user.id, + action: "create", + entityType: "clinic_pricing", + entityId: created.$id, + changes: payload, + }); + } + } catch (e) { + return { ok: false, error: appwriteError(e, "Fiyatlandırma kaydedilemedi.") }; + } + + revalidatePath("/connections"); + return { ok: true }; +} + +export async function clearClinicPricingAction( + _prev: ClinicPricingActionState, + formData: FormData, +): Promise { + const id = String(formData.get("id") ?? "").trim(); + if (!id) return { ok: false, error: "Kayıt bulunamadı." }; + + let ctx; + try { + ctx = await requireTenant(); + requireRole(ctx, ["owner", "admin"]); + requireTenantKind(ctx, ["lab"]); + } catch { + return { ok: false, error: "Yetkiniz yok." }; + } + + try { + const { tablesDB } = createAdminClient(); + const row = (await tablesDB.getRow( + DATABASE_ID, + TABLES.clinicPricing, + id, + )) as unknown as ClinicPricing; + if (row.labTenantId !== ctx.tenantId) { + return { ok: false, error: "Yetkiniz yok." }; + } + await tablesDB.deleteRow(DATABASE_ID, TABLES.clinicPricing, id); + await logAudit({ + tenantId: ctx.tenantId, + userId: ctx.user.id, + action: "delete", + entityType: "clinic_pricing", + entityId: id, + }); + } catch (e) { + return { ok: false, error: appwriteError(e, "Silinemedi.") }; + } + + revalidatePath("/connections"); + return { ok: true }; +} diff --git a/src/lib/appwrite/clinic-pricing-queries.ts b/src/lib/appwrite/clinic-pricing-queries.ts new file mode 100644 index 0000000..63e93cc --- /dev/null +++ b/src/lib/appwrite/clinic-pricing-queries.ts @@ -0,0 +1,31 @@ +import "server-only"; + +import { Query } from "node-appwrite"; + +import { DATABASE_ID, TABLES, type ClinicPricing } from "./schema"; +import { createAdminClient } from "./server"; +import { toPlain } from "./serialize"; + +export async function listClinicPricing( + labTenantId: string, + clinicTenantId: string, +): Promise { + const { tablesDB } = createAdminClient(); + const result = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.clinicPricing, + queries: [ + Query.equal("labTenantId", labTenantId), + Query.equal("clinicTenantId", clinicTenantId), + Query.limit(50), + ], + }); + return toPlain(result.rows as unknown as ClinicPricing[]); +} + +export async function listClinicPricingForClinic( + clinicTenantId: string, + labTenantId: string, +): Promise { + return listClinicPricing(labTenantId, clinicTenantId); +} diff --git a/src/lib/appwrite/clinic-pricing-types.ts b/src/lib/appwrite/clinic-pricing-types.ts new file mode 100644 index 0000000..9a1dcc9 --- /dev/null +++ b/src/lib/appwrite/clinic-pricing-types.ts @@ -0,0 +1,14 @@ +export type ClinicPricingFormState = { + ok: boolean; + error?: string; + fieldErrors?: Record; +}; + +export const initialClinicPricingFormState: ClinicPricingFormState = { ok: false }; + +export type ClinicPricingActionState = { + ok: boolean; + error?: string; +}; + +export const initialClinicPricingActionState: ClinicPricingActionState = { ok: false }; diff --git a/src/lib/appwrite/job-actions.ts b/src/lib/appwrite/job-actions.ts index 1086190..a00a13a 100644 --- a/src/lib/appwrite/job-actions.ts +++ b/src/lib/appwrite/job-actions.ts @@ -7,6 +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 { DATABASE_ID, TABLES, @@ -43,11 +44,9 @@ function pickFields(formData: FormData) { patientId: String(formData.get("patientId") ?? "").trim(), patientCode: String(formData.get("patientCode") ?? "").trim(), prostheticType: String(formData.get("prostheticType") ?? "").trim(), - memberCount: String(formData.get("memberCount") ?? ""), + teeth: formData.getAll("teeth").map((v) => String(v).trim()).filter(Boolean), color: String(formData.get("color") ?? "").trim(), description: String(formData.get("description") ?? "").trim(), - price: String(formData.get("price") ?? "").trim(), - currency: String(formData.get("currency") ?? "").trim(), dueDate: String(formData.get("dueDate") ?? "").trim(), }; } @@ -139,6 +138,14 @@ export async function createJobAction( } try { + // Server-side price calculation — clinic never sets the price. + const quote = await calculateJobPrice({ + labTenantId: parsed.data.labTenantId, + clinicTenantId: ctx.tenantId, + prostheticType: parsed.data.prostheticType, + teethCount: parsed.data.teeth.length, + }); + const created = await tablesDB.createRow( DATABASE_ID, TABLES.jobs, @@ -150,11 +157,12 @@ export async function createJobAction( patientId: parsed.data.patientId, patientCode, prostheticType: parsed.data.prostheticType, - memberCount: parsed.data.memberCount, + memberCount: parsed.data.teeth.length, + teeth: parsed.data.teeth, color: parsed.data.color, description: parsed.data.description, - price: parsed.data.price, - currency: parsed.data.currency, + price: quote?.amount, + currency: quote?.currency, dueDate: parsed.data.dueDate, status: "pending", }, diff --git a/src/lib/appwrite/pricing.ts b/src/lib/appwrite/pricing.ts new file mode 100644 index 0000000..2892990 --- /dev/null +++ b/src/lib/appwrite/pricing.ts @@ -0,0 +1,114 @@ +import "server-only"; + +import { Query } from "node-appwrite"; + +import { + DATABASE_ID, + TABLES, + type ClinicPricing, + type Prosthetic, + type ProstheticType, + type TenantSettings, +} from "./schema"; +import { createAdminClient } from "./server"; + +export type JobPriceQuote = { + amount: number; + currency: string; + source: "clinic_custom" | "clinic_discount" | "catalog"; + unitPrice: number; + discountPercent?: number; +}; + +/** + * Resolves the price a clinic should be charged for a job, given the lab's + * catalog and any clinic-specific pricing overrides. + * + * Cascade (first match wins): + * 1. clinic_pricing row for (lab, clinic, type) with customUnitPrice → use it + * 2. clinic_pricing row with discountPercent → catalog unitPrice × (1 - d/100) + * 3. lab's prosthetics catalog row matching `type` (not archived) + * 4. nothing → returns null, the lab will need to set the price manually + * + * Currency precedence: clinic override → catalog → lab tenant default → "TRY". + * + * @param teethCount multiplied with the resolved unit price + */ +export async function calculateJobPrice(opts: { + labTenantId: string; + clinicTenantId: string; + prostheticType: ProstheticType; + teethCount: number; +}): Promise { + if (opts.teethCount <= 0) return null; + const { tablesDB } = createAdminClient(); + + const [pricingRes, catalogRes, labSettingsRes] = await Promise.all([ + tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.clinicPricing, + queries: [ + Query.equal("labTenantId", opts.labTenantId), + Query.equal("clinicTenantId", opts.clinicTenantId), + Query.equal("prostheticType", opts.prostheticType), + Query.limit(1), + ], + }), + tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.prosthetics, + queries: [ + Query.equal("tenantId", opts.labTenantId), + Query.equal("type", opts.prostheticType), + Query.notEqual("archived", true), + Query.orderAsc("$createdAt"), + Query.limit(1), + ], + }), + tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.tenantSettings, + queries: [Query.equal("tenantId", opts.labTenantId), Query.limit(1)], + }), + ]); + + const override = pricingRes.rows[0] as unknown as ClinicPricing | undefined; + const catalog = catalogRes.rows[0] as unknown as Prosthetic | undefined; + const labSettings = labSettingsRes.rows[0] as unknown as TenantSettings | undefined; + + const fallbackCurrency = labSettings?.defaultCurrency ?? "TRY"; + + if (override?.customUnitPrice !== undefined && override.customUnitPrice !== null) { + const unit = override.customUnitPrice; + return { + amount: unit * opts.teethCount, + currency: override.currency || catalog?.currency || fallbackCurrency, + source: "clinic_custom", + unitPrice: unit, + }; + } + + if (!catalog) return null; + + if ( + override?.discountPercent !== undefined && + override.discountPercent !== null && + override.discountPercent > 0 + ) { + const discounted = catalog.unitPrice * (1 - override.discountPercent / 100); + return { + amount: discounted * opts.teethCount, + currency: override.currency || catalog.currency || fallbackCurrency, + source: "clinic_discount", + unitPrice: discounted, + discountPercent: override.discountPercent, + }; + } + + return { + amount: catalog.unitPrice * opts.teethCount, + currency: catalog.currency || fallbackCurrency, + source: "catalog", + unitPrice: catalog.unitPrice, + }; +} diff --git a/src/lib/appwrite/schema.ts b/src/lib/appwrite/schema.ts index 7a1fb23..1ad33b8 100644 --- a/src/lib/appwrite/schema.ts +++ b/src/lib/appwrite/schema.ts @@ -10,6 +10,7 @@ export const TABLES = { profiles: "profiles", connections: "connections", patients: "patients", + clinicPricing: "clinic_pricing", jobs: "jobs", jobFiles: "job_files", jobStatusHistory: "job_status_history", @@ -102,6 +103,7 @@ export interface Job extends Row { patientId?: string; prostheticType: ProstheticType; memberCount: number; + teeth?: string[]; color?: string; description?: string; price?: number; @@ -111,6 +113,16 @@ export interface Job extends Row { dueDate?: string; } +export interface ClinicPricing extends Row { + labTenantId: string; + clinicTenantId: string; + prostheticType: ProstheticType; + customUnitPrice?: number; + discountPercent?: number; + currency?: string; + createdBy: string; +} + export type JobFileKind = "scan" | "image" | "document"; export interface JobFile extends Row { diff --git a/src/lib/validation/clinic-pricing.ts b/src/lib/validation/clinic-pricing.ts new file mode 100644 index 0000000..733ad2c --- /dev/null +++ b/src/lib/validation/clinic-pricing.ts @@ -0,0 +1,66 @@ +import { z } from "zod"; + +const PROSTHETIC_TYPES = [ + "metal_porselen", + "zirkonyum", + "implant_ustu_zirkonyum", + "gecici", + "e_max", + "diger", +] as const; + +function toOptionalNumber(v: unknown): number | undefined { + if (v === undefined || v === null || v === "") return undefined; + const n = typeof v === "number" ? v : Number(String(v).replace(",", ".")); + return Number.isFinite(n) ? n : undefined; +} + +export const clinicPricingSchema = z + .object({ + clinicTenantId: z.string().min(1, "Klinik seçin."), + prostheticType: z.enum(PROSTHETIC_TYPES, { message: "Protez türü seçin." }), + customUnitPrice: z + .union([z.string(), z.number(), z.literal("")]) + .optional() + .transform(toOptionalNumber), + discountPercent: z + .union([z.string(), z.number(), z.literal("")]) + .optional() + .transform(toOptionalNumber), + currency: z + .string() + .trim() + .max(8) + .optional() + .transform((v) => (v ? v.toUpperCase() : undefined)), + }) + .superRefine((data, ctx) => { + if ( + data.customUnitPrice === undefined && + data.discountPercent === undefined + ) { + ctx.addIssue({ + code: "custom", + path: ["customUnitPrice"], + message: "Özel fiyat veya indirim oranı girin.", + }); + } + if (data.discountPercent !== undefined) { + if (data.discountPercent < 0 || data.discountPercent > 100) { + ctx.addIssue({ + code: "custom", + path: ["discountPercent"], + message: "İndirim 0-100 arası olmalı.", + }); + } + } + if (data.customUnitPrice !== undefined && data.customUnitPrice < 0) { + ctx.addIssue({ + code: "custom", + path: ["customUnitPrice"], + message: "Negatif fiyat olamaz.", + }); + } + }); + +export type ClinicPricingInput = z.infer; diff --git a/src/lib/validation/job.ts b/src/lib/validation/job.ts index 5a281c0..460d7a5 100644 --- a/src/lib/validation/job.ts +++ b/src/lib/validation/job.ts @@ -9,6 +9,8 @@ const PROSTHETIC_TYPES = [ "diger", ] as const; +const FDI_TOOTH = /^(1[1-8]|2[1-8]|3[1-8]|4[1-8])$/; + export const createJobSchema = z.object({ labTenantId: z.string().min(1, "Laboratuvar seçin."), patientId: z @@ -18,14 +20,10 @@ export const createJobSchema = z.object({ .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." }), - memberCount: z - .union([z.string(), z.number()]) - .transform((v) => { - if (typeof v === "number") return v; - const n = parseInt(v, 10); - return Number.isFinite(n) ? n : NaN; - }) - .pipe(z.number().int().min(1, "En az 1 üye.").max(32, "En fazla 32 üye.")), + teeth: z + .array(z.string().regex(FDI_TOOTH, "Geçersiz diş numarası")) + .min(1, "En az 1 diş seçin.") + .max(32, "En fazla 32 diş seçilebilir."), color: z .string() .trim() @@ -38,20 +36,6 @@ export const createJobSchema = z.object({ .max(2000) .optional() .transform((v) => (v ? v : undefined)), - price: z - .union([z.string(), z.number()]) - .optional() - .transform((v) => { - if (v === undefined || v === "") return undefined; - const n = typeof v === "number" ? v : Number(String(v).replace(",", ".")); - return Number.isFinite(n) ? n : undefined; - }), - currency: z - .string() - .trim() - .max(8) - .optional() - .transform((v) => (v ? v.toUpperCase() : "TRY")), dueDate: z .string() .trim()