feat(jobs/new): clinic picks a lab catalog product, not a raw type
What the clinic sees has changed from a fixed 6-item Protez Türü dropdown
to the actual products that lab has published in its catalog. Picking 'Premium
Zirkonyum (1500 TRY / diş)' is now an option; picking the generic
'Zirkonyum' bucket is not.
Wiring
- DB: jobs.prostheticId string column (optional — legacy jobs stay valid).
The denormalised prostheticType is still written, sourced server-side
from the chosen catalog row, so reports/aggregates keep working.
- validation/job.ts: prostheticId required, prostheticType removed from
the form payload (computed instead).
- job-actions.ts: looks up the catalog row, verifies tenantId match +
not archived, then pulls .type and .unitPrice from it to drive
calculateJobPriceForProsthetic.
Pricing helper
- New calculateJobPriceForProsthetic(prosthetic, clinicTenantId,
teethCount). Reuses the same clinic_pricing cascade
(custom price wins, otherwise discountPercent off catalog) but skips
the type-based catalog lookup entirely — it already has the row.
- /api/pricing/quote rewritten to accept { prostheticId, teethCount },
still gated on an approved connection between clinic and the lab that
owns the product.
UI
- jobs/new page server-loads each connected lab's active prosthetics
once and hands them to NewJobForm as prostheticsByLab.
- NewJobForm: 'Ürün *' Select replaces 'Protez Türü *'. The list filters
by the currently selected lab; switching labs clears the chosen
product. Each option shows name + 'unitPrice / diş' on the right.
Once selected, a small caption surfaces the underlying category
label ('Kategori: Zirkonyum') so the clinic still understands what
bucket the product falls into.
- PriceQuoteCard's hasInputs gating moved off prostheticType to
prostheticId.
This commit is contained in:
@@ -7,7 +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 { calculateJobPriceForProsthetic } from "./pricing";
|
||||
import {
|
||||
DATABASE_ID,
|
||||
TABLES,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type Job,
|
||||
type JobStep,
|
||||
type Patient,
|
||||
type Prosthetic,
|
||||
} from "./schema";
|
||||
import { createAdminClient } from "./server";
|
||||
import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard";
|
||||
@@ -43,7 +44,7 @@ function pickFields(formData: FormData) {
|
||||
labTenantId: String(formData.get("labTenantId") ?? "").trim(),
|
||||
patientId: String(formData.get("patientId") ?? "").trim(),
|
||||
patientCode: String(formData.get("patientCode") ?? "").trim(),
|
||||
prostheticType: String(formData.get("prostheticType") ?? "").trim(),
|
||||
prostheticId: String(formData.get("prostheticId") ?? "").trim(),
|
||||
teeth: formData.getAll("teeth").map((v) => String(v).trim()).filter(Boolean),
|
||||
color: String(formData.get("color") ?? "").trim(),
|
||||
description: String(formData.get("description") ?? "").trim(),
|
||||
@@ -137,12 +138,43 @@ export async function createJobAction(
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve the chosen catalog product. It must belong to the selected lab
|
||||
// and still be active — anything else is rejected with a clear error.
|
||||
let prosthetic: Prosthetic;
|
||||
try {
|
||||
const row = await tablesDB.getRow(
|
||||
DATABASE_ID,
|
||||
TABLES.prosthetics,
|
||||
parsed.data.prostheticId,
|
||||
);
|
||||
prosthetic = row as unknown as Prosthetic;
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Seçilen ürün bulunamadı.",
|
||||
fieldErrors: { prostheticId: "Ürün bulunamadı." },
|
||||
};
|
||||
}
|
||||
if (prosthetic.tenantId !== parsed.data.labTenantId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Seçilen ürün bu laboratuvara ait değil.",
|
||||
fieldErrors: { prostheticId: "Ürün bu lab'a ait değil." },
|
||||
};
|
||||
}
|
||||
if (prosthetic.archived) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Seçilen ürün arşivlenmiş.",
|
||||
fieldErrors: { prostheticId: "Bu ürün artık aktif değil." },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Server-side price calculation — clinic never sets the price.
|
||||
const quote = await calculateJobPrice({
|
||||
labTenantId: parsed.data.labTenantId,
|
||||
const quote = await calculateJobPriceForProsthetic({
|
||||
prosthetic,
|
||||
clinicTenantId: ctx.tenantId,
|
||||
prostheticType: parsed.data.prostheticType,
|
||||
teethCount: parsed.data.teeth.length,
|
||||
});
|
||||
|
||||
@@ -156,7 +188,8 @@ export async function createJobAction(
|
||||
createdBy: ctx.user.id,
|
||||
patientId: parsed.data.patientId,
|
||||
patientCode,
|
||||
prostheticType: parsed.data.prostheticType,
|
||||
prostheticType: prosthetic.type,
|
||||
prostheticId: prosthetic.$id,
|
||||
memberCount: parsed.data.teeth.length,
|
||||
teeth: parsed.data.teeth,
|
||||
color: parsed.data.color,
|
||||
|
||||
@@ -28,6 +28,87 @@ export type JobPriceQuote = {
|
||||
discountPercent?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Price an explicit catalog row for a clinic. Use this when the clinic picked
|
||||
* a specific product from the lab catalog — the catalog row is the source of
|
||||
* truth for the unit price, clinic_pricing only adds an override.
|
||||
*/
|
||||
export async function calculateJobPriceForProsthetic(opts: {
|
||||
prosthetic: Prosthetic;
|
||||
clinicTenantId: string;
|
||||
teethCount: number;
|
||||
}): Promise<JobPriceQuote | null> {
|
||||
if (opts.teethCount <= 0) return null;
|
||||
const { tablesDB } = createAdminClient();
|
||||
const [pricingRes, labSettingsRes] = await Promise.all([
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.clinicPricing,
|
||||
queries: [
|
||||
Query.equal("labTenantId", opts.prosthetic.tenantId),
|
||||
Query.equal("clinicTenantId", opts.clinicTenantId),
|
||||
Query.equal("prostheticType", opts.prosthetic.type),
|
||||
Query.limit(1),
|
||||
],
|
||||
}),
|
||||
tablesDB.listRows({
|
||||
databaseId: DATABASE_ID,
|
||||
tableId: TABLES.tenantSettings,
|
||||
queries: [Query.equal("tenantId", opts.prosthetic.tenantId), Query.limit(1)],
|
||||
}),
|
||||
]);
|
||||
const override = pricingRes.rows[0] as unknown as ClinicPricing | undefined;
|
||||
const labSettings = labSettingsRes.rows[0] as unknown as TenantSettings | undefined;
|
||||
const fallbackCurrency = labSettings?.defaultCurrency ?? "TRY";
|
||||
const catalogUnitPrice = opts.prosthetic.unitPrice;
|
||||
const catalogTotal = catalogUnitPrice * opts.teethCount;
|
||||
|
||||
if (override?.customUnitPrice !== undefined && override.customUnitPrice !== null) {
|
||||
const unit = override.customUnitPrice;
|
||||
const amount = unit * opts.teethCount;
|
||||
return {
|
||||
amount,
|
||||
currency: override.currency || opts.prosthetic.currency || fallbackCurrency,
|
||||
source: "clinic_custom",
|
||||
unitPrice: unit,
|
||||
catalogUnitPrice,
|
||||
catalogTotal,
|
||||
savings: catalogTotal > amount ? catalogTotal - amount : undefined,
|
||||
teethCount: opts.teethCount,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
override?.discountPercent !== undefined &&
|
||||
override.discountPercent !== null &&
|
||||
override.discountPercent > 0
|
||||
) {
|
||||
const discounted = catalogUnitPrice * (1 - override.discountPercent / 100);
|
||||
const amount = discounted * opts.teethCount;
|
||||
return {
|
||||
amount,
|
||||
currency: override.currency || opts.prosthetic.currency || fallbackCurrency,
|
||||
source: "clinic_discount",
|
||||
unitPrice: discounted,
|
||||
catalogUnitPrice,
|
||||
catalogTotal,
|
||||
savings: catalogTotal - amount,
|
||||
discountPercent: override.discountPercent,
|
||||
teethCount: opts.teethCount,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
amount: catalogTotal,
|
||||
currency: opts.prosthetic.currency || fallbackCurrency,
|
||||
source: "catalog",
|
||||
unitPrice: catalogUnitPrice,
|
||||
catalogUnitPrice,
|
||||
catalogTotal,
|
||||
teethCount: opts.teethCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the price a clinic should be charged for a job, given the lab's
|
||||
* catalog and any clinic-specific pricing overrides.
|
||||
|
||||
@@ -100,6 +100,7 @@ export interface Job extends Row {
|
||||
patientCode: string;
|
||||
patientId?: string;
|
||||
prostheticType: ProstheticType;
|
||||
prostheticId?: string;
|
||||
memberCount: number;
|
||||
teeth?: string[];
|
||||
color?: string;
|
||||
|
||||
@@ -19,7 +19,7 @@ export const createJobSchema = z.object({
|
||||
.optional()
|
||||
.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." }),
|
||||
prostheticId: z.string().min(1, "Ürün seçin."),
|
||||
teeth: z
|
||||
.array(z.string().regex(FDI_TOOTH, "Geçersiz diş numarası"))
|
||||
.min(1, "En az 1 diş seçin.")
|
||||
|
||||
Reference in New Issue
Block a user