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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user