Files
clinicpro/assets/admin/components/schedule/ScheduleSection.tsx
T
hamed ed516c81a8 feat: Enhance appointment management by decoupling online booking toggle for admin context
- Introduced management mode for appointment slots, allowing doctors, admins, and clinic managers to view and book slots regardless of the online booking status.
- Updated SlotCalculatorService to accept a management context parameter, bypassing online booking restrictions.
- Modified appointment-related endpoints to handle management context and ensure proper authorization checks.
- Added tests to verify that management users can access slots even when online booking is disabled, while public users are still restricted.
- Improved documentation for API endpoints to reflect new management parameters and behaviors.
2026-07-22 16:43:56 +03:30

1346 lines
69 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 } from '../../lib/utils';
import Modal from '../ui/Modal';
import ConfirmDialog from '../ui/ConfirmDialog';
import GlobalSearchableSelect from '../ui/SearchableSelect';
/**
* ساختار واحد تنظیمات نوبت‌دهی یک پزشک — برنامه هفتگی، استثناهای تاریخ و تعطیلات.
*
* همین کامپوننت هم در پنل شخصی پزشک رندر می‌شود و هم در تب‌های پنل کلینیک؛
* تنها ورودی متمایزکننده `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; }
interface DateOverrideData { uuid: string; doctor_uuid: string; date: number; active: boolean; reason: string | null; custom_slots: SessionConfig[]; }
interface HolidayData { uuid: string; doctor_uuid: string; start_date: number; end_date: number; 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: 'week' | 'month';
booking_mode: 'slot' | 'service';
buffer_minutes: number;
}
const DEFAULT_BOOKING_META: BookingMeta = {
online_booking_enabled: true,
booking_window_value: 1,
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 new Date().toISOString().slice(0, 10); }
// ── 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-slate-800 dark:text-slate-200' : 'text-slate-400 dark:text-slate-500'}>
{value ? formatPersianDate(value) : (placeholder ?? 'انتخاب تاریخ')}
</span>
<CalendarIcon className="w-4 h-4 text-slate-400 shrink-0 mr-2" />
</button>
{open && createPortal(
<div ref={dropRef} style={dropStyle}
className="bg-white dark:bg-gray-900 border border-slate-200 dark:border-gray-700 rounded-2xl shadow-2xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 dark:border-gray-800">
<button type="button" onClick={nextMonth}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 text-slate-500 transition-colors">
<ChevronRightIcon className="w-4 h-4" />
</button>
<span className="text-sm font-semibold text-slate-800 dark:text-slate-100" 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-slate-100 dark:hover:bg-gray-800 text-slate-500 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-slate-400 dark:text-slate-500 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-slate-300 dark:text-gray-600 cursor-not-allowed'
: sel ? 'bg-primary-500 text-white font-semibold shadow-sm'
: tod ? 'ring-1 ring-primary-400 text-primary-700 dark:text-primary-300 font-medium'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-gray-800'}`}>
{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 new Date(ts * 1000).toISOString().slice(0, 10);
}
// ── 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-slate-500 dark:text-slate-400 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-slate-500 dark:text-slate-400 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-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/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-primary-600 dark:text-primary-400 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: '#fff',
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-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 flex items-center justify-center">
<MapPinIcon className="w-6 h-6 text-amber-500" />
</div>
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{title}</p>
<p className="text-xs text-slate-400 dark:text-slate-500 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-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/30">
<CalendarIcon className="w-4 h-4 text-emerald-600 dark:text-emerald-400 shrink-0" />
<span className="text-sm text-emerald-700 dark:text-emerald-300">
مجموع <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-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800/40">
<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-slate-100 dark:bg-gray-700 text-slate-700 dark:text-slate-200" 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-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/30">
<CalendarIcon className="w-4 h-4 text-emerald-600 dark:text-emerald-400 shrink-0" />
<span className="text-sm text-emerald-700 dark:text-emerald-300">
مجموع <strong>{totalSlots}</strong> نوبت در هفته
</span>
</div>
)}
{/* ─ روش نوبت‌دهی */}
<div className="mb-3 rounded-xl border border-slate-200 dark:border-gray-700 overflow-hidden">
<div className="px-4 py-3 bg-slate-50 dark:bg-gray-800/50">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">روش نوبت‌دهی</span>
</div>
<div className="px-4 py-3 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{([
['slot', 'نوبت‌دهی اسلاتی', 'شما بازه‌های کاری و «مدت هر نوبت» را مشخص می‌کنید؛ سیستم بازه را به نوبت‌های هم‌اندازه تقسیم می‌کند. مناسب ویزیت‌های با زمان یکسان.'],
['service', 'نوبت‌دهی سرویسی', 'مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود؛ سیستم نزدیک‌ترین زمان خالیِ کافی را پیشنهاد می‌دهد. مناسب خدمات با زمان متفاوت.'],
] as const).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-slate-200 dark:border-gray-700 bg-white dark:bg-gray-900'
} ${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-slate-300 dark:border-gray-600'}`} />
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{lbl}</span>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 leading-relaxed">{desc}</p>
</button>
);
})}
</div>
{modeLocked ? (
<p className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
<LockClosedIcon className="w-3.5 h-3.5 shrink-0" />
نوع نوبت‌دهی ثبت شده و دیگر قابل تغییر نیست.
</p>
) : (
<p className="text-xs text-amber-600 dark:text-amber-400 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-slate-600 dark:text-slate-400">فاصله بین نوبت‌ها</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-slate-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-2 py-1.5 focus:outline-none focus:ring-0"
/>
<span className="text-sm text-slate-600 dark:text-slate-400">دقیقه</span>
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 leading-relaxed">
مدت هر نوبت از «مدت سرویس» انتخاب‌شده تعیین می‌شود. لازم است حداقل یک سرویس با «نمایش در نوبت‌دهی» در بخش <span className="font-medium">{clinicUuid ? 'سرویس‌های کلینیک' : 'سرویس‌ها'}</span> تعریف کنید، وگرنه ذخیره نمی‌شود.
</p>
</>
) : (
<p className="text-xs text-slate-400 dark:text-slate-500 leading-relaxed">
مدت هر نوبت از «زمان هر نوبت» در شیفت‌های زیر تعیین می‌شود.
</p>
)}
</div>
</div>
{/* ─ نوبت‌دهی آنلاین */}
<div className="mb-3 rounded-xl border border-slate-200 dark:border-gray-700 overflow-hidden">
{/* header + toggle */}
<label className="flex items-center justify-between gap-3 px-4 py-3 cursor-pointer bg-slate-50 dark:bg-gray-800/50">
<div className="flex items-center gap-2">
<GlobeAltIcon className="w-4 h-4 text-slate-500 dark:text-slate-400 shrink-0" />
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">نوبت‌دهی آنلاین</span>
</div>
<span className="relative inline-flex shrink-0">
<input
type="checkbox"
className="peer sr-only"
checked={meta.online_booking_enabled}
onChange={(e) => setMeta(m => ({ ...m, online_booking_enabled: e.target.checked }))}
/>
<span className="w-10 h-6 rounded-full bg-slate-300 dark:bg-gray-600 transition-colors peer-checked:bg-emerald-500" />
<span className="absolute top-0.5 right-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform peer-checked:-translate-x-4" />
</span>
</label>
{/* 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-slate-600 dark:text-slate-400">رزرو آنلاین تا</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-slate-200 dark:border-gray-700 bg-white dark:bg-gray-900 focus:outline-none focus:ring-0 px-2 py-1.5"
/>
<div style={{ width: 110 }}>
<GlobalSearchableSelect
options={[{ value: 'week', label: 'هفته' }, { value: 'month', label: 'ماه' }]}
value={meta.booking_window_unit}
onChange={(v) => setMeta(m => ({ ...m, booking_window_unit: (v as 'week' | 'month') }))}
isDisabled={!meta.online_booking_enabled}
height={38}
/>
</div>
</div>
<span className="text-sm text-slate-600 dark:text-slate-400">آینده</span>
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 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-red-500 dark:text-red-400 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={`روش «${meta.booking_mode === 'service' ? 'نوبت‌دهی سرویسی' : 'نوبت‌دهی اسلاتی'}» را انتخاب کرده‌اید. این انتخاب پس از ثبت به‌هیچ‌عنوان قابل تغییر نیست. ادامه می‌دهید؟`}
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(tsToDate(existing.date));
setOverrideType(existing.active ? 'custom' : 'closed');
setReason(existing.reason ?? '');
setSlots((existing.custom_slots ?? []).map(toSession));
} else {
setDateStr(new Date().toISOString().slice(0, 10));
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-slate-700 dark:text-slate-300 mb-1.5">تاریخ</label>
<PersianDateInput value={dateStr} onChange={setDateStr} placeholder="انتخاب تاریخ" />
</div>
{/* ─ نوع override */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 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-red-400 bg-red-50 dark:bg-red-500/10 text-red-700 dark:text-red-300' : 'border-slate-200 dark:border-gray-700 text-slate-600 dark:text-slate-400 hover:border-slate-300'}`}>
<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-amber-400 bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300' : 'border-slate-200 dark:border-gray-700 text-slate-600 dark:text-slate-400 hover:border-slate-300'}`}>
<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-slate-700 dark:text-slate-300 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-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/30">
<ExclamationTriangleIcon className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
<p className="text-xs text-red-700 dark:text-red-400">
در این روز هیچ نوبتی داده نخواهد شد، حتی اگر در برنامه هفتگی ساعات کاری داشته باشید.
</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-slate-400 dark:text-slate-500">
<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-slate-200 dark:border-gray-700 bg-white dark:bg-gray-800/60">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-slate-800 dark:text-slate-200">{formatPersianDate(tsToDate(ov.date))}</span>
{ov.active ? (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300">
{ov.custom_slots.length > 0 ? `${ov.custom_slots.length} بازه سفارشی` : 'ساعات خاص'}
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400">
تعطیل
</span>
)}
</div>
{ov.reason && <p className="text-xs text-slate-400 dark:text-slate-500 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-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-500/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-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/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(tsToDate(existing.start_date));
setEndDate(tsToDate(existing.end_date));
setReason(existing.reason ?? '');
} else {
const today = new Date().toISOString().slice(0, 10);
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-slate-700 dark:text-slate-300 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-slate-700 dark:text-slate-300 mb-1.5">تا تاریخ</label>
<PersianDateInput value={endDate} onChange={setEndDate} minDate={startDate} />
</div>
</div>
{endDate && startDate && endDate < startDate && (
<p className="text-xs text-red-500">تاریخ پایان نمی‌تواند قبل از تاریخ شروع باشد</p>
)}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 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">
{!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-slate-400 dark:text-slate-500">
<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 sameDay = tsToDate(h.start_date) === tsToDate(h.end_date);
return (
<div key={h.uuid} className={`flex items-center gap-3 p-3.5 rounded-xl border transition-colors ${isCurrent ? 'border-red-200 dark:border-red-500/30 bg-red-50/40 dark:bg-red-500/5' : isPast ? 'border-slate-200 dark:border-gray-700 bg-slate-50/40 dark:bg-gray-800/30 opacity-70' : 'border-orange-200 dark:border-orange-500/30 bg-orange-50/30 dark:bg-orange-500/5'}`}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-sm font-medium ${isPast ? 'text-slate-500 dark:text-slate-400' : 'text-slate-800 dark:text-slate-200'}`}>
{sameDay ? formatPersianDate(tsToDate(h.start_date)) : `${formatPersianDate(tsToDate(h.start_date))} تا ${formatPersianDate(tsToDate(h.end_date))}`}
</span>
{isCurrent && h.active && (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 animate-pulse">در جریان</span>
)}
{!isCurrent && !isPast && h.active && (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400">آینده</span>
)}
{isPast && (
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 dark:bg-gray-700 text-slate-400 dark:text-slate-500">گذشته</span>
)}
{!h.active && (
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 dark:bg-gray-700 text-slate-400 dark:text-slate-500">غیرفعال</span>
)}
</div>
{h.reason && <p className="text-xs text-slate-400 dark:text-slate-500 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-slate-200 dark:border-gray-600 text-slate-500 dark:text-slate-400 hover:border-slate-300 hover:text-slate-700 dark:hover:text-slate-200' : 'border-emerald-200 dark:border-emerald-500/40 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-500/10'}`}>
{h.active ? 'غیرفعال' : 'فعال'}
</button>
<button onClick={() => { setEditing(h); setModalOpen(true); }}
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-500/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-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/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-slate-700 dark:text-slate-300 mb-4">برنامه کاری</h2>
<div className="flex gap-1 mb-5 bg-slate-100 dark:bg-gray-800 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-white dark:bg-gray-700 text-slate-800 dark:text-slate-100 shadow-sm'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200'
}`}>
{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>
);
}