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>
356 lines
13 KiB
TypeScript
356 lines
13 KiB
TypeScript
import React, { useMemo, useState } from 'react';
|
|
import { PlusIcon, TrashIcon } 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 PersianDateInput from '../components/ui/PersianDateInput';
|
|
import { formatDate, isoToUnix, unixToIso } from '../lib/utils';
|
|
import { useUrlState } from '../hooks/useUrlState';
|
|
import { usePermissions } from '../hooks/usePermissions';
|
|
import { useBranches } from '../hooks/useBranches';
|
|
import { usePriceLists, type PriceList, type PriceListItem } from '../hooks/usePriceLists';
|
|
import { useAllServiceItems } from '../hooks/useServiceCatalog';
|
|
|
|
interface Draft {
|
|
uuid?: string;
|
|
name: string;
|
|
address_uuid: string | null;
|
|
valid_from: number;
|
|
valid_to: number;
|
|
items: PriceListItem[];
|
|
}
|
|
|
|
const DAY = 86400;
|
|
|
|
function emptyDraft(): Draft {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
return { name: '', address_uuid: null, valid_from: now, valid_to: now + 90 * DAY, items: [] };
|
|
}
|
|
|
|
/**
|
|
* لیستهای قیمت بازهدار.
|
|
*
|
|
* وضعیت هر لیست سه حالت دارد و هر سه معنای عملیاتی متفاوتی دارند: پیشنویس هیچ اثری
|
|
* روی قیمت امروز ندارد، فعال حاکم است، و منقضی فقط تاریخچه است.
|
|
*/
|
|
export default function PriceListsPage() {
|
|
const { lists, loading, create, update, setItems, activate, remove } = usePriceLists();
|
|
const { branches } = useBranches();
|
|
const { items: services } = useAllServiceItems();
|
|
const { can } = usePermissions();
|
|
const canManage = can('appointment_settings', 'update');
|
|
|
|
const [urlState, setUrlState] = useUrlState({ search: '' });
|
|
const [draft, setDraft] = useState<Draft | null>(null);
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
const rows = useMemo(() => {
|
|
const q = urlState.search.trim();
|
|
return lists.filter((l) => q === '' || l.name.includes(q));
|
|
}, [lists, urlState.search]);
|
|
|
|
const statusOf = (list: PriceList) => {
|
|
if (!list.active) return { label: 'پیشنویس', className: 'badge' };
|
|
if (list.valid_to < now) return { label: 'منقضی', className: 'badge red' };
|
|
return { label: 'فعال', className: 'badge green' };
|
|
};
|
|
|
|
const columns: Column<PriceList>[] = [
|
|
{
|
|
key: 'name',
|
|
header: 'لیست',
|
|
render: (l) => (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
<span style={{ fontWeight: 600 }}>{l.name}</span>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
{l.address_uuid === null ? 'همهٔ شعبهها' : l.address_name ?? 'یک شعبه'}
|
|
</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'range',
|
|
header: 'بازهٔ اعتبار',
|
|
render: (l) => (
|
|
<span style={{ fontSize: 13 }}>
|
|
{formatDate(l.valid_from)} تا {formatDate(l.valid_to)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'items',
|
|
header: 'تعداد قیمت',
|
|
render: (l) => <span style={{ fontSize: 13 }}>{l.items.length}</span>,
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'وضعیت',
|
|
render: (l) => {
|
|
const status = statusOf(l);
|
|
return (
|
|
<span className={status.className}>
|
|
<span className="bdot" />
|
|
{status.label}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
|
|
const save = async () => {
|
|
if (!draft) return;
|
|
|
|
const body = {
|
|
name: draft.name,
|
|
address_uuid: draft.address_uuid,
|
|
valid_from: draft.valid_from,
|
|
valid_to: draft.valid_to,
|
|
};
|
|
|
|
const saved = draft.uuid
|
|
? await update.mutateAsync({ uuid: draft.uuid, body })
|
|
: await create.mutateAsync(body);
|
|
|
|
await setItems.mutateAsync({ uuid: saved.data.uuid, items: draft.items });
|
|
|
|
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(emptyDraft())}>
|
|
<PlusIcon style={{ width: 15 }} /> لیست تازه
|
|
</button>
|
|
) : undefined
|
|
}
|
|
/>
|
|
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<DataTable
|
|
columns={columns}
|
|
data={rows}
|
|
loading={loading}
|
|
searchValue={urlState.search}
|
|
onSearchChange={(v) => setUrlState({ search: v })}
|
|
searchPlaceholder="جستجو در لیستها..."
|
|
emptyMessage="هنوز لیست قیمتی ساخته نشده است"
|
|
actions={(l) =>
|
|
canManage ? (
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
onClick={() =>
|
|
setDraft({
|
|
uuid: l.uuid,
|
|
name: l.name,
|
|
address_uuid: l.address_uuid,
|
|
valid_from: l.valid_from,
|
|
valid_to: l.valid_to,
|
|
items: l.items,
|
|
})
|
|
}
|
|
>
|
|
ویرایش
|
|
</button>
|
|
|
|
{/* «کپی از لیست قبلی»: بیشتر لیستها نسخهٔ کمیتغییریافتهٔ قبلیاند. */}
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
onClick={() =>
|
|
setDraft({
|
|
name: `${l.name} — نسخهٔ تازه`,
|
|
address_uuid: l.address_uuid,
|
|
valid_from: l.valid_to + DAY,
|
|
valid_to: l.valid_to + 90 * DAY,
|
|
items: l.items,
|
|
})
|
|
}
|
|
>
|
|
کپی
|
|
</button>
|
|
|
|
{!l.active && (
|
|
<button
|
|
type="button"
|
|
className="btn primary sm"
|
|
disabled={activate.isPending}
|
|
onClick={() => activate.mutate(l.uuid)}
|
|
>
|
|
فعالسازی
|
|
</button>
|
|
)}
|
|
|
|
{!l.active && (
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
disabled={remove.isPending}
|
|
onClick={() => remove.mutate(l.uuid)}
|
|
aria-label="حذف لیست"
|
|
>
|
|
<TrashIcon style={{ width: 15 }} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : null
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<Modal
|
|
open={draft !== null}
|
|
title={draft?.uuid ? 'ویرایش لیست قیمت' : 'لیست قیمت تازه'}
|
|
size="lg"
|
|
onClose={() => setDraft(null)}
|
|
footer={
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="btn primary"
|
|
disabled={
|
|
!draft ||
|
|
draft.name.trim() === '' ||
|
|
draft.valid_to <= draft.valid_from ||
|
|
create.isPending ||
|
|
update.isPending ||
|
|
setItems.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="pl-name">نام لیست</label>
|
|
<input
|
|
id="pl-name"
|
|
className="input"
|
|
value={draft.name}
|
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
|
placeholder="مثلاً: تعرفهٔ نیمهٔ دوم ۱۴۰۵"
|
|
/>
|
|
</div>
|
|
|
|
<div className="field">
|
|
<label>شعبه</label>
|
|
<SearchableSelect
|
|
value={draft.address_uuid ?? ''}
|
|
onChange={(v) => setDraft({ ...draft, address_uuid: v ? String(v) : null })}
|
|
options={[
|
|
{ value: '', label: 'همهٔ شعبهها' },
|
|
...branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' })),
|
|
]}
|
|
/>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
لیستِ یک شعبه بر لیست عمومی مقدم است و با آن تداخل حساب نمیشود.
|
|
</span>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
|
<div className="field" style={{ minWidth: 180 }}>
|
|
<label>از تاریخ</label>
|
|
<PersianDateInput
|
|
value={unixToIso(draft.valid_from)}
|
|
onChange={(iso) => setDraft({ ...draft, valid_from: isoToUnix(iso) ?? draft.valid_from })}
|
|
/>
|
|
</div>
|
|
<div className="field" style={{ minWidth: 180 }}>
|
|
<label>تا تاریخ</label>
|
|
<PersianDateInput
|
|
value={unixToIso(draft.valid_to)}
|
|
onChange={(iso) => setDraft({ ...draft, valid_to: isoToUnix(iso) ?? draft.valid_to })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{draft.valid_to <= draft.valid_from && (
|
|
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
|
|
پایان بازه باید بعد از شروع آن باشد.
|
|
</span>
|
|
)}
|
|
|
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
|
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>قیمتها</h3>
|
|
|
|
{draft.items.map((item, index) => (
|
|
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
|
<div style={{ minWidth: 220 }}>
|
|
<SearchableSelect
|
|
value={item.service_uuid}
|
|
onChange={(v) =>
|
|
setDraft({
|
|
...draft,
|
|
items: draft.items.map((it, i) =>
|
|
i === index ? { ...it, service_uuid: String(v ?? '') } : it,
|
|
),
|
|
})
|
|
}
|
|
options={services.map((s) => ({ value: s.uuid, label: s.name }))}
|
|
placeholder="خدمت"
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ maxWidth: 200 }}>
|
|
<PriceInput
|
|
value={item.price_rials}
|
|
onChange={(v) =>
|
|
setDraft({
|
|
...draft,
|
|
items: draft.items.map((it, i) => (i === index ? { ...it, price_rials: v } : it)),
|
|
})
|
|
}
|
|
suffix="ریال"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
onClick={() => setDraft({ ...draft, items: draft.items.filter((_, i) => i !== index) })}
|
|
aria-label="حذف قیمت"
|
|
>
|
|
<TrashIcon style={{ width: 15 }} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
onClick={() =>
|
|
setDraft({ ...draft, items: [...draft.items, { service_uuid: '', price_rials: 0 }] })
|
|
}
|
|
>
|
|
<PlusIcon style={{ width: 15 }} /> افزودن قیمت
|
|
</button>
|
|
</div>
|
|
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
خدمتی که در این لیست نیاید، قیمتش از تعرفهٔ سال یا خودِ خدمت خوانده میشود —
|
|
پس هیچوقت بیقیمت نمیماند.
|
|
</span>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|