Files
clinicpro/assets/admin/pages/PatientPackageLedgerPage.tsx
T
hamedandClaude Opus 5 5c754244f2 feat(admin): finish the screens that were stopping one step short
Five places where the data existed and the screen did not use it.

Booking a whole course had no button because it needs a doctor and the course
does not carry one — each session can be with a different doctor. The page now
asks for the doctor the same way the resource booking page does, and the button
explains that it is all-or-nothing before it is pressed.

A course whose package does not cover the remaining sessions is still valid —
the rest is simply charged normally — but nobody was told. The course response
carries package_balance and the shortfall, and the page warns. Before session
six, not during it.

The credit ledger already returned who recorded a row and which appointment it
belonged to, and showed neither. An adjustable ledger without the name of the
person who adjusted it is half an audit trail.

Version history printed a JSON blob of each version's effects, which does not
answer the question anyone actually has: what changed? It now diffs each
version against the previous one, field by field, and says so plainly when a
version changed nothing meaningful.

A resource with no calendar showed "—" for utilization. Null means undefined,
not zero, and the next step is always the same: set up the calendar. It is a
link now. The report range also accepts a custom from/to, kept in the URL like
the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:07:27 +03:30

198 lines
7.1 KiB
TypeScript

import React, { useState } from 'react';
import { Link, 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>
),
},
{
// «چه کسی» و «کدام نوبت» از قبل در پاسخ بودند و نمایش داده نمی‌شدند. دفترِ
// اصلاح‌پذیر بدون نامِ اصلاح‌کننده، نصف حسابرسی است.
key: 'created_by',
header: 'ثبت‌کننده',
render: (r) => (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>{r.created_by ?? 'سیستم'}</span>
),
},
{
key: 'appointment_uuid',
header: 'نوبت',
render: (r) =>
r.appointment_uuid ? (
<Link
to={`/admin/appointments/${r.appointment_uuid}`}
style={{ fontSize: 12, color: 'var(--primary)' }}
>
مشاهدهٔ نوبت
</Link>
) : (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}></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>
);
}