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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user