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:
@@ -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" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
368
apps/Frontend/src/components/settings/office-hours-card.tsx
Normal file
368
apps/Frontend/src/components/settings/office-hours-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user