812 lines
36 KiB
TypeScript
812 lines
36 KiB
TypeScript
import React, { useRef, useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
PlusIcon, ChevronRightIcon, ChevronLeftIcon, CalendarDaysIcon,
|
|
TableCellsIcon, ClockIcon, UserCircleIcon, PhoneIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import type { PaginatedResponse, ApiResponse } from '../lib/api';
|
|
import type { Appointment } from '../types';
|
|
import { formatDate, toGregorianDate } from '../lib/utils';
|
|
import { useAuthStore } from '../stores/authStore';
|
|
import AppointmentStatusDropdown from '../components/ui/AppointmentStatusDropdown';
|
|
import PersianCalendar from '../components/ui/PersianCalendar';
|
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
|
|
|
const EMPTY_ARR: Appointment[] = [];
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Status config
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const STATUS_META: Record<string, { label: string; color: string; bg: string }> = {
|
|
pending: { label: 'رزرو شده', color: 'var(--info)', bg: 'var(--info-bg)' },
|
|
confirmed: { label: 'تأیید شده', color: 'var(--success)', bg: 'var(--success-bg)' },
|
|
completed: { label: 'تکمیل شده', color: 'var(--success)', bg: 'var(--success-bg)' },
|
|
cancelled_by_doctor: { label: 'لغو پزشک', color: 'var(--danger)', bg: 'var(--danger-bg)' },
|
|
cancelled_by_user: { label: 'لغو بیمار', color: 'var(--danger)', bg: 'var(--danger-bg)' },
|
|
no_show: { label: 'غیبت', color: 'var(--text-3)', bg: 'var(--surface-2)' },
|
|
expired: { label: 'منقضی', color: 'var(--text-3)', bg: 'var(--surface-2)' },
|
|
};
|
|
|
|
function statusMeta(s: string) {
|
|
return STATUS_META[s] ?? { label: s, color: 'var(--text-3)', bg: 'var(--surface-2)' };
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Stats icons (inline SVG)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function IconTotal() {
|
|
const dots: [number, number][] = [];
|
|
for (const x of [10, 16, 22, 28, 34]) for (const y of [10, 16, 22, 28, 34]) dots.push([x, y]);
|
|
return (
|
|
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
|
|
<circle cx="22" cy="22" r="22" fill="#ede9fe" />
|
|
{dots.map(([x, y]) => <circle key={`${x}-${y}`} cx={x} cy={y} r="1.8" fill="#7c3aed" opacity="0.6" />)}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconCompleted() {
|
|
return (
|
|
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
|
|
<circle cx="22" cy="22" r="22" fill="#dcfce7" />
|
|
<ellipse cx="22" cy="18" rx="6" ry="7" fill="#16a34a" opacity="0.8" />
|
|
<ellipse cx="22" cy="32" rx="10" ry="6" fill="#16a34a" opacity="0.5" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconWaiting() {
|
|
return (
|
|
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
|
|
<circle cx="22" cy="22" r="22" fill="#fff7ed" />
|
|
<path d="M22 14v8l5 3" stroke="#ea580c" strokeWidth="2.5" strokeLinecap="round" />
|
|
<circle cx="22" cy="22" r="9" stroke="#ea580c" strokeWidth="2" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconCancelled() {
|
|
return (
|
|
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
|
|
<circle cx="22" cy="22" r="22" fill="#fef2f2" />
|
|
<circle cx="22" cy="22" r="9" stroke="#ef4444" strokeWidth="2" />
|
|
<path d="M17 17l10 10M27 17l-10 10" stroke="#ef4444" strokeWidth="2" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Stats Bar
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
interface TodayStats { total: number; completed: number; waiting: number; cancelled: number; }
|
|
|
|
function StatsBar({ date, isAdmin }: { date: string; isAdmin: boolean }) {
|
|
const { data } = useQuery<ApiResponse<TodayStats>>({
|
|
queryKey: ['appt-today-stats', date, isAdmin],
|
|
queryFn: () => api.get(
|
|
isAdmin
|
|
? `/api/v1/admin/appointments/today-stats?date=${date}`
|
|
: `/api/v1/my/appointments/today-stats?date=${date}`
|
|
),
|
|
});
|
|
const s = data?.data ?? { total: 0, completed: 0, waiting: 0, cancelled: 0 };
|
|
const stats = [
|
|
{ label: 'کل نوبتهای امروز', value: s.total, icon: <IconTotal /> },
|
|
{ label: 'نوبتهای انجام شده', value: s.completed, icon: <IconCompleted /> },
|
|
{ label: 'مراجعین در انتظار', value: s.waiting, icon: <IconWaiting /> },
|
|
{ label: 'نوبتهای لغو شده', value: s.cancelled, icon: <IconCancelled /> },
|
|
];
|
|
return (
|
|
<div style={{
|
|
display: 'flex', background: 'var(--surface)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--r)', overflow: 'hidden', marginBottom: 16,
|
|
}}>
|
|
{stats.map((s, i) => (
|
|
<div key={i} style={{
|
|
flex: 1, padding: '16px 20px', display: 'flex', alignItems: 'center', gap: 14,
|
|
borderRight: i < 3 ? '1px solid var(--border)' : 'none',
|
|
}}>
|
|
<div style={{ flexShrink: 0 }}>{s.icon}</div>
|
|
<div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 4 }}>{s.label}</div>
|
|
<div style={{ fontSize: 26, fontWeight: 800, color: 'var(--text)' }}>
|
|
{s.value.toLocaleString('fa-IR')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Date Navigator
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const WEEK_DAYS_FA = ['یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه', 'شنبه'];
|
|
|
|
function getPersianWeekDay(gregorianDate: string): string {
|
|
return WEEK_DAYS_FA[new Date(gregorianDate + 'T12:00:00').getDay()];
|
|
}
|
|
|
|
function DateNavigator({ date, onChange }: { date: string; onChange: (d: string) => void }) {
|
|
const [showCal, setShowCal] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
const weekDay = getPersianWeekDay(date);
|
|
const isFriday = new Date(date + 'T12:00:00').getDay() === 5;
|
|
|
|
function addDays(n: number) {
|
|
const d = new Date(date + 'T12:00:00');
|
|
d.setDate(d.getDate() + n);
|
|
onChange(toGregorianDate(d));
|
|
}
|
|
|
|
return (
|
|
<div ref={ref} style={{ display: 'flex', alignItems: 'center', gap: 2, position: 'relative' }}>
|
|
<button className="btn sm" style={navBtnSx} onClick={() => addDays(1)}>
|
|
<ChevronRightIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
<div style={{
|
|
padding: '0 14px', height: 44,
|
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--r-sm)', minWidth: 130, gap: 1,
|
|
}}>
|
|
<span style={{ fontSize: 13, fontWeight: 700, lineHeight: 1.2, color: isFriday ? '#ef4444' : 'var(--text)' }}>
|
|
{weekDay}
|
|
</span>
|
|
<span style={{ fontSize: 11, color: 'var(--text-2)', lineHeight: 1.2 }}>
|
|
{formatDate(date)}
|
|
</span>
|
|
</div>
|
|
<button className="btn sm" style={navBtnSx} onClick={() => addDays(-1)}>
|
|
<ChevronLeftIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
<button className="btn sm" style={navBtnSx} onClick={() => setShowCal(c => !c)}>
|
|
<CalendarDaysIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
{showCal && (
|
|
<PersianCalendar value={date} onChange={v => { onChange(v); setShowCal(false); }} onClose={() => setShowCal(false)} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const navBtnSx: React.CSSProperties = {
|
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--r-sm)', height: 36, width: 36,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
cursor: 'pointer', color: 'var(--text-2)',
|
|
};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Table View
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function TableView({
|
|
items, loading, queryKey, showDoctor,
|
|
}: {
|
|
items: Appointment[]; loading: boolean; queryKey: unknown[]; showDoctor: boolean;
|
|
}) {
|
|
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
|
|
if (!items.length) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>نوبتی برای این روز ثبت نشده است</div>;
|
|
|
|
return (
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
|
<thead>
|
|
<tr style={{ borderBottom: '1px solid var(--border)', background: 'var(--surface-2)' }}>
|
|
<th style={th}>ردیف</th>
|
|
<th style={th}>نام بیمار</th>
|
|
<th style={th}>شماره تماس</th>
|
|
{showDoctor && <th style={th}>پزشک</th>}
|
|
<th style={th}>شروع</th>
|
|
<th style={th}>پایان</th>
|
|
<th style={th}>وضعیت</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((a, i) => (
|
|
<tr key={a.uuid} style={{ borderBottom: '1px solid var(--border)' }}>
|
|
<td style={td}>{(i + 1).toLocaleString('fa-IR')}</td>
|
|
<td style={td}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<UserCircleIcon style={{ width: 18, height: 18, color: 'var(--text-3)' }} />
|
|
{a.patient_name || '—'}
|
|
</div>
|
|
</td>
|
|
<td style={{ ...td, direction: 'ltr', textAlign: 'right' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, justifyContent: 'flex-end' }}>
|
|
<PhoneIcon style={{ width: 14, height: 14, color: 'var(--text-3)' }} />
|
|
{a.patient_mobile}
|
|
</div>
|
|
</td>
|
|
{showDoctor && <td style={td}>{a.doctor_name}</td>}
|
|
<td style={{ ...td, fontWeight: 600 }}>{a.appointment_time}</td>
|
|
<td style={td}>{a.end_time}</td>
|
|
<td style={td}>
|
|
<AppointmentStatusDropdown
|
|
uuid={a.uuid}
|
|
currentStatus={a.status}
|
|
version={a.version}
|
|
queryKey={queryKey}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const th: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', fontWeight: 600, color: 'var(--text-2)', whiteSpace: 'nowrap' };
|
|
const td: React.CSSProperties = { padding: '10px 14px', textAlign: 'right', color: 'var(--text)', verticalAlign: 'middle' };
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Schedule View — Slot Card
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
interface SlotItem {
|
|
start: number;
|
|
end: number;
|
|
start_time: string;
|
|
end_time: string;
|
|
is_available: boolean;
|
|
appointment: Appointment | null;
|
|
cancelled_appointment: Appointment | null;
|
|
}
|
|
|
|
interface SessionGroup {
|
|
start_time: string;
|
|
end_time: string;
|
|
slots: SlotItem[];
|
|
}
|
|
|
|
function CancelledBadge({ appt }: { appt: Appointment }) {
|
|
const sm = statusMeta(appt.status);
|
|
return (
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px',
|
|
borderRadius: 'var(--r-sm)', background: '#f9fafb',
|
|
border: '1px dashed var(--border-2)', fontSize: 12, color: 'var(--text-3)',
|
|
marginBottom: 4,
|
|
}}>
|
|
<span style={{ fontSize: 14, lineHeight: 1 }}>⊘</span>
|
|
<span style={{
|
|
padding: '1px 6px', borderRadius: 99, fontSize: 11, fontWeight: 700,
|
|
background: `${sm.color}15`, color: sm.color, whiteSpace: 'nowrap',
|
|
}}>{sm.label}</span>
|
|
<span style={{ direction: 'ltr', color: 'var(--text)', fontWeight: 600, whiteSpace: 'nowrap' }}>
|
|
{appt.appointment_time} — {appt.end_time}
|
|
</span>
|
|
<span style={{ fontWeight: 600, color: 'var(--text)' }}>{appt.patient_name || '—'}</span>
|
|
<span style={{ direction: 'ltr', color: 'var(--text-3)' }}>{appt.patient_mobile}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SlotCard({ slot, queryKey, onBook }: { slot: SlotItem; queryKey: unknown[]; onBook: (slot: SlotItem) => void }) {
|
|
const isPast = slot.start < Math.floor(Date.now() / 1000);
|
|
|
|
if (!slot.is_available && slot.appointment) {
|
|
const a = slot.appointment;
|
|
const sm = statusMeta(a.status);
|
|
return (
|
|
<div style={{
|
|
border: `1.5px solid ${sm.color}40`, borderRadius: 'var(--r)',
|
|
background: sm.bg, overflow: 'hidden',
|
|
opacity: isPast ? 0.6 : 1,
|
|
}}>
|
|
<div style={{
|
|
padding: '8px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
borderBottom: `1px solid ${sm.color}20`,
|
|
}}>
|
|
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
|
|
<div style={{ fontSize: 12, color: 'var(--text-3)', fontWeight: 600 }}>
|
|
{slot.start_time} — {slot.end_time}
|
|
</div>
|
|
</div>
|
|
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
|
<UserCircleIcon style={{ width: 16, height: 16, color: 'var(--text-3)' }} />
|
|
<span style={{ fontWeight: 600 }}>{a.patient_name || '—'}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)' }}>
|
|
<PhoneIcon style={{ width: 14, height: 14 }} />
|
|
<span style={{ direction: 'ltr' }}>{a.patient_mobile}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Available slot — possibly with cancelled history
|
|
if (isPast) {
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
{slot.cancelled_appointment && <CancelledBadge appt={slot.cancelled_appointment} />}
|
|
<div style={{
|
|
border: '1.5px dashed var(--border-2)', borderRadius: 'var(--r)', padding: '10px 16px',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
cursor: 'not-allowed', color: 'var(--text-3)', fontSize: 13, fontWeight: 600,
|
|
background: 'var(--surface-2)', gap: 6,
|
|
}}>
|
|
<span style={{ fontSize: 15 }}>⊘</span> گذشته
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
{slot.cancelled_appointment && <CancelledBadge appt={slot.cancelled_appointment} />}
|
|
<div
|
|
onClick={() => onBook(slot)}
|
|
style={{
|
|
border: '1.5px dashed color-mix(in oklch, var(--info) 50%, transparent)', borderRadius: 'var(--r)', padding: '10px 16px',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
cursor: 'pointer', color: 'var(--info)', fontSize: 13, fontWeight: 600,
|
|
background: 'var(--info-bg)', transition: 'all 0.15s', gap: 6,
|
|
}}
|
|
onMouseEnter={e => (e.currentTarget.style.background = 'color-mix(in oklch, var(--info) 20%, transparent)')}
|
|
onMouseLeave={e => (e.currentTarget.style.background = 'var(--info-bg)')}
|
|
>
|
|
<span style={{ fontSize: 18 }}>⊕</span> نوبت جدید
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SessionDivider({ startTime, endTime, count }: { startTime: string; endTime: string; count: number }) {
|
|
const hour = parseInt(startTime.split(':')[0], 10);
|
|
const isAm = hour < 12;
|
|
const icon = isAm ? '🌅' : '🌆';
|
|
const label = isAm ? 'صبح' : 'عصر';
|
|
const color = isAm ? '#f59e0b' : '#6366f1';
|
|
return (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 2px' }}>
|
|
<span style={{ fontSize: 15 }}>{icon}</span>
|
|
<span style={{ fontWeight: 700, fontSize: 13, color }}>{label}</span>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)', fontWeight: 500 }}>
|
|
{startTime} — {endTime}
|
|
</span>
|
|
<span style={{
|
|
background: `${color}18`, borderRadius: 99, padding: '1px 8px',
|
|
fontSize: 11, color, fontWeight: 600,
|
|
}}>{count} نوبت</span>
|
|
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ScheduleView({
|
|
sessions, loading, queryKey, onBook,
|
|
}: {
|
|
sessions: SessionGroup[]; loading: boolean; queryKey: unknown[]; onBook: (s: SlotItem) => void;
|
|
}) {
|
|
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
|
|
if (!sessions.length) return (
|
|
<div style={{ padding: 40, textAlign: 'center' }}>
|
|
<div style={{ fontSize: 32, marginBottom: 8 }}>🏖️</div>
|
|
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>این روز تعطیل است</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>هیچ برنامه زمانبندی برای این روز تنظیم نشده است</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '4px 0' }}>
|
|
{sessions.map((session, si) => (
|
|
<div key={si} style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: si > 0 ? 16 : 0 }}>
|
|
<SessionDivider startTime={session.start_time} endTime={session.end_time} count={session.slots.length} />
|
|
{session.slots.map((slot, i) => (
|
|
<div key={`${slot.start}-${i}`} style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'center' }}>
|
|
<SlotCard slot={slot} queryKey={queryKey} onBook={onBook} />
|
|
<div style={{
|
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
|
|
fontSize: 12, color: 'var(--text-3)', fontWeight: 600, minWidth: 48,
|
|
}}>
|
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--text-3)', display: 'block' }} />
|
|
{slot.start_time}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// New Appointment Modal
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
interface BookingSlot { start: number; end: number; start_time: string; end_time: string; doctor_uuid: string; doctor_name: string; }
|
|
|
|
function NewAppointmentModal({
|
|
slot, onClose, onSuccess,
|
|
}: { slot: BookingSlot; onClose: () => void; onSuccess: () => void }) {
|
|
const [mobile, setMobile] = useState('');
|
|
const [patientName, setPatientName] = useState('');
|
|
|
|
const role = useAuthStore(s => s.primaryRole);
|
|
const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment';
|
|
|
|
const isValid = mobile.length >= 10 && patientName.trim().length >= 2;
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: () => api.post(createEndpoint, {
|
|
doctor_uuid: slot.doctor_uuid,
|
|
slot_start: slot.start,
|
|
slot_end: slot.end,
|
|
patient_mobile: mobile,
|
|
patient_name: patientName.trim(),
|
|
}),
|
|
onSuccess: () => {
|
|
toast.success('نوبت با موفقیت ثبت شد');
|
|
onSuccess();
|
|
onClose();
|
|
},
|
|
onError: (e: any) => {
|
|
const msg = e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در ثبت نوبت';
|
|
toast.error(msg);
|
|
},
|
|
});
|
|
|
|
const inputSx: React.CSSProperties = {
|
|
width: '100%', height: 38, padding: '0 10px', borderRadius: 'var(--r-sm)',
|
|
border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13,
|
|
boxSizing: 'border-box',
|
|
};
|
|
const labelSx: React.CSSProperties = {
|
|
display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-2)', marginBottom: 6,
|
|
};
|
|
|
|
return (
|
|
<div style={{
|
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 500,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}} onClick={onClose}>
|
|
<div style={{
|
|
background: 'var(--surface)', borderRadius: 'var(--r)', padding: 24,
|
|
minWidth: 320, maxWidth: 400, width: '90vw', boxShadow: 'var(--shadow-xl)',
|
|
}} onClick={e => e.stopPropagation()}>
|
|
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 4 }}>ثبت نوبت</div>
|
|
<div style={{ fontSize: 13, color: 'var(--text-2)', marginBottom: 16 }}>
|
|
{slot.start_time} تا {slot.end_time} — {slot.doctor_name}
|
|
</div>
|
|
|
|
<div style={{ marginBottom: 12 }}>
|
|
<label style={labelSx}>نام و نام خانوادگی بیمار *</label>
|
|
<input
|
|
type="text"
|
|
value={patientName}
|
|
onChange={e => setPatientName(e.target.value)}
|
|
placeholder="مثال: علی محمدی"
|
|
style={inputSx}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ marginBottom: 16 }}>
|
|
<label style={labelSx}>شماره موبایل بیمار *</label>
|
|
<input
|
|
type="tel"
|
|
value={mobile}
|
|
onChange={e => setMobile(e.target.value)}
|
|
placeholder="مثال: 09123456789"
|
|
style={{ ...inputSx, direction: 'ltr' }}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
<button className="btn sm" onClick={onClose}>انصراف</button>
|
|
<button
|
|
className="btn primary sm"
|
|
onClick={() => mutation.mutate()}
|
|
disabled={!isValid || mutation.isPending}
|
|
>
|
|
{mutation.isPending ? '...' : 'ثبت نوبت'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Main Page
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export default function AppointmentsPage() {
|
|
const primaryRole = useAuthStore(s => s.primaryRole);
|
|
const dbUuid = useAuthStore(s => s.dbUuid);
|
|
const isAdmin = primaryRole === 'admin';
|
|
const isClinic = primaryRole === 'clinic';
|
|
const isDoctor = primaryRole === 'doctor';
|
|
const isRepresentation = primaryRole === 'representation';
|
|
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const [selectedDate, setSelectedDate] = useState(today);
|
|
const [viewMode, setViewMode] = useState<'table' | 'schedule'>('schedule');
|
|
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor && dbUuid ? dbUuid : '');
|
|
const [bookingSlot, setBookingSlot] = useState<BookingSlot | null>(null);
|
|
const [bookingHint, setBookingHint] = useState(false);
|
|
const qc = useQueryClient();
|
|
|
|
// ── Appointments query
|
|
const apptEndpoint = isAdmin
|
|
? '/api/v1/admin/appointments'
|
|
: isRepresentation
|
|
? '/api/v1/representation/appointments'
|
|
: '/api/v1/my/appointments';
|
|
const apptQueryKey = ['appointments', apptEndpoint, selectedDate, selectedDoctorUuid];
|
|
const apptParams = new URLSearchParams({ date: selectedDate, limit: '500' });
|
|
if (selectedDoctorUuid) apptParams.set('doctor_uuid', selectedDoctorUuid);
|
|
|
|
const apptQuery = useQuery<PaginatedResponse<Appointment>>({
|
|
queryKey: apptQueryKey,
|
|
queryFn: () => api.get(`${apptEndpoint}?${apptParams}`),
|
|
});
|
|
const appointments: Appointment[] = apptQuery.data?.data ?? EMPTY_ARR;
|
|
|
|
// ── Clinic: load doctors from clinic profile (not derived from appointments)
|
|
const clinicDoctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
|
|
queryKey: ['clinic-doctors', dbUuid],
|
|
queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`),
|
|
enabled: isClinic && !!dbUuid,
|
|
});
|
|
const clinicDoctorsList = clinicDoctorsQuery.data?.data?.data ?? [];
|
|
|
|
// ── Unique doctors from results (for clinic tabs) + merge with clinic list
|
|
const doctors = React.useMemo(() => {
|
|
const map = new Map<string, string>();
|
|
// first from clinic API (authoritative list)
|
|
clinicDoctorsList.forEach(d => map.set(d.uuid, d.name));
|
|
// then supplement with appointment data (for admin view)
|
|
appointments.forEach(a => {
|
|
if (a.doctor_uuid && a.doctor_name && !map.has(a.doctor_uuid)) {
|
|
map.set(a.doctor_uuid, a.doctor_name);
|
|
}
|
|
});
|
|
return Array.from(map.entries()).map(([uuid, name]) => ({ uuid, name }));
|
|
}, [appointments, clinicDoctorsList]);
|
|
|
|
const showDoctorTabs = isClinic && doctors.length >= 2;
|
|
const showDoctorCol = isAdmin || (isClinic && !selectedDoctorUuid);
|
|
|
|
// ── Slots query (schedule view)
|
|
const slotsQueryKey = ['appt-slots', selectedDoctorUuid, selectedDate];
|
|
const slotsQuery = useQuery<ApiResponse<any>>({
|
|
queryKey: slotsQueryKey,
|
|
queryFn: () => api.get(`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}`),
|
|
enabled: viewMode === 'schedule' && !!selectedDoctorUuid,
|
|
});
|
|
|
|
// ── Merge sessions + appointments for schedule view
|
|
const mergedSessions: SessionGroup[] = React.useMemo(() => {
|
|
if (viewMode !== 'schedule') return [];
|
|
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
|
|
|
|
const CANCELLED_STATUSES = new Set(['cancelled_by_doctor', 'cancelled_by_user', 'cancelled_by_admin', 'auto_cancel_unpaid', 'no_show', 'expired']);
|
|
const activeByStart = new Map<number, Appointment>();
|
|
const cancelledByStart = new Map<number, Appointment>();
|
|
|
|
appointments.forEach(a => {
|
|
const key = typeof a.slot_start === 'number' ? a.slot_start : parseInt(String(a.slot_start), 10);
|
|
if (CANCELLED_STATUSES.has(a.status)) {
|
|
const existing = cancelledByStart.get(key);
|
|
if (!existing || a.created_at > existing.created_at) cancelledByStart.set(key, a);
|
|
} else {
|
|
activeByStart.set(key, a);
|
|
}
|
|
});
|
|
|
|
return rawSessions.map((session: any) => ({
|
|
start_time: session.start_time as string,
|
|
end_time: session.end_time as string,
|
|
slots: (session.slots as any[]).map((s: any) => {
|
|
const slotStart = typeof s.start === 'number' ? s.start : parseInt(String(s.start), 10);
|
|
const slotEnd = typeof s.end === 'number' ? s.end : parseInt(String(s.end), 10);
|
|
return {
|
|
start: slotStart,
|
|
end: slotEnd,
|
|
start_time: s.start_time ?? new Date(slotStart * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
|
end_time: s.end_time ?? new Date(slotEnd * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
|
is_available: s.is_available as boolean,
|
|
appointment: activeByStart.get(slotStart) ?? null,
|
|
cancelled_appointment: cancelledByStart.get(slotStart) ?? null,
|
|
};
|
|
}),
|
|
}));
|
|
}, [viewMode, slotsQuery.data, appointments]);
|
|
|
|
// ── Handle slot click → open booking modal
|
|
function handleSlotClick(slot: SlotItem) {
|
|
// نماینده اجازهی ثبت نوبت ندارد؛ صفحه برای او فقط مشاهده است.
|
|
if (isRepresentation) return;
|
|
const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? '';
|
|
setBookingHint(false);
|
|
setBookingSlot({
|
|
start: slot.start,
|
|
end: slot.end,
|
|
start_time: slot.start_time,
|
|
end_time: slot.end_time,
|
|
doctor_uuid: selectedDoctorUuid,
|
|
doctor_name: doctorName,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: '20px 24px' }}>
|
|
{/* Stats Bar */}
|
|
<StatsBar date={selectedDate} isAdmin={isAdmin} />
|
|
|
|
{/* Doctor tabs (clinic with ≥2 doctors) */}
|
|
{showDoctorTabs && (
|
|
<div style={{ display: 'flex', gap: 4, marginBottom: 12 }}>
|
|
<button
|
|
onClick={() => setSelectedDoctorUuid('')}
|
|
style={tabStyle(selectedDoctorUuid === '')}
|
|
>
|
|
همه
|
|
</button>
|
|
{doctors.map(d => (
|
|
<button
|
|
key={d.uuid}
|
|
onClick={() => setSelectedDoctorUuid(d.uuid)}
|
|
style={tabStyle(selectedDoctorUuid === d.uuid)}
|
|
>
|
|
{d.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Main card */}
|
|
<div style={{
|
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--r)', overflow: 'hidden',
|
|
}}>
|
|
{/* Toolbar */}
|
|
<div style={{
|
|
padding: '12px 16px', borderBottom: '1px solid var(--border)',
|
|
display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
|
|
}}>
|
|
{/* New appointment button — نماینده اجازهی ثبت ندارد */}
|
|
{!isRepresentation && (
|
|
<button
|
|
className="btn primary sm"
|
|
onClick={() => {
|
|
if (!selectedDoctorUuid) { toast.error('ابتدا یک پزشک انتخاب کنید'); return; }
|
|
setViewMode('schedule');
|
|
setBookingHint(true);
|
|
}}
|
|
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
|
|
>
|
|
<PlusIcon style={{ width: 15, height: 15 }} />
|
|
نوبت جدید
|
|
</button>
|
|
)}
|
|
|
|
{/* View toggle */}
|
|
<div style={{
|
|
display: 'flex', background: 'var(--surface-2)', borderRadius: 'var(--r-sm)',
|
|
padding: 2, gap: 1,
|
|
}}>
|
|
{(['table', 'schedule'] as const).map(mode => (
|
|
<button
|
|
key={mode}
|
|
onClick={() => { setViewMode(mode); if (mode === 'table') setBookingHint(false); }}
|
|
style={{
|
|
padding: '5px 12px', borderRadius: 'var(--r-sm)', fontSize: 12, fontWeight: 600,
|
|
border: 'none', cursor: 'pointer',
|
|
background: viewMode === mode ? 'var(--surface)' : 'transparent',
|
|
color: viewMode === mode ? 'var(--primary)' : 'var(--text-3)',
|
|
boxShadow: viewMode === mode ? 'var(--shadow-sm)' : 'none',
|
|
}}
|
|
>
|
|
{mode === 'table' ? (
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
<TableCellsIcon style={{ width: 14, height: 14 }} />نمایش جدولی
|
|
</span>
|
|
) : (
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
<ClockIcon style={{ width: 14, height: 14 }} />زمانبندی
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Doctor selector (admin / clinic) */}
|
|
{!isDoctor && (
|
|
<div style={{ minWidth: 200 }}>
|
|
<SearchableSelect
|
|
options={doctors.map(d => ({ value: d.uuid, label: d.name }))}
|
|
value={selectedDoctorUuid || null}
|
|
onChange={(v) => setSelectedDoctorUuid(v ? String(v) : '')}
|
|
placeholder="انتخاب پزشک..."
|
|
isClearable
|
|
height={36}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ flex: 1 }} />
|
|
|
|
{/* Date navigator */}
|
|
<DateNavigator date={selectedDate} onChange={setSelectedDate} />
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div style={{ padding: 16 }}>
|
|
{viewMode === 'table' ? (
|
|
<TableView
|
|
items={appointments}
|
|
loading={apptQuery.isLoading}
|
|
queryKey={apptQueryKey}
|
|
showDoctor={showDoctorCol}
|
|
/>
|
|
) : (
|
|
<>
|
|
{bookingHint && !isRepresentation && (
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12,
|
|
padding: '10px 16px', borderRadius: 'var(--r-sm)',
|
|
background: 'var(--info-bg)', border: '1px solid color-mix(in oklch, var(--info) 40%, transparent)',
|
|
fontSize: 13, color: 'var(--info)',
|
|
}}>
|
|
<span style={{ fontSize: 18 }}>👆</span>
|
|
<span>روی یک زمان خالی <strong>کلیک کنید</strong> تا نوبت جدید ثبت شود</span>
|
|
<button
|
|
onClick={() => setBookingHint(false)}
|
|
style={{ marginRight: 'auto', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--info)', fontSize: 16 }}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
<ScheduleView
|
|
sessions={mergedSessions}
|
|
loading={apptQuery.isLoading || slotsQuery.isLoading}
|
|
queryKey={apptQueryKey}
|
|
onBook={handleSlotClick}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* New appointment modal */}
|
|
{bookingSlot && (
|
|
<NewAppointmentModal
|
|
slot={bookingSlot}
|
|
onClose={() => setBookingSlot(null)}
|
|
onSuccess={() => {
|
|
qc.invalidateQueries({ queryKey: apptQueryKey });
|
|
qc.invalidateQueries({ queryKey: slotsQueryKey });
|
|
qc.invalidateQueries({ queryKey: ['appt-today-stats', selectedDate] });
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function tabStyle(active: boolean): React.CSSProperties {
|
|
return {
|
|
padding: '6px 14px', borderRadius: 'var(--r-sm)', fontSize: 13, fontWeight: 600,
|
|
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
|
|
background: active ? '#eff6ff' : 'var(--surface)',
|
|
color: active ? 'var(--primary)' : 'var(--text-2)',
|
|
cursor: 'pointer',
|
|
};
|
|
}
|