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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user