improvement/ui-ux-improvements #17

Merged
admin merged 3 commits from improvement/ui-ux-improvements into master 2026-05-08 14:30:15 +03:30
5 changed files with 127 additions and 45 deletions
Showing only changes of commit 803564802c - Show all commits

View File

@@ -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}
/> />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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;