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:
@@ -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 Türü *</Label>
|
<Label htmlFor="prostheticId">Ürü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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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, "Ürü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.")
|
||||||
|
|||||||
Reference in New Issue
Block a user