feat: wire editable «اطلاعات پرونده» form into patient case-file info tab
Port tauri /files-services FileInfoSection into PatientDetailPage's info
tab. The tab previously rendered a stripped read-only InfoRow list; it now
renders the inline editable PatientRecordInfoForm (18 fields + ثبت اطلاعات),
matching the tauri source. Reuses the existing form component, patientForm
helpers, and the already-present patient/provinces/cities/insurance-pricing
endpoints (no new API). PATCH /api/v1/patient/{uuid} on submit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,16 @@ beforeEach(() => {
|
|||||||
if (url === '/api/v1/patient/r1') return Promise.resolve({ success: true, data: {
|
if (url === '/api/v1/patient/r1') return Promise.resolve({ success: true, data: {
|
||||||
uuid: 'r1', user_name: 'ساغر صابری', record_number: 'P-1001', created_at: 1700000000,
|
uuid: 'r1', user_name: 'ساغر صابری', record_number: 'P-1001', created_at: 1700000000,
|
||||||
user_mobile: '09120000000', user_national_code: '1234567890', tags: [],
|
user_mobile: '09120000000', user_national_code: '1234567890', tags: [],
|
||||||
profile: { gender: 'female', referral_source: 'اینستاگرام', address: 'یزد', description: 'یادداشت' },
|
profile: {
|
||||||
|
name: 'ساغر صابری', national_code: '1234567890', mobile: '09120000000',
|
||||||
|
gender: 'female', fathers_name: 'رضا', job: 'مهندس',
|
||||||
|
referral_source: 'اینستاگرام', address: 'یزد', description: 'یادداشت',
|
||||||
|
},
|
||||||
|
} });
|
||||||
|
if (url === '/api/v1/provinces') return Promise.resolve({ success: true, data: [{ id: 10, name: 'یزد' }] });
|
||||||
|
if (url.startsWith('/api/v1/cities')) return Promise.resolve({ success: true, data: [{ id: 100, name: 'یزد' }] });
|
||||||
|
if (url === '/api/v1/insurance-pricing') return Promise.resolve({ success: true, data: {
|
||||||
|
insurances: [{ insurance_id: 1, insurance_name: 'تأمین اجتماعی', type: 'basic' }],
|
||||||
} });
|
} });
|
||||||
if (url === '/api/v1/patient/r1/sessions') return Promise.resolve({ success: true, data: [
|
if (url === '/api/v1/patient/r1/sessions') return Promise.resolve({ success: true, data: [
|
||||||
{ uuid: 's1', services: [{ service_name: 'اسکیلینگ' }], doctor_name: 'دکتر فتحی', final_price_rials: 2500000, is_paid: false, patient_debt_rials: 1500000, notes: 'یادداشت', created_at: 1700000000, visit_price_rials: 0 },
|
{ uuid: 's1', services: [{ service_name: 'اسکیلینگ' }], doctor_name: 'دکتر فتحی', final_price_rials: 2500000, is_paid: false, patient_debt_rials: 1500000, notes: 'یادداشت', created_at: 1700000000, visit_price_rials: 0 },
|
||||||
@@ -88,13 +97,32 @@ describe('PatientDetailPage (پرونده تبدار)', () => {
|
|||||||
expect(await screen.findByText('اطلاعات فاکتور')).toBeInTheDocument();
|
expect(await screen.findByText('اطلاعات فاکتور')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows patient info on the info tab', async () => {
|
it('renders the editable «اطلاعات پرونده» form prefilled from the profile', async () => {
|
||||||
renderDetail();
|
renderDetail();
|
||||||
await loaded();
|
await loaded();
|
||||||
fireEvent.click(screen.getByText('اطلاعات پرونده'));
|
fireEvent.click(screen.getByText('اطلاعات پرونده'));
|
||||||
expect(screen.getByText('کد ملی')).toBeInTheDocument();
|
// لیبل فیلدهای فرم (معادل tauri FileInfoSection)
|
||||||
expect(screen.getByText('1234567890')).toBeInTheDocument();
|
expect(await screen.findByText('نام و نام خانوادگی مراجعه کننده')).toBeInTheDocument();
|
||||||
expect(screen.getByText('اینستاگرام')).toBeInTheDocument();
|
expect(screen.getByText('کدملی')).toBeInTheDocument();
|
||||||
|
// مقادیر پیشپرشده از پروفایل
|
||||||
|
expect(screen.getByDisplayValue('1234567890')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('ساغر صابری')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('P-1001')).toBeInTheDocument(); // شماره پرونده readonly
|
||||||
|
// دکمهٔ ثبت
|
||||||
|
expect(screen.getByRole('button', { name: 'ثبت اطلاعات' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the info form with placeholders when the profile is empty', async () => {
|
||||||
|
get.mockImplementation((url: string) => {
|
||||||
|
if (url === '/api/v1/patient/r1') return Promise.resolve({ success: true, data: {
|
||||||
|
uuid: 'r1', user_name: 'بدون پروفایل', record_number: 'P-9', created_at: 1700000000, profile: null,
|
||||||
|
} });
|
||||||
|
return Promise.resolve({ success: true, data: [] });
|
||||||
|
});
|
||||||
|
renderDetail();
|
||||||
|
fireEvent.click(await screen.findByText('اطلاعات پرونده'));
|
||||||
|
expect(await screen.findByPlaceholderText('نام و نام خانوادگی')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'ثبت اطلاعات' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the call-center tab with a register form and history', async () => {
|
it('renders the call-center tab with a register form and history', async () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useParams, useSearchParams, Link } from 'react-router-dom';
|
import { useParams, useSearchParams, Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +25,11 @@ import { TurnsFilter, AddTurn } from '../components/icons/FilesToolbarIcons';
|
|||||||
import {
|
import {
|
||||||
TabServices, TabInfo, TabCalendar, TabCard, TabWallet, TabSMS, TabCall, TabAttach, TabBody,
|
TabServices, TabInfo, TabCalendar, TabCard, TabWallet, TabSMS, TabCall, TabAttach, TabBody,
|
||||||
} from '../components/icons/FilesServiceIcons';
|
} from '../components/icons/FilesServiceIcons';
|
||||||
|
import PatientRecordInfoForm from '../components/PatientRecordInfoForm';
|
||||||
|
import {
|
||||||
|
profileToFormValues, formValuesToPayload,
|
||||||
|
GENDER_OPTS, MARITAL_OPTS, EDUCATION_OPTS, REFERRAL_OPTS,
|
||||||
|
} from '../lib/patientForm';
|
||||||
|
|
||||||
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'messages' | 'callcenter' | 'attach' | 'records';
|
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'messages' | 'callcenter' | 'attach' | 'records';
|
||||||
|
|
||||||
@@ -40,8 +45,6 @@ const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }
|
|||||||
{ key: 'records', label: 'پرونده پزشکی', icon: (c) => <TabBody color={c} /> },
|
{ key: 'records', label: 'پرونده پزشکی', icon: (c) => <TabBody color={c} /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const GENDER_LABEL: Record<string, string> = { male: 'مرد', female: 'زن' };
|
|
||||||
|
|
||||||
const PAYMENT_STATUS: Record<string, string> = {
|
const PAYMENT_STATUS: Record<string, string> = {
|
||||||
pending: 'در انتظار', success: 'موفق', failed: 'ناموفق', canceled: 'لغو شده', refunded: 'بازگشت',
|
pending: 'در انتظار', success: 'موفق', failed: 'ناموفق', canceled: 'لغو شده', refunded: 'بازگشت',
|
||||||
};
|
};
|
||||||
@@ -54,15 +57,6 @@ function Placeholder({ label }: { label: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value?: string | null }) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '10px 0', borderTop: '1px solid var(--border)', gap: 12 }}>
|
|
||||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>{label}</span>
|
|
||||||
<span style={{ fontSize: 13.5, color: 'var(--text)', fontWeight: 600, direction: 'ltr' }}>{value || '—'}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** پرونده — the tabbed patient case-file (Figma "جزئیات پرونده"). */
|
/** پرونده — the tabbed patient case-file (Figma "جزئیات پرونده"). */
|
||||||
export default function PatientDetailPage() {
|
export default function PatientDetailPage() {
|
||||||
const { uuid } = useParams<{ uuid: string }>();
|
const { uuid } = useParams<{ uuid: string }>();
|
||||||
@@ -79,11 +73,51 @@ export default function PatientDetailPage() {
|
|||||||
enabled: !!uuid,
|
enabled: !!uuid,
|
||||||
});
|
});
|
||||||
const record = data?.data;
|
const record = data?.data;
|
||||||
const profile: any = record?.profile ?? {};
|
|
||||||
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [invoiceUuid, setInvoiceUuid] = useState<string | null>(null);
|
const [invoiceUuid, setInvoiceUuid] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// ── فرم «اطلاعات پرونده» (اینلاین، معادل tauri FileInfoSection) ──────────────
|
||||||
|
// استان/شهر (Location) و بیمهٔ پایه (insurance-pricing) برای گزینههای فرم.
|
||||||
|
const [editProvinceId, setEditProvinceId] = useState<number | null>(null);
|
||||||
|
const seededProvince = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!seededProvince.current && record?.profile) {
|
||||||
|
setEditProvinceId(record.profile.province_id ?? null);
|
||||||
|
seededProvince.current = true;
|
||||||
|
}
|
||||||
|
}, [record]);
|
||||||
|
|
||||||
|
const provincesQ = useQuery<any>({
|
||||||
|
queryKey: ['provinces'],
|
||||||
|
queryFn: () => api.get('/api/v1/provinces'),
|
||||||
|
staleTime: 600_000,
|
||||||
|
});
|
||||||
|
const citiesQ = useQuery<any>({
|
||||||
|
queryKey: ['cities', editProvinceId],
|
||||||
|
queryFn: () => api.get(`/api/v1/cities${editProvinceId ? `?province_id=${editProvinceId}` : ''}`),
|
||||||
|
staleTime: 300_000,
|
||||||
|
});
|
||||||
|
const pricingQ = useQuery<ApiResponse<any>>({
|
||||||
|
queryKey: ['insurance-pricing'],
|
||||||
|
queryFn: () => api.get('/api/v1/insurance-pricing'),
|
||||||
|
enabled: !!uuid,
|
||||||
|
});
|
||||||
|
const locOpts = (raw: any) =>
|
||||||
|
(raw?.data?.data ?? raw?.data ?? []).map((x: any) => ({ value: Number(x.id), label: x.name }));
|
||||||
|
const baseInsuranceOptions = ((pricingQ.data?.data as any)?.insurances ?? [])
|
||||||
|
.filter((i: any) => i.type === 'basic')
|
||||||
|
.map((i: any) => ({ value: String(i.insurance_id), label: i.insurance_name }));
|
||||||
|
|
||||||
|
const updateProfileMut = useMutation({
|
||||||
|
mutationFn: (body: object) => api.patch(`/api/v1/patient/${uuid}`, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['patient', uuid] });
|
||||||
|
toast.success('اطلاعات بیمار بهروزرسانی شد');
|
||||||
|
},
|
||||||
|
onError: (e: any) => toast.error(e?.message || 'خطا در بهروزرسانی اطلاعات بیمار'),
|
||||||
|
});
|
||||||
|
|
||||||
// sessions + appointments load eagerly so the banner (debt / next visit) is ready.
|
// sessions + appointments load eagerly so the banner (debt / next visit) is ready.
|
||||||
const sessionsQ = useQuery<ApiResponse<SessionCardData[]>>({
|
const sessionsQ = useQuery<ApiResponse<SessionCardData[]>>({
|
||||||
queryKey: ['patient-sessions', uuid],
|
queryKey: ['patient-sessions', uuid],
|
||||||
@@ -151,16 +185,23 @@ export default function PatientDetailPage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
<div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
|
||||||
) : tab === 'info' ? (
|
) : tab === 'info' ? (
|
||||||
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 20, maxWidth: 620 }}>
|
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: 20 }}>
|
||||||
<InfoRow label="شماره پرونده" value={record?.record_number} />
|
<PatientRecordInfoForm
|
||||||
<InfoRow label="نام مراجعهکننده" value={record?.user_name} />
|
defaultValues={profileToFormValues(record?.profile ?? null)}
|
||||||
<InfoRow label="کد ملی" value={record?.user_national_code} />
|
recordNumber={record?.record_number}
|
||||||
<InfoRow label="شماره تماس" value={record?.user_mobile} />
|
options={{
|
||||||
<InfoRow label="جنسیت" value={profile.gender ? GENDER_LABEL[profile.gender] : null} />
|
gender: GENDER_OPTS,
|
||||||
<InfoRow label="تاریخ تولد" value={profile.date_of_birth ? formatDate(profile.date_of_birth) : null} />
|
marital: MARITAL_OPTS,
|
||||||
<InfoRow label="نحوه آشنایی" value={profile.referral_source} />
|
education: EDUCATION_OPTS,
|
||||||
<InfoRow label="آدرس" value={profile.address} />
|
referral: REFERRAL_OPTS,
|
||||||
<InfoRow label="توضیحات" value={profile.description} />
|
insurance: baseInsuranceOptions,
|
||||||
|
province: locOpts(provincesQ.data),
|
||||||
|
city: locOpts(citiesQ.data),
|
||||||
|
}}
|
||||||
|
onSubmit={(v) => updateProfileMut.mutate(formValuesToPayload(v))}
|
||||||
|
isSubmitting={updateProfileMut.isPending}
|
||||||
|
onProvinceChange={setEditProvinceId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : tab === 'services' ? (
|
) : tab === 'services' ? (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user