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:
hamed
2026-07-31 11:11:03 +03:30
co-authored by Claude Opus 5
parent d6294242b7
commit ca9648732d
35 changed files with 3205 additions and 78 deletions
+89 -2
View File
@@ -2,12 +2,13 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, useSearchParams, Link, useNavigate } from 'react-router-dom';
import {
ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon,
ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon, RectangleStackIcon,
CalendarDaysIcon, CreditCardIcon, BanknotesIcon, ChatBubbleLeftRightIcon,
PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon,
ArrowUpTrayIcon, TrashIcon, DocumentIcon, UserIcon,
} from '@heroicons/react/24/outline';
import { PlusIcon } from '@heroicons/react/24/outline';
import { usePackages, usePatientPackages } from '../hooks/usePackages';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
@@ -41,7 +42,7 @@ import {
} from '../lib/patientForm';
import { usePermissions } from '../hooks/usePermissions';
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'notes' | 'callcenter' | 'attach' | 'records';
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'packages' | 'notes' | 'callcenter' | 'attach' | 'records';
const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }[] = [
{ key: 'services', label: 'سرویس‌ها', icon: (c) => <TabServices color={c} /> },
@@ -49,6 +50,7 @@ const TABS: { key: TabKey; label: string; icon: (c: string) => React.ReactNode }
{ key: 'appointments', label: 'نوبت‌ها', icon: (c) => <TabCalendar color={c} /> },
{ key: 'payments', label: 'پرداخت‌ها', icon: (c) => <TabCard color={c} /> },
{ key: 'wallet', label: 'کیف پول', icon: (c) => <TabWallet color={c} /> },
{ key: 'packages', label: 'پکیج‌ها', icon: (c) => <RectangleStackIcon style={{ width: 18, color: c }} /> },
{ key: 'notes', label: 'یادداشت‌ها', icon: (c) => <DocumentTextIcon style={{ width: 18, color: c }} /> },
{ key: 'callcenter', label: 'کال سنتر', icon: (c) => <TabCall color={c} /> },
{ key: 'attach', label: 'ضمیمه', icon: (c) => <TabAttach color={c} /> },
@@ -260,6 +262,8 @@ export default function PatientDetailPage() {
<PaymentsTab q={sessionsQ} />
) : tab === 'wallet' ? (
<WalletTab uuid={uuid!} />
) : tab === 'packages' ? (
<PackagesTab uuid={uuid!} />
) : tab === 'callcenter' ? (
<CallCenterTab uuid={uuid!} />
) : tab === 'attach' ? (
@@ -1021,3 +1025,86 @@ function AppointmentsTab({ uuid, q }: {
);
}
/**
* پکیج‌های بیمار.
*
* مانده از دفتر می‌آید نه از شمارنده؛ لینک «دفتر» همان تاریخچه را نشان می‌دهد تا
* معلوم باشد عدد از کجا آمده.
*/
function PackagesTab({ uuid }: { uuid: string }) {
const { patientPackages, loading, sell } = usePatientPackages(uuid);
const { packages } = usePackages();
const [selected, setSelected] = useState('');
const active = packages.filter((p) => p.active);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="card" style={{ display: 'flex', alignItems: 'flex-end', gap: 10, flexWrap: 'wrap' }}>
<div className="field" style={{ minWidth: 240, margin: 0 }}>
<label>فروش پکیج تازه</label>
<SearchableSelect
value={selected}
onChange={(v) => setSelected(String(v ?? ''))}
options={active.map((p) => ({
value: p.uuid,
label: `${p.name}${p.session_count} جلسه`,
}))}
placeholder="انتخاب پکیج"
/>
</div>
<button
type="button"
className="btn primary sm"
disabled={selected === '' || sell.isPending}
onClick={async () => {
await sell.mutateAsync({ package_uuid: selected });
setSelected('');
}}
>
ثبت خرید
</button>
</div>
{loading ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری</div>
) : patientPackages.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
این بیمار هنوز پکیجی نخریده است
</div>
) : (
patientPackages.map((p) => (
<div key={p.uuid} className="card" style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<span style={{ fontWeight: 600 }}>{p.package_name}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
خرید {formatDate(p.purchased_at)}
{p.valid_to !== null && ` · اعتبار تا ${formatDate(p.valid_to)}`}
</span>
</div>
<span style={{ fontSize: 14 }}>
مانده: <strong>{p.balance}</strong> از {p.session_count}
</span>
{p.expired && <span className="badge red"><span className="bdot" />منقضی</span>}
{!p.expired && p.balance === 0 && (
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
اعتبار پکیج تمام شده؛ نوبت بعدی نقدی محاسبه میشود.
</span>
)}
<Link
className="btn secondary sm"
style={{ marginRight: 'auto' }}
to={`/admin/patient-package/${p.uuid}/ledger`}
>
دفتر اعتبار
</Link>
</div>
))
)}
</div>
);
}