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:
@@ -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' }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user