import React, { useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; import PageHeader from '../components/ui/PageHeader'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import SearchableSelect from '../components/ui/SearchableSelect'; import PersianDateInput from '../components/ui/PersianDateInput'; import { usePermissions } from '../hooks/usePermissions'; import { useResources } from '../hooks/useResources'; import { useResourceAvailability, useResourceCalendar, useResourceExceptions, } from '../hooks/useResourceCalendar'; import { formatDate } from '../lib/utils'; import type { ResourceException } from '../types'; /** ۰ = شنبه — همان قرارداد بک‌اند و ساعت کاری شعبه. */ const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه']; const MINUTES_IN_DAY = 1440; /** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده می‌شد. */ const REASON_LABELS: Record = { national_holiday: 'تعطیل رسمی', tenant_holiday: 'تعطیلی این محیط', no_shift: 'شیفتی تعریف نشده', branch_closed: 'شعبه این روز بسته است', outside_branch_hours: 'شیفت بیرون از ساعت کاری شعبه', exception: 'مرخصی یا سرویس', resource_inactive: 'منبع غیرفعال است', branch_inactive: 'شعبه غیرفعال است', }; const EXCEPTION_TYPES = [ { value: 'leave', label: 'مرخصی' }, { value: 'absence', label: 'غیبت' }, { value: 'maintenance', label: 'سرویس دوره‌ای' }, { value: 'closure', label: 'تعطیلی موردی' }, ]; type Draft = { start: string; end: string; endOfDay: boolean }; function toTime(minute: number): string { return `${String(Math.floor(minute / 60)).padStart(2, '0')}:${String(minute % 60).padStart(2, '0')}`; } function toMinutes(time: string): number | null { const m = /^(\d{1,2}):(\d{2})$/.exec(time.trim()); if (!m) return null; const minutes = Number(m[1]) * 60 + Number(m[2]); return minutes >= 0 && minutes <= MINUTES_IN_DAY ? minutes : null; } /** نیمه‌شبِ امروز به‌صورت timestamp ثانیه‌ای. */ function todayMidnight(): number { const d = new Date(); d.setHours(0, 0, 0, 0); return Math.floor(d.getTime() / 1000); } /** * تقویم یک منبع: شیفت هفتگی، استثناها، و پیش‌نمایش ساعت آزاد. * * پیش‌نمایش عمداً «ساعت آزاد» نامیده نشده بلکه «خام» است: نوبت‌های ثبت‌شده در آن * کسر نشده‌اند و اشتباه گرفتنش با «وقت قابل رزرو» به بیش‌رزروی می‌انجامد. */ export default function ResourceCalendarPage() { const { resourceUuid } = useParams<{ resourceUuid: string }>(); const { calendar, loading, save } = useResourceCalendar(resourceUuid); const { exceptions, create, remove } = useResourceExceptions(resourceUuid); const { resources } = useResources(); const { can } = usePermissions(); const canUpdate = can('appointment_settings', 'update'); const resource = resources.find((r) => r.uuid === resourceUuid); const [draft, setDraft] = useState>({}); const [error, setError] = useState(null); const [toDelete, setToDelete] = useState(null); const previewFrom = todayMidnight(); const previewTo = previewFrom + 13 * 86400; const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo); useEffect(() => { if (!calendar) return; const next: Record = {}; DAY_LABELS.forEach((_, day) => { next[day] = (calendar.days[String(day)] ?? []).map((r) => ({ start: toTime(r.start_minute), end: r.end_minute === MINUTES_IN_DAY ? '23:59' : toTime(r.end_minute), endOfDay: r.end_minute === MINUTES_IN_DAY, })); }); setDraft(next); }, [calendar]); const totalShifts = useMemo( () => Object.values(draft).reduce((sum, rows) => sum + rows.length, 0), [draft], ); const editRange = (day: number, index: number, patch: Partial) => setDraft((d) => ({ ...d, [day]: (d[day] ?? []).map((r, i) => (i === index ? { ...r, ...patch } : r)) })); const submit = () => { const days: Record = {}; for (const [dayKey, rows] of Object.entries(draft)) { const parsed: { start_minute: number; end_minute: number }[] = []; for (const row of rows) { const start = toMinutes(row.start); const end = row.endOfDay ? MINUTES_IN_DAY : toMinutes(row.end); if (start === null || end === null) { setError(`ساعت روز ${DAY_LABELS[Number(dayKey)]} را به شکل ۰۹:۰۰ وارد کنید`); return; } if (end <= start) { setError(`در روز ${DAY_LABELS[Number(dayKey)]} پایان شیفت باید بعد از شروع آن باشد`); return; } parsed.push({ start_minute: start, end_minute: end }); } days[dayKey] = parsed; } setError(null); save.mutate(days); }; return (
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ شیفت‌ها'} ) : undefined } /> {error && (
{error}
)}

شیفت هفتگی {totalShifts > 0 && ({totalShifts} شیفت)}

{loading ? (
در حال بارگذاری...
) : ( DAY_LABELS.map((label, day) => { const rows = draft[day] ?? []; return (
{label} {rows.length === 0 && بدون شیفت}
{canUpdate && ( )}
{rows.map((row, index) => (
editRange(day, index, { start: e.target.value })} style={{ width: 116 }} /> تا {row.endOfDay ? ( ۲۴:۰۰ ) : ( editRange(day, index, { end: e.target.value })} style={{ width: 116 }} /> )} {canUpdate && ( )}
))}
); }) )}
create.mutate(payload)} onDelete={setToDelete} />

پیش‌نمایش دو هفته

ساعت خام — نوبت‌های ثبت‌شده هنوز از آن کسر نشده‌اند.

{(availability?.days ?? []).map((day) => (
{DAY_LABELS[day.day_of_week]} · {formatDate(day.date * 1000)} {day.intervals.length === 0 ? ( {day.reasons.map((r) => REASON_LABELS[r] ?? r).join('، ') || '—'} ) : ( {day.total_minutes} دقیقه )}
))}
toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })} onCancel={() => setToDelete(null)} />
); } function ExceptionsCard({ exceptions, canUpdate, saving, onCreate, onDelete, }: { exceptions: ResourceException[]; canUpdate: boolean; saving: boolean; onCreate: (payload: { type: string; starts_at: number; ends_at: number; reason?: string | null }) => void; onDelete: (e: ResourceException) => void; }) { const [type, setType] = useState('leave'); const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [reason, setReason] = useState(''); const toTimestamp = (value: string): number | null => { if (value === '') return null; const ms = new Date(`${value}T00:00:00`).getTime(); return Number.isNaN(ms) ? null : Math.floor(ms / 1000); }; const start = toTimestamp(startDate); const end = toTimestamp(endDate); // پایان روزِ انتخاب‌شده، نه آغازش: مرخصیِ «تا سه‌شنبه» شامل خودِ سه‌شنبه است. const endExclusive = end === null ? null : end + 86400; const invalid = start === null || endExclusive === null || endExclusive <= start; return (

مرخصی و سرویس

{exceptions.length === 0 ? (

استثنایی ثبت نشده است.

) : (
{exceptions.map((e) => (
{e.type_label} {formatDate(e.starts_at * 1000)} تا {formatDate(e.ends_at * 1000)} {e.reason ? ` · ${e.reason}` : ''} {canUpdate && ( )}
))}
)} {canUpdate && (
setType(v ? String(v) : 'leave')} placeholder="نوع استثنا" height={36} /> {/* تقویم شمسی، نه `input type=date` میلادی: اپراتور تاریخ را شمسی می‌گوید و ترجمهٔ ذهنی همان‌جایی است که استثنا یک روز جابه‌جا ثبت می‌شود. */}
setReason(e.target.value)} placeholder="توضیح (اختیاری)" />
)}
); }