Files
clinicpro/assets/admin/pages/PatientRecordFormPage.tsx
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- 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.
2026-08-07 21:13:38 +03:30

212 lines
10 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 { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate, 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 type { PatientRecord } from '../types';
import PersianDateInput from '../components/ui/PersianDateInput';
import SearchableSelect from '../components/ui/SearchableSelect';
import { numericField } from '../lib/forms';
import BackButton from '../components/ui/BackButton';
import { iranNationalCodeSchema, iranMobileSchema, unixToIso } from '../lib/utils';
import { useRecordNumberSettings } from '../hooks/useRecordNumberSettings';
const REFERRAL_OPTIONS = ['اینستاگرام', 'معرفی دوستان و آشنایان', 'جستجوی اینترنتی', 'تابلو مطب', 'سایر'];
const schema = z.object({
name: z.string().min(1, 'نام و نام خانوادگی الزامی است'),
// الزامی‌بودنش شرطی است: با الگوی فعال، سرور شماره را می‌سازد و این فیلد اصلاً
// فرستاده نمی‌شود. اعتبارسنجیِ شرطی در `superRefine` پایین‌تر است.
record_number: z.string(),
gender: z.enum(['male', 'female'], { errorMap: () => ({ message: 'جنسیت را انتخاب کنید' }) }),
national_code: iranNationalCodeSchema,
mobile: iranMobileSchema,
birth_date: z.string().optional(),
referral_source: z.string().optional(),
description: z.string().optional(),
});
type Form = z.infer<typeof schema>;
const toEpoch = (iso?: string) => (iso ? Math.floor(new Date(iso).getTime() / 1000) : null);
const fromEpoch = (ts?: number | null) => unixToIso(ts);
/** تشکیل/ویرایش پرونده — patient record create & edit form (Figma "تشکیل پرونده"). */
export default function PatientRecordFormPage() {
const { uuid } = useParams<{ uuid: string }>();
const isEdit = !!uuid;
const navigate = useNavigate();
const qc = useQueryClient();
const { settings } = useRecordNumberSettings();
/** الگو روشن است ⇒ شماره را سرور می‌سازد. */
const autoNumbered = !!settings?.enabled;
/** ورود دستی: وقتی الگویی نیست، یا کاربر صاحب مجموعه است (سرور هم همین را می‌سنجد). */
const manualAllowed = !autoNumbered || !!settings?.can_edit;
const formSchema = useMemo(
() => schema.superRefine((v, ctx) => {
// فقط وقتی شماره دستی است الزامی می‌ماند؛ وگرنه کاربر با فیلدی که نمی‌تواند
// پرش کند پشت فرمِ قفل‌شده می‌ماند.
if (!autoNumbered && !(v.record_number ?? '').trim()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['record_number'], message: 'شماره پرونده الزامی است' });
}
}),
[autoNumbered],
);
const form = useForm<Form>({
resolver: zodResolver(formSchema),
defaultValues: { name: '', record_number: '', gender: undefined as any, national_code: '', mobile: '', birth_date: '', referral_source: '', description: '' },
});
const { data: recordData } = useQuery<ApiResponse<PatientRecord>>({
queryKey: ['patient', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}`),
enabled: isEdit,
});
useEffect(() => {
const r = recordData?.data;
if (!r) return;
const p: any = r.profile ?? {};
form.reset({
name: r.user_name ?? '',
record_number: r.record_number ?? '',
gender: (p.gender as 'male' | 'female') ?? undefined,
national_code: r.user_national_code ?? '',
mobile: r.user_mobile ?? '',
birth_date: fromEpoch(p.date_of_birth),
referral_source: p.referral_source ?? '',
description: p.description ?? '',
});
}, [recordData]); // eslint-disable-line react-hooks/exhaustive-deps
const save = useMutation({
mutationFn: async (d: Form) => {
const profilePayload = {
gender: d.gender,
date_of_birth: toEpoch(d.birth_date),
referral_source: d.referral_source || null,
description: d.description || null,
};
// شمارهٔ پرونده فقط وقتی فرستاده می‌شود که ورود دستی مجاز باشد؛ وگرنه سرور
// ۴۰۳ می‌دهد و سرورست که شماره را از الگو می‌سازد.
const numberPayload = manualAllowed ? { record_number: d.record_number ?? '' } : {};
if (isEdit) {
return api.patch(`/api/v1/patient/${uuid}`, {
name: d.name, national_code: d.national_code, mobile: d.mobile, ...numberPayload, ...profilePayload,
});
}
// create: POST creates the record + identity, then PATCH applies the profile demographics
const created = await api.post<ApiResponse<PatientRecord>>('/api/v1/patient', {
name: d.name, mobile: d.mobile, national_code: d.national_code, ...numberPayload,
});
const newUuid = (created as any)?.data?.uuid;
if (newUuid) await api.patch(`/api/v1/patient/${newUuid}`, profilePayload);
return created;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['patients'] });
toast.success(isEdit ? 'پرونده ویرایش شد' : 'پرونده تشکیل شد');
navigate('/admin/patients');
},
onError: (e: any) => toast.error(e.message),
});
const Field = ({ label, required, error, children }: { label: string; required?: boolean; error?: string; children: React.ReactNode }) => (
<div>
<label className="field-label">{label} {required && <span style={{ color: 'var(--danger)' }}>*</span>}</label>
{children}
{error && <span className="field-error">{error}</span>}
</div>
);
return (
<div className="fade-in" style={{ maxWidth: 1000, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18 }}>
<BackButton fallback="/admin/patients" />
<div style={{ fontSize: 14, color: 'var(--text-3)' }}>پرونده <b style={{ color: 'var(--text)' }}>{isEdit ? 'ویرایش پرونده' : 'تشکیل پرونده'}</b></div>
</div>
<form onSubmit={form.handleSubmit((d) => save.mutate(d))}
style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 24 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 18 }}>
<Field label="نام و نام خانوادگی مراجعه کننده" required error={form.formState.errors.name?.message}>
<div className="field"><input {...form.register('name')} placeholder="نام و نام خانوادگی را وارد نمایید" /></div>
</Field>
<Field
label="شماره پرونده"
required={!autoNumbered}
error={form.formState.errors.record_number?.message}
>
{manualAllowed ? (
<div className="field">
<input
{...form.register('record_number')}
placeholder={autoNumbered ? (settings?.next_preview ?? 'شماره پرونده') : 'شماره پرونده'}
/>
</div>
) : (
<div className="field" style={{ opacity: 0.7 }}>
<input value={isEdit ? (recordData?.data?.record_number ?? '') : (settings?.next_preview ?? '')} readOnly dir="ltr" />
</div>
)}
{autoNumbered && (
<span style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'block', marginTop: 4 }}>
{manualAllowed
? `خالی بماند تا از الگو ساخته شود (شمارهٔ بعدی: ${settings?.next_preview ?? '—'})`
: 'شماره از الگوی مجموعه ساخته می‌شود'}
</span>
)}
</Field>
<Field label="جنسیت" required error={form.formState.errors.gender?.message}>
<SearchableSelect
inputId="patient-gender-select"
options={[{ value: 'female', label: 'زن' }, { value: 'male', label: 'مرد' }]}
value={form.watch('gender') ?? null}
onChange={(v) => form.setValue('gender', v as Form['gender'], { shouldValidate: true, shouldDirty: true })}
placeholder="انتخاب..."
height={38}
/>
</Field>
<Field label="کد ملی" required error={form.formState.errors.national_code?.message}>
<div className="field"><input {...numericField(form.register('national_code'), 10)} placeholder="کد ملی را وارد نمایید" /></div>
</Field>
<Field label="شماره تماس" required error={form.formState.errors.mobile?.message}>
<div className="field"><input {...numericField(form.register('mobile'), 11)} placeholder="شماره تماس را وارد نمایید" /></div>
</Field>
<Field label="تاریخ تولد">
<PersianDateInput value={form.watch('birth_date') ?? ''} onChange={(v) => form.setValue('birth_date', v)} enableYearPicker />
</Field>
<Field label="نحوه آشنایی">
<SearchableSelect
options={REFERRAL_OPTIONS.map((o) => ({ value: o, label: o }))}
value={form.watch('referral_source') || null}
onChange={(v) => form.setValue('referral_source', v ? String(v) : '', { shouldDirty: true })}
placeholder="انتخاب کنید..."
isClearable
height={38}
/>
</Field>
</div>
<div style={{ marginTop: 18 }}>
<Field label="توضیحات">
<div className="field" style={{ height: 'auto' }}><textarea {...form.register('description')} rows={4} placeholder="توضیحات" style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} /></div>
</Field>
</div>
<button type="submit" className="btn primary" style={{ marginTop: 22, height: 44, padding: '0 24px' }} disabled={save.isPending}>
{save.isPending ? 'در حال ذخیره...' : 'ثبت اطلاعات'}
</button>
</form>
</div>
);
}