The holiday model was already right — national holidays global, a per-tenant override in both directions, per-doctor and per-resource exceptions — but nothing could create a national holiday. The only writer was an import command, so the calendar the whole product inherits from had no owner. Three admin-only routes give it one. POST upserts, because `date` is unique and re-sending a day should rename it rather than surface a raw database error; PATCH takes only the title, because moving a date means a different holiday. The system admin has no work environment, so the list endpoint now returns the calendar with an empty `overrides` for that role instead of the 403 `pair()` would raise — the person who maintains the calendar has to be able to read it. Both holiday tabs — the doctor's and the resource's — now open with the official calendar above their own exceptions, from one shared card rather than two copies that would drift. Each row can be opted out of with a single click, which is the existing holiday-override endpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1487 lines
75 KiB
TypeScript
1487 lines
75 KiB
TypeScript
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';
|
||
|
||
/**
|
||
* ساختار واحد تنظیمات نوبتدهی یک پزشک — برنامه هفتگی، استثناهای تاریخ و تعطیلات.
|
||
*
|
||
* همین کامپوننت هم در پنل شخصی پزشک رندر میشود و هم در تبهای پنل کلینیک؛
|
||
* تنها ورودی متمایزکننده `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;
|
||
booking_mode: 'slot' | 'service' | 'resource';
|
||
/** گام جستجوی وقت در حالت منبعمحور — دقیقه */
|
||
step_minutes?: number;
|
||
/** ترتیب امتحانکردن منابع در حالت منبعمحور */
|
||
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: 'نوبتدهی منبعمحور',
|
||
};
|
||
|
||
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 strategiesQ = useQuery({
|
||
queryKey: ['resource-strategies'],
|
||
queryFn: () =>
|
||
api.get<ApiResponse<{ code: string; label: string }[]>>(
|
||
'/api/v1/appointment-settings/resource-strategies',
|
||
),
|
||
enabled: meta.booking_mode === 'resource',
|
||
staleTime: 300_000,
|
||
});
|
||
|
||
const strategies = strategiesQ.data?.data ?? [];
|
||
|
||
const readinessQ = useQuery({
|
||
queryKey: ['resource-mode-readiness'],
|
||
queryFn: async () => {
|
||
const [resources, services] = await Promise.all([
|
||
api.get<ApiResponse<{ uuid: string }[]>>('/api/v1/resources'),
|
||
api.get<ApiResponse<{ uuid: string; has_segments?: boolean }[]>>('/api/v1/service-items'),
|
||
]);
|
||
|
||
return {
|
||
hasResources: (resources.data ?? []).length > 0,
|
||
hasSegments: (services.data ?? []).some((s) => s.has_segments === true),
|
||
};
|
||
},
|
||
enabled: meta.booking_mode === 'resource' && !modeLocked,
|
||
staleTime: 30_000,
|
||
});
|
||
|
||
const resourceReadiness = readinessQ.data ?? { hasResources: false, hasSegments: 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">
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||
{([
|
||
['slot', 'نوبتدهی اسلاتی', 'شما بازههای کاری و «مدت هر نوبت» را مشخص میکنید؛ سیستم بازه را به نوبتهای هماندازه تقسیم میکند. مناسب ویزیتهای با زمان یکسان.'],
|
||
['service', 'نوبتدهی سرویسی', 'مدت هر نوبت از «مدت سرویس» انتخابشده تعیین میشود؛ سیستم نزدیکترین زمان خالیِ کافی را پیشنهاد میدهد. مناسب خدمات با زمان متفاوت.'],
|
||
['resource', 'نوبتدهی منبعمحور', 'نوبت به چند بخش تقسیم میشود و هر بخش منابع خودش (اتاق، اپراتور، دستگاه) را میگیرد؛ وقت آزاد از تقاطع تقویم همان منابع میآید. مناسب کلینیک زیبایی و لیزر.'],
|
||
] 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-[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 === 'resource' && !modeLocked && (
|
||
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface-2)] p-3 space-y-2">
|
||
<span className="text-xs font-medium text-[var(--text)]">
|
||
پیش از انتخاب این حالت، اینها باید آماده باشند:
|
||
</span>
|
||
{/* انتخاب برگشتناپذیر است، پس شرطها باید **قبل** از ثبت دیده شوند نه در قالب خطای ۴۲۲ بعدش. */}
|
||
<ul className="space-y-1.5">
|
||
{[
|
||
['حداقل یک منبع فعال (اتاق، اپراتور یا دستگاه)', resourceReadiness.hasResources],
|
||
['حداقل یک سرویس با بخشهای تعریفشده', resourceReadiness.hasSegments],
|
||
].map(([label, ok]) => (
|
||
<li key={String(label)} className="flex items-center gap-2 text-xs">
|
||
<span className={ok ? 'text-[var(--success)]' : 'text-[var(--danger)]'}>
|
||
{ok ? '✓' : '✗'}
|
||
</span>
|
||
<span className="text-[var(--text-2)]">{label}</span>
|
||
{!ok && (
|
||
<a href="/admin/resources" className="text-[var(--primary)] underline">
|
||
تعریف کنید
|
||
</a>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
{!resourceReadiness.hasResources && (
|
||
<p className="text-xs text-[var(--danger)]">
|
||
بدون منبع فعال، هیچ وقتی محاسبه نمیشود و چون این انتخاب برگشتناپذیر است، محیط قفل میماند.
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{meta.booking_mode === 'resource' && strategies.length > 0 && (
|
||
<div className="space-y-1.5">
|
||
<span className="text-sm text-[var(--text-2)]">ترتیب انتخاب منابع</span>
|
||
<div className="max-w-md">
|
||
<GlobalSearchableSelect
|
||
value={meta.resource_strategy ?? 'first_available'}
|
||
onChange={(v) =>
|
||
setMeta(m => ({ ...m, resource_strategy: String(v ?? 'first_available') }))
|
||
}
|
||
options={strategies.map(s => ({ value: s.code, label: s.label }))}
|
||
/>
|
||
</div>
|
||
<p className="text-xs text-[var(--text-3)] leading-relaxed">
|
||
این فقط ترتیب امتحانکردن منابع را عوض میکند؛ اگر منبعی آزاد نباشد در هر
|
||
حالت رد میشود و وقت از دست نمیرود.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{meta.booking_mode === 'resource' && (
|
||
<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.step_minutes ?? 15}
|
||
onChange={(e) =>
|
||
setMeta(m => ({ ...m, step_minutes: Math.max(5, Number(digitsOnly(e.target.value)) || 5) }))
|
||
}
|
||
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>
|
||
<p className="w-full text-xs text-[var(--text-3)] leading-relaxed">
|
||
گام کوچکتر وقتهای بیشتری پیدا میکند ولی جستجو را کندتر میکند؛ پنج دقیقه کمترین مقدار مجاز است.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{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 */}
|
||
<label className="flex items-center justify-between gap-3 px-4 py-3 cursor-pointer 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>
|
||
<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-[var(--surface-3)] transition-colors peer-checked:bg-[var(--success)]" />
|
||
<span className="absolute top-0.5 right-0.5 w-5 h-5 rounded-full bg-[var(--surface)] 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-[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>
|
||
);
|
||
}
|
||
|