202 lines
8.1 KiB
TypeScript
202 lines
8.1 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 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>
|
||
);
|
||
}
|