"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>
280 lines
9.4 KiB
TypeScript
280 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|