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

@@ -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>
);
}