Files
clinicpro/assets/admin/pages/AppointmentsPage.tsx
T
hamedandClaude Fable 5 165ececab4 feat(appointments): close the three remaining design gaps
1. شارژ کیف پول is now functional end-to-end. New owner-gated
   POST /api/v1/patient/{uuid}/wallet/charge creates a manual credit
   WalletTransaction (computed balance_after); the patient detail's wallet tab
   gains a top-up modal (PriceInput + description) and supports ?tab= deep
   links. The deposit sections of the create drawer, the edit page and the
   replace modal link to it via WalletChargeLink (record resolved by mobile).
2. جایگزینی نوبت now matches appointments-replace.pdf: patient search-or-new,
   بخش/سرویس/پرسنل selects prefilled from the appointment, deposit toggle +
   amount + charge link, read-only original date/time, status pick and notes —
   all through the general PATCH.
3. The confirmed-appointments table is paginated (20/page, client-side so the
   schedule view and doctor-tab derivation keep the whole day), resetting on
   date/doctor/filter changes. The page-local STATUS_META also adopts the
   design labels plus following_up/salon for the schedule cards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:26:47 +03:30

893 lines
40 KiB
TypeScript

import React, { useEffect, 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 Pagination from '../components/ui/Pagination';
import AppointmentActionsMenu from '../components/AppointmentActions';
import NewAppointmentDrawer from '../components/NewAppointmentDrawer';
import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal';
import type { AppointmentFilters } from '../components/AppointmentFiltersModal';
import { AdjustmentsHorizontalIcon } from '@heroicons/react/24/outline';
import PersianCalendar from '../components/ui/PersianCalendar';
import SearchableSelect from '../components/ui/SearchableSelect';
const EMPTY_ARR: Appointment[] = [];
// ─────────────────────────────────────────────────────────────────────────────
// Status config
// ─────────────────────────────────────────────────────────────────────────────
// Labels follow the Figma نوبت‌ها design; keep in sync with AppointmentStatusDropdown.
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)' },
following_up: { label: 'در حال پیگیری', color: 'var(--warning)', bg: 'var(--warning-bg)' },
salon: { label: 'سالن', color: 'var(--violet)', bg: 'var(--violet-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 [drawerOpen, setDrawerOpen] = useState(false);
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState<AppointmentFilters>(EMPTY_FILTERS);
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;
const filteredAppointments = applyAppointmentFilters(appointments, filters);
const filtersActive = filters !== EMPTY_FILTERS && JSON.stringify(filters) !== JSON.stringify(EMPTY_FILTERS);
// Table pagination is client-side: the day's full list stays loaded because
// the schedule view and doctor-tab derivation need every row.
const TABLE_PAGE_SIZE = 20;
const [tablePage, setTablePage] = useState(1);
useEffect(() => { setTablePage(1); }, [selectedDate, selectedDoctorUuid, filters]);
const pagedAppointments = filteredAppointments.slice((tablePage - 1) * TABLE_PAGE_SIZE, tablePage * TABLE_PAGE_SIZE);
// ── 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; }
setDrawerOpen(true);
}}
style={{ display: 'flex', alignItems: 'center', gap: 5 }}
>
<PlusIcon style={{ width: 15, height: 15 }} />
نوبت جدید
</button>
)}
{/* Personnel filter (Figma «پرسنل را انتخاب کنید...») */}
<StaffFilterSelect value={filters.staffUuid} onChange={(v) => setFilters(f => ({ ...f, staffUuid: v }))} />
{/* Filters (Figma فیلترها) */}
<button
aria-label="فیلترها"
className="btn sm"
onClick={() => setFiltersOpen(true)}
style={{
border: `1px solid ${filtersActive ? 'var(--primary)' : 'var(--border)'}`,
color: filtersActive ? 'var(--primary)' : 'var(--text-2)',
background: 'var(--surface)',
}}
>
<AdjustmentsHorizontalIcon style={{ width: 16 }} />
</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={pagedAppointments}
loading={apptQuery.isLoading}
queryKey={apptQueryKey}
showDoctor={showDoctorCol}
/>
{filteredAppointments.length > TABLE_PAGE_SIZE && (
<div style={{ marginTop: 14 }}>
<Pagination page={tablePage} total={filteredAppointments.length} limit={TABLE_PAGE_SIZE} onPageChange={setTablePage} />
</div>
)}
</>
) : (
<>
{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] });
}}
/>
)}
{/* Filters modal */}
{filtersOpen && (
<AppointmentFiltersModal value={filters} onApply={setFilters} onClose={() => setFiltersOpen(false)} />
)}
{/* Rich create drawer (اضافه کردن نوبت جدید) */}
{drawerOpen && (
<NewAppointmentDrawer
doctorUuid={selectedDoctorUuid}
defaultDate={selectedDate}
queryKey={apptQueryKey}
onClose={() => setDrawerOpen(false)}
/>
)}
</div>
);
}
/** Toolbar staff filter — filters the loaded day's table rows by پرسنل. */
function StaffFilterSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const staffQ = useQuery<ApiResponse<{ uuid: string; full_name: string }[]>>({
queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff'),
});
return (
<select aria-label="پرسنل" value={value} onChange={e => onChange(e.target.value)}
style={{ height: 32, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 12.5, fontFamily: 'inherit', padding: '0 10px', minWidth: 170 }}>
<option value="">پرسنل را انتخاب کنید...</option>
{(staffQ.data?.data ?? []).map(s => <option key={s.uuid} value={s.uuid}>{s.full_name}</option>)}
</select>
);
}
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',
};
}