Files
clinicpro/assets/admin/components/resources/ResourceBlocksPanel.tsx
T

202 lines
8.1 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 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;
}
const HOUR = 3600;
/**
* غیرفعال‌سازی موقتِ یک منبع — بستن یک بازهٔ زمانی مشخص.
*
* عمداً از «استثنای تقویم» جداست و همین‌جا هم گفته می‌شود: آن الگوی کاری منبع را عوض
* می‌کند و ماندگار است، این فقط یک بازهٔ مشخص را می‌بندد. اپراتوری که این تفاوت را
* نداند، تعطیلی یک بعدازظهر را برای همیشه در تقویم ثبت می‌کند.
*
* پیش‌تر مودالی بود که از ردیف جدولِ منابع باز می‌شد؛ حالا یکی از تب‌های خودِ منبع
* است، کنار بقیهٔ تنظیماتش.
*/
export default function ResourceBlocksPanel({ resourceUuid, canUpdate }: {
resourceUuid?: string;
canUpdate: boolean;
}) {
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 (
<div className="card card-pad">
<h2 style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>غیرفعال‌سازی موقت</h2>
<p className="field-hint" style={{ marginTop: 4, marginBottom: 16 }}>
یک بازهٔ مشخص را می‌بندد و تا وقتی حذفش نکنید می‌ماند. برای تغییر
<strong> الگوی کاری </strong>
منبع (مثلاً «پنجشنبه‌ها تعطیل») از تب «تعطیلات و استثنا» استفاده کنید، نه از اینجا.
</p>
{canUpdate && (
<div style={{ display: 'grid', gap: 12, marginBottom: 18 }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
<div className="field-block" style={{ minWidth: 180 }}>
<label>روز</label>
<PersianDateInput value={day} onChange={setDay} ariaLabel="روزِ بازهٔ بسته‌شده" />
</div>
<div className="field-block" style={{ width: 110 }}>
<label htmlFor="block-from">از ساعت</label>
<label className="field">
<input
id="block-from"
type="number"
min={0}
max={23}
value={fromHour}
onChange={(e) => setFromHour(Number(e.target.value))}
/>
</label>
</div>
<div className="field-block" style={{ width: 110 }}>
<label htmlFor="block-to">تا ساعت</label>
<label className="field">
<input
id="block-to"
type="number"
min={1}
max={24}
value={toHour}
onChange={(e) => setToHour(Number(e.target.value))}
/>
</label>
</div>
</div>
<div className="field-block">
<label htmlFor="block-reason">دلیل <span className="opt">(اختیاری)</span></label>
<label className="field">
<input
id="block-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="مثلاً سرویس دوره‌ای دستگاه"
/>
</label>
</div>
{rangeInvalid && (
<p className="field-err" role="alert" style={{ marginTop: 0 }}>
ساعت پایان باید بعد از ساعت شروع باشد.
</p>
)}
<div>
<button
type="button"
className="btn primary"
disabled={rangeInvalid || dayStart === 0 || create.isPending}
onClick={() =>
create.mutate({
starts_at: dayStart + fromHour * HOUR,
ends_at: dayStart + toHour * HOUR,
reason,
})
}
>
{create.isPending ? 'در حال ثبت...' : 'بستن این بازه'}
</button>
</div>
</div>
)}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14 }}>
<h3 style={{ fontSize: 14, fontWeight: 700, margin: '0 0 10px' }}>بازه‌های بسته‌شده</h3>
{isLoading ? (
<div className="skeleton" style={{ height: 60, borderRadius: 'var(--r-sm)' }} />
) : blocks.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
این منبع هیچ بازهٔ بسته‌شده‌ای ندارد.
</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{blocks.map((block) => (
<div
key={block.uuid}
style={{
display: 'flex', alignItems: 'center', gap: 10, fontSize: 13,
padding: '8px 10px', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
}}
>
<span style={{ fontWeight: 600 }}>{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, flex: 1, minWidth: 0 }}>
{block.segment_name ?? '—'}
</span>
{canUpdate && (
<button
type="button"
className="mini-btn danger"
disabled={remove.isPending}
onClick={() => remove.mutate(block.uuid)}
aria-label={`باز کردن بازهٔ ${formatDate(block.starts_at)}`}
>
<TrashIcon style={{ width: 16 }} />
</button>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}