Rebuild the patient records (پروندهها) area, phase A of the Figma redesign:
- BE: add a clinic-scoped `record_number` and a TenantTag `tags` M2M to
PatientRecord (migration + EAGER-hydrated collection). POST /patient and
PATCH /patient/{uuid} now accept `record_number` and tenant-scoped `tags`
(foreign tag → 422); demographic fields (gender, date_of_birth,
referral_source, description) continue to live on UserProfile via PATCH.
- FE: new PatientsListPage (table + card views, search, pagination, tags
column, "تشکیل پرونده") at /admin/patients, and PatientRecordFormPage
(create/edit) that POSTs the record then PATCHes the demographics. Point
the sidebar "پرونده" entry to the new list.
Phases B–E (tabbed patient file, service stepper, invoice, payments/wallet,
call-center) follow. Backend covered by PHPUnit, FE by Vitest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
155 lines
8.0 KiB
TypeScript
155 lines
8.0 KiB
TypeScript
import { useEffect } 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-dom';
|
||
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';
|
||
|
||
const REFERRAL_OPTIONS = ['اینستاگرام', 'معرفی دوستان و آشنایان', 'جستجوی اینترنتی', 'تابلو مطب', 'سایر'];
|
||
|
||
const schema = z.object({
|
||
name: z.string().min(1, 'نام و نام خانوادگی الزامی است'),
|
||
record_number: z.string().min(1, 'شماره پرونده الزامی است'),
|
||
gender: z.enum(['male', 'female'], { errorMap: () => ({ message: 'جنسیت را انتخاب کنید' }) }),
|
||
national_code: z.string().regex(/^\d{10}$/, 'کد ملی باید ۱۰ رقم باشد'),
|
||
mobile: z.string().regex(/^09\d{9}$/, 'شماره تماس نامعتبر است'),
|
||
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) => (ts ? new Date(ts * 1000).toISOString().slice(0, 10) : '');
|
||
|
||
/** تشکیل/ویرایش پرونده — 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 form = useForm<Form>({
|
||
resolver: zodResolver(schema),
|
||
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,
|
||
};
|
||
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,
|
||
});
|
||
}
|
||
// 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,
|
||
});
|
||
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 }}>
|
||
<Link to="/admin/patients" className="btn sm ghost" style={{ color: 'var(--text-2)' }}><ChevronRightIcon style={{ width: 16 }} /> بازگشت</Link>
|
||
<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 error={form.formState.errors.record_number?.message}>
|
||
<div className="field"><input {...form.register('record_number')} placeholder="شماره پرونده" /></div>
|
||
</Field>
|
||
<Field label="جنسیت" required error={form.formState.errors.gender?.message}>
|
||
<div className="field"><select {...form.register('gender')} style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit' }}>
|
||
<option value="">انتخاب...</option>
|
||
<option value="female">زن</option>
|
||
<option value="male">مرد</option>
|
||
</select></div>
|
||
</Field>
|
||
<Field label="کد ملی" required error={form.formState.errors.national_code?.message}>
|
||
<div className="field"><input {...form.register('national_code')} inputMode="numeric" placeholder="کد ملی را وارد نمایید" /></div>
|
||
</Field>
|
||
<Field label="شماره تماس" required error={form.formState.errors.mobile?.message}>
|
||
<div className="field"><input {...form.register('mobile')} inputMode="numeric" placeholder="شماره تماس را وارد نمایید" /></div>
|
||
</Field>
|
||
<Field label="تاریخ تولد">
|
||
<PersianDateInput value={form.watch('birth_date') ?? ''} onChange={(v) => form.setValue('birth_date', v)} />
|
||
</Field>
|
||
<Field label="نحوه آشنایی">
|
||
<div className="field"><select {...form.register('referral_source')} style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit' }}>
|
||
<option value="">انتخاب کنید...</option>
|
||
{REFERRAL_OPTIONS.map((o) => <option key={o} value={o}>{o}</option>)}
|
||
</select></div>
|
||
</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>
|
||
);
|
||
}
|