- 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.
416 lines
20 KiB
TypeScript
416 lines
20 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useNavigate, useParams, Link } from 'react-router';
|
|
import { ChevronRightIcon } from '@heroicons/react/24/outline';
|
|
import { toast } from 'sonner';
|
|
import { api } from '../lib/api';
|
|
import type { ApiResponse } from '../lib/api';
|
|
import PersianDateInput from '../components/ui/PersianDateInput';
|
|
import PriceInput from '../components/ui/PriceInput';
|
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
|
import { WalletChargeLink } from '../components/AppointmentActions';
|
|
import { formatRial, rialToToman, tomanToRial } from '../lib/utils';
|
|
import { DEFAULT_SERVICE_CATEGORY } from '../lib/insuranceShares';
|
|
import BackButton from '../components/ui/BackButton';
|
|
import { useAppointmentInsurance } from '../hooks/useAppointmentInsurance';
|
|
import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices';
|
|
import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker';
|
|
import type { PickedService, ServicePick } from '../components/appointments/ServiceSlotPicker';
|
|
import Switch from '../components/ui/Switch';
|
|
|
|
interface Option { uuid: string; name?: string; full_name?: string }
|
|
|
|
interface AppointmentDetail {
|
|
uuid: string; slot_start: number; slot_end: number; status: string; version: number;
|
|
note?: string | null;
|
|
patient_mobile?: string | null;
|
|
user?: { uuid: string; mobile: string } | null;
|
|
deposit_required?: boolean; deposit_amount_rials?: number | null;
|
|
service_section?: Option | null; service_item?: Option | null; staff?: Option | null;
|
|
visit_price_rials?: number | null;
|
|
service_items?: { uuid: string; name?: string; price_rials?: number | null; service_category?: string | null; insurance_covered?: boolean }[] | null;
|
|
insurance_service_category?: string | null;
|
|
insurance_base_id?: number | null;
|
|
doctor?: { uuid: string; name?: string } | null;
|
|
/** null = مطب شخصی. مبنای تشخیص روش نوبتدهیِ همین نوبت، نه محیط جاری پنل. */
|
|
clinic_uuid?: string | null;
|
|
is_reserve?: boolean;
|
|
service_total_minutes?: number | null;
|
|
service_buffer_minutes?: number | null;
|
|
}
|
|
|
|
const isoDate = (ts: number) => {
|
|
const d = new Date(ts * 1000);
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
};
|
|
const isoTime = (ts: number) => {
|
|
const d = new Date(ts * 1000);
|
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
};
|
|
const toEpoch = (date: string, time: string) => Math.floor(new Date(`${date}T${time || '00:00'}`).getTime() / 1000);
|
|
|
|
/** ویرایش نوبت (Figma edit.pdf) — full-page edit of service specs, timing, deposit, status and notes. */
|
|
export default function AppointmentEditPage() {
|
|
const { uuid = '' } = useParams();
|
|
const navigate = useNavigate();
|
|
const qc = useQueryClient();
|
|
|
|
const { data, isLoading } = useQuery<ApiResponse<{ data: AppointmentDetail }>>({
|
|
queryKey: ['appointment-edit', uuid],
|
|
queryFn: () => api.get(`/api/v1/appointment/${uuid}`),
|
|
enabled: !!uuid,
|
|
});
|
|
const a = data?.data?.data;
|
|
|
|
const [sectionUuid, setSectionUuid] = useState('');
|
|
const [itemUuid, setItemUuid] = useState('');
|
|
const [staffUuid, setStaffUuid] = useState('');
|
|
const [date, setDate] = useState('');
|
|
const [start, setStart] = useState('');
|
|
const [end, setEnd] = useState('');
|
|
const [depositRequired, setDepositRequired] = useState(false);
|
|
const [depositToman, setDepositToman] = useState(0);
|
|
const [status, setStatus] = useState('');
|
|
const [note, setNote] = useState('');
|
|
const [serviceCategory, setServiceCategory] = useState('');
|
|
const [insuranceId, setInsuranceId] = useState('');
|
|
|
|
// hydrate once the appointment arrives
|
|
useEffect(() => {
|
|
if (!a) return;
|
|
setSectionUuid(a.service_section?.uuid ?? '');
|
|
setItemUuid(a.service_item?.uuid ?? '');
|
|
setStaffUuid(a.staff?.uuid ?? '');
|
|
setDate(isoDate(a.slot_start));
|
|
setStart(isoTime(a.slot_start));
|
|
setEnd(isoTime(a.slot_end));
|
|
setDepositRequired(!!a.deposit_required);
|
|
setDepositToman(rialToToman(a.deposit_amount_rials ?? 0));
|
|
setStatus(a.status);
|
|
setNote(a.note ?? '');
|
|
setServiceCategory(a.insurance_service_category ?? '');
|
|
setInsuranceId(a.insurance_base_id ? String(a.insurance_base_id) : '');
|
|
}, [a]);
|
|
|
|
const insurance = useAppointmentInsurance(!!uuid);
|
|
|
|
// روش نوبتدهی از برنامهٔ **همین نوبت** پرسیده میشود (clinic_uuid صریح)، نه از محیط
|
|
// جاری پنل: یک پزشک میتواند در مطب اسلاتی و در کلینیک سرویسی باشد.
|
|
const { bookingMode, services } = useDoctorBookingServices(
|
|
a?.doctor?.uuid,
|
|
a ? (a.clinic_uuid ?? null) : undefined,
|
|
);
|
|
// نوبت رزرو زمان ندارد؛ انتخابگر زمان برایش بیمعناست.
|
|
const serviceMode = bookingMode === 'service' && !a?.is_reserve;
|
|
const [pick, setPick] = useState<ServicePick | null>(null);
|
|
|
|
// سرویسهای فعلی نوبت، برای هیدریت اولیهٔ انتخابگر. مدتِ هر سرویس از تعریف خودش
|
|
// خوانده میشود، نه از تقسیم مدت کل — تقسیم یک حدس است و override منشی را جعل میکند.
|
|
// سرویسی که دیگر bookable نیست در فهرست services نمیآید و اینجا هم رد میشود؛
|
|
// پس فهرست پس از انتخابگر ممکن است کوتاهتر از نوبت باشد و کاربر باید ببیند.
|
|
const initialSelection = useMemo<PickedService[]>(() => {
|
|
if (!a?.service_items?.length || services.length === 0) return [];
|
|
return a.service_items.flatMap((s) => {
|
|
const known = services.find((b) => b.uuid === s.uuid);
|
|
return known
|
|
? [{
|
|
uuid: known.uuid,
|
|
name: known.name,
|
|
section: known.service_section.name,
|
|
duration: known.duration_minutes ?? 0,
|
|
}]
|
|
: [];
|
|
});
|
|
}, [a?.service_items, services]);
|
|
|
|
const droppedServices = (a?.service_items?.length ?? 0) - initialSelection.length;
|
|
|
|
// نوع خدمتِ مؤثر: انتخاب نوبت، وگرنه تنها نوع فعالِ tenant (همان قاعدهٔ سرور).
|
|
const effectiveCategory = serviceCategory || insurance.defaultCategory || DEFAULT_SERVICE_CATEGORY;
|
|
|
|
const shares = insurance.breakdown([
|
|
// نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را میگیرد (مثل سرور).
|
|
{ total: insurance.visitPriceOf(a?.visit_price_rials), category: effectiveCategory, insured: true },
|
|
...(a?.service_items ?? []).map((s) => ({
|
|
total: Number(s.price_rials ?? 0),
|
|
category: s.service_category ?? DEFAULT_SERVICE_CATEGORY,
|
|
insured: s.insurance_covered !== false,
|
|
})),
|
|
], insuranceId);
|
|
|
|
const sectionsQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections') });
|
|
const itemsQ = useQuery<ApiResponse<Option[]>>({
|
|
queryKey: ['service-items', sectionUuid],
|
|
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
|
|
enabled: !!sectionUuid,
|
|
});
|
|
const staffQ = useQuery<ApiResponse<Option[]>>({ queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff') });
|
|
|
|
const save = useMutation({
|
|
mutationFn: async () => {
|
|
// حالت سرویسی: زمان و سرویسها با endpoint سرویسآگاه میروند — کلاینت مدت
|
|
// نمیفرستد. بقیهٔ فیلدها (بیعانه، بیمه، وضعیت، یادداشت) همان PATCH قبلی.
|
|
if (serviceMode) {
|
|
await api.post(`/api/v1/appointment/${uuid}/service-reschedule`, {
|
|
start: pick!.slot!.start,
|
|
service_item_uuids: pick!.serviceUuids,
|
|
durations: pick!.durations,
|
|
version: a?.version,
|
|
});
|
|
}
|
|
|
|
return api.patch(`/api/v1/appointment/${uuid}`, {
|
|
...(serviceMode ? {} : {
|
|
slot_start: toEpoch(date, start),
|
|
slot_end: toEpoch(date, end),
|
|
service_item_uuid: itemUuid,
|
|
}),
|
|
service_section_uuid: sectionUuid,
|
|
staff_uuid: staffUuid,
|
|
deposit_required: depositRequired,
|
|
deposit_amount_rials: depositRequired ? tomanToRial(depositToman) : null,
|
|
note,
|
|
insurance_service_category: serviceCategory || null,
|
|
insurance_base_id: insuranceId ? Number(insuranceId) : null,
|
|
...(status !== a?.status ? { status } : {}),
|
|
// نسخه پس از service-reschedule یک قدم جلو رفته؛ optimistic lock را دور نزن.
|
|
...(serviceMode ? {} : { version: a?.version }),
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['appointments'] });
|
|
toast.success('نوبت بهروزرسانی شد');
|
|
// به همان روزِ نوبت برگرد، نه امروز.
|
|
navigate(`/admin/appointments?date=${date}`);
|
|
},
|
|
onError: (e: any) => toast.error(e.message || 'خطا در ذخیره اطلاعات'),
|
|
});
|
|
|
|
const label = { fontSize: 12.5, color: 'var(--text-3)' } as const;
|
|
|
|
if (isLoading || !a) return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
|
|
|
|
const statusOptions: [string, string][] = [
|
|
['pending', 'ثبت شده'], ['confirmed', 'قطعی شده'], ['following_up', 'در حال پیگیری'],
|
|
['salon', 'سالن'], ['completed', 'ویزیت شده'], ['cancelled_by_doctor', 'لغو شده'],
|
|
];
|
|
|
|
return (
|
|
<div className="fade-in" style={{ maxWidth: 860, margin: '0 auto' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 14 }}>
|
|
<BackButton fallback={`/admin/appointments?date=${date}`} />
|
|
</div>
|
|
|
|
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 22 }}>
|
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>مشخصات سرویس:</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 18 }}>
|
|
<div>
|
|
<label style={label}>بخش</label>
|
|
<div style={{ marginTop: 6 }}>
|
|
<SearchableSelect
|
|
options={(sectionsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
|
|
value={sectionUuid || null}
|
|
onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }}
|
|
placeholder="انتخاب بخش"
|
|
isLoading={sectionsQ.isLoading}
|
|
isClearable
|
|
height={38}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/* در حالت سرویسی، انتخاب سرویس داخل ServiceSlotPicker است (چند-سرویسی و
|
|
مدتدار)؛ نگهداشتن این SearchableSelect تکی یعنی دو منبع برای یک چیز. */}
|
|
{!serviceMode && (
|
|
<div>
|
|
<label style={label}>سرویس</label>
|
|
<div style={{ marginTop: 6 }}>
|
|
<SearchableSelect
|
|
options={(itemsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
|
|
value={itemUuid || null}
|
|
onChange={v => setItemUuid(v ? String(v) : '')}
|
|
placeholder="انتخاب سرویس"
|
|
isDisabled={!sectionUuid}
|
|
isLoading={itemsQ.isLoading}
|
|
isClearable
|
|
height={38}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label style={label}>پرسنل</label>
|
|
<div style={{ marginTop: 6 }}>
|
|
<SearchableSelect
|
|
options={(staffQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.full_name ?? '' }))}
|
|
value={staffUuid || null}
|
|
onChange={v => setStaffUuid(v ? String(v) : '')}
|
|
placeholder="انتخاب پرسنل"
|
|
isLoading={staffQ.isLoading}
|
|
isClearable
|
|
height={38}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>بیمه:</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 18 }}>
|
|
{/* نوع خدمت فقط وقتی چند نوع فعال است پرسیده میشود. */}
|
|
{insurance.needsCategoryChoice && (
|
|
<div>
|
|
<label style={label}>نوع خدمت</label>
|
|
<div style={{ marginTop: 6 }}>
|
|
<SearchableSelect
|
|
options={insurance.categoryOptions}
|
|
value={serviceCategory || null}
|
|
onChange={v => setServiceCategory(v ? String(v) : '')}
|
|
placeholder="انتخاب نوع خدمت"
|
|
isClearable
|
|
height={38}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<label style={label}>بیمه</label>
|
|
<div style={{ marginTop: 6 }}>
|
|
<SearchableSelect
|
|
options={insurance.insuranceOptions}
|
|
value={insuranceId || null}
|
|
onChange={v => setInsuranceId(v ? String(v) : '')}
|
|
placeholder="بدون بیمه"
|
|
noOptionsMessage="قرارداد بیمهٔ فعالی ندارید"
|
|
isClearable
|
|
height={38}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{insuranceId !== '' && (
|
|
<div>
|
|
<label style={label}>سهم بیمه / سهم بیمار</label>
|
|
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8, minHeight: 38, fontSize: 13 }}>
|
|
<span style={{ color: 'var(--success)', fontWeight: 700 }}>{formatRial(shares.insurance)}</span>
|
|
<span style={{ color: 'var(--text-3)' }}>/</span>
|
|
<span style={{ color: 'var(--primary)', fontWeight: 700 }}>{formatRial(shares.patient)}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>زمان نوبت:</div>
|
|
|
|
{/* حالت اسلاتی — دقیقاً همان سه فیلد قبلی، دستنخورده. */}
|
|
{!serviceMode && (
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 18 }}>
|
|
<div>
|
|
<label style={label}>انتخاب تاریخ</label>
|
|
<div style={{ marginTop: 6 }}><PersianDateInput value={date} onChange={setDate} /></div>
|
|
</div>
|
|
<div>
|
|
<label style={label}>ساعت شروع</label>
|
|
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
|
|
</div>
|
|
<div>
|
|
<label style={label}>ساعت پایان</label>
|
|
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* حالت سرویسی — ورودی دستی ساعت پنهان است، نه disabled: فیلد غیرفعال یعنی
|
|
کاربر فکر میکند باید کاری بکند. مدت را سرور از سرویسها حساب میکند. */}
|
|
{serviceMode && (
|
|
<div style={{ marginBottom: 18 }}>
|
|
<div style={{ maxWidth: 280, marginBottom: 12 }}>
|
|
<label style={label}>انتخاب تاریخ</label>
|
|
<div style={{ marginTop: 6 }}><PersianDateInput value={date} onChange={setDate} /></div>
|
|
</div>
|
|
|
|
{droppedServices > 0 && (
|
|
<div style={{
|
|
fontSize: 12.5, color: 'var(--warning)', background: 'var(--warning-bg)',
|
|
border: '1px solid var(--warning)', borderRadius: 'var(--r-sm)',
|
|
padding: '8px 10px', marginBottom: 10,
|
|
}}>
|
|
{droppedServices} سرویس این نوبت دیگر برای نوبتدهی فعال نیست و در فهرست پایین نیامده است.
|
|
</div>
|
|
)}
|
|
|
|
{date && (
|
|
<ServiceSlotPicker
|
|
doctorUuid={a.doctor?.uuid ?? ''}
|
|
date={date}
|
|
services={services}
|
|
clinicUuidOverride={a.clinic_uuid ?? null}
|
|
excludeAppointmentUuid={a.uuid}
|
|
initialSelection={initialSelection}
|
|
onSelect={setPick}
|
|
/>
|
|
)}
|
|
|
|
{pick?.slot && (
|
|
<div style={{ fontSize: 12.5, color: 'var(--text-2)', marginTop: 8 }}>
|
|
زمان انتخابی: <strong dir="ltr">{isoTime(pick.slot.start)}</strong>
|
|
{a.service_buffer_minutes ? ` (+${a.service_buffer_minutes} دقیقه فاصله)` : ''}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>بیعانه:</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', marginBottom: 18 }}>
|
|
<Switch
|
|
inline
|
|
checked={depositRequired}
|
|
onChange={setDepositRequired}
|
|
label="بیعانه مورد نیاز است."
|
|
/>
|
|
{depositRequired && (
|
|
<>
|
|
<div style={{ minWidth: 220 }}>
|
|
<label style={label}>مبلغ بیعانه (تومان)</label>
|
|
<div style={{ marginTop: 6 }}><PriceInput value={depositToman} onChange={setDepositToman} /></div>
|
|
</div>
|
|
<WalletChargeLink mobile={a.patient_mobile || a.user?.mobile || ''} />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ maxWidth: 320, marginBottom: 18 }}>
|
|
<label style={label}>انتخاب وضعیت</label>
|
|
<div style={{ marginTop: 6 }}>
|
|
<SearchableSelect
|
|
options={statusOptions.map(([v, l]) => ({ value: v, label: l }))}
|
|
value={status || null}
|
|
onChange={v => setStatus(v ? String(v) : '')}
|
|
placeholder="انتخاب وضعیت"
|
|
height={38}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<label style={label}>توضیحات</label>
|
|
<div className="field" style={{ height: 'auto', margin: '6px 0 18px' }}>
|
|
<textarea value={note} onChange={e => setNote(e.target.value)} rows={4} placeholder="توضیحات"
|
|
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} />
|
|
</div>
|
|
|
|
<button
|
|
className="btn primary"
|
|
disabled={save.isPending || (serviceMode
|
|
? !pick?.slot || pick.serviceUuids.length === 0
|
|
: !date || !start || !end)}
|
|
onClick={() => save.mutate()}
|
|
>
|
|
ثبت اطلاعات
|
|
</button>
|
|
{serviceMode && !pick?.slot && (
|
|
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginTop: 8 }}>
|
|
برای ثبت، سرویس و سپس یکی از زمانهای خالی را انتخاب کنید.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|