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:
kovakmedya
2026-05-22 01:01:35 +03:00
parent dfd30ef239
commit 97a6031992
7 changed files with 224 additions and 68 deletions
@@ -28,16 +28,14 @@ import type { ProstheticType } from "@/lib/appwrite/schema";
const NONE_PATIENT = "__none__"; const NONE_PATIENT = "__none__";
const PROSTHETIC_TYPES: ProstheticType[] = [
"metal_porselen",
"zirkonyum",
"implant_ustu_zirkonyum",
"gecici",
"e_max",
"diger",
];
type PatientOption = { id: string; code: string; label: string }; type PatientOption = { id: string; code: string; label: string };
type ProstheticOption = {
id: string;
name: string;
type: string;
unitPrice: number;
currency?: string;
};
type Quote = { type Quote = {
amount: number; amount: number;
@@ -62,10 +60,12 @@ function formatMoney(amount: number, currency: string): string {
export function NewJobForm({ export function NewJobForm({
labs, labs,
patients, patients,
defaultCurrency: _defaultCurrency, prostheticsByLab,
defaultCurrency,
}: { }: {
labs: JobCounterpart[]; labs: JobCounterpart[];
patients: PatientOption[]; patients: PatientOption[];
prostheticsByLab: Record<string, ProstheticOption[]>;
defaultCurrency: string; defaultCurrency: string;
}) { }) {
const router = useRouter(); const router = useRouter();
@@ -75,10 +75,13 @@ export function NewJobForm({
); );
const [teeth, setTeeth] = useState<string[]>([]); const [teeth, setTeeth] = useState<string[]>([]);
const [labTenantId, setLabTenantId] = useState<string>(labs[0]?.tenantId ?? ""); const [labTenantId, setLabTenantId] = useState<string>(labs[0]?.tenantId ?? "");
const [prostheticType, setProstheticType] = useState<ProstheticType | "">(""); const [prostheticId, setProstheticId] = useState<string>("");
const [quote, setQuote] = useState<Quote | null>(null); const [quote, setQuote] = useState<Quote | null>(null);
const [quoteLoading, setQuoteLoading] = useState(false); const [quoteLoading, setQuoteLoading] = useState(false);
const labProsthetics = prostheticsByLab[labTenantId] ?? [];
const selectedProsthetic = labProsthetics.find((p) => p.id === prostheticId);
const patientById = useMemo( const patientById = useMemo(
() => new Map(patients.map((p) => [p.id, p])), () => new Map(patients.map((p) => [p.id, p])),
[patients], [patients],
@@ -94,9 +97,15 @@ export function NewJobForm({
} }
}, [state, router]); }, [state, router]);
// Reset prosthetic selection when the lab changes so we never carry the
// previous lab's catalog ID over.
useEffect(() => {
setProstheticId("");
}, [labTenantId]);
// Live price quote — debounced to a single fetch per 250ms when inputs settle. // Live price quote — debounced to a single fetch per 250ms when inputs settle.
useEffect(() => { useEffect(() => {
if (!labTenantId || !prostheticType || teeth.length === 0) { if (!prostheticId || teeth.length === 0) {
setQuote(null); setQuote(null);
setQuoteLoading(false); setQuoteLoading(false);
return; return;
@@ -108,11 +117,7 @@ export function NewJobForm({
method: "POST", method: "POST",
signal: ctrl.signal, signal: ctrl.signal,
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({ prostheticId, teethCount: teeth.length }),
labTenantId,
prostheticType,
teethCount: teeth.length,
}),
}) })
.then((r) => r.json()) .then((r) => r.json())
.then((d) => { .then((d) => {
@@ -127,7 +132,7 @@ export function NewJobForm({
ctrl.abort(); ctrl.abort();
clearTimeout(timer); clearTimeout(timer);
}; };
}, [labTenantId, prostheticType, teeth.length]); }, [prostheticId, teeth.length]);
return ( return (
<form action={action} className="grid gap-5"> <form action={action} className="grid gap-5">
@@ -215,25 +220,38 @@ export function NewJobForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="prostheticType">Protez *</Label> <Label htmlFor="prostheticId">Ün *</Label>
<input type="hidden" name="prostheticType" value={prostheticType} /> <input type="hidden" name="prostheticId" value={prostheticId} />
<Select <Select value={prostheticId} onValueChange={setProstheticId} disabled={labProsthetics.length === 0}>
value={prostheticType} <SelectTrigger id="prostheticId">
onValueChange={(v) => setProstheticType(v as ProstheticType)} <SelectValue
> placeholder={
<SelectTrigger id="prostheticType"> labProsthetics.length === 0
<SelectValue placeholder="Tür seçin" /> ? "Laboratuvarın aktif ürünü yok"
: "Bir ürün seçin"
}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{PROSTHETIC_TYPES.map((t) => ( {labProsthetics.map((p) => (
<SelectItem key={t} value={t}> <SelectItem key={p.id} value={p.id}>
{PROSTHETIC_TYPE_LABELS[t]} <span className="flex w-full items-center justify-between gap-3">
<span>{p.name}</span>
<span className="text-muted-foreground text-xs tabular-nums">
{p.unitPrice.toLocaleString("tr-TR")} {p.currency || defaultCurrency} / diş
</span>
</span>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{state.fieldErrors?.prostheticType && ( {selectedProsthetic && (
<p className="text-destructive text-xs">{state.fieldErrors.prostheticType}</p> <p className="text-muted-foreground text-xs">
Kategori: {PROSTHETIC_TYPE_LABELS[selectedProsthetic.type as ProstheticType] ?? selectedProsthetic.type}
</p>
)}
{state.fieldErrors?.prostheticId && (
<p className="text-destructive text-xs">{state.fieldErrors.prostheticId}</p>
)} )}
</div> </div>
@@ -259,7 +277,7 @@ export function NewJobForm({
<PriceQuoteCard <PriceQuoteCard
quote={quote} quote={quote}
loading={quoteLoading} loading={quoteLoading}
hasInputs={Boolean(labTenantId && prostheticType && teeth.length > 0)} hasInputs={Boolean(prostheticId && teeth.length > 0)}
/> />
</div> </div>
+27 -2
View File
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { listApprovedLabsForClinic } from "@/lib/appwrite/job-queries"; import { listApprovedLabsForClinic } from "@/lib/appwrite/job-queries";
import { listPatients } from "@/lib/appwrite/patient-queries"; import { listPatients } from "@/lib/appwrite/patient-queries";
import { listActiveProsthetics } from "@/lib/appwrite/prosthetic-queries";
import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard"; import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard";
import { NewJobForm } from "./components/new-job-form"; import { NewJobForm } from "./components/new-job-form";
@@ -25,6 +26,26 @@ export default async function NewJobPage() {
listApprovedLabsForClinic(ctx.tenantId), listApprovedLabsForClinic(ctx.tenantId),
listPatients(ctx.tenantId, { includeArchived: false }), listPatients(ctx.tenantId, { includeArchived: false }),
]); ]);
// Active catalog products for each connected lab — clinic picks one of
// these in the form instead of a free-text prosthetic type.
const prostheticsByLab: Record<
string,
{ id: string; name: string; type: string; unitPrice: number; currency?: string }[]
> = {};
await Promise.all(
labs.map(async (l) => {
const list = await listActiveProsthetics(l.tenantId);
prostheticsByLab[l.tenantId] = list.map((p) => ({
id: p.$id,
name: p.name,
type: p.type,
unitPrice: p.unitPrice,
currency: p.currency,
}));
}),
);
const defaultCurrency = ctx.settings?.defaultCurrency ?? "TRY"; const defaultCurrency = ctx.settings?.defaultCurrency ?? "TRY";
return ( return (
@@ -55,7 +76,8 @@ export default async function NewJobPage() {
<CardHeader> <CardHeader>
<CardTitle>İş Bilgileri</CardTitle> <CardTitle>İş Bilgileri</CardTitle>
<CardDescription> <CardDescription>
Hasta kodu, protez türü ve diğer detayları girin. Dosya yüklemesi sonraki sürümde. Hasta, ürün, diş seçimi ve detaylar. Fiyat laboratuvarın katalog ve
klinik indiriminize göre otomatik hesaplanır.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -64,8 +86,11 @@ export default async function NewJobPage() {
patients={patients.map((p) => ({ patients={patients.map((p) => ({
id: p.$id, id: p.$id,
code: p.patientCode, code: p.patientCode,
label: `${p.firstName} ${p.lastName}`, label:
[p.firstName, p.lastName].filter(Boolean).join(" ") ||
`Hasta ${p.patientCode}`,
}))} }))}
prostheticsByLab={prostheticsByLab}
defaultCurrency={defaultCurrency} defaultCurrency={defaultCurrency}
/> />
</CardContent> </CardContent>
+25 -27
View File
@@ -1,20 +1,16 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { Query } from "node-appwrite"; import { Query } from "node-appwrite";
import { calculateJobPrice } from "@/lib/appwrite/pricing"; import { calculateJobPriceForProsthetic } from "@/lib/appwrite/pricing";
import { DATABASE_ID, TABLES, type Connection, type ProstheticType } from "@/lib/appwrite/schema"; import {
DATABASE_ID,
TABLES,
type Connection,
type Prosthetic,
} from "@/lib/appwrite/schema";
import { createAdminClient } from "@/lib/appwrite/server"; import { createAdminClient } from "@/lib/appwrite/server";
import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard"; import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard";
const PROSTHETIC_TYPES = new Set<ProstheticType>([
"metal_porselen",
"zirkonyum",
"implant_ustu_zirkonyum",
"gecici",
"e_max",
"diger",
]);
export async function POST(request: Request) { export async function POST(request: Request) {
let ctx; let ctx;
try { try {
@@ -24,37 +20,40 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: false, error: "Yetki yok." }, { status: 401 }); return NextResponse.json({ ok: false, error: "Yetki yok." }, { status: 401 });
} }
let body: { let body: { prostheticId?: string; teethCount?: number };
labTenantId?: string;
prostheticType?: string;
teethCount?: number;
};
try { try {
body = await request.json(); body = await request.json();
} catch { } catch {
return NextResponse.json({ ok: false, error: "Geçersiz istek." }, { status: 400 }); return NextResponse.json({ ok: false, error: "Geçersiz istek." }, { status: 400 });
} }
const labTenantId = String(body.labTenantId ?? "").trim(); const prostheticId = String(body.prostheticId ?? "").trim();
const prostheticType = String(body.prostheticType ?? "").trim();
const teethCount = Number(body.teethCount); const teethCount = Number(body.teethCount);
if (!prostheticId || !Number.isFinite(teethCount) || teethCount <= 0) {
if (!labTenantId || !PROSTHETIC_TYPES.has(prostheticType as ProstheticType)) {
return NextResponse.json({ ok: true, quote: null }); return NextResponse.json({ ok: true, quote: null });
} }
if (!Number.isFinite(teethCount) || teethCount <= 0) {
const { tablesDB } = createAdminClient();
let prosthetic: Prosthetic;
try {
const row = await tablesDB.getRow(DATABASE_ID, TABLES.prosthetics, prostheticId);
prosthetic = row as unknown as Prosthetic;
} catch {
return NextResponse.json({ ok: true, quote: null });
}
if (prosthetic.archived) {
return NextResponse.json({ ok: true, quote: null }); return NextResponse.json({ ok: true, quote: null });
} }
// Only return a quote if the clinic actually has an approved bond with the // Only return a quote if the clinic actually has an approved bond with the
// chosen lab — keeps catalog pricing private to connected pairs. // lab that owns this product — keeps catalog data private to connected pairs.
const { tablesDB } = createAdminClient();
const connRes = await tablesDB.listRows({ const connRes = await tablesDB.listRows({
databaseId: DATABASE_ID, databaseId: DATABASE_ID,
tableId: TABLES.connections, tableId: TABLES.connections,
queries: [ queries: [
Query.equal("clinicTenantId", ctx.tenantId), Query.equal("clinicTenantId", ctx.tenantId),
Query.equal("labTenantId", labTenantId), Query.equal("labTenantId", prosthetic.tenantId),
Query.equal("status", "approved"), Query.equal("status", "approved"),
Query.limit(1), Query.limit(1),
], ],
@@ -63,10 +62,9 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true, quote: null }); return NextResponse.json({ ok: true, quote: null });
} }
const quote = await calculateJobPrice({ const quote = await calculateJobPriceForProsthetic({
labTenantId, prosthetic,
clinicTenantId: ctx.tenantId, clinicTenantId: ctx.tenantId,
prostheticType: prostheticType as ProstheticType,
teethCount, teethCount,
}); });
return NextResponse.json({ ok: true, quote }); return NextResponse.json({ ok: true, quote });
+39 -6
View File
@@ -7,7 +7,7 @@ import { z } from "zod";
import { logAudit } from "./audit"; import { logAudit } from "./audit";
import { syncFinanceForJob } from "./finance-sync"; import { syncFinanceForJob } from "./finance-sync";
import { createNotification } from "./notification-helpers"; import { createNotification } from "./notification-helpers";
import { calculateJobPrice } from "./pricing"; import { calculateJobPriceForProsthetic } from "./pricing";
import { import {
DATABASE_ID, DATABASE_ID,
TABLES, TABLES,
@@ -15,6 +15,7 @@ import {
type Job, type Job,
type JobStep, type JobStep,
type Patient, type Patient,
type Prosthetic,
} from "./schema"; } from "./schema";
import { createAdminClient } from "./server"; import { createAdminClient } from "./server";
import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard"; import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard";
@@ -43,7 +44,7 @@ function pickFields(formData: FormData) {
labTenantId: String(formData.get("labTenantId") ?? "").trim(), labTenantId: String(formData.get("labTenantId") ?? "").trim(),
patientId: String(formData.get("patientId") ?? "").trim(), patientId: String(formData.get("patientId") ?? "").trim(),
patientCode: String(formData.get("patientCode") ?? "").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), teeth: formData.getAll("teeth").map((v) => String(v).trim()).filter(Boolean),
color: String(formData.get("color") ?? "").trim(), color: String(formData.get("color") ?? "").trim(),
description: String(formData.get("description") ?? "").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 { try {
// Server-side price calculation — clinic never sets the price. // Server-side price calculation — clinic never sets the price.
const quote = await calculateJobPrice({ const quote = await calculateJobPriceForProsthetic({
labTenantId: parsed.data.labTenantId, prosthetic,
clinicTenantId: ctx.tenantId, clinicTenantId: ctx.tenantId,
prostheticType: parsed.data.prostheticType,
teethCount: parsed.data.teeth.length, teethCount: parsed.data.teeth.length,
}); });
@@ -156,7 +188,8 @@ export async function createJobAction(
createdBy: ctx.user.id, createdBy: ctx.user.id,
patientId: parsed.data.patientId, patientId: parsed.data.patientId,
patientCode, patientCode,
prostheticType: parsed.data.prostheticType, prostheticType: prosthetic.type,
prostheticId: prosthetic.$id,
memberCount: parsed.data.teeth.length, memberCount: parsed.data.teeth.length,
teeth: parsed.data.teeth, teeth: parsed.data.teeth,
color: parsed.data.color, color: parsed.data.color,
+81
View File
@@ -28,6 +28,87 @@ export type JobPriceQuote = {
discountPercent?: number; 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 * Resolves the price a clinic should be charged for a job, given the lab's
* catalog and any clinic-specific pricing overrides. * catalog and any clinic-specific pricing overrides.
+1
View File
@@ -100,6 +100,7 @@ export interface Job extends Row {
patientCode: string; patientCode: string;
patientId?: string; patientId?: string;
prostheticType: ProstheticType; prostheticType: ProstheticType;
prostheticId?: string;
memberCount: number; memberCount: number;
teeth?: string[]; teeth?: string[];
color?: string; color?: string;
+1 -1
View File
@@ -19,7 +19,7 @@ export const createJobSchema = z.object({
.optional() .optional()
.transform((v) => (v ? v : undefined)), .transform((v) => (v ? v : undefined)),
patientCode: z.string().trim().min(1, "Hasta kodu zorunlu.").max(50), 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, n seçin."),
teeth: z teeth: z
.array(z.string().regex(FDI_TOOTH, "Geçersiz diş numarası")) .array(z.string().regex(FDI_TOOTH, "Geçersiz diş numarası"))
.min(1, "En az 1 diş seçin.") .min(1, "En az 1 diş seçin.")