Files
clinicpro/assets/admin/pages/PriceListsPage.tsx
T
hamedandClaude Opus 5 635bf3d2a8 fix(admin): correct two design-system mismatches found by looking at the pages
Screenshotting the pages under dark mode and compact density (rather than
trusting that design tokens were enough) turned up two mistakes repeated across
every page this feature set added:

- `.card` carries only the surface, border and radius — padding comes from the
  separate `.card-pad`. Fifteen cards were rendering with their content flush
  against the edges.
- `.field` *is* the input box, a 40px-tall flex row. Wrapping a label plus a
  control in it produced a joined addon rather than a label above its field.
  `.field-block` is the label-above layout, and thirty-seven wrappers now use it.

Both were invisible to type-checking and to the tests, which is exactly why the
visual pass was worth running. Numbers in the new UI now go through
formatNumber so they render as Persian digits, and the utilization page's
header no longer repeats the sentence that appears under its filters verbatim.

The QA driver gained a `--ui` flag: theme and density live in
localStorage['clinicpro-ui'], so without seeding them dark mode and compact
density cannot be screenshotted at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:42:11 +03:30

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-block">
<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-block">
<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-block" 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-block" 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>
);
}