diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index c5456448..82520ec2 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -42,6 +42,7 @@ import MyPatientsPage from './pages/MyPatientsPage'; import MyPaymentsPage from './pages/MyPaymentsPage'; import MyPaymentDetailPage from './pages/MyPaymentDetailPage'; import NewSessionPage from './pages/NewSessionPage'; +import EditSessionPage from './pages/EditSessionPage'; import SessionPaymentPage from './pages/SessionPaymentPage'; import InsurancePricingPage from './pages/InsurancePricingPage'; import ClaimsPage from './pages/ClaimsPage'; @@ -215,6 +216,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/SessionServiceCard.tsx b/assets/admin/components/SessionServiceCard.tsx index 46cfd0ff..ee12d0f8 100644 --- a/assets/admin/components/SessionServiceCard.tsx +++ b/assets/admin/components/SessionServiceCard.tsx @@ -13,10 +13,15 @@ export interface SessionPaymentEntry { export interface SessionCardData { uuid: string; - services?: Array<{ service_name?: string; name?: string; line_total_rials?: number; price_rials?: number; quantity?: number }>; + services?: Array<{ service_item_uuid?: string; service_name?: string; name?: string; line_total_rials?: number; price_rials?: number; quantity?: number }>; + consumables?: Array<{ inventory_item_uuid?: string; item_name?: string; price_rials?: number; quantity?: number }>; visit_price_rials?: number; services_total_rials?: number; session_at?: number | null; + insurance_base_id?: number | null; + insurance_supplementary_id?: number | null; + base_insurance_discount_percent?: number; + supplementary_discount_percent?: number; doctor_name?: string | null; final_price_rials?: number; patient_debt_rials?: number; @@ -50,13 +55,17 @@ function Row({ label, value, valueStyle }: { label: string; value: string; value * pixel-for-pixel from tauri files/services/ServiceCard. Paid cards show * «مشاهده فاکتور», unpaid ones «تکمیل پرداخت». */ -export default function SessionServiceCard({ session, onSettle, onViewInvoice, onArchive, settling, issuing }: { +export default function SessionServiceCard({ session, onSettle, onViewInvoice, onArchive, onEdit, onViewAudit, settling, issuing }: { session: SessionCardData; onSettle: (uuid: string) => void; /** کل session پاس می‌شود؛ اگر invoice_uuid نداشت، caller فاکتور را می‌سازد. */ onViewInvoice: (session: SessionCardData) => void; /** آرشیو/خروج از آرشیو مراجعه. */ onArchive?: (session: SessionCardData, archived: boolean) => void; + /** ویرایش سرویس‌های مراجعه. */ + onEdit?: (session: SessionCardData) => void; + /** نمایش تاریخچه‌ی تغییرات (Audit Log). */ + onViewAudit?: (session: SessionCardData) => void; settling?: boolean; /** true وقتی صدور فاکتور همین لحظه در جریان است (دکمه قفل می‌شود). */ issuing?: boolean; @@ -104,6 +113,18 @@ export default function SessionServiceCard({ session, onSettle, onViewInvoice, o style={{ display: 'block', width: '100%', textAlign: 'right', padding: '8px 10px', fontSize: 13, background: 'transparent', border: 'none', borderRadius: 6, cursor: 'pointer', color: 'var(--text)', fontFamily: 'inherit' }}> مشاهده فاکتور + {onEdit && ( + + )} + {onViewAudit && ( + + )} {onArchive && ( diff --git a/assets/admin/components/session/PaymentStep.tsx b/assets/admin/components/session/PaymentStep.tsx index b51b65c7..8d97d3e5 100644 --- a/assets/admin/components/session/PaymentStep.tsx +++ b/assets/admin/components/session/PaymentStep.tsx @@ -4,12 +4,15 @@ import { ChevronDownIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../../lib/api'; import type { ApiResponse } from '../../lib/api'; -import { formatRial, formatDateTime, tomanToRial } from '../../lib/utils'; +import { PencilIcon, TrashIcon } from '@heroicons/react/24/outline'; +import { formatRial, formatDateTime, tomanToRial, rialToToman } from '../../lib/utils'; import type { DiscountSuggestion } from '../../types'; import type { SessionCardData } from '../SessionServiceCard'; import SearchableSelect from '../ui/SearchableSelect'; import PersianDateInput from '../ui/PersianDateInput'; import PriceInput from '../ui/PriceInput'; +import Modal from '../ui/Modal'; +import ConfirmDialog from '../ui/ConfirmDialog'; import { Step2PaymentCard, FilesServiceBalanceWallet, TrashRed } from '../icons/FilesServiceIcons'; /** روش‌های پرداخت — همان چهار گزینه‌ی آکاردئون tauri Step2Payment. */ @@ -72,6 +75,21 @@ export default function PaymentStep({ recordUuid, session, walletBalance, onCont onError: (e: Error) => toast.error(e.message), }); + // ویرایش/حذف پرداخت + const [editPay, setEditPay] = useState<{ uuid: string; method: string; amountToman: number } | null>(null); + const [delPayUuid, setDelPayUuid] = useState(null); + + const editPayMut = useMutation({ + mutationFn: ({ uuid, body }: { uuid: string; body: object }) => api.patch(`/api/v1/session/${sessionUuid}/payments/${uuid}`, body), + onSuccess: () => { invalidate(); setEditPay(null); toast.success('پرداخت ویرایش شد'); }, + onError: (e: Error) => toast.error(e.message), + }); + const delPayMut = useMutation({ + mutationFn: (uuid: string) => api.delete(`/api/v1/session/${sessionUuid}/payments/${uuid}`), + onSuccess: () => { invalidate(); setDelPayUuid(null); toast.success('پرداخت حذف شد'); }, + onError: (e: Error) => toast.error(e.message), + }); + const suggestionsQ = useQuery({ queryKey: ['discount-suggestions', sessionUuid], queryFn: () => api.get>(`/api/v1/session/${sessionUuid}/discount-suggestions`), @@ -253,12 +271,24 @@ export default function PaymentStep({ recordUuid, session, walletBalance, onCont
{payments.map((p) => (
-
+
{METHOD_LABELS[p.method] ?? p.method} مبلغ : {formatRial(p.amount_rials)} + {p.method !== 'wallet' && ( + + + + + )}
{(p.paid_at ?? p.created_at) && {formatDateTime(p.paid_at ?? p.created_at!)}} @@ -281,6 +311,44 @@ export default function PaymentStep({ recordUuid, session, walletBalance, onCont
+ + {editPay && ( + setEditPay(null)}> +
+
+ + m.key !== 'wallet').map((m) => ({ value: m.key, label: m.label }))} + value={editPay.method} + onChange={(v) => setEditPay((s) => s && { ...s, method: v ? String(v) : s.method })} + height={40} + /> +
+
+ + setEditPay((s) => s && { ...s, amountToman: v })} /> +
+
+ + +
+
+
+ )} + + delPayUuid && delPayMut.mutate(delPayUuid)} + onCancel={() => setDelPayUuid(null)} + /> ); } diff --git a/assets/admin/components/session/SessionAuditModal.tsx b/assets/admin/components/session/SessionAuditModal.tsx new file mode 100644 index 00000000..627cdb3a --- /dev/null +++ b/assets/admin/components/session/SessionAuditModal.tsx @@ -0,0 +1,77 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../../lib/api'; +import type { ApiResponse } from '../../lib/api'; +import type { SessionAuditEntry } from '../../types'; +import { formatDateTime, formatRial } from '../../lib/utils'; +import Modal from '../ui/Modal'; + +const FIELD_LABELS: Record = { + visit_price_rials: 'قیمت ویزیت', + services: 'سرویس‌ها', + consumables: 'کالاهای مصرفی', + services_total_rials: 'مجموع سرویس‌ها', + final_price_rials: 'مبلغ نهایی', + payment: 'پرداخت', + discount: 'تخفیف', +}; + +const OP_LABELS: Record = { + create: { label: 'ایجاد', color: '#3c9a4f' }, + update: { label: 'ویرایش', color: '#5559ce' }, + delete: { label: 'حذف', color: '#EF4444' }, +}; + +// مقادیر پولی (ریال) را با فرمت تومان نشان بده؛ بقیه را خام. +const RIAL_FIELDS = new Set(['visit_price_rials', 'services_total_rials', 'final_price_rials', 'payment', 'discount']); +const fmtValue = (field: string, v: string | null): string => { + if (v === null || v === '') return '—'; + if (RIAL_FIELDS.has(field) && /^\d+$/.test(v)) return formatRial(Number(v)); + return v; +}; + +export default function SessionAuditModal({ sessionUuid, onClose }: { sessionUuid: string | null; onClose: () => void }) { + const { data, isLoading } = useQuery({ + queryKey: ['session-audit-log', sessionUuid], + queryFn: () => api.get>(`/api/v1/session/${sessionUuid}/audit-log`), + enabled: !!sessionUuid, + }); + const rows: SessionAuditEntry[] = (data?.data as any) ?? []; + + if (!sessionUuid) return null; + + return ( + + {isLoading ? ( +
در حال بارگذاری...
+ ) : rows.length === 0 ? ( +
تغییری ثبت نشده است.
+ ) : ( +
+ {rows.map((r, i) => { + const op = OP_LABELS[r.operation] ?? { label: r.operation, color: 'var(--text-3)' }; + return ( +
+
+ + {op.label} + {FIELD_LABELS[r.field] ?? r.field} + + {formatDateTime(r.created_at)} +
+
+ {fmtValue(r.field, r.old_value)} + + {fmtValue(r.field, r.new_value)} +
+
+ {r.actor_name && توسط: {r.actor_name}} + {r.note && {r.note}} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/assets/admin/pages/EditSessionPage.tsx b/assets/admin/pages/EditSessionPage.tsx new file mode 100644 index 00000000..fb901c97 --- /dev/null +++ b/assets/admin/pages/EditSessionPage.tsx @@ -0,0 +1,66 @@ +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; +import type { PatientRecord } from '../types'; +import type { SessionCardData } from '../components/SessionServiceCard'; +import CreateStep from '../components/session/CreateStep'; +import { ArrowLeftPH, ArrowLeftD, CloseModalD } from '../components/icons/FilesServiceIcons'; + +/** ویرایش مراجعه‌ی ثبت‌شده — همان فرم «ایجاد سرویس» در حالت ویرایش (PATCH). */ +export default function EditSessionPage() { + const { recordUuid = '', sessionUuid = '' } = useParams(); + const nav = useNavigate(); + + const recordQ = useQuery>({ + queryKey: ['patient-detail', recordUuid], + queryFn: () => api.get(`/api/v1/patient/${recordUuid}`), + enabled: !!recordUuid, + }); + const record = recordQ.data?.data as PatientRecord | undefined; + const patientName = record?.user_name || record?.profile?.full_name || '—'; + + const sessionsQ = useQuery>({ + queryKey: ['patient-sessions', recordUuid, 'all'], + queryFn: () => api.get(`/api/v1/patient/${recordUuid}/sessions?filter=all`), + enabled: !!recordUuid, + }); + const session = (sessionsQ.data?.data ?? []).find((s) => s.uuid === sessionUuid); + + const finish = () => nav(`/admin/patients/${recordUuid}?tab=services`); + + return ( +
+
+
+
nav(-1)} className="bg-white dark:bg-[#222433]" style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 8px', borderRadius: 12, cursor: 'pointer' }}> + + بازگشت +
+ + پرونده + + {patientName} +
+
+ +
+
+
+

ویرایش مراجعه

+ +
+ + {session ? ( + nav(-1)} /> + ) : ( +
در حال بارگذاری...
+ )} +
+
+
+ ); +} diff --git a/assets/admin/pages/PatientDetailPage.tsx b/assets/admin/pages/PatientDetailPage.tsx index df45b912..f96c43d1 100644 --- a/assets/admin/pages/PatientDetailPage.tsx +++ b/assets/admin/pages/PatientDetailPage.tsx @@ -26,6 +26,7 @@ import { useIssueInvoice } from '../hooks/useIssueInvoice'; import AppointmentTurnCard, { type AppointmentCardData } from '../components/AppointmentTurnCard'; import SessionPaymentAccordion, { type SessionPaymentData } from '../components/SessionPaymentAccordion'; import InvoiceSummaryModal from '../components/InvoiceSummaryModal'; +import SessionAuditModal from '../components/session/SessionAuditModal'; import SearchableSelect from '../components/ui/SearchableSelect'; import { TurnsFilter, AddTurn } from '../components/icons/FilesToolbarIcons'; import { @@ -81,6 +82,7 @@ export default function PatientDetailPage() { const qc = useQueryClient(); const nav = useNavigate(); const [invoiceUuid, setInvoiceUuid] = useState(null); + const [auditSessionUuid, setAuditSessionUuid] = useState(null); const [sessionFilter, setSessionFilter] = useState<'active' | 'all' | 'archived'>('active'); // «مشاهده فاکتور»: اگر session هنوز فاکتور ندارد، اول صادر می‌شود (idempotent). @@ -241,7 +243,7 @@ export default function PatientDetailPage() { ) : (
{sessions.map((s) => ( - nav(`/admin/patients/${uuid}/session/${u}/pay`)} onViewInvoice={viewInvoice} onArchive={(sess, archived) => archiveMut.mutate({ sessionUuid: sess.uuid, archived })} issuing={issueInvoiceMut.isPending} /> + nav(`/admin/patients/${uuid}/session/${u}/pay`)} onViewInvoice={viewInvoice} onEdit={(sess) => nav(`/admin/patients/${uuid}/session/${sess.uuid}/edit`)} onViewAudit={(sess) => setAuditSessionUuid(sess.uuid)} onArchive={(sess, archived) => archiveMut.mutate({ sessionUuid: sess.uuid, archived })} issuing={issueInvoiceMut.isPending} /> ))}
)} @@ -265,6 +267,7 @@ export default function PatientDetailPage() { )} setInvoiceUuid(null)} /> + setAuditSessionUuid(null)} />
); } diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index f4f6b01f..f930404b 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -153,6 +153,16 @@ export interface DiscountRule { updated_at: number; } +export interface SessionAuditEntry { + field: string; + operation: 'create' | 'update' | 'delete'; + old_value: string | null; + new_value: string | null; + actor_name: string | null; + note: string | null; + created_at: number; +} + export interface DiscountSuggestion { rule_uuid: string; rule_name: string;