/opt/mawid/apps/dashboard/app/(app)
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
= {
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 = {
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(null);
const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date()));
const [appointments, setAppointments] = useState(null);
const [summary, setSummary] = useState(null);
const [selected, setSelected] = useState(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'),
api(`/appointments?from=${weekStart.toISOString()}&to=${weekEnd.toISOString()}`),
]);
setClinic(clinicData);
setAppointments(appts);
}, [weekStart, weekEnd]);
useEffect(() => {
load().catch(() => setAppointments([]));
}, [load]);
useEffect(() => {
api('/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 (
{summary && (
{t('weeklySummary')}
)}
{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',
})}
{appointments === null ? (
) : (
{days.map((day) => (
{day.toLocaleDateString(lang === 'ar' ? 'ar' : lang, { weekday: 'short', day: 'numeric' })}
{apptsForDay(day).map((appt) => (
))}
))}
)}
{selected && clinic && (
setSelected(null)}
onChanged={() => {
setSelected(null);
load();
}}
/>
)}
{creating && clinic && (
setCreating(false)}
onCreated={() => {
setCreating(false);
load();
}}
/>
)}
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
);
}
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(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(
`/appointments/availability?serviceId=${appointment.service.id}&staffId=${appointment.staff.id}&fromDate=${from}&toDate=${to}`,
);
setSlots(result);
}
const isOpen = ['pending', 'confirmed'].includes(appointment.status);
return (
{t(`st_${appointment.status}`)}
{appointment.unconfirmedRisk && appointment.status === 'pending' && (
⚠ {t('atRisk')}
)}
{isOpen && (
{appointment.status === 'pending' && (
)}
)}
{slots && (
{t('pickSlot')}
{slots.length === 0 &&
{t('noSlots')}
}
{slots.slice(0, 60).map((slot) => (
))}
)}
);
}
function NewAppointmentModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const { t, lang } = useLang();
const [patientSearch, setPatientSearch] = useState('');
const [patients, setPatients] = useState([]);
const [patientId, setPatientId] = useState('');
const [services, setServices] = useState([]);
const [staff, setStaff] = useState([]);
const [serviceId, setServiceId] = useState('');
const [staffId, setStaffId] = useState('');
const [slots, setSlots] = useState(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
api('/services').then((rows) => setServices(rows.filter((r) => r.active)));
api('/staff').then(setStaff);
}, []);
useEffect(() => {
const handle = setTimeout(() => {
api(`/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(
`/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 (
setPatientSearch(e.target.value)}
/>
{patients.map((p) => (
))}
{serviceId && slots === null &&
}
{slots && (
{t('pickSlot')}
{slots.length === 0 &&
{t('noSlots')}
}
{slots.slice(0, 60).map((slot) => (
))}
)}
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
{label}
{value}
);
}