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
+200
View File
@@ -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<string, string> {
const out: Record<string, string> = {};
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<ClinicPricingFormState> {
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<ClinicPricingActionState> {
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 };
}
@@ -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<ClinicPricing[]> {
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<ClinicPricing[]> {
return listClinicPricing(labTenantId, clinicTenantId);
}
+14
View File
@@ -0,0 +1,14 @@
export type ClinicPricingFormState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string>;
};
export const initialClinicPricingFormState: ClinicPricingFormState = { ok: false };
export type ClinicPricingActionState = {
ok: boolean;
error?: string;
};
export const initialClinicPricingActionState: ClinicPricingActionState = { ok: false };
+14 -6
View File
@@ -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",
},
+114
View File
@@ -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<JobPriceQuote | null> {
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,
};
}
+12
View File
@@ -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 {