bugfix/demo-bugs-fixed #19
@@ -285,7 +285,6 @@ export default function AppointmentsPage() {
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
|
||||
<ScheduleDayPicker
|
||||
value={scheduleDate}
|
||||
minDate={todayStart}
|
||||
onChange={(d) => setScheduleDate(startOfLocalDay(d))}
|
||||
/>
|
||||
{loadingSchedule && (
|
||||
|
||||
@@ -2,17 +2,11 @@
|
||||
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
addCalendarDays,
|
||||
compareLocalDayStart,
|
||||
startOfLocalDay,
|
||||
} from '@/lib/appointmentTime';
|
||||
import { addCalendarDays, startOfLocalDay } from '@/lib/appointmentTime';
|
||||
|
||||
interface ScheduleDayPickerProps {
|
||||
value: Date;
|
||||
onChange: (day: Date) => void;
|
||||
/** Optional lower bound for day selection and previous-day navigation. */
|
||||
minDate?: Date;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
@@ -39,27 +33,10 @@ function buildLocalDay(year: number, month: number, day: number): Date {
|
||||
return new Date(year, month, day, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function clampToValidDay(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
min?: Date,
|
||||
): Date {
|
||||
const maxDay = daysInMonth(year, month);
|
||||
let next = buildLocalDay(year, month, Math.min(Math.max(1, day), maxDay));
|
||||
if (min) {
|
||||
const floor = startOfLocalDay(min);
|
||||
if (compareLocalDayStart(next, floor) < 0) {
|
||||
next = floor;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function yearRange(min?: Date, anchor?: Date): number[] {
|
||||
const now = new Date();
|
||||
const startYear = min ? min.getFullYear() : now.getFullYear() - 5;
|
||||
const endYear = Math.max(now.getFullYear() + 2, anchor?.getFullYear() ?? now.getFullYear());
|
||||
function yearRange(anchor: Date): number[] {
|
||||
const anchorYear = anchor.getFullYear();
|
||||
const startYear = anchorYear - 10;
|
||||
const endYear = anchorYear + 2;
|
||||
const years: number[] = [];
|
||||
for (let y = startYear; y <= endYear; y += 1) {
|
||||
years.push(y);
|
||||
@@ -72,21 +49,18 @@ const selectClassName = `
|
||||
bg-background-card/90 text-text-primary text-sm
|
||||
pl-2 pr-7 py-1.5
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
`;
|
||||
|
||||
export function ScheduleDayPicker({
|
||||
value,
|
||||
onChange,
|
||||
minDate,
|
||||
label = 'Schedule date',
|
||||
}: ScheduleDayPickerProps) {
|
||||
/**
|
||||
* Calendar day navigator (arrows + year/month/day panel).
|
||||
* Does not restrict past dates — parent pages enforce read-only vs editable for schedule grids/forms.
|
||||
*/
|
||||
export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) {
|
||||
const panelId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
|
||||
const normalizedValue = startOfLocalDay(value);
|
||||
const normalizedMin = minDate ? startOfLocalDay(minDate) : undefined;
|
||||
|
||||
const labelText = normalizedValue.toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
@@ -95,28 +69,20 @@ export function ScheduleDayPicker({
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const previousDay = addCalendarDays(normalizedValue, -1);
|
||||
const canGoPrevious =
|
||||
!normalizedMin || compareLocalDayStart(previousDay, normalizedMin) >= 0;
|
||||
|
||||
const years = yearRange(normalizedMin, normalizedValue);
|
||||
const years = yearRange(normalizedValue);
|
||||
const selectedYear = normalizedValue.getFullYear();
|
||||
const selectedMonth = normalizedValue.getMonth();
|
||||
const selectedDay = normalizedValue.getDate();
|
||||
const dayCount = daysInMonth(selectedYear, selectedMonth);
|
||||
|
||||
function applyParts(year: number, month: number, day: number, closePanel = false) {
|
||||
onChange(clampToValidDay(year, month, day, normalizedMin));
|
||||
const maxDay = daysInMonth(year, month);
|
||||
onChange(buildLocalDay(year, month, Math.min(Math.max(1, day), maxDay)));
|
||||
if (closePanel) {
|
||||
setPanelOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreviousDay() {
|
||||
if (!canGoPrevious) return;
|
||||
onChange(previousDay);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!panelOpen) return;
|
||||
|
||||
@@ -146,9 +112,8 @@ export function ScheduleDayPicker({
|
||||
<div className="flex items-center gap-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-1 py-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreviousDay}
|
||||
disabled={!canGoPrevious}
|
||||
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35 disabled:opacity-40 disabled:pointer-events-none"
|
||||
onClick={() => onChange(addCalendarDays(normalizedValue, -1))}
|
||||
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
|
||||
aria-label="Previous day"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 icon-flat" />
|
||||
@@ -232,17 +197,11 @@ export function ScheduleDayPicker({
|
||||
}
|
||||
className={selectClassName}
|
||||
>
|
||||
{MONTH_LABELS.map((name, index) => {
|
||||
const disabled =
|
||||
normalizedMin &&
|
||||
selectedYear === normalizedMin.getFullYear() &&
|
||||
index < normalizedMin.getMonth();
|
||||
return (
|
||||
<option key={name} value={index} disabled={disabled}>
|
||||
{name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
{MONTH_LABELS.map((name, index) => (
|
||||
<option key={name} value={index}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
|
||||
@@ -272,18 +231,11 @@ export function ScheduleDayPicker({
|
||||
}
|
||||
className={selectClassName}
|
||||
>
|
||||
{Array.from({ length: dayCount }, (_, i) => i + 1).map((day) => {
|
||||
const disabled =
|
||||
normalizedMin &&
|
||||
selectedYear === normalizedMin.getFullYear() &&
|
||||
selectedMonth === normalizedMin.getMonth() &&
|
||||
day < normalizedMin.getDate();
|
||||
return (
|
||||
<option key={day} value={day} disabled={disabled}>
|
||||
{day}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
{Array.from({ length: dayCount }, (_, i) => i + 1).map((day) => (
|
||||
<option key={day} value={day}>
|
||||
{day}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
|
||||
|
||||
@@ -12,8 +12,6 @@ interface AppointmentsStripProps {
|
||||
onToggleStripHidden: () => void;
|
||||
selectedDay: Date;
|
||||
onSelectDay: (day: Date) => void;
|
||||
/** Same lower bound as Appointments schedule (cannot pick days before this). */
|
||||
minScheduleDate: Date;
|
||||
appointments: TreatmentAppointment[];
|
||||
selectedAppointmentId: string | null;
|
||||
onSelectAppointment: (id: string) => void;
|
||||
@@ -25,7 +23,6 @@ export function AppointmentsStrip({
|
||||
onToggleStripHidden,
|
||||
selectedDay,
|
||||
onSelectDay,
|
||||
minScheduleDate,
|
||||
appointments,
|
||||
selectedAppointmentId,
|
||||
onSelectAppointment,
|
||||
@@ -65,7 +62,6 @@ export function AppointmentsStrip({
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
|
||||
<ScheduleDayPicker
|
||||
value={selectedDay}
|
||||
minDate={minScheduleDate}
|
||||
onChange={(d) => onSelectDay(startOfLocalDay(d))}
|
||||
/>
|
||||
{loading && (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Button } from '@/components/ui/shared/Button';
|
||||
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||
import { Toast } from '@/components/ui/shared/Toast';
|
||||
import { isSameLocalCalendarDay, startOfLocalDay } from '@/lib/appointmentTime';
|
||||
import { compareLocalDayStart, isSameLocalCalendarDay, startOfLocalDay } from '@/lib/appointmentTime';
|
||||
import {
|
||||
fetchLinkedOrganizations,
|
||||
fetchMyAppointmentsForDay,
|
||||
@@ -54,7 +54,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
|
||||
const [stripHidden, setStripHidden] = useState(false);
|
||||
|
||||
const scheduleMinDate = useMemo(() => startOfLocalDay(new Date()), []);
|
||||
const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
|
||||
|
||||
const [selectedDay, setSelectedDay] = useState(() => startOfLocalDay(new Date()));
|
||||
const [appointments, setAppointments] = useState<TreatmentAppointment[]>([]);
|
||||
@@ -87,6 +87,13 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
[appointments, selectedAppointmentId],
|
||||
);
|
||||
|
||||
const isViewingPastDay = useMemo(
|
||||
() => compareLocalDayStart(selectedDay, todayStart) < 0,
|
||||
[selectedDay, todayStart],
|
||||
);
|
||||
|
||||
const canEditTreatmentForDay = Boolean(selectedAppointment) && !isViewingPastDay;
|
||||
|
||||
const activeRecord = useMemo(
|
||||
() => records.find((r) => r.clientId === activeRecordId) ?? records[0],
|
||||
[records, activeRecordId],
|
||||
@@ -339,13 +346,19 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
onToggleStripHidden={() => setStripHidden((s) => !s)}
|
||||
selectedDay={selectedDay}
|
||||
onSelectDay={setSelectedDay}
|
||||
minScheduleDate={scheduleMinDate}
|
||||
appointments={appointments}
|
||||
selectedAppointmentId={selectedAppointmentId}
|
||||
onSelectAppointment={onPickAppointment}
|
||||
loading={apptsLoading}
|
||||
/>
|
||||
|
||||
{isViewingPastDay && (
|
||||
<p className="text-sm text-text-secondary rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/50 px-3 py-2">
|
||||
Past days are view-only. You can review appointments and history, but treatment records
|
||||
cannot be added or changed.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(280px,380px)_minmax(0,1fr)] gap-6 items-start">
|
||||
<div className="space-y-4">
|
||||
{selectedAppointment ? (
|
||||
@@ -423,7 +436,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
<FdiToothChart
|
||||
selected={selectedTeethSet}
|
||||
onToggle={toggleTooth}
|
||||
disabled={!selectedAppointment}
|
||||
disabled={!canEditTreatmentForDay}
|
||||
/>
|
||||
|
||||
<div className="surface-card p-4 space-y-4">
|
||||
@@ -437,7 +450,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={!selectedAppointment}
|
||||
disabled={!canEditTreatmentForDay}
|
||||
onClick={() => {
|
||||
const nr = newRecord();
|
||||
fixActiveAfterRecordsChange([...records, nr]);
|
||||
@@ -486,7 +499,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
}}
|
||||
placeholder="Write clinical notes for this record…"
|
||||
rows={5}
|
||||
disabled={!selectedAppointment}
|
||||
disabled={!canEditTreatmentForDay}
|
||||
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[120px]"
|
||||
/>
|
||||
</label>
|
||||
@@ -503,7 +516,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
),
|
||||
);
|
||||
}}
|
||||
disabled={!selectedAppointment}
|
||||
disabled={!canEditTreatmentForDay}
|
||||
className="capitalize"
|
||||
style={{ color: treatmentTypeTextColor }}
|
||||
>
|
||||
@@ -522,7 +535,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
id="treatment-record-attachments"
|
||||
type="file"
|
||||
multiple
|
||||
disabled={!selectedAppointment}
|
||||
disabled={!canEditTreatmentForDay}
|
||||
onChange={(e) => {
|
||||
addAttachments(e.target.files);
|
||||
e.target.value = '';
|
||||
@@ -533,7 +546,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={!selectedAppointment}
|
||||
disabled={!canEditTreatmentForDay}
|
||||
onClick={() => attachmentInputRef.current?.click()}
|
||||
aria-controls="treatment-record-attachments"
|
||||
>
|
||||
@@ -589,7 +602,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
<Checkbox
|
||||
key={o.id}
|
||||
checked={activeRecord.sendToOrganizationIds.includes(o.id)}
|
||||
disabled={!selectedAppointment || Boolean(activeRecord.sentAt)}
|
||||
disabled={!canEditTreatmentForDay || Boolean(activeRecord.sentAt)}
|
||||
onChange={(checked) => {
|
||||
setRecords((prev) =>
|
||||
prev.map((r) => {
|
||||
@@ -614,7 +627,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={!selectedAppointment || Boolean(activeRecord.sentAt) || sendBusyId === activeRecord.clientId}
|
||||
disabled={!canEditTreatmentForDay || Boolean(activeRecord.sentAt) || sendBusyId === activeRecord.clientId}
|
||||
isLoading={sendBusyId === activeRecord.clientId}
|
||||
onClick={() => void handleSendRecord(activeRecord)}
|
||||
>
|
||||
@@ -633,7 +646,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={!selectedAppointment || saveBusy}
|
||||
disabled={!canEditTreatmentForDay || saveBusy}
|
||||
isLoading={saveBusy}
|
||||
onClick={() => void handleSaveAll()}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user