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

350 lines
12 KiB
TypeScript

import React, { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
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 SearchableSelect from '../components/ui/SearchableSelect';
import { ActiveBadge } from '../components/ui/StatusBadge';
import { useUrlState } from '../hooks/useUrlState';
import { usePermissions } from '../hooks/usePermissions';
import { useCourseProtocols } from '../hooks/useCourses';
import { api, type ApiResponse } from '../lib/api';
import type { CourseProtocol, ServiceItem } from '../types';
interface StepDraft {
session_number: number;
energy: string;
}
interface Draft {
uuid?: string;
service_uuid: string;
session_count: number;
min_days: number;
ideal_days: number;
max_days: number;
prefer_same_resource: boolean;
steps: StepDraft[];
}
const EMPTY: Draft = {
service_uuid: '',
session_count: 6,
min_days: 21,
ideal_days: 28,
max_days: 45,
prefer_same_resource: true,
steps: [],
};
/**
* پروتکل دوره per سرویس.
*
* سه فاصله سه معنا دارند و ترتیبشان اجباری است؛ فرم همان‌جا می‌گوید، نه اینکه بگذارد
* کاربر ذخیره کند و ۴۲۲ بگیرد.
*/
export default function CourseProtocolsPage() {
const { protocols, loading, create, update, deactivate } = useCourseProtocols();
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-courses'],
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 protocols.filter((p) => q === '' || p.service_name.includes(q));
}, [protocols, urlState.search]);
const orderInvalid = draft !== null && !(draft.min_days <= draft.ideal_days && draft.ideal_days <= draft.max_days);
const columns: Column<CourseProtocol>[] = [
{
key: 'service_name',
header: 'سرویس',
render: (p) => <span style={{ fontWeight: 600 }}>{p.service_name}</span>,
},
{
key: 'session_count',
header: 'تعداد جلسه',
render: (p) => <span style={{ fontSize: 13 }}>{p.session_count}</span>,
},
{
key: 'spacing',
header: 'فاصله (روز)',
render: (p) => (
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
حداقل {p.min_days} · ایده‌آل {p.ideal_days} · حداکثر {p.max_days}
</span>
),
},
{
key: 'steps',
header: 'پارامتر جلسات',
render: (p) => (
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
{p.steps.length === 0 ? '—' : `${p.steps.length} جلسه پارامتر دارد`}
</span>
),
},
{
key: 'active',
header: 'وضعیت',
render: (p) => <ActiveBadge active={p.active} />,
},
];
const save = async () => {
if (!draft) return;
const body = {
service_uuid: draft.service_uuid,
session_count: draft.session_count,
min_days: draft.min_days,
ideal_days: draft.ideal_days,
max_days: draft.max_days,
prefer_same_resource: draft.prefer_same_resource,
steps: draft.steps
.filter((s) => s.energy.trim() !== '')
.map((s) => ({ session_number: s.session_number, params: { energy: Number(s.energy) } })),
};
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, steps: [] })}>
<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,
service_uuid: p.service_uuid,
session_count: p.session_count,
min_days: p.min_days,
ideal_days: p.ideal_days,
max_days: p.max_days,
prefer_same_resource: p.prefer_same_resource,
steps: p.steps.map((s) => ({
session_number: s.session_number,
energy: String(s.params.energy ?? ''),
})),
})
}
>
ویرایش
</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 ||
(!draft.uuid && draft.service_uuid === '') ||
draft.session_count < 2 ||
orderInvalid ||
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 }}>
{!draft.uuid && (
<div className="field-block">
<label>سرویس</label>
<SearchableSelect
value={draft.service_uuid}
onChange={(v) => setDraft({ ...draft, service_uuid: String(v ?? '') })}
options={services.map((s) => ({ value: s.uuid, label: s.name }))}
placeholder="انتخاب سرویس"
/>
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>هر سرویس یک پروتکل دارد.</span>
</div>
)}
<div className="field-block" style={{ maxWidth: 200 }}>
<label htmlFor="cp-sessions">تعداد جلسه</label>
<input
id="cp-sessions"
className="input"
type="number"
min={2}
value={draft.session_count}
onChange={(e) => setDraft({ ...draft, session_count: Number(e.target.value) })}
/>
{draft.session_count < 2 && (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
دورهٔ کمتر از دو جلسه همان نوبت تکی است.
</span>
)}
</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{([
['min_days', 'حداقل (روز)'],
['ideal_days', 'ایده‌آل (روز)'],
['max_days', 'حداکثر (روز)'],
] as const).map(([key, label]) => (
<div className="field-block" key={key} style={{ maxWidth: 150 }}>
<label htmlFor={`cp-${key}`}>{label}</label>
<input
id={`cp-${key}`}
className="input"
type="number"
min={1}
value={draft[key]}
onChange={(e) => setDraft({ ...draft, [key]: Number(e.target.value) })}
/>
</div>
))}
</div>
{orderInvalid && (
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
ترتیب باید حداقل ایده‌آل حداکثر باشد.
</span>
)}
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={draft.prefer_same_resource}
onChange={(e) => setDraft({ ...draft, prefer_same_resource: e.target.checked })}
/>
تا حد امکان همان منبع جلسهٔ قبل
</label>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
<h3 style={{ fontSize: 14, margin: '0 0 10px' }}>پارامتر جلسات (اختیاری)</h3>
{draft.steps.map((step, index) => (
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
className="input"
style={{ maxWidth: 110 }}
type="number"
min={1}
max={draft.session_count}
value={step.session_number}
onChange={(e) =>
setDraft({
...draft,
steps: draft.steps.map((s, i) =>
i === index ? { ...s, session_number: Number(e.target.value) } : s,
),
})
}
/>
<input
className="input"
style={{ maxWidth: 140 }}
type="number"
placeholder="سطح انرژی"
value={step.energy}
onChange={(e) =>
setDraft({
...draft,
steps: draft.steps.map((s, i) => (i === index ? { ...s, energy: e.target.value } : s)),
})
}
/>
<button
type="button"
className="btn secondary sm"
onClick={() => setDraft({ ...draft, steps: draft.steps.filter((_, i) => i !== index) })}
aria-label="حذف پارامتر"
>
<TrashIcon style={{ width: 15 }} />
</button>
</div>
))}
<button
type="button"
className="btn secondary sm"
onClick={() =>
setDraft({
...draft,
steps: [...draft.steps, { session_number: draft.steps.length + 1, energy: '' }],
})
}
>
<PlusIcon style={{ width: 15 }} /> افزودن پارامتر جلسه
</button>
</div>
</div>
)}
</Modal>
</div>
);
}