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>
463 lines
18 KiB
TypeScript
463 lines
18 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { toast } from 'sonner';
|
|
import SearchableSelect from './ui/SearchableSelect';
|
|
import { api, ApiError, type ApiResponse } from '../lib/api';
|
|
import { useBranches } from '../hooks/useBranches';
|
|
|
|
interface SegmentRequirementDraft {
|
|
type_uuid: string;
|
|
skill_uuid: string | null;
|
|
count: number;
|
|
occupancy: 'exclusive' | 'shared';
|
|
constraints: string[];
|
|
}
|
|
|
|
interface SegmentDraft {
|
|
sequence: number;
|
|
name: string;
|
|
duration_source: 'fixed' | 'items';
|
|
duration_minutes: number;
|
|
patient_present: boolean;
|
|
mergeable: boolean;
|
|
requirements: SegmentRequirementDraft[];
|
|
}
|
|
|
|
interface PlanPreviewSegment {
|
|
sequence: number;
|
|
name: string;
|
|
offset_minutes: number;
|
|
duration_minutes: number;
|
|
patient_present: boolean;
|
|
requirements: { role_name: string; count: number; candidates?: number }[];
|
|
}
|
|
|
|
interface PlanPreview {
|
|
total_minutes: number;
|
|
segments: PlanPreviewSegment[];
|
|
}
|
|
|
|
interface ResourceType {
|
|
uuid: string;
|
|
code: string;
|
|
name: string;
|
|
}
|
|
|
|
const OCCUPANCY_OPTIONS = [
|
|
{ value: 'exclusive', label: 'انحصاری — منبع کامل قفل میشود' },
|
|
{ value: 'shared', label: 'اشتراکی — از ظرفیت منبع یکی کم میشود' },
|
|
];
|
|
|
|
const DURATION_OPTIONS = [
|
|
{ value: 'fixed', label: 'مدت ثابت' },
|
|
{ value: 'items', label: 'از آیتمهای انتخابی' },
|
|
];
|
|
|
|
interface Props {
|
|
serviceUuid: string;
|
|
canEdit: boolean;
|
|
}
|
|
|
|
/**
|
|
* بخشهای نوبت — بخش UI تسک ۰۵.
|
|
*
|
|
* نوار پیشنمایش تنها جایی است که تفاوت «مدت نوبت» و «زمانی که منبع واقعاً درگیر است»
|
|
* دیده میشود؛ همان تفاوتی که کل تسک ۰۵ برایش وجود دارد: «انتظار اثر کرم» اتاق را
|
|
* میگیرد ولی اپراتور را آزاد میگذارد.
|
|
*/
|
|
export default function ServiceSegmentsTab({ serviceUuid, canEdit }: Props) {
|
|
const qc = useQueryClient();
|
|
const { branches } = useBranches();
|
|
|
|
const [segments, setSegments] = useState<SegmentDraft[]>([]);
|
|
const [branchUuid, setBranchUuid] = useState('');
|
|
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ['service-segments', serviceUuid],
|
|
queryFn: () =>
|
|
api.get<ApiResponse<{ segments: SegmentDraft[] }>>(`/api/v1/service-item/${serviceUuid}/segments`),
|
|
enabled: !!serviceUuid,
|
|
});
|
|
|
|
const { data: typesData } = useQuery({
|
|
queryKey: ['resource-types-for-segments'],
|
|
queryFn: () => api.get<ApiResponse<ResourceType[]>>('/api/v1/resource-types'),
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (data?.data?.segments) setSegments(data.data.segments);
|
|
}, [data]);
|
|
|
|
const save = useMutation({
|
|
mutationFn: (body: SegmentDraft[]) =>
|
|
api.put<ApiResponse<{ segments: SegmentDraft[] }>>(
|
|
`/api/v1/service-item/${serviceUuid}/segments`,
|
|
{ segments: body },
|
|
),
|
|
onSuccess: () => {
|
|
toast.success('بخشها ذخیره شد');
|
|
qc.invalidateQueries({ queryKey: ['service-segments', serviceUuid] });
|
|
qc.invalidateQueries({ queryKey: ['segments-preview', serviceUuid] });
|
|
},
|
|
onError: (e) => toast.error(e instanceof ApiError ? e.message : 'ذخیرهٔ بخشها ناموفق بود'),
|
|
});
|
|
|
|
const { data: previewData, error: previewError } = useQuery({
|
|
queryKey: ['segments-preview', serviceUuid, branchUuid],
|
|
queryFn: () =>
|
|
api.post<ApiResponse<PlanPreview>>('/api/v1/appointment-plan/preview', {
|
|
service_uuid: serviceUuid,
|
|
branch_uuid: branchUuid,
|
|
}),
|
|
enabled: !!branchUuid,
|
|
retry: false,
|
|
});
|
|
|
|
const preview = previewData?.data;
|
|
const types = typesData?.data ?? [];
|
|
|
|
// «اپراتور واقعاً درگیر» — مجموع بخشهایی که بیمار حاضر است.
|
|
const patientFacingMinutes =
|
|
preview?.segments.filter((s) => s.patient_present).reduce((sum, s) => sum + s.duration_minutes, 0) ?? 0;
|
|
|
|
const patch = (index: number, changes: Partial<SegmentDraft>) =>
|
|
setSegments(segments.map((s, i) => (i === index ? { ...s, ...changes } : s)));
|
|
|
|
const patchRequirement = (
|
|
segmentIndex: number,
|
|
reqIndex: number,
|
|
changes: Partial<SegmentRequirementDraft>,
|
|
) =>
|
|
setSegments(
|
|
segments.map((s, i) =>
|
|
i === segmentIndex
|
|
? { ...s, requirements: s.requirements.map((r, j) => (j === reqIndex ? { ...r, ...changes } : r)) }
|
|
: s,
|
|
),
|
|
);
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
|
<section className="card card-pad" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
|
<h3 style={{ fontSize: 15, margin: 0 }}>بخشهای نوبت</h3>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
سرویس بدون بخش، یک نوبت پیوسته است که پزشک را میگیرد — همان رفتار امروز.
|
|
</span>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری…</span>
|
|
) : (
|
|
segments.map((segment, index) => (
|
|
<div
|
|
key={index}
|
|
style={{
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 'var(--r)',
|
|
padding: 14,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 10,
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
ترتیب
|
|
<input
|
|
className="input"
|
|
style={{ width: 70 }}
|
|
type="number"
|
|
min={1}
|
|
value={segment.sequence}
|
|
disabled={!canEdit}
|
|
onChange={(e) => patch(index, { sequence: Number(e.target.value) })}
|
|
/>
|
|
</label>
|
|
|
|
<input
|
|
className="input"
|
|
style={{ maxWidth: 220 }}
|
|
value={segment.name}
|
|
placeholder="نام بخش"
|
|
disabled={!canEdit}
|
|
onChange={(e) => patch(index, { name: e.target.value })}
|
|
/>
|
|
|
|
<div style={{ minWidth: 180 }}>
|
|
<SearchableSelect
|
|
value={segment.duration_source}
|
|
onChange={(v) => patch(index, { duration_source: (v as 'fixed' | 'items') ?? 'fixed' })}
|
|
options={DURATION_OPTIONS}
|
|
isDisabled={!canEdit}
|
|
/>
|
|
</div>
|
|
|
|
{segment.duration_source === 'fixed' && (
|
|
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
دقیقه
|
|
<input
|
|
className="input"
|
|
style={{ width: 90 }}
|
|
type="number"
|
|
min={1}
|
|
value={segment.duration_minutes}
|
|
disabled={!canEdit}
|
|
onChange={(e) => patch(index, { duration_minutes: Number(e.target.value) })}
|
|
/>
|
|
</label>
|
|
)}
|
|
|
|
{canEdit && (
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
style={{ marginRight: 'auto' }}
|
|
onClick={() => setSegments(segments.filter((_, i) => i !== index))}
|
|
aria-label="حذف بخش"
|
|
>
|
|
<TrashIcon style={{ width: 15 }} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={segment.patient_present}
|
|
disabled={!canEdit}
|
|
onChange={(e) => patch(index, { patient_present: e.target.checked })}
|
|
/>
|
|
بیمار حاضر است
|
|
</label>
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={segment.mergeable}
|
|
disabled={!canEdit}
|
|
onChange={(e) => patch(index, { mergeable: e.target.checked })}
|
|
/>
|
|
با چند آیتم فقط یک بار بیاید
|
|
</label>
|
|
</div>
|
|
|
|
{/* ── نیازمندیهای منبع ─────────────────────────────────────── */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{segment.requirements.map((requirement, reqIndex) => (
|
|
<div key={reqIndex} style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
<div style={{ minWidth: 170 }}>
|
|
<SearchableSelect
|
|
value={requirement.type_uuid}
|
|
onChange={(v) => patchRequirement(index, reqIndex, { type_uuid: String(v ?? '') })}
|
|
options={types.map((t) => ({ value: t.uuid, label: t.name }))}
|
|
placeholder="نقش منبع"
|
|
isDisabled={!canEdit}
|
|
/>
|
|
</div>
|
|
|
|
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
تعداد
|
|
<input
|
|
className="input"
|
|
style={{ width: 70 }}
|
|
type="number"
|
|
min={1}
|
|
value={requirement.count}
|
|
disabled={!canEdit}
|
|
onChange={(e) =>
|
|
patchRequirement(index, reqIndex, { count: Number(e.target.value) })
|
|
}
|
|
/>
|
|
</label>
|
|
|
|
<div style={{ minWidth: 250 }}>
|
|
<SearchableSelect
|
|
value={requirement.occupancy}
|
|
onChange={(v) =>
|
|
patchRequirement(index, reqIndex, {
|
|
occupancy: (v as 'exclusive' | 'shared') ?? 'exclusive',
|
|
})
|
|
}
|
|
options={OCCUPANCY_OPTIONS}
|
|
isDisabled={!canEdit}
|
|
/>
|
|
</div>
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={requirement.constraints.includes('same_gender_as_patient')}
|
|
disabled={!canEdit}
|
|
onChange={(e) =>
|
|
patchRequirement(index, reqIndex, {
|
|
constraints: e.target.checked ? ['same_gender_as_patient'] : [],
|
|
})
|
|
}
|
|
/>
|
|
همجنس بیمار
|
|
</label>
|
|
|
|
{canEdit && (
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
onClick={() =>
|
|
patch(index, {
|
|
requirements: segment.requirements.filter((_, j) => j !== reqIndex),
|
|
})
|
|
}
|
|
aria-label="حذف نیازمندی"
|
|
>
|
|
<TrashIcon style={{ width: 15 }} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{canEdit && (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
onClick={() =>
|
|
patch(index, {
|
|
requirements: [
|
|
...segment.requirements,
|
|
{ type_uuid: '', skill_uuid: null, count: 1, occupancy: 'exclusive', constraints: [] },
|
|
],
|
|
})
|
|
}
|
|
>
|
|
<PlusIcon style={{ width: 15 }} /> افزودن نیازمندی منبع
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
|
|
{canEdit && (
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button
|
|
type="button"
|
|
className="btn secondary sm"
|
|
onClick={() =>
|
|
setSegments([
|
|
...segments,
|
|
{
|
|
sequence: segments.length + 1,
|
|
name: '',
|
|
duration_source: 'fixed',
|
|
duration_minutes: 15,
|
|
patient_present: true,
|
|
mergeable: false,
|
|
requirements: [],
|
|
},
|
|
])
|
|
}
|
|
>
|
|
<PlusIcon style={{ width: 15 }} /> افزودن بخش
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
className="btn primary sm"
|
|
disabled={save.isPending || segments.some((s) => s.name.trim() === '')}
|
|
onClick={() => save.mutate(segments)}
|
|
>
|
|
ذخیرهٔ بخشها
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* ── نوار پیشنمایش زمانی ──────────────────────────────────────────── */}
|
|
<section className="card card-pad" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 12, 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>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
منابع واجد شرایط از همان شعبه خوانده میشوند، پس پیشنمایش بدون شعبه معنا ندارد.
|
|
</span>
|
|
</div>
|
|
|
|
{previewError && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<span style={{ fontSize: 13, color: 'var(--danger)' }}>
|
|
{previewError instanceof ApiError ? previewError.message : 'پیشنمایش ساخته نشد'}
|
|
</span>
|
|
{/* خطای «هیچ منبعی نیست» بدون راه اصلاح، فقط بنبست است. */}
|
|
<Link className="btn secondary sm" to="/admin/resources" style={{ alignSelf: 'flex-start' }}>
|
|
افزودن منبع به این شعبه
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{preview && (
|
|
<>
|
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 14 }}>
|
|
<span>
|
|
مدت کل نوبت: <strong>{preview.total_minutes}</strong> دقیقه
|
|
</span>
|
|
<span style={{ color: 'var(--text-2)' }}>
|
|
بیمار واقعاً درگیر: {patientFacingMinutes} دقیقه
|
|
</span>
|
|
</div>
|
|
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<div style={{ display: 'flex', minWidth: 420, gap: 2 }}>
|
|
{preview.segments.map((segment) => (
|
|
<div
|
|
key={segment.sequence}
|
|
title={`${segment.name} — ${segment.duration_minutes} دقیقه`}
|
|
style={{
|
|
// عرض متناسب مدت: بخش سیدقیقهای باید شش برابر بخش پنجدقیقهای دیده شود.
|
|
flex: `${Math.max(1, segment.duration_minutes)} 0 0`,
|
|
minWidth: 60,
|
|
padding: '10px 8px',
|
|
borderRadius: 'var(--r-sm)',
|
|
background: segment.patient_present ? 'var(--primary-soft)' : 'var(--surface-2)',
|
|
border: '1px solid var(--border)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 4,
|
|
}}
|
|
>
|
|
<span style={{ fontSize: 12, fontWeight: 600 }}>{segment.name}</span>
|
|
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>
|
|
{segment.duration_minutes} دقیقه
|
|
</span>
|
|
<span style={{ fontSize: 11, color: 'var(--text-2)' }}>
|
|
{segment.requirements.length === 0
|
|
? 'بدون منبع'
|
|
: segment.requirements.map((r) => r.role_name).join('، ')}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
بخشهای کمرنگ آنهاییاند که بیمار حاضر نیست — منبع گرفته میشود ولی کاری
|
|
روی بیمار انجام نمیشود.
|
|
</span>
|
|
</>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|