feat: schedule page UX improvements + CDT combo price skip

- Move Select Procedures above Check Eligibility in appointment right-click menu
- Show 3 blank service lines by default when opening Select Procedures with no saved procedures
- Fix serviceLines not being preserved when API returns empty procedures list
- CDT combo buttons no longer auto-fill price (only fill codes); user maps price via Map Price button
- Overlap detection in schedule: shorten earlier appointment display span when a later one starts within its range
- Procedures dialog: replace single manual-add row with 3 pre-filled blank rows grid + Add Line / Save Lines buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ff
2026-05-30 19:37:12 -04:00
parent 70f36fc13c
commit 4bf8cb1a94
4 changed files with 139 additions and 97 deletions

View File

@@ -31,8 +31,6 @@ import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { PROCEDURE_COMBOS } from "@/utils/procedureCombos"; import { PROCEDURE_COMBOS } from "@/utils/procedureCombos";
import { import {
CODE_MAP,
getPriceForCodeWithAgeFromMap,
findPriceMismatches, findPriceMismatches,
type PriceMismatch, type PriceMismatch,
} from "@/utils/procedureCombosMapping"; } from "@/utils/procedureCombosMapping";
@@ -67,12 +65,15 @@ export function AppointmentProceduresDialog({
// NPI provider state — stored per-appointment on the procedure rows // NPI provider state — stored per-appointment on the procedure rows
const [selectedNpiProviderId, setSelectedNpiProviderId] = useState<number | null>(null); const [selectedNpiProviderId, setSelectedNpiProviderId] = useState<number | null>(null);
// manual add row state // pending (unsaved) lines — 3 blank rows by default
const [manualCode, setManualCode] = useState(""); interface PendingRow { code: string; label: string; fee: string; tooth: string; surface: string; }
const [manualLabel, setManualLabel] = useState(""); const emptyRow = (): PendingRow => ({ code: "", label: "", fee: "", tooth: "", surface: "" });
const [manualFee, setManualFee] = useState(""); const [pendingRows, setPendingRows] = useState<PendingRow[]>([emptyRow(), emptyRow(), emptyRow()]);
const [manualTooth, setManualTooth] = useState("");
const [manualSurface, setManualSurface] = useState(""); // reset pending rows when dialog opens
useEffect(() => {
if (open) setPendingRows([emptyRow(), emptyRow(), emptyRow()]);
}, [open]);
// inline edit state // inline edit state
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(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({ const bulkAddMutation = useMutation({
mutationFn: async (rows: any[]) => { mutationFn: async (rows: any[]) => {
const res = await apiRequest("POST", "/api/appointment-procedures/bulk", rows); 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(); return res.json();
}, },
onSuccess: () => { onSuccess: () => {
toast({ title: "Combo added" }); toast({ title: "Procedures saved" });
setPendingRows([emptyRow(), emptyRow(), emptyRow()]);
queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] }); queryClient.invalidateQueries({ queryKey: ["appointment-procedures", appointmentId] });
}, },
}); });
@@ -265,16 +239,7 @@ export function AppointmentProceduresDialog({
const handleAddCombo = (comboKey: string) => { const handleAddCombo = (comboKey: string) => {
const combo = PROCEDURE_COMBOS[comboKey]; const combo = PROCEDURE_COMBOS[comboKey];
if (!combo || !patient?.dateOfBirth) return; if (!combo) 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;
const rows = combo.codes.map((code: string, idx: number) => ({ const rows = combo.codes.map((code: string, idx: number) => ({
appointmentId, appointmentId,
@@ -282,7 +247,7 @@ export function AppointmentProceduresDialog({
npiProviderId: selectedNpiProviderId ?? null, npiProviderId: selectedNpiProviderId ?? null,
procedureCode: code, procedureCode: code,
procedureLabel: combo.label, procedureLabel: combo.label,
fee: getPriceForCodeWithAgeFromMap(CODE_MAP, code, age).toNumber(), fee: 0,
source: "COMBO", source: "COMBO",
comboKey, comboKey,
toothNumber: combo.toothNumbers?.[idx] ?? null, toothNumber: combo.toothNumbers?.[idx] ?? null,
@@ -305,6 +270,24 @@ export function AppointmentProceduresDialog({
const cancelEdit = () => { setEditingId(null); setEditRow({}); }; 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 = () => { const handleDirectClaim = () => {
setLocation(`/claims?appointmentId=${appointmentId}&mode=direct`); setLocation(`/claims?appointmentId=${appointmentId}&mode=direct`);
onOpenChange(false); onOpenChange(false);
@@ -374,35 +357,62 @@ export function AppointmentProceduresDialog({
<RegularComboButtons onRegularCombo={handleAddCombo} /> <RegularComboButtons onRegularCombo={handleAddCombo} />
</div> </div>
{/* ── Manual Add ─────────────────────────────────────── */} {/* ── Pending Lines ───────────────────────────────────── */}
<div className="mt-8 border rounded-lg p-4 bg-muted/20 space-y-3"> <div className="mt-8 border rounded-lg p-4 bg-muted/20 space-y-2">
<div className="font-medium text-sm">Add Manual Procedure</div> <div className="font-medium text-sm mb-3">Add Procedures</div>
<div className="grid grid-cols-1 md:grid-cols-5 gap-3"> {/* Column headers */}
<div> <div className="grid grid-cols-[100px_1fr_90px_80px_80px_36px] gap-2 px-1 text-xs font-semibold text-muted-foreground">
<Label>Code</Label> <div>Code</div><div>Label</div><div>Fee</div><div>Tooth</div><div>Surface</div><div />
<Input value={manualCode} onChange={(e) => setManualCode(e.target.value)} placeholder="D0120" />
</div> </div>
<div> {pendingRows.map((row, i) => (
<Label>Label</Label> <div key={i} className="grid grid-cols-[100px_1fr_90px_80px_80px_36px] gap-2 items-center">
<Input value={manualLabel} onChange={(e) => setManualLabel(e.target.value)} placeholder="Exam" /> <Input
placeholder="D0120"
value={row.code}
onChange={(e) => setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, code: e.target.value } : r))}
/>
<Input
placeholder="Exam"
value={row.label}
onChange={(e) => setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, label: e.target.value } : r))}
/>
<Input
type="number"
placeholder="0.00"
value={row.fee}
onChange={(e) => setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, fee: e.target.value } : r))}
/>
<Input
placeholder="14"
value={row.tooth}
onChange={(e) => setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, tooth: e.target.value } : r))}
/>
<Input
placeholder="MO"
value={row.surface}
onChange={(e) => setPendingRows((prev) => prev.map((r, idx) => idx === i ? { ...r, surface: e.target.value } : r))}
/>
<button
type="button"
className="p-1 rounded hover:bg-red-50"
onClick={() => setPendingRows((prev) => prev.filter((_, idx) => idx !== i))}
>
<Trash2 className="h-4 w-4 text-red-400 hover:text-red-600" />
</button>
</div> </div>
<div> ))}
<Label>Fee</Label> <div className="flex justify-between items-center pt-2">
<Input value={manualFee} onChange={(e) => setManualFee(e.target.value)} placeholder="100" type="number" /> <Button size="sm" variant="outline" onClick={() => setPendingRows((prev) => [...prev, emptyRow()])}>
</div>
<div>
<Label>Tooth</Label>
<Input value={manualTooth} onChange={(e) => setManualTooth(e.target.value)} placeholder="14" />
</div>
<div>
<Label>Surface</Label>
<Input value={manualSurface} onChange={(e) => setManualSurface(e.target.value)} placeholder="MO" />
</div>
</div>
<div className="flex justify-end">
<Button size="sm" onClick={() => runWithPriceCheck(manualCode, Number(manualFee), () => addManualMutation.mutate())} disabled={!manualCode || addManualMutation.isPending}>
<Plus className="h-4 w-4 mr-1" /> <Plus className="h-4 w-4 mr-1" />
Add Procedure Add Line
</Button>
<Button
size="sm"
onClick={handleSavePendingRows}
disabled={!pendingRows.some((r) => r.code.trim()) || bulkAddMutation.isPending}
>
<Save className="h-4 w-4 mr-1" />
Save Lines
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -456,7 +456,7 @@ export function ClaimForm({
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
serviceLines: mappedLines, serviceLines: mappedLines.length > 0 ? mappedLines : prev.serviceLines,
...(data.appointmentFiles?.length ...(data.appointmentFiles?.length
? { claimFiles: data.appointmentFiles } ? { claimFiles: data.appointmentFiles }
: {}), : {}),
@@ -630,7 +630,7 @@ export function ClaimForm({
insuranceProvider: "", insuranceProvider: "",
insuranceSiteKey: "", insuranceSiteKey: "",
status: "PENDING", status: "PENDING",
serviceLines: Array.from({ length: 10 }, () => ({ serviceLines: Array.from({ length: proceduresOnly ? 3 : 10 }, () => ({
procedureCode: "", procedureCode: "",
procedureDate: serviceDate, procedureDate: serviceDate,
quad: "", quad: "",
@@ -1876,7 +1876,7 @@ export function ClaimForm({
prev, prev,
comboKey as any, comboKey as any,
patient?.dateOfBirth ?? "", patient?.dateOfBirth ?? "",
{ replaceAll: false, lineDate: prev.serviceDate }, { replaceAll: false, lineDate: prev.serviceDate, skipPrice: true },
prev.insuranceSiteKey, prev.insuranceSiteKey,
); );
setTimeout(() => scrollToLine(0), 0); setTimeout(() => scrollToLine(0), 0);
@@ -2116,7 +2116,7 @@ export function ClaimForm({
prev, prev,
comboKey as any, comboKey as any,
patient?.dateOfBirth ?? "", patient?.dateOfBirth ?? "",
{ replaceAll: false, lineDate: prev.serviceDate }, { replaceAll: false, lineDate: prev.serviceDate, skipPrice: true },
); );
setTimeout(() => scrollToLine(0), 0); setTimeout(() => scrollToLine(0), 0);
return next; return next;
@@ -2679,7 +2679,7 @@ export function ClaimForm({
prev, prev,
comboKey as any, comboKey as any,
patient?.dateOfBirth ?? "", patient?.dateOfBirth ?? "",
{ replaceAll: false, lineDate: prev.serviceDate }, { replaceAll: false, lineDate: prev.serviceDate, skipPrice: true },
); );
setTimeout(() => scrollToLine(0), 0); setTimeout(() => scrollToLine(0), 0);
return next; return next;

View File

@@ -597,10 +597,40 @@ export default function AppointmentsPage() {
return Math.max(1, Math.round(diff / 15)); 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) // Slots that are "continued" rows of a multi-slot appointment (should not render a td)
const coveredSlots = new Set<string>(); const coveredSlots = new Set<string>();
(processedAppointments ?? []).forEach((apt) => { (processedAppointments ?? []).forEach((apt) => {
const span = getSlotSpan(apt); const span = getDisplaySpan(apt);
if (span <= 1) return; if (span <= 1) return;
const startStr = (typeof apt.startTime === "string" ? apt.startTime : formatLocalTime(apt.startTime)).substring(0, 5); const startStr = (typeof apt.startTime === "string" ? apt.startTime : formatLocalTime(apt.startTime)).substring(0, 5);
const [startH, startM] = startStr.split(":").map(Number); const [startH, startM] = startStr.split(":").map(Number);
@@ -1556,16 +1586,6 @@ export default function AppointmentsPage() {
</span> </span>
</Item> </Item>
{/* Check Eligibility */}
<Item
onClick={({ props }) => handleCheckEligibility(props.appointmentId)}
>
<span className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Check Eligibility
</span>
</Item>
{/* Select Procedures */} {/* Select Procedures */}
<Item <Item
onClick={({ props }) => handleSelectProcedures(props.appointmentId)} onClick={({ props }) => handleSelectProcedures(props.appointmentId)}
@@ -1576,6 +1596,16 @@ export default function AppointmentsPage() {
</span> </span>
</Item> </Item>
{/* Check Eligibility */}
<Item
onClick={({ props }) => handleCheckEligibility(props.appointmentId)}
>
<span className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Check Eligibility
</span>
</Item>
{/* Claims / PreAuth */} {/* Claims / PreAuth */}
<Item <Item
onClick={({ props }) => handleClaimsPreAuth(props.appointmentId)} onClick={({ props }) => handleClaimsPreAuth(props.appointmentId)}
@@ -1806,7 +1836,7 @@ export default function AppointmentsPage() {
{staffMembers.map((staff, staffIndex) => { {staffMembers.map((staff, staffIndex) => {
if (coveredSlots.has(`${timeSlot.time}-${staff.id}`)) return null; if (coveredSlots.has(`${timeSlot.time}-${staff.id}`)) return null;
const apt = getAppointmentAtSlot(timeSlot, Number(staff.id)); const apt = getAppointmentAtSlot(timeSlot, Number(staff.id));
const span = apt ? getSlotSpan(apt) : 1; const span = apt ? getDisplaySpan(apt) : 1;
return ( return (
<DroppableTimeSlot <DroppableTimeSlot
key={`${timeSlot.time}-${staff.id}`} key={`${timeSlot.time}-${staff.id}`}

View File

@@ -33,6 +33,7 @@ export type ApplyOptions = {
lineDate?: string; lineDate?: string;
clearTrailing?: boolean; clearTrailing?: boolean;
replaceAll?: boolean; replaceAll?: boolean;
skipPrice?: boolean;
}; };
/* ----------------------------- Helpers ----------------------------- */ /* ----------------------------- Helpers ----------------------------- */
@@ -329,9 +330,8 @@ export function applyComboToForm<T extends ClaimFormLike>(
// Make sure we have enough rows for the whole combo // Make sure we have enough rows for the whole combo
ensureCapacity(next.serviceLines, insertAt + preset.codes.length, lineDate); ensureCapacity(next.serviceLines, insertAt + preset.codes.length, lineDate);
// Age on the specific line date we will set const age = options.skipPrice ? 0 : ageOnDate(patientDOB, lineDate);
const age = ageOnDate(patientDOB, lineDate); const map = options.skipPrice ? CODE_MAP : getCodeMap(insuranceSiteKey);
const map = getCodeMap(insuranceSiteKey);
for (let j = 0; j < preset.codes.length; j++) { for (let j = 0; j < preset.codes.length; j++) {
const i = insertAt + j; const i = insertAt + j;
@@ -340,7 +340,9 @@ export function applyComboToForm<T extends ClaimFormLike>(
const codeRaw = preset.codes[j]; const codeRaw = preset.codes[j];
if (!codeRaw) continue; if (!codeRaw) continue;
const code = normalizeCode(codeRaw); 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]; const original = next.serviceLines[i];