feat(patients): standalone new-session page with sectioned UX

Dedicated route /admin/my-patients/:recordUuid/session/new replacing the
modal: insurance & price section (auto-fill from pricing), services,
payment/notes, sticky live summary. New-visit buttons navigate to it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-23 20:41:22 +03:30
co-authored by Claude Opus 4.8
parent 017ea2ae22
commit 18ae55ac30
3 changed files with 258 additions and 14 deletions
+2
View File
@@ -30,6 +30,7 @@ import MyClinicPage from './pages/MyClinicPage';
import SettingsPage from './pages/SettingsPage';
import DoctorProfilePage from './pages/DoctorProfilePage';
import MyPatientsPage from './pages/MyPatientsPage';
import NewSessionPage from './pages/NewSessionPage';
import InsurancePricingPage from './pages/InsurancePricingPage';
import ClaimsPage from './pages/ClaimsPage';
import MyFinancialPage from './pages/MyFinancialPage';
@@ -170,6 +171,7 @@ export default function App() {
{/* دکتر / منشی / کلینیک */}
<Route path="my-patients" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><MyPatientsPage /></RoleRoute>} />
<Route path="my-patients/:recordUuid/session/new" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']} blockClinicScope><NewSessionPage /></RoleRoute>} />
<Route path="insurance-pricing" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><InsurancePricingPage /></RoleRoute>} />
<Route path="claims" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><ClaimsPage /></RoleRoute>} />
<Route path="my-financial" element={<RoleRoute roles={['doctor', 'secretary', 'clinic']}><MyFinancialPage /></RoleRoute>} />
+4 -14
View File
@@ -15,6 +15,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import React, { useCallback, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { z } from "zod";
import FeatureGate from "../components/ui/FeatureGate";
@@ -103,6 +104,7 @@ function calcFinalPrice(
function MyPatientsPageInner() {
const qc = useQueryClient();
const navigate = useNavigate();
const [selectedRecord, setSelectedRecord] = useState<PatientRecord | null>(
null,
);
@@ -828,13 +830,7 @@ function MyPatientsPageInner() {
</button>
<button
className="btn primary sm"
onClick={() => {
form.reset();
setSelectedServices([]);
setBaseInsuranceId("");
setSuppInsuranceId("");
setSessionModal(true);
}}
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
>
<PlusIcon style={{ width: 15 }} /> مراجعه جدید
</button>
@@ -1005,13 +1001,7 @@ function MyPatientsPageInner() {
<button
className="btn primary sm"
style={{ marginTop: 12 }}
onClick={() => {
form.reset();
setSelectedServices([]);
setBaseInsuranceId("");
setSuppInsuranceId("");
setSessionModal(true);
}}
onClick={() => navigate(`/admin/my-patients/${selectedRecord.uuid}/session/new`)}
>
<PlusIcon style={{ width: 14 }} /> ثبت اولین مراجعه
</button>
+252
View File
@@ -0,0 +1,252 @@
import { useMemo, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { ChevronRightIcon, PlusIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { PatientRecord, ServiceSection, ServiceItem } from '../types';
import { formatRial } from '../lib/utils';
import SearchableSelect from '../components/ui/SearchableSelect';
const schema = z.object({
visit_price_rials: z.coerce.number().min(0),
base_insurance_discount_percent: z.coerce.number().min(0).max(100),
supplementary_discount_percent: z.coerce.number().min(0).max(100),
insurance_base_id: z.coerce.number().optional(),
insurance_supplementary_id: z.coerce.number().optional(),
payment_method: z.enum(['cash', 'card', 'insurance', 'online', 'pending']),
notes: z.string().optional(),
});
type FormData = z.infer<typeof schema>;
interface PricingInsurance { insurance_id: number; insurance_name: string; type: string; patient_share_rials: number | null }
interface InsurancePricing { free_visit_price_rials: number; insurances: PricingInsurance[] }
const PAYMENT_LABELS: Record<string, string> = {
cash: 'نقدی', card: 'کارت', insurance: 'بیمه', online: 'آنلاین', pending: 'در انتظار',
};
function calcFinal(visit: number, base: number, supp: number, services: number) {
const afterBase = visit * (1 - base / 100);
const afterSupp = afterBase * (1 - supp / 100);
return Math.round(afterSupp) + services;
}
const sectionTitle: React.CSSProperties = { fontWeight: 700, fontSize: 13.5, color: 'var(--text-2)', margin: '0 0 12px' };
const fieldLabel: React.CSSProperties = { fontSize: 12.5, fontWeight: 600, marginBottom: 5, display: 'block' };
export default function NewSessionPage() {
const { recordUuid = '' } = useParams();
const nav = useNavigate();
const qc = useQueryClient();
const [selectedServices, setSelectedServices] = useState<{ uuid: string; name: string; price: number }[]>([]);
const [sectionUuid, setSectionUuid] = useState('');
const [itemUuid, setItemUuid] = useState('');
const [baseId, setBaseId] = useState('');
const [suppId, setSuppId] = useState('');
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { visit_price_rials: 0, base_insurance_discount_percent: 0, supplementary_discount_percent: 0, payment_method: 'cash' },
});
const recordQ = useQuery<ApiResponse<PatientRecord>>({
queryKey: ['patient-detail', recordUuid],
queryFn: () => api.get(`/api/v1/patient/${recordUuid}`),
enabled: !!recordUuid,
});
const record = (recordQ.data?.data as PatientRecord | undefined) ?? undefined;
const patientName = record?.user_name || record?.profile?.full_name || '—';
const { data: sectionsData } = useQuery<ApiResponse<ServiceSection[]>>({
queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections'),
});
const { data: itemsData } = useQuery<ApiResponse<ServiceItem[]>>({
queryKey: ['service-items-for-session', sectionUuid],
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
enabled: !!sectionUuid,
});
const { data: pricingData } = useQuery<ApiResponse<InsurancePricing>>({
queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'),
});
const pricing = (pricingData?.data as InsurancePricing | undefined) ?? undefined;
const freeVisit = pricing?.free_visit_price_rials ?? 0;
const baseOpts = (pricing?.insurances ?? []).filter(i => i.type === 'basic').map(i => ({ value: String(i.insurance_id), label: i.insurance_name }));
const suppOpts = (pricing?.insurances ?? []).filter(i => i.type === 'supplementary').map(i => ({ value: String(i.insurance_id), label: i.insurance_name }));
const sectionOptions = (sectionsData?.data ?? []).map(s => ({ value: s.uuid, label: s.name }));
const itemOptions = (itemsData?.data ?? []).filter(i => i.active).map(i => ({ value: i.uuid, label: `${i.name}${formatRial(i.price_rials)}` }));
const shareToPercent = (id: string): number => {
if (!id || freeVisit <= 0) return 0;
const ins = pricing?.insurances.find(i => String(i.insurance_id) === id);
if (!ins || ins.patient_share_rials == null) return 0;
return Math.round((Math.max(0, freeVisit - ins.patient_share_rials) / freeVisit) * 1000) / 10;
};
const applyBase = (id: string) => {
setBaseId(id);
form.setValue('insurance_base_id', id ? Number(id) : undefined);
if (id && freeVisit > 0) {
form.setValue('visit_price_rials', freeVisit);
form.setValue('base_insurance_discount_percent', shareToPercent(id));
}
};
const applySupp = (id: string) => {
setSuppId(id);
form.setValue('insurance_supplementary_id', id ? Number(id) : undefined);
if (id) form.setValue('supplementary_discount_percent', shareToPercent(id));
};
const addService = () => {
if (!itemUuid) return;
const found = itemsData?.data?.find(i => i.uuid === itemUuid);
if (!found || selectedServices.some(s => s.uuid === found.uuid)) return;
setSelectedServices(p => [...p, { uuid: found.uuid, name: found.name, price: found.price_rials }]);
setItemUuid('');
};
const visit = Number(form.watch('visit_price_rials')) || 0;
const base = Number(form.watch('base_insurance_discount_percent')) || 0;
const supp = Number(form.watch('supplementary_discount_percent')) || 0;
const servicesTotal = useMemo(() => selectedServices.reduce((s, x) => s + x.price, 0), [selectedServices]);
const afterBase = Math.round(visit * (1 - base / 100));
const afterSupp = Math.round(afterBase * (1 - supp / 100));
const finalPrice = calcFinal(visit, base, supp, servicesTotal);
const createMut = useMutation({
mutationFn: (body: object) => api.post(`/api/v1/patient/${recordUuid}/session`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['patient-sessions', recordUuid] });
toast.success('مراجعه ثبت شد');
nav(-1);
},
onError: (e: Error) => toast.error(e.message),
});
const submit = form.handleSubmit((d) => {
createMut.mutate({ ...d, services: selectedServices.map(s => ({ service_item_uuid: s.uuid })) });
});
const summaryRow = (label: string, val: number, strong = false) => (
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: strong ? 15 : 13, fontWeight: strong ? 700 : 400, color: strong ? 'var(--text)' : 'var(--text-3)' }}>
<span>{label}</span>
<span style={strong ? { color: 'var(--primary)' } : undefined}>{formatRial(val)}</span>
</div>
);
return (
<div className="fade-in" style={{ maxWidth: 1100, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<button className="btn ghost sm" onClick={() => nav(-1)}>
<ChevronRightIcon style={{ width: 15 }} /> بازگشت
</button>
<div>
<h1 className="section-title" style={{ margin: 0 }}>ثبت مراجعه جدید</h1>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>بیمار: {patientName}</div>
</div>
</div>
<form onSubmit={submit} style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 320px', gap: 16, alignItems: 'start' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="card" style={{ padding: 20 }}>
<h3 style={sectionTitle}>بیمه و مبلغ ویزیت</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div>
<label style={fieldLabel}>بیمه پایه</label>
<select className="input" value={baseId} onChange={e => applyBase(e.target.value)}>
<option value="">بدون بیمه پایه</option>
{baseOpts.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div>
<label style={fieldLabel}>بیمه تکمیلی</label>
<select className="input" value={suppId} onChange={e => applySupp(e.target.value)}>
<option value="">بدون بیمه تکمیلی</option>
{suppOpts.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div>
<label style={fieldLabel}>قیمت ویزیت (تومان)</label>
<input className="input" type="number" min={0} dir="ltr" {...form.register('visit_price_rials')} />
</div>
<div>
<label style={fieldLabel}>تخفیف بیمه پایه (%)</label>
<input className="input" type="number" min={0} max={100} dir="ltr" {...form.register('base_insurance_discount_percent')} />
</div>
<div>
<label style={fieldLabel}>تخفیف تکمیلی (%)</label>
<input className="input" type="number" min={0} max={100} dir="ltr" {...form.register('supplementary_discount_percent')} />
</div>
</div>
</div>
<div className="card" style={{ padding: 20 }}>
<h3 style={sectionTitle}>خدمات</h3>
<div style={{ display: 'flex', gap: 8, marginBottom: selectedServices.length ? 12 : 0 }}>
<div style={{ flex: 1 }}>
<SearchableSelect options={sectionOptions} value={sectionUuid} onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }} placeholder="انتخاب بخش..." />
</div>
<div style={{ flex: 1 }}>
<SearchableSelect options={itemOptions} value={itemUuid} onChange={v => setItemUuid(v ? String(v) : '')} placeholder="انتخاب سرویس..." isDisabled={!sectionUuid} />
</div>
<button type="button" className="btn primary sm" onClick={addService} disabled={!itemUuid}>
<PlusIcon style={{ width: 14 }} />
</button>
</div>
{selectedServices.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{selectedServices.map(svc => (
<span key={svc.uuid} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'var(--primary-soft)', borderRadius: 20, padding: '4px 10px', fontSize: 12.5 }}>
<span style={{ fontWeight: 500 }}>{svc.name}</span>
<span style={{ color: 'var(--primary)', fontWeight: 600 }}>{formatRial(svc.price)}</span>
<button type="button" style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--danger)', padding: 0, display: 'flex' }}
onClick={() => setSelectedServices(p => p.filter(s => s.uuid !== svc.uuid))}>
<XMarkIcon style={{ width: 14 }} />
</button>
</span>
))}
</div>
)}
</div>
<div className="card" style={{ padding: 20 }}>
<h3 style={sectionTitle}>پرداخت و یادداشت</h3>
<div style={{ marginBottom: 12 }}>
<label style={fieldLabel}>روش پرداخت</label>
<select className="input" {...form.register('payment_method')}>
{Object.entries(PAYMENT_LABELS).map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
<div>
<label style={fieldLabel}>یادداشت</label>
<textarea className="input" rows={3} dir="rtl" placeholder="یادداشت پزشک..." {...form.register('notes')} />
</div>
</div>
</div>
<div className="card" style={{ padding: 20, position: 'sticky', top: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
<h3 style={sectionTitle}>خلاصه</h3>
{summaryRow('ویزیت آزاد', visit)}
{base > 0 && summaryRow('پس از بیمه پایه', afterBase)}
{supp > 0 && summaryRow('پس از بیمه تکمیلی', afterSupp)}
{servicesTotal > 0 && summaryRow('جمع خدمات', servicesTotal)}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 8, marginTop: 2 }}>
{summaryRow('مبلغ نهایی (سهم بیمار)', finalPrice, true)}
</div>
<button type="submit" className="btn primary block" style={{ marginTop: 8 }} disabled={createMut.isPending}>
{createMut.isPending ? 'در حال ذخیره...' : 'ثبت مراجعه'}
</button>
<button type="button" className="btn ghost block" onClick={() => nav(-1)}>انصراف</button>
</div>
</form>
</div>
);
}