feat(patient): implement record number pattern management

- Add RecordNumberSettingsController for managing patient record number patterns.
- Create RecordNumberPattern entity to represent the pattern configuration.
- Implement RecordNumberPatternRepository for database interactions.
- Develop RecordNumberGenerator service for generating and validating record numbers.
- Add tests for record number generation, backfilling, and API interactions.
- Ensure proper access control for viewing and updating patterns based on user roles.
This commit is contained in:
hamed
2026-08-04 12:30:17 +03:30
parent 9b8eef9598
commit 85a27812c7
24 changed files with 2009 additions and 19 deletions
+54 -7
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -14,12 +14,15 @@ 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, 'نام و نام خانوادگی الزامی است'),
record_number: z.string().min(1, 'شماره پرونده الزامی است'),
// الزامی‌بودنش شرطی است: با الگوی فعال، سرور شماره را می‌سازد و این فیلد اصلاً
// فرستاده نمی‌شود. اعتبارسنجیِ شرطی در `superRefine` پایین‌تر است.
record_number: z.string(),
gender: z.enum(['male', 'female'], { errorMap: () => ({ message: 'جنسیت را انتخاب کنید' }) }),
national_code: iranNationalCodeSchema,
mobile: iranMobileSchema,
@@ -39,8 +42,25 @@ export default function PatientRecordFormPage() {
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(schema),
resolver: zodResolver(formSchema),
defaultValues: { name: '', record_number: '', gender: undefined as any, national_code: '', mobile: '', birth_date: '', referral_source: '', description: '' },
});
@@ -74,14 +94,18 @@ export default function PatientRecordFormPage() {
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, record_number: d.record_number, ...profilePayload,
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, record_number: d.record_number,
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);
@@ -116,11 +140,34 @@ export default function PatientRecordFormPage() {
<Field label="نام و نام خانوادگی مراجعه کننده" required error={form.formState.errors.name?.message}>
<div className="field"><input {...form.register('name')} placeholder="نام و نام خانوادگی را وارد نمایید" /></div>
</Field>
<Field label="شماره پرونده" required error={form.formState.errors.record_number?.message}>
<div className="field"><input {...form.register('record_number')} placeholder="شماره پرونده" /></div>
<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 })}