- Updated DoctorDetailPage, MySecretariesPage, RecordNumberSettingsPage, RepresentationsPage, ResourcePoolsPage, ResourceTypesPage, SecretariesPage, SecretaryDetailPage, SettingsPage, SkillsPage, SmsWalletPage, and TagsSettingsPage to use the new Switch component instead of native checkboxes. - Enhanced accessibility by ensuring the Switch component uses appropriate roles and labels. - Added tests for the new Switch component to ensure functionality and accessibility compliance. - Updated styles to accommodate the new Switch component design.
270 lines
11 KiB
TypeScript
270 lines
11 KiB
TypeScript
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<number, Draft[]>;
|
|
|
|
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<Days>({});
|
|
// نسخهٔ سرور بهصورت رشته نگه داشته میشود تا مقایسهٔ dirty یک `===` ساده باشد.
|
|
const [baseline, setBaseline] = useState<string | null>(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<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 reset = () => {
|
|
if (baseline === null) return;
|
|
setDraft(JSON.parse(baseline) as Days);
|
|
};
|
|
|
|
const submit = () => {
|
|
// دکمه هم غیرفعال است، ولی گاردِ دوم میماند: ذخیره از راه دیگری هم صدا زده میشود.
|
|
if (firstIssue) return;
|
|
|
|
const days: Record<string, { start_minute: number; end_minute: number }[]> = {};
|
|
|
|
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 (
|
|
<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>
|
|
|
|
{loading ? (
|
|
<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: 10 }}>
|
|
{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 (
|
|
<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>
|
|
|
|
<span className="wh-eod">
|
|
<Switch
|
|
inline
|
|
checked={row.endOfDay}
|
|
disabled={!canUpdate}
|
|
onChange={(v) => editRange(day, index, { endOfDay: v })}
|
|
label="تا پایان روز"
|
|
ariaLabel={`تا پایان روز برای شیفت ${formatNumber(index + 1)} روز ${label}`}
|
|
/>
|
|
</span>
|
|
|
|
{canUpdate && (
|
|
<button
|
|
type="button"
|
|
className="mini-btn danger"
|
|
onClick={() => removeRange(day, index)}
|
|
aria-label={`حذف شیفت ${formatNumber(index + 1)} روز ${label}`}
|
|
>
|
|
<TrashIcon style={{ width: 16 }} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{issue && (
|
|
<p className="field-err" role="alert" style={{ marginTop: 2 }}>{issue}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{canUpdate && (
|
|
<div className="wh-actions">
|
|
<button type="button" className="btn secondary sm" onClick={() => addRange(day)}>
|
|
<PlusIcon style={{ width: 15 }} /> شیفت
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{canUpdate && dirty && (
|
|
<div className="save-bar">
|
|
<div className="sb-msg" style={firstIssue ? { color: 'var(--danger)' } : undefined}>
|
|
<ExclamationTriangleIcon
|
|
style={{ width: 17, flexShrink: 0, color: firstIssue ? 'var(--danger)' : 'var(--warning)' }}
|
|
/>
|
|
{firstIssue ?? 'تغییرات شیفت ذخیره نشده است'}
|
|
</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 || !!firstIssue}
|
|
onClick={submit}
|
|
>
|
|
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ شیفتها'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|