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>
280 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
}
|