feat(admin): session edit form, payment edit/delete UI, and audit history modal

- SessionServiceCard menu gains «ویرایش» and «تاریخچه تغییرات» items.
- SessionAuditModal shows the session's change history (field, op badge,
  old->new, actor, time) from /session/{uuid}/audit-log.
- PaymentStep payment rows get edit (modal) + delete (confirm) controls for
  non-wallet payments, calling the new PATCH/DELETE payment endpoints.
- CreateStep accepts an editSession prop (prefill + PATCH); EditSessionPage
  reuses it at /patients/:recordUuid/session/:sessionUuid/edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-17 15:23:02 +03:30
co-authored by Claude Fable 5
parent 48579dc06f
commit f2ea2ff262
8 changed files with 287 additions and 12 deletions
+23 -2
View File
@@ -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' }}>
مشاهده فاکتور
</button>
{onEdit && (
<button type="button" onClick={() => { setMenuOpen(false); onEdit(session); }}
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' }}>
ویرایش
</button>
)}
{onViewAudit && (
<button type="button" onClick={() => { setMenuOpen(false); onViewAudit(session); }}
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' }}>
تاریخچه تغییرات
</button>
)}
{onArchive && (
<button type="button" onClick={() => { setMenuOpen(false); onArchive(session, !session.archived); }}
style={{ display: 'block', width: '100%', textAlign: 'right', padding: '8px 10px', fontSize: 13, background: 'transparent', border: 'none', borderRadius: 6, cursor: 'pointer', color: session.archived ? 'var(--text)' : '#EF4444', fontFamily: 'inherit' }}>
+34 -7
View File
@@ -55,6 +55,8 @@ interface Props {
profile?: PatientProfile | null;
onCreated: (sessionUuid: string) => void;
onCancel: () => void;
/** وقتی داده شود فرم در حالت ویرایش است و با PATCH به همان مراجعه ارسال می‌کند. */
editSession?: import('../SessionServiceCard').SessionCardData;
}
/**
@@ -62,8 +64,9 @@ interface Props {
* تاریخ/ساعت پذیرش، بخش/سرویس/پرسنل، کالای مصرفی با شمارنده، پکیج، و بلوک بیمه‌ی
* موجودِ NewSessionPage (نمایش شرطی: سرویسِ تحت پوشش بیمه یا بیمه در پروفایل بیمار).
*/
export default function CreateStep({ recordUuid, profile, onCreated, onCancel }: Props) {
export default function CreateStep({ recordUuid, profile, onCreated, onCancel, editSession }: Props) {
const userName = useAuthStore((s) => s.userName);
const isEdit = !!editSession;
// ── state گام ایجاد ──────────────────────────────────────────────────────
const [dateISO, setDateISO] = useState(todayISO());
@@ -111,9 +114,31 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel }:
const requireVisit = (pricingData as any)?.data?.require_visit_price ?? false;
useEffect(() => {
if (freeVisit > 0 && (!visitPrice || visitPrice === '0')) setVisitPrice(String(rialToToman(freeVisit)));
if (!isEdit && freeVisit > 0 && (!visitPrice || visitPrice === '0')) setVisitPrice(String(rialToToman(freeVisit)));
}, [freeVisit]); // eslint-disable-line react-hooks/exhaustive-deps
// پیش‌پرکردن فرم در حالت ویرایش (یک‌بار).
const [prefilled, setPrefilled] = useState(false);
useEffect(() => {
if (!editSession || prefilled) return;
setVisitPrice(String(rialToToman(editSession.visit_price_rials ?? 0)));
if (editSession.session_at) {
const d = new Date(editSession.session_at * 1000);
setDateISO(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
setTime(`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`);
}
setNotes((editSession as any).notes ?? '');
if (editSession.insurance_base_id) { setBaseId(String(editSession.insurance_base_id)); setBasePercent(String(editSession.base_insurance_discount_percent ?? 0)); }
if (editSession.insurance_supplementary_id) { setSuppId(String(editSession.insurance_supplementary_id)); setSuppPercent(String(editSession.supplementary_discount_percent ?? 0)); }
setSelectedServices((editSession.services ?? []).map((s) => ({
uuid: s.service_item_uuid ?? '', name: s.service_name || s.name || '', price: s.price_rials ?? 0, qty: s.quantity ?? 1, insured: false,
})).filter((s) => s.uuid));
setSelectedConsumables((editSession.consumables ?? []).map((c) => ({
uuid: c.inventory_item_uuid ?? '', name: c.item_name ?? '', price: c.price_rials ?? 0, qty: c.quantity ?? 1,
})).filter((c) => c.uuid));
setPrefilled(true);
}, [editSession, prefilled]);
const contracts = (contractsData as any)?.data?.data as Contract[] | undefined ?? [];
const baseOpts = contracts.filter(c => c.insurance_kind === 'basic').map(c => ({ value: String(c.insurance_id), label: c.insurance_name ?? `#${c.insurance_id}` }));
const suppOpts = contracts.filter(c => c.insurance_kind === 'supplementary').map(c => ({ value: String(c.insurance_id), label: c.insurance_name ?? `#${c.insurance_id}` }));
@@ -216,10 +241,12 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel }:
// ── ثبت ──────────────────────────────────────────────────────────────────
const createMut = useMutation({
mutationFn: (body: object) => api.post(`/api/v1/patient/${recordUuid}/session`, body),
mutationFn: (body: object) => isEdit
? api.patch(`/api/v1/session/${editSession!.uuid}`, body)
: api.post(`/api/v1/patient/${recordUuid}/session`, body),
onSuccess: (res: any) => {
toast.success('مراجعه ثبت شد');
onCreated(res?.data?.uuid as string);
toast.success(isEdit ? 'مراجعه ویرایش شد' : 'مراجعه ثبت شد');
onCreated((isEdit ? editSession!.uuid : res?.data?.uuid) as string);
},
onError: (e: Error) => toast.error(e.message),
});
@@ -236,7 +263,7 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel }:
supplementary_discount_percent: showInsurance ? supp : 0,
...(showInsurance && baseId ? { insurance_base_id: Number(baseId) } : {}),
...(showInsurance && suppId ? { insurance_supplementary_id: Number(suppId) } : {}),
payment_method: 'pending',
...(isEdit ? {} : { payment_method: 'pending' }),
...(notes ? { notes } : {}),
session_at: toSessionAt(dateISO, time),
...(packageUuid ? { inventory_package_uuid: packageUuid } : {}),
@@ -436,7 +463,7 @@ export default function CreateStep({ recordUuid, profile, onCreated, onCancel }:
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginTop: 16, width: '100%', maxWidth: 340, margin: '16px auto 0' }}>
<button type="button" style={{ ...ghostBtn, width: 164 }} onClick={onCancel}>انصراف</button>
<button type="button" style={{ ...primaryBtn, width: 164 }} onClick={submit} disabled={createMut.isPending}>
{createMut.isPending ? 'در حال ذخیره...' : 'ایجاد سرویس'}
{createMut.isPending ? 'در حال ذخیره...' : (isEdit ? 'ذخیره تغییرات' : 'ایجاد سرویس')}
</button>
</div>
</div>
@@ -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<string | null>(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<ApiResponse<DiscountSuggestion[]>>(`/api/v1/session/${sessionUuid}/discount-suggestions`),
@@ -253,12 +271,24 @@ export default function PaymentStep({ recordUuid, session, walletBalance, onCont
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{payments.map((p) => (
<div key={p.uuid} className="dark:border-[#404040]" style={{ padding: '6px 0', borderBottom: '1px solid #e0e0e0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 32 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="dark:bg-[#6A6AD9]" style={{ width: 8, height: 8, borderRadius: '50%', background: '#636bd4' }} />
<span className="dark:text-[#D7D8ED]" style={{ fontSize: 14, color: '#111827' }}>{METHOD_LABELS[p.method] ?? p.method}</span>
</span>
<span className="dark:text-[#D7D8ED]" style={{ flex: 1, textAlign: 'left', fontSize: 14, color: '#111827' }}>مبلغ : {formatRial(p.amount_rials)}</span>
{p.method !== 'wallet' && (
<span style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button type="button" aria-label="ویرایش پرداخت" onClick={() => setEditPay({ uuid: p.uuid, method: p.method, amountToman: rialToToman(p.amount_rials) })}
style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: '#5559ce', display: 'flex' }}>
<PencilIcon style={{ width: 16 }} />
</button>
<button type="button" aria-label="حذف پرداخت" onClick={() => setDelPayUuid(p.uuid)}
style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: '#EF4444', display: 'flex' }}>
<TrashIcon style={{ width: 16 }} />
</button>
</span>
)}
</div>
<div className="dark:text-[#A1A1A1]" style={{ display: 'flex', gap: 12, marginTop: 4, fontSize: 12, color: '#9CA3AF', flexWrap: 'wrap' }}>
{(p.paid_at ?? p.created_at) && <span>{formatDateTime(p.paid_at ?? p.created_at!)}</span>}
@@ -281,6 +311,44 @@ export default function PaymentStep({ recordUuid, session, walletBalance, onCont
<button type="button" style={{ ...primaryBtn, flex: 1 }} onClick={onContinue}>ثبت و ادامه</button>
</div>
</div>
{editPay && (
<Modal open title="ویرایش پرداخت" size="sm" onClose={() => setEditPay(null)}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={fieldLabel}>روش پرداخت</label>
<SearchableSelect
options={METHODS.filter((m) => 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}
/>
</div>
<div>
<label style={fieldLabel}>مبلغ (تومان)</label>
<PriceInput latin className="cp-input" style={{ width: '100%' }} value={editPay.amountToman} onChange={(v) => setEditPay((s) => s && { ...s, amountToman: v })} />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn ghost sm" onClick={() => setEditPay(null)}>انصراف</button>
<button className="btn primary sm" disabled={editPayMut.isPending || editPay.amountToman <= 0}
onClick={() => editPay && editPayMut.mutate({ uuid: editPay.uuid, body: { method: editPay.method, amount_rials: tomanToRial(editPay.amountToman) } })}>
{editPayMut.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</div>
</div>
</Modal>
)}
<ConfirmDialog
open={!!delPayUuid}
title="حذف پرداخت"
message="آیا از حذف این پرداخت مطمئن هستید؟ این عملیات در تاریخچه ثبت می‌شود."
confirmLabel="حذف"
danger
loading={delPayMut.isPending}
onConfirm={() => delPayUuid && delPayMut.mutate(delPayUuid)}
onCancel={() => setDelPayUuid(null)}
/>
</>
);
}
@@ -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<string, string> = {
visit_price_rials: 'قیمت ویزیت',
services: 'سرویس‌ها',
consumables: 'کالاهای مصرفی',
services_total_rials: 'مجموع سرویس‌ها',
final_price_rials: 'مبلغ نهایی',
payment: 'پرداخت',
discount: 'تخفیف',
};
const OP_LABELS: Record<string, { label: string; color: string }> = {
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<ApiResponse<SessionAuditEntry[]>>(`/api/v1/session/${sessionUuid}/audit-log`),
enabled: !!sessionUuid,
});
const rows: SessionAuditEntry[] = (data?.data as any) ?? [];
if (!sessionUuid) return null;
return (
<Modal open title="تاریخچه تغییرات" size="lg" onClose={onClose}>
{isLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : rows.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>تغییری ثبت نشده است.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{rows.map((r, i) => {
const op = OP_LABELS[r.operation] ?? { label: r.operation, color: 'var(--text-3)' };
return (
<div key={i} className="card" style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="badge" style={{ fontSize: 11, color: '#fff', background: op.color, borderRadius: 4, padding: '2px 8px' }}>{op.label}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)' }}>{FIELD_LABELS[r.field] ?? r.field}</span>
</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>{formatDateTime(r.created_at)}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, flexWrap: 'wrap' }}>
<span style={{ color: 'var(--text-3)' }}>{fmtValue(r.field, r.old_value)}</span>
<span style={{ color: 'var(--text-3)' }}></span>
<span style={{ color: 'var(--text)', fontWeight: 600 }}>{fmtValue(r.field, r.new_value)}</span>
</div>
<div style={{ display: 'flex', gap: 12, fontSize: 12, color: 'var(--text-3)' }}>
{r.actor_name && <span>توسط: {r.actor_name}</span>}
{r.note && <span>{r.note}</span>}
</div>
</div>
);
})}
</div>
)}
</Modal>
);
}