A doctor working both at their own practice and at a clinic had to write two independent schedules and neither panel could see the other, so the clinic showed an empty form even though the doctor had configured their practice. The schedule is now a single record owned by the doctor. What varies between days is the place: the context of a shift is read from its location_id, not from the record it lives in. Booking in a context therefore sees only that context's days, so a personal-practice secretary still cannot book a clinic day. The caller's own context decides which addresses they may assign: the doctor gets every place of theirs, a clinic manager only its own, and shifts outside their reach are returned for display but preserved verbatim on save. Existing per-clinic rows are merged by migration; location_id was already stored on every shift, so no context information is lost. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1447 lines
73 KiB
TypeScript
1447 lines
73 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';
|
||
import Switch from '../ui/Switch';
|
||
|
||
/**
|
||
* ساختار واحد تنظیمات نوبتدهی یک پزشک — برنامه هفتگی، استثناهای تاریخ و تعطیلات.
|
||
*
|
||
* همین کامپوننت هم در پنل شخصی پزشک رندر میشود و هم در تبهای پنل کلینیک؛
|
||
* تنها ورودی متمایزکننده `doctorUuid` است تا دو پیادهسازی موازی به وجود نیاید.
|
||
*/
|
||
|
||
export interface AddressData {
|
||
id: string; uuid: string;
|
||
type: 'personal' | 'clinic';
|
||
clinic_id: string | null;
|
||
clinic_name: string | null;
|
||
name: string | null; address: string | null;
|
||
telephone: string | null;
|
||
map: { latitude: string | null; longitude: string | null };
|
||
city: { id: string; name: string } | null;
|
||
province: { id: string; name: string } | null;
|
||
}
|
||
|
||
// ── Schedule types ─────────────────────────────────────────────────────────
|
||
|
||
interface SlotConfig { start: string; end: string; duration: number; }
|
||
// `*_string` فیلدهای «Y-m-d» سرور هستند؛ برای نمایش همیشه اولویت با آنهاست تا
|
||
// تبدیل timestamp در مرورگر تاریخ را یک روز جابهجا نکند.
|
||
interface DateOverrideData { uuid: string; doctor_uuid: string; date: number; date_string?: string; active: boolean; reason: string | null; custom_slots: SessionConfig[]; }
|
||
interface HolidayData { uuid: string; doctor_uuid: string; start_date: number; end_date: number; start_date_string?: string; end_date_string?: string; active: boolean; reason: string | null; }
|
||
|
||
interface SessionConfig {
|
||
active: boolean;
|
||
location_id: number | null;
|
||
start_time: string;
|
||
end_time: string;
|
||
duration_per_patient: number;
|
||
has_rest: boolean;
|
||
rest_interval: number;
|
||
time_to_rest: number;
|
||
patient_limit: number | null;
|
||
}
|
||
interface NewDayConfig { sessions: SessionConfig[]; }
|
||
type NewScheduleMap = Record<string, NewDayConfig>;
|
||
interface BookingMeta {
|
||
online_booking_enabled: boolean;
|
||
booking_window_value: number;
|
||
booking_window_unit: BookingWindowUnit;
|
||
/**
|
||
* `resource` دیگر قابل انتخاب نیست و فقط برای خواندنِ رکوردهای قدیمی در نوع میماند
|
||
* — سرور همچنان همان مقدار را برمیگرداند.
|
||
*/
|
||
booking_mode: 'slot' | 'service' | 'resource';
|
||
/** فقط پسفرست میشود تا مقدارِ رکوردهای قدیمی در PATCH پاک نشود. */
|
||
step_minutes?: number;
|
||
/** فقط پسفرست میشود تا مقدارِ رکوردهای قدیمی در PATCH پاک نشود. */
|
||
resource_strategy?: string;
|
||
buffer_minutes: number;
|
||
}
|
||
type BookingWindowUnit = 'day' | 'week' | 'month';
|
||
const BOOKING_WINDOW_UNITS: { value: BookingWindowUnit; label: string }[] = [
|
||
{ value: 'day', label: 'روز' },
|
||
{ value: 'week', label: 'هفته' },
|
||
{ value: 'month', label: 'ماه' },
|
||
];
|
||
/** برچسب فارسی هر حالت — یک جا، تا پیام تأیید و کارتها از هم واگرا نشوند. */
|
||
const MODE_LABELS: Record<BookingMeta['booking_mode'], string> = {
|
||
slot: 'نوبتدهی اسلاتی',
|
||
service: 'نوبتدهی سرویسی',
|
||
resource: 'نوبتدهی منبعمحور',
|
||
};
|
||
|
||
/**
|
||
* حالتهای قابل انتخاب برای پزشک. `resource` عمداً اینجا نیست: انتخابش برگشتناپذیر
|
||
* بود و محیط را قفل میکرد، پس از تنظیمات پزشک برداشته شد. صفحهٔ «نوبت منبعمحور» هم
|
||
* در ۲۰۲۶-۰۸-۰۸ حذف شد؛ رکوردهای قدیمی فقط خواندنی اعلام میشوند (پایین همین فایل).
|
||
*/
|
||
const BOOKING_MODE_CARDS: ReadonlyArray<readonly [BookingMeta['booking_mode'], string, string]> = [
|
||
['slot', 'نوبتدهی اسلاتی', 'شما بازههای کاری و «مدت هر نوبت» را مشخص میکنید؛ سیستم بازه را به نوبتهای هماندازه تقسیم میکند. مناسب ویزیتهای با زمان یکسان.'],
|
||
['service', 'نوبتدهی سرویسی', 'مدت هر نوبت از «مدت سرویس» انتخابشده تعیین میشود؛ سیستم نزدیکترین زمان خالیِ کافی را پیشنهاد میدهد. مناسب خدمات با زمان متفاوت.'],
|
||
];
|
||
|
||
const DEFAULT_BOOKING_META: BookingMeta = {
|
||
online_booking_enabled: true,
|
||
booking_window_value: 3,
|
||
booking_window_unit: 'month',
|
||
booking_mode: 'slot',
|
||
buffer_minutes: 0,
|
||
};
|
||
interface WeeklyScheduleData {
|
||
uuid: string;
|
||
doctor_uuid: string;
|
||
schedule: NewScheduleMap;
|
||
meta?: BookingMeta;
|
||
booking_mode_locked?: boolean;
|
||
/** همهٔ مکانهای پزشک — مطب شخصی و هر کلینیکی که عضوش است. فقط برای برچسبزدن. */
|
||
locations?: AddressData[];
|
||
/** مکانهایی که همین کاربر حق دارد روی برنامه بنشاند. */
|
||
selectable_location_ids?: number[];
|
||
}
|
||
|
||
// ── 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>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* شیفتی که مکانش خارج از دسترس این کاربر است.
|
||
*
|
||
* حذفش از فهرست، برنامه را ناقص نشان میدهد و کاربر روی همان ساعت شیفت تازه میگذارد؛
|
||
* ویرایشپذیر کردنش هم یعنی مدیر کلینیک میتواند برنامهٔ مطب شخصی پزشک را عوض کند.
|
||
* پس دیده میشود و دست نمیخورد.
|
||
*/
|
||
function ForeignSessionRow({ session, placeName }: { session: SessionConfig; placeName: string }) {
|
||
return (
|
||
<div style={{
|
||
border: '1px dashed var(--border-2)', borderRadius: 'var(--r)', background: 'var(--surface-2)',
|
||
padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap',
|
||
}}>
|
||
<LockClosedIcon style={{ width: 15, height: 15, color: 'var(--text-3)', flexShrink: 0 }} />
|
||
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
|
||
{session.start_time} تا {session.end_time}
|
||
</span>
|
||
<span className="badge" style={{ flexShrink: 0 }}>{placeName}</span>
|
||
<span className="muted" style={{ fontSize: 12 }}>خارج از دسترس شما</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; addresses: AddressData[]; readOnly?: boolean }) {
|
||
const qc = useQueryClient();
|
||
// برنامه یکی است و شیفتهای همهٔ محیطهای پزشک را دارد. مدیر کلینیک شیفت مطب شخصی
|
||
// را میبیند ولی نباید بتواند عوضش کند، پس این دو از هم جدا نگه داشته میشوند.
|
||
const [allLocations, setAllLocations] = useState<AddressData[]>([]);
|
||
const [selectableIds, setSelectableIds] = useState<number[] | null>(null);
|
||
const [scheduleMap, setScheduleMap] = useState<NewScheduleMap>(EMPTY_NEW_SCHEDULE);
|
||
const [scheduleUuid, setScheduleUuid] = useState<string | null>(null);
|
||
const [expandedDay, setExpandedDay] = useState<string | null>(null);
|
||
const [meta, setMeta] = useState<BookingMeta>(DEFAULT_BOOKING_META);
|
||
// نوع نوبتدهی پس از اولین ثبت قفل میشود؛ confirmMode = دیالوگ هشدار قبل از ثبت اول.
|
||
const [modeLocked, setModeLocked] = useState(false);
|
||
|
||
const [confirmMode, setConfirmMode] = useState(false);
|
||
|
||
const scheduleQ = useQuery({
|
||
queryKey: ['doctor-schedule', doctorUuid, clinicUuid ?? null],
|
||
queryFn: () => api.get<ApiResponse<any>>(withClinic(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, clinicUuid)),
|
||
staleTime: 0,
|
||
retry: (count, err) => !(err instanceof ApiError && err.status === 404),
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (scheduleQ.data) {
|
||
const d: WeeklyScheduleData = scheduleQ.data?.data?.data ?? scheduleQ.data?.data;
|
||
if (d?.schedule) {
|
||
const merged: NewScheduleMap = { ...EMPTY_NEW_SCHEDULE };
|
||
for (const key of Object.keys(d.schedule)) {
|
||
const raw = d.schedule[key] as any;
|
||
if (raw?.sessions) merged[key] = { sessions: raw.sessions };
|
||
}
|
||
setScheduleMap(merged);
|
||
setScheduleUuid(d.uuid);
|
||
}
|
||
if (d?.meta) setMeta({ ...DEFAULT_BOOKING_META, ...d.meta });
|
||
if (d?.locations) setAllLocations(d.locations);
|
||
if (d?.selectable_location_ids) setSelectableIds(d.selectable_location_ids.map(Number));
|
||
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 canEditSession = (session: SessionConfig) =>
|
||
selectableIds === null || session.location_id === null || selectableIds.includes(session.location_id);
|
||
|
||
const locationLabel = (locationId: number | null) => {
|
||
const found = (allLocations.length ? allLocations : addresses).find(a => Number(a.id) === locationId);
|
||
if (!found) return 'مکان نامشخص';
|
||
return found.type === 'clinic'
|
||
? (found.clinic_name ?? found.name ?? 'کلینیک')
|
||
: (found.name ?? 'مطب شخصی');
|
||
};
|
||
|
||
const overlapDays = useMemo(() =>
|
||
Object.fromEntries(SCHEDULE_DAYS.map(d => [d.key, hasOverlap(scheduleMap[d.key]?.sessions ?? [])]))
|
||
, [scheduleMap]);
|
||
const hasAnyOverlap = Object.values(overlapDays).some(Boolean);
|
||
|
||
const totalSlots = useMemo(() =>
|
||
SCHEDULE_DAYS.reduce((sum, d) => {
|
||
const sessions = scheduleMap[d.key]?.sessions ?? [];
|
||
return sum + sessions.filter(s => s.active).reduce((s2, s) => s2 + calcSlotCount(s), 0);
|
||
}, 0)
|
||
, [scheduleMap]);
|
||
|
||
const missingLocation = addresses.length > 0 && SCHEDULE_DAYS.some(d =>
|
||
(scheduleMap[d.key]?.sessions ?? []).some(s => s.active && s.location_id === null)
|
||
);
|
||
|
||
const saveMut = useMutation({
|
||
mutationFn: () => {
|
||
if (hasAnyOverlap) throw new Error('تداخل زمانی در برنامه وجود دارد');
|
||
if (missingLocation) throw new Error('مکان مطب برای همه بازههای فعال الزامی است');
|
||
return scheduleUuid
|
||
? api.patch<ApiResponse<any>>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null })
|
||
: api.post<ApiResponse<any>>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null });
|
||
},
|
||
onSuccess: (res) => {
|
||
const d: WeeklyScheduleData = res?.data?.data ?? res?.data;
|
||
if (d?.uuid && !scheduleUuid) setScheduleUuid(d.uuid);
|
||
setModeLocked(true); // پس از ثبت، نوع نوبتدهی قفل میشود
|
||
toast.success('برنامه هفتگی ذخیره شد');
|
||
qc.invalidateQueries({ queryKey: ['doctor-schedule', doctorUuid, clinicUuid ?? null] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const setDaySessions = (key: string, sessions: SessionConfig[]) =>
|
||
setScheduleMap(prev => ({ ...prev, [key]: { sessions } }));
|
||
|
||
const addSession = (key: string) => {
|
||
const existing = scheduleMap[key]?.sessions ?? [];
|
||
const lastEnd = existing.length > 0
|
||
? existing.reduce((max, s) => parseMinutes(s.end_time) > parseMinutes(max) ? s.end_time : max, '00:00')
|
||
: '09:00';
|
||
const newStart = existing.length > 0 ? addMinutes(lastEnd, 30) : '09:00';
|
||
const newEnd = addMinutes(newStart, 240);
|
||
const defaultLoc = addresses.length > 0 ? Number(addresses[0].id) : null;
|
||
setDaySessions(key, [...existing, { ...DEFAULT_SESSION, start_time: newStart, end_time: newEnd, location_id: defaultLoc }]);
|
||
setExpandedDay(key);
|
||
};
|
||
|
||
const removeSession = (key: string, idx: number) =>
|
||
setDaySessions(key, (scheduleMap[key]?.sessions ?? []).filter((_, i) => i !== idx));
|
||
|
||
const updateSession = (key: string, idx: number, s: SessionConfig) =>
|
||
setDaySessions(key, (scheduleMap[key]?.sessions ?? []).map((old, i) => i === idx ? s : old));
|
||
|
||
if (scheduleQ.isLoading) return (
|
||
<div className="space-y-3">{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-12 rounded-xl skeleton" />)}</div>
|
||
);
|
||
|
||
if (addresses.length === 0) return <NoLocationsNotice clinicUuid={clinicUuid} />;
|
||
|
||
// نمای فقطخواندنی برای نماینده: برنامهی هفتگی بهصورت متن، بدون فرم.
|
||
if (readOnly) {
|
||
if (scheduleQ.isLoading) {
|
||
return <div className="space-y-2">{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-12 rounded-xl skeleton" />)}</div>;
|
||
}
|
||
return (
|
||
<div className="space-y-2">
|
||
{totalSlots > 0 && (
|
||
<div className="flex items-center gap-2 mb-3 p-3 rounded-xl bg-[var(--success-bg)] border border-[var(--success)]">
|
||
<CalendarIcon className="w-4 h-4 text-[var(--success)] shrink-0" />
|
||
<span className="text-sm text-[var(--success)]">
|
||
مجموع <strong>{totalSlots}</strong> نوبت در هفته
|
||
</span>
|
||
</div>
|
||
)}
|
||
{SCHEDULE_DAYS.map(day => {
|
||
const activeSessions = (scheduleMap[day.key]?.sessions ?? []).filter(s => s.active);
|
||
const daySlots = activeSessions.reduce((sum, s) => sum + calcSlotCount(s), 0);
|
||
return (
|
||
<div key={day.key} className="flex items-center gap-3 px-4 py-3 rounded-xl border border-[var(--border)] bg-[var(--surface)]">
|
||
<b style={{ fontSize: 14, minWidth: 56, flexShrink: 0 }}>{day.label}</b>
|
||
<div className="flex-1 flex gap-2 items-center flex-wrap min-w-0">
|
||
{activeSessions.length > 0 ? activeSessions.map((s, i) => (
|
||
<span key={i} className="text-xs px-2 py-1 rounded-lg bg-[var(--surface-2)] text-[var(--text)]" style={{ fontFamily: 'monospace', direction: 'ltr', whiteSpace: 'nowrap' }}>
|
||
{s.start_time} – {s.end_time}
|
||
</span>
|
||
)) : (
|
||
<span className="muted text-xs">تعطیل</span>
|
||
)}
|
||
</div>
|
||
{daySlots > 0 && (
|
||
<span className="badge green" style={{ flexShrink: 0 }}>
|
||
<span className="bdot" />{daySlots} نوبت
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-2">
|
||
{/* ─ خلاصه کل هفته */}
|
||
{totalSlots > 0 && (
|
||
<div className="flex items-center gap-2 mb-3 p-3 rounded-xl bg-[var(--success-bg)] border border-[var(--success)]">
|
||
<CalendarIcon className="w-4 h-4 text-[var(--success)] shrink-0" />
|
||
<span className="text-sm text-[var(--success)]">
|
||
مجموع <strong>{totalSlots}</strong> نوبت در هفته
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* ─ روش نوبتدهی */}
|
||
<div className="mb-3 rounded-xl border border-[var(--border)] overflow-hidden">
|
||
<div className="px-4 py-3 bg-[var(--surface-2)]">
|
||
<span className="text-sm font-medium text-[var(--text)]">روش نوبتدهی</span>
|
||
</div>
|
||
<div className="px-4 py-3 space-y-3">
|
||
{/* رکوردهای قدیمیِ منبعمحور هنوز در پایگاهداده هستند؛ بدون این، کارتی
|
||
انتخابشده دیده نمیشد و صفحه «بیحالت» به نظر میرسید. */}
|
||
{meta.booking_mode === 'resource' && (
|
||
<p className="text-xs text-[var(--text-2)] flex items-center gap-1.5">
|
||
<LockClosedIcon className="w-3.5 h-3.5 shrink-0" />
|
||
روش ثبتشدهٔ این پزشک «{MODE_LABELS.resource}» است؛ این روش دیگر قابل انتخاب نیست.
|
||
</p>
|
||
)}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||
{BOOKING_MODE_CARDS.map(([val, lbl, desc]) => {
|
||
const selected = meta.booking_mode === val;
|
||
return (
|
||
<button
|
||
key={val}
|
||
type="button"
|
||
disabled={modeLocked}
|
||
onClick={() => !modeLocked && setMeta(m => ({ ...m, booking_mode: val }))}
|
||
className={`text-right p-3 rounded-lg border transition-colors ${
|
||
selected
|
||
? 'border-[var(--primary)] bg-[var(--primary)]/5'
|
||
: 'border-[var(--border)] bg-[var(--surface)]'
|
||
} ${modeLocked ? 'opacity-70 cursor-not-allowed' : 'hover:border-[var(--primary)]'}`}
|
||
>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<span className={`w-3.5 h-3.5 rounded-full border shrink-0 ${selected ? 'border-[var(--primary)] bg-[var(--primary)]' : 'border-[var(--border-2)]'}`} />
|
||
<span className="text-sm font-medium text-[var(--text)]">{lbl}</span>
|
||
</div>
|
||
<p className="text-xs text-[var(--text-2)] leading-relaxed">{desc}</p>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
{modeLocked ? (
|
||
<p className="text-xs text-[var(--text-2)] flex items-center gap-1.5">
|
||
<LockClosedIcon className="w-3.5 h-3.5 shrink-0" />
|
||
نوع نوبتدهی ثبت شده و دیگر قابل تغییر نیست.
|
||
</p>
|
||
) : (
|
||
<p className="text-xs text-[var(--warning)] leading-relaxed">
|
||
⚠️ توجه: نوع نوبتدهی پس از اولین ثبت <span className="font-medium">بههیچعنوان قابل تغییر نیست</span>. پیش از ذخیره با دقت انتخاب کنید.
|
||
</p>
|
||
)}
|
||
{meta.booking_mode === 'service' ? (
|
||
<>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="text-sm text-[var(--text-2)]">فاصله بین نوبتها</span>
|
||
<input
|
||
type="text"
|
||
inputMode="numeric"
|
||
dir="ltr"
|
||
value={meta.buffer_minutes}
|
||
onChange={(e) => setMeta(m => ({ ...m, buffer_minutes: Math.max(0, Number(digitsOnly(e.target.value)) || 0) }))}
|
||
className="w-16 text-center text-sm rounded-lg border border-[var(--border)] bg-[var(--surface)] px-2 py-1.5 focus:outline-none focus:ring-0"
|
||
/>
|
||
<span className="text-sm text-[var(--text-2)]">دقیقه</span>
|
||
</div>
|
||
<p className="text-xs text-[var(--text-3)] leading-relaxed">
|
||
مدت هر نوبت از «مدت سرویس» انتخابشده تعیین میشود. لازم است حداقل یک سرویس با «نمایش در نوبتدهی آنلاین» در بخش <span className="font-medium">{clinicUuid ? 'سرویسهای کلینیک' : 'سرویسها'}</span> تعریف کنید، وگرنه ذخیره نمیشود.
|
||
</p>
|
||
</>
|
||
) : (
|
||
<p className="text-xs text-[var(--text-3)] leading-relaxed">
|
||
مدت هر نوبت از «زمان هر نوبت» در شیفتهای زیر تعیین میشود.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ─ نوبتدهی آنلاین */}
|
||
<div className="mb-3 rounded-xl border border-[var(--border)] overflow-hidden">
|
||
{/* header + toggle */}
|
||
<div className="flex items-center justify-between gap-3 px-4 py-3 bg-[var(--surface-2)]">
|
||
<div className="flex items-center gap-2">
|
||
<GlobeAltIcon className="w-4 h-4 text-[var(--text-2)] shrink-0" />
|
||
<span className="text-sm font-medium text-[var(--text)]">نوبتدهی آنلاین</span>
|
||
</div>
|
||
<Switch
|
||
checked={meta.online_booking_enabled}
|
||
onChange={(v) => setMeta(m => ({ ...m, online_booking_enabled: v }))}
|
||
ariaLabel="نوبتدهی آنلاین"
|
||
/>
|
||
</div>
|
||
|
||
{/* booking window control */}
|
||
<div className={`px-4 py-3 transition-opacity ${meta.online_booking_enabled ? '' : 'opacity-50 pointer-events-none'}`}>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="text-sm text-[var(--text-2)]">رزرو آنلاین تا</span>
|
||
<div className="flex items-stretch gap-2">
|
||
<input
|
||
type="text"
|
||
inputMode="numeric"
|
||
dir="ltr"
|
||
value={meta.booking_window_value}
|
||
disabled={!meta.online_booking_enabled}
|
||
onChange={(e) => setMeta(m => ({ ...m, booking_window_value: Math.max(1, Number(digitsOnly(e.target.value)) || 1) }))}
|
||
className="w-14 text-center text-sm rounded-lg border border-[var(--border)] bg-[var(--surface)] focus:outline-none focus:ring-0 px-2 py-1.5"
|
||
/>
|
||
<div style={{ width: 110 }}>
|
||
<GlobalSearchableSelect
|
||
options={BOOKING_WINDOW_UNITS}
|
||
value={meta.booking_window_unit}
|
||
onChange={(v) => setMeta(m => ({ ...m, booking_window_unit: (v as BookingWindowUnit) }))}
|
||
isDisabled={!meta.online_booking_enabled}
|
||
height={38}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<span className="text-sm text-[var(--text-2)]">آینده</span>
|
||
</div>
|
||
<p className="text-xs text-[var(--text-3)] mt-2 leading-relaxed">
|
||
بیمار فقط تا این بازه میتواند آنلاین نوبت بگیرد؛ روزهای بعد از آن روی تقویم غیرفعالاند.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{SCHEDULE_DAYS.map(day => {
|
||
const sessions = scheduleMap[day.key]?.sessions ?? [];
|
||
const isOverlap = overlapDays[day.key];
|
||
const isExpanded = expandedDay === day.key;
|
||
const activeSessions = sessions.filter(s => s.active);
|
||
const daySlots = activeSessions.reduce((sum, s) => sum + calcSlotCount(s), 0);
|
||
|
||
return (
|
||
<div key={day.key} className="card" style={{ overflow: 'hidden' }}>
|
||
{/* Day header */}
|
||
<div
|
||
style={{ padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', userSelect: 'none' }}
|
||
onClick={() => setExpandedDay(isExpanded ? null : day.key)}
|
||
>
|
||
<b style={{ fontSize: 14, minWidth: 56, flexShrink: 0 }}>{day.label}</b>
|
||
<div style={{ flex: 1, display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', minWidth: 0 }}>
|
||
{activeSessions.map((s, i) => (
|
||
<span key={i} style={{ fontSize: 13, fontFamily: 'monospace', color: 'var(--text-2)', direction: 'ltr', whiteSpace: 'nowrap' }}>
|
||
{s.start_time} — {s.end_time}
|
||
</span>
|
||
))}
|
||
{sessions.length === 0 && <span className="muted" style={{ fontSize: 12 }}>تعطیل</span>}
|
||
</div>
|
||
{daySlots > 0 && (
|
||
<span className="badge green" style={{ flexShrink: 0 }}>
|
||
<span className="bdot" />{daySlots} نوبت
|
||
</span>
|
||
)}
|
||
{isOverlap && (
|
||
<span className="badge red" style={{ flexShrink: 0 }}>
|
||
<ExclamationTriangleIcon style={{ width: 12, height: 12 }} />تداخل
|
||
</span>
|
||
)}
|
||
<button type="button" className="btn ghost sm" style={{ flexShrink: 0 }}
|
||
onClick={e => { e.stopPropagation(); addSession(day.key); }}>
|
||
<PlusIcon style={{ width: 13, height: 13 }} />
|
||
بازه
|
||
</button>
|
||
<ChevronDownIcon style={{
|
||
width: 16, height: 16, flexShrink: 0, color: 'var(--text-3)',
|
||
transform: isExpanded ? 'rotate(180deg)' : 'none',
|
||
transition: 'transform .2s',
|
||
}} />
|
||
</div>
|
||
|
||
{/* Expanded sessions */}
|
||
{isExpanded && (
|
||
<div style={{ borderTop: '1px solid var(--border)', padding: '12px 16px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
{sessions.length === 0 ? (
|
||
<div style={{ textAlign: 'center', padding: '16px 0' }}>
|
||
<p className="muted" style={{ fontSize: 13, marginBottom: 10 }}>هیچ بازهای برای این روز تنظیم نشده</p>
|
||
<button type="button" onClick={() => addSession(day.key)} className="btn ghost sm">
|
||
<PlusIcon style={{ width: 14, height: 14 }} />
|
||
افزودن اولین بازه
|
||
</button>
|
||
</div>
|
||
) : sessions.map((session, idx) => (
|
||
canEditSession(session) ? (
|
||
<SessionEditor key={idx} session={session} addresses={addresses}
|
||
serviceMode={meta.booking_mode === 'service'}
|
||
onChange={s => updateSession(day.key, idx, s)}
|
||
onRemove={() => removeSession(day.key, idx)} />
|
||
) : (
|
||
<ForeignSessionRow key={idx} session={session} placeName={locationLabel(session.location_id)} />
|
||
)
|
||
))}
|
||
</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>
|
||
);
|
||
}
|
||
|