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 };
}