improvement/ui-ux-improvements #17
@@ -17,7 +17,7 @@ import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker';
|
|||||||
import { Toast } from '@/components/ui/common/Toast';
|
import { Toast } from '@/components/ui/common/Toast';
|
||||||
import type { AppointmentPurpose } from '@/types/appointment';
|
import type { AppointmentPurpose } from '@/types/appointment';
|
||||||
import { formatApiErrorMessage } from '@/lib/formatApiError';
|
import { formatApiErrorMessage } from '@/lib/formatApiError';
|
||||||
import { getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime';
|
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime';
|
||||||
|
|
||||||
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
||||||
firstName: '',
|
firstName: '',
|
||||||
@@ -48,6 +48,7 @@ export default function AppointmentsPage() {
|
|||||||
const [bookingHour, setBookingHour] = useState(9);
|
const [bookingHour, setBookingHour] = useState(9);
|
||||||
const [bookingProviderId, setBookingProviderId] = useState<string | null>(null);
|
const [bookingProviderId, setBookingProviderId] = useState<string | null>(null);
|
||||||
const [bookingProviderName, setBookingProviderName] = useState('');
|
const [bookingProviderName, setBookingProviderName] = useState('');
|
||||||
|
const [editingAppointmentId, setEditingAppointmentId] = useState<string | null>(null);
|
||||||
const [savingAppointment, setSavingAppointment] = useState(false);
|
const [savingAppointment, setSavingAppointment] = useState(false);
|
||||||
|
|
||||||
const [toastError, setToastError] = useState('');
|
const [toastError, setToastError] = useState('');
|
||||||
@@ -58,6 +59,14 @@ export default function AppointmentsPage() {
|
|||||||
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
|
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
|
||||||
|
|
||||||
const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
|
const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
|
||||||
|
const isViewingPastDay = useMemo(
|
||||||
|
() => compareLocalDayStart(scheduleDate, todayStart) < 0,
|
||||||
|
[scheduleDate, todayStart],
|
||||||
|
);
|
||||||
|
const activeEditingAppointment = useMemo(
|
||||||
|
() => appointments.find((a) => a.id === editingAppointmentId) ?? null,
|
||||||
|
[appointments, editingAppointmentId],
|
||||||
|
);
|
||||||
|
|
||||||
const scheduleLoadGen = useRef(0);
|
const scheduleLoadGen = useRef(0);
|
||||||
|
|
||||||
@@ -155,6 +164,12 @@ export default function AppointmentsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSlotClick(hour: number, providerUserId: string, providerName: string) {
|
function handleSlotClick(hour: number, providerUserId: string, providerName: string) {
|
||||||
|
if (isViewingPastDay) {
|
||||||
|
setToastSuccess('');
|
||||||
|
setToastError('');
|
||||||
|
setToastInfo('Past appointments are view-only.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!selectedPatient) {
|
if (!selectedPatient) {
|
||||||
setToastSuccess('');
|
setToastSuccess('');
|
||||||
setToastError('');
|
setToastError('');
|
||||||
@@ -164,6 +179,22 @@ export default function AppointmentsPage() {
|
|||||||
setBookingHour(hour);
|
setBookingHour(hour);
|
||||||
setBookingProviderId(providerUserId);
|
setBookingProviderId(providerUserId);
|
||||||
setBookingProviderName(providerName);
|
setBookingProviderName(providerName);
|
||||||
|
setEditingAppointmentId(null);
|
||||||
|
setBookingOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAppointmentClick(appointment: AppointmentRecord) {
|
||||||
|
if (isViewingPastDay) {
|
||||||
|
setToastSuccess('');
|
||||||
|
setToastError('');
|
||||||
|
setToastInfo('Past appointments are view-only.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const provider = providers.find((p) => p.userId === appointment.providerUserId);
|
||||||
|
setBookingHour(new Date(appointment.startAt).getHours());
|
||||||
|
setBookingProviderId(appointment.providerUserId);
|
||||||
|
setBookingProviderName(provider?.name ?? bookingProviderName);
|
||||||
|
setEditingAppointmentId(appointment.id);
|
||||||
setBookingOpen(true);
|
setBookingOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,15 +210,22 @@ export default function AppointmentsPage() {
|
|||||||
setToastSuccess('');
|
setToastSuccess('');
|
||||||
setToastInfo('');
|
setToastInfo('');
|
||||||
try {
|
try {
|
||||||
await appointmentsApi.create(payload);
|
if (activeEditingAppointment) {
|
||||||
|
await appointmentsApi.update(activeEditingAppointment.id, payload);
|
||||||
|
} else {
|
||||||
|
await appointmentsApi.create(payload);
|
||||||
|
}
|
||||||
setBookingOpen(false);
|
setBookingOpen(false);
|
||||||
setToastSuccess('Appointment saved.');
|
setEditingAppointmentId(null);
|
||||||
|
setToastSuccess(activeEditingAppointment ? 'Appointment updated.' : 'Appointment saved.');
|
||||||
await loadSchedule();
|
await loadSchedule();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message =
|
const message =
|
||||||
err && typeof err === 'object' && 'message' in err
|
err && typeof err === 'object' && 'message' in err
|
||||||
? String((err as { message: unknown }).message)
|
? String((err as { message: unknown }).message)
|
||||||
: 'Could not save appointment.';
|
: activeEditingAppointment
|
||||||
|
? 'Could not update appointment.'
|
||||||
|
: 'Could not save appointment.';
|
||||||
setToastError(message);
|
setToastError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingAppointment(false);
|
setSavingAppointment(false);
|
||||||
@@ -285,10 +323,11 @@ export default function AppointmentsPage() {
|
|||||||
day={scheduleDate}
|
day={scheduleDate}
|
||||||
providers={providers}
|
providers={providers}
|
||||||
appointments={appointments}
|
appointments={appointments}
|
||||||
canBook={canManageAppointments}
|
canBook={canManageAppointments && !isViewingPastDay}
|
||||||
canDelete={canManageAppointments}
|
canDelete={canManageAppointments}
|
||||||
onDeleteAppointment={(id) => void handleDeleteAppointment(id)}
|
onDeleteAppointment={(id) => void handleDeleteAppointment(id)}
|
||||||
onSlotClick={(hour, uid, name) => handleSlotClick(hour, uid, name)}
|
onSlotClick={(hour, uid, name) => handleSlotClick(hour, uid, name)}
|
||||||
|
onAppointmentClick={(apt) => handleAppointmentClick(apt)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,7 +339,11 @@ export default function AppointmentsPage() {
|
|||||||
providerUserId={bookingProviderId}
|
providerUserId={bookingProviderId}
|
||||||
providerName={bookingProviderName}
|
providerName={bookingProviderName}
|
||||||
initialHour={bookingHour}
|
initialHour={bookingHour}
|
||||||
onClose={() => setBookingOpen(false)}
|
editingAppointment={activeEditingAppointment}
|
||||||
|
onClose={() => {
|
||||||
|
setBookingOpen(false);
|
||||||
|
setEditingAppointmentId(null);
|
||||||
|
}}
|
||||||
onSubmit={handleSaveAppointment}
|
onSubmit={handleSaveAppointment}
|
||||||
loading={savingAppointment}
|
loading={savingAppointment}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { useEffect, useState } from 'react';
|
|||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/common/Button';
|
import { Button } from '@/components/ui/common/Button';
|
||||||
import { Dropdown } from '@/components/ui/common/Dropdown';
|
import { Dropdown } from '@/components/ui/common/Dropdown';
|
||||||
import type { AppointmentPurpose } from '@/types/appointment';
|
import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment';
|
||||||
import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles';
|
import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||||
import type { Patient } from '@/types/patient';
|
import type { Patient } from '@/types/patient';
|
||||||
import {
|
import {
|
||||||
combineLocalDateAndTime,
|
combineLocalDateAndTime,
|
||||||
|
compareLocalDayStart,
|
||||||
formatTimeForInput,
|
formatTimeForInput,
|
||||||
isSameLocalCalendarDay,
|
isSameLocalCalendarDay,
|
||||||
} from '@/lib/appointmentTime';
|
} from '@/lib/appointmentTime';
|
||||||
@@ -28,6 +29,7 @@ interface AppointmentBookingModalProps {
|
|||||||
endAt: string;
|
endAt: string;
|
||||||
purpose: AppointmentPurpose;
|
purpose: AppointmentPurpose;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
|
editingAppointment?: AppointmentRecord | null;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ export function AppointmentBookingModal({
|
|||||||
initialHour,
|
initialHour,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
editingAppointment = null,
|
||||||
loading = false,
|
loading = false,
|
||||||
}: AppointmentBookingModalProps) {
|
}: AppointmentBookingModalProps) {
|
||||||
const [startTime, setStartTime] = useState('09:00');
|
const [startTime, setStartTime] = useState('09:00');
|
||||||
@@ -61,29 +64,37 @@ export function AppointmentBookingModal({
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const start = new Date(
|
if (editingAppointment) {
|
||||||
scheduleDate.getFullYear(),
|
const start = new Date(editingAppointment.startAt);
|
||||||
scheduleDate.getMonth(),
|
const end = new Date(editingAppointment.endAt);
|
||||||
scheduleDate.getDate(),
|
setStartTime(formatTimeForInput(start));
|
||||||
initialHour,
|
setEndTime(formatTimeForInput(end));
|
||||||
0,
|
setPurpose((editingAppointment.purpose as AppointmentPurpose) ?? 'consultation');
|
||||||
0,
|
} else {
|
||||||
0,
|
const start = new Date(
|
||||||
);
|
scheduleDate.getFullYear(),
|
||||||
const end = new Date(
|
scheduleDate.getMonth(),
|
||||||
scheduleDate.getFullYear(),
|
scheduleDate.getDate(),
|
||||||
scheduleDate.getMonth(),
|
initialHour,
|
||||||
scheduleDate.getDate(),
|
0,
|
||||||
initialHour < 23 ? initialHour + 1 : 23,
|
0,
|
||||||
initialHour < 23 ? 0 : 59,
|
0,
|
||||||
0,
|
);
|
||||||
0,
|
const end = new Date(
|
||||||
);
|
scheduleDate.getFullYear(),
|
||||||
setStartTime(formatTimeForInput(start));
|
scheduleDate.getMonth(),
|
||||||
setEndTime(formatTimeForInput(end));
|
scheduleDate.getDate(),
|
||||||
setPurpose('consultation');
|
initialHour < 23 ? initialHour + 1 : 23,
|
||||||
|
initialHour < 23 ? 0 : 59,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
setStartTime(formatTimeForInput(start));
|
||||||
|
setEndTime(formatTimeForInput(end));
|
||||||
|
setPurpose('consultation');
|
||||||
|
}
|
||||||
setError('');
|
setError('');
|
||||||
}, [open, scheduleDate, initialHour]);
|
}, [open, scheduleDate, initialHour, editingAppointment]);
|
||||||
|
|
||||||
if (!open || !providerUserId) {
|
if (!open || !providerUserId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -97,7 +108,7 @@ export function AppointmentBookingModal({
|
|||||||
if (!providerUserId) {
|
if (!providerUserId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!patient) {
|
if (!editingAppointment && !patient) {
|
||||||
setError('Select a patient first.');
|
setError('Select a patient first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -116,9 +127,22 @@ export function AppointmentBookingModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
if (compareLocalDayStart(scheduleDate, today) < 0) {
|
||||||
|
setError('Past appointments are view-only.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectivePatientId = editingAppointment?.patientId ?? patient?.id;
|
||||||
|
const effectiveProviderId = editingAppointment?.providerUserId ?? providerUserId;
|
||||||
|
if (!effectivePatientId || !effectiveProviderId) {
|
||||||
|
setError('Missing appointment details.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
patientId: patient.id,
|
patientId: effectivePatientId,
|
||||||
providerUserId,
|
providerUserId: effectiveProviderId,
|
||||||
startAt: startAt.toISOString(),
|
startAt: startAt.toISOString(),
|
||||||
endAt: endAt.toISOString(),
|
endAt: endAt.toISOString(),
|
||||||
purpose,
|
purpose,
|
||||||
@@ -135,7 +159,7 @@ export function AppointmentBookingModal({
|
|||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h2 id="appointment-modal-title" className="text-lg font-semibold text-text-primary pr-2">
|
<h2 id="appointment-modal-title" className="text-lg font-semibold text-text-primary pr-2">
|
||||||
New appointment
|
{editingAppointment ? 'Edit appointment' : 'New appointment'}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -154,7 +178,11 @@ export function AppointmentBookingModal({
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-secondary mb-1">Patient</label>
|
<label className="block text-sm font-medium text-text-secondary mb-1">Patient</label>
|
||||||
<p className="text-sm text-text-primary rounded-[var(--radius-md)] border border-border bg-background-secondary/60 px-3 py-2">
|
<p className="text-sm text-text-primary rounded-[var(--radius-md)] border border-border bg-background-secondary/60 px-3 py-2">
|
||||||
{patient ? `${patient.firstName} ${patient.lastName}` : '—'}
|
{editingAppointment
|
||||||
|
? `${editingAppointment.patient.firstName} ${editingAppointment.patient.lastName}`
|
||||||
|
: patient
|
||||||
|
? `${patient.firstName} ${patient.lastName}`
|
||||||
|
: '—'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ interface AppointmentScheduleGridProps {
|
|||||||
canDelete?: boolean;
|
canDelete?: boolean;
|
||||||
onDeleteAppointment?: (id: string) => void;
|
onDeleteAppointment?: (id: string) => void;
|
||||||
onSlotClick: (hour: number, providerUserId: string, providerName: string) => void;
|
onSlotClick: (hour: number, providerUserId: string, providerName: string) => void;
|
||||||
|
onAppointmentClick?: (appointment: AppointmentRecord) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppointmentScheduleGrid({
|
export function AppointmentScheduleGrid({
|
||||||
@@ -45,6 +46,7 @@ export function AppointmentScheduleGrid({
|
|||||||
canDelete = false,
|
canDelete = false,
|
||||||
onDeleteAppointment,
|
onDeleteAppointment,
|
||||||
onSlotClick,
|
onSlotClick,
|
||||||
|
onAppointmentClick,
|
||||||
}: AppointmentScheduleGridProps) {
|
}: AppointmentScheduleGridProps) {
|
||||||
const gridHeight = HOURS.length * HOUR_PX;
|
const gridHeight = HOURS.length * HOUR_PX;
|
||||||
|
|
||||||
@@ -120,9 +122,11 @@ export function AppointmentScheduleGrid({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
key={apt.id}
|
key={apt.id}
|
||||||
className={`absolute left-0.5 right-0.5 rounded-[var(--radius-sm)] border pointer-events-none z-10 flex flex-row items-center gap-1.5 px-1.5 py-1 min-h-[36px] ${purposeStyle(apt.purpose)}`}
|
onClick={() => onAppointmentClick?.(apt)}
|
||||||
|
className={`absolute left-0.5 right-0.5 rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex flex-row items-center gap-1.5 px-1.5 py-1 min-h-[36px] text-left ${purposeStyle(apt.purpose)} focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35`}
|
||||||
style={{ top: pos.top, height: pos.height, minHeight: 36 }}
|
style={{ top: pos.top, height: pos.height, minHeight: 36 }}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none flex-1 min-w-0 overflow-hidden text-left">
|
<div className="pointer-events-none flex-1 min-w-0 overflow-hidden text-left">
|
||||||
@@ -147,7 +151,7 @@ export function AppointmentScheduleGrid({
|
|||||||
<Trash2 className={`w-4 h-4 ${purposeDeleteIconClass(apt.purpose)}`} />
|
<Trash2 className={`w-4 h-4 ${purposeDeleteIconClass(apt.purpose)}`} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { addCalendarDays, compareLocalDayStart } from '@/lib/appointmentTime';
|
import { addCalendarDays } from '@/lib/appointmentTime';
|
||||||
|
|
||||||
interface ScheduleDayPickerProps {
|
interface ScheduleDayPickerProps {
|
||||||
value: Date;
|
value: Date;
|
||||||
onChange: (day: Date) => void;
|
onChange: (day: Date) => void;
|
||||||
/** Inclusive minimum calendar day (typically today at local midnight). */
|
/** Optional lower bound; picker navigation is unrestricted for history browsing. */
|
||||||
minDate: Date;
|
minDate?: Date;
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScheduleDayPicker({ value, onChange, minDate, label = 'Schedule date' }: ScheduleDayPickerProps) {
|
export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) {
|
||||||
const canGoPrev = compareLocalDayStart(value, minDate) > 0;
|
|
||||||
|
|
||||||
const labelText = value.toLocaleDateString(undefined, {
|
const labelText = value.toLocaleDateString(undefined, {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -27,9 +25,8 @@ export function ScheduleDayPicker({ value, onChange, minDate, label = 'Schedule
|
|||||||
<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"
|
||||||
disabled={!canGoPrev}
|
|
||||||
onClick={() => onChange(addCalendarDays(value, -1))}
|
onClick={() => onChange(addCalendarDays(value, -1))}
|
||||||
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 disabled:opacity-35 disabled:pointer-events-none 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"
|
||||||
aria-label="Previous day"
|
aria-label="Previous day"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4 icon-flat" />
|
<ChevronLeft className="h-4 w-4 icon-flat" />
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface CreateAppointmentBody {
|
|||||||
purpose: string;
|
purpose: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UpdateAppointmentBody = Partial<CreateAppointmentBody>;
|
||||||
|
|
||||||
export const appointmentsApi = {
|
export const appointmentsApi = {
|
||||||
columnProviders: async (): Promise<{ success: boolean; data: AppointmentColumnProvider[] }> => {
|
columnProviders: async (): Promise<{ success: boolean; data: AppointmentColumnProvider[] }> => {
|
||||||
const response = await apiClient.get('/appointments/column-providers');
|
const response = await apiClient.get('/appointments/column-providers');
|
||||||
@@ -25,6 +27,14 @@ export const appointmentsApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
update: async (
|
||||||
|
id: string,
|
||||||
|
body: UpdateAppointmentBody,
|
||||||
|
): Promise<{ success: boolean; data: AppointmentRecord }> => {
|
||||||
|
const response = await apiClient.patch(`/appointments/${id}`, body);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
remove: async (id: string): Promise<{ success: boolean }> => {
|
remove: async (id: string): Promise<{ success: boolean }> => {
|
||||||
const response = await apiClient.delete(`/appointments/${id}`);
|
const response = await apiClient.delete(`/appointments/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
Reference in New Issue
Block a user