Files
clinicpro/assets/admin/pages/AppointmentsPage.tsx
T
hamed 45ee725820 feat: update appointment management API and frontend components
- Added new endpoint to get today's appointment statistics with optional date filter.
- Enhanced appointment listing API to support filtering by date and doctor UUID.
- Updated Appointment model to include new fields and modified status values.
- Implemented AppointmentStatusDropdown component for status management with visual feedback.
- Created PersianCalendar component for date selection in Jalali format.
- Updated API documentation to reflect changes in appointment management.
2026-06-11 14:13:42 +03:30

633 lines
28 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';
// ─────────────────────────────────────────────────────────────────────────────
// Status config
// ─────────────────────────────────────────────────────────────────────────────
const STATUS_META: Record<string, { label: string; color: string; bg: string }> = {
pending: { label: 'رزرو شده', color: '#3b82f6', bg: '#eff6ff' },
confirmed: { label: 'تأیید شده', color: '#22c55e', bg: '#f0fdf4' },
completed: { label: 'تکمیل شده', color: '#16a34a', bg: '#dcfce7' },
cancelled_by_doctor: { label: 'لغو پزشک', color: '#ef4444', bg: '#fef2f2' },
cancelled_by_user: { label: 'لغو بیمار', color: '#ef4444', bg: '#fef2f2' },
no_show: { label: 'غیبت', color: '#9ca3af', bg: '#f9fafb' },
expired: { label: 'منقضی', color: '#9ca3af', bg: '#f9fafb' },
};
function statusMeta(s: string) {
return STATUS_META[s] ?? { label: s, color: '#9ca3af', bg: '#f9fafb' };
}
// ─────────────────────────────────────────────────────────────────────────────
// Stats icons (inline SVG)
// ─────────────────────────────────────────────────────────────────────────────
function IconTotal() {
return (
<svg width="44" height="44" viewBox="0 0 44 44" fill="none">
<circle cx="22" cy="22" r="22" fill="#ede9fe" />
{[8,14,20,26].map(x => [8,14,20,26].map(y => (
<circle key={`${x}${y}`} cx={x} cy={y} r="2" fill="#7c3aed" opacity="0.7" />
)))}
</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 }: { date: string }) {
const { data } = useQuery<ApiResponse<TodayStats>>({
queryKey: ['appt-today-stats', date],
queryFn: () => api.get(`/api/v1/admin/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
// ─────────────────────────────────────────────────────────────────────────────
function DateNavigator({ date, onChange }: { date: string; onChange: (d: string) => void }) {
const [showCal, setShowCal] = useState(false);
const ref = useRef<HTMLDivElement>(null);
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 12px', height: 36, display: 'flex', alignItems: 'center',
fontSize: 13, fontWeight: 700, color: 'var(--text)', minWidth: 110, justifyContent: 'center',
background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-sm)',
}}>
{formatDate(date)}
</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>
<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>
<td style={td}>
<button style={{
padding: '4px 10px', borderRadius: 99, fontSize: 12,
background: 'var(--surface-2)', border: '1px solid var(--border)',
cursor: 'pointer', color: 'var(--text-2)',
}}>
...
</button>
</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;
}
function SlotCard({ slot, queryKey, onBook }: { slot: SlotItem; queryKey: unknown[]; onBook: (slot: SlotItem) => void }) {
if (slot.is_available) {
return (
<div
onClick={() => onBook(slot)}
style={{
border: '1.5px dashed #93c5fd', borderRadius: 'var(--r)', padding: '10px 16px',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: '#3b82f6', fontSize: 13, fontWeight: 600,
background: '#eff6ff', transition: 'all 0.15s', gap: 6,
}}
onMouseEnter={e => (e.currentTarget.style.background = '#dbeafe')}
onMouseLeave={e => (e.currentTarget.style.background = '#eff6ff')}
>
<span style={{ fontSize: 18 }}></span> نوبت جدید
</div>
);
}
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',
}}>
<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>
);
}
function ScheduleView({
slots, loading, queryKey, onBook,
}: {
slots: SlotItem[]; loading: boolean; queryKey: unknown[]; onBook: (s: SlotItem) => void;
}) {
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
if (!slots.length) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>هیچ slot زمانی برای این روز تنظیم نشده است</div>;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '4px 0' }}>
{slots.map((slot, i) => (
<div key={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>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 qc = useQueryClient();
const mutation = useMutation({
mutationFn: () => api.post('/api/v1/admin/appointment', {
doctor_uuid: slot.doctor_uuid,
slot_start: slot.start,
slot_end: slot.end,
patient_mobile: mobile,
}),
onSuccess: () => {
toast.success('نوبت با موفقیت ثبت شد');
onSuccess();
onClose();
},
onError: (e: any) => {
const msg = e?.response?.data?.errors?.[0]?.message ?? 'خطا در ثبت نوبت';
toast.error(msg);
},
});
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: 16 }}>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-2)', marginBottom: 6 }}>
موبایل بیمار
</label>
<input
type="tel"
value={mobile}
onChange={e => setMobile(e.target.value)}
placeholder="مثال: ۰۹۱۲۳۴۵۶۷۸۹"
style={{
width: '100%', height: 38, padding: '0 10px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13,
direction: 'ltr', boxSizing: 'border-box',
}}
autoFocus
/>
</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={mobile.length < 10 || mutation.isPending}
>
{mutation.isPending ? '...' : 'ثبت نوبت'}
</button>
</div>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Main Page
// ─────────────────────────────────────────────────────────────────────────────
export default function AppointmentsPage() {
const { primaryRole, dbUuid } = useAuthStore(s => ({ primaryRole: s.primaryRole, dbUuid: s.dbUuid }));
const isAdmin = primaryRole === 'admin';
const isClinic = primaryRole === 'clinic';
const isDoctor = primaryRole === 'doctor';
const today = new Date().toISOString().slice(0, 10);
const [selectedDate, setSelectedDate] = useState(today);
const [viewMode, setViewMode] = useState<'table' | 'schedule'>('table');
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor && dbUuid ? dbUuid : '');
const [bookingSlot, setBookingSlot] = useState<BookingSlot | null>(null);
const qc = useQueryClient();
// ── Appointments query
const apptQueryKey = ['admin-appointments', 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(`/api/v1/admin/appointments?${apptParams}`),
});
const appointments: Appointment[] = apptQuery.data?.data ?? [];
// ── Unique doctors from results (for clinic tabs)
const doctors = React.useMemo(() => {
const map = new Map<string, string>();
appointments.forEach(a => {
if (a.doctor_uuid && a.doctor_name) map.set(a.doctor_uuid, a.doctor_name);
});
return Array.from(map.entries()).map(([uuid, name]) => ({ uuid, name }));
}, [appointments]);
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 slots + appointments for schedule view
const mergedSlots: SlotItem[] = React.useMemo(() => {
if (viewMode !== 'schedule') return [];
const rawSlots: any[] = slotsQuery.data?.data ?? [];
const apptByStart = new Map<number, Appointment>();
appointments.forEach(a => apptByStart.set(a.slot_start, a));
return rawSlots.map((s: any) => {
const appt = apptByStart.get(s.start) ?? null;
return {
start: s.start,
end: s.end,
start_time: s.start_time ?? new Date(s.start * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
end_time: s.end_time ?? new Date(s.end * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
is_available: !appt,
appointment: appt,
};
});
}, [viewMode, slotsQuery.data, appointments]);
// ── Handle slot click → open booking modal
function handleSlotClick(slot: SlotItem) {
const doctorName = doctors.find(d => d.uuid === selectedDoctorUuid)?.name ?? '';
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 */}
{isAdmin && <StatsBar date={selectedDate} />}
{/* 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 */}
<button
className="btn primary sm"
onClick={() => {
if (!selectedDoctorUuid) { toast.error('ابتدا یک پزشک انتخاب کنید'); return; }
const d = doctors.find(x => x.uuid === selectedDoctorUuid);
setBookingSlot({ start: 0, end: 0, start_time: '', end_time: '', doctor_uuid: selectedDoctorUuid, doctor_name: d?.name ?? '' });
}}
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)}
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 && (
<select
value={selectedDoctorUuid}
onChange={e => setSelectedDoctorUuid(e.target.value)}
style={{
height: 36, padding: '0 10px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', background: 'var(--surface)',
fontSize: 13, color: 'var(--text)', minWidth: 180, cursor: 'pointer',
}}
>
<option value="">پرسنل را انتخاب کنید...</option>
{doctors.map(d => (
<option key={d.uuid} value={d.uuid}>{d.name}</option>
))}
</select>
)}
<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}
/>
) : (
<ScheduleView
slots={mergedSlots}
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',
};
}