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:
@@ -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 ConfirmDialog from '../ui/ConfirmDialog';
|
||||||
import SearchableSelect from '../ui/SearchableSelect';
|
import SearchableSelect from '../ui/SearchableSelect';
|
||||||
import PersianDateInput from '../ui/PersianDateInput';
|
import PersianDateInput from '../ui/PersianDateInput';
|
||||||
import { useResourceAvailability, useResourceExceptions } from '../../hooks/useResourceCalendar';
|
import { useResourceAvailability, useResourceExceptions } from '../../hooks/useResourceCalendar';
|
||||||
import { formatDate } from '../../lib/utils';
|
import { formatDate, formatNumber } from '../../lib/utils';
|
||||||
import { DAY_LABELS } from './ResourceWorkingHoursPanel';
|
import { DAY_LABELS } from './ResourceWorkingHoursPanel';
|
||||||
import NationalHolidaysCard from '../holidays/NationalHolidaysCard';
|
import NationalHolidaysCard from '../holidays/NationalHolidaysCard';
|
||||||
import type { ResourceException } from '../../types';
|
import type { ResourceAvailability, ResourceException } from '../../types';
|
||||||
|
|
||||||
/** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده میشد. */
|
/** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده میشد. */
|
||||||
const REASON_LABELS: Record<string, string> = {
|
const REASON_LABELS: Record<string, string> = {
|
||||||
@@ -37,6 +39,9 @@ function todayMidnight(): number {
|
|||||||
*
|
*
|
||||||
* پیشنمایش عمداً «ساعت آزاد» نامیده نشده بلکه «خام» است: نوبتهای ثبتشده در آن
|
* پیشنمایش عمداً «ساعت آزاد» نامیده نشده بلکه «خام» است: نوبتهای ثبتشده در آن
|
||||||
* کسر نشدهاند و اشتباه گرفتنش با «وقت قابل رزرو» به بیشرزروی میانجامد.
|
* کسر نشدهاند و اشتباه گرفتنش با «وقت قابل رزرو» به بیشرزروی میانجامد.
|
||||||
|
*
|
||||||
|
* دو ستون، نه `auto-fit`: تعطیلات و استثنا هر دو ورودیاند و کنار هم میمانند،
|
||||||
|
* پیشنمایش خروجی است و ستون خودش را میگیرد.
|
||||||
*/
|
*/
|
||||||
export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
|
export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
|
||||||
resourceUuid?: string;
|
resourceUuid?: string;
|
||||||
@@ -50,49 +55,26 @@ export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
|
|||||||
const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo);
|
const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', alignItems: 'start' }}>
|
<div className="wh-two-col">
|
||||||
<NationalHolidaysCard canUpdate={canUpdate} />
|
<div style={{ display: 'grid', gap: 'var(--gap)' }}>
|
||||||
|
<NationalHolidaysCard canUpdate={canUpdate} />
|
||||||
<ExceptionsCard
|
<ExceptionsCard
|
||||||
exceptions={exceptions}
|
exceptions={exceptions}
|
||||||
canUpdate={canUpdate}
|
canUpdate={canUpdate}
|
||||||
saving={create.isPending}
|
saving={create.isPending}
|
||||||
onCreate={(payload) => create.mutate(payload)}
|
onCreate={(payload) => create.mutate(payload)}
|
||||||
onDelete={setToDelete}
|
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>
|
</div>
|
||||||
|
|
||||||
|
<PreviewCard availability={availability} />
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!toDelete}
|
open={!!toDelete}
|
||||||
title="حذف استثنا"
|
title="حذف استثنا"
|
||||||
message={`آیا از حذف «${toDelete?.type_label}» مطمئن هستید؟`}
|
message={`آیا از حذف «${toDelete?.type_label}» مطمئن هستید؟`}
|
||||||
confirmLabel="حذف"
|
confirmLabel="حذف"
|
||||||
|
danger
|
||||||
loading={remove.isPending}
|
loading={remove.isPending}
|
||||||
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
||||||
onCancel={() => 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({
|
function ExceptionsCard({
|
||||||
exceptions, canUpdate, saving, onCreate, onDelete,
|
exceptions, canUpdate, saving, onCreate, onDelete,
|
||||||
}: {
|
}: {
|
||||||
@@ -115,6 +152,9 @@ function ExceptionsCard({
|
|||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
|
|
||||||
|
// id یکتا لازم است: این کارت در صفحهٔ منبع و صفحهٔ تنظیمات نوبتدهی هر دو رندر میشود.
|
||||||
|
const uid = useId();
|
||||||
|
|
||||||
const toTimestamp = (value: string): number | null => {
|
const toTimestamp = (value: string): number | null => {
|
||||||
if (value === '') return null;
|
if (value === '') return null;
|
||||||
const ms = new Date(`${value}T00:00:00`).getTime();
|
const ms = new Date(`${value}T00:00:00`).getTime();
|
||||||
@@ -127,24 +167,44 @@ function ExceptionsCard({
|
|||||||
const endExclusive = end === null ? null : end + 86400;
|
const endExclusive = end === null ? null : end + 86400;
|
||||||
const invalid = start === null || endExclusive === null || endExclusive <= start;
|
const invalid = start === null || endExclusive === null || endExclusive <= start;
|
||||||
|
|
||||||
|
const typeLabel = useMemo(
|
||||||
|
() => EXCEPTION_TYPES.find((t) => t.value === type)?.label ?? '',
|
||||||
|
[type],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card" style={{ padding: 14 }}>
|
<div className="card card-pad">
|
||||||
<h2 className="section-title" style={{ margin: '0 0 10px' }}>مرخصی و سرویس</h2>
|
<h2 style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)', marginBottom: 12 }}>
|
||||||
|
مرخصی و سرویس
|
||||||
|
</h2>
|
||||||
|
|
||||||
{exceptions.length === 0 ? (
|
{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) => (
|
{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 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)}
|
{formatDate(e.starts_at)} تا {formatDate(e.ends_at)}
|
||||||
{e.reason ? ` · ${e.reason}` : ''}
|
{e.reason ? ` · ${e.reason}` : ''}
|
||||||
</span>
|
</span>
|
||||||
{canUpdate && (
|
{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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -152,29 +212,59 @@ function ExceptionsCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canUpdate && (
|
{canUpdate ? (
|
||||||
<div style={{ display: 'grid', gap: 8 }}>
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
<SearchableSelect
|
<div className="field-block">
|
||||||
options={EXCEPTION_TYPES}
|
<label id={`${uid}-type-label`} htmlFor={`${uid}-type`}>نوع استثنا</label>
|
||||||
value={type}
|
<SearchableSelect
|
||||||
onChange={(v) => setType(v ? String(v) : 'leave')}
|
inputId={`${uid}-type`}
|
||||||
placeholder="نوع استثنا"
|
ariaLabelledBy={`${uid}-type-label`}
|
||||||
height={36}
|
options={EXCEPTION_TYPES}
|
||||||
/>
|
value={type}
|
||||||
|
onChange={(v) => setType(v ? String(v) : 'leave')}
|
||||||
|
placeholder="نوع استثنا"
|
||||||
|
height={40}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* تقویم شمسی، نه `input type=date` میلادی: اپراتور تاریخ را شمسی میگوید و
|
{/* تقویم شمسی، نه `input type=date` میلادی: اپراتور تاریخ را شمسی میگوید و
|
||||||
ترجمهٔ ذهنی همانجایی است که استثنا یک روز جابهجا ثبت میشود. */}
|
ترجمهٔ ذهنی همانجایی است که استثنا یک روز جابهجا ثبت میشود. */}
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div className="field-block">
|
||||||
<PersianDateInput value={startDate} onChange={setStartDate} placeholder="از تاریخ" />
|
<label>از تاریخ</label>
|
||||||
|
<PersianDateInput
|
||||||
|
value={startDate}
|
||||||
|
onChange={setStartDate}
|
||||||
|
placeholder="از تاریخ"
|
||||||
|
ariaLabel={`تاریخ شروع ${typeLabel}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
<div className="field-block">
|
||||||
<PersianDateInput value={endDate} onChange={setEndDate} placeholder="تا تاریخ" />
|
<label>تا تاریخ</label>
|
||||||
|
<PersianDateInput
|
||||||
|
value={endDate}
|
||||||
|
onChange={setEndDate}
|
||||||
|
placeholder="تا تاریخ"
|
||||||
|
ariaLabel={`تاریخ پایان ${typeLabel}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn secondary"
|
className="btn primary"
|
||||||
disabled={saving || invalid}
|
disabled={saving || invalid}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onCreate({
|
onCreate({
|
||||||
@@ -191,6 +281,10 @@ function ExceptionsCard({
|
|||||||
{saving ? 'در حال ثبت...' : 'ثبت استثنا'}
|
{saving ? 'در حال ثبت...' : 'ثبت استثنا'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: 12.5, color: 'var(--text-3)', margin: 0 }}>
|
||||||
|
برای ثبت یا حذف استثنا مجوز ویرایش تنظیمات نوبتدهی لازم است.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
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 { useResourceCalendar } from '../../hooks/useResourceCalendar';
|
||||||
|
import { formatNumber } from '../../lib/utils';
|
||||||
|
|
||||||
/** ۰ = شنبه — همان قرارداد بکاند برای روزهای هفته. */
|
/** ۰ = شنبه — همان قرارداد بکاند برای روزهای هفته. */
|
||||||
export const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'];
|
export const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سهشنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه'];
|
||||||
@@ -8,6 +11,7 @@ export const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه
|
|||||||
const MINUTES_IN_DAY = 1440;
|
const MINUTES_IN_DAY = 1440;
|
||||||
|
|
||||||
type Draft = { start: string; end: string; endOfDay: boolean };
|
type Draft = { start: string; end: string; endOfDay: boolean };
|
||||||
|
type Days = Record<number, Draft[]>;
|
||||||
|
|
||||||
function toTime(minute: number): string {
|
function toTime(minute: number): string {
|
||||||
return `${String(Math.floor(minute / 60)).padStart(2, '0')}:${String(minute % 60).padStart(2, '0')}`;
|
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 }: {
|
export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
|
||||||
resourceUuid?: string;
|
resourceUuid?: string;
|
||||||
@@ -32,12 +40,14 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
|
|||||||
}) {
|
}) {
|
||||||
const { calendar, loading, save } = useResourceCalendar(resourceUuid);
|
const { calendar, loading, save } = useResourceCalendar(resourceUuid);
|
||||||
|
|
||||||
const [draft, setDraft] = useState<Record<number, Draft[]>>({});
|
const [draft, setDraft] = useState<Days>({});
|
||||||
const [error, setError] = useState<string | null>(null);
|
// نسخهٔ سرور بهصورت رشته نگه داشته میشود تا مقایسهٔ dirty یک `===` ساده باشد.
|
||||||
|
const [baseline, setBaseline] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<{ day: number; message: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!calendar) return;
|
if (!calendar) return;
|
||||||
const next: Record<number, Draft[]> = {};
|
const next: Days = {};
|
||||||
DAY_LABELS.forEach((_, day) => {
|
DAY_LABELS.forEach((_, day) => {
|
||||||
next[day] = (calendar.days[String(day)] ?? []).map((r) => ({
|
next[day] = (calendar.days[String(day)] ?? []).map((r) => ({
|
||||||
start: toTime(r.start_minute),
|
start: toTime(r.start_minute),
|
||||||
@@ -46,6 +56,8 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
setDraft(next);
|
setDraft(next);
|
||||||
|
setBaseline(JSON.stringify(next));
|
||||||
|
setError(null);
|
||||||
}, [calendar]);
|
}, [calendar]);
|
||||||
|
|
||||||
const totalShifts = useMemo(
|
const totalShifts = useMemo(
|
||||||
@@ -53,13 +65,39 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
|
|||||||
[draft],
|
[draft],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// پیش از اولین پاسخ سرور baseline نداریم، پس هیچچیز dirty نیست.
|
||||||
|
const dirty = baseline !== null && JSON.stringify(draft) !== baseline;
|
||||||
|
|
||||||
const editRange = (day: number, index: number, patch: Partial<Draft>) =>
|
const editRange = (day: number, index: number, patch: Partial<Draft>) =>
|
||||||
setDraft((d) => ({ ...d, [day]: (d[day] ?? []).map((r, i) => (i === index ? { ...r, ...patch } : r)) }));
|
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 submit = () => {
|
||||||
const days: Record<string, { start_minute: number; end_minute: number }[]> = {};
|
const days: Record<string, { start_minute: number; end_minute: number }[]> = {};
|
||||||
|
|
||||||
for (const [dayKey, rows] of Object.entries(draft)) {
|
for (const [dayKey, rows] of Object.entries(draft)) {
|
||||||
|
const day = Number(dayKey);
|
||||||
const parsed: { start_minute: number; end_minute: number }[] = [];
|
const parsed: { start_minute: number; end_minute: number }[] = [];
|
||||||
|
|
||||||
for (const row of rows) {
|
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);
|
const end = row.endOfDay ? MINUTES_IN_DAY : toMinutes(row.end);
|
||||||
|
|
||||||
if (start === null || end === null) {
|
if (start === null || end === null) {
|
||||||
setError(`ساعت روز ${DAY_LABELS[Number(dayKey)]} را به شکل ۰۹:۰۰ وارد کنید`);
|
setError({ day, message: `ساعت روز ${DAY_LABELS[day]} را به شکل ۰۹:۰۰ وارد کنید` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (end <= start) {
|
if (end <= start) {
|
||||||
setError(`در روز ${DAY_LABELS[Number(dayKey)]} پایان شیفت باید بعد از شروع آن باشد`);
|
setError({ day, message: `در روز ${DAY_LABELS[day]} پایان شیفت باید بعد از شروع آن باشد` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
parsed.push({ start_minute: start, end_minute: end });
|
parsed.push({ start_minute: start, end_minute: end });
|
||||||
@@ -85,103 +123,144 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gap: 12 }}>
|
<div className="card card-pad">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
<div className="card-title-row" style={{ marginBottom: 6 }}>
|
||||||
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--text-3)' }}>
|
<h2 style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>شیفت هفتگی</h2>
|
||||||
روزهای کاری و ساعت هر روز. تعطیلات رسمی و مرخصی از همین ساعت کسر میشوند.
|
{totalShifts > 0 && <span className="badge gray">{formatNumber(totalShifts)} شیفت</span>}
|
||||||
{totalShifts > 0 && <> · {totalShifts} شیفت</>}
|
|
||||||
</p>
|
|
||||||
{canUpdate && (
|
|
||||||
<button type="button" className="btn primary sm" disabled={save.isPending} onClick={submit}>
|
|
||||||
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ شیفتها'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="field-hint" style={{ marginTop: 0, marginBottom: 14 }}>
|
||||||
|
روزهای کاری و ساعت هر روز. تعطیلات رسمی و مرخصی از همین ساعت کسر میشوند.
|
||||||
|
</p>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
className="card"
|
role="alert"
|
||||||
style={{ padding: '12px 16px', color: 'var(--danger)', background: 'var(--danger-bg)', fontSize: 13 }}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{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) => {
|
{DAY_LABELS.map((label, day) => {
|
||||||
const rows = draft[day] ?? [];
|
const rows = draft[day] ?? [];
|
||||||
|
const cls = ['wh-day', rows.length ? 'has-shift' : '', error?.day === day ? 'err' : '']
|
||||||
|
.filter(Boolean).join(' ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={day} className="card" style={{ padding: 14 }}>
|
<div key={day} className={cls}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginBottom: rows.length ? 10 : 0 }}>
|
<div className="wh-day-name">{label}</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<span style={{ fontWeight: 700, fontSize: 14 }}>{label}</span>
|
{rows.length === 0 ? (
|
||||||
{rows.length === 0 && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>بدون شیفت</span>}
|
<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>
|
</div>
|
||||||
{canUpdate && (
|
)}
|
||||||
<button
|
|
||||||
type="button"
|
{canUpdate && (
|
||||||
className="btn secondary sm"
|
<div className="wh-actions">
|
||||||
onClick={() => setDraft((d) => ({ ...d, [day]: [...(d[day] ?? []), { start: '09:00', end: '17:00', endOfDay: false }] }))}
|
{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 }} /> شیفت
|
<PlusIcon style={{ width: 15 }} /> شیفت
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { ScheduleSection } from '../components/schedule/ScheduleSection';
|
|||||||
import ResourceWorkingHoursPanel from '../components/resources/ResourceWorkingHoursPanel';
|
import ResourceWorkingHoursPanel from '../components/resources/ResourceWorkingHoursPanel';
|
||||||
import ResourceExceptionsPanel from '../components/resources/ResourceExceptionsPanel';
|
import ResourceExceptionsPanel from '../components/resources/ResourceExceptionsPanel';
|
||||||
import FreeVisitPrice from '../components/FreeVisitPrice';
|
import FreeVisitPrice from '../components/FreeVisitPrice';
|
||||||
import BackButton from '../components/ui/BackButton';
|
import PageHeader from '../components/ui/PageHeader';
|
||||||
import type { ClinicDoctorItem } from '../components/ClinicDoctorsManager';
|
import type { ClinicDoctorItem } from '../components/ClinicDoctorsManager';
|
||||||
|
|
||||||
const SCOPES = [
|
const SCOPES = [
|
||||||
@@ -72,68 +72,92 @@ function ClinicAppointmentSettingsContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// هویت موردِ انتخابشده فقط یک بار گفته میشود — در توضیح هدر. پیشتر همین جمله
|
||||||
|
// در تب فعال، در زیرعنوان و در یک کارتِ جداگانه سه بار تکرار میشد.
|
||||||
|
const description = scope === 'doctors'
|
||||||
|
? (selectedDoctor ? `تنظیمات نوبتدهی ${selectedDoctor.name}` : 'تنظیمات نوبتدهی پزشکان کلینیک')
|
||||||
|
: (selectedResource
|
||||||
|
? `تنظیمات نوبتدهی ${selectedResource.name} · ${selectedResource.type_name}`
|
||||||
|
: 'تنظیمات نوبتدهی منابع کلینیک');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fade-in" style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
|
<div className="fade-in">
|
||||||
<div>
|
<PageHeader
|
||||||
<BackButton fallback="/admin/settings-menu" />
|
title="مدیریت نوبتدهی"
|
||||||
</div>
|
description={description}
|
||||||
<div className="card-title-row">
|
backTo="/admin/settings-menu"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
|
||||||
|
{/* دو انتخابگر پشت سر هم میمانند — «نما» بعد «مورد». در اسلات action هدر،
|
||||||
|
سوییچر به لبهٔ مقابلِ صفحه میافتاد و از تبی که کنترل میکند جدا میشد. */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="section-title">مدیریت نوبت دهی</h1>
|
<span className="field-label" id="appt-scope-tabs">نما</span>
|
||||||
<div className="muted">
|
<div className="seg" role="group" aria-labelledby="appt-scope-tabs">
|
||||||
{scope === 'doctors'
|
{SCOPES.map((s) => (
|
||||||
? (selectedDoctor ? `تنظیمات نوبتدهی ${selectedDoctor.name}` : 'تنظیمات نوبتدهی پزشکان کلینیک')
|
<button
|
||||||
: (selectedResource ? `تنظیمات نوبتدهی ${selectedResource.name}` : 'تنظیمات نوبتدهی منابع کلینیک')}
|
key={s.id}
|
||||||
|
className={scope === s.id ? 'active' : ''}
|
||||||
|
aria-pressed={scope === s.id}
|
||||||
|
onClick={() => setUrlState({ scope: s.id })}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="seg" style={{ alignSelf: 'flex-start' }}>
|
{scope === 'doctors' ? (
|
||||||
{SCOPES.map((s) => (
|
<DoctorsScope
|
||||||
<button
|
loading={doctorsQ.isLoading}
|
||||||
key={s.id}
|
doctors={doctorList}
|
||||||
className={scope === s.id ? 'active' : ''}
|
selected={selectedDoctor}
|
||||||
onClick={() => setUrlState({ scope: s.id })}
|
clinicUuid={clinicUuid}
|
||||||
>
|
readOnly={apptReadOnly}
|
||||||
{s.label}
|
onSelect={(uuid) => setUrlState({ doctor: uuid })}
|
||||||
</button>
|
/>
|
||||||
))}
|
) : (
|
||||||
|
<ResourcesScope
|
||||||
|
loading={resourcesLoading}
|
||||||
|
resources={resources}
|
||||||
|
selected={selectedResource}
|
||||||
|
canUpdate={!apptReadOnly}
|
||||||
|
onSelect={(uuid) => setUrlState({ resource: uuid })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{scope === 'doctors' ? (
|
|
||||||
<DoctorsScope
|
|
||||||
loading={doctorsQ.isLoading}
|
|
||||||
doctors={doctorList}
|
|
||||||
selected={selectedDoctor}
|
|
||||||
clinicUuid={clinicUuid}
|
|
||||||
readOnly={apptReadOnly}
|
|
||||||
onSelect={(uuid) => setUrlState({ doctor: uuid })}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ResourcesScope
|
|
||||||
loading={resourcesLoading}
|
|
||||||
resources={resources}
|
|
||||||
selected={selectedResource}
|
|
||||||
canUpdate={!apptReadOnly}
|
|
||||||
onSelect={(uuid) => setUrlState({ resource: uuid })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabBar<T extends { uuid: string; name: string }>({ items, selected, onSelect }: {
|
/**
|
||||||
|
* نوار انتخابِ پزشک/منبع.
|
||||||
|
*
|
||||||
|
* بدون کارتِ دورش: تنها محتوایش یک گروه pill بود و کارتِ تمامعرض، ~۴۵۰px فضای
|
||||||
|
* خالی میساخت. لیبل قابلمشاهده هم لازم است، وگرنه نوار برای screen reader یک
|
||||||
|
* ردیف دکمهٔ بیعنوان است.
|
||||||
|
*/
|
||||||
|
function TabBar<T extends { uuid: string; name: string }>({ items, selected, onSelect, label, labelId }: {
|
||||||
items: T[];
|
items: T[];
|
||||||
selected: T | null;
|
selected: T | null;
|
||||||
onSelect: (uuid: string) => void;
|
onSelect: (uuid: string) => void;
|
||||||
|
label: string;
|
||||||
|
labelId: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="card card-pad" style={{ paddingBottom: 12 }}>
|
<div>
|
||||||
<div className="seg" style={{ overflowX: 'auto', flexWrap: 'nowrap' }}>
|
<span className="field-label" id={labelId}>{label}</span>
|
||||||
|
<div
|
||||||
|
className="seg"
|
||||||
|
role="group"
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
style={{ maxWidth: '100%', overflowX: 'auto', flexWrap: 'nowrap' }}
|
||||||
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.uuid}
|
key={item.uuid}
|
||||||
className={selected?.uuid === item.uuid ? 'active' : ''}
|
className={selected?.uuid === item.uuid ? 'active' : ''}
|
||||||
|
aria-pressed={selected?.uuid === item.uuid}
|
||||||
style={{ whiteSpace: 'nowrap' }}
|
style={{ whiteSpace: 'nowrap' }}
|
||||||
onClick={() => onSelect(item.uuid)}
|
onClick={() => onSelect(item.uuid)}
|
||||||
>
|
>
|
||||||
@@ -145,14 +169,23 @@ function TabBar<T extends { uuid: string; name: string }>({ items, selected, onS
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectedHeader({ icon, label }: { icon: React.ReactNode; label: string }) {
|
/** حالت خالی. کلاس `empty` در `styles.css` تعریف نشده بود، پس چیدمان اینجاست. */
|
||||||
|
function EmptyState({ icon, message, action }: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
message: string;
|
||||||
|
action: React.ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="card card-pad"
|
className="card card-pad"
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 'var(--gap)' }}
|
style={{
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
|
gap: 12, padding: '36px 22px', color: 'var(--text-3)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<span style={{ fontWeight: 600 }}>{label}</span>
|
<p className="muted" style={{ fontSize: 13.5 }}>{message}</p>
|
||||||
|
{action}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -169,27 +202,21 @@ function DoctorsScope({ loading, doctors, selected, clinicUuid, readOnly, onSele
|
|||||||
|
|
||||||
if (doctors.length === 0) {
|
if (doctors.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="card card-pad">
|
<EmptyState
|
||||||
<div className="empty" style={{ padding: '20px 0' }}>
|
icon={<UserGroupIcon style={{ width: 30, height: 30 }} />}
|
||||||
<UserGroupIcon style={{ width: 30, height: 30 }} />
|
message="هیچ پزشکی به این کلینیک متصل نیست"
|
||||||
<p className="muted">هیچ پزشکی به این کلینیک متصل نیست</p>
|
action={<Link className="btn primary sm" to="/admin/settings/clinic-doctors">مدیریت پزشکان کلینیک</Link>}
|
||||||
<Link className="btn primary sm" to="/admin/settings/clinic-doctors">مدیریت پزشکان کلینیک</Link>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TabBar items={doctors} selected={selected} onSelect={onSelect} />
|
<TabBar items={doctors} selected={selected} onSelect={onSelect} label="پزشک" labelId="appt-doctor-tabs" />
|
||||||
|
|
||||||
{/* key اجباری است: بدون آن state ویرایشِ برنامه بین پزشکان نشت میکند */}
|
{/* key اجباری است: بدون آن state ویرایشِ برنامه بین پزشکان نشت میکند */}
|
||||||
{selected && (
|
{selected && (
|
||||||
<div key={selected.uuid}>
|
<div key={selected.uuid} style={{ display: 'grid', gap: 'var(--gap)' }}>
|
||||||
<SelectedHeader
|
|
||||||
icon={<UserGroupIcon style={{ width: 18, height: 18, color: 'var(--primary)' }} />}
|
|
||||||
label={selected.name}
|
|
||||||
/>
|
|
||||||
<FreeVisitPrice doctorUuid={selected.uuid} readOnly={readOnly} />
|
<FreeVisitPrice doctorUuid={selected.uuid} readOnly={readOnly} />
|
||||||
<ScheduleSection doctorUuid={selected.uuid} clinicUuid={clinicUuid} readOnly={readOnly} />
|
<ScheduleSection doctorUuid={selected.uuid} clinicUuid={clinicUuid} readOnly={readOnly} />
|
||||||
</div>
|
</div>
|
||||||
@@ -209,27 +236,21 @@ function ResourcesScope({ loading, resources, selected, canUpdate, onSelect }: {
|
|||||||
|
|
||||||
if (resources.length === 0) {
|
if (resources.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="card card-pad">
|
<EmptyState
|
||||||
<div className="empty" style={{ padding: '20px 0' }}>
|
icon={<CubeIcon style={{ width: 30, height: 30 }} />}
|
||||||
<CubeIcon style={{ width: 30, height: 30 }} />
|
message="هنوز منبعی تعریف نشده است"
|
||||||
<p className="muted">هنوز منبعی تعریف نشده است</p>
|
action={<Link className="btn primary sm" to="/admin/resources">تنظیمات ← منابع</Link>}
|
||||||
<Link className="btn primary sm" to="/admin/resources">تنظیمات ← منابع</Link>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TabBar items={resources} selected={selected} onSelect={onSelect} />
|
<TabBar items={resources} selected={selected} onSelect={onSelect} label="منبع" labelId="appt-resource-tabs" />
|
||||||
|
|
||||||
{/* همان دلیل تب پزشک: بدون key، شیفتِ نیمهویرایششده به منبع بعدی میچسبد. */}
|
{/* همان دلیل تب پزشک: بدون key، شیفتِ نیمهویرایششده به منبع بعدی میچسبد. */}
|
||||||
{selected && (
|
{selected && (
|
||||||
<div key={selected.uuid} style={{ display: 'grid', gap: 'var(--gap)' }}>
|
<div key={selected.uuid} style={{ display: 'grid', gap: 'var(--gap)' }}>
|
||||||
<SelectedHeader
|
|
||||||
icon={<CubeIcon style={{ width: 18, height: 18, color: 'var(--primary)' }} />}
|
|
||||||
label={`${selected.name} · ${selected.type_name}`}
|
|
||||||
/>
|
|
||||||
<ResourceWorkingHoursPanel resourceUuid={selected.uuid} canUpdate={canUpdate} />
|
<ResourceWorkingHoursPanel resourceUuid={selected.uuid} canUpdate={canUpdate} />
|
||||||
<ResourceExceptionsPanel resourceUuid={selected.uuid} canUpdate={canUpdate} />
|
<ResourceExceptionsPanel resourceUuid={selected.uuid} canUpdate={canUpdate} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe('ResourceDetailPage', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument());
|
||||||
expect(screen.getByText('جمعه')).toBeInTheDocument();
|
expect(screen.getByText('جمعه')).toBeInTheDocument();
|
||||||
expect(screen.getAllByText('بدون شیفت')).toHaveLength(7);
|
expect(screen.getAllByText('بدون شیفت — این روز بسته است')).toHaveLength(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('شیفت ذخیرهشده را بهصورت ساعت نشان میدهد', async () => {
|
it('شیفت ذخیرهشده را بهصورت ساعت نشان میدهد', async () => {
|
||||||
@@ -125,6 +125,68 @@ describe('ResourceDetailPage', () => {
|
|||||||
expect(screen.queryByText('no_shift')).not.toBeInTheDocument();
|
expect(screen.queryByText('no_shift')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* وقتی هیچ روزی شیفت ندارد، ۱۴ سطرِ یکسان نویز است و با یک حالت خالی جمع میشود.
|
||||||
|
* شرط باید تنگ بماند: تست بالا ثابت میکند دلیلِ غیر از `no_shift` سطرها را نگه میدارد.
|
||||||
|
*/
|
||||||
|
it('پیشنمایشِ کاملاً تعریفنشده را در یک حالت خالی جمع میکند', async () => {
|
||||||
|
mockApi(emptyDays(), [
|
||||||
|
{ date: 1785529800, day_of_week: 0, intervals: [], total_minutes: 0, reasons: ['no_shift'] },
|
||||||
|
{ date: 1785616200, day_of_week: 1, intervals: [], total_minutes: 0, reasons: ['no_shift'] },
|
||||||
|
]);
|
||||||
|
renderPage('/admin/resources/r1?tab=exceptions');
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/در دو هفتهٔ آینده هیچ ساعتی باز نیست/)).toBeInTheDocument());
|
||||||
|
expect(screen.queryByText('شیفتی تعریف نشده')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** نوار ذخیره فقط وقتی ظاهر میشود که چیزی تغییر کرده باشد — خودش نشانهٔ dirty است. */
|
||||||
|
it('نوار ذخیره تا وقتی تغییری نباشد نمیآید', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockApi();
|
||||||
|
renderPage('/admin/resources/r1?tab=hours');
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument());
|
||||||
|
expect(screen.queryByRole('button', { name: 'ذخیرهٔ شیفتها' })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getAllByRole('button', { name: 'شیفت' })[0]);
|
||||||
|
|
||||||
|
expect(await screen.findByText('تغییرات شیفت ذخیره نشده است')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'ذخیرهٔ شیفتها' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** «بازگرداندن» باید به نسخهٔ سرور برگردد، نه صرفاً آخرین ویرایش را لغو کند. */
|
||||||
|
it('بازگرداندن، draft را به نسخهٔ سرور برمیگرداند', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockApi();
|
||||||
|
renderPage('/admin/resources/r1?tab=hours');
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument());
|
||||||
|
await user.click(screen.getAllByRole('button', { name: 'شیفت' })[0]);
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'بازگرداندن' }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByText('تغییرات شیفت ذخیره نشده است')).not.toBeInTheDocument());
|
||||||
|
expect(screen.getAllByText('بدون شیفت — این روز بسته است')).toHaveLength(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** پرتکرارترین حالت یک ساعتِ یکسان برای کل هفته است؛ بدون کپی، ۱۵ تعامل لازم بود. */
|
||||||
|
it('شیفت یک روز را به همهٔ روزهای هفته کپی میکند', async () => {
|
||||||
|
const days = emptyDays();
|
||||||
|
days['0'] = [{ sequence: 0, start_minute: 540, end_minute: 1020, start_time: '09:00', end_time: '17:00', active: true }];
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockApi(days);
|
||||||
|
renderPage('/admin/resources/r1?tab=hours');
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument());
|
||||||
|
await user.click(screen.getByRole('button', { name: 'کپی شیفتهای شنبه به همهٔ روزهای هفته' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getAllByDisplayValue('09:00')).toHaveLength(7));
|
||||||
|
expect(screen.getAllByDisplayValue('17:00')).toHaveLength(7);
|
||||||
|
expect(screen.queryByText('بدون شیفت — این روز بسته است')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
/** پیشنمایش نباید «وقت قابل رزرو» خوانده شود — نوبتها هنوز کسر نشدهاند. */
|
/** پیشنمایش نباید «وقت قابل رزرو» خوانده شود — نوبتها هنوز کسر نشدهاند. */
|
||||||
it('پیشنمایش را خام معرفی میکند', async () => {
|
it('پیشنمایش را خام معرفی میکند', async () => {
|
||||||
mockApi();
|
mockApi();
|
||||||
|
|||||||
@@ -974,6 +974,43 @@ html, body { max-width: 100%; overflow-x: hidden; }
|
|||||||
.time-input-plain::-webkit-calendar-picker-indicator { display: none; }
|
.time-input-plain::-webkit-calendar-picker-indicator { display: none; }
|
||||||
.time-input-plain { appearance: none; -webkit-appearance: none; }
|
.time-input-plain { appearance: none; -webkit-appearance: none; }
|
||||||
|
|
||||||
|
/* ── شیفت هفتگی منبع ──────────────────────────────────────────────────────
|
||||||
|
چیدمان «یک ردیف = یک روز». کلاس است نه inline style، چون شکستِ ریسپانسیو و
|
||||||
|
حالت خطا با style درونخطی ممکن نیست، و رنگها که از توکن بیایند دارکمود و
|
||||||
|
تراکم فشرده خودبهخود درست میمانند. */
|
||||||
|
.wh-day {
|
||||||
|
display: grid; grid-template-columns: 88px minmax(0, 1fr) auto;
|
||||||
|
gap: 12px; align-items: start;
|
||||||
|
padding: 11px 14px; border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm); background: var(--surface-2); transition: .15s;
|
||||||
|
}
|
||||||
|
/* روزِ دارای شیفت باید از روزِ تعطیل جدا دیده شود، وگرنه هفته یک بلوک یکدست است. */
|
||||||
|
.wh-day.has-shift { background: var(--surface); border-color: var(--border-2); }
|
||||||
|
.wh-day.err { border-color: var(--danger); background: var(--danger-bg); }
|
||||||
|
.wh-day-name { font-weight: 700; font-size: 13.5px; color: var(--text); padding-top: 10px; }
|
||||||
|
.wh-day-off { font-size: 12.5px; color: var(--text-3); padding-top: 11px; }
|
||||||
|
.wh-rows { display: grid; gap: 8px; min-width: 0; }
|
||||||
|
.wh-actions { display: flex; align-items: center; gap: 4px; padding-top: 3px; }
|
||||||
|
|
||||||
|
.wh-shift { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.wh-time { width: 138px; gap: 6px; }
|
||||||
|
.wh-time .lbl { font-size: 11.5px; color: var(--text-3); flex-shrink: 0; }
|
||||||
|
.wh-time input { font-size: 13.5px; }
|
||||||
|
/* چکباکس خام ~۱۳px است و زیر حداقلِ ارتفاعِ لمسی میافتد. */
|
||||||
|
.wh-eod { display: flex; align-items: center; gap: 6px; min-height: 32px; font-size: 12.5px; color: var(--text-2); cursor: pointer; }
|
||||||
|
.wh-eod input { width: 17px; height: 17px; accent-color: var(--primary); cursor: pointer; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.wh-day { grid-template-columns: minmax(0, 1fr); gap: 8px; }
|
||||||
|
.wh-day-name, .wh-day-off, .wh-actions { padding-top: 0; }
|
||||||
|
.wh-time { width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* تعطیلات/استثنا در یک ستون و پیشنمایش در ستون دیگر — سه کارتِ نامرتبط در یک
|
||||||
|
auto-fit، ستون کناری را نیمهخالی رها میکرد. */
|
||||||
|
.wh-two-col { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--gap); align-items: start; }
|
||||||
|
@media (max-width: 1100px) { .wh-two-col { grid-template-columns: minmax(0, 1fr); } }
|
||||||
|
|
||||||
/* ── CKEditor 5 ────────────────────────────────────────────────────────────
|
/* ── CKEditor 5 ────────────────────────────────────────────────────────────
|
||||||
ادیتور همهٔ رنگهایش را از متغیرهای --ck-color-* خودش میگیرد و پیشفرض آنها
|
ادیتور همهٔ رنگهایش را از متغیرهای --ck-color-* خودش میگیرد و پیشفرض آنها
|
||||||
روشن است؛ بدون این نگاشت، ادیتور در [data-theme="dark"] سفید میماند. */
|
روشن است؛ بدون این نگاشت، ادیتور در [data-theme="dark"] سفید میماند. */
|
||||||
|
|||||||
Reference in New Issue
Block a user