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 PROSTHETIC_TYPES: ProstheticType[] = [
|
||||
"metal_porselen",
|
||||
"zirkonyum",
|
||||
"implant_ustu_zirkonyum",
|
||||
"gecici",
|
||||
"e_max",
|
||||
"diger",
|
||||
];
|
||||
|
||||
type PatientOption = { id: string; code: string; label: string };
|
||||
type ProstheticOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
unitPrice: number;
|
||||
currency?: string;
|
||||
};
|
||||
|
||||
type Quote = {
|
||||
amount: number;
|
||||
@@ -62,10 +60,12 @@ function formatMoney(amount: number, currency: string): string {
|
||||
export function NewJobForm({
|
||||
labs,
|
||||
patients,
|
||||
defaultCurrency: _defaultCurrency,
|
||||
prostheticsByLab,
|
||||
defaultCurrency,
|
||||
}: {
|
||||
labs: JobCounterpart[];
|
||||
patients: PatientOption[];
|
||||
prostheticsByLab: Record<string, ProstheticOption[]>;
|
||||
defaultCurrency: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
@@ -75,10 +75,13 @@ export function NewJobForm({
|
||||
);
|
||||
const [teeth, setTeeth] = useState<string[]>([]);
|
||||
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 [quoteLoading, setQuoteLoading] = useState(false);
|
||||
|
||||
const labProsthetics = prostheticsByLab[labTenantId] ?? [];
|
||||
const selectedProsthetic = labProsthetics.find((p) => p.id === prostheticId);
|
||||
|
||||
const patientById = useMemo(
|
||||
() => new Map(patients.map((p) => [p.id, p])),
|
||||
[patients],
|
||||
@@ -94,9 +97,15 @@ export function NewJobForm({
|
||||
}
|
||||
}, [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.
|
||||
useEffect(() => {
|
||||
if (!labTenantId || !prostheticType || teeth.length === 0) {
|
||||
if (!prostheticId || teeth.length === 0) {
|
||||
setQuote(null);
|
||||
setQuoteLoading(false);
|
||||
return;
|
||||
@@ -108,11 +117,7 @@ export function NewJobForm({
|
||||
method: "POST",
|
||||
signal: ctrl.signal,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
labTenantId,
|
||||
prostheticType,
|
||||
teethCount: teeth.length,
|
||||
}),
|
||||
body: JSON.stringify({ prostheticId, teethCount: teeth.length }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
@@ -127,7 +132,7 @@ export function NewJobForm({
|
||||
ctrl.abort();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [labTenantId, prostheticType, teeth.length]);
|
||||
}, [prostheticId, teeth.length]);
|
||||
|
||||
return (
|
||||
<form action={action} className="grid gap-5">
|
||||
@@ -215,25 +220,38 @@ export function NewJobForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="prostheticType">Protez Türü *</Label>
|
||||
<input type="hidden" name="prostheticType" value={prostheticType} />
|
||||
<Select
|
||||
value={prostheticType}
|
||||
onValueChange={(v) => setProstheticType(v as ProstheticType)}
|
||||
>
|
||||
<SelectTrigger id="prostheticType">
|
||||
<SelectValue placeholder="Tür seçin" />
|
||||
<Label htmlFor="prostheticId">Ürün *</Label>
|
||||
<input type="hidden" name="prostheticId" value={prostheticId} />
|
||||
<Select value={prostheticId} onValueChange={setProstheticId} disabled={labProsthetics.length === 0}>
|
||||
<SelectTrigger id="prostheticId">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
labProsthetics.length === 0
|
||||
? "Laboratuvarın aktif ürünü yok"
|
||||
: "Bir ürün seçin"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROSTHETIC_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{PROSTHETIC_TYPE_LABELS[t]}
|
||||
{labProsthetics.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
<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>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{state.fieldErrors?.prostheticType && (
|
||||
<p className="text-destructive text-xs">{state.fieldErrors.prostheticType}</p>
|
||||
{selectedProsthetic && (
|
||||
<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>
|
||||
|
||||
@@ -259,7 +277,7 @@ export function NewJobForm({
|
||||
<PriceQuoteCard
|
||||
quote={quote}
|
||||
loading={quoteLoading}
|
||||
hasInputs={Boolean(labTenantId && prostheticType && teeth.length > 0)}
|
||||
hasInputs={Boolean(prostheticId && teeth.length > 0)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { listApprovedLabsForClinic } from "@/lib/appwrite/job-queries";
|
||||
import { listPatients } from "@/lib/appwrite/patient-queries";
|
||||
import { listActiveProsthetics } from "@/lib/appwrite/prosthetic-queries";
|
||||
import { requireTenant, requireTenantKind } from "@/lib/appwrite/tenant-guard";
|
||||
import { NewJobForm } from "./components/new-job-form";
|
||||
|
||||
@@ -25,6 +26,26 @@ export default async function NewJobPage() {
|
||||
listApprovedLabsForClinic(ctx.tenantId),
|
||||
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";
|
||||
|
||||
return (
|
||||
@@ -55,7 +76,8 @@ export default async function NewJobPage() {
|
||||
<CardHeader>
|
||||
<CardTitle>İş Bilgileri</CardTitle>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -64,8 +86,11 @@ export default async function NewJobPage() {
|
||||
patients={patients.map((p) => ({
|
||||
id: p.$id,
|
||||
code: p.patientCode,
|
||||
label: `${p.firstName} ${p.lastName}`,
|
||||
label:
|
||||
[p.firstName, p.lastName].filter(Boolean).join(" ") ||
|
||||
`Hasta ${p.patientCode}`,
|
||||
}))}
|
||||
prostheticsByLab={prostheticsByLab}
|
||||
defaultCurrency={defaultCurrency}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user