- 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.
212 lines
10 KiB
TypeScript
212 lines
10 KiB
TypeScript
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>
|
||
);
|
||
}
|