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>
197 lines
7.7 KiB
TypeScript
197 lines
7.7 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
|
import { formatDate } from '../lib/utils';
|
|
import { usePermissions } from '../hooks/usePermissions';
|
|
import { useBranches } from '../hooks/useBranches';
|
|
import { useNextSlotSuggestion, useTreatmentCourse } from '../hooks/useCourses';
|
|
import type { CourseSessionRow } from '../types';
|
|
|
|
const SESSION_STATUS: Record<CourseSessionRow['status'], { label: string; className: string }> = {
|
|
planned: { label: 'برنامهریزیشده', className: 'badge' },
|
|
booked: { label: 'رزروشده', className: 'badge amber' },
|
|
completed: { label: 'انجامشده', className: 'badge green' },
|
|
skipped: { label: 'ردشده', className: 'badge red' },
|
|
};
|
|
|
|
/**
|
|
* یک دورهٔ درمان: پیشرفت، جلسات، و پیشنهاد تاریخ جلسهٔ بعدی.
|
|
*
|
|
* پیشنهاد به شعبه وابسته است (ظرفیت هر شعبه فرق دارد)، پس تا شعبه انتخاب نشود چیزی
|
|
* پرسیده نمیشود.
|
|
*/
|
|
export default function TreatmentCoursePage() {
|
|
const { courseUuid } = useParams<{ courseUuid: string }>();
|
|
const { course, loading, abandon } = useTreatmentCourse(courseUuid);
|
|
const { branches } = useBranches();
|
|
const { can } = usePermissions();
|
|
const canManage = can('appointment_settings', 'update');
|
|
|
|
const [branchUuid, setBranchUuid] = useState('');
|
|
const [abandoning, setAbandoning] = useState(false);
|
|
const [reason, setReason] = useState('');
|
|
|
|
const { suggestion } = useNextSlotSuggestion(courseUuid, branchUuid || undefined);
|
|
|
|
const columns: Column<CourseSessionRow>[] = [
|
|
{
|
|
key: 'session_number',
|
|
header: 'جلسه',
|
|
render: (s) => <span style={{ fontWeight: 600 }}>{s.session_number}</span>,
|
|
},
|
|
{
|
|
key: 'status',
|
|
header: 'وضعیت',
|
|
render: (s) => (
|
|
<span className={SESSION_STATUS[s.status].className}>
|
|
<span className="bdot" />
|
|
{SESSION_STATUS[s.status].label}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'slot_start',
|
|
header: 'تاریخ نوبت',
|
|
render: (s) => (
|
|
<span style={{ fontSize: 13 }}>{s.slot_start === null ? '—' : formatDate(s.slot_start)}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'params',
|
|
header: 'پارامتر',
|
|
render: (s) => (
|
|
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>
|
|
{Object.entries(s.params).length === 0
|
|
? '—'
|
|
: Object.entries(s.params)
|
|
.map(([k, v]) => `${k}: ${v}`)
|
|
.join('، ')}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'completed_at',
|
|
header: 'انجامشده در',
|
|
render: (s) => (
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
{s.completed_at === null ? '—' : formatDate(s.completed_at)}
|
|
</span>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
<PageHeader
|
|
title={course ? `دورهٔ ${course.service_name}` : 'دورهٔ درمان'}
|
|
description="پیشرفت دوره، جلسات و پیشنهاد تاریخ جلسهٔ بعدی."
|
|
backTo="/admin/patients"
|
|
/>
|
|
|
|
{course && (
|
|
<div className="card card-pad" style={{ marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
|
|
<span style={{ fontSize: 15 }}>
|
|
جلسهٔ <strong>{course.progress.completed}</strong> از {course.progress.total} انجام شده
|
|
</span>
|
|
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>
|
|
رزروشده: {course.progress.booked} · باقیمانده: {course.progress.planned}
|
|
</span>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
فاصله: حداقل {course.min_days} · ایدهآل {course.ideal_days} · حداکثر {course.max_days} روز
|
|
</span>
|
|
{course.status !== 'active' && (
|
|
<span className="badge red">
|
|
<span className="bdot" />
|
|
{course.status === 'completed' ? 'تمامشده' : 'رهاشده'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{course.abandon_reason && (
|
|
<span style={{ fontSize: 13, color: 'var(--text-2)' }}>دلیل رهاکردن: {course.abandon_reason}</span>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, flexWrap: 'wrap' }}>
|
|
<div className="field-block" style={{ minWidth: 220, margin: 0 }}>
|
|
<label>شعبه برای پیشنهاد وقت</label>
|
|
<SearchableSelect
|
|
value={branchUuid}
|
|
onChange={(v) => setBranchUuid(String(v ?? ''))}
|
|
options={branches.map((b) => ({ value: b.uuid, label: b.name || 'بدون نام' }))}
|
|
placeholder="انتخاب شعبه"
|
|
/>
|
|
</div>
|
|
|
|
{canManage && course.status === 'active' && (
|
|
<button type="button" className="btn secondary sm" onClick={() => setAbandoning(true)}>
|
|
رهاکردن دوره
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{suggestion && suggestion.session_number !== null && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 13 }}>
|
|
<span>
|
|
جلسهٔ بعدی: <strong>{suggestion.session_number}</strong>
|
|
{suggestion.ideal_at !== undefined && ` · تاریخ ایدهآل ${formatDate(suggestion.ideal_at)}`}
|
|
</span>
|
|
{suggestion.range && (
|
|
<span style={{ color: 'var(--text-3)', fontSize: 12 }}>
|
|
بازهٔ مجاز: {formatDate(suggestion.range.min)} تا {formatDate(suggestion.range.max)}
|
|
</span>
|
|
)}
|
|
{suggestion.suggested_slots.length > 0 && (
|
|
<span style={{ color: 'var(--text-2)' }}>
|
|
نزدیکترین وقتها:{' '}
|
|
{suggestion.suggested_slots.map((s) => formatDate(s.start)).join('، ')}
|
|
</span>
|
|
)}
|
|
{suggestion.warning && (
|
|
<span style={{ color: 'var(--warning)' }}>{suggestion.warning}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<DataTable
|
|
columns={columns}
|
|
data={course?.sessions ?? []}
|
|
loading={loading}
|
|
emptyMessage="این دوره جلسهای ندارد"
|
|
/>
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
open={abandoning}
|
|
title="رهاکردن دوره"
|
|
message="جلسات باقیمانده برنامهریزیشده میمانند و دوره از فهرست فعال خارج میشود."
|
|
confirmLabel="رهاکن"
|
|
danger
|
|
loading={abandon.isPending}
|
|
onCancel={() => setAbandoning(false)}
|
|
onConfirm={async () => {
|
|
await abandon.mutateAsync(reason.trim() || 'رهاکردن دوره');
|
|
setAbandoning(false);
|
|
}}
|
|
>
|
|
<div className="field-block">
|
|
<label htmlFor="abandon-reason">دلیل</label>
|
|
<input
|
|
id="abandon-reason"
|
|
className="input"
|
|
value={reason}
|
|
onChange={(e) => setReason(e.target.value)}
|
|
placeholder="مثلاً: انصراف بیمار"
|
|
/>
|
|
</div>
|
|
</ConfirmDialog>
|
|
</div>
|
|
);
|
|
}
|