import React, { useEffect, useMemo, useState } from 'react'; import { ExclamationTriangleIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; import { useResourceCalendar } from '../../hooks/useResourceCalendar'; import Switch from '../ui/Switch'; import { formatNumber } from '../../lib/utils'; /** ۰ = شنبه — همان قرارداد بک‌اند برای روزهای هفته. */ export const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه']; const MINUTES_IN_DAY = 1440; type Draft = { start: string; end: string; endOfDay: boolean }; type Days = Record; 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; } /** * یک روز را اعتبارسنجی می‌کند و اولین ایراد را برمی‌گرداند (یا `null`). * * سرور هم همین سه قاعده را دارد (`ResourceCalendarService::assertRanges`) و مرجع * نهایی اوست؛ این نسخه فقط بازخورد را از «بعد از کلیک ذخیره» به «حین تایپ» می‌آورد، * چون تداخل ۰۹:۰۰–۱۳:۰۰ با ۱۲:۰۰–۱۷:۰۰ را باید همان‌جا که ساخته می‌شود دید. */ function validateDay(day: number, rows: Draft[]): string | null { const parsed: { start: number; end: 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) { return `ساعت روز ${DAY_LABELS[day]} را به شکل ۰۹:۰۰ وارد کنید`; } if (end <= start) { return `در روز ${DAY_LABELS[day]} پایان شیفت باید بعد از شروع آن باشد`; } parsed.push({ start, end }); } // مرتب‌سازی لازم است: تداخل بین شیفت اول و سوم هم باید دیده شود، نه فقط همسایه‌ها. const sorted = [...parsed].sort((a, b) => a.start - b.start); for (let i = 1; i < sorted.length; i += 1) { if (sorted[i].start < sorted[i - 1].end) { return `شیفت‌های روز ${DAY_LABELS[day]} با هم تداخل دارند — بازه‌ها نباید هم‌پوشانی داشته باشند`; } } return null; } /** * شیفت هفتگی یک منبع — روزهای کاری و ساعت هر روز. * * با حذف دامنهٔ شعبه، این شیفت تنها مرجع ساعت کاری منبع است؛ فقط تعطیلات رسمی و * استثناهای خودِ منبع از آن کسر می‌شوند. * * چیدمان عمداً تک‌ستونه است: هفته یک توالی است و گرید دوستونه ترتیبش را زیگزاگ * می‌کرد. ذخیره در `save-bar` چسبان می‌نشیند تا هم پایینِ فیلدها باشد و هم * وجودش خودش نشانهٔ «ذخیره‌نشده» باشد. */ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: { resourceUuid?: string; canUpdate: boolean; }) { const { calendar, loading, save } = useResourceCalendar(resourceUuid); const [draft, setDraft] = useState({}); // نسخهٔ سرور به‌صورت رشته نگه داشته می‌شود تا مقایسهٔ dirty یک `===` ساده باشد. const [baseline, setBaseline] = useState(null); useEffect(() => { if (!calendar) return; const next: Days = {}; 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); setBaseline(JSON.stringify(next)); }, [calendar]); const totalShifts = useMemo( () => Object.values(draft).reduce((sum, rows) => sum + rows.length, 0), [draft], ); // خطا مشتقِ draft است، نه state جدا: با state، پیامِ یک ویرایشِ اصلاح‌شده تا // کلیکِ بعدیِ ذخیره روی صفحه می‌ماند. const issues = useMemo( () => DAY_LABELS.map((_, day) => validateDay(day, draft[day] ?? [])), [draft], ); const firstIssue = issues.find((m): m is string => m !== null) ?? null; // پیش از اولین پاسخ سرور baseline نداریم، پس هیچ‌چیز dirty نیست. const dirty = baseline !== null && JSON.stringify(draft) !== baseline; const editRange = (day: number, index: number, patch: Partial) => setDraft((d) => ({ ...d, [day]: (d[day] ?? []).map((r, i) => (i === index ? { ...r, ...patch } : r)) })); const addRange = (day: number) => setDraft((d) => ({ ...d, [day]: [...(d[day] ?? []), { start: '09:00', end: '17:00', endOfDay: false }] })); const removeRange = (day: number, index: number) => setDraft((d) => ({ ...d, [day]: (d[day] ?? []).filter((_, i) => i !== index) })); const reset = () => { if (baseline === null) return; setDraft(JSON.parse(baseline) as Days); }; const submit = () => { // دکمه هم غیرفعال است، ولی گاردِ دوم می‌ماند: ذخیره از راه دیگری هم صدا زده می‌شود. if (firstIssue) return; const days: Record = {}; for (const [dayKey, rows] of Object.entries(draft)) { days[dayKey] = rows.map((row) => ({ start_minute: toMinutes(row.start)!, end_minute: row.endOfDay ? MINUTES_IN_DAY : toMinutes(row.end)!, })); } save.mutate(days); }; return (

شیفت هفتگی

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

روزهای کاری و ساعت هر روز. تعطیلات رسمی و مرخصی از همین ساعت کسر می‌شوند.

{loading ? (
{DAY_LABELS.map((_, i) => (
))}
) : (
{DAY_LABELS.map((label, day) => { const rows = draft[day] ?? []; const issue = issues[day]; const cls = ['wh-day', rows.length ? 'has-shift' : '', issue ? 'err' : ''] .filter(Boolean).join(' '); return (
{label}
{rows.length === 0 ? (
بدون شیفت — این روز بسته است
) : (
{rows.map((row, index) => (
editRange(day, index, { endOfDay: v })} label="تا پایان روز" ariaLabel={`تا پایان روز برای شیفت ${formatNumber(index + 1)} روز ${label}`} /> {canUpdate && ( )}
))} {issue && (

{issue}

)}
)} {canUpdate && (
)}
); })}
)} {canUpdate && dirty && (
{firstIssue ?? 'تغییرات شیفت ذخیره نشده است'}
)}
); }