- Implemented InviteDoctorModal component for inviting doctors to clinics. - Updated ClinicDashboard to include a button for inviting doctors and handle modal state. - Added createAppointment API endpoint in AdminApiController for scheduling appointments. - Enhanced ClinicInvitationController to check user access when inviting doctors. - Updated MyAppointmentsController to ensure unique appointment records. - Added seed_test_data.php for populating test data including doctors, clinics, and appointments. - Refactored styles to include new appointment status badges and updated font imports.
677 lines
28 KiB
TypeScript
677 lines
28 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
MagnifyingGlassIcon, EyeIcon, TableCellsIcon, CalendarDaysIcon as CalendarViewIcon,
|
|
CalendarIcon, ClockIcon, CheckCircleIcon, XCircleIcon,
|
|
PlusIcon, XMarkIcon, ChevronRightIcon, ChevronLeftIcon, UserCircleIcon,
|
|
} 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, maskMobile, toGregorianDate } from '../lib/utils';
|
|
import DataTable, { Column } from '../components/ui/DataTable';
|
|
import Pagination from '../components/ui/Pagination';
|
|
import PersianDateInput from '../components/ui/PersianDateInput';
|
|
import { useAuthStore } from '../stores/authStore';
|
|
|
|
// ── Status config ──────────────────────────────────────────────────────────
|
|
|
|
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
|
waiting_for_payment: { label: 'انتظار پرداخت', cls: 'status-amber' },
|
|
reserved: { label: 'رزرو شده', cls: 'status-blue' },
|
|
checked_in: { label: 'ورود به مطب', cls: 'status-violet' },
|
|
waiting: { label: 'صف انتظار', cls: 'status-amber' },
|
|
in_progress: { label: 'در حال ویزیت', cls: 'status-violet' },
|
|
visited: { label: 'ویزیت شده', cls: 'status-green' },
|
|
completed: { label: 'تکمیل شده', cls: 'status-green' },
|
|
cancelled_by_user: { label: 'لغو توسط بیمار', cls: 'status-red' },
|
|
cancelled_by_doctor: { label: 'لغو توسط پزشک', cls: 'status-red' },
|
|
cancelled_by_admin: { label: 'لغو توسط ادمین', cls: 'status-red' },
|
|
auto_cancel_unpaid: { label: 'لغو خودکار', cls: 'status-gray' },
|
|
no_show: { label: 'غیبت', cls: 'status-gray' },
|
|
};
|
|
|
|
const STATUS_FILTERS = [
|
|
{ value: '', label: 'همه وضعیتها' },
|
|
{ value: 'reserved', label: 'رزرو شده' },
|
|
{ value: 'waiting_for_payment', label: 'در انتظار پرداخت' },
|
|
{ value: 'checked_in', label: 'ورود به مطب' },
|
|
{ value: 'waiting', label: 'صف انتظار' },
|
|
{ value: 'in_progress', label: 'در حال ویزیت' },
|
|
{ value: 'visited', label: 'ویزیت شده' },
|
|
{ value: 'completed', label: 'تکمیل شده' },
|
|
{ value: 'cancelled_by_doctor', label: 'لغو پزشک' },
|
|
{ value: 'cancelled_by_user', label: 'لغو بیمار' },
|
|
{ value: 'no_show', label: 'غیبت' },
|
|
];
|
|
|
|
function ApptStatus({ status }: { status: string }) {
|
|
const m = STATUS_META[status] ?? { label: status, cls: 'status-gray' };
|
|
return (
|
|
<span className={`appt-status ${m.cls}`}>
|
|
<span className="appt-status-dot" />
|
|
{m.label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ── Timeline View ─────────────────────────────────────────────────────────
|
|
|
|
interface TimelineProps {
|
|
items: Appointment[];
|
|
loading: boolean;
|
|
onView: (uuid: string) => void;
|
|
groupByDoctor?: boolean;
|
|
}
|
|
|
|
function DayGroup({ date, appts, onView }: { date: string; appts: Appointment[]; onView: (u: string) => void }) {
|
|
return (
|
|
<div style={{ marginBottom: '1.25rem' }}>
|
|
<div style={{
|
|
fontSize: 12, fontWeight: 600, color: 'var(--text-3)', marginBottom: '0.6rem',
|
|
display: 'flex', alignItems: 'center', gap: 8,
|
|
}}>
|
|
<span style={{ width: 24, height: 1, background: 'var(--border)', display: 'inline-block' }} />
|
|
{formatDate(date)}
|
|
<span style={{ flex: 1, height: 1, background: 'var(--border)', display: 'inline-block' }} />
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{appts.map((a) => (
|
|
<div
|
|
key={a.uuid}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 14, padding: '0.7rem 1rem',
|
|
background: 'var(--surface-alt, #f8fafc)', borderRadius: 10,
|
|
border: '1px solid var(--border)', cursor: 'pointer', transition: 'box-shadow .15s',
|
|
}}
|
|
onClick={() => onView(a.uuid)}
|
|
onMouseEnter={e => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,.07)')}
|
|
onMouseLeave={e => (e.currentTarget.style.boxShadow = 'none')}
|
|
>
|
|
<div style={{
|
|
width: 50, height: 50, borderRadius: 10, flexShrink: 0,
|
|
background: 'var(--primary-soft, #eef2ff)', color: 'var(--primary)',
|
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
|
fontWeight: 700, fontSize: 14, lineHeight: 1.2,
|
|
}}>
|
|
{a.appointment_time}
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontWeight: 600, fontSize: 13.5, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
{a.patient_name || maskMobile(a.patient_mobile)}
|
|
</div>
|
|
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>
|
|
دکتر {a.doctor_name}{a.clinic_name ? ` · ${a.clinic_name}` : ''}
|
|
</div>
|
|
</div>
|
|
<ApptStatus status={a.status} />
|
|
<EyeIcon style={{ width: 15, height: 15, color: 'var(--text-3)', flexShrink: 0 }} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TimelineView({ items, loading, onView, groupByDoctor }: TimelineProps) {
|
|
if (loading) {
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: '1rem' }}>
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div key={i} style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
|
<div className="skeleton" style={{ width: 50, height: 50, borderRadius: 10, flexShrink: 0 }} />
|
|
<div style={{ flex: 1 }}>
|
|
<div className="skeleton" style={{ height: 13, borderRadius: 4, width: '50%', marginBottom: 7 }} />
|
|
<div className="skeleton" style={{ height: 11, borderRadius: 4, width: '30%' }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!items.length) {
|
|
return <p className="muted" style={{ textAlign: 'center', padding: '3rem 0', fontSize: 13.5 }}>هیچ نوبتی یافت نشد</p>;
|
|
}
|
|
|
|
if (groupByDoctor) {
|
|
// group by doctor → date
|
|
const byDoctor = items.reduce<Record<string, Appointment[]>>((acc, a) => {
|
|
(acc[a.doctor_name] ??= []).push(a);
|
|
return acc;
|
|
}, {});
|
|
|
|
return (
|
|
<div style={{ padding: '0 1rem 1rem' }}>
|
|
{Object.entries(byDoctor).map(([docName, docAppts]) => {
|
|
const byDate = docAppts.reduce<Record<string, Appointment[]>>((acc, a) => {
|
|
(acc[a.appointment_date] ??= []).push(a);
|
|
return acc;
|
|
}, {});
|
|
return (
|
|
<div key={docName} style={{ marginBottom: '1.5rem' }}>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 8, marginBottom: '0.75rem',
|
|
padding: '6px 10px', borderRadius: 8,
|
|
background: 'var(--primary-soft, #eef2ff)', border: '1px solid color-mix(in srgb, var(--primary) 20%, transparent)',
|
|
}}>
|
|
<UserCircleIcon style={{ width: 16, height: 16, color: 'var(--primary)' }} />
|
|
<span style={{ fontWeight: 700, fontSize: 13.5, color: 'var(--primary)' }}>دکتر {docName}</span>
|
|
<span className="muted" style={{ fontSize: 12, marginRight: 'auto' }}>{docAppts.length} نوبت</span>
|
|
</div>
|
|
{Object.entries(byDate).map(([date, appts]) => (
|
|
<DayGroup key={date} date={date} appts={appts} onView={onView} />
|
|
))}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// default: group by date only
|
|
const byDate = items.reduce<Record<string, Appointment[]>>((acc, a) => {
|
|
(acc[a.appointment_date] ??= []).push(a);
|
|
return acc;
|
|
}, {});
|
|
|
|
return (
|
|
<div style={{ padding: '0 1rem 1rem' }}>
|
|
{Object.entries(byDate).map(([date, appts]) => (
|
|
<DayGroup key={date} date={date} appts={appts} onView={onView} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Doctor-grouped table ──────────────────────────────────────────────────
|
|
|
|
interface DoctorGroupedTableProps {
|
|
items: Appointment[];
|
|
loading: boolean;
|
|
onView: (uuid: string) => void;
|
|
columns: Column<Appointment>[];
|
|
}
|
|
|
|
function DoctorGroupedTable({ items, loading, onView, columns }: DoctorGroupedTableProps) {
|
|
if (loading) {
|
|
return (
|
|
<div style={{ padding: '1rem' }}>
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div key={i} className="skeleton" style={{ height: 44, borderRadius: 6, marginBottom: 8 }} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
if (!items.length) {
|
|
return <p className="muted" style={{ textAlign: 'center', padding: '3rem 0', fontSize: 13.5 }}>هیچ نوبتی یافت نشد</p>;
|
|
}
|
|
|
|
const byDoctor = items.reduce<Record<string, Appointment[]>>((acc, a) => {
|
|
(acc[a.doctor_name] ??= []).push(a);
|
|
return acc;
|
|
}, {});
|
|
|
|
return (
|
|
<div>
|
|
{Object.entries(byDoctor).map(([docName, docAppts]) => (
|
|
<div key={docName}>
|
|
{/* Doctor header row */}
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', gap: 8,
|
|
padding: '8px 16px', margin: '0',
|
|
background: 'color-mix(in srgb, var(--primary) 7%, var(--surface))',
|
|
borderBottom: '1px solid var(--border)', borderTop: '1px solid var(--border)',
|
|
}}>
|
|
<UserCircleIcon style={{ width: 16, height: 16, color: 'var(--primary)' }} />
|
|
<span style={{ fontWeight: 700, fontSize: 13.5, color: 'var(--primary)' }}>دکتر {docName}</span>
|
|
<span className="muted" style={{ fontSize: 12 }}>{docAppts.length} نوبت</span>
|
|
</div>
|
|
|
|
{/* Appointments table for this doctor */}
|
|
<DataTable<Appointment>
|
|
columns={columns}
|
|
data={docAppts}
|
|
loading={false}
|
|
emptyMessage=""
|
|
actions={(appt) => (
|
|
<button className="mini-btn" onClick={() => onView(appt.uuid)} title="مشاهده">
|
|
<EyeIcon style={{ width: 16, height: 16 }} />
|
|
</button>
|
|
)}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── New Appointment Modal ─────────────────────────────────────────────────
|
|
|
|
interface NewApptModalProps {
|
|
onClose: () => void;
|
|
onCreated: () => void;
|
|
defaultDoctorUuid?: string;
|
|
}
|
|
|
|
function NewAppointmentModal({ onClose, onCreated, defaultDoctorUuid }: NewApptModalProps) {
|
|
const { primaryRole } = useAuthStore();
|
|
const isAdmin = primaryRole === 'admin';
|
|
|
|
const [step, setStep] = useState<1 | 2 | 3>(1);
|
|
const [doctorUuid, setDoctorUuid] = useState(defaultDoctorUuid ?? '');
|
|
const [patientMobile, setPatientMobile] = useState('');
|
|
const [dateStr, setDateStr] = useState(toGregorianDate(new Date()));
|
|
const [slots, setSlots] = useState<Array<{ start: number; end: number; label: string }>>([]);
|
|
const [selectedSlot, setSelectedSlot] = useState<{ start: number; end: number; label: string } | null>(null);
|
|
const [note, setNote] = useState('');
|
|
const [loadingSlots, setLoadingSlots] = useState(false);
|
|
|
|
const fetchSlots = async () => {
|
|
if (!doctorUuid.trim() || !dateStr) { toast.error('UUID پزشک و تاریخ را وارد کنید'); return; }
|
|
setLoadingSlots(true);
|
|
try {
|
|
const res = await api.get<ApiResponse<{ slots: Array<{ start: number; end: number; label: string }> }>>(
|
|
`/api/v1/appointment-slots?doctor_uuid=${doctorUuid.trim()}&date=${dateStr}`
|
|
);
|
|
const raw = (res as any)?.data?.slots ?? [];
|
|
setSlots(raw);
|
|
setSelectedSlot(null);
|
|
setStep(2);
|
|
if (!raw.length) toast.info('هیچ نوبت خالی در این تاریخ وجود ندارد');
|
|
} catch (e: any) {
|
|
toast.error(e.message ?? 'خطا در دریافت نوبتها');
|
|
} finally {
|
|
setLoadingSlots(false);
|
|
}
|
|
};
|
|
|
|
const createMut = useMutation({
|
|
mutationFn: () => {
|
|
if (!selectedSlot) throw new Error('نوبت را انتخاب کنید');
|
|
const body: Record<string, unknown> = {
|
|
doctor_uuid: doctorUuid.trim(),
|
|
slot_start: selectedSlot.start,
|
|
slot_end: selectedSlot.end,
|
|
note: note || undefined,
|
|
};
|
|
if (isAdmin) {
|
|
body.patient_mobile = patientMobile.trim();
|
|
return api.post('/api/v1/admin/appointment', body);
|
|
}
|
|
return api.post('/api/v1/appointment', body);
|
|
},
|
|
onSuccess: () => { toast.success('نوبت با موفقیت ثبت شد'); onCreated(); onClose(); },
|
|
onError: (e: Error) => toast.error(e.message),
|
|
});
|
|
|
|
const handleDateChange = (v: string) => {
|
|
setDateStr(v); setSlots([]); setSelectedSlot(null); setStep(1);
|
|
};
|
|
|
|
const changeDate = (delta: number) => {
|
|
const d = new Date(dateStr + 'T12:00:00');
|
|
d.setDate(d.getDate() + delta);
|
|
handleDateChange(toGregorianDate(d));
|
|
};
|
|
|
|
return (
|
|
<div className="overlay" onClick={onClose}>
|
|
<div className="modal" style={{ maxWidth: 500 }} onClick={e => e.stopPropagation()}>
|
|
<div className="modal-head">
|
|
<b>ثبت نوبت جدید</b>
|
|
<button className="mini-btn" onClick={onClose}><XMarkIcon style={{ width: 16, height: 16 }} /></button>
|
|
</div>
|
|
|
|
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|
<div>
|
|
<label className="field-label">UUID پزشک *</label>
|
|
<input
|
|
className="input" dir="ltr" placeholder="xxxxxxxx-xxxx-..."
|
|
value={doctorUuid}
|
|
readOnly={!!defaultDoctorUuid}
|
|
onChange={e => { setDoctorUuid(e.target.value); setStep(1); setSlots([]); setSelectedSlot(null); }}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="field-label">تاریخ *</label>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
<button className="mini-btn" onClick={() => changeDate(-1)} title="روز قبل">
|
|
<ChevronRightIcon style={{ width: 14, height: 14 }} />
|
|
</button>
|
|
<PersianDateInput
|
|
value={dateStr}
|
|
onChange={handleDateChange}
|
|
min={toGregorianDate(new Date())}
|
|
style={{ flex: 1 }}
|
|
/>
|
|
<button className="mini-btn" onClick={() => changeDate(1)} title="روز بعد">
|
|
<ChevronLeftIcon style={{ width: 14, height: 14 }} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{isAdmin && (
|
|
<div>
|
|
<label className="field-label">موبایل بیمار *</label>
|
|
<input className="input" dir="ltr" placeholder="09xxxxxxxxx" value={patientMobile} onChange={e => setPatientMobile(e.target.value)} />
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
className="btn outline sm"
|
|
onClick={fetchSlots}
|
|
disabled={loadingSlots || !doctorUuid.trim() || !dateStr}
|
|
style={{ alignSelf: 'flex-start' }}
|
|
>
|
|
{loadingSlots ? 'در حال دریافت...' : 'دریافت نوبتهای خالی'}
|
|
</button>
|
|
|
|
{step >= 2 && slots.length > 0 && (
|
|
<div>
|
|
<label className="field-label">انتخاب نوبت — {formatDate(dateStr)}</label>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8, marginTop: 6 }}>
|
|
{slots.map((s) => (
|
|
<button
|
|
key={s.start}
|
|
onClick={() => { setSelectedSlot(s); setStep(3); }}
|
|
style={{
|
|
padding: '8px 4px', borderRadius: 8, fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
|
border: selectedSlot?.start === s.start ? '2px solid var(--primary)' : '1px solid var(--border)',
|
|
background: selectedSlot?.start === s.start ? 'var(--primary-soft, #eef2ff)' : 'var(--surface)',
|
|
color: selectedSlot?.start === s.start ? 'var(--primary)' : 'var(--text)',
|
|
transition: 'all .15s',
|
|
}}
|
|
>
|
|
{s.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{step >= 2 && slots.length === 0 && (
|
|
<p className="muted" style={{ textAlign: 'center', fontSize: 13 }}>نوبت خالی در این تاریخ وجود ندارد</p>
|
|
)}
|
|
|
|
{step >= 3 && (
|
|
<div>
|
|
<label className="field-label">یادداشت (اختیاری)</label>
|
|
<textarea className="input" rows={2} style={{ resize: 'vertical' }} value={note} onChange={e => setNote(e.target.value)} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="modal-foot">
|
|
<button className="btn ghost sm" onClick={onClose}>انصراف</button>
|
|
<button
|
|
className="btn primary sm"
|
|
disabled={!selectedSlot || createMut.isPending || (isAdmin && !patientMobile.trim())}
|
|
onClick={() => createMut.mutate()}
|
|
>
|
|
{createMut.isPending ? 'در حال ثبت...' : 'ثبت نوبت'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Main Component ────────────────────────────────────────────────────────
|
|
|
|
export default function AppointmentsPage() {
|
|
const navigate = useNavigate();
|
|
const { primaryRole, dbUuid } = useAuthStore();
|
|
|
|
const [page, setPage] = useState(1);
|
|
const [search, setSearch] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState('');
|
|
const [dateFilter, setDateFilter] = useState('');
|
|
const [doctorFilter, setDoctorFilter] = useState('');
|
|
const [viewMode, setViewMode] = useState<'table' | 'timeline'>('table');
|
|
const [newApptOpen, setNewApptOpen] = useState(false);
|
|
const limit = 15;
|
|
|
|
const isAdmin = primaryRole === 'admin';
|
|
const isDoctor = primaryRole === 'doctor';
|
|
const isClinic = primaryRole === 'clinic';
|
|
const endpoint = isAdmin ? '/api/v1/admin/appointments' : '/api/v1/my/appointments';
|
|
|
|
const { data, isLoading, refetch } = useQuery({
|
|
queryKey: ['appointments', endpoint, page, search, statusFilter, dateFilter],
|
|
queryFn: () => {
|
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
|
if (search) params.set('search', search);
|
|
if (statusFilter) params.set('status', statusFilter);
|
|
if (dateFilter) params.set('date', dateFilter);
|
|
return api.get<PaginatedResponse<Appointment>>(`${endpoint}?${params}`);
|
|
},
|
|
});
|
|
|
|
const allItems = data?.data ?? [];
|
|
const total = data?.meta?.totalRecords ?? 0;
|
|
|
|
// unique doctor names for clinic filter tab
|
|
const doctorNames = useMemo(() => {
|
|
const names = [...new Set(allItems.map(a => a.doctor_name))];
|
|
return names.sort();
|
|
}, [allItems]);
|
|
|
|
const items = useMemo(() => {
|
|
if (!doctorFilter) return allItems;
|
|
return allItems.filter(a => a.doctor_name === doctorFilter);
|
|
}, [allItems, doctorFilter]);
|
|
|
|
// Show doctor grouping in clinic panel when multiple doctors exist
|
|
const showDoctorGroup = isClinic && doctorNames.length > 1 && !doctorFilter;
|
|
|
|
const columns: Column<Appointment>[] = [
|
|
{
|
|
key: 'patient',
|
|
header: 'بیمار',
|
|
render: (a) => (
|
|
<div className="cell-user">
|
|
<div className="avatar sm" style={{
|
|
background: 'linear-gradient(145deg, oklch(0.62 0.15 222), oklch(0.48 0.16 222))',
|
|
}}>
|
|
{(a.patient_name ?? '؟').slice(0, 2)}
|
|
</div>
|
|
<div>
|
|
<b>{a.patient_name || '—'}</b>
|
|
<br /><small dir="ltr">{maskMobile(a.patient_mobile)}</small>
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
...(!showDoctorGroup ? [{ key: 'doctor_name' as keyof Appointment, header: 'پزشک', render: (a: Appointment) => `دکتر ${a.doctor_name}` }] : []),
|
|
{ key: 'clinic_name', header: 'کلینیک', render: (a) => <span className="muted">{a.clinic_name ?? '—'}</span> },
|
|
{
|
|
key: 'appointment_date',
|
|
header: 'تاریخ و ساعت',
|
|
render: (a) => (
|
|
<div>
|
|
<span style={{ fontWeight: 600 }}>{formatDate(a.appointment_date)}</span>
|
|
<br />
|
|
<small className="muted" style={{ direction: 'ltr', display: 'inline-block' }}>{a.appointment_time}</small>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'وضعیت',
|
|
render: (a) => <ApptStatus status={a.status} />,
|
|
},
|
|
{
|
|
key: 'created_at',
|
|
header: 'ثبت در',
|
|
render: (a) => <span className="muted" style={{ fontSize: 12 }}>{formatDate(a.created_at)}</span>,
|
|
},
|
|
];
|
|
|
|
const pageTitle = isAdmin ? 'نوبتها'
|
|
: isDoctor ? 'نوبتهای من'
|
|
: primaryRole === 'secretary' ? 'نوبتهای پزشک'
|
|
: 'نوبتهای کلینیک';
|
|
|
|
const statCards = [
|
|
{ label: 'کل نوبتها', value: total > 0 ? String(total) : null, bg: 'var(--info-bg)', color: 'var(--info)', Icon: CalendarIcon },
|
|
{ label: 'در انتظار', value: null, bg: 'var(--warning-bg)', color: 'var(--warning)', Icon: ClockIcon },
|
|
{ label: 'ویزیت شده', value: null, bg: 'var(--success-bg)', color: 'var(--success)', Icon: CheckCircleIcon },
|
|
{ label: 'لغو شده', value: null, bg: 'var(--danger-bg)', color: 'var(--danger)', Icon: XCircleIcon },
|
|
];
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
{/* Header */}
|
|
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
|
<div>
|
|
<h1 className="section-title">{pageTitle}</h1>
|
|
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>مدیریت و پیگیری نوبتهای درمانی</div>
|
|
</div>
|
|
<button className="btn primary sm" onClick={() => setNewApptOpen(true)}>
|
|
<PlusIcon style={{ width: 15, height: 15 }} />
|
|
ثبت نوبت جدید
|
|
</button>
|
|
</div>
|
|
|
|
{/* Stat cards */}
|
|
<div className="stat-grid" style={{ gridTemplateColumns: 'repeat(4,1fr)' }}>
|
|
{statCards.map((c) => (
|
|
<div key={c.label} className="stat">
|
|
<div className="ico" style={{ background: c.bg, color: c.color }}>
|
|
<c.Icon style={{ width: 20, height: 20 }} />
|
|
</div>
|
|
<div className="lbl">{c.label}</div>
|
|
<div className="val">
|
|
{isLoading
|
|
? <span className="skeleton" style={{ display: 'inline-block', width: 48, height: 26, borderRadius: 4 }} />
|
|
: c.value ?? '—'}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Main card */}
|
|
<div className="card">
|
|
<div className="card-pad" style={{ paddingBottom: 0 }}>
|
|
|
|
{/* Doctor filter tabs — only for clinic with multiple doctors */}
|
|
{isClinic && doctorNames.length > 1 && (
|
|
<div style={{
|
|
display: 'flex', gap: 6, flexWrap: 'wrap',
|
|
marginBottom: 14, paddingBottom: 14, borderBottom: '1px solid var(--border)',
|
|
}}>
|
|
<button
|
|
onClick={() => setDoctorFilter('')}
|
|
className={`btn sm ${!doctorFilter ? 'primary' : 'ghost'}`}
|
|
style={{ fontSize: 12 }}
|
|
>
|
|
همه پزشکان
|
|
</button>
|
|
{doctorNames.map(name => (
|
|
<button
|
|
key={name}
|
|
onClick={() => setDoctorFilter(name)}
|
|
className={`btn sm ${doctorFilter === name ? 'primary' : 'ghost'}`}
|
|
style={{ fontSize: 12 }}
|
|
>
|
|
دکتر {name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="toolbar" style={{ flexWrap: 'wrap', gap: 10 }}>
|
|
<div className="field" style={{ minWidth: 220 }}>
|
|
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
|
|
<input
|
|
placeholder="جستجو (موبایل / نام)..."
|
|
value={search}
|
|
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
|
/>
|
|
</div>
|
|
|
|
<PersianDateInput
|
|
value={dateFilter}
|
|
onChange={(v) => { setDateFilter(v); setPage(1); }}
|
|
placeholder="فیلتر تاریخ"
|
|
/>
|
|
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
|
style={{
|
|
height: 36, padding: '0 10px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)',
|
|
background: 'var(--surface)', color: 'var(--text)', fontSize: 13, cursor: 'pointer',
|
|
}}
|
|
>
|
|
{STATUS_FILTERS.map(f => (
|
|
<option key={f.value} value={f.value}>{f.label}</option>
|
|
))}
|
|
</select>
|
|
|
|
<div style={{ marginRight: 'auto' }} />
|
|
|
|
<div className="seg">
|
|
<button className={viewMode === 'table' ? 'on' : ''} onClick={() => setViewMode('table')} title="نمای جدول">
|
|
<TableCellsIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
<button className={viewMode === 'timeline' ? 'on' : ''} onClick={() => setViewMode('timeline')} title="نمای زمانی">
|
|
<CalendarViewIcon style={{ width: 15, height: 15 }} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{viewMode === 'table' ? (
|
|
<>
|
|
{showDoctorGroup ? (
|
|
<DoctorGroupedTable
|
|
items={items}
|
|
loading={isLoading}
|
|
onView={(uuid) => navigate(`/admin/appointments/${uuid}`)}
|
|
columns={columns}
|
|
/>
|
|
) : (
|
|
<DataTable<Appointment>
|
|
columns={columns}
|
|
data={items}
|
|
loading={isLoading}
|
|
emptyMessage="هیچ نوبتی یافت نشد"
|
|
actions={(appt) => (
|
|
<button className="mini-btn" onClick={() => navigate(`/admin/appointments/${appt.uuid}`)} title="مشاهده">
|
|
<EyeIcon style={{ width: 16, height: 16 }} />
|
|
</button>
|
|
)}
|
|
/>
|
|
)}
|
|
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
|
</>
|
|
) : (
|
|
<>
|
|
<TimelineView
|
|
items={items}
|
|
loading={isLoading}
|
|
onView={(uuid) => navigate(`/admin/appointments/${uuid}`)}
|
|
groupByDoctor={showDoctorGroup}
|
|
/>
|
|
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{newApptOpen && (
|
|
<NewAppointmentModal
|
|
onClose={() => setNewApptOpen(false)}
|
|
onCreated={refetch}
|
|
defaultDoctorUuid={isDoctor ? (dbUuid ?? '') : ''}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|