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:
@@ -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<number | null>(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<PendingRow[]>([emptyRow(), emptyRow(), emptyRow()]);
|
||||
|
||||
// reset pending rows when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) setPendingRows([emptyRow(), emptyRow(), emptyRow()]);
|
||||
}, [open]);
|
||||
|
||||
// inline edit state
|
||||
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({
|
||||
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({
|
||||
<RegularComboButtons onRegularCombo={handleAddCombo} />
|
||||
</div>
|
||||
|
||||
{/* ── Manual Add ─────────────────────────────────────── */}
|
||||
<div className="mt-8 border rounded-lg p-4 bg-muted/20 space-y-3">
|
||||
<div className="font-medium text-sm">Add Manual Procedure</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
<div>
|
||||
<Label>Code</Label>
|
||||
<Input value={manualCode} onChange={(e) => setManualCode(e.target.value)} placeholder="D0120" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Label</Label>
|
||||
<Input value={manualLabel} onChange={(e) => setManualLabel(e.target.value)} placeholder="Exam" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Fee</Label>
|
||||
<Input value={manualFee} onChange={(e) => setManualFee(e.target.value)} placeholder="100" type="number" />
|
||||
</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>
|
||||
{/* ── Pending Lines ───────────────────────────────────── */}
|
||||
<div className="mt-8 border rounded-lg p-4 bg-muted/20 space-y-2">
|
||||
<div className="font-medium text-sm mb-3">Add Procedures</div>
|
||||
{/* Column headers */}
|
||||
<div className="grid grid-cols-[100px_1fr_90px_80px_80px_36px] gap-2 px-1 text-xs font-semibold text-muted-foreground">
|
||||
<div>Code</div><div>Label</div><div>Fee</div><div>Tooth</div><div>Surface</div><div />
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => runWithPriceCheck(manualCode, Number(manualFee), () => addManualMutation.mutate())} disabled={!manualCode || addManualMutation.isPending}>
|
||||
{pendingRows.map((row, i) => (
|
||||
<div key={i} className="grid grid-cols-[100px_1fr_90px_80px_80px_36px] gap-2 items-center">
|
||||
<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 className="flex justify-between items-center pt-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setPendingRows((prev) => [...prev, emptyRow()])}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string>();
|
||||
(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() {
|
||||
</span>
|
||||
</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 */}
|
||||
<Item
|
||||
onClick={({ props }) => handleSelectProcedures(props.appointmentId)}
|
||||
@@ -1576,6 +1596,16 @@ export default function AppointmentsPage() {
|
||||
</span>
|
||||
</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 */}
|
||||
<Item
|
||||
onClick={({ props }) => 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 (
|
||||
<DroppableTimeSlot
|
||||
key={`${timeSlot.time}-${staff.id}`}
|
||||
|
||||
@@ -33,6 +33,7 @@ export type ApplyOptions = {
|
||||
lineDate?: string;
|
||||
clearTrailing?: boolean;
|
||||
replaceAll?: boolean;
|
||||
skipPrice?: boolean;
|
||||
};
|
||||
/* ----------------------------- Helpers ----------------------------- */
|
||||
|
||||
@@ -329,9 +330,8 @@ export function applyComboToForm<T extends ClaimFormLike>(
|
||||
// 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<T extends ClaimFormLike>(
|
||||
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];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user