feat: add schedule column labels, office hours enforcement, and appointment move fix

- Schedule columns default to labels A–F (localStorage, per-browser, click to rename)
- Settings → Advanced → Office Hours: configure Doctors (A-C) and Hygienists (D-F) AM/PM hours per weekday
- Gray out schedule slots outside office hours; override dialog for manual exceptions
- Override Office Hours toggle: select specific dates where all slots are open
- Fix appointment move: send only real DB fields to avoid Zod strict-mode rejection of computed fields (hasProcedures, hasClaimWithNumber)
- Fix backend PUT /appointments: safe error logging to prevent Prisma error crashing Node inspect
- Add OfficeHours Prisma model and GET/PUT /api/office-hours route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gitead
2026-05-05 09:15:18 -04:00
parent 70ffd8398b
commit 2312ad66ca
465 changed files with 11834 additions and 1461 deletions

View File

@@ -381,34 +381,37 @@ router.put(
const newYMD = new Date(date).toISOString().slice(0, 10);
const isDateChanged = oldYMD !== newYMD;
const updatePayload = {
...appointmentData,
...(isDateChanged ? { eligibilityStatus: "UNKNOWN" as const } : {}),
};
// Only pass the fields that are safe to update; never overwrite patientId/userId via this route
const updatePayload: Record<string, unknown> = {};
if (appointmentData.staffId !== undefined) updatePayload.staffId = appointmentData.staffId;
if (appointmentData.title !== undefined) updatePayload.title = appointmentData.title;
if (appointmentData.date !== undefined) updatePayload.date = appointmentData.date;
if (appointmentData.startTime !== undefined) updatePayload.startTime = appointmentData.startTime;
if (appointmentData.endTime !== undefined) updatePayload.endTime = appointmentData.endTime;
if (appointmentData.type !== undefined) updatePayload.type = appointmentData.type;
if (appointmentData.status !== undefined) updatePayload.status = appointmentData.status;
if (appointmentData.notes !== undefined) updatePayload.notes = appointmentData.notes;
if (isDateChanged) updatePayload.eligibilityStatus = "UNKNOWN";
// Update appointment
const updatedAppointment = await storage.updateAppointment(
appointmentId,
updatePayload
updatePayload as any
);
return res.json(updatedAppointment);
} catch (error) {
console.error("Error updating appointment:", error);
// Prisma error objects crash Node's util.inspect — always log as string
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
console.error("Error updating appointment:", msg);
if (error instanceof z.ZodError) {
console.log(
"Validation error details:",
JSON.stringify(error.format(), null, 2)
);
return res.status(400).json({
message: "Validation error",
errors: error.format(),
});
const fieldErrors = error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(" | ");
console.error("Zod validation in PUT /appointments:", fieldErrors);
return res.status(400).json({ message: fieldErrors });
}
res.status(500).json({
message: "Failed to update appointment",
error: error instanceof Error ? error.message : String(error),
return res.status(500).json({
message: msg,
});
}
}

View File

@@ -26,6 +26,7 @@ import exportPaymentsReportsRoutes from "./export-payments-reports";
import jobMonitorRoutes from "./job-monitor";
import twilioRoutes from "./twilio";
import aiSettingsRoutes from "./ai-settings";
import officeHoursRoutes from "./office-hours";
const router = Router();
@@ -56,5 +57,6 @@ router.use("/export-payments-reports", exportPaymentsReportsRoutes);
router.use("/job-monitor", jobMonitorRoutes);
router.use("/twilio", twilioRoutes);
router.use("/ai", aiSettingsRoutes);
router.use("/office-hours", officeHoursRoutes);
export default router;

View File

@@ -0,0 +1,37 @@
import express, { Request, Response } from "express";
import { storage } from "../storage";
const router = express.Router();
// GET /api/office-hours
router.get("/", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const record = await storage.getOfficeHours(userId);
return res.status(200).json(record ? record.data : null);
} catch (err) {
return res.status(500).json({ error: "Failed to fetch office hours", details: String(err) });
}
});
// PUT /api/office-hours
router.put("/", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const data = req.body;
if (!data || typeof data !== "object") {
return res.status(400).json({ message: "Invalid office hours data" });
}
const record = await storage.upsertOfficeHours(userId, data);
return res.status(200).json(record.data);
} catch (err) {
return res.status(500).json({ error: "Failed to save office hours", details: String(err) });
}
});
export default router;

View File

@@ -114,14 +114,10 @@ export const appointmentsStorage: IStorage = {
id: number,
updateData: UpdateAppointment
): Promise<Appointment> {
try {
return await db.appointment.update({
where: { id },
data: updateData,
});
} catch (err) {
throw new Error(`Appointment with ID ${id} not found`);
}
return db.appointment.update({
where: { id },
data: updateData,
});
},
async deleteAppointment(id: number): Promise<void> {

View File

@@ -19,6 +19,7 @@ import * as exportPaymentsReportsStorage from "./export-payments-reports-storage
import { cronJobLogStorage } from "./cron-job-log-storage";
import { twilioStorage } from "./twilio-storage";
import { aiSettingsStorage } from "./ai-settings-storage";
import { officeHoursStorage } from "./office-hours-storage";
export const storage = {
@@ -41,6 +42,7 @@ export const storage = {
...cronJobLogStorage,
...twilioStorage,
...aiSettingsStorage,
...officeHoursStorage,
};

View File

@@ -0,0 +1,15 @@
import { prisma as db } from "@repo/db/client";
export const officeHoursStorage = {
async getOfficeHours(userId: number) {
return db.officeHours.findUnique({ where: { userId } });
},
async upsertOfficeHours(userId: number, data: object) {
return db.officeHours.upsert({
where: { userId },
update: { data },
create: { userId, data },
});
},
};

View File

@@ -26,6 +26,7 @@ import {
Stethoscope,
Workflow,
Bot,
Clock,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useMemo, useState, useEffect } from "react";
@@ -221,6 +222,11 @@ export function Sidebar() {
path: "/settings/ai",
icon: <Bot className="h-4 w-4 text-gray-400" />,
},
{
name: "Office Hours",
path: "/settings/officehours",
icon: <Clock className="h-4 w-4 text-gray-400" />,
},
],
},
],

View File

@@ -0,0 +1,368 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Calendar } from "@/components/ui/calendar";
const DAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] as const;
const DAY_LABELS: Record<typeof DAYS[number], string> = {
monday: "Monday",
tuesday: "Tuesday",
wednesday: "Wednesday",
thursday: "Thursday",
friday: "Friday",
saturday: "Saturday",
sunday: "Sunday",
};
type Day = typeof DAYS[number];
type DayHours = {
enabled: boolean;
amStart: string;
amEnd: string;
pmStart: string;
pmEnd: string;
};
type WeekHours = Record<Day, DayHours>;
export type OfficeHoursData = {
doctors: WeekHours;
hygienists: WeekHours;
overrideDates?: string[]; // YYYY-MM-DD dates where office hours are lifted entirely
};
const DEFAULT_DAY_HOURS: DayHours = {
enabled: true,
amStart: "09:00",
amEnd: "12:00",
pmStart: "13:00",
pmEnd: "17:00",
};
const WEEKEND_DEFAULT: DayHours = {
enabled: false,
amStart: "09:00",
amEnd: "12:00",
pmStart: "13:00",
pmEnd: "17:00",
};
function buildDefaultWeek(): WeekHours {
return {
monday: { ...DEFAULT_DAY_HOURS },
tuesday: { ...DEFAULT_DAY_HOURS },
wednesday: { ...DEFAULT_DAY_HOURS },
thursday: { ...DEFAULT_DAY_HOURS },
friday: { ...DEFAULT_DAY_HOURS },
saturday: { ...WEEKEND_DEFAULT },
sunday: { ...WEEKEND_DEFAULT },
};
}
const DEFAULT_OFFICE_HOURS: OfficeHoursData = {
doctors: buildDefaultWeek(),
hygienists: buildDefaultWeek(),
};
function TimeSelect({
value,
onChange,
disabled,
}: {
value: string;
onChange: (v: string) => void;
disabled?: boolean;
}) {
const options: string[] = [];
for (let h = 6; h <= 20; h++) {
options.push(`${String(h).padStart(2, "0")}:00`);
options.push(`${String(h).padStart(2, "0")}:30`);
}
const toDisplay = (t: string) => {
const parts = t.split(":").map(Number);
const hh = parts[0] ?? 0;
const mm = parts[1] ?? 0;
const period = hh >= 12 ? "PM" : "AM";
const h12 = hh > 12 ? hh - 12 : hh === 0 ? 12 : hh;
return `${h12}:${String(mm).padStart(2, "0")} ${period}`;
};
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className="border rounded px-1 py-0.5 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{options.map((o) => (
<option key={o} value={o}>
{toDisplay(o)}
</option>
))}
</select>
);
}
function DayRow({
day,
hours,
onChange,
}: {
day: Day;
hours: DayHours;
onChange: (updated: DayHours) => void;
}) {
const set = (field: keyof DayHours, value: string | boolean) =>
onChange({ ...hours, [field]: value });
return (
<div className={`grid grid-cols-[120px_1fr] gap-2 items-start py-2 border-b last:border-b-0 ${!hours.enabled ? "opacity-60" : ""}`}>
<label className="flex items-center gap-2 cursor-pointer pt-1">
<input
type="checkbox"
checked={hours.enabled}
onChange={(e) => set("enabled", e.target.checked)}
className="w-4 h-4 accent-teal-600"
/>
<span className="text-sm font-medium">{DAY_LABELS[day]}</span>
</label>
<div className="flex flex-wrap gap-x-6 gap-y-1">
<div className="flex items-center gap-1.5 text-sm">
<span className="text-gray-500 w-6">AM</span>
<TimeSelect value={hours.amStart} onChange={(v) => set("amStart", v)} disabled={!hours.enabled} />
<span className="text-gray-400"></span>
<TimeSelect value={hours.amEnd} onChange={(v) => set("amEnd", v)} disabled={!hours.enabled} />
</div>
<div className="flex items-center gap-1.5 text-sm">
<span className="text-gray-500 w-6">PM</span>
<TimeSelect value={hours.pmStart} onChange={(v) => set("pmStart", v)} disabled={!hours.enabled} />
<span className="text-gray-400"></span>
<TimeSelect value={hours.pmEnd} onChange={(v) => set("pmEnd", v)} disabled={!hours.enabled} />
</div>
</div>
</div>
);
}
function SectionHours({
title,
subtitle,
week,
onChange,
}: {
title: string;
subtitle: string;
week: WeekHours;
onChange: (updated: WeekHours) => void;
}) {
return (
<div className="border rounded-lg p-4">
<div className="mb-3">
<h4 className="font-semibold text-gray-800">{title}</h4>
<p className="text-xs text-gray-500 mt-0.5">{subtitle}</p>
</div>
{DAYS.map((day) => (
<DayRow
key={day}
day={day}
hours={week[day]}
onChange={(updated) => onChange({ ...week, [day]: updated })}
/>
))}
</div>
);
}
function toYMD(date: Date): string {
return date.toLocaleDateString("en-CA"); // "YYYY-MM-DD" in local time
}
function formatDisplayDate(ymd: string): string {
// "2026-05-10" → "May 10, 2026"
const [y, m, d] = ymd.split("-").map(Number);
return new Date(y!, m! - 1, d!).toLocaleDateString("en-US", {
month: "short", day: "numeric", year: "numeric",
});
}
export function OfficeHoursCard() {
const { toast } = useToast();
const [formData, setFormData] = useState<OfficeHoursData>(DEFAULT_OFFICE_HOURS);
// Override-dates UI state (not persisted until Save is clicked)
const [overrideToggle, setOverrideToggle] = useState(false);
const [pickedDate, setPickedDate] = useState<Date | undefined>(undefined);
const { data: savedHours, isLoading } = useQuery<OfficeHoursData | null>({
queryKey: ["/api/office-hours"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/office-hours");
if (!res.ok) return null;
return res.json();
},
});
useEffect(() => {
if (savedHours) {
setFormData({
doctors: { ...buildDefaultWeek(), ...savedHours.doctors },
hygienists: { ...buildDefaultWeek(), ...savedHours.hygienists },
overrideDates: savedHours.overrideDates ?? [],
});
}
}, [savedHours]);
const overrideDates: string[] = formData.overrideDates ?? [];
const addOverrideDate = () => {
if (!pickedDate) return;
const ymd = toYMD(pickedDate);
if (overrideDates.includes(ymd)) return; // already added
setFormData((prev) => ({ ...prev, overrideDates: [...overrideDates, ymd].sort() }));
setPickedDate(undefined);
};
const removeOverrideDate = (ymd: string) => {
setFormData((prev) => ({
...prev,
overrideDates: overrideDates.filter((d) => d !== ymd),
}));
};
const saveMutation = useMutation({
mutationFn: async (data: OfficeHoursData) => {
const res = await apiRequest("PUT", "/api/office-hours", data);
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.message || "Failed to save office hours");
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/office-hours"] });
toast({ title: "Office Hours Saved" });
},
onError: (err: any) => {
toast({ title: "Error", description: err?.message, variant: "destructive" });
},
});
if (isLoading) return <p className="text-sm text-gray-400 py-4">Loading...</p>;
return (
<Card>
<CardContent className="py-6 space-y-6">
<div>
<h3 className="text-lg font-semibold">Office Hours</h3>
<p className="text-sm text-gray-500 mt-1">
Define which time slots are available for scheduling. Appointments outside these hours
require a manual override by a dental assistant.
</p>
</div>
<SectionHours
title="Doctors' Hours"
subtitle="Applies to schedule columns A, B, C"
week={formData.doctors}
onChange={(updated) => setFormData((prev) => ({ ...prev, doctors: updated }))}
/>
<SectionHours
title="Hygienists' Hours"
subtitle="Applies to schedule columns D, E, F"
week={formData.hygienists}
onChange={(updated) => setFormData((prev) => ({ ...prev, hygienists: updated }))}
/>
{/* Override Office Hours section */}
<div className="border rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-semibold text-gray-800">Override Office Hours</h4>
<p className="text-xs text-gray-500 mt-0.5">
On selected dates, all time slots are open with no restrictions.
</p>
</div>
{/* Toggle */}
<button
type="button"
onClick={() => setOverrideToggle((v) => !v)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
overrideToggle ? "bg-teal-600" : "bg-gray-300"
}`}
aria-pressed={overrideToggle}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
overrideToggle ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
</div>
{/* Calendar + add button — shown when toggle is on */}
{overrideToggle && (
<div className="space-y-3">
<p className="text-sm text-gray-600">Select a date to add to the override list:</p>
<div className="border rounded-md inline-block">
<Calendar
mode="single"
selected={pickedDate}
onSelect={(d) => setPickedDate(d ?? undefined)}
className="p-2"
/>
</div>
<div>
<button
type="button"
onClick={addOverrideDate}
disabled={!pickedDate}
className="px-3 py-1.5 text-sm bg-teal-600 text-white rounded hover:bg-teal-700 disabled:opacity-40"
>
Add Date
</button>
</div>
</div>
)}
{/* List of saved override dates */}
{overrideDates.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">Override dates</p>
<ul className="space-y-1">
{overrideDates.map((ymd) => (
<li key={ymd} className="flex items-center justify-between text-sm bg-teal-50 border border-teal-100 rounded px-3 py-1.5">
<span className="font-medium text-teal-800">{formatDisplayDate(ymd)}</span>
<button
type="button"
onClick={() => removeOverrideDate(ymd)}
className="text-teal-400 hover:text-red-500 text-xs ml-4"
title="Remove"
>
</button>
</li>
))}
</ul>
</div>
)}
</div>
<div className="pt-2">
<button
onClick={() => saveMutation.mutate(formData)}
disabled={saveMutation.isPending}
className="bg-teal-600 text-white px-4 py-2 rounded hover:bg-teal-700 text-sm disabled:opacity-50"
>
{saveMutation.isPending ? "Saving..." : "Save Office Hours"}
</button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -59,6 +59,7 @@ import {
} from "@/redux/slices/seleniumTaskSlice";
import { SeleniumTaskBanner } from "@/components/ui/selenium-task-banner";
import { PatientStatusBadge } from "@/components/appointments/patient-status-badge";
import type { OfficeHoursData } from "@/components/settings/office-hours-card";
// Define types for scheduling
interface TimeSlot {
@@ -157,6 +158,14 @@ export default function AppointmentsPage() {
const [selectedReminderColumns, setSelectedReminderColumns] = useState<Set<number>>(new Set());
const [selectedDownloadPdfColumns, setSelectedDownloadPdfColumns] = useState<Set<number>>(new Set());
const [isDownloadingClaimPdfs, setIsDownloadingClaimPdfs] = useState(false);
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
const [editingLabelStaffId, setEditingLabelStaffId] = useState<string | null>(null);
const [pendingOverride, setPendingOverride] = useState<{
type: "move" | "create";
appointmentId?: number;
timeSlot: TimeSlot;
staffId: number;
} | null>(null);
const toggleReminderColumn = (staffId: number) => {
setSelectedReminderColumns((prev) => {
@@ -233,6 +242,17 @@ export default function AppointmentsPage() {
const appointments = dayPayload.appointments ?? [];
const patientsFromDay = dayPayload.patients ?? [];
// Office hours (used to enforce scheduling rules)
const { data: officeHours } = useQuery<OfficeHoursData | null>({
queryKey: ["/api/office-hours"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/office-hours");
if (!res.ok) return null;
return res.json();
},
enabled: !!user,
});
// Staff memebers
const { data: staffMembersRaw = [] as Staff[] } = useQuery<Staff[]>({
queryKey: ["/api/staffs/"],
@@ -259,6 +279,50 @@ export default function AppointmentsPage() {
color: colors[index % colors.length] || "bg-gray-400",
}));
// Initialize column labels from localStorage; default A, B, C… for new staff
useEffect(() => {
if (!staffMembersRaw.length) return;
const stored = JSON.parse(
localStorage.getItem("scheduleColumnLabels") || "{}"
) as Record<string, string>;
let changed = false;
staffMembersRaw.forEach((staff, index) => {
if (!(String(staff.id) in stored)) {
stored[String(staff.id)] = String.fromCharCode(65 + index);
changed = true;
}
});
if (changed) localStorage.setItem("scheduleColumnLabels", JSON.stringify(stored));
setColumnLabels(stored);
}, [staffMembersRaw]);
const handleLabelSave = (staffId: string, value: string) => {
const trimmed = value.trim() || String.fromCharCode(65 + staffMembersRaw.findIndex((s) => String(s.id) === staffId));
setColumnLabels((prev) => {
const updated = { ...prev, [staffId]: trimmed };
localStorage.setItem("scheduleColumnLabels", JSON.stringify(updated));
return updated;
});
setEditingLabelStaffId(null);
};
// Returns true when a time slot is within the configured office hours for that staff group
const isWithinOfficeHours = (timeStr: string, staffIndex: number): boolean => {
if (!officeHours) return true; // no config = unrestricted
// If today is an override date, all slots are open
const todayYMD = selectedDate.toLocaleDateString("en-CA");
if (officeHours.overrideDates?.includes(todayYMD)) return true;
const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"] as const;
const dayName = dayNames[selectedDate.getDay()] as keyof typeof officeHours.doctors;
const group = staffIndex <= 2 ? officeHours.doctors : officeHours.hygienists;
const dayHours = group[dayName];
if (!dayHours || !dayHours.enabled) return false;
return (
(timeStr >= dayHours.amStart && timeStr <= dayHours.amEnd) ||
(timeStr >= dayHours.pmStart && timeStr <= dayHours.pmEnd)
);
};
// Generate time slots from 8:00 AM to 6:00 PM in 15-minute increments
const timeSlots: TimeSlot[] = [];
for (let hour = 8; hour <= 18; hour++) {
@@ -641,14 +705,20 @@ export default function AppointmentsPage() {
// Find staff member
const staff = staffMembers.find((s) => Number(s.id) === newStaffId);
// Update appointment data
const { id, createdAt, ...sanitizedAppointment } = appointment;
// Send only the real DB fields — the appointment object may contain computed
// fields (hasProcedures, hasClaimWithNumber, etc.) that the Zod schema rejects
const apt = appointment as any;
const updatedAppointment: UpdateAppointment = {
...sanitizedAppointment,
startTime: newTimeSlot.time, // Already in HH:MM format
endTime: endTime, // Already in HH:MM format
patientId: apt.patientId,
userId: apt.userId,
staffId: newStaffId,
title: apt.title,
date: apt.date,
type: apt.type,
status: apt.status ?? undefined,
startTime: newTimeSlot.time,
endTime: endTime,
notes: `Appointment with ${staff?.name}`,
staffId: newStaffId, // Update staffId
};
// Call update mutation
updateAppointmentMutation.mutate({
@@ -727,41 +797,64 @@ export default function AppointmentsPage() {
function DroppableTimeSlot({
timeSlot,
staffId,
staffIndex,
appointment,
staff,
}: {
timeSlot: TimeSlot;
staffId: number;
staffIndex: number;
appointment: ScheduledAppointment | undefined;
staff: Staff;
}) {
const blocked = !isWithinOfficeHours(timeSlot.time, staffIndex);
const [{ isOver, canDrop }, drop] = useDrop(() => ({
accept: ItemTypes.APPOINTMENT,
drop: (item: { id: number }) => {
handleMoveAppointment(item.id, timeSlot, staffId);
},
canDrop: (item: { id: number }) => {
// Prevent dropping if there's already an appointment here
return !appointment;
if (blocked) {
// Store plain data — never store callbacks in state across re-renders
setPendingOverride({ type: "move", appointmentId: item.id, timeSlot, staffId });
} else {
handleMoveAppointment(item.id, timeSlot, staffId);
}
},
canDrop: () => !appointment,
collect: (monitor) => ({
isOver: !!monitor.isOver(),
canDrop: !!monitor.canDrop(),
}),
}));
const handleClickEmpty = () => {
if (blocked) {
setPendingOverride({ type: "create", timeSlot, staffId });
} else {
handleCreateAppointmentAtSlot(timeSlot, staffId);
}
};
return (
<td
ref={drop as unknown as React.RefObject<HTMLTableCellElement>}
key={`${timeSlot.time}-${staffId}`}
className={`px-1 py-1 border relative h-14 ${isOver && canDrop ? "bg-green-100" : ""}`}
className={`px-1 py-1 border relative h-14 ${
isOver && canDrop ? "bg-green-100" : blocked ? "bg-gray-100" : ""
}`}
title={blocked ? "Outside office hours — click to override" : undefined}
>
{appointment ? (
<DraggableAppointment appointment={appointment} staff={staff} />
) : (
<button
className={`w-full h-full ${isOver && canDrop ? "bg-green-100" : "text-gray-400 hover:bg-gray-100"} rounded flex items-center justify-center`}
onClick={() => handleCreateAppointmentAtSlot(timeSlot, staffId)}
className={`w-full h-full rounded flex items-center justify-center ${
isOver && canDrop
? "bg-green-100"
: blocked
? "text-gray-300 hover:bg-gray-200"
: "text-gray-400 hover:bg-gray-100"
}`}
onClick={handleClickEmpty}
>
<Plus className="h-4 w-4" />
</button>
@@ -1150,7 +1243,7 @@ export default function AppointmentsPage() {
onChange={() => toggleStaffColumn(Number(staff.id))}
/>
<span className="text-sm font-medium">
{String.fromCharCode(65 + index)}
{columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
</span>
</label>
))}
@@ -1187,7 +1280,7 @@ export default function AppointmentsPage() {
onChange={() => toggleClaimColumn(Number(staff.id))}
/>
<span className="text-sm font-medium">
{String.fromCharCode(65 + index)}
{columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
</span>
</label>
))}
@@ -1213,7 +1306,7 @@ export default function AppointmentsPage() {
onChange={() => toggleReminderColumn(Number(staff.id))}
/>
<span className="text-sm font-medium">
{String.fromCharCode(65 + index)}
{columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
</span>
</label>
))}
@@ -1250,7 +1343,7 @@ export default function AppointmentsPage() {
onChange={() => toggleDownloadPdfColumn(Number(staff.id))}
/>
<span className="text-sm font-medium">
{String.fromCharCode(65 + index)}
{columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
</span>
</label>
))}
@@ -1443,15 +1536,33 @@ export default function AppointmentsPage() {
<thead>
<tr>
<th className="p-2 border bg-gray-50 w-[100px]">Time</th>
{staffMembers.map((staff) => (
{staffMembers.map((staff, index) => (
<th
key={staff.id}
className={`p-2 border bg-gray-50 ${staff.role === "doctor" ? "font-bold" : ""}`}
>
{staff.name}
<div className="text-xs text-gray-500">
{staff.role}
</div>
{editingLabelStaffId === String(staff.id) ? (
<input
className="w-16 text-center border rounded text-sm font-bold px-1"
defaultValue={columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
onBlur={(e) => handleLabelSave(String(staff.id), e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleLabelSave(String(staff.id), e.currentTarget.value);
if (e.key === "Escape") setEditingLabelStaffId(null);
}}
autoFocus
/>
) : (
<div
className="font-bold text-sm cursor-pointer hover:bg-gray-200 rounded px-1 inline-block"
title="Click to rename"
onClick={() => setEditingLabelStaffId(String(staff.id))}
>
{columnLabels[String(staff.id)] ?? String.fromCharCode(65 + index)}
</div>
)}
<div className="text-xs text-gray-500">{staff.name}</div>
<div className="text-xs text-gray-400">{staff.role}</div>
</th>
))}
</tr>
@@ -1462,11 +1573,12 @@ export default function AppointmentsPage() {
<td className="border px-2 py-1 text-xs text-gray-600 font-medium">
{timeSlot.displayTime}
</td>
{staffMembers.map((staff) => (
{staffMembers.map((staff, staffIndex) => (
<DroppableTimeSlot
key={`${timeSlot.time}-${staff.id}`}
timeSlot={timeSlot}
staffId={Number(staff.id)}
staffIndex={staffIndex}
appointment={getAppointmentAtSlot(
timeSlot,
Number(staff.id)
@@ -1504,6 +1616,39 @@ export default function AppointmentsPage() {
entityName={String(confirmDeleteState.appointmentId)}
/>
{/* Outside-office-hours override dialog */}
{pendingOverride && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-lg p-6 max-w-sm w-full mx-4">
<h3 className="text-base font-semibold mb-2">Outside Office Hours</h3>
<p className="text-sm text-gray-600 mb-5">
This time slot is outside the configured office hours. Do you want to schedule here anyway?
</p>
<div className="flex justify-end gap-3">
<button
className="px-4 py-2 text-sm border rounded hover:bg-gray-50"
onClick={() => setPendingOverride(null)}
>
Cancel
</button>
<button
className="px-4 py-2 text-sm bg-teal-600 text-white rounded hover:bg-teal-700"
onClick={() => {
if (pendingOverride.type === "move" && pendingOverride.appointmentId != null) {
handleMoveAppointment(pendingOverride.appointmentId, pendingOverride.timeSlot, pendingOverride.staffId);
} else {
handleCreateAppointmentAtSlot(pendingOverride.timeSlot, pendingOverride.staffId);
}
setPendingOverride(null);
}}
>
Schedule Anyway
</button>
</div>
</div>
</div>
)}
{/* Select Procedures modal — stays on appointments page */}
{isSelectProceduresOpen && selectProceduresPatientId !== null && (
<ClaimForm

View File

@@ -15,6 +15,7 @@ import { NpiProviderTable } from "@/components/settings/npiProviderTable";
import { ProgramBridgeTable } from "@/components/settings/program-bridge-table";
import { TwilioSettingsCard } from "@/components/settings/twilio-settings-card";
import { AiSettingsCard } from "@/components/settings/ai-settings-card";
import { OfficeHoursCard } from "@/components/settings/office-hours-card";
type SectionId =
| "staff"
@@ -24,7 +25,8 @@ type SectionId =
| "npi"
| "programs"
| "twilio"
| "ai";
| "ai"
| "officehours";
export default function SettingsPage() {
const { toast } = useToast();
@@ -251,6 +253,9 @@ export default function SettingsPage() {
case "ai":
return <AiSettingsCard />;
case "officehours":
return <OfficeHoursCard />;
default:
return null;
}