Screenshotting the pages under dark mode and compact density (rather than trusting that design tokens were enough) turned up two mistakes repeated across every page this feature set added: - `.card` carries only the surface, border and radius — padding comes from the separate `.card-pad`. Fifteen cards were rendering with their content flush against the edges. - `.field` *is* the input box, a 40px-tall flex row. Wrapping a label plus a control in it produced a joined addon rather than a label above its field. `.field-block` is the label-above layout, and thirty-seven wrappers now use it. Both were invisible to type-checking and to the tests, which is exactly why the visual pass was worth running. Numbers in the new UI now go through formatNumber so they render as Persian digits, and the utilization page's header no longer repeats the sentence that appears under its filters verbatim. The QA driver gained a `--ui` flag: theme and density live in localStorage['clinicpro-ui'], so without seeding them dark mode and compact density cannot be screenshotted at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
198 lines
7.4 KiB
TypeScript
198 lines
7.4 KiB
TypeScript
import React, { useState } from 'react';
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { toast } from 'sonner';
|
||
import { TrashIcon } from '@heroicons/react/24/outline';
|
||
import Modal from './ui/Modal';
|
||
import PersianDateInput from './ui/PersianDateInput';
|
||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||
import { formatDate, isoToUnix, unixToIso } from '../lib/utils';
|
||
|
||
interface Block {
|
||
uuid: string;
|
||
starts_at: number;
|
||
ends_at: number;
|
||
segment_name: string | null;
|
||
}
|
||
|
||
interface Props {
|
||
resourceUuid: string | null;
|
||
resourceName: string;
|
||
onClose: () => void;
|
||
}
|
||
|
||
const HOUR = 3600;
|
||
|
||
/**
|
||
* مسدودسازی موردی یک منبع.
|
||
*
|
||
* عمداً از «استثنای تقویم» جداست و همینجا هم گفته میشود: آن الگوی کاری منبع را عوض
|
||
* میکند و ماندگار است، این فقط یک بازهٔ مشخص را میبندد. اپراتوری که این تفاوت را
|
||
* نداند، تعطیلی یک بعدازظهر را برای همیشه در تقویم ثبت میکند.
|
||
*/
|
||
export default function ResourceBlocksModal({ resourceUuid, resourceName, onClose }: Props) {
|
||
const qc = useQueryClient();
|
||
const key = ['resource-blocks', resourceUuid];
|
||
|
||
const [day, setDay] = useState(() => unixToIso(Math.floor(Date.now() / 1000) + 86400));
|
||
const [fromHour, setFromHour] = useState(9);
|
||
const [toHour, setToHour] = useState(13);
|
||
const [reason, setReason] = useState('');
|
||
|
||
const { data, isLoading } = useQuery({
|
||
queryKey: key,
|
||
queryFn: () => api.get<ApiResponse<Block[]>>(`/api/v1/resource/${resourceUuid}/blocks`),
|
||
enabled: !!resourceUuid,
|
||
});
|
||
|
||
const blocks = data?.data ?? [];
|
||
|
||
const create = useMutation({
|
||
mutationFn: (body: { starts_at: number; ends_at: number; reason: string }) =>
|
||
api.post<ApiResponse<Block>>(`/api/v1/resource/${resourceUuid}/blocks`, body),
|
||
onSuccess: () => {
|
||
toast.success('بازه مسدود شد');
|
||
qc.invalidateQueries({ queryKey: key });
|
||
setReason('');
|
||
},
|
||
// ۴۰۹ یعنی آن بازه نوبت دارد؛ پیام سرور دقیقاً میگوید اول چه باید کرد.
|
||
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'مسدودسازی ناموفق بود'),
|
||
});
|
||
|
||
const remove = useMutation({
|
||
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/resource-block/${uuid}`),
|
||
onSuccess: () => {
|
||
toast.success('مسدودسازی برداشته شد');
|
||
qc.invalidateQueries({ queryKey: key });
|
||
},
|
||
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'حذف ناموفق بود'),
|
||
});
|
||
|
||
const dayStart = isoToUnix(day) ?? 0;
|
||
const rangeInvalid = toHour <= fromHour;
|
||
|
||
return (
|
||
<Modal
|
||
open={resourceUuid !== null}
|
||
title={`مسدودسازی موردی — ${resourceName}`}
|
||
onClose={onClose}
|
||
footer={
|
||
<button type="button" className="btn secondary" onClick={onClose}>
|
||
بستن
|
||
</button>
|
||
}
|
||
>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: 0, lineHeight: 1.7 }}>
|
||
این یک بازهٔ مشخص را میبندد و تا وقتی حذفش نکنید میماند. برای تغییر
|
||
<strong> الگوی کاری </strong>
|
||
منبع (مثلاً «پنجشنبهها تعطیل») از استثنای تقویم استفاده کنید، نه از اینجا.
|
||
</p>
|
||
|
||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||
<div className="field-block" style={{ minWidth: 170, margin: 0 }}>
|
||
<label>روز</label>
|
||
<PersianDateInput value={day} onChange={setDay} />
|
||
</div>
|
||
|
||
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
از ساعت
|
||
<input
|
||
className="input"
|
||
style={{ width: 70 }}
|
||
type="number"
|
||
min={0}
|
||
max={23}
|
||
value={fromHour}
|
||
onChange={(e) => setFromHour(Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
|
||
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
تا ساعت
|
||
<input
|
||
className="input"
|
||
style={{ width: 70 }}
|
||
type="number"
|
||
min={1}
|
||
max={24}
|
||
value={toHour}
|
||
onChange={(e) => setToHour(Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="field-block" style={{ margin: 0 }}>
|
||
<label htmlFor="block-reason">دلیل</label>
|
||
<input
|
||
id="block-reason"
|
||
className="input"
|
||
value={reason}
|
||
onChange={(e) => setReason(e.target.value)}
|
||
placeholder="مثلاً: سرویس دورهای دستگاه"
|
||
/>
|
||
</div>
|
||
|
||
{rangeInvalid && (
|
||
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
|
||
ساعت پایان باید بعد از ساعت شروع باشد.
|
||
</span>
|
||
)}
|
||
|
||
<div>
|
||
<button
|
||
type="button"
|
||
className="btn primary sm"
|
||
disabled={rangeInvalid || dayStart === 0 || create.isPending}
|
||
onClick={() =>
|
||
create.mutate({
|
||
starts_at: dayStart + fromHour * HOUR,
|
||
ends_at: dayStart + toHour * HOUR,
|
||
reason,
|
||
})
|
||
}
|
||
>
|
||
مسدود کن
|
||
</button>
|
||
</div>
|
||
|
||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>مسدودسازیهای فعلی</h3>
|
||
|
||
{isLoading ? (
|
||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری…</span>
|
||
) : blocks.length === 0 ? (
|
||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||
این منبع مسدودسازی موردی ندارد.
|
||
</span>
|
||
) : (
|
||
blocks.map((block) => (
|
||
<div
|
||
key={block.uuid}
|
||
style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, marginBottom: 6 }}
|
||
>
|
||
<span>{formatDate(block.starts_at)}</span>
|
||
<span dir="ltr" style={{ color: 'var(--text-2)' }}>
|
||
{new Date(block.starts_at * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })}
|
||
{' – '}
|
||
{new Date(block.ends_at * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' })}
|
||
</span>
|
||
<span style={{ color: 'var(--text-3)', fontSize: 12 }}>{block.segment_name ?? '—'}</span>
|
||
<button
|
||
type="button"
|
||
className="btn secondary sm"
|
||
style={{ marginRight: 'auto' }}
|
||
disabled={remove.isPending}
|
||
onClick={() => remove.mutate(block.uuid)}
|
||
aria-label="برداشتن مسدودسازی"
|
||
>
|
||
<TrashIcon style={{ width: 15 }} />
|
||
</button>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|