Five places where the data existed and the screen did not use it. Booking a whole course had no button because it needs a doctor and the course does not carry one — each session can be with a different doctor. The page now asks for the doctor the same way the resource booking page does, and the button explains that it is all-or-nothing before it is pressed. A course whose package does not cover the remaining sessions is still valid — the rest is simply charged normally — but nobody was told. The course response carries package_balance and the shortfall, and the page warns. Before session six, not during it. The credit ledger already returned who recorded a row and which appointment it belonged to, and showed neither. An adjustable ledger without the name of the person who adjusted it is half an audit trail. Version history printed a JSON blob of each version's effects, which does not answer the question anyone actually has: what changed? It now diffs each version against the previous one, field by field, and says so plainly when a version changed nothing meaningful. A resource with no calendar showed "—" for utilization. Null means undefined, not zero, and the next step is always the same: set up the calendar. It is a link now. The report range also accepts a custom from/to, kept in the URL like the rest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
198 lines
7.1 KiB
TypeScript
198 lines
7.1 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { Link, useParams } from 'react-router-dom';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
|
import Modal from '../components/ui/Modal';
|
|
import { formatDate, formatRial } from '../lib/utils';
|
|
import { usePermissions } from '../hooks/usePermissions';
|
|
import { useCreditLedger } from '../hooks/usePackages';
|
|
import type { CreditLedgerRow } from '../types';
|
|
|
|
const KIND_LABELS: Record<CreditLedgerRow['kind'], string> = {
|
|
purchase: 'خرید',
|
|
consume: 'مصرف در نوبت',
|
|
refund: 'بازگشت با لغو',
|
|
adjustment: 'اصلاح دستی',
|
|
expiry: 'انقضا',
|
|
};
|
|
|
|
/**
|
|
* دفتر اعتبار یک پکیج خریداریشده.
|
|
*
|
|
* ستون «مانده» در هر ردیف نشان میدهد عدد نهایی از کجا آمده — همان چیزی که یک
|
|
* شمارندهٔ ذخیرهشده هرگز نمیتواند نشان دهد.
|
|
*/
|
|
export default function PatientPackageLedgerPage() {
|
|
const { patientPackageUuid } = useParams<{ patientPackageUuid: string }>();
|
|
const { ledger, loading, adjust, expire } = useCreditLedger(patientPackageUuid);
|
|
const { can } = usePermissions();
|
|
const canCorrect = can('appointment_settings', 'update');
|
|
|
|
const [adjusting, setAdjusting] = useState(false);
|
|
const [delta, setDelta] = useState(1);
|
|
const [reason, setReason] = useState('');
|
|
|
|
const columns: Column<CreditLedgerRow>[] = [
|
|
{
|
|
key: 'created_at',
|
|
header: 'تاریخ',
|
|
render: (r) => <span style={{ fontSize: 13 }}>{formatDate(r.created_at)}</span>,
|
|
},
|
|
{
|
|
key: 'kind',
|
|
header: 'نوع',
|
|
render: (r) => <span style={{ fontSize: 13 }}>{KIND_LABELS[r.kind]}</span>,
|
|
},
|
|
{
|
|
key: 'delta',
|
|
header: 'تغییر',
|
|
render: (r) => (
|
|
<span style={{ fontSize: 13, fontWeight: 600, color: r.delta > 0 ? 'var(--success)' : 'var(--danger)' }}>
|
|
{r.delta > 0 ? `+${r.delta}` : r.delta}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'running_balance',
|
|
header: 'مانده',
|
|
render: (r) => <span style={{ fontSize: 13 }}>{r.running_balance}</span>,
|
|
},
|
|
{
|
|
key: 'reason',
|
|
header: 'دلیل',
|
|
render: (r) => (
|
|
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
|
|
{r.reason ?? r.service_name ?? '—'}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
// «چه کسی» و «کدام نوبت» از قبل در پاسخ بودند و نمایش داده نمیشدند. دفترِ
|
|
// اصلاحپذیر بدون نامِ اصلاحکننده، نصف حسابرسی است.
|
|
key: 'created_by',
|
|
header: 'ثبتکننده',
|
|
render: (r) => (
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>{r.created_by ?? 'سیستم'}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'appointment_uuid',
|
|
header: 'نوبت',
|
|
render: (r) =>
|
|
r.appointment_uuid ? (
|
|
<Link
|
|
to={`/admin/appointments/${r.appointment_uuid}`}
|
|
style={{ fontSize: 12, color: 'var(--primary)' }}
|
|
>
|
|
مشاهدهٔ نوبت
|
|
</Link>
|
|
) : (
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>—</span>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
<PageHeader
|
|
title={ledger ? `دفتر اعتبار: ${ledger.package.package_name}` : 'دفتر اعتبار'}
|
|
description="هر تغییر اعتبار یک ردیف است؛ ردیفها حذف یا ویرایش نمیشوند."
|
|
backTo="/admin/patients"
|
|
/>
|
|
|
|
{ledger && (
|
|
<div className="card" style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
|
<span style={{ fontSize: 14 }}>
|
|
مانده: <strong>{ledger.package.balance}</strong> از {ledger.package.session_count} جلسه
|
|
</span>
|
|
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
|
|
پرداختی: {formatRial(ledger.package.price_paid_rials)}
|
|
</span>
|
|
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
|
خرید: {formatDate(ledger.package.purchased_at)}
|
|
{ledger.package.valid_to !== null && ` · اعتبار تا ${formatDate(ledger.package.valid_to)}`}
|
|
</span>
|
|
{ledger.package.expired && <span className="badge red"><span className="bdot" />منقضی</span>}
|
|
|
|
{canCorrect && (
|
|
<div style={{ display: 'flex', gap: 8, marginRight: 'auto' }}>
|
|
<button type="button" className="btn secondary sm" onClick={() => setAdjusting(true)}>
|
|
اصلاح دستی
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
disabled={ledger.package.balance <= 0 || expire.isPending}
|
|
onClick={() => expire.mutate('ابطال دستی توسط کلینیک')}
|
|
>
|
|
ابطال مانده
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<DataTable
|
|
columns={columns}
|
|
data={ledger?.rows ?? []}
|
|
loading={loading}
|
|
emptyMessage="این پکیج هنوز تراکنشی ندارد"
|
|
/>
|
|
</div>
|
|
|
|
<Modal
|
|
open={adjusting}
|
|
title="اصلاح دستی اعتبار"
|
|
onClose={() => setAdjusting(false)}
|
|
footer={
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="btn primary"
|
|
disabled={delta === 0 || reason.trim() === '' || adjust.isPending}
|
|
onClick={async () => {
|
|
await adjust.mutateAsync({ delta, reason: reason.trim() });
|
|
setAdjusting(false);
|
|
setReason('');
|
|
}}
|
|
>
|
|
ثبت اصلاح
|
|
</button>
|
|
<button type="button" className="btn secondary" onClick={() => setAdjusting(false)}>
|
|
انصراف
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
<div className="field">
|
|
<label htmlFor="adj-delta">تغییر (مثبت یا منفی)</label>
|
|
<input
|
|
id="adj-delta"
|
|
className="input"
|
|
type="number"
|
|
value={delta}
|
|
onChange={(e) => setDelta(Number(e.target.value))}
|
|
/>
|
|
</div>
|
|
|
|
<div className="field">
|
|
<label htmlFor="adj-reason">دلیل</label>
|
|
<input
|
|
id="adj-reason"
|
|
className="input"
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
placeholder="مثلاً: جبران جلسهٔ لغوشده توسط کلینیک"
|
|
/>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
دلیل در دفتر ثبت میشود و بعداً قابل ویرایش نیست.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|