feat(pricing): tooth-based selection, lab-owned pricing, clinic-specific overrides

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.
This commit is contained in:
kovakmedya
2026-05-21 22:04:26 +03:00
parent ee9c0015a5
commit 95f2d065b4
14 changed files with 971 additions and 76 deletions
@@ -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<string, string>;
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<string, ClinicPricing[]>;
defaultCurrency?: string;
}) {
if (rows.length === 0) {
return (
<p className="text-muted-foreground py-6 text-center text-sm">
@@ -43,6 +60,8 @@ export function ConnectionsTable({ rows }: { rows: ConnectionWithCounterpart[] }
);
}
const isLab = selfKind === "lab";
return (
<Table>
<TableHeader>
@@ -50,19 +69,40 @@ export function ConnectionsTable({ rows }: { rows: ConnectionWithCounterpart[] }
<TableHead>Karşı taraf</TableHead>
<TableHead>Tür</TableHead>
<TableHead>Onay tarihi</TableHead>
<TableHead>{isLab ? "Fiyat kuralları" : "Sizin için kurallar"}</TableHead>
<TableHead className="text-right">İşlem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<ApprovedRow key={r.$id} row={r} />
))}
{rows.map((r) => {
const counterpartId = isLab ? r.clinicTenantId : r.labTenantId;
const pricing = pricingByCounterpart[counterpartId] ?? [];
return (
<ApprovedRow
key={r.$id}
row={r}
isLab={isLab}
pricing={pricing}
defaultCurrency={defaultCurrency}
/>
);
})}
</TableBody>
</Table>
);
}
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 (
<TableRow>
<TableCell className="font-medium">{row.counterpart?.companyName ?? "—"}</TableCell>
@@ -95,7 +137,38 @@ function ApprovedRow({ row }: { row: ConnectionWithCounterpart }) {
<TableCell className="text-muted-foreground">
{row.approvedAt ? dateFormatter.format(new Date(row.approvedAt)) : "—"}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{pricing.length === 0 ? (
<span>Katalog fiyatı</span>
) : (
<ul className="space-y-0.5">
{pricing.slice(0, 3).map((p) => (
<li key={p.$id}>
<span className="text-foreground">
{TYPE_LABELS[p.prostheticType] ?? p.prostheticType}
</span>
{": "}
{p.customUnitPrice !== undefined && p.customUnitPrice !== null
? `${p.customUnitPrice} ${p.currency || defaultCurrency}`
: p.discountPercent
? `%${p.discountPercent} indirim`
: "—"}
</li>
))}
{pricing.length > 3 && <li>+ {pricing.length - 3} kural</li>}
</ul>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{isLab && (
<ClinicPricingDialog
clinicTenantId={counterpartId}
clinicName={row.counterpart?.companyName ?? "Klinik"}
rows={pricing}
defaultCurrency={defaultCurrency}
/>
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
@@ -129,6 +202,7 @@ function ApprovedRow({ row }: { row: ConnectionWithCounterpart }) {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</TableCell>
</TableRow>
);