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
+4
View File
@@ -75,6 +75,8 @@ import ClinicAppointmentSettingsPage from './pages/ClinicAppointmentSettingsPage
import PatientsListPage from './pages/PatientsListPage';
import InventoryPage from './pages/InventoryPage';
import BranchesPage from './pages/BranchesPage';
import PackagesPage from './pages/PackagesPage';
import PatientPackageLedgerPage from './pages/PatientPackageLedgerPage';
import PoliciesPage from './pages/PoliciesPage';
import PolicyFormPage from './pages/PolicyFormPage';
import PolicySimulationPage from './pages/PolicySimulationPage';
@@ -293,6 +295,8 @@ export default function App() {
<Route path="policies" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PoliciesPage /></RoleRoute>} />
<Route path="policies/new" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'update']}><PolicyFormPage /></RoleRoute>} />
<Route path="policies/:policyUuid/simulate" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PolicySimulationPage /></RoleRoute>} />
<Route path="packages" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PackagesPage /></RoleRoute>} />
<Route path="patient-package/:patientPackageUuid/ledger" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><PatientPackageLedgerPage /></RoleRoute>} />
<Route path="resources" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourcesPage /></RoleRoute>} />
<Route path="resources/types" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceTypesPage /></RoleRoute>} />
<Route path="resources/skills" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><SkillsPage /></RoleRoute>} />
@@ -2,7 +2,7 @@ import React from 'react';
import {
CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon,
BanknotesIcon, UsersIcon, ShieldCheckIcon,
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon,
TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, ScaleIcon, RectangleStackIcon,
} from '@heroicons/react/24/outline';
import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar';
@@ -33,6 +33,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [
{ key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'holidays', label: 'تعطیلات رسمی', icon: CalendarDaysIcon, to: '/admin/holidays', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'policies', label: 'قوانین', icon: ScaleIcon, to: '/admin/policies', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'packages', label: 'پکیج‌ها', icon: RectangleStackIcon, to: '/admin/packages', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] },
{ key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] },
{ key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' },
{ key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] },
+120
View File
@@ -0,0 +1,120 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, ApiError, type ApiResponse } from '../lib/api';
import type { CreditLedger, PackageDefinition, PatientPackage } from '../types';
/**
* پکیج و اعتبار جلسات.
*
* مانده هیچ‌جا کش نمی‌شود و بعد از هر تغییر دفتر، کوئری باطل می‌شود: عددی که کاربر
* می‌بیند باید همان جمع دفتر باشد، نه چیزی که فرانت حساب کرده.
*/
const PACKAGES_KEY = ['packages'];
function fail(e: unknown, fallback: string) {
toast.error(e instanceof ApiError ? e.message : fallback);
}
export function usePackages() {
const qc = useQueryClient();
const query = useQuery({
queryKey: PACKAGES_KEY,
queryFn: () => api.get<ApiResponse<PackageDefinition[]>>('/api/v1/packages'),
});
const invalidate = () => qc.invalidateQueries({ queryKey: PACKAGES_KEY });
const create = useMutation({
mutationFn: (body: Record<string, unknown>) =>
api.post<ApiResponse<PackageDefinition>>('/api/v1/packages', body),
onSuccess: () => {
toast.success('پکیج ساخته شد');
invalidate();
},
onError: (e) => fail(e, 'ساخت پکیج ناموفق بود'),
});
const update = useMutation({
mutationFn: ({ uuid, body }: { uuid: string; body: Record<string, unknown> }) =>
api.patch<ApiResponse<PackageDefinition>>(`/api/v1/package/${uuid}`, body),
onSuccess: () => {
toast.success('پکیج به‌روزرسانی شد');
invalidate();
},
onError: (e) => fail(e, 'به‌روزرسانی پکیج ناموفق بود'),
});
/** حذف = غیرفعال کردن؛ پکیج فروخته‌شده حذف‌شدنی نیست. */
const deactivate = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<PackageDefinition>>(`/api/v1/package/${uuid}`),
onSuccess: () => {
toast.success('پکیج غیرفعال شد');
invalidate();
},
onError: (e) => fail(e, 'غیرفعال‌سازی ناموفق بود'),
});
return { packages: query.data?.data ?? [], loading: query.isLoading, create, update, deactivate };
}
export function usePatientPackages(patientUuid: string | undefined) {
const qc = useQueryClient();
const key = ['patient-packages', patientUuid];
const query = useQuery({
queryKey: key,
queryFn: () => api.get<ApiResponse<PatientPackage[]>>(`/api/v1/patient/${patientUuid}/packages`),
enabled: !!patientUuid,
});
const sell = useMutation({
mutationFn: (body: { package_uuid: string; price_paid_rials?: number }) =>
api.post<ApiResponse<PatientPackage>>(`/api/v1/patient/${patientUuid}/package`, body),
onSuccess: () => {
toast.success('پکیج برای بیمار ثبت شد');
qc.invalidateQueries({ queryKey: key });
},
onError: (e) => fail(e, 'ثبت پکیج ناموفق بود'),
});
return { patientPackages: query.data?.data ?? [], loading: query.isLoading, sell };
}
export function useCreditLedger(patientPackageUuid: string | undefined) {
const qc = useQueryClient();
const key = ['credit-ledger', patientPackageUuid];
const query = useQuery({
queryKey: key,
queryFn: () => api.get<ApiResponse<CreditLedger>>(`/api/v1/patient-package/${patientPackageUuid}/ledger`),
enabled: !!patientPackageUuid,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: key });
qc.invalidateQueries({ queryKey: ['patient-packages'] });
};
const adjust = useMutation({
mutationFn: (body: { delta: number; reason: string }) =>
api.post<ApiResponse<PatientPackage>>(`/api/v1/patient-package/${patientPackageUuid}/adjust`, body),
onSuccess: () => {
toast.success('اصلاح اعتبار ثبت شد');
invalidate();
},
onError: (e) => fail(e, 'اصلاح اعتبار ناموفق بود'),
});
const expire = useMutation({
mutationFn: (reason: string) =>
api.post<ApiResponse<PatientPackage>>(`/api/v1/patient-package/${patientPackageUuid}/expire`, { reason }),
onSuccess: () => {
toast.success('پکیج ابطال شد');
invalidate();
},
onError: (e) => fail(e, 'ابطال پکیج ناموفق بود'),
});
return { ledger: query.data?.data, loading: query.isLoading, adjust, expire };
}
+279
View File
@@ -0,0 +1,279 @@
import React, { useEffect, useMemo, useState } from 'react';
import { PlusIcon } from '@heroicons/react/24/outline';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { type Column } from '../components/ui/DataTable';
import Modal from '../components/ui/Modal';
import PriceInput from '../components/ui/PriceInput';
import SearchableSelect from '../components/ui/SearchableSelect';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { formatRial } from '../lib/utils';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { usePackages } from '../hooks/usePackages';
import { api, type ApiResponse } from '../lib/api';
import { useQuery } from '@tanstack/react-query';
import type { PackageDefinition, ServiceItem } from '../types';
interface Draft {
uuid?: string;
name: string;
session_count: number;
price_rials: number;
validity_days: number | '';
service_uuids: string[];
}
const EMPTY: Draft = { name: '', session_count: 6, price_rials: 0, validity_days: '', service_uuids: [] };
/**
* تعریف پکیج‌ها.
*
* ماندهٔ بیمار اینجا نیست — آن در پروندهٔ بیمار است. اینجا فقط «چه می‌فروشیم».
*/
export default function PackagesPage() {
const { packages, loading, create, update, deactivate } = usePackages();
const { can } = usePermissions();
const canManage = can('appointment_settings', 'update');
const [urlState, setUrlState] = useUrlState({ search: '' });
const [draft, setDraft] = useState<Draft | null>(null);
const { data: servicesData } = useQuery({
queryKey: ['service-items-for-packages'],
queryFn: () => api.get<ApiResponse<ServiceItem[]>>('/api/v1/service-items'),
staleTime: 60_000,
});
const services = servicesData?.data ?? [];
const rows = useMemo(() => {
const q = urlState.search.trim();
return packages.filter((p) => q === '' || p.name.includes(q));
}, [packages, urlState.search]);
const columns: Column<PackageDefinition>[] = [
{
key: 'name',
header: 'پکیج',
render: (p) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<span style={{ fontWeight: 600 }}>{p.name}</span>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{p.services.map((s) => s.name).join('، ') || 'بدون سرویس'}
</span>
</div>
),
},
{
key: 'session_count',
header: 'تعداد جلسه',
render: (p) => <span style={{ fontSize: 13 }}>{p.session_count}</span>,
},
{
key: 'price_rials',
header: 'قیمت',
render: (p) => <span style={{ fontSize: 13 }}>{formatRial(p.price_rials)}</span>,
},
{
key: 'validity_days',
header: 'اعتبار',
render: (p) => (
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
{p.validity_days === null ? 'بی‌پایان' : `${p.validity_days} روز`}
</span>
),
},
{
key: 'active',
header: 'وضعیت',
render: (p) => <ActiveBadge active={p.active} />,
},
];
const save = async () => {
if (!draft) return;
const body = {
name: draft.name,
session_count: draft.session_count,
price_rials: draft.price_rials,
validity_days: draft.validity_days === '' ? null : draft.validity_days,
service_uuids: draft.service_uuids,
};
if (draft.uuid) {
await update.mutateAsync({ uuid: draft.uuid, body });
} else {
await create.mutateAsync(body);
}
setDraft(null);
};
return (
<div className="fade-in">
<PageHeader
title="پکیج‌ها"
description="بستهٔ چندجلسه‌ای که بیمار یکجا می‌خرد و بعداً جلساتش را رزرو می‌کند."
backTo="/admin/settings-menu"
action={
canManage ? (
<button type="button" className="btn primary sm" onClick={() => setDraft({ ...EMPTY })}>
<PlusIcon style={{ width: 15 }} /> پکیج تازه
</button>
) : undefined
}
/>
<DataTable
columns={columns}
data={rows}
loading={loading}
searchValue={urlState.search}
onSearchChange={(v) => setUrlState({ search: v })}
searchPlaceholder="جستجو در پکیج‌ها..."
emptyMessage="هنوز پکیجی تعریف نشده است"
actions={(p) =>
canManage ? (
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
className="btn secondary sm"
onClick={() =>
setDraft({
uuid: p.uuid,
name: p.name,
session_count: p.session_count,
price_rials: p.price_rials,
validity_days: p.validity_days ?? '',
service_uuids: p.services.map((s) => s.uuid),
})
}
>
ویرایش
</button>
{p.active && (
<button
type="button"
className="btn secondary sm"
disabled={deactivate.isPending}
onClick={() => deactivate.mutate(p.uuid)}
>
غیرفعال
</button>
)}
</div>
) : null
}
/>
<Modal
open={draft !== null}
title={draft?.uuid ? 'ویرایش پکیج' : 'پکیج تازه'}
onClose={() => setDraft(null)}
footer={
<>
<button
type="button"
className="btn primary"
disabled={!draft?.name.trim() || draft.service_uuids.length === 0 || create.isPending || update.isPending}
onClick={save}
>
ذخیره
</button>
<button type="button" className="btn secondary" onClick={() => setDraft(null)}>
انصراف
</button>
</>
}
>
{draft && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="field">
<label htmlFor="pkg-name">نام پکیج</label>
<input
id="pkg-name"
className="input"
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="۶ جلسه لیزر فول‌بادی"
/>
</div>
<div className="field">
<label htmlFor="pkg-sessions">تعداد جلسه</label>
<input
id="pkg-sessions"
className="input"
type="number"
min={1}
value={draft.session_count}
onChange={(e) => setDraft({ ...draft, session_count: Number(e.target.value) })}
/>
</div>
<div className="field">
<label>قیمت</label>
<PriceInput
value={draft.price_rials}
onChange={(v) => setDraft({ ...draft, price_rials: v })}
suffix="ریال"
/>
</div>
<div className="field">
<label htmlFor="pkg-validity">اعتبار (روز)</label>
<input
id="pkg-validity"
className="input"
type="number"
min={1}
value={draft.validity_days}
onChange={(e) =>
setDraft({ ...draft, validity_days: e.target.value === '' ? '' : Number(e.target.value) })
}
placeholder="خالی یعنی بی‌پایان"
/>
</div>
<div className="field">
<label>سرویسهای پوششدادهشده</label>
<SearchableSelect
value={null}
onChange={(v) => {
const uuid = String(v ?? '');
if (uuid && !draft.service_uuids.includes(uuid)) {
setDraft({ ...draft, service_uuids: [...draft.service_uuids, uuid] });
}
}}
options={services
.filter((s) => !draft.service_uuids.includes(s.uuid))
.map((s) => ({ value: s.uuid, label: s.name }))}
placeholder="افزودن سرویس"
/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{draft.service_uuids.map((uuid) => (
<button
key={uuid}
type="button"
className="badge"
onClick={() =>
setDraft({ ...draft, service_uuids: draft.service_uuids.filter((u) => u !== uuid) })
}
>
{services.find((s) => s.uuid === uuid)?.name ?? uuid}
</button>
))}
</div>
{draft.service_uuids.length === 0 && (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
پکیج بدون سرویس قابل مصرف نیست.
</span>
)}
</div>
</div>
)}
</Modal>
</div>
);
}
+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>
);
}
@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('../lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error {},
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { Routes, Route } from 'react-router-dom';
import { api } from '../lib/api';
import PatientPackageLedgerPage from './PatientPackageLedgerPage';
const get = api.get as ReturnType<typeof vi.fn>;
const ledger = {
package: {
uuid: 'pp1',
package_uuid: 'p1',
package_name: '۶ جلسه لیزر',
patient_uuid: 'pat1',
session_count: 6,
price_paid_rials: 25_000_000,
purchased_at: 1_700_000_000,
valid_to: null,
expired: false,
balance: 5,
},
rows: [
{
uuid: 'l1', kind: 'purchase', delta: 6, appointment_uuid: null, service_uuid: null,
service_name: null, reason: 'خرید پکیج', created_by: 'u1', created_at: 1_700_000_000,
running_balance: 6,
},
{
uuid: 'l2', kind: 'consume', delta: -1, appointment_uuid: 'a1', service_uuid: 's1',
service_name: 'لیزر', reason: null, created_by: null, created_at: 1_700_100_000,
running_balance: 5,
},
],
};
function renderPage() {
return renderWithProviders(
<Routes>
<Route path="/admin/patient-package/:patientPackageUuid/ledger" element={<PatientPackageLedgerPage />} />
</Routes>,
{ route: '/admin/patient-package/pp1/ledger' },
);
}
describe('PatientPackageLedgerPage', () => {
beforeEach(() => {
vi.clearAllMocks();
get.mockResolvedValue({ success: true, data: ledger });
});
/** ماندهٔ هر ردیف باید دیده شود — همان چیزی که یک شمارنده نمی‌تواند نشان دهد. */
it('shows every ledger row with its running balance', async () => {
renderPage();
await waitFor(() => expect(screen.getByText('خرید')).toBeInTheDocument());
expect(screen.getByText('مصرف در نوبت')).toBeInTheDocument();
expect(screen.getByText('+6')).toBeInTheDocument();
expect(screen.getByText('-1')).toBeInTheDocument();
});
it('summarises the balance against the purchased session count', async () => {
renderPage();
await waitFor(() => expect(screen.getByText(/از 6 جلسه/)).toBeInTheDocument());
// عدد مانده در همان جمله می‌آید؛ «۵ از ۶» یعنی یک جلسه مصرف شده.
expect(screen.getByText(/از 6 جلسه/).parentElement).toHaveTextContent('5');
});
});
@@ -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>
);
}
+47
View File
@@ -1204,3 +1204,50 @@ export interface PolicySimulationRun {
rows: SimulationRow[];
warning: string | null;
}
// ── پکیج و اعتبار جلسات (تسک ۱۱) ─────────────────────────────────────────────
export interface PackageDefinition {
uuid: string;
name: string;
session_count: number;
price_rials: number;
/** `null` یعنی بی‌پایان */
validity_days: number | null;
active: boolean;
services: { uuid: string; name: string }[];
created_at: number;
}
export interface PatientPackage {
uuid: string;
package_uuid: string;
package_name: string;
patient_uuid: string;
session_count: number;
price_paid_rials: number;
purchased_at: number;
valid_to: number | null;
expired: boolean;
/** همیشه از جمع دفتر می‌آید — هیچ ستونی در دیتابیس نیست */
balance: number;
}
export interface CreditLedgerRow {
uuid: string;
kind: 'purchase' | 'consume' | 'refund' | 'adjustment' | 'expiry';
delta: number;
appointment_uuid: string | null;
service_uuid: string | null;
service_name: string | null;
reason: string | null;
created_by: string | null;
created_at: number;
/** در UI محاسبه‌شده نیست — سرور همان جمع تجمعی را می‌دهد */
running_balance: number;
}
export interface CreditLedger {
package: PatientPackage;
rows: CreditLedgerRow[];
}