"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>
121 lines
4.3 KiB
TypeScript
121 lines
4.3 KiB
TypeScript
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 };
|
|
}
|