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>
350 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|