/
opt
/
mawid
/
apps
/
dashboard
/
app
/
(app)
/
/opt/mawid/apps/dashboard/app/(app)
mkdir
upload
Name
Size
Mode
Actions
conversations/
-
0755
rm
patients/
-
0755
rm
settings/
-
0755
rm
layout.tsx
2671
0644
edit
dl
rm
page.tsx
15906
0644
edit
dl
rm
Edit:
/opt/mawid/apps/dashboard/app/(app)/page.tsx
(15906B)
'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { api, serviceName, timeInTz } from '@/lib/api'; import { useLang } from '@/lib/i18n'; import { Badge, Button, Card, Field, Input, Modal, Select, Spinner } from '@/components/ui'; interface Clinic { id: string; timezone: string; } interface Appointment { id: string; startsAt: string; endsAt: string; status: 'pending' | 'confirmed' | 'cancelled' | 'completed' | 'no_show'; unconfirmedRisk: boolean; patient: { id: string; name: string | null; waPhone: string }; staff: { id: string; name: string }; service: { id: string; name: unknown }; } interface Slot { startsAtUtc: string; local: string; staffId: string; staffName: string; } interface Summary { appointments: number; cancellations: number; noShows: number; confirmedAfterReminder: number; refilledSlots: number; } interface ServiceRow { id: string; name: unknown; active: boolean; } interface StaffRow { id: string; name: string; services: { id: string }[]; } interface PatientRow { id: string; name: string | null; waPhone: string; } const STATUS_STYLES: Record<Appointment['status'], string> = { pending: 'border-amber-300 bg-amber-50', confirmed: 'border-emerald-300 bg-emerald-50', cancelled: 'border-slate-200 bg-slate-50 opacity-60', completed: 'border-blue-300 bg-blue-50', no_show: 'border-red-300 bg-red-50', }; const STATUS_BADGE: Record<Appointment['status'], 'amber' | 'emerald' | 'slate' | 'blue' | 'red'> = { pending: 'amber', confirmed: 'emerald', cancelled: 'slate', completed: 'blue', no_show: 'red', }; const DAY_MS = 24 * 3600_000; function startOfWeek(date: Date): Date { const d = new Date(date); d.setHours(0, 0, 0, 0); const day = (d.getDay() + 6) % 7; // Monday = 0 return new Date(d.getTime() - day * DAY_MS); } export default function CalendarPage() { const { t, lang } = useLang(); const [clinic, setClinic] = useState<Clinic | null>(null); const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date())); const [appointments, setAppointments] = useState<Appointment[] | null>(null); const [summary, setSummary] = useState<Summary | null>(null); const [selected, setSelected] = useState<Appointment | null>(null); const [creating, setCreating] = useState(false); const weekEnd = useMemo(() => new Date(weekStart.getTime() + 7 * DAY_MS), [weekStart]); const load = useCallback(async () => { const [clinicData, appts] = await Promise.all([ api<Clinic>('/clinic'), api<Appointment[]>(`/appointments?from=${weekStart.toISOString()}&to=${weekEnd.toISOString()}`), ]); setClinic(clinicData); setAppointments(appts); }, [weekStart, weekEnd]); useEffect(() => { load().catch(() => setAppointments([])); }, [load]); useEffect(() => { api<Summary>('/summary/weekly').then(setSummary).catch(() => {}); }, []); const days = useMemo( () => Array.from({ length: 7 }, (_, i) => new Date(weekStart.getTime() + i * DAY_MS)), [weekStart], ); const tz = clinic?.timezone ?? 'Europe/Istanbul'; function apptsForDay(day: Date): Appointment[] { const next = new Date(day.getTime() + DAY_MS); return (appointments ?? []).filter((a) => { const start = new Date(a.startsAt); return start >= day && start < next; }); } return ( <div className="space-y-4"> {summary && ( <Card> <h2 className="mb-2 text-sm font-semibold text-slate-500">{t('weeklySummary')}</h2> <div className="grid grid-cols-2 gap-3 sm:grid-cols-5"> <Stat label={t('sAppointments')} value={summary.appointments} /> <Stat label={t('sCancellations')} value={summary.cancellations} /> <Stat label={t('sNoShows')} value={summary.noShows} /> <Stat label={t('sPrevented')} value={summary.confirmedAfterReminder} /> <Stat label={t('sRefilled')} value={summary.refilledSlots} /> </div> </Card> )} <div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex items-center gap-2"> <Button variant="secondary" onClick={() => setWeekStart(new Date(weekStart.getTime() - 7 * DAY_MS))}> ← </Button> <Button variant="secondary" onClick={() => setWeekStart(startOfWeek(new Date()))}> {t('today')} </Button> <Button variant="secondary" onClick={() => setWeekStart(new Date(weekStart.getTime() + 7 * DAY_MS))}> → </Button> <span className="ms-2 text-sm font-medium text-slate-600"> {weekStart.toLocaleDateString(lang === 'ar' ? 'ar' : lang, { day: 'numeric', month: 'short' })} {' – '} {new Date(weekEnd.getTime() - DAY_MS).toLocaleDateString(lang === 'ar' ? 'ar' : lang, { day: 'numeric', month: 'short', })} </span> </div> <Button onClick={() => setCreating(true)}>+ {t('newAppointment')}</Button> </div> {appointments === null ? ( <Spinner /> ) : ( <div className="grid grid-cols-1 gap-3 md:grid-cols-7"> {days.map((day) => ( <div key={day.toISOString()} className="min-h-24 rounded-xl border border-slate-200 bg-white p-2"> <div className="mb-2 text-center text-xs font-semibold text-slate-500"> {day.toLocaleDateString(lang === 'ar' ? 'ar' : lang, { weekday: 'short', day: 'numeric' })} </div> <div className="space-y-1.5"> {apptsForDay(day).map((appt) => ( <button key={appt.id} onClick={() => setSelected(appt)} className={`block w-full rounded-lg border p-1.5 text-start text-xs ${STATUS_STYLES[appt.status]}`} > <div className="font-semibold">{timeInTz(appt.startsAt, tz)}</div> <div className="truncate">{appt.patient.name ?? appt.patient.waPhone}</div> <div className="truncate text-slate-500">{serviceName(appt.service.name, lang)}</div> {appt.unconfirmedRisk && appt.status === 'pending' && ( <div className="mt-0.5"> <Badge color="red">⚠ {t('atRisk')}</Badge> </div> )} </button> ))} </div> </div> ))} </div> )} {selected && clinic && ( <AppointmentModal appointment={selected} tz={tz} onClose={() => setSelected(null)} onChanged={() => { setSelected(null); load(); }} /> )} {creating && clinic && ( <NewAppointmentModal onClose={() => setCreating(false)} onCreated={() => { setCreating(false); load(); }} /> )} </div> ); } function Stat({ label, value }: { label: string; value: number }) { return ( <div> <div className="text-2xl font-bold">{value}</div> <div className="text-xs text-slate-500">{label}</div> </div> ); } function AppointmentModal({ appointment, tz, onClose, onChanged, }: { appointment: Appointment; tz: string; onClose: () => void; onChanged: () => void; }) { const { t, lang } = useLang(); const [busy, setBusy] = useState(false); const [slots, setSlots] = useState<Slot[] | null>(null); async function patch(body: object) { setBusy(true); try { await api(`/appointments/${appointment.id}`, { method: 'PATCH', body: JSON.stringify(body) }); onChanged(); } finally { setBusy(false); } } async function cancel() { setBusy(true); try { await api(`/appointments/${appointment.id}/cancel`, { method: 'POST', body: JSON.stringify({}) }); onChanged(); } finally { setBusy(false); } } async function loadSlots() { const from = new Date().toISOString().slice(0, 10); const to = new Date(Date.now() + 14 * DAY_MS).toISOString().slice(0, 10); const result = await api<Slot[]>( `/appointments/availability?serviceId=${appointment.service.id}&staffId=${appointment.staff.id}&fromDate=${from}&toDate=${to}`, ); setSlots(result); } const isOpen = ['pending', 'confirmed'].includes(appointment.status); return ( <Modal open onClose={onClose} title={serviceName(appointment.service.name, lang)}> <div className="space-y-2 text-sm"> <Row label={t('patient')} value={`${appointment.patient.name ?? '—'} (${appointment.patient.waPhone})`} /> <Row label={t('staff')} value={appointment.staff.name} /> <Row label={t('calendar')} value={`${new Date(appointment.startsAt).toLocaleDateString(lang === 'ar' ? 'ar' : lang, { weekday: 'long', day: 'numeric', month: 'long' })} ${timeInTz(appointment.startsAt, tz)}`} /> <div className="flex items-center gap-2"> <Badge color={STATUS_BADGE[appointment.status]}>{t(`st_${appointment.status}`)}</Badge> {appointment.unconfirmedRisk && appointment.status === 'pending' && ( <Badge color="red">⚠ {t('atRisk')}</Badge> )} </div> </div> {isOpen && ( <div className="mt-4 flex flex-wrap gap-2"> {appointment.status === 'pending' && ( <Button disabled={busy} onClick={() => patch({ status: 'confirmed' })}> {t('confirm')} </Button> )} <Button variant="secondary" disabled={busy} onClick={() => patch({ status: 'completed' })}> {t('complete')} </Button> <Button variant="secondary" disabled={busy} onClick={() => patch({ status: 'no_show' })}> {t('markNoShow')} </Button> <Button variant="secondary" disabled={busy} onClick={loadSlots}> {t('reschedule')} </Button> <Button variant="danger" disabled={busy} onClick={cancel}> {t('cancelAppt')} </Button> </div> )} {slots && ( <div className="mt-4"> <h3 className="mb-2 text-sm font-semibold">{t('pickSlot')}</h3> {slots.length === 0 && <p className="text-sm text-slate-500">{t('noSlots')}</p>} <div className="grid max-h-48 grid-cols-2 gap-1.5 overflow-y-auto sm:grid-cols-3"> {slots.slice(0, 60).map((slot) => ( <button key={slot.startsAtUtc} disabled={busy} onClick={() => patch({ startsAtUtc: slot.startsAtUtc })} className="rounded-lg border border-slate-200 px-2 py-1.5 text-xs hover:border-emerald-500 hover:bg-emerald-50" > {slot.local} </button> ))} </div> </div> )} </Modal> ); } function NewAppointmentModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) { const { t, lang } = useLang(); const [patientSearch, setPatientSearch] = useState(''); const [patients, setPatients] = useState<PatientRow[]>([]); const [patientId, setPatientId] = useState(''); const [services, setServices] = useState<ServiceRow[]>([]); const [staff, setStaff] = useState<StaffRow[]>([]); const [serviceId, setServiceId] = useState(''); const [staffId, setStaffId] = useState(''); const [slots, setSlots] = useState<Slot[] | null>(null); const [busy, setBusy] = useState(false); useEffect(() => { api<ServiceRow[]>('/services').then((rows) => setServices(rows.filter((r) => r.active))); api<StaffRow[]>('/staff').then(setStaff); }, []); useEffect(() => { const handle = setTimeout(() => { api<PatientRow[]>(`/patients?search=${encodeURIComponent(patientSearch)}`).then(setPatients); }, 250); return () => clearTimeout(handle); }, [patientSearch]); const eligibleStaff = useMemo( () => staff.filter((s) => !serviceId || s.services.some((sv) => sv.id === serviceId)), [staff, serviceId], ); useEffect(() => { setSlots(null); if (!serviceId) return; const from = new Date().toISOString().slice(0, 10); const to = new Date(Date.now() + 14 * DAY_MS).toISOString().slice(0, 10); api<Slot[]>( `/appointments/availability?serviceId=${serviceId}${staffId ? `&staffId=${staffId}` : ''}&fromDate=${from}&toDate=${to}`, ).then(setSlots); }, [serviceId, staffId]); async function create(slot: Slot) { setBusy(true); try { await api('/appointments', { method: 'POST', body: JSON.stringify({ patientId, serviceId, staffId: slot.staffId, startsAtUtc: slot.startsAtUtc, }), }); onCreated(); } finally { setBusy(false); } } return ( <Modal open onClose={onClose} title={t('newAppointment')}> <div className="space-y-3"> <Field label={t('patient')}> <Input placeholder={t('searchPatient')} value={patientSearch} onChange={(e) => setPatientSearch(e.target.value)} /> <div className="mt-1 max-h-32 overflow-y-auto rounded-lg border border-slate-100"> {patients.map((p) => ( <button key={p.id} onClick={() => setPatientId(p.id)} className={`block w-full px-3 py-1.5 text-start text-sm ${ patientId === p.id ? 'bg-emerald-50 font-semibold text-emerald-700' : 'hover:bg-slate-50' }`} > {p.name ?? '—'} <span className="text-slate-400">{p.waPhone}</span> </button> ))} </div> </Field> <Field label={t('service')}> <Select value={serviceId} onChange={(e) => setServiceId(e.target.value)}> <option value="">—</option> {services.map((s) => ( <option key={s.id} value={s.id}> {serviceName(s.name, lang)} </option> ))} </Select> </Field> <Field label={t('staff')}> <Select value={staffId} onChange={(e) => setStaffId(e.target.value)}> <option value="">—</option> {eligibleStaff.map((s) => ( <option key={s.id} value={s.id}> {s.name} </option> ))} </Select> </Field> {serviceId && slots === null && <Spinner />} {slots && ( <div> <h3 className="mb-2 text-sm font-semibold">{t('pickSlot')}</h3> {slots.length === 0 && <p className="text-sm text-slate-500">{t('noSlots')}</p>} <div className="grid max-h-48 grid-cols-2 gap-1.5 overflow-y-auto sm:grid-cols-3"> {slots.slice(0, 60).map((slot) => ( <button key={`${slot.staffId}-${slot.startsAtUtc}`} disabled={busy || !patientId} onClick={() => create(slot)} className="rounded-lg border border-slate-200 px-2 py-1.5 text-xs hover:border-emerald-500 hover:bg-emerald-50 disabled:opacity-40" > <div>{slot.local}</div> <div className="text-slate-400">{slot.staffName}</div> </button> ))} </div> </div> )} </div> </Modal> ); } function Row({ label, value }: { label: string; value: string }) { return ( <div className="flex justify-between gap-4"> <span className="text-slate-500">{label}</span> <span className="text-end font-medium">{value}</span> </div> ); }
Save
cmd:
run