diff --git a/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.tsx b/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.tsx index 43367442..7a37605c 100755 --- a/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.tsx +++ b/apps/Frontend/src/components/appointment-procedures/appointment-procedures-dialog.tsx @@ -31,8 +31,6 @@ import { apiRequest, queryClient } from "@/lib/queryClient"; import { useToast } from "@/hooks/use-toast"; import { PROCEDURE_COMBOS } from "@/utils/procedureCombos"; import { - CODE_MAP, - getPriceForCodeWithAgeFromMap, findPriceMismatches, type PriceMismatch, } from "@/utils/procedureCombosMapping"; @@ -67,12 +65,15 @@ export function AppointmentProceduresDialog({ // NPI provider state — stored per-appointment on the procedure rows const [selectedNpiProviderId, setSelectedNpiProviderId] = useState(null); - // manual add row state - const [manualCode, setManualCode] = useState(""); - const [manualLabel, setManualLabel] = useState(""); - const [manualFee, setManualFee] = useState(""); - const [manualTooth, setManualTooth] = useState(""); - const [manualSurface, setManualSurface] = useState(""); + // pending (unsaved) lines — 3 blank rows by default + interface PendingRow { code: string; label: string; fee: string; tooth: string; surface: string; } + const emptyRow = (): PendingRow => ({ code: "", label: "", fee: "", tooth: "", surface: "" }); + const [pendingRows, setPendingRows] = useState([emptyRow(), emptyRow(), emptyRow()]); + + // reset pending rows when dialog opens + useEffect(() => { + if (open) setPendingRows([emptyRow(), emptyRow(), emptyRow()]); + }, [open]); // inline edit state const [editingId, setEditingId] = useState(null); @@ -180,42 +181,15 @@ export function AppointmentProceduresDialog({ }, }); - const addManualMutation = useMutation({ - mutationFn: async () => { - const payload = { - appointmentId, - patientId, - npiProviderId: selectedNpiProviderId ?? null, - procedureCode: manualCode, - procedureLabel: manualLabel || null, - fee: manualFee ? Number(manualFee) : null, - toothNumber: manualTooth || null, - toothSurface: manualSurface || null, - source: "MANUAL", - }; - const res = await apiRequest("POST", "/api/appointment-procedures", payload); - if (!res.ok) throw new Error("Failed to add procedure"); - return res.json(); - }, - onSuccess: () => { - toast({ title: "Procedure added" }); - setManualCode(""); setManualLabel(""); setManualFee(""); - setManualTooth(""); setManualSurface(""); - queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] }); - }, - onError: (err: any) => { - toast({ title: "Error", description: err.message ?? "Failed to add procedure", variant: "destructive" }); - }, - }); - const bulkAddMutation = useMutation({ mutationFn: async (rows: any[]) => { const res = await apiRequest("POST", "/api/appointment-procedures/bulk", rows); - if (!res.ok) throw new Error("Failed to add combo procedures"); + if (!res.ok) throw new Error("Failed to add procedures"); return res.json(); }, onSuccess: () => { - toast({ title: "Combo added" }); + toast({ title: "Procedures saved" }); + setPendingRows([emptyRow(), emptyRow(), emptyRow()]); queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] }); }, }); @@ -265,16 +239,7 @@ export function AppointmentProceduresDialog({ const handleAddCombo = (comboKey: string) => { const combo = PROCEDURE_COMBOS[comboKey]; - if (!combo || !patient?.dateOfBirth) return; - - const dob = patient.dateOfBirth; - const ref = new Date(); - const birth = new Date(dob as any); - let age = ref.getFullYear() - birth.getFullYear(); - const hadBirthday = - ref.getMonth() > birth.getMonth() || - (ref.getMonth() === birth.getMonth() && ref.getDate() >= birth.getDate()); - if (!hadBirthday) age -= 1; + if (!combo) return; const rows = combo.codes.map((code: string, idx: number) => ({ appointmentId, @@ -282,7 +247,7 @@ export function AppointmentProceduresDialog({ npiProviderId: selectedNpiProviderId ?? null, procedureCode: code, procedureLabel: combo.label, - fee: getPriceForCodeWithAgeFromMap(CODE_MAP, code, age).toNumber(), + fee: 0, source: "COMBO", comboKey, toothNumber: combo.toothNumbers?.[idx] ?? null, @@ -305,6 +270,24 @@ export function AppointmentProceduresDialog({ const cancelEdit = () => { setEditingId(null); setEditRow({}); }; + const handleSavePendingRows = () => { + const rows = pendingRows + .filter((r) => r.code.trim()) + .map((r) => ({ + appointmentId, + patientId, + npiProviderId: selectedNpiProviderId ?? null, + procedureCode: r.code.trim().toUpperCase(), + procedureLabel: r.label || null, + fee: r.fee ? Number(r.fee) : 0, + toothNumber: r.tooth || null, + toothSurface: r.surface || null, + source: "MANUAL", + })); + if (!rows.length) return; + bulkAddMutation.mutate(rows); + }; + const handleDirectClaim = () => { setLocation(`/claims?appointmentId=${appointmentId}&mode=direct`); onOpenChange(false); @@ -374,35 +357,62 @@ export function AppointmentProceduresDialog({ - {/* ── Manual Add ─────────────────────────────────────── */} -
-
Add Manual Procedure
-
-
- - setManualCode(e.target.value)} placeholder="D0120" /> -
-
- - setManualLabel(e.target.value)} placeholder="Exam" /> -
-
- - setManualFee(e.target.value)} placeholder="100" type="number" /> -
-
- - setManualTooth(e.target.value)} placeholder="14" /> -
-
- - setManualSurface(e.target.value)} placeholder="MO" /> -
+ {/* ── Pending Lines ───────────────────────────────────── */} +
+
Add Procedures
+ {/* Column headers */} +
+
Code
Label
Fee
Tooth
Surface
-
- +
+ ))} +
+ +
diff --git a/apps/Frontend/src/components/claims/claim-form.tsx b/apps/Frontend/src/components/claims/claim-form.tsx index c0440efb..cbad964e 100755 --- a/apps/Frontend/src/components/claims/claim-form.tsx +++ b/apps/Frontend/src/components/claims/claim-form.tsx @@ -456,7 +456,7 @@ export function ClaimForm({ setForm((prev) => ({ ...prev, - serviceLines: mappedLines, + serviceLines: mappedLines.length > 0 ? mappedLines : prev.serviceLines, ...(data.appointmentFiles?.length ? { claimFiles: data.appointmentFiles } : {}), @@ -630,7 +630,7 @@ export function ClaimForm({ insuranceProvider: "", insuranceSiteKey: "", status: "PENDING", - serviceLines: Array.from({ length: 10 }, () => ({ + serviceLines: Array.from({ length: proceduresOnly ? 3 : 10 }, () => ({ procedureCode: "", procedureDate: serviceDate, quad: "", @@ -1876,7 +1876,7 @@ export function ClaimForm({ prev, comboKey as any, patient?.dateOfBirth ?? "", - { replaceAll: false, lineDate: prev.serviceDate }, + { replaceAll: false, lineDate: prev.serviceDate, skipPrice: true }, prev.insuranceSiteKey, ); setTimeout(() => scrollToLine(0), 0); @@ -2116,7 +2116,7 @@ export function ClaimForm({ prev, comboKey as any, patient?.dateOfBirth ?? "", - { replaceAll: false, lineDate: prev.serviceDate }, + { replaceAll: false, lineDate: prev.serviceDate, skipPrice: true }, ); setTimeout(() => scrollToLine(0), 0); return next; @@ -2679,7 +2679,7 @@ export function ClaimForm({ prev, comboKey as any, patient?.dateOfBirth ?? "", - { replaceAll: false, lineDate: prev.serviceDate }, + { replaceAll: false, lineDate: prev.serviceDate, skipPrice: true }, ); setTimeout(() => scrollToLine(0), 0); return next; diff --git a/apps/Frontend/src/pages/appointments-page.tsx b/apps/Frontend/src/pages/appointments-page.tsx index 3b1c895f..4181dd62 100755 --- a/apps/Frontend/src/pages/appointments-page.tsx +++ b/apps/Frontend/src/pages/appointments-page.tsx @@ -597,10 +597,40 @@ export default function AppointmentsPage() { return Math.max(1, Math.round(diff / 15)); }; + // Compute display span — same as getSlotSpan but truncated if a later appointment in the + // same staff column starts within this appointment's time range (overlap case). + const getDisplaySpan = (apt: ScheduledAppointment): number => { + const fullSpan = getSlotSpan(apt); + const startStr = (typeof apt.startTime === "string" ? apt.startTime : formatLocalTime(apt.startTime)).substring(0, 5); + const [startH, startM] = startStr.split(":").map(Number); + const startMinutes = (startH ?? 0) * 60 + (startM ?? 0); + + const nextOverlap = (processedAppointments ?? []) + .filter((other) => { + if (other.id === apt.id || other.staffId !== apt.staffId) return false; + const otherStart = (typeof other.startTime === "string" ? other.startTime : formatLocalTime(other.startTime)).substring(0, 5); + const [oh, om] = otherStart.split(":").map(Number); + const otherMin = (oh ?? 0) * 60 + (om ?? 0); + return otherMin > startMinutes && otherMin < startMinutes + fullSpan * 15; + }) + .sort((a, b) => { + const aStart = (typeof a.startTime === "string" ? a.startTime : formatLocalTime(a.startTime)).substring(0, 5); + const bStart = (typeof b.startTime === "string" ? b.startTime : formatLocalTime(b.startTime)).substring(0, 5); + return aStart.localeCompare(bStart); + })[0]; + + if (!nextOverlap) return fullSpan; + + const nextStart = (typeof nextOverlap.startTime === "string" ? nextOverlap.startTime : formatLocalTime(nextOverlap.startTime)).substring(0, 5); + const [nh, nm] = nextStart.split(":").map(Number); + const nextMin = (nh ?? 0) * 60 + (nm ?? 0); + return Math.max(1, Math.round((nextMin - startMinutes) / 15)); + }; + // Slots that are "continued" rows of a multi-slot appointment (should not render a td) const coveredSlots = new Set(); (processedAppointments ?? []).forEach((apt) => { - const span = getSlotSpan(apt); + const span = getDisplaySpan(apt); if (span <= 1) return; const startStr = (typeof apt.startTime === "string" ? apt.startTime : formatLocalTime(apt.startTime)).substring(0, 5); const [startH, startM] = startStr.split(":").map(Number); @@ -1556,16 +1586,6 @@ export default function AppointmentsPage() { - {/* Check Eligibility */} - handleCheckEligibility(props.appointmentId)} - > - - - Check Eligibility - - - {/* Select Procedures */} handleSelectProcedures(props.appointmentId)} @@ -1576,6 +1596,16 @@ export default function AppointmentsPage() { + {/* Check Eligibility */} + handleCheckEligibility(props.appointmentId)} + > + + + Check Eligibility + + + {/* Claims / PreAuth */} handleClaimsPreAuth(props.appointmentId)} @@ -1806,7 +1836,7 @@ export default function AppointmentsPage() { {staffMembers.map((staff, staffIndex) => { if (coveredSlots.has(`${timeSlot.time}-${staff.id}`)) return null; const apt = getAppointmentAtSlot(timeSlot, Number(staff.id)); - const span = apt ? getSlotSpan(apt) : 1; + const span = apt ? getDisplaySpan(apt) : 1; return ( ( // Make sure we have enough rows for the whole combo ensureCapacity(next.serviceLines, insertAt + preset.codes.length, lineDate); - // Age on the specific line date we will set - const age = ageOnDate(patientDOB, lineDate); - const map = getCodeMap(insuranceSiteKey); + const age = options.skipPrice ? 0 : ageOnDate(patientDOB, lineDate); + const map = options.skipPrice ? CODE_MAP : getCodeMap(insuranceSiteKey); for (let j = 0; j < preset.codes.length; j++) { const i = insertAt + j; @@ -340,7 +340,9 @@ export function applyComboToForm( const codeRaw = preset.codes[j]; if (!codeRaw) continue; const code = normalizeCode(codeRaw); - const price = getPriceForCodeWithAgeFromMap(map, code, age); + const price = options.skipPrice + ? new Decimal(0) + : getPriceForCodeWithAgeFromMap(map, code, age); const original = next.serviceLines[i];