Frontend for the Figma عملیات menu on the confirmed-appointments table: - AppointmentActions composite: six-item row menu (ویرایش، ثبت سرویس، مشاهده، جا به جایی، انتقال به لیست رزرو، جایگزینی) plus the four modals it opens. ثبت سرویس and the info modal resolve the patient record via the patient-list search (mobile) to reuse the existing wallet endpoint and NewSessionPage. - Info modal mirrors appointments-info.pdf: start time, duration, phone, بخش/سرویس/پرسنل, wallet balance, status dropdown, مشاهده پرونده. - Move/transfer/replace modals PATCH the new general update endpoint with optimistic-lock version; transfer uses day-level midnight slots. - Status labels/transitions updated to the design set (ثبت شده/قطعی شده/ در حال پیگیری/سالن/ویزیت شده/لغو شده) in AppointmentStatusDropdown and StatusBadge; Appointment type gains the new workflow fields; the table gains سرویس/پرسنل/عملیات columns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
821 lines
36 KiB
TypeScript
821 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 AppointmentActionsMenu from '../components/AppointmentActions';
|
|
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' }}>
|
|
<div className="table-wrap"><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>
|
|
<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}>{a.service_item?.name || '—'}</td>
|
|
<td style={td}>{a.staff?.full_name || '—'}</td>
|
|
<td style={td}>
|
|
<AppointmentStatusDropdown
|
|
uuid={a.uuid}
|
|
currentStatus={a.status}
|
|
version={a.version}
|
|
queryKey={queryKey}
|
|
/>
|
|
</td>
|
|
<td style={td}>
|
|
<AppointmentActionsMenu appointment={a} queryKey={queryKey} />
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table></div>
|
|
</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',
|
|
};
|
|
}
|