- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks. - Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content. - Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
369 lines
15 KiB
TypeScript
369 lines
15 KiB
TypeScript
import React, { useCallback, useMemo, useState } from 'react';
|
||
import { useNavigate, useSearchParams } from 'react-router';
|
||
import PageHeader from '../components/ui/PageHeader';
|
||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||
import HoldCountdown from '../components/HoldCountdown';
|
||
import { formatDate, formatNumber } from '../lib/utils';
|
||
import { useAddresses } from '../hooks/useAddresses';
|
||
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
||
import {
|
||
REASON_LABELS,
|
||
useAvailabilitySearch,
|
||
useHold,
|
||
type AvailableSlot,
|
||
type HoldResult,
|
||
} from '../hooks/useResourceBooking';
|
||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
|
||
const DAY = 86400;
|
||
|
||
function timeOf(ts: number): string {
|
||
return new Date(ts * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
|
||
/**
|
||
* رزرو نوبت در حالت منبعمحور.
|
||
*
|
||
* سه مرحلهٔ جدا و عمداً جدا: جستجو → گرفتن موقت → ثبت نهایی. بین دومی و سومی یک مهلت
|
||
* هست و صفحه آن را با شمارش معکوس نشان میدهد؛ رزروی که در سکوت منقضی شود، اپراتور را
|
||
* با یک ۴۰۹ بیتوضیح تنها میگذارد.
|
||
*/
|
||
export default function ResourceBookingPage() {
|
||
const navigate = useNavigate();
|
||
const [params] = useSearchParams();
|
||
const { addresses } = useAddresses();
|
||
/**
|
||
* محل نوبتدهی از خودِ محیط میآید، نه از یک انتخابگر.
|
||
*
|
||
* منابع دامنهٔ شعبه ندارند: دستگاه و اتاق مالِ همین کلینیکاند و آن select همیشه یک
|
||
* گزینه داشت — یک کلیک اجباری که هیچ تصمیمی نبود.
|
||
*/
|
||
const branchUuid = addresses[0]?.uuid ?? '';
|
||
const { items: services } = useAllServiceItems();
|
||
const { create, release, confirm, rebook } = useHold();
|
||
|
||
/**
|
||
* حالت جابهجایی — همان سه مرحله، با یک تفاوت در گام آخر.
|
||
*
|
||
* صفحهٔ جدا نساختیم چون جستجو و رزرو موقت دقیقاً هماناند؛ چیزی که فرق میکند فقط
|
||
* این است که در پایان بهجای «ثبت نوبت تازه»، نوبت موجود جابهجا میشود و زمان قدیم
|
||
* در همان درخواست آزاد میشود.
|
||
*/
|
||
const rebookUuid = params.get('rebook');
|
||
|
||
const [serviceUuid, setServiceUuid] = useState('');
|
||
const [days, setDays] = useState('7');
|
||
const [searching, setSearching] = useState(false);
|
||
|
||
const [doctorUuid, setDoctorUuid] = useState('');
|
||
const [hold, setHold] = useState<HoldResult | null>(null);
|
||
const [expired, setExpired] = useState(false);
|
||
|
||
/** جایگزینی منبع per نقش، فقط برای همان زمانِ انتخابشده. */
|
||
const [picked, setPicked] = useState<Record<string, string[]> | null>(null);
|
||
const [pickedSlot, setPickedSlot] = useState<AvailableSlot | null>(null);
|
||
|
||
/**
|
||
* پزشکِ نوبت — ثبت نهایی بدونش ممکن نیست.
|
||
*
|
||
* منشی از اندپوینت احرازشده میگیرد تا فقط پزشکان تخصیصیافتهاش بیایند؛ همان
|
||
* قاعدهای که صفحهٔ نوبتها از قبل دارد.
|
||
*/
|
||
const doctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
|
||
queryKey: ['booking-doctors'],
|
||
queryFn: () => api.get('/api/v1/my/clinic-doctors'),
|
||
staleTime: 60_000,
|
||
});
|
||
|
||
const doctors = doctorsQuery.data?.data?.data ?? [];
|
||
|
||
const range = useMemo(() => {
|
||
const from = Math.floor(Date.now() / 1000);
|
||
return { from, to: from + Number(days) * DAY };
|
||
}, [days]);
|
||
|
||
const { result, loading, error, refetch } = useAvailabilitySearch(
|
||
{ serviceUuid, branchUuid, from: range.from, to: range.to },
|
||
searching,
|
||
);
|
||
|
||
const onExpired = useCallback(() => setExpired(true), []);
|
||
|
||
const chooseSlot = (slot: AvailableSlot) => {
|
||
setPickedSlot(slot);
|
||
setPicked(
|
||
Object.fromEntries(
|
||
Object.entries(slot.assignment).map(([role, resources]) => [role, resources.map((r) => r.uuid)]),
|
||
),
|
||
);
|
||
};
|
||
|
||
/**
|
||
* گزینههای جایگزین یک نقش: منابعی که در **همین زمان** پیشنهاد شدهاند.
|
||
*
|
||
* فهرست کاملِ منابع محیط اینجا غلط است — منبعی که موتور برای این زمان نداده، آزاد
|
||
* نبوده، و نشان دادنش یعنی اپراتور چیزی انتخاب کند که ۴۰۹ میگیرد.
|
||
*/
|
||
const optionsFor = (role: string): { value: string; label: string }[] =>
|
||
(pickedSlot?.assignment[role] ?? []).map((r) => ({ value: r.uuid, label: r.name }));
|
||
|
||
const takeHold = async () => {
|
||
if (!pickedSlot || !picked) return;
|
||
|
||
try {
|
||
const created = await create.mutateAsync({
|
||
service_uuid: serviceUuid,
|
||
branch_uuid: branchUuid,
|
||
start: pickedSlot.start,
|
||
assignment: picked,
|
||
});
|
||
|
||
setHold(created.data);
|
||
setExpired(false);
|
||
} catch (e) {
|
||
// ۴۰۹ یعنی همین لحظه کس دیگری گرفت. گفتنش کافی نیست: کاربر باید بلافاصله
|
||
// جایگزین ببیند، وگرنه باید دستی دوباره جستجو بزند و بختش را از نو امتحان کند.
|
||
if (e instanceof ApiError && e.status === 409) {
|
||
setPickedSlot(null);
|
||
setPicked(null);
|
||
await refetch();
|
||
}
|
||
}
|
||
};
|
||
|
||
const reasonText = result?.reason ? REASON_LABELS[result.reason] ?? result.reason : null;
|
||
|
||
return (
|
||
<div className="fade-in">
|
||
<PageHeader
|
||
title={rebookUuid ? 'جابهجایی نوبت' : 'رزرو نوبت منبعمحور'}
|
||
description={
|
||
rebookUuid
|
||
? 'زمان تازه را انتخاب و نگه دارید؛ زمان قبلی در همان لحظهٔ جابهجایی آزاد میشود.'
|
||
: 'وقت آزاد از تقاطع تقویم منابع میآید؛ هر وقت با منابع پیشنهادی خودش نمایش داده میشود.'
|
||
}
|
||
backTo={rebookUuid ? `/admin/appointments/${rebookUuid}` : '/admin/appointments'}
|
||
/>
|
||
|
||
<div className="card card-pad" style={{ marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||
<div className="field-block" style={{ minWidth: 220, margin: 0 }}>
|
||
<label>خدمت</label>
|
||
<SearchableSelect
|
||
value={serviceUuid}
|
||
onChange={(v) => {
|
||
setServiceUuid(String(v ?? ''));
|
||
setPickedSlot(null);
|
||
}}
|
||
options={services.map((s) => ({ value: s.uuid, label: s.name }))}
|
||
placeholder="انتخاب خدمت"
|
||
/>
|
||
</div>
|
||
|
||
<div className="field-block" style={{ minWidth: 160, margin: 0 }}>
|
||
<label>بازه</label>
|
||
<SearchableSelect
|
||
value={days}
|
||
onChange={(v) => setDays(String(v ?? '7'))}
|
||
options={[
|
||
{ value: '7', label: 'یک هفته' },
|
||
{ value: '30', label: 'یک ماه' },
|
||
{ value: '90', label: 'سه ماه' },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
className="btn primary sm"
|
||
disabled={serviceUuid === '' || branchUuid === '' || loading}
|
||
onClick={() => setSearching(true)}
|
||
>
|
||
جستجوی وقت
|
||
</button>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="card card-pad" style={{ marginBottom: 16, fontSize: 13, color: 'var(--danger)' }}>
|
||
{error instanceof ApiError ? error.message : 'جستجوی وقت ناموفق بود'}
|
||
</div>
|
||
)}
|
||
|
||
{result && (
|
||
<div className="card card-pad" style={{ marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 14 }}>
|
||
<span>
|
||
مدت نوبت: <strong>{formatNumber(result.plan.total_minutes)}</strong> دقیقه
|
||
</span>
|
||
<span style={{ color: 'var(--text-2)' }}>
|
||
{formatNumber(result.plan.segments.length)} بخش · {formatNumber(result.slots.length)} وقت پیدا شد
|
||
</span>
|
||
</div>
|
||
|
||
{/* فهرست خالی خطا نیست؛ دلیلش را میگوییم تا کاربر حدس نزند. */}
|
||
{result.slots.length === 0 && reasonText && (
|
||
<span style={{ fontSize: 13, color: 'var(--warning)' }}>{reasonText}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{result && result.slots.length > 0 && (
|
||
<div style={{ overflowX: 'auto' }} className="card card-pad">
|
||
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse', minWidth: 520 }}>
|
||
<thead>
|
||
<tr style={{ color: 'var(--text-3)', textAlign: 'right' }}>
|
||
<th style={{ padding: '8px 6px', fontWeight: 500 }}>تاریخ</th>
|
||
<th style={{ padding: '8px 6px', fontWeight: 500 }}>ساعت</th>
|
||
<th style={{ padding: '8px 6px', fontWeight: 500 }}>منابع پیشنهادی</th>
|
||
<th style={{ padding: '8px 6px', fontWeight: 500 }} />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{result.slots.slice(0, 100).map((slot) => (
|
||
<tr
|
||
key={slot.start}
|
||
style={{
|
||
borderTop: '1px solid var(--border)',
|
||
background: pickedSlot?.start === slot.start ? 'var(--primary-soft)' : undefined,
|
||
}}
|
||
>
|
||
<td style={{ padding: '8px 6px' }}>{formatDate(slot.start)}</td>
|
||
<td style={{ padding: '8px 6px' }} dir="ltr">
|
||
{timeOf(slot.start)} – {timeOf(slot.end)}
|
||
</td>
|
||
<td style={{ padding: '8px 6px', color: 'var(--text-2)' }}>
|
||
{Object.values(slot.assignment)
|
||
.flat()
|
||
.map((r) => r.name)
|
||
.join('، ')}
|
||
</td>
|
||
<td style={{ padding: '8px 6px' }}>
|
||
<button
|
||
type="button"
|
||
className="btn secondary sm"
|
||
disabled={hold !== null}
|
||
onClick={() => chooseSlot(slot)}
|
||
>
|
||
انتخاب
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{pickedSlot && (
|
||
<div className="card card-pad" style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||
<h3 style={{ fontSize: 15, margin: 0 }}>
|
||
{formatDate(pickedSlot.start)} · <span dir="ltr">{timeOf(pickedSlot.start)}</span>
|
||
</h3>
|
||
{hold && !expired && <HoldCountdown expiresAt={hold.expires_at} onExpired={onExpired} />}
|
||
{expired && (
|
||
<span className="badge red">
|
||
<span className="bdot" />
|
||
مهلت تمام شد — دوباره جستجو کنید
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
{Object.entries(pickedSlot.assignment).map(([role, resources]) => (
|
||
<div key={role} style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||
<span style={{ fontSize: 13, minWidth: 90, color: 'var(--text-2)' }}>{role}</span>
|
||
<div style={{ minWidth: 220 }}>
|
||
<SearchableSelect
|
||
value={picked?.[role]?.[0] ?? resources[0]?.uuid ?? ''}
|
||
onChange={(v) =>
|
||
setPicked((prev) => ({ ...(prev ?? {}), [role]: [String(v ?? '')] }))
|
||
}
|
||
options={optionsFor(role)}
|
||
isDisabled={hold !== null}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
||
فهرست هر نقش فقط منابعی است که در همین زمان آزادند؛ تخصیص منابع به بیمار
|
||
نمایش داده نمیشود.
|
||
</span>
|
||
|
||
{/* در جابهجایی پزشک عوض نمیشود؛ پرسیدنش یعنی دعوت به تغییری که خواسته نشده. */}
|
||
{!rebookUuid && (
|
||
<div className="field-block" style={{ maxWidth: 280, margin: 0 }}>
|
||
<label>پزشک نوبت</label>
|
||
<SearchableSelect
|
||
value={doctorUuid}
|
||
onChange={(v) => setDoctorUuid(String(v ?? ''))}
|
||
options={doctors.map((d) => ({ value: d.uuid, label: d.name }))}
|
||
placeholder="انتخاب پزشک"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||
{hold === null ? (
|
||
<button
|
||
type="button"
|
||
className="btn primary"
|
||
disabled={create.isPending}
|
||
onClick={takeHold}
|
||
>
|
||
نگهداشتن این زمان
|
||
</button>
|
||
) : (
|
||
<>
|
||
{rebookUuid ? (
|
||
<button
|
||
type="button"
|
||
className="btn primary"
|
||
disabled={expired || rebook.isPending}
|
||
onClick={async () => {
|
||
await rebook.mutateAsync({
|
||
appointmentUuid: rebookUuid,
|
||
holdUuid: hold.hold_uuid,
|
||
});
|
||
navigate(`/admin/appointments/${rebookUuid}`);
|
||
}}
|
||
>
|
||
جابهجایی به این زمان
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="btn primary"
|
||
disabled={expired || doctorUuid === '' || confirm.isPending}
|
||
onClick={async () => {
|
||
await confirm.mutateAsync({ hold_uuid: hold.hold_uuid, doctor_uuid: doctorUuid });
|
||
navigate('/admin/appointments');
|
||
}}
|
||
>
|
||
ثبت نهایی نوبت
|
||
</button>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
className="btn secondary"
|
||
disabled={release.isPending}
|
||
onClick={async () => {
|
||
await release.mutateAsync(hold.hold_uuid);
|
||
setHold(null);
|
||
setPickedSlot(null);
|
||
}}
|
||
>
|
||
آزادکردن
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|