Files
clinicpro/assets/admin/pages/PatientRecordFormPage.tsx
T
hamedandClaude Opus 4.8 15c6f5dce7 feat(patients): phase A — records list + create/edit form (Figma)
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>
2026-07-13 14:54:38 +03:30

155 lines
8.0 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 } 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>
);
}