Files
clinicpro/assets/admin/pages/ResourceCalendarPage.tsx
T
hamedandClaude Opus 5 000cf70761 feat(resources): warn before switching a resource off, and pick dates in Jalali
The deactivation warning was blocked on task 07: there was no way to count "the
appointments on this resource" until occupancy rows linked the two. They do
now, so GET /resource/{uuid} returns upcoming_appointments. It stays off the
list endpoint, where it would be one count query per row.

It is a warning, not a block, and the wording says so: switching a resource off
does not cancel anything, it only removes the resource from future searches.
The panel shows it the moment the "active" box is unticked.

The calendar's exception range still used <input type="date">, which is
Gregorian. Operators say dates in Jalali, and the mental conversion is exactly
where an exception gets recorded a day off. PersianDateInput takes the same
YYYY-MM-DD string, so this is a drop-in swap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:45:25 +03:30

384 lines
16 KiB
TypeScript

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<string, string> = {
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<Record<number, Draft[]>>({});
const [error, setError] = useState<string | null>(null);
const [toDelete, setToDelete] = useState<ResourceException | null>(null);
const previewFrom = todayMidnight();
const previewTo = previewFrom + 13 * 86400;
const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo);
useEffect(() => {
if (!calendar) return;
const next: Record<number, Draft[]> = {};
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<Draft>) =>
setDraft((d) => ({ ...d, [day]: (d[day] ?? []).map((r, i) => (i === index ? { ...r, ...patch } : r)) }));
const submit = () => {
const days: Record<string, { start_minute: number; end_minute: number }[]> = {};
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 (
<div className="fade-in">
<PageHeader
title={`تقویم ${resource?.name ?? 'منبع'}`}
description="شیفت هفتگی منبع. ساعت واقعی از تقاطع این شیفت‌ها با ساعت کاری شعبه به‌دست می‌آید و تعطیلات و مرخصی از آن کسر می‌شود."
backTo="/admin/resources"
breadcrumbs={[{ label: 'منابع', to: '/admin/resources' }, { label: 'تقویم' }]}
action={
canUpdate ? (
<button type="button" className="btn primary" disabled={save.isPending} onClick={submit}>
{save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ شیفت‌ها'}
</button>
) : undefined
}
/>
{error && (
<div
className="card"
style={{ padding: '12px 16px', marginBottom: 16, color: 'var(--danger)', background: 'var(--danger-bg)', fontSize: 13 }}
>
{error}
</div>
)}
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))' }}>
<section style={{ display: 'grid', gap: 12 }}>
<h2 className="section-title" style={{ margin: 0 }}>
شیفت هفتگی {totalShifts > 0 && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>({totalShifts} شیفت)</span>}
</h2>
{loading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : (
DAY_LABELS.map((label, day) => {
const rows = draft[day] ?? [];
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>
{canUpdate && (
<button
type="button"
className="btn secondary sm"
onClick={() => setDraft((d) => ({ ...d, [day]: [...(d[day] ?? []), { start: '09:00', end: '17:00', endOfDay: false }] }))}
>
<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>
);
})
)}
</section>
<section style={{ display: 'grid', gap: 12, alignContent: 'start' }}>
<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 * 1000)}
</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>
</section>
</div>
<ConfirmDialog
open={!!toDelete}
title="حذف استثنا"
message={`آیا از حذف «${toDelete?.type_label}» مطمئن هستید؟`}
confirmLabel="حذف"
loading={remove.isPending}
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
onCancel={() => setToDelete(null)}
/>
</div>
);
}
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<string>('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 (
<div className="card" style={{ padding: 14 }}>
<h2 className="section-title" style={{ margin: '0 0 10px' }}>مرخصی و سرویس</h2>
{exceptions.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: '0 0 10px' }}>استثنایی ثبت نشده است.</p>
) : (
<div style={{ display: 'grid', gap: 8, marginBottom: 12 }}>
{exceptions.map((e) => (
<div key={e.uuid} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<span className="badge amber" style={{ fontSize: 11 }}>{e.type_label}</span>
<span style={{ flex: 1, color: 'var(--text-2)' }}>
{formatDate(e.starts_at * 1000)} تا {formatDate(e.ends_at * 1000)}
{e.reason ? ` · ${e.reason}` : ''}
</span>
{canUpdate && (
<button type="button" className="btn secondary sm" onClick={() => onDelete(e)} aria-label="حذف استثنا">
</button>
)}
</div>
))}
</div>
)}
{canUpdate && (
<div style={{ display: 'grid', gap: 8 }}>
<SearchableSelect
options={EXCEPTION_TYPES}
value={type}
onChange={(v) => setType(v ? String(v) : 'leave')}
placeholder="نوع استثنا"
height={36}
/>
{/* تقویم شمسی، نه `input type=date` میلادی: اپراتور تاریخ را شمسی می‌گوید و
ترجمهٔ ذهنی همان‌جایی است که استثنا یک روز جابه‌جا ثبت می‌شود. */}
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1 }}>
<PersianDateInput value={startDate} onChange={setStartDate} placeholder="از تاریخ" />
</div>
<div style={{ flex: 1 }}>
<PersianDateInput value={endDate} onChange={setEndDate} placeholder="تا تاریخ" />
</div>
</div>
<input className="field" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="توضیح (اختیاری)" />
<button
type="button"
className="btn secondary"
disabled={saving || invalid}
onClick={() => {
onCreate({
type,
starts_at: start!,
ends_at: endExclusive!,
reason: reason.trim() === '' ? null : reason.trim(),
});
setStartDate('');
setEndDate('');
setReason('');
}}
>
{saving ? 'در حال ثبت...' : 'ثبت استثنا'}
</button>
</div>
)}
</div>
);
}