The last structural gap from task 05 was the third occupancy mode. It is passive: the resource is genuinely held — nobody else can take that room while the patient waits for the anaesthetic — but the time is not work done. It blocks exactly like exclusive; the difference is in the report, where without it a room that spends half its day waiting reads as fully utilised. The mode is validated, offered in the segment editor and carried through to the plan. Everything else that was still marked as a deviation is now recorded in docs/architecture/deviations.md, one row each, in the form "what the plan said / what was built / why". That includes the ones I would defend (five plan services collapsed into one builder that only build() calls; a Skill foreign key instead of a JSON array, because a deleted skill in JSON fails silently) and the ones that are simply facts about the product (service_option does not exist here, so a column for it would sit empty until someone read it as a bug). The i18n section says plainly that the product is single-language and describes the order to migrate in if that changes — a translation layer with one language is an indirection, not an abstraction. All sixteen checklists now read zero pending and zero unresolved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
465 lines
18 KiB
TypeScript
465 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' | 'passive';
|
|
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: 'اشتراکی — از ظرفیت منبع یکی کم میشود' },
|
|
// مثل انحصاری قفل میکند، ولی در گزارش «کار مفید» حساب نمیشود.
|
|
{ value: 'passive', 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' | 'passive') ?? '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>
|
|
);
|
|
}
|