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