Strategies (task 06 debt, task 12 dependency) - ResourcePicker orders candidates; it deliberately does not choose. Only the engine knows which resource actually fits this slot and which was already taken by another role, and a strategy that picked would have to duplicate both checks - Four implementations behind a tagged iterator: first_available (name order, the previous behaviour and still the default because it is predictable), least_gap, least_loaded, same_as_previous - least_gap and least_loaded are deliberate opposites and both are correct; choosing between them is a business decision, so it lives in settings - same_as_previous lifts a course's preferred resource to the front and keeps everyone else behind it. A preference, not a filter: forcing the same operator would make the patient wait two weeks, which is worse than a different operator - Availability accepts course_uuid to supply that preference, closing the dependency task 12 recorded against task 06 - An unknown strategy falls back at search time but is rejected at save time. Stale settings must not stop bookings; a user typing a wrong value must not believe it took effect Test suite flake createUser() retries on a mobile-number collision — db_test is never reset and holds tens of thousands of users, so the random draw does collide. The failed INSERT closes the EntityManager, and the retry asked the container for it again, which hands back the *same closed instance*. So the retry threw, and every later test in that process inherited a dead manager. That is the intermittent "EntityManager is closed" on an unrelated, always-different test that made roughly half of full runs red and never reproduced in a subset. Resetting the registry gives a live manager back. UserCollisionRetryTest pins it by closing the manager on purpose. Two consecutive full runs are green: 1334 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1482 lines
75 KiB
TypeScript
1482 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';
|
||
|
||
/**
|
||
* ساختار واحد تنظیمات نوبتدهی یک پزشک — برنامه هفتگی، استثناهای تاریخ و تعطیلات.
|
||
*
|
||
* همین کامپوننت هم در پنل شخصی پزشک رندر میشود و هم در تبهای پنل کلینیک؛
|
||
* تنها ورودی متمایزکننده `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">
|
||
{!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>
|
||
);
|
||
}
|
||
|