feat: enhance ResourceWorkingHoursPanel with improved error handling and UI updates

- Added new icons and improved error messaging for better user feedback.
- Refactored state management to include baseline comparison for dirty state detection.
- Introduced functionality to copy shifts across all days and reset to baseline.
- Updated UI layout for better responsiveness and usability.
- Enhanced tests to cover new features and ensure proper functionality.

refactor: update ClinicAppointmentSettingsPage to use PageHeader component

- Replaced BackButton with PageHeader for a more consistent header layout.
- Simplified the structure of the appointment settings page for better readability.

test: improve ResourceDetailPage tests for shift management

- Updated tests to reflect changes in shift display and error handling.
- Added tests for new features including the reset functionality and copying shifts.

style: add styles for weekly shift layout in ResourceWorkingHoursPanel

- Introduced new CSS classes for better layout and responsiveness of the weekly shift display.
- Ensured styles are consistent with the overall design system.
This commit is contained in:
hamed
2026-08-03 09:24:50 +03:30
parent 4fe0c4f9bf
commit c42679d98c
5 changed files with 511 additions and 218 deletions
@@ -1,12 +1,14 @@
import React, { useState } from 'react';
import React, { useId, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { XMarkIcon } from '@heroicons/react/24/outline';
import ConfirmDialog from '../ui/ConfirmDialog';
import SearchableSelect from '../ui/SearchableSelect';
import PersianDateInput from '../ui/PersianDateInput';
import { useResourceAvailability, useResourceExceptions } from '../../hooks/useResourceCalendar';
import { formatDate } from '../../lib/utils';
import { formatDate, formatNumber } from '../../lib/utils';
import { DAY_LABELS } from './ResourceWorkingHoursPanel';
import NationalHolidaysCard from '../holidays/NationalHolidaysCard';
import type { ResourceException } from '../../types';
import type { ResourceAvailability, ResourceException } from '../../types';
/** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده می‌شد. */
const REASON_LABELS: Record<string, string> = {
@@ -37,6 +39,9 @@ function todayMidnight(): number {
*
* پیش‌نمایش عمداً «ساعت آزاد» نامیده نشده بلکه «خام» است: نوبت‌های ثبت‌شده در آن
* کسر نشده‌اند و اشتباه گرفتنش با «وقت قابل رزرو» به بیش‌رزروی می‌انجامد.
*
* دو ستون، نه `auto-fit`: تعطیلات و استثنا هر دو ورودی‌اند و کنار هم می‌مانند،
* پیش‌نمایش خروجی است و ستون خودش را می‌گیرد.
*/
export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
resourceUuid?: string;
@@ -50,49 +55,26 @@ export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo);
return (
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', alignItems: 'start' }}>
<NationalHolidaysCard canUpdate={canUpdate} />
<ExceptionsCard
exceptions={exceptions}
canUpdate={canUpdate}
saving={create.isPending}
onCreate={(payload) => create.mutate(payload)}
onDelete={setToDelete}
/>
<div className="card" style={{ padding: 14 }}>
<h2 className="section-title" style={{ margin: '0 0 4px' }}>پیشنمایش دو هفته</h2>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 10px' }}>
ساعت <strong>خام</strong> نوبتهای ثبتشده هنوز از آن کسر نشدهاند.
</p>
<div style={{ display: 'grid', gap: 6 }}>
{(availability?.days ?? []).map((day) => (
<div
key={day.date}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, fontSize: 13 }}
>
<span style={{ color: 'var(--text-2)' }}>
{DAY_LABELS[day.day_of_week]} · {formatDate(day.date)}
</span>
{day.intervals.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{day.reasons.map((r) => REASON_LABELS[r] ?? r).join('، ') || '—'}
</span>
) : (
<span style={{ fontWeight: 600 }}>{day.total_minutes} دقیقه</span>
)}
</div>
))}
</div>
<div className="wh-two-col">
<div style={{ display: 'grid', gap: 'var(--gap)' }}>
<NationalHolidaysCard canUpdate={canUpdate} />
<ExceptionsCard
exceptions={exceptions}
canUpdate={canUpdate}
saving={create.isPending}
onCreate={(payload) => create.mutate(payload)}
onDelete={setToDelete}
/>
</div>
<PreviewCard availability={availability} />
<ConfirmDialog
open={!!toDelete}
title="حذف استثنا"
message={`آیا از حذف «${toDelete?.type_label}» مطمئن هستید؟`}
confirmLabel="حذف"
danger
loading={remove.isPending}
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
onCancel={() => setToDelete(null)}
@@ -101,6 +83,61 @@ export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
);
}
/**
* پیش‌نمایش دو هفته.
*
* ۱۴ سطرِ یکنواختِ «شیفتی تعریف نشده» فقط نویز است و با یک حالت خالیِ راه‌حل‌دار
* جمع می‌شود. شرط عمداً تنگ است: اگر حتی یک روز به دلیل دیگری (تعطیل رسمی،
* مرخصی) بسته باشد سطرها می‌مانند، چون آن دلیل خودش اطلاعات است — نه نویز.
*/
function PreviewCard({ availability }: { availability?: ResourceAvailability }) {
const days = availability?.days ?? [];
const nothingConfigured = days.length > 0
&& days.every((d) => d.intervals.length === 0
&& d.reasons.length > 0
&& d.reasons.every((r) => r === 'no_shift'));
return (
<div className="card card-pad" style={{ position: 'sticky', top: 'var(--gap)' }}>
<h2 style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>پیشنمایش دو هفته</h2>
<p className="field-hint" style={{ marginTop: 4, marginBottom: 14 }}>
ساعت <strong>خام</strong> نوبتهای ثبتشده هنوز از آن کسر نشدهاند.
</p>
{days.length === 0 ? (
<div className="skeleton" style={{ height: 120, borderRadius: 'var(--r-sm)' }} />
) : nothingConfigured ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0, lineHeight: 1.9 }}>
در دو هفتهٔ آینده هیچ ساعتی باز نیست. اول در «شیفت هفتگی» روزهای کاری را تعریف کنید.
</p>
) : (
<div style={{ display: 'grid', gap: 7 }}>
{days.map((day) => (
<div
key={day.date}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
gap: 8, fontSize: 13,
}}
>
<span style={{ color: 'var(--text-2)' }}>
{DAY_LABELS[day.day_of_week]} · {formatDate(day.date)}
</span>
{day.intervals.length === 0 ? (
<span className="badge gray" style={{ fontSize: 11 }}>
{day.reasons.map((r) => REASON_LABELS[r] ?? r).join('، ') || '—'}
</span>
) : (
<span style={{ fontWeight: 700 }}>{formatNumber(day.total_minutes)} دقیقه</span>
)}
</div>
))}
</div>
)}
</div>
);
}
function ExceptionsCard({
exceptions, canUpdate, saving, onCreate, onDelete,
}: {
@@ -115,6 +152,9 @@ function ExceptionsCard({
const [endDate, setEndDate] = useState('');
const [reason, setReason] = useState('');
// id یکتا لازم است: این کارت در صفحهٔ منبع و صفحهٔ تنظیمات نوبت‌دهی هر دو رندر می‌شود.
const uid = useId();
const toTimestamp = (value: string): number | null => {
if (value === '') return null;
const ms = new Date(`${value}T00:00:00`).getTime();
@@ -127,24 +167,44 @@ function ExceptionsCard({
const endExclusive = end === null ? null : end + 86400;
const invalid = start === null || endExclusive === null || endExclusive <= start;
const typeLabel = useMemo(
() => EXCEPTION_TYPES.find((t) => t.value === type)?.label ?? '',
[type],
);
return (
<div className="card" style={{ padding: 14 }}>
<h2 className="section-title" style={{ margin: '0 0 10px' }}>مرخصی و سرویس</h2>
<div className="card card-pad">
<h2 style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)', marginBottom: 12 }}>
مرخصی و سرویس
</h2>
{exceptions.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: '0 0 10px' }}>استثنایی ثبت نشده است.</p>
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: '0 0 14px' }}>
استثنایی ثبت نشده است.
</p>
) : (
<div style={{ display: 'grid', gap: 8, marginBottom: 12 }}>
<div style={{ display: 'grid', gap: 8, marginBottom: 16 }}>
{exceptions.map((e) => (
<div key={e.uuid} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<div
key={e.uuid}
style={{
display: 'flex', alignItems: 'center', gap: 8, fontSize: 13,
padding: '8px 10px', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
}}
>
<span className="badge amber" style={{ fontSize: 11 }}>{e.type_label}</span>
<span style={{ flex: 1, color: 'var(--text-2)' }}>
<span style={{ flex: 1, color: 'var(--text-2)', minWidth: 0 }}>
{formatDate(e.starts_at)} تا {formatDate(e.ends_at)}
{e.reason ? ` · ${e.reason}` : ''}
</span>
{canUpdate && (
<button type="button" className="btn secondary sm" onClick={() => onDelete(e)} aria-label="حذف استثنا">
<button
type="button"
className="mini-btn danger"
onClick={() => onDelete(e)}
aria-label={`حذف ${e.type_label} از ${formatDate(e.starts_at)}`}
>
<XMarkIcon style={{ width: 16 }} />
</button>
)}
</div>
@@ -152,29 +212,59 @@ function ExceptionsCard({
</div>
)}
{canUpdate && (
<div style={{ display: 'grid', gap: 8 }}>
<SearchableSelect
options={EXCEPTION_TYPES}
value={type}
onChange={(v) => setType(v ? String(v) : 'leave')}
placeholder="نوع استثنا"
height={36}
/>
{canUpdate ? (
<div style={{ display: 'grid', gap: 12 }}>
<div className="field-block">
<label id={`${uid}-type-label`} htmlFor={`${uid}-type`}>نوع استثنا</label>
<SearchableSelect
inputId={`${uid}-type`}
ariaLabelledBy={`${uid}-type-label`}
options={EXCEPTION_TYPES}
value={type}
onChange={(v) => setType(v ? String(v) : 'leave')}
placeholder="نوع استثنا"
height={40}
/>
</div>
{/* تقویم شمسی، نه `input type=date` میلادی: اپراتور تاریخ را شمسی می‌گوید و
ترجمهٔ ذهنی همان‌جایی است که استثنا یک روز جابه‌جا ثبت می‌شود. */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1 }}>
<PersianDateInput value={startDate} onChange={setStartDate} placeholder="از تاریخ" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field-block">
<label>از تاریخ</label>
<PersianDateInput
value={startDate}
onChange={setStartDate}
placeholder="از تاریخ"
ariaLabel={`تاریخ شروع ${typeLabel}`}
/>
</div>
<div style={{ flex: 1 }}>
<PersianDateInput value={endDate} onChange={setEndDate} placeholder="تا تاریخ" />
<div className="field-block">
<label>تا تاریخ</label>
<PersianDateInput
value={endDate}
onChange={setEndDate}
placeholder="تا تاریخ"
ariaLabel={`تاریخ پایان ${typeLabel}`}
/>
</div>
</div>
<input className="field" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="توضیح (اختیاری)" />
<div className="field-block">
<label htmlFor={`${uid}-reason`}>توضیح <span className="opt">(اختیاری)</span></label>
<label className="field">
<input
id={`${uid}-reason`}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="مثلاً سرویس سالانهٔ دستگاه"
/>
</label>
</div>
<button
type="button"
className="btn secondary"
className="btn primary"
disabled={saving || invalid}
onClick={() => {
onCreate({
@@ -191,6 +281,10 @@ function ExceptionsCard({
{saving ? 'در حال ثبت...' : 'ثبت استثنا'}
</button>
</div>
) : (
<p style={{ fontSize: 12.5, color: 'var(--text-3)', margin: 0 }}>
برای ثبت یا حذف استثنا مجوز ویرایش تنظیمات نوبتدهی لازم است.
</p>
)}
</div>
);
@@ -1,6 +1,9 @@
import React, { useEffect, useMemo, useState } from 'react';
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import {
ExclamationTriangleIcon, PlusIcon, Square2StackIcon, TrashIcon,
} from '@heroicons/react/24/outline';
import { useResourceCalendar } from '../../hooks/useResourceCalendar';
import { formatNumber } from '../../lib/utils';
/** ۰ = شنبه — همان قرارداد بک‌اند برای روزهای هفته. */
export const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'];
@@ -8,6 +11,7 @@ export const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌
const MINUTES_IN_DAY = 1440;
type Draft = { start: string; end: string; endOfDay: boolean };
type Days = Record<number, Draft[]>;
function toTime(minute: number): string {
return `${String(Math.floor(minute / 60)).padStart(2, '0')}:${String(minute % 60).padStart(2, '0')}`;
@@ -25,6 +29,10 @@ function toMinutes(time: string): number | null {
*
* با حذف دامنهٔ شعبه، این شیفت تنها مرجع ساعت کاری منبع است؛ فقط تعطیلات رسمی و
* استثناهای خودِ منبع از آن کسر می‌شوند.
*
* چیدمان عمداً تک‌ستونه است: هفته یک توالی است و گرید دوستونه ترتیبش را زیگزاگ
* می‌کرد. ذخیره در `save-bar` چسبان می‌نشیند تا هم پایینِ فیلدها باشد و هم
* وجودش خودش نشانهٔ «ذخیره‌نشده» باشد.
*/
export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
resourceUuid?: string;
@@ -32,12 +40,14 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
}) {
const { calendar, loading, save } = useResourceCalendar(resourceUuid);
const [draft, setDraft] = useState<Record<number, Draft[]>>({});
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<Days>({});
// نسخهٔ سرور به‌صورت رشته نگه داشته می‌شود تا مقایسهٔ dirty یک `===` ساده باشد.
const [baseline, setBaseline] = useState<string | null>(null);
const [error, setError] = useState<{ day: number; message: string } | null>(null);
useEffect(() => {
if (!calendar) return;
const next: Record<number, Draft[]> = {};
const next: Days = {};
DAY_LABELS.forEach((_, day) => {
next[day] = (calendar.days[String(day)] ?? []).map((r) => ({
start: toTime(r.start_minute),
@@ -46,6 +56,8 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
}));
});
setDraft(next);
setBaseline(JSON.stringify(next));
setError(null);
}, [calendar]);
const totalShifts = useMemo(
@@ -53,13 +65,39 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
[draft],
);
// پیش از اولین پاسخ سرور baseline نداریم، پس هیچ‌چیز dirty نیست.
const dirty = baseline !== null && JSON.stringify(draft) !== baseline;
const editRange = (day: number, index: number, patch: Partial<Draft>) =>
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 copyToAllDays = (day: number) =>
setDraft((d) => {
const source = (d[day] ?? []).map((r) => ({ ...r }));
const next: Days = {};
DAY_LABELS.forEach((_, i) => { next[i] = source.map((r) => ({ ...r })); });
return next;
});
const reset = () => {
if (baseline === null) return;
setDraft(JSON.parse(baseline) as Days);
setError(null);
};
const submit = () => {
const days: Record<string, { start_minute: number; end_minute: number }[]> = {};
for (const [dayKey, rows] of Object.entries(draft)) {
const day = Number(dayKey);
const parsed: { start_minute: number; end_minute: number }[] = [];
for (const row of rows) {
@@ -67,11 +105,11 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
const end = row.endOfDay ? MINUTES_IN_DAY : toMinutes(row.end);
if (start === null || end === null) {
setError(`ساعت روز ${DAY_LABELS[Number(dayKey)]} را به شکل ۰۹:۰۰ وارد کنید`);
setError({ day, message: `ساعت روز ${DAY_LABELS[day]} را به شکل ۰۹:۰۰ وارد کنید` });
return;
}
if (end <= start) {
setError(`در روز ${DAY_LABELS[Number(dayKey)]} پایان شیفت باید بعد از شروع آن باشد`);
setError({ day, message: `در روز ${DAY_LABELS[day]} پایان شیفت باید بعد از شروع آن باشد` });
return;
}
parsed.push({ start_minute: start, end_minute: end });
@@ -85,103 +123,144 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
};
return (
<div style={{ display: 'grid', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)' }}>
روزهای کاری و ساعت هر روز. تعطیلات رسمی و مرخصی از همین ساعت کسر میشوند.
{totalShifts > 0 && <> · {totalShifts} شیفت</>}
</p>
{canUpdate && (
<button type="button" className="btn primary sm" disabled={save.isPending} onClick={submit}>
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ شیفت‌ها'}
</button>
)}
<div className="card card-pad">
<div className="card-title-row" style={{ marginBottom: 6 }}>
<h2 style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>شیفت هفتگی</h2>
{totalShifts > 0 && <span className="badge gray">{formatNumber(totalShifts)} شیفت</span>}
</div>
<p className="field-hint" style={{ marginTop: 0, marginBottom: 14 }}>
روزهای کاری و ساعت هر روز. تعطیلات رسمی و مرخصی از همین ساعت کسر میشوند.
</p>
{error && (
<div
className="card"
style={{ padding: '12px 16px', color: 'var(--danger)', background: 'var(--danger-bg)', fontSize: 13 }}
role="alert"
style={{
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12,
padding: '10px 14px', borderRadius: 'var(--r-sm)',
background: 'var(--danger-bg)', color: 'var(--danger)', fontSize: 13, fontWeight: 600,
}}
>
{error}
<ExclamationTriangleIcon style={{ width: 16, flexShrink: 0 }} />
{error.message}
</div>
)}
{loading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
<div style={{ display: 'grid', gap: 10 }}>
{DAY_LABELS.map((_, i) => (
<div key={i} className="skeleton" style={{ height: 56, borderRadius: 'var(--r-sm)' }} />
))}
</div>
) : (
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))' }}>
<div style={{ display: 'grid', gap: 10 }}>
{DAY_LABELS.map((label, day) => {
const rows = draft[day] ?? [];
const cls = ['wh-day', rows.length ? 'has-shift' : '', error?.day === day ? 'err' : '']
.filter(Boolean).join(' ');
return (
<div key={day} className="card" style={{ padding: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginBottom: rows.length ? 10 : 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 700, fontSize: 14 }}>{label}</span>
{rows.length === 0 && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>بدون شیفت</span>}
<div key={day} className={cls}>
<div className="wh-day-name">{label}</div>
{rows.length === 0 ? (
<div className="wh-day-off">بدون شیفت این روز بسته است</div>
) : (
<div className="wh-rows">
{rows.map((row, index) => (
<div key={index} className="wh-shift">
<label className="field wh-time">
<span className="lbl">از</span>
<input
type="time"
value={row.start}
disabled={!canUpdate}
onChange={(e) => editRange(day, index, { start: e.target.value })}
aria-label={`ساعت شروع شیفت ${formatNumber(index + 1)} روز ${label}`}
/>
</label>
<label className="field wh-time">
<span className="lbl">تا</span>
{row.endOfDay ? (
<span style={{ color: 'var(--text-2)', fontSize: 13.5 }}>۲۴:۰۰</span>
) : (
<input
type="time"
value={row.end}
disabled={!canUpdate}
onChange={(e) => editRange(day, index, { end: e.target.value })}
aria-label={`ساعت پایان شیفت ${formatNumber(index + 1)} روز ${label}`}
/>
)}
</label>
<label className="wh-eod">
<input
type="checkbox"
checked={row.endOfDay}
disabled={!canUpdate}
onChange={(e) => editRange(day, index, { endOfDay: e.target.checked })}
/>
تا پایان روز
</label>
{canUpdate && (
<button
type="button"
className="mini-btn danger"
onClick={() => removeRange(day, index)}
aria-label={`حذف شیفت ${formatNumber(index + 1)} روز ${label}`}
>
<TrashIcon style={{ width: 16 }} />
</button>
)}
</div>
))}
</div>
{canUpdate && (
<button
type="button"
className="btn secondary sm"
onClick={() => setDraft((d) => ({ ...d, [day]: [...(d[day] ?? []), { start: '09:00', end: '17:00', endOfDay: false }] }))}
>
)}
{canUpdate && (
<div className="wh-actions">
{rows.length > 0 && (
<button
type="button"
className="mini-btn"
onClick={() => copyToAllDays(day)}
title="کپی به همهٔ روزهای هفته"
aria-label={`کپی شیفت‌های ${label} به همهٔ روزهای هفته`}
>
<Square2StackIcon style={{ width: 16 }} />
</button>
)}
<button type="button" className="btn secondary sm" onClick={() => addRange(day)}>
<PlusIcon style={{ width: 15 }} /> شیفت
</button>
)}
</div>
<div style={{ display: 'grid', gap: 8 }}>
{rows.map((row, index) => (
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<input
type="time"
className="field"
value={row.start}
disabled={!canUpdate}
onChange={(e) => editRange(day, index, { start: e.target.value })}
style={{ width: 116 }}
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>تا</span>
{row.endOfDay ? (
<span className="field" style={{ width: 116, color: 'var(--text-2)' }}>۲۴:۰۰</span>
) : (
<input
type="time"
className="field"
value={row.end}
disabled={!canUpdate}
onChange={(e) => editRange(day, index, { end: e.target.value })}
style={{ width: 116 }}
/>
)}
<label style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: 'var(--text-3)' }}>
<input
type="checkbox"
checked={row.endOfDay}
disabled={!canUpdate}
onChange={(e) => editRange(day, index, { endOfDay: e.target.checked })}
/>
تا پایان روز
</label>
{canUpdate && (
<button
type="button"
className="btn secondary sm"
onClick={() => setDraft((d) => ({ ...d, [day]: (d[day] ?? []).filter((_, i) => i !== index) }))}
aria-label="حذف شیفت"
>
<TrashIcon style={{ width: 15 }} />
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
)}
{canUpdate && dirty && (
<div className="save-bar">
<div className="sb-msg">
<ExclamationTriangleIcon style={{ width: 17, color: 'var(--warning)' }} />
تغییرات شیفت ذخیره نشده است
</div>
<div className="sb-actions">
<button type="button" className="btn ghost" disabled={save.isPending} onClick={reset}>
بازگرداندن
</button>
<button type="button" className="btn primary" disabled={save.isPending} onClick={submit}>
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ شیفت‌ها'}
</button>
</div>
</div>
)}
</div>
);
}