Files
clinicpro/assets/admin/pages/PackagesPage.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

280 lines
9.5 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-block">
<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-block">
<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-block">
<label>قیمت</label>
<PriceInput
value={draft.price_rials}
onChange={(v) => setDraft({ ...draft, price_rials: v })}
suffix="ریال"
/>
</div>
<div className="field-block">
<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-block">
<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>
);
}