"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>
174 lines
6.2 KiB
TypeScript
174 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|