feat(package): session packages backed by a credit ledger
"Six laser sessions" is the common case in an aesthetics clinic: the patient pays once and books the sessions later. Credit is a ledger, not a counter. No table has a remaining/used_count column and a schema test enforces that — the balance is always SUM(delta) over append-only rows, so every number a patient sees has a full history behind it. Corrections are new rows, never edits. - purchase / consume / refund / adjustment / expiry, each with a reason, an author and the appointment it belongs to - consume happens in confirm(), never in quote(): if the preview consumed, a page refresh would cost the patient a session - cancelling adds a refund row; the consume row stays - FIFO across a patient's packages — the oldest is closest to expiring - an empty package is not an error, it just does not apply and the patient pays - adjust/expire need a doctor or clinic role, and adjust always needs a reason - app:package:expire writes the closing row so "where did my 3 sessions go?" always has an answer Consume takes a pessimistic lock on the one package row. That is the opposite of task 07's slot buckets, and docs/api/package.md carries the table explaining why, so nobody unifies them later. Idempotency checks for an existing consume row before inserting rather than catching the unique violation: in Doctrine that exception closes the EntityManager and burns the rest of the request. The unique key stays as the last line of defence. Admin: PackagesPage, a packages tab on the patient record, and a ledger page whose running-balance column shows where the final number came from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
import React, { useState } from 'react';
|
||||
import { 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user