Files
clinicpro/assets/admin/components/schedule/ScheduleSection.tsx
T
hamed 0e7970d6e0 feat: replace checkboxes with Switch component for better UI consistency
- Updated DoctorDetailPage, MySecretariesPage, RecordNumberSettingsPage, RepresentationsPage, ResourcePoolsPage, ResourceTypesPage, SecretariesPage, SecretaryDetailPage, SettingsPage, SkillsPage, SmsWalletPage, and TagsSettingsPage to use the new Switch component instead of native checkboxes.
- Enhanced accessibility by ensuring the Switch component uses appropriate roles and labels.
- Added tests for the new Switch component to ensure functionality and accessibility compliance.
- Updated styles to accommodate the new Switch component design.
2026-08-05 16:09:46 +03:30

1392 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
PencilIcon, TrashIcon, PlusIcon, CalendarIcon,
ChevronDownIcon, ChevronRightIcon, ChevronLeftIcon,
CheckCircleIcon, XCircleIcon, XMarkIcon, ExclamationTriangleIcon,
LockClosedIcon, MapPinIcon, GlobeAltIcon,
} from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api, ApiError } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import { formatNumber, digitsOnly, todayIso, unixToIso } from '../../lib/utils';
import Modal from '../ui/Modal';
import ConfirmDialog from '../ui/ConfirmDialog';
import GlobalSearchableSelect from '../ui/SearchableSelect';
import NationalHolidaysCard from '../holidays/NationalHolidaysCard';
import Switch from '../ui/Switch';
/**
* ساختار واحد تنظیمات نوبت‌دهی یک پزشک — برنامه هفتگی، استثناهای تاریخ و تعطیلات.
*
* همین کامپوننت هم در پنل شخصی پزشک رندر می‌شود و هم در تب‌های پنل کلینیک؛
* تنها ورودی متمایزکننده `doctorUuid` است تا دو پیاده‌سازی موازی به وجود نیاید.
*/
export interface AddressData {
id: string; uuid: string;
type: 'personal' | 'clinic';
clinic_id: string | null;
clinic_name: string | null;
name: string | null; address: string | null;
telephone: string | null;
map: { latitude: string | null; longitude: string | null };
city: { id: string; name: string } | null;
province: { id: string; name: string } | null;
}
// ── Schedule types ─────────────────────────────────────────────────────────
interface SlotConfig { start: string; end: string; duration: number; }
// `*_string` فیلدهای «Y-m-d» سرور هستند؛ برای نمایش همیشه اولویت با آن‌هاست تا
// تبدیل timestamp در مرورگر تاریخ را یک روز جابه‌جا نکند.
interface DateOverrideData { uuid: string; doctor_uuid: string; date: number; date_string?: string; active: boolean; reason: string | null; custom_slots: SessionConfig[]; }
interface HolidayData { uuid: string; doctor_uuid: string; start_date: number; end_date: number; start_date_string?: string; end_date_string?: string; active: boolean; reason: string | null; }
interface SessionConfig {
active: boolean;
location_id: number | null;
start_time: string;
end_time: string;
duration_per_patient: number;
has_rest: boolean;
rest_interval: number;
time_to_rest: number;
patient_limit: number | null;
}
interface NewDayConfig { sessions: SessionConfig[]; }
type NewScheduleMap = Record<string, NewDayConfig>;
interface BookingMeta {
online_booking_enabled: boolean;
booking_window_value: number;
booking_window_unit: BookingWindowUnit;
/**
* `resource` دیگر قابل انتخاب نیست و فقط برای خواندنِ رکوردهای قدیمی در نوع می‌ماند
* — سرور همچنان همان مقدار را برمی‌گرداند.
*/
booking_mode: 'slot' | 'service' | 'resource';
/** فقط پس‌فرست می‌شود تا مقدارِ رکوردهای قدیمی در PATCH پاک نشود. */
step_minutes?: number;
/** فقط پس‌فرست می‌شود تا مقدارِ رکوردهای قدیمی در PATCH پاک نشود. */
resource_strategy?: string;
buffer_minutes: number;
}
type BookingWindowUnit = 'day' | 'week' | 'month';
const BOOKING_WINDOW_UNITS: { value: BookingWindowUnit; label: string }[] = [
{ value: 'day', label: 'روز' },
{ value: 'week', label: 'هفته' },
{ value: 'month', label: 'ماه' },
];
/** برچسب فارسی هر حالت — یک جا، تا پیام تأیید و کارت‌ها از هم واگرا نشوند. */
const MODE_LABELS: Record<BookingMeta['booking_mode'], string> = {
slot: 'نوبت‌دهی اسلاتی',
service: 'نوبت‌دهی سرویسی',
resource: 'نوبت‌دهی منبع‌محور',
};
/**
* حالت‌های قابل انتخاب برای پزشک. `resource` عمداً اینجا نیست: نوبت‌دهی منبع‌محور از
* تنظیمات پزشک برداشته شده و مسیر خودش (صفحهٔ «نوبت منبع‌محور» و تقویم هر منبع) را
* دارد. انتخابش برگشت‌ناپذیر بود و محیط را قفل می‌کرد.
*/
const BOOKING_MODE_CARDS: ReadonlyArray<readonly [BookingMeta['booking_mode'], string, string]> = [
['slot', 'نوبت‌دهی اسلاتی', 'شما بازه‌های کاری و «مدت هر نوبت» را مشخص می‌کنید؛ سیستم بازه را به نوبت‌های هم‌اندازه تقسیم می‌کند. مناسب ویزیت‌های با زمان یکسان.'],
['service', 'نوبت‌دهی سرویسی', 'مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود؛ سیستم نزدیک‌ترین زمان خالیِ کافی را پیشنهاد می‌دهد. مناسب خدمات با زمان متفاوت.'],
];
const DEFAULT_BOOKING_META: BookingMeta = {
online_booking_enabled: true,
booking_window_value: 3,
booking_window_unit: 'month',
booking_mode: 'slot',
buffer_minutes: 0,
};
interface WeeklyScheduleData { uuid: string; doctor_uuid: string; schedule: NewScheduleMap; meta?: BookingMeta; booking_mode_locked?: boolean; }
// ── Persian (Jalali) date utilities ───────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-require-imports
const jalaali = require('jalaali-js') as {
toJalaali: (gy: number, gm: number, gd: number) => { jy: number; jm: number; jd: number };
toGregorian: (jy: number, jm: number, jd: number) => { gy: number; gm: number; gd: number };
jalaaliMonthLength: (jy: number, jm: number) => number;
};
const JALALI_MONTHS = ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند'];
const JALALI_DAYS_SHORT = ['ش','ی','د','س','چ','پ','ج'];
function toPersianNums(n: number | string): string {
return String(n).replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[parseInt(d)]);
}
function gToJ(gDate: string): { jy: number; jm: number; jd: number } | null {
if (!gDate) return null;
const [gy, gm, gd] = gDate.split('-').map(Number);
return jalaali.toJalaali(gy, gm, gd);
}
function jToG(jy: number, jm: number, jd: number): string {
const { gy, gm, gd } = jalaali.toGregorian(jy, jm, jd);
return `${gy}-${String(gm).padStart(2,'0')}-${String(gd).padStart(2,'0')}`;
}
function formatPersianDate(gDate: string): string {
const j = gToJ(gDate);
if (!j) return gDate;
return `${toPersianNums(j.jd)} ${JALALI_MONTHS[j.jm - 1]} ${toPersianNums(j.jy)}`;
}
function jFirstDayOfWeek(jy: number, jm: number): number {
const { gy, gm, gd } = jalaali.toGregorian(jy, jm, 1);
const dow = new Date(gy, gm - 1, gd).getDay(); // 0=Sunday
return (dow + 1) % 7; // 0=Saturday, 1=Sunday, ..., 6=Friday
}
function todayGregorian(): string { return todayIso(); }
// ── Persian Date Picker ────────────────────────────────────────────────────
function PersianDateInput({ value, onChange, minDate, placeholder }: {
value: string; onChange: (v: string) => void; minDate?: string; placeholder?: string;
}) {
const [open, setOpen] = useState(false);
const [viewJy, setViewJy] = useState(0);
const [viewJm, setViewJm] = useState(0);
const [rect, setRect] = useState<DOMRect | null>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const dropRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const j = gToJ(value || todayGregorian());
if (j) { setViewJy(j.jy); setViewJm(j.jm); }
}, [open, value]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (dropRef.current && !dropRef.current.contains(e.target as Node) &&
btnRef.current && !btnRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, [open]);
const openPicker = () => {
if (!btnRef.current) return;
setRect(btnRef.current.getBoundingClientRect());
setOpen(v => !v);
};
const prevMonth = () => viewJm === 1 ? (setViewJy(y => y - 1), setViewJm(12)) : setViewJm(m => m - 1);
const nextMonth = () => viewJm === 12 ? (setViewJy(y => y + 1), setViewJm(1)) : setViewJm(m => m + 1);
const selectDay = (jd: number) => { onChange(jToG(viewJy, viewJm, jd)); setOpen(false); };
const selectedJ = gToJ(value);
const minJ = minDate ? gToJ(minDate) : null;
const todayJ = gToJ(todayGregorian());
const daysInMonth = viewJy && viewJm ? jalaali.jalaaliMonthLength(viewJy, viewJm) : 0;
const firstDow = viewJy && viewJm ? jFirstDayOfWeek(viewJy, viewJm) : 0;
const isSelected = (jd: number) => !!selectedJ && selectedJ.jy === viewJy && selectedJ.jm === viewJm && selectedJ.jd === jd;
const isToday = (jd: number) => !!todayJ && todayJ.jy === viewJy && todayJ.jm === viewJm && todayJ.jd === jd;
const isDisabled = (jd: number) => {
if (!minJ) return false;
if (viewJy < minJ.jy) return true;
if (viewJy === minJ.jy && viewJm < minJ.jm) return true;
if (viewJy === minJ.jy && viewJm === minJ.jm && jd < minJ.jd) return true;
return false;
};
const dropStyle: React.CSSProperties = rect
? { position: 'fixed', top: rect.bottom + 4, left: rect.left, zIndex: 9999, width: 288 }
: {};
return (
<div>
<button ref={btnRef} type="button" onClick={openPicker}
className="cp-input h-11 flex items-center justify-between w-full cursor-pointer">
<span className={value ? 'text-[var(--text)]' : 'text-[var(--text-3)]'}>
{value ? formatPersianDate(value) : (placeholder ?? 'انتخاب تاریخ')}
</span>
<CalendarIcon className="w-4 h-4 text-[var(--text-3)] shrink-0 mr-2" />
</button>
{open && createPortal(
<div ref={dropRef} style={dropStyle}
className="bg-[var(--surface)] border border-[var(--border)] rounded-2xl shadow-2xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<button type="button" onClick={nextMonth}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] text-[var(--text-2)] transition-colors">
<ChevronRightIcon className="w-4 h-4" />
</button>
<span className="text-sm font-semibold text-[var(--text)]" dir="rtl">
{viewJm > 0 ? JALALI_MONTHS[viewJm - 1] : ''} {toPersianNums(viewJy)}
</span>
<button type="button" onClick={prevMonth}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)] text-[var(--text-2)] transition-colors">
<ChevronLeftIcon className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-7 px-3 pt-2 pb-1">
{JALALI_DAYS_SHORT.map(d => (
<div key={d} className="text-center text-xs font-medium text-[var(--text-3)] py-1">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-0.5 px-3 pb-3">
{Array.from({ length: firstDow }).map((_, i) => <div key={`e-${i}`} />)}
{Array.from({ length: daysInMonth }).map((_, i) => {
const jd = i + 1;
const sel = isSelected(jd);
const tod = isToday(jd);
const dis = isDisabled(jd);
return (
<button key={jd} type="button" disabled={dis} onClick={() => selectDay(jd)}
className={`w-full aspect-square flex items-center justify-center rounded-lg text-sm transition-colors
${dis ? 'text-[var(--text-3)] cursor-not-allowed'
: sel ? 'bg-[var(--primary)] text-[var(--on-primary)] font-semibold shadow-sm'
: tod ? 'ring-1 ring-[var(--primary)] text-[var(--primary-700)] dark:text-[var(--primary)] font-medium'
: 'text-[var(--text)] hover:bg-[var(--surface-2)] dark:hover:bg-[var(--surface)]'}`}>
{toPersianNums(jd)}
</button>
);
})}
</div>
</div>,
document.body
)}
</div>
);
}
const SCHEDULE_DAYS = [
{ key: '0', label: 'شنبه' },
{ key: '1', label: 'یکشنبه' },
{ key: '2', label: 'دوشنبه' },
{ key: '3', label: 'سه‌شنبه' },
{ key: '4', label: 'چهارشنبه' },
{ key: '5', label: 'پنجشنبه' },
{ key: '6', label: 'جمعه' },
];
const DURATION_OPTS = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60];
const DEFAULT_SESSION: SessionConfig = {
active: true, location_id: null,
start_time: '09:00', end_time: '13:00',
duration_per_patient: 20,
has_rest: false, rest_interval: 60, time_to_rest: 10,
patient_limit: null,
};
const EMPTY_NEW_SCHEDULE: NewScheduleMap = Object.fromEntries(
SCHEDULE_DAYS.map(d => [d.key, { sessions: [] }])
);
function parseMinutes(t: string): number {
const [h, m] = t.split(':').map(Number);
return h * 60 + m;
}
function hasOverlap(sessions: SessionConfig[]): boolean {
const active = sessions.filter(s => s.active);
const sorted = [...active].sort((a, b) => parseMinutes(a.start_time) - parseMinutes(b.start_time));
for (let i = 0; i < sorted.length - 1; i++) {
if (parseMinutes(sorted[i].end_time) > parseMinutes(sorted[i + 1].start_time)) return true;
}
return false;
}
function addMinutes(time: string, mins: number): string {
const total = Math.min(parseMinutes(time) + mins, 23 * 60 + 59);
const h = Math.floor(total / 60).toString().padStart(2, '0');
const m = (total % 60).toString().padStart(2, '0');
return `${h}:${m}`;
}
function calcSlotCount(session: SessionConfig): number {
const totalMins = parseMinutes(session.end_time) - parseMinutes(session.start_time);
if (totalMins <= 0 || session.duration_per_patient <= 0) return 0;
const dur = session.duration_per_patient;
const limit = session.patient_limit ?? Infinity;
let current = 0, elapsedWork = 0, count = 0;
while (current + dur <= totalMins) {
if (count >= limit) break;
if (session.has_rest && session.rest_interval > 0 && elapsedWork > 0 && elapsedWork >= session.rest_interval) {
current += session.time_to_rest; elapsedWork = 0; continue;
}
current += dur; elapsedWork += dur; count++;
}
return count;
}
function tsToDate(ts: number): string {
return unixToIso(ts);
}
/** رشتهٔ `Y-m-d` سرور در اولویت است؛ timestamp فقط fallback رکوردهای قدیمی پاسخ است. */
function dayString(serverString: string | undefined, ts: number): string {
return serverString || tsToDate(ts);
}
// ── Schedule components ────────────────────────────────────────────────────
// انتخاب ساعت و دقیقه به صورت دو منوی جدا (24 ساعته، دقیقه با گام 5)
const HOUR_VALUES = Array.from({ length: 24 }, (_, h) => h.toString().padStart(2, '0'));
const MINUTE_VALUES = Array.from({ length: 12 }, (_, i) => (i * 5).toString().padStart(2, '0'));
function TimeSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [hRaw, mRaw] = (value || '00:00').split(':');
const h = (hRaw ?? '00').padStart(2, '0');
// دقیقه را به نزدیک‌ترین گام ۵ گرد کن تا همیشه در لیست موجود باشد
const mNum = Math.round((Number(mRaw) || 0) / 5) * 5;
const m = (mNum >= 60 ? 55 : mNum).toString().padStart(2, '0');
return (
<div className="cp-time-select" style={{ display: 'flex', alignItems: 'center', gap: 6 }} dir="ltr">
<div style={{ width: 92 }}>
<GlobalSearchableSelect
options={HOUR_VALUES.map(hv => ({ value: hv, label: hv }))}
value={h}
onChange={v => onChange(`${v ? String(v) : h}:${m}`)}
height={38}
/>
</div>
<span style={{ fontWeight: 700, color: 'var(--text-2)' }}>:</span>
<div style={{ width: 92 }}>
<GlobalSearchableSelect
options={MINUTE_VALUES.map(mv => ({ value: mv, label: mv }))}
value={m}
onChange={v => onChange(`${h}:${v ? String(v) : m}`)}
height={38}
/>
</div>
</div>
);
}
function SlotEditor({ slots, onChange }: {
slots: SlotConfig[];
onChange: (slots: SlotConfig[]) => void;
}) {
const add = () => onChange([...slots, { start: '09:00', end: '13:00', duration: 30 }]);
const remove = (i: number) => onChange(slots.filter((_, idx) => idx !== i));
const update = (i: number, field: keyof SlotConfig, val: string | number) =>
onChange(slots.map((s, idx) => idx === i ? { ...s, [field]: val } : s));
return (
<div className="space-y-2">
{slots.map((slot, i) => (
<div key={i} className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-[var(--text-2)] shrink-0">از</span>
<input type="time" value={slot.start} onChange={e => update(i, 'start', e.target.value)}
className="cp-input h-8 w-28 text-sm font-mono text-center px-2" />
<span className="text-xs text-[var(--text-2)] shrink-0">تا</span>
<input type="time" value={slot.end} onChange={e => update(i, 'end', e.target.value)}
className="cp-input h-8 w-28 text-sm font-mono text-center px-2" />
<div style={{ width: 110 }}>
<GlobalSearchableSelect
options={DURATION_OPTS.map(d => ({ value: d, label: `${d} دقیقه` }))}
value={slot.duration}
onChange={(v) => update(i, 'duration', Number(v))}
height={32}
/>
</div>
<button type="button" onClick={() => remove(i)}
className="w-7 h-7 flex items-center justify-center rounded-lg text-[var(--text-3)] hover:text-[var(--danger)] hover:bg-[var(--danger-bg)] dark:hover:bg-[var(--danger)]/10 transition-colors shrink-0">
<XMarkIcon className="w-3.5 h-3.5" />
</button>
</div>
))}
<button type="button" onClick={add}
className="flex items-center gap-1.5 text-xs text-[var(--primary)] dark:text-[var(--primary)] hover:underline mt-1">
<PlusIcon className="w-3.5 h-3.5" />افزودن بازه زمانی
</button>
</div>
);
}
function SessionEditor({ session, onChange, onRemove, addresses, serviceMode = false }: {
session: SessionConfig;
onChange: (s: SessionConfig) => void;
onRemove: () => void;
addresses: AddressData[];
/** حالت نوبت‌دهی سرویسی: فیلدهای اسلاتی (بازه هر نوبت، استراحت، شمارش) نمایش داده نمی‌شوند. */
serviceMode?: boolean;
}) {
const upd = <K extends keyof SessionConfig>(k: K, v: SessionConfig[K]) =>
onChange({ ...session, [k]: v });
const slotCount = calcSlotCount(session);
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--r)', background: 'var(--surface)', overflow: 'hidden' }}>
{/* Time inputs */}
<div style={{ padding: '14px 16px 12px', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12, alignItems: 'end' }}>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>از ساعت</div>
<TimeSelect value={session.start_time} onChange={v => upd('start_time', v)} />
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>تا ساعت</div>
<TimeSelect value={session.end_time} onChange={v => upd('end_time', v)} />
</div>
<button type="button" className="mini-btn danger" onClick={onRemove} style={{ marginBottom: 1 }}>
<TrashIcon style={{ width: 15, height: 15 }} />
</button>
</div>
{/* Duration (slot mode only) + Location */}
<div style={{ padding: '0 16px 14px', display: 'grid', gridTemplateColumns: serviceMode ? '1fr' : '1fr 1fr', gap: 12 }}>
{!serviceMode && (
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>بازه هر نوبت</div>
<GlobalSearchableSelect
options={DURATION_OPTS.map(d => ({ value: d, label: `${d} دقیقه` }))}
value={session.duration_per_patient}
onChange={(v) => upd('duration_per_patient', Number(v))}
/>
</div>
)}
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>مکان نوبت</div>
<GlobalSearchableSelect
options={[
...addresses.filter(a => a.type === 'personal').map(a => ({
value: Number(a.id),
label: `🏥 ${a.name ?? a.address ?? `مطب ${a.id}`}`,
})),
...addresses.filter(a => a.type === 'clinic').map(a => ({
value: Number(a.id),
label: `🏨 ${a.clinic_name ? `${a.clinic_name}${a.name ? ` — ${a.name}` : ''}` : (a.name ?? a.address ?? `کلینیک ${a.id}`)}`,
})),
]}
value={session.location_id ?? null}
onChange={(v) => upd('location_id', v ? Number(v) : null)}
placeholder="انتخاب مکان"
isClearable
/>
</div>
</div>
{/* Rest toggle (slot mode only) */}
{!serviceMode && (
<div style={{
padding: '12px 16px', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', borderTop: '1px solid var(--border)',
}}>
<div>
<b style={{ fontSize: 13 }}>استراحت دوره‌ای بین نوبت‌ها</b>
<div className="muted" style={{ fontSize: 12, marginTop: 2 }}>توقف خودکار پس از مدت کاری مشخص</div>
</div>
<button type="button" onClick={() => upd('has_rest', !session.has_rest)}
style={{
position: 'relative', width: 40, height: 22, borderRadius: 99,
border: 'none', cursor: 'pointer', flexShrink: 0,
background: session.has_rest ? 'var(--primary)' : 'var(--surface-3)',
transition: 'background .2s',
}}>
<span style={{
position: 'absolute', top: 3, borderRadius: '50%',
width: 16, height: 16, background: 'var(--surface)',
boxShadow: '0 1px 3px rgba(0,0,0,.25)',
insetInlineStart: session.has_rest ? 20 : 3,
}} />
</button>
</div>
)}
{/* Rest params */}
{!serviceMode && session.has_rest && (
<div style={{ padding: '0 16px 14px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>هر (دقیقه کار)</div>
<div className="field">
<input type="text" inputMode="numeric" dir="ltr" value={session.rest_interval}
onChange={e => upd('rest_interval', Number(digitsOnly(e.target.value)) || 0)} />
</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>استراحت (دقیقه)</div>
<div className="field">
<input type="text" inputMode="numeric" dir="ltr" value={session.time_to_rest}
onChange={e => upd('time_to_rest', Number(digitsOnly(e.target.value)) || 0)} />
</div>
</div>
</div>
)}
{/* Slot count footer (slot mode only) */}
{!serviceMode && (
<div style={{
padding: '10px 16px', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', borderTop: '1px solid var(--border)',
background: 'var(--surface-2)',
}}>
<span className="muted" style={{ fontSize: 12 }}>تعداد نوبت محاسبه‌شده در این بازه</span>
{slotCount > 0 ? (
<span className="badge green">
<CheckCircleIcon style={{ width: 13, height: 13 }} />
{slotCount} نوبت
</span>
) : (
<span className="muted" style={{ fontSize: 12 }}></span>
)}
</div>
)}
</div>
);
}
// ── Weekly Schedule Tab ────────────────────────────────────────────────────
/**
* هر درخواست تنظیمات نوبت‌دهی باید محیطش را حمل کند: بدون clinic_uuid یعنی مطب
* شخصی پزشک، و با آن یعنی همان پزشک داخل آن کلینیک. این دو داده‌ی جدا دارند.
*/
const withClinic = (url: string, clinicUuid?: string | null): string =>
clinicUuid ? `${url}${url.includes('?') ? '&' : '?'}clinic_uuid=${encodeURIComponent(clinicUuid)}` : url;
/**
* وقتی محیط انتخاب‌شده مکانِ نوبت فعالی ندارد. متن به محیط بستگی دارد: پزشک عضو
* کلینیک آدرس مستقل ثبت نمی‌کند، پس در محیط کلینیک نباید «ابتدا آدرس مطب را ثبت
* کنید» ببیند — آدرس از تنظیمات همان کلینیک می‌آید.
*/
function NoLocationsNotice({ clinicUuid }: { clinicUuid?: string | null }) {
const [title, hint] = clinicUuid
? ['این کلینیک هنوز مکان نوبت‌دهی فعال ندارد', 'ابتدا در تنظیمات کلینیک یک آدرس فعال ثبت شود']
: ['ابتدا آدرس مطب را ثبت کنید', 'برنامه کاری نیاز به حداقل یک مکان نوبت دارد'];
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<div className="w-12 h-12 rounded-full bg-[var(--warning-bg)] border border-[var(--warning)] flex items-center justify-center">
<MapPinIcon className="w-6 h-6 text-[var(--warning)]" />
</div>
<div>
<p className="text-sm font-medium text-[var(--text)]">{title}</p>
<p className="text-xs text-[var(--text-3)] mt-1">{hint}</p>
</div>
</div>
);
}
export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; addresses: AddressData[]; readOnly?: boolean }) {
const qc = useQueryClient();
const [scheduleMap, setScheduleMap] = useState<NewScheduleMap>(EMPTY_NEW_SCHEDULE);
const [scheduleUuid, setScheduleUuid] = useState<string | null>(null);
const [expandedDay, setExpandedDay] = useState<string | null>(null);
const [meta, setMeta] = useState<BookingMeta>(DEFAULT_BOOKING_META);
// نوع نوبت‌دهی پس از اولین ثبت قفل می‌شود؛ confirmMode = دیالوگ هشدار قبل از ثبت اول.
const [modeLocked, setModeLocked] = useState(false);
const [confirmMode, setConfirmMode] = useState(false);
const scheduleQ = useQuery({
queryKey: ['doctor-schedule', doctorUuid, clinicUuid ?? null],
queryFn: () => api.get<ApiResponse<any>>(withClinic(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, clinicUuid)),
staleTime: 0,
retry: (count, err) => !(err instanceof ApiError && err.status === 404),
});
useEffect(() => {
if (scheduleQ.data) {
const d: WeeklyScheduleData = scheduleQ.data?.data?.data ?? scheduleQ.data?.data;
if (d?.schedule) {
const merged: NewScheduleMap = { ...EMPTY_NEW_SCHEDULE };
for (const key of Object.keys(d.schedule)) {
const raw = d.schedule[key] as any;
if (raw?.sessions) merged[key] = { sessions: raw.sessions };
}
setScheduleMap(merged);
setScheduleUuid(d.uuid);
}
if (d?.meta) setMeta({ ...DEFAULT_BOOKING_META, ...d.meta });
setModeLocked(!!d?.booking_mode_locked);
} else if (scheduleQ.error instanceof ApiError && scheduleQ.error.status === 404) {
setScheduleMap(EMPTY_NEW_SCHEDULE); setScheduleUuid(null);
}
}, [scheduleQ.data, scheduleQ.error]);
const overlapDays = useMemo(() =>
Object.fromEntries(SCHEDULE_DAYS.map(d => [d.key, hasOverlap(scheduleMap[d.key]?.sessions ?? [])]))
, [scheduleMap]);
const hasAnyOverlap = Object.values(overlapDays).some(Boolean);
const totalSlots = useMemo(() =>
SCHEDULE_DAYS.reduce((sum, d) => {
const sessions = scheduleMap[d.key]?.sessions ?? [];
return sum + sessions.filter(s => s.active).reduce((s2, s) => s2 + calcSlotCount(s), 0);
}, 0)
, [scheduleMap]);
const missingLocation = addresses.length > 0 && SCHEDULE_DAYS.some(d =>
(scheduleMap[d.key]?.sessions ?? []).some(s => s.active && s.location_id === null)
);
const saveMut = useMutation({
mutationFn: () => {
if (hasAnyOverlap) throw new Error('تداخل زمانی در برنامه وجود دارد');
if (missingLocation) throw new Error('مکان مطب برای همه بازه‌های فعال الزامی است');
return scheduleUuid
? api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null })
: api.post<ApiResponse<any>>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null });
},
onSuccess: (res) => {
const d: WeeklyScheduleData = res?.data?.data ?? res?.data;
if (d?.uuid && !scheduleUuid) setScheduleUuid(d.uuid);
setModeLocked(true); // پس از ثبت، نوع نوبت‌دهی قفل می‌شود
toast.success('برنامه هفتگی ذخیره شد');
qc.invalidateQueries({ queryKey: ['doctor-schedule', doctorUuid, clinicUuid ?? null] });
},
onError: (e: Error) => toast.error(e.message),
});
const setDaySessions = (key: string, sessions: SessionConfig[]) =>
setScheduleMap(prev => ({ ...prev, [key]: { sessions } }));
const addSession = (key: string) => {
const existing = scheduleMap[key]?.sessions ?? [];
const lastEnd = existing.length > 0
? existing.reduce((max, s) => parseMinutes(s.end_time) > parseMinutes(max) ? s.end_time : max, '00:00')
: '09:00';
const newStart = existing.length > 0 ? addMinutes(lastEnd, 30) : '09:00';
const newEnd = addMinutes(newStart, 240);
const defaultLoc = addresses.length > 0 ? Number(addresses[0].id) : null;
setDaySessions(key, [...existing, { ...DEFAULT_SESSION, start_time: newStart, end_time: newEnd, location_id: defaultLoc }]);
setExpandedDay(key);
};
const removeSession = (key: string, idx: number) =>
setDaySessions(key, (scheduleMap[key]?.sessions ?? []).filter((_, i) => i !== idx));
const updateSession = (key: string, idx: number, s: SessionConfig) =>
setDaySessions(key, (scheduleMap[key]?.sessions ?? []).map((old, i) => i === idx ? s : old));
if (scheduleQ.isLoading) return (
<div className="space-y-3">{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-12 rounded-xl skeleton" />)}</div>
);
if (addresses.length === 0) return <NoLocationsNotice clinicUuid={clinicUuid} />;
// نمای فقط‌خواندنی برای نماینده: برنامه‌ی هفتگی به‌صورت متن، بدون فرم.
if (readOnly) {
if (scheduleQ.isLoading) {
return <div className="space-y-2">{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-12 rounded-xl skeleton" />)}</div>;
}
return (
<div className="space-y-2">
{totalSlots > 0 && (
<div className="flex items-center gap-2 mb-3 p-3 rounded-xl bg-[var(--success-bg)] border border-[var(--success)]">
<CalendarIcon className="w-4 h-4 text-[var(--success)] shrink-0" />
<span className="text-sm text-[var(--success)]">
مجموع <strong>{totalSlots}</strong> نوبت در هفته
</span>
</div>
)}
{SCHEDULE_DAYS.map(day => {
const activeSessions = (scheduleMap[day.key]?.sessions ?? []).filter(s => s.active);
const daySlots = activeSessions.reduce((sum, s) => sum + calcSlotCount(s), 0);
return (
<div key={day.key} className="flex items-center gap-3 px-4 py-3 rounded-xl border border-[var(--border)] bg-[var(--surface)]">
<b style={{ fontSize: 14, minWidth: 56, flexShrink: 0 }}>{day.label}</b>
<div className="flex-1 flex gap-2 items-center flex-wrap min-w-0">
{activeSessions.length > 0 ? activeSessions.map((s, i) => (
<span key={i} className="text-xs px-2 py-1 rounded-lg bg-[var(--surface-2)] text-[var(--text)]" style={{ fontFamily: 'monospace', direction: 'ltr', whiteSpace: 'nowrap' }}>
{s.start_time} {s.end_time}
</span>
)) : (
<span className="muted text-xs">تعطیل</span>
)}
</div>
{daySlots > 0 && (
<span className="badge green" style={{ flexShrink: 0 }}>
<span className="bdot" />{daySlots} نوبت
</span>
)}
</div>
);
})}
</div>
);
}
return (
<div className="space-y-2">
{/* ─ خلاصه کل هفته */}
{totalSlots > 0 && (
<div className="flex items-center gap-2 mb-3 p-3 rounded-xl bg-[var(--success-bg)] border border-[var(--success)]">
<CalendarIcon className="w-4 h-4 text-[var(--success)] shrink-0" />
<span className="text-sm text-[var(--success)]">
مجموع <strong>{totalSlots}</strong> نوبت در هفته
</span>
</div>
)}
{/* ─ روش نوبت‌دهی */}
<div className="mb-3 rounded-xl border border-[var(--border)] overflow-hidden">
<div className="px-4 py-3 bg-[var(--surface-2)]">
<span className="text-sm font-medium text-[var(--text)]">روش نوبت‌دهی</span>
</div>
<div className="px-4 py-3 space-y-3">
{/* رکوردهای قدیمیِ منبع‌محور هنوز در پایگاه‌داده هستند؛ بدون این، کارتی
انتخاب‌شده دیده نمی‌شد و صفحه «بی‌حالت» به نظر می‌رسید. */}
{meta.booking_mode === 'resource' && (
<p className="text-xs text-[var(--text-2)] flex items-center gap-1.5">
<LockClosedIcon className="w-3.5 h-3.5 shrink-0" />
روش ثبت‌شدهٔ این پزشک «{MODE_LABELS.resource}» است؛ این روش دیگر قابل انتخاب نیست.
</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{BOOKING_MODE_CARDS.map(([val, lbl, desc]) => {
const selected = meta.booking_mode === val;
return (
<button
key={val}
type="button"
disabled={modeLocked}
onClick={() => !modeLocked && setMeta(m => ({ ...m, booking_mode: val }))}
className={`text-right p-3 rounded-lg border transition-colors ${
selected
? 'border-[var(--primary)] bg-[var(--primary)]/5'
: 'border-[var(--border)] bg-[var(--surface)]'
} ${modeLocked ? 'opacity-70 cursor-not-allowed' : 'hover:border-[var(--primary)]'}`}
>
<div className="flex items-center gap-2 mb-1">
<span className={`w-3.5 h-3.5 rounded-full border shrink-0 ${selected ? 'border-[var(--primary)] bg-[var(--primary)]' : 'border-[var(--border-2)]'}`} />
<span className="text-sm font-medium text-[var(--text)]">{lbl}</span>
</div>
<p className="text-xs text-[var(--text-2)] leading-relaxed">{desc}</p>
</button>
);
})}
</div>
{modeLocked ? (
<p className="text-xs text-[var(--text-2)] flex items-center gap-1.5">
<LockClosedIcon className="w-3.5 h-3.5 shrink-0" />
نوع نوبت‌دهی ثبت شده و دیگر قابل تغییر نیست.
</p>
) : (
<p className="text-xs text-[var(--warning)] leading-relaxed">
⚠️ توجه: نوع نوبت‌دهی پس از اولین ثبت <span className="font-medium">به‌هیچ‌عنوان قابل تغییر نیست</span>. پیش از ذخیره با دقت انتخاب کنید.
</p>
)}
{meta.booking_mode === 'service' ? (
<>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-[var(--text-2)]">فاصله بین نوبت‌ها</span>
<input
type="text"
inputMode="numeric"
dir="ltr"
value={meta.buffer_minutes}
onChange={(e) => setMeta(m => ({ ...m, buffer_minutes: Math.max(0, Number(digitsOnly(e.target.value)) || 0) }))}
className="w-16 text-center text-sm rounded-lg border border-[var(--border)] bg-[var(--surface)] px-2 py-1.5 focus:outline-none focus:ring-0"
/>
<span className="text-sm text-[var(--text-2)]">دقیقه</span>
</div>
<p className="text-xs text-[var(--text-3)] leading-relaxed">
مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود. لازم است حداقل یک سرویس با «نمایش در نوبت‌دهی» در بخش <span className="font-medium">{clinicUuid ? 'سرویس‌های کلینیک' : 'سرویس‌ها'}</span> تعریف کنید، وگرنه ذخیره نمی‌شود.
</p>
</>
) : (
<p className="text-xs text-[var(--text-3)] leading-relaxed">
مدت هر نوبت از «زمان هر نوبت» در شیفت‌های زیر تعیین می‌شود.
</p>
)}
</div>
</div>
{/* ─ نوبت‌دهی آنلاین */}
<div className="mb-3 rounded-xl border border-[var(--border)] overflow-hidden">
{/* header + toggle */}
<div className="flex items-center justify-between gap-3 px-4 py-3 bg-[var(--surface-2)]">
<div className="flex items-center gap-2">
<GlobeAltIcon className="w-4 h-4 text-[var(--text-2)] shrink-0" />
<span className="text-sm font-medium text-[var(--text)]">نوبت‌دهی آنلاین</span>
</div>
<Switch
checked={meta.online_booking_enabled}
onChange={(v) => setMeta(m => ({ ...m, online_booking_enabled: v }))}
ariaLabel="نوبت‌دهی آنلاین"
/>
</div>
{/* booking window control */}
<div className={`px-4 py-3 transition-opacity ${meta.online_booking_enabled ? '' : 'opacity-50 pointer-events-none'}`}>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-[var(--text-2)]">رزرو آنلاین تا</span>
<div className="flex items-stretch gap-2">
<input
type="text"
inputMode="numeric"
dir="ltr"
value={meta.booking_window_value}
disabled={!meta.online_booking_enabled}
onChange={(e) => setMeta(m => ({ ...m, booking_window_value: Math.max(1, Number(digitsOnly(e.target.value)) || 1) }))}
className="w-14 text-center text-sm rounded-lg border border-[var(--border)] bg-[var(--surface)] focus:outline-none focus:ring-0 px-2 py-1.5"
/>
<div style={{ width: 110 }}>
<GlobalSearchableSelect
options={BOOKING_WINDOW_UNITS}
value={meta.booking_window_unit}
onChange={(v) => setMeta(m => ({ ...m, booking_window_unit: (v as BookingWindowUnit) }))}
isDisabled={!meta.online_booking_enabled}
height={38}
/>
</div>
</div>
<span className="text-sm text-[var(--text-2)]">آینده</span>
</div>
<p className="text-xs text-[var(--text-3)] mt-2 leading-relaxed">
بیمار فقط تا این بازه می‌تواند آنلاین نوبت بگیرد؛ روزهای بعد از آن روی تقویم غیرفعال‌اند.
</p>
</div>
</div>
{SCHEDULE_DAYS.map(day => {
const sessions = scheduleMap[day.key]?.sessions ?? [];
const isOverlap = overlapDays[day.key];
const isExpanded = expandedDay === day.key;
const activeSessions = sessions.filter(s => s.active);
const daySlots = activeSessions.reduce((sum, s) => sum + calcSlotCount(s), 0);
return (
<div key={day.key} className="card" style={{ overflow: 'hidden' }}>
{/* Day header */}
<div
style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', userSelect: 'none' }}
onClick={() => setExpandedDay(isExpanded ? null : day.key)}
>
<b style={{ fontSize: 14, minWidth: 56, flexShrink: 0 }}>{day.label}</b>
<div style={{ flex: 1, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', minWidth: 0 }}>
{activeSessions.map((s, i) => (
<span key={i} style={{ fontSize: 13, fontFamily: 'monospace', color: 'var(--text-2)', direction: 'ltr', whiteSpace: 'nowrap' }}>
{s.start_time} {s.end_time}
</span>
))}
{sessions.length === 0 && <span className="muted" style={{ fontSize: 12 }}>تعطیل</span>}
</div>
{daySlots > 0 && (
<span className="badge green" style={{ flexShrink: 0 }}>
<span className="bdot" />{daySlots} نوبت
</span>
)}
{isOverlap && (
<span className="badge red" style={{ flexShrink: 0 }}>
<ExclamationTriangleIcon style={{ width: 12, height: 12 }} />تداخل
</span>
)}
<button type="button" className="btn ghost sm" style={{ flexShrink: 0 }}
onClick={e => { e.stopPropagation(); addSession(day.key); }}>
<PlusIcon style={{ width: 13, height: 13 }} />
بازه
</button>
<ChevronDownIcon style={{
width: 16, height: 16, flexShrink: 0, color: 'var(--text-3)',
transform: isExpanded ? 'rotate(180deg)' : 'none',
transition: 'transform .2s',
}} />
</div>
{/* Expanded sessions */}
{isExpanded && (
<div style={{ borderTop: '1px solid var(--border)', padding: '12px 16px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{sessions.length === 0 ? (
<div style={{ textAlign: 'center', padding: '16px 0' }}>
<p className="muted" style={{ fontSize: 13, marginBottom: 10 }}>هیچ بازه‌ای برای این روز تنظیم نشده</p>
<button type="button" onClick={() => addSession(day.key)} className="btn ghost sm">
<PlusIcon style={{ width: 14, height: 14 }} />
افزودن اولین بازه
</button>
</div>
) : sessions.map((session, idx) => (
<SessionEditor key={idx} session={session} addresses={addresses}
serviceMode={meta.booking_mode === 'service'}
onChange={s => updateSession(day.key, idx, s)}
onRemove={() => removeSession(day.key, idx)} />
))}
</div>
)}
</div>
);
})}
<div className="flex items-center justify-between pt-3">
{(hasAnyOverlap || missingLocation) && (
<p className="text-xs text-[var(--danger)] flex items-center gap-1">
<ExclamationTriangleIcon className="w-3.5 h-3.5" />
{hasAnyOverlap ? 'تداخل زمانی در برنامه وجود دارد' : 'مکان مطب برای همه بازه‌ها الزامی است'}
</p>
)}
{!readOnly && (
<button type="button" onClick={() => modeLocked ? saveMut.mutate() : setConfirmMode(true)}
disabled={saveMut.isPending || hasAnyOverlap || missingLocation}
className="btn primary sm" style={{ marginInlineStart: 'auto', opacity: (saveMut.isPending || hasAnyOverlap || missingLocation) ? 0.5 : 1 }}>
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره برنامه هفتگی'}
</button>
)}
</div>
<ConfirmDialog
open={confirmMode}
danger
title="تأیید نوع نوبت‌دهی"
message={`روش «${MODE_LABELS[meta.booking_mode]}» را انتخاب کرده‌اید. این انتخاب پس از ثبت به‌هیچ‌عنوان قابل تغییر نیست. ادامه می‌دهید؟`}
confirmLabel="ثبت و قفل"
loading={saveMut.isPending}
onConfirm={() => { setConfirmMode(false); saveMut.mutate(); }}
onCancel={() => setConfirmMode(false)}
/>
</div>
);
}
// ── Date Override Modal ────────────────────────────────────────────────────
function DateOverrideModal({ open, onClose, existing, doctorUuid, clinicUuid, onSaved, addresses }: {
open: boolean; onClose: () => void;
existing: DateOverrideData | null; doctorUuid: string; clinicUuid?: string | null;
onSaved: () => void; addresses: AddressData[];
}) {
const [dateStr, setDateStr] = useState('');
const [overrideType, setOverrideType] = useState<'closed' | 'custom'>('closed');
const [reason, setReason] = useState('');
const [slots, setSlots] = useState<SessionConfig[]>([]);
const toSession = useCallback((s: any): SessionConfig => ({
active: true,
start_time: s.start_time ?? s.start ?? '09:00',
end_time: s.end_time ?? s.end ?? '13:00',
duration_per_patient: s.duration_per_patient ?? s.duration ?? 20,
location_id: s.location_id ?? null,
has_rest: s.has_rest ?? false,
rest_interval: s.rest_interval ?? 60,
time_to_rest: s.time_to_rest ?? 10,
patient_limit: s.patient_limit ?? null,
}), []);
useEffect(() => {
if (open) {
if (existing) {
setDateStr(dayString(existing.date_string, existing.date));
setOverrideType(existing.active ? 'custom' : 'closed');
setReason(existing.reason ?? '');
setSlots((existing.custom_slots ?? []).map(toSession));
} else {
setDateStr(todayGregorian());
setOverrideType('closed'); setReason(''); setSlots([]);
}
}
}, [open, existing, toSession]);
const saveMut = useMutation({
mutationFn: () => {
const active = overrideType === 'custom';
const body = { date: dateStr, active, reason: reason || undefined, custom_slots: active ? slots : [] };
if (existing)
return api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/date-override/${existing.uuid}`, body);
return api.post<ApiResponse<any>>('/api/v1/appointment-settings/date-override', { ...body, doctor_uuid: doctorUuid, clinic_uuid: clinicUuid ?? null });
},
onSuccess: () => { toast.success(existing ? 'ویرایش شد' : 'تاریخ خاص اضافه شد'); onSaved(); onClose(); },
onError: (e: Error) => toast.error(e.message),
});
return (
<Modal open={open} title={existing ? 'ویرایش تاریخ خاص' : 'افزودن تاریخ خاص'} size="md" onClose={onClose}
footer={
<>
<button type="button" onClick={onClose} className="cp-btn-secondary px-5">لغو</button>
<button type="button" onClick={() => saveMut.mutate()} disabled={saveMut.isPending || !dateStr}
className="btn primary sm">
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</>
}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">تاریخ</label>
<PersianDateInput value={dateStr} onChange={setDateStr} placeholder="انتخاب تاریخ" />
</div>
{/* ─ نوع override */}
<div>
<label className="block text-sm font-medium text-[var(--text)] mb-2">نوع این روز</label>
<div className="grid grid-cols-2 gap-2">
<button type="button"
onClick={() => setOverrideType('closed')}
className={`flex items-center gap-2 p-3 rounded-xl border-2 text-sm transition-colors text-right ${overrideType === 'closed' ? 'border-[var(--danger)] bg-[var(--danger-bg)] text-[var(--danger)]' : 'border-[var(--border)] text-[var(--text-2)] hover:border-[var(--border-2)]'}`}>
<XCircleIcon className="w-5 h-5 shrink-0" />
<div>
<p className="font-medium leading-tight">تعطیل است</p>
<p className="text-xs opacity-70 mt-0.5">هیچ نوبتی داده نمی‌شود</p>
</div>
</button>
<button type="button"
onClick={() => setOverrideType('custom')}
className={`flex items-center gap-2 p-3 rounded-xl border-2 text-sm transition-colors text-right ${overrideType === 'custom' ? 'border-[var(--warning)] bg-[var(--warning-bg)] text-[var(--warning)]' : 'border-[var(--border)] text-[var(--text-2)] hover:border-[var(--border-2)]'}`}>
<CalendarIcon className="w-5 h-5 shrink-0" />
<div>
<p className="font-medium leading-tight">ساعات خاص</p>
<p className="text-xs opacity-70 mt-0.5">جایگزین برنامه هفتگی</p>
</div>
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">دلیل (اختیاری)</label>
<input type="text" value={reason} onChange={e => setReason(e.target.value)}
placeholder={overrideType === 'closed' ? 'مثال: سفر، مریضی، کنگره...' : 'مثال: شیفت اضطراری، ویزیت خاص...'}
className="input" />
</div>
{overrideType === 'custom' && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<label style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-2)' }}>بازه‌های زمانی</label>
<button type="button" className="btn ghost sm"
onClick={() => setSlots(prev => [...prev, {
...DEFAULT_SESSION,
location_id: addresses[0] ? Number(addresses[0].id) : null,
}])}>
<PlusIcon style={{ width: 13, height: 13 }} />
افزودن بازه
</button>
</div>
{slots.length === 0 ? (
<p className="muted" style={{ fontSize: 12 }}>اگر خالی بماند از برنامه هفتگی استفاده می‌شود</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{slots.map((session, idx) => (
<SessionEditor key={idx} session={session} addresses={addresses}
onChange={s => setSlots(prev => prev.map((old, i) => i === idx ? s : old))}
onRemove={() => setSlots(prev => prev.filter((_, i) => i !== idx))} />
))}
</div>
)}
</div>
)}
{overrideType === 'closed' && (
<div className="flex items-start gap-2 p-3 rounded-xl bg-[var(--danger-bg)] border border-[var(--danger)]">
<ExclamationTriangleIcon className="w-4 h-4 text-[var(--danger)] shrink-0 mt-0.5" />
<p className="text-xs text-[var(--danger)]">
در این روز هیچ نوبتی داده نخواهد شد، حتی اگر در برنامه هفتگی ساعات کاری داشته باشید.
</p>
</div>
)}
</div>
</Modal>
);
}
// ── Date Overrides Tab ─────────────────────────────────────────────────────
function DateOverridesTab({ doctorUuid, clinicUuid, addresses, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; addresses: AddressData[]; readOnly?: boolean }) {
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<DateOverrideData | null>(null);
const [deletingUuid, setDeletingUuid] = useState<string | null>(null);
const listQ = useQuery({
queryKey: ['doctor-overrides', doctorUuid, clinicUuid ?? null],
queryFn: () => api.get<ApiResponse<any>>(withClinic(`/api/v1/appointment-settings/date-override/list/${doctorUuid}`, clinicUuid)),
staleTime: 0,
});
const overrides: DateOverrideData[] = useMemo(
() => listQ.data?.data?.data ?? listQ.data?.data ?? [], [listQ.data]
);
const deleteMut = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<any>>(`/api/v1/appointment-settings/date-override/${uuid}`),
onSuccess: () => { toast.success('حذف شد'); setDeletingUuid(null); qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid, clinicUuid ?? null] }); },
onError: (e: Error) => toast.error(e.message),
});
if (listQ.isLoading) return (
<div className="space-y-2">{Array.from({ length: 3 }).map((_, i) => <div key={i} className="h-14 rounded-xl skeleton" />)}</div>
);
if (addresses.length === 0) return <NoLocationsNotice clinicUuid={clinicUuid} />;
return (
<div className="space-y-3">
{!readOnly && (
<div className="flex justify-end">
<button type="button" onClick={() => { setEditing(null); setModalOpen(true); }}
className="flex items-center gap-1.5 text-sm cp-btn-primary">
<PlusIcon className="w-4 h-4" />تاریخ خاص جدید
</button>
</div>
)}
{overrides.length === 0 ? (
<div className="text-center py-10 text-[var(--text-3)]">
<CalendarIcon className="w-10 h-10 mx-auto mb-2 opacity-30" />
<p className="text-sm">هیچ تاریخ خاصی تنظیم نشده</p>
</div>
) : (
<div className="space-y-2">
{overrides.map(ov => (
<div key={ov.uuid} className="flex items-center gap-3 p-3.5 rounded-xl border border-[var(--border)] bg-[var(--surface)]">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-[var(--text)]">{formatPersianDate(dayString(ov.date_string, ov.date))}</span>
{ov.active ? (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-[var(--warning-bg)] text-[var(--warning)]">
{ov.custom_slots.length > 0 ? `${ov.custom_slots.length} بازه سفارشی` : 'ساعات خاص'}
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-[var(--danger-bg)] text-[var(--danger)]">
تعطیل
</span>
)}
</div>
{ov.reason && <p className="text-xs text-[var(--text-3)] mt-0.5">{ov.reason}</p>}
</div>
{!readOnly && (
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => { setEditing(ov); setModalOpen(true); }}
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--text-3)] hover:text-[var(--info)] hover:bg-[var(--info-bg)] dark:hover:bg-[var(--info)]/10 transition-colors">
<PencilIcon className="w-3.5 h-3.5" />
</button>
<button onClick={() => setDeletingUuid(ov.uuid)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--text-3)] hover:text-[var(--danger)] hover:bg-[var(--danger-bg)] dark:hover:bg-[var(--danger)]/10 transition-colors">
<TrashIcon className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
))}
</div>
)}
<DateOverrideModal open={modalOpen} onClose={() => { setModalOpen(false); setEditing(null); }}
existing={editing} doctorUuid={doctorUuid} clinicUuid={clinicUuid} addresses={addresses}
onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-overrides', doctorUuid, clinicUuid ?? null] })} />
<ConfirmDialog open={!!deletingUuid} title="حذف تاریخ خاص" message="آیا از حذف این تاریخ خاص اطمینان دارید؟"
confirmLabel="بله، حذف کن" danger loading={deleteMut.isPending}
onConfirm={() => deletingUuid && deleteMut.mutate(deletingUuid)} onCancel={() => setDeletingUuid(null)} />
</div>
);
}
// ── Holiday Modal ──────────────────────────────────────────────────────────
function HolidayModal({ open, onClose, existing, doctorUuid, clinicUuid, onSaved }: {
open: boolean; onClose: () => void;
existing: HolidayData | null; doctorUuid: string; clinicUuid?: string | null;
onSaved: () => void;
}) {
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [reason, setReason] = useState('');
useEffect(() => {
if (open) {
if (existing) {
setStartDate(dayString(existing.start_date_string, existing.start_date));
setEndDate(dayString(existing.end_date_string, existing.end_date));
setReason(existing.reason ?? '');
} else {
const today = todayGregorian();
setStartDate(today); setEndDate(today); setReason('');
}
}
}, [open, existing]);
const invalid = !startDate || !endDate || endDate < startDate;
const saveMut = useMutation({
mutationFn: () => {
const body = { start_date: startDate, end_date: endDate, reason: reason || undefined };
if (existing)
return api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/holidays/${existing.uuid}`, body);
return api.post<ApiResponse<any>>('/api/v1/appointment-settings/holidays', { ...body, doctor_uuid: doctorUuid, clinic_uuid: clinicUuid ?? null });
},
onSuccess: () => { toast.success(existing ? 'تعطیلات ویرایش شد' : 'تعطیلات اضافه شد'); onSaved(); onClose(); },
onError: (e: Error) => toast.error(e.message),
});
return (
<Modal open={open} title={existing ? 'ویرایش تعطیلات' : 'افزودن تعطیلات'} size="md" onClose={onClose}
footer={
<>
<button type="button" onClick={onClose} className="cp-btn-secondary px-5">لغو</button>
<button type="button" onClick={() => saveMut.mutate()} disabled={saveMut.isPending || invalid}
className="btn primary sm">
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</>
}
>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">از تاریخ</label>
<PersianDateInput value={startDate} onChange={v => { setStartDate(v); if (endDate && v > endDate) setEndDate(v); }} />
</div>
<div>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">تا تاریخ</label>
<PersianDateInput value={endDate} onChange={setEndDate} minDate={startDate} />
</div>
</div>
{endDate && startDate && endDate < startDate && (
<p className="text-xs text-[var(--danger)]">تاریخ پایان نمی‌تواند قبل از تاریخ شروع باشد</p>
)}
<div>
<label className="block text-sm font-medium text-[var(--text)] mb-1.5">دلیل (اختیاری)</label>
<input type="text" value={reason} onChange={e => setReason(e.target.value)}
placeholder="مثال: سفر، کنگره پزشکی..." className="input" />
</div>
</div>
</Modal>
);
}
// ── Holidays Tab ───────────────────────────────────────────────────────────
function HolidaysTab({ doctorUuid, clinicUuid, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; readOnly?: boolean }) {
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<HolidayData | null>(null);
const [deletingUuid, setDeletingUuid] = useState<string | null>(null);
const listQ = useQuery({
queryKey: ['doctor-holidays', doctorUuid, clinicUuid ?? null],
queryFn: () => api.get<ApiResponse<any>>(withClinic(`/api/v1/appointment-settings/holidays/list/${doctorUuid}`, clinicUuid)),
staleTime: 0,
});
const holidays: HolidayData[] = useMemo(
() => listQ.data?.data?.data ?? listQ.data?.data ?? [], [listQ.data]
);
const deleteMut = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<any>>(`/api/v1/appointment-settings/holidays/${uuid}`),
onSuccess: () => { toast.success('حذف شد'); setDeletingUuid(null); qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid, clinicUuid ?? null] }); },
onError: (e: Error) => toast.error(e.message),
});
const toggleMut = useMutation({
mutationFn: (h: HolidayData) => api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/holidays/${h.uuid}`, { active: !h.active }),
onSuccess: () => { toast.success('وضعیت بروز شد'); qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid, clinicUuid ?? null] }); },
onError: (e: Error) => toast.error(e.message),
});
if (listQ.isLoading) return (
<div className="space-y-2">{Array.from({ length: 3 }).map((_, i) => <div key={i} className="h-14 rounded-xl skeleton" />)}</div>
);
return (
<div className="space-y-3">
{/* تقویم رسمی بالای تعطیلی اختصاصی: پزشک اول می‌بیند چه چیزی از قبل بسته است،
بعد تعطیلی خودش را رویش اضافه می‌کند. */}
<NationalHolidaysCard canUpdate={!readOnly} />
{!readOnly && (
<div className="flex justify-end">
<button type="button" onClick={() => { setEditing(null); setModalOpen(true); }}
className="flex items-center gap-1.5 text-sm cp-btn-primary">
<PlusIcon className="w-4 h-4" />تعطیلات جدید
</button>
</div>
)}
{holidays.length === 0 ? (
<div className="text-center py-10 text-[var(--text-3)]">
<CalendarIcon className="w-10 h-10 mx-auto mb-2 opacity-30" />
<p className="text-sm">هیچ تعطیلاتی ثبت نشده</p>
</div>
) : (
<div className="space-y-2">
{holidays.map(h => {
const now = Math.floor(Date.now() / 1000);
const isPast = h.end_date < now;
const isCurrent = h.start_date <= now && h.end_date >= now;
const startDay = dayString(h.start_date_string, h.start_date);
const endDay = dayString(h.end_date_string, h.end_date);
const sameDay = startDay === endDay;
return (
<div key={h.uuid} className={`flex items-center gap-3 p-3.5 rounded-xl border transition-colors ${isCurrent ? 'border-[var(--danger)] bg-[var(--danger-bg)]/40' : isPast ? 'border-[var(--border)] bg-[var(--surface-2)]/40 opacity-70' : 'border-[var(--accent)] bg-[var(--accent-bg)]/30'}`}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-sm font-medium ${isPast ? 'text-[var(--text-2)]' : 'text-[var(--text)]'}`}>
{sameDay ? formatPersianDate(startDay) : `${formatPersianDate(startDay)} تا ${formatPersianDate(endDay)}`}
</span>
{isCurrent && h.active && (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-[var(--danger-bg)] text-[var(--danger)] animate-pulse">در جریان</span>
)}
{!isCurrent && !isPast && h.active && (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-[var(--accent-bg)] text-[var(--accent)]">آینده</span>
)}
{isPast && (
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--surface-2)] text-[var(--text-3)]">گذشته</span>
)}
{!h.active && (
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--surface-2)] text-[var(--text-3)]">غیرفعال</span>
)}
</div>
{h.reason && <p className="text-xs text-[var(--text-3)] mt-0.5">{h.reason}</p>}
</div>
{!readOnly && (
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => toggleMut.mutate(h)} disabled={toggleMut.isPending}
className={`text-xs px-2.5 h-7 rounded-lg border transition-colors disabled:opacity-50 ${h.active ? 'border-[var(--border)] text-[var(--text-2)] hover:border-[var(--border-2)] hover:text-[var(--text)]' : 'border-[var(--success)] text-[var(--success)] hover:bg-[var(--success-bg)] dark:hover:bg-[var(--success)]/10'}`}>
{h.active ? 'غیرفعال' : 'فعال'}
</button>
<button onClick={() => { setEditing(h); setModalOpen(true); }}
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--text-3)] hover:text-[var(--info)] hover:bg-[var(--info-bg)] dark:hover:bg-[var(--info)]/10 transition-colors">
<PencilIcon className="w-3.5 h-3.5" />
</button>
<button onClick={() => setDeletingUuid(h.uuid)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-[var(--text-3)] hover:text-[var(--danger)] hover:bg-[var(--danger-bg)] dark:hover:bg-[var(--danger)]/10 transition-colors">
<TrashIcon className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
);
})}
</div>
)}
<HolidayModal open={modalOpen} onClose={() => { setModalOpen(false); setEditing(null); }}
existing={editing} doctorUuid={doctorUuid} clinicUuid={clinicUuid}
onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-holidays', doctorUuid, clinicUuid ?? null] })} />
<ConfirmDialog open={!!deletingUuid} title="حذف تعطیلات" message="آیا از حذف این تعطیلات اطمینان دارید؟"
confirmLabel="بله، حذف کن" danger loading={deleteMut.isPending}
onConfirm={() => deletingUuid && deleteMut.mutate(deletingUuid)} onCancel={() => setDeletingUuid(null)} />
</div>
);
}
// ── Schedule Section ───────────────────────────────────────────────────────
const SCHEDULE_TABS = [
{ id: 'weekly' as const, label: 'ساعات کاری هفتگی' },
{ id: 'overrides' as const, label: 'تاریخ‌های خاص' },
{ id: 'holidays' as const, label: 'تعطیلات' },
];
export function ScheduleSection({ doctorUuid, clinicUuid, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; readOnly?: boolean }) {
const [tab, setTab] = useState<'weekly' | 'overrides' | 'holidays'>('weekly');
const locationsQ = useQuery({
queryKey: ['available-locations', doctorUuid, clinicUuid ?? null],
queryFn: () => api.get<ApiResponse<any>>(withClinic(`/api/v1/appointment-settings/available-locations/${doctorUuid}`, clinicUuid)),
enabled: !!doctorUuid,
staleTime: 30_000,
});
const availableLocations: AddressData[] = locationsQ.data?.data?.data ?? locationsQ.data?.data ?? [];
return (
<div className="cp-card p-6">
<h2 className="text-sm font-semibold text-[var(--text)] mb-4">برنامه کاری</h2>
<div className="flex gap-1 mb-5 bg-[var(--surface-2)] p-1 rounded-xl">
{SCHEDULE_TABS.map(t => (
<button key={t.id} type="button" onClick={() => setTab(t.id)}
className={`flex-1 text-xs sm:text-sm font-medium px-2 py-1.5 rounded-lg transition-colors ${
tab === t.id
? 'bg-[var(--surface)] text-[var(--text)] shadow-sm'
: 'text-[var(--text-2)] hover:text-[var(--text)]'
}`}>
{t.label}
</button>
))}
</div>
{tab === 'weekly' && <WeeklyScheduleTab doctorUuid={doctorUuid} clinicUuid={clinicUuid} addresses={availableLocations} readOnly={readOnly} />}
{tab === 'overrides' && <DateOverridesTab doctorUuid={doctorUuid} clinicUuid={clinicUuid} addresses={availableLocations} readOnly={readOnly} />}
{tab === 'holidays' && <HolidaysTab doctorUuid={doctorUuid} clinicUuid={clinicUuid} readOnly={readOnly} />}
</div>
);
}