Files
clinicpro/assets/admin/components/ResourceBlocksModal.tsx
T
hamedandClaude Opus 5 635bf3d2a8 fix(admin): correct two design-system mismatches found by looking at the pages
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>
2026-07-31 21:42:11 +03:30

198 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}