improvement/ui-ux-improvements #17
@@ -17,7 +17,7 @@ import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker';
|
||||
import { Toast } from '@/components/ui/common/Toast';
|
||||
import type { AppointmentPurpose } from '@/types/appointment';
|
||||
import { formatApiErrorMessage } from '@/lib/formatApiError';
|
||||
import { getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime';
|
||||
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime';
|
||||
|
||||
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
||||
firstName: '',
|
||||
@@ -48,6 +48,7 @@ export default function AppointmentsPage() {
|
||||
const [bookingHour, setBookingHour] = useState(9);
|
||||
const [bookingProviderId, setBookingProviderId] = useState<string | null>(null);
|
||||
const [bookingProviderName, setBookingProviderName] = useState('');
|
||||
const [editingAppointmentId, setEditingAppointmentId] = useState<string | null>(null);
|
||||
const [savingAppointment, setSavingAppointment] = useState(false);
|
||||
|
||||
const [toastError, setToastError] = useState('');
|
||||
@@ -58,6 +59,14 @@ export default function AppointmentsPage() {
|
||||
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
|
||||
|
||||
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);
|
||||
|
||||
@@ -155,6 +164,12 @@ export default function AppointmentsPage() {
|
||||
}
|
||||
|
||||
function handleSlotClick(hour: number, providerUserId: string, providerName: string) {
|
||||
if (isViewingPastDay) {
|
||||
setToastSuccess('');
|
||||
setToastError('');
|
||||
setToastInfo('Past appointments are view-only.');
|
||||
return;
|
||||
}
|
||||
if (!selectedPatient) {
|
||||
setToastSuccess('');
|
||||
setToastError('');
|
||||
@@ -164,6 +179,22 @@ export default function AppointmentsPage() {
|
||||
setBookingHour(hour);
|
||||
setBookingProviderId(providerUserId);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -179,14 +210,21 @@ export default function AppointmentsPage() {
|
||||
setToastSuccess('');
|
||||
setToastInfo('');
|
||||
try {
|
||||
if (activeEditingAppointment) {
|
||||
await appointmentsApi.update(activeEditingAppointment.id, payload);
|
||||
} else {
|
||||
await appointmentsApi.create(payload);
|
||||
}
|
||||
setBookingOpen(false);
|
||||
setToastSuccess('Appointment saved.');
|
||||
setEditingAppointmentId(null);
|
||||
setToastSuccess(activeEditingAppointment ? 'Appointment updated.' : 'Appointment saved.');
|
||||
await loadSchedule();
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err && typeof err === 'object' && 'message' in err
|
||||
? String((err as { message: unknown }).message)
|
||||
: activeEditingAppointment
|
||||
? 'Could not update appointment.'
|
||||
: 'Could not save appointment.';
|
||||
setToastError(message);
|
||||
} finally {
|
||||
@@ -285,10 +323,11 @@ export default function AppointmentsPage() {
|
||||
day={scheduleDate}
|
||||
providers={providers}
|
||||
appointments={appointments}
|
||||
canBook={canManageAppointments}
|
||||
canBook={canManageAppointments && !isViewingPastDay}
|
||||
canDelete={canManageAppointments}
|
||||
onDeleteAppointment={(id) => void handleDeleteAppointment(id)}
|
||||
onSlotClick={(hour, uid, name) => handleSlotClick(hour, uid, name)}
|
||||
onAppointmentClick={(apt) => handleAppointmentClick(apt)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,7 +339,11 @@ export default function AppointmentsPage() {
|
||||
providerUserId={bookingProviderId}
|
||||
providerName={bookingProviderName}
|
||||
initialHour={bookingHour}
|
||||
onClose={() => setBookingOpen(false)}
|
||||
editingAppointment={activeEditingAppointment}
|
||||
onClose={() => {
|
||||
setBookingOpen(false);
|
||||
setEditingAppointmentId(null);
|
||||
}}
|
||||
onSubmit={handleSaveAppointment}
|
||||
loading={savingAppointment}
|
||||
/>
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useEffect, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/common/Button';
|
||||
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 type { Patient } from '@/types/patient';
|
||||
import {
|
||||
combineLocalDateAndTime,
|
||||
compareLocalDayStart,
|
||||
formatTimeForInput,
|
||||
isSameLocalCalendarDay,
|
||||
} from '@/lib/appointmentTime';
|
||||
@@ -28,6 +29,7 @@ interface AppointmentBookingModalProps {
|
||||
endAt: string;
|
||||
purpose: AppointmentPurpose;
|
||||
}) => Promise<void>;
|
||||
editingAppointment?: AppointmentRecord | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
@@ -40,6 +42,7 @@ export function AppointmentBookingModal({
|
||||
initialHour,
|
||||
onClose,
|
||||
onSubmit,
|
||||
editingAppointment = null,
|
||||
loading = false,
|
||||
}: AppointmentBookingModalProps) {
|
||||
const [startTime, setStartTime] = useState('09:00');
|
||||
@@ -61,6 +64,13 @@ export function AppointmentBookingModal({
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
if (editingAppointment) {
|
||||
const start = new Date(editingAppointment.startAt);
|
||||
const end = new Date(editingAppointment.endAt);
|
||||
setStartTime(formatTimeForInput(start));
|
||||
setEndTime(formatTimeForInput(end));
|
||||
setPurpose((editingAppointment.purpose as AppointmentPurpose) ?? 'consultation');
|
||||
} else {
|
||||
const start = new Date(
|
||||
scheduleDate.getFullYear(),
|
||||
scheduleDate.getMonth(),
|
||||
@@ -82,8 +92,9 @@ export function AppointmentBookingModal({
|
||||
setStartTime(formatTimeForInput(start));
|
||||
setEndTime(formatTimeForInput(end));
|
||||
setPurpose('consultation');
|
||||
}
|
||||
setError('');
|
||||
}, [open, scheduleDate, initialHour]);
|
||||
}, [open, scheduleDate, initialHour, editingAppointment]);
|
||||
|
||||
if (!open || !providerUserId) {
|
||||
return null;
|
||||
@@ -97,7 +108,7 @@ export function AppointmentBookingModal({
|
||||
if (!providerUserId) {
|
||||
return;
|
||||
}
|
||||
if (!patient) {
|
||||
if (!editingAppointment && !patient) {
|
||||
setError('Select a patient first.');
|
||||
return;
|
||||
}
|
||||
@@ -116,9 +127,22 @@ export function AppointmentBookingModal({
|
||||
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({
|
||||
patientId: patient.id,
|
||||
providerUserId,
|
||||
patientId: effectivePatientId,
|
||||
providerUserId: effectiveProviderId,
|
||||
startAt: startAt.toISOString(),
|
||||
endAt: endAt.toISOString(),
|
||||
purpose,
|
||||
@@ -135,7 +159,7 @@ export function AppointmentBookingModal({
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h2 id="appointment-modal-title" className="text-lg font-semibold text-text-primary pr-2">
|
||||
New appointment
|
||||
{editingAppointment ? 'Edit appointment' : 'New appointment'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -154,7 +178,11 @@ export function AppointmentBookingModal({
|
||||
<div>
|
||||
<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">
|
||||
{patient ? `${patient.firstName} ${patient.lastName}` : '—'}
|
||||
{editingAppointment
|
||||
? `${editingAppointment.patient.firstName} ${editingAppointment.patient.lastName}`
|
||||
: patient
|
||||
? `${patient.firstName} ${patient.lastName}`
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ interface AppointmentScheduleGridProps {
|
||||
canDelete?: boolean;
|
||||
onDeleteAppointment?: (id: string) => void;
|
||||
onSlotClick: (hour: number, providerUserId: string, providerName: string) => void;
|
||||
onAppointmentClick?: (appointment: AppointmentRecord) => void;
|
||||
}
|
||||
|
||||
export function AppointmentScheduleGrid({
|
||||
@@ -45,6 +46,7 @@ export function AppointmentScheduleGrid({
|
||||
canDelete = false,
|
||||
onDeleteAppointment,
|
||||
onSlotClick,
|
||||
onAppointmentClick,
|
||||
}: AppointmentScheduleGridProps) {
|
||||
const gridHeight = HOURS.length * HOUR_PX;
|
||||
|
||||
@@ -120,9 +122,11 @@ export function AppointmentScheduleGrid({
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
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 }}
|
||||
>
|
||||
<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)}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { addCalendarDays, compareLocalDayStart } from '@/lib/appointmentTime';
|
||||
import { addCalendarDays } from '@/lib/appointmentTime';
|
||||
|
||||
interface ScheduleDayPickerProps {
|
||||
value: Date;
|
||||
onChange: (day: Date) => void;
|
||||
/** Inclusive minimum calendar day (typically today at local midnight). */
|
||||
minDate: Date;
|
||||
/** Optional lower bound; picker navigation is unrestricted for history browsing. */
|
||||
minDate?: Date;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function ScheduleDayPicker({ value, onChange, minDate, label = 'Schedule date' }: ScheduleDayPickerProps) {
|
||||
const canGoPrev = compareLocalDayStart(value, minDate) > 0;
|
||||
|
||||
export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) {
|
||||
const labelText = value.toLocaleDateString(undefined, {
|
||||
weekday: '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)]">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canGoPrev}
|
||||
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"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 icon-flat" />
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface CreateAppointmentBody {
|
||||
purpose: string;
|
||||
}
|
||||
|
||||
export type UpdateAppointmentBody = Partial<CreateAppointmentBody>;
|
||||
|
||||
export const appointmentsApi = {
|
||||
columnProviders: async (): Promise<{ success: boolean; data: AppointmentColumnProvider[] }> => {
|
||||
const response = await apiClient.get('/appointments/column-providers');
|
||||
@@ -25,6 +27,14 @@ export const appointmentsApi = {
|
||||
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 }> => {
|
||||
const response = await apiClient.delete(`/appointments/${id}`);
|
||||
return response.data;
|
||||
|
||||
Reference in New Issue
Block a user