Task 08's pricing chain was reachable only through the API, so a clinic could not define a price list or see what a booked appointment was actually charged. Price lists - Draft / active / expired are shown as three states because they mean three different things operationally: a draft has no effect on today's price at all - Activation is a separate action rather than a checkbox in the form, matching the backend rule that creating a list must not change anything - "Copy" seeds a new list from an existing one starting the day the old one ends, since most lists are last quarter's with a few numbers moved - "All branches" is an explicit option, not an empty field Invoice card - Renders the recorded chain down to the final amount, hiding zero rows so the card stays readable - A missing invoice renders as a normal state, not an error: an appointment that was never confirmed has no invoice - Says outright that the numbers are from the appointment's own date and later tariff changes do not move them — otherwise someone who edited a price yesterday reads today's older number as a bug Also corrects task 08's checklist: its test section carried a copy-pasted "no UI was built" note against rows whose tests have existed since the task shipped. Replaced with the real test names and the two that genuinely are not covered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
132 lines
4.4 KiB
TypeScript
132 lines
4.4 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import { api, ApiError, type ApiResponse } from '../lib/api';
|
|
|
|
/**
|
|
* لیست قیمت بازهدار و فاکتور نوبت.
|
|
*
|
|
* لیست تا فعال نشده هیچ اثری ندارد؛ ساختن پیشنویس نباید قیمت امروز را عوض کند. پس
|
|
* `create` و `activate` عمداً دو عمل جدا هستند، نه یک فرم با تیک «فعال».
|
|
*/
|
|
export interface PriceListItem {
|
|
service_uuid: string;
|
|
service_name?: string;
|
|
price_rials: number;
|
|
}
|
|
|
|
export interface PriceList {
|
|
uuid: string;
|
|
name: string;
|
|
address_uuid: string | null;
|
|
address_name: string | null;
|
|
valid_from: number;
|
|
valid_to: number;
|
|
active: boolean;
|
|
items: PriceListItem[];
|
|
created_at: number;
|
|
}
|
|
|
|
export interface PriceSnapshotLine {
|
|
label: string;
|
|
rials: number;
|
|
kind?: string;
|
|
}
|
|
|
|
export interface PriceSnapshot {
|
|
base_rials: number;
|
|
items_rials: number;
|
|
discount_rials: number;
|
|
insurance_base_rials: number;
|
|
insurance_supplementary_rials: number;
|
|
tax_rials: number;
|
|
final_rials: number;
|
|
deposit_rials: number;
|
|
created_at?: number;
|
|
breakdown: {
|
|
discounts: PriceSnapshotLine[];
|
|
sources: Record<string, unknown>;
|
|
};
|
|
}
|
|
|
|
const KEY = ['price-lists'];
|
|
|
|
function fail(e: unknown, fallback: string) {
|
|
toast.error(e instanceof ApiError ? e.message : fallback);
|
|
}
|
|
|
|
export function usePriceLists() {
|
|
const qc = useQueryClient();
|
|
|
|
const query = useQuery({
|
|
queryKey: KEY,
|
|
queryFn: () => api.get<ApiResponse<PriceList[]>>('/api/v1/price-lists'),
|
|
});
|
|
|
|
const invalidate = () => qc.invalidateQueries({ queryKey: KEY });
|
|
|
|
const create = useMutation({
|
|
mutationFn: (body: Record<string, unknown>) =>
|
|
api.post<ApiResponse<PriceList>>('/api/v1/price-lists', body),
|
|
onSuccess: () => {
|
|
toast.success('لیست قیمت ساخته شد — تا فعال نشود اثری ندارد');
|
|
invalidate();
|
|
},
|
|
onError: (e) => fail(e, 'ساخت لیست قیمت ناموفق بود'),
|
|
});
|
|
|
|
const update = useMutation({
|
|
mutationFn: ({ uuid, body }: { uuid: string; body: Record<string, unknown> }) =>
|
|
api.patch<ApiResponse<PriceList>>(`/api/v1/price-list/${uuid}`, body),
|
|
onSuccess: () => {
|
|
toast.success('لیست قیمت بهروزرسانی شد');
|
|
invalidate();
|
|
},
|
|
onError: (e) => fail(e, 'بهروزرسانی ناموفق بود'),
|
|
});
|
|
|
|
const setItems = useMutation({
|
|
mutationFn: ({ uuid, items }: { uuid: string; items: PriceListItem[] }) =>
|
|
api.put<ApiResponse<PriceList>>(`/api/v1/price-list/${uuid}/items`, { items }),
|
|
onSuccess: () => {
|
|
toast.success('قیمتها ذخیره شد');
|
|
invalidate();
|
|
},
|
|
onError: (e) => fail(e, 'ذخیرهٔ قیمتها ناموفق بود'),
|
|
});
|
|
|
|
/** تداخل بازه با لیست فعالِ همدامنه اینجا ۴۲۲ میگیرد؛ پیام سرور دقیقتر است. */
|
|
const activate = useMutation({
|
|
mutationFn: (uuid: string) => api.post<ApiResponse<PriceList>>(`/api/v1/price-list/${uuid}/activate`, {}),
|
|
onSuccess: () => {
|
|
toast.success('لیست قیمت فعال شد');
|
|
invalidate();
|
|
},
|
|
onError: (e) => fail(e, 'فعالسازی ناموفق بود'),
|
|
});
|
|
|
|
const remove = useMutation({
|
|
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/price-list/${uuid}`),
|
|
onSuccess: () => {
|
|
toast.success('لیست قیمت حذف شد');
|
|
invalidate();
|
|
},
|
|
onError: (e) => fail(e, 'حذف ناموفق بود'),
|
|
});
|
|
|
|
return { lists: query.data?.data ?? [], loading: query.isLoading, create, update, setItems, activate, remove };
|
|
}
|
|
|
|
/** فاکتور یک نوبت — همان اعداد لحظهٔ ثبت، حتی اگر قیمتها بعداً عوض شده باشند. */
|
|
export function useAppointmentInvoice(appointmentUuid: string | undefined) {
|
|
const query = useQuery({
|
|
queryKey: ['appointment-invoice', appointmentUuid],
|
|
queryFn: () =>
|
|
api.get<ApiResponse<PriceSnapshot>>(`/api/v1/appointment/${appointmentUuid}/price-snapshot`),
|
|
enabled: !!appointmentUuid,
|
|
// نوبتِ بدون فاکتور ۴۰۴ میدهد و آن خطا نیست، یعنی «هنوز ثبت نشده».
|
|
retry: false,
|
|
});
|
|
|
|
return { invoice: query.data?.data, loading: query.isLoading, missing: query.isError };
|
|
}
|