feat(admin): catalog groups and appointment segments editors
Tasks 04 and 05 shipped working engines that a clinic could only reach through the API. Both now have the panel that makes them usable. Groups tab - Inline min/max per group, saved on blur, with the meaning of an empty maximum spelled out next to the field rather than left as folklore - Incompatible / prerequisite rows; the prerequisite-cycle 422 surfaces the server's own message, which is more precise than anything generic - A live preview that calls the same service-selection/validate the public site calls, debounced 400ms. Two separate calculations would eventually show the operator and the patient different numbers - The breakdown table shows which item was counted as the anchor and which as additional, so a surprising total explains itself Segments tab - Sequence, duration source, patient-present and mergeable per segment, plus resource requirements with an explanation attached to each occupancy mode - A timeline bar whose widths are proportional to duration, with segments the patient is absent for drawn faded. That contrast is the whole point of task 05: the waiting segment holds the room but frees the operator - "No eligible resource" renders with a link to add one — an error with no route forward is a dead end Task 05's checklist had been left on "not started" this whole time even though its code shipped with the task; it is now filled in against reality. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../test/utils';
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
ApiError: class extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { api } from '../lib/api';
|
||||
import ServiceGroupsTab from './ServiceGroupsTab';
|
||||
|
||||
const get = api.get as ReturnType<typeof vi.fn>;
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||
|
||||
const groups = [
|
||||
{
|
||||
uuid: 'g1',
|
||||
name: 'نواحی بدن',
|
||||
min_select: 1,
|
||||
max_select: null,
|
||||
items: [{ uuid: 'i1', name: 'صورت' }],
|
||||
},
|
||||
];
|
||||
|
||||
const items = [
|
||||
{ uuid: 'i1', name: 'صورت' },
|
||||
{ uuid: 'i2', name: 'بیکینی' },
|
||||
];
|
||||
|
||||
function mockApi() {
|
||||
get.mockImplementation((path: string) => {
|
||||
if (path.endsWith('/groups')) return Promise.resolve({ success: true, data: groups });
|
||||
if (path === '/api/v1/service-items') return Promise.resolve({ success: true, data: items });
|
||||
return Promise.resolve({ success: true, data: null });
|
||||
});
|
||||
post.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
valid: false,
|
||||
errors: [{ code: 'min_select', message: 'انتخاب حداقل ۱ مورد از «نواحی بدن» الزامی است' }],
|
||||
total_duration_minutes: 23,
|
||||
total_price_rials: 800000,
|
||||
breakdown: [
|
||||
{ item_uuid: 'i1', item_name: 'صورت', counted_as: 'solo', minutes: 15, price_rials: 500000 },
|
||||
{ item_uuid: 'i2', item_name: 'بیکینی', counted_as: 'additional', minutes: 8, price_rials: 300000 },
|
||||
],
|
||||
},
|
||||
});
|
||||
patch.mockResolvedValue({ success: true, data: groups[0] });
|
||||
}
|
||||
|
||||
describe('ServiceGroupsTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockApi();
|
||||
});
|
||||
|
||||
it('shows each group with its min and max', async () => {
|
||||
renderWithProviders(<ServiceGroupsTab serviceUuid="s1" canEdit />, { route: '/admin/services/s1' });
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('نواحی بدن')).toBeInTheDocument());
|
||||
expect(screen.getByDisplayValue('1')).toBeInTheDocument();
|
||||
// حداکثرِ خالی یعنی نامحدود — نه صفر.
|
||||
expect(screen.getByPlaceholderText('نامحدود')).toHaveValue(null);
|
||||
});
|
||||
|
||||
/**
|
||||
* ⭐ پیشنمایش نباید بدون انتخاب، درخواستی بزند: فراخوانی با سبد خالی هم نویز شبکه
|
||||
* است هم خطای «حداقل انتخاب» را بیجا نشان میدهد.
|
||||
*/
|
||||
it('does not call validate until something is selected', async () => {
|
||||
renderWithProviders(<ServiceGroupsTab serviceUuid="s1" canEdit />, { route: '/admin/services/s1' });
|
||||
|
||||
await waitFor(() => expect(screen.getByText('پیشنمایش انتخاب')).toBeInTheDocument());
|
||||
|
||||
expect(post).not.toHaveBeenCalledWith('/api/v1/service-selection/validate', expect.anything());
|
||||
expect(screen.getByText(/چند آیتم انتخاب کنید/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains the meaning of an empty maximum', async () => {
|
||||
renderWithProviders(<ServiceGroupsTab serviceUuid="s1" canEdit />, { route: '/admin/services/s1' });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/حداکثرِ خالی یعنی نامحدود/)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
import ConfirmDialog from './ui/ConfirmDialog';
|
||||
import { formatRial } from '../lib/utils';
|
||||
import {
|
||||
useAllServiceItems,
|
||||
useSelectionPreview,
|
||||
useServiceGroups,
|
||||
useServiceRelations,
|
||||
type ItemRelation,
|
||||
} from '../hooks/useServiceCatalog';
|
||||
|
||||
interface Props {
|
||||
serviceUuid: string;
|
||||
canEdit: boolean;
|
||||
/** روابط فعلیِ سرویس از پاسخ جزئیات — `PUT` جایگزینی کامل است. */
|
||||
initialRelations?: ItemRelation[];
|
||||
}
|
||||
|
||||
const RELATION_LABELS: Record<ItemRelation['type'], string> = {
|
||||
incompatible_with: 'با هم انجام نمیشوند',
|
||||
requires: 'پیشنیاز دارد',
|
||||
};
|
||||
|
||||
/**
|
||||
* گروههای انتخاب و روابط آیتمها — بخش UI تسک ۰۴.
|
||||
*
|
||||
* پیشنمایش مدت و قیمت از **همان** اندپوینتی میآید که سایت عمومی میزند
|
||||
* (`service-selection/validate`)، نه از یک جمعزدن جداگانه در فرانت: دو محاسبه یعنی
|
||||
* بالاخره روزی دو عدد متفاوت به اپراتور و بیمار نشان داده شود.
|
||||
*/
|
||||
export default function ServiceGroupsTab({ serviceUuid, canEdit, initialRelations = [] }: Props) {
|
||||
const { groups, loading, create, update, remove, setItems } = useServiceGroups(serviceUuid);
|
||||
const { save: saveRelations } = useServiceRelations(serviceUuid);
|
||||
const { items } = useAllServiceItems();
|
||||
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
const [relations, setRelations] = useState<ItemRelation[]>(initialRelations);
|
||||
|
||||
// ── پیشنمایش زنده با debounce ───────────────────────────────────────────
|
||||
// بدون تأخیر، هر کلیک روی چیپ یک درخواست میسازد و اپراتورِ سریع، ده درخواست.
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [debounced, setDebounced] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(selected), 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [selected]);
|
||||
|
||||
const { preview, loading: previewing } = useSelectionPreview(debounced, serviceUuid);
|
||||
|
||||
const itemOptions = useMemo(
|
||||
() => items.map((i) => ({ value: i.uuid, label: i.name })),
|
||||
[items],
|
||||
);
|
||||
|
||||
const nameOf = (uuid: string) => items.find((i) => i.uuid === uuid)?.name ?? uuid;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
{/* ── گروهها ───────────────────────────────────────────────────────── */}
|
||||
<section className="card" 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>
|
||||
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div className="field" style={{ minWidth: 240, margin: 0 }}>
|
||||
<label htmlFor="new-group">گروه تازه</label>
|
||||
<input
|
||||
id="new-group"
|
||||
className="input"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
placeholder="مثلاً: نواحی بدن"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary sm"
|
||||
disabled={newGroupName.trim() === '' || create.isPending}
|
||||
onClick={async () => {
|
||||
await create.mutateAsync({ name: newGroupName.trim(), min_select: 0, max_select: null });
|
||||
setNewGroupName('');
|
||||
}}
|
||||
>
|
||||
<PlusIcon style={{ width: 15 }} /> افزودن گروه
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری…</span>
|
||||
) : groups.length === 0 ? (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||
این سرویس گروه انتخابی ندارد؛ بیمار هر ترکیبی را میتواند انتخاب کند.
|
||||
</span>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<div
|
||||
key={group.uuid}
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r)',
|
||||
padding: 14,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 220 }}
|
||||
defaultValue={group.name}
|
||||
disabled={!canEdit}
|
||||
onBlur={(e) => {
|
||||
if (e.target.value.trim() !== '' && e.target.value !== group.name) {
|
||||
update.mutate({ uuid: group.uuid, body: { name: e.target.value.trim() } });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
حداقل
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: 70 }}
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={group.min_select}
|
||||
disabled={!canEdit}
|
||||
onBlur={(e) =>
|
||||
update.mutate({ uuid: group.uuid, body: { min_select: Number(e.target.value) } })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
حداکثر
|
||||
<input
|
||||
className="input"
|
||||
style={{ width: 70 }}
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="نامحدود"
|
||||
defaultValue={group.max_select ?? ''}
|
||||
disabled={!canEdit}
|
||||
onBlur={(e) =>
|
||||
update.mutate({
|
||||
uuid: group.uuid,
|
||||
// خالی یعنی نامحدود؛ صفر یعنی «هیچکدام» و معنای متفاوتی دارد.
|
||||
body: { max_select: e.target.value === '' ? null : Number(e.target.value) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
style={{ marginRight: 'auto' }}
|
||||
onClick={() => setDeleting(group.uuid)}
|
||||
aria-label="حذف گروه"
|
||||
>
|
||||
<TrashIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field" style={{ margin: 0 }}>
|
||||
<label>آیتمهای این گروه</label>
|
||||
<SearchableSelect
|
||||
value={null}
|
||||
onChange={(v) => {
|
||||
const uuid = String(v ?? '');
|
||||
if (uuid && !group.items.some((i) => i.uuid === uuid)) {
|
||||
setItems.mutate({
|
||||
uuid: group.uuid,
|
||||
itemUuids: [...group.items.map((i) => i.uuid), uuid],
|
||||
});
|
||||
}
|
||||
}}
|
||||
options={itemOptions.filter((o) => !group.items.some((i) => i.uuid === o.value))}
|
||||
placeholder="افزودن آیتم"
|
||||
isDisabled={!canEdit}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
|
||||
{group.items.map((item) => (
|
||||
<button
|
||||
key={item.uuid}
|
||||
type="button"
|
||||
className="badge"
|
||||
disabled={!canEdit}
|
||||
onClick={() =>
|
||||
setItems.mutate({
|
||||
uuid: group.uuid,
|
||||
itemUuids: group.items.filter((i) => i.uuid !== item.uuid).map((i) => i.uuid),
|
||||
})
|
||||
}
|
||||
>
|
||||
{item.name} ✕
|
||||
</button>
|
||||
))}
|
||||
{group.items.length === 0 && (
|
||||
<span style={{ fontSize: 12, color: 'var(--danger)' }}>
|
||||
گروه بدون آیتم، حداقلِ انتخابش هرگز برآورده نمیشود.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── روابط ─────────────────────────────────────────────────────────── */}
|
||||
<section className="card" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<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>
|
||||
|
||||
{relations.map((relation, index) => (
|
||||
<div key={index} style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ minWidth: 200 }}>
|
||||
<SearchableSelect
|
||||
value={relation.related_item_uuid}
|
||||
onChange={(v) =>
|
||||
setRelations(
|
||||
relations.map((r, i) =>
|
||||
i === index ? { ...r, related_item_uuid: String(v ?? '') } : r,
|
||||
),
|
||||
)
|
||||
}
|
||||
options={itemOptions}
|
||||
isDisabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ minWidth: 190 }}>
|
||||
<SearchableSelect
|
||||
value={relation.type}
|
||||
onChange={(v) =>
|
||||
setRelations(
|
||||
relations.map((r, i) =>
|
||||
i === index ? { ...r, type: (v as ItemRelation['type']) ?? 'incompatible_with' } : r,
|
||||
),
|
||||
)
|
||||
}
|
||||
options={Object.entries(RELATION_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
isDisabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() => setRelations(relations.filter((_, i) => i !== index))}
|
||||
aria-label="حذف رابطه"
|
||||
>
|
||||
<TrashIcon style={{ width: 15 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{canEdit && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary sm"
|
||||
onClick={() =>
|
||||
setRelations([...relations, { related_item_uuid: '', type: 'incompatible_with' }])
|
||||
}
|
||||
>
|
||||
<PlusIcon style={{ width: 15 }} /> افزودن رابطه
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary sm"
|
||||
disabled={saveRelations.isPending || relations.some((r) => r.related_item_uuid === '')}
|
||||
onClick={() => saveRelations.mutate(relations)}
|
||||
>
|
||||
ذخیرهٔ روابط
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── پیشنمایش زنده ────────────────────────────────────────────────── */}
|
||||
<section className="card" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<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>
|
||||
|
||||
<SearchableSelect
|
||||
value={null}
|
||||
onChange={(v) => {
|
||||
const uuid = String(v ?? '');
|
||||
if (uuid && !selected.includes(uuid)) setSelected([...selected, uuid]);
|
||||
}}
|
||||
options={itemOptions.filter((o) => !selected.includes(o.value))}
|
||||
placeholder="آیتمی را برای آزمایش انتخاب کنید"
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{selected.map((uuid) => (
|
||||
<button
|
||||
key={uuid}
|
||||
type="button"
|
||||
className="badge"
|
||||
onClick={() => setSelected(selected.filter((u) => u !== uuid))}
|
||||
>
|
||||
{nameOf(uuid)} ✕
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected.length === 0 ? (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||
چند آیتم انتخاب کنید تا مدت، قیمت و خطاهای ترکیب را ببینید.
|
||||
</span>
|
||||
) : previewing ? (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال محاسبه…</span>
|
||||
) : preview ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 14 }}>
|
||||
<span>
|
||||
مدت: <strong>{preview.total_duration_minutes}</strong> دقیقه
|
||||
</span>
|
||||
<span>قیمت: {formatRial(preview.total_price_rials)}</span>
|
||||
{preview.valid ? (
|
||||
<span className="badge green"><span className="bdot" />ترکیب معتبر</span>
|
||||
) : (
|
||||
<span className="badge red"><span className="bdot" />ترکیب نامعتبر</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* همهٔ خطاها با هم میآیند، نه اولی — کاربر نباید سه بار رفتوبرگشت کند. */}
|
||||
{preview.errors.map((error, index) => (
|
||||
<span key={index} style={{ fontSize: 13, color: 'var(--danger)' }}>
|
||||
{error.message}
|
||||
</span>
|
||||
))}
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', fontSize: 13, borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ color: 'var(--text-3)', textAlign: 'right' }}>
|
||||
<th style={{ padding: '6px 4px', fontWeight: 500 }}>آیتم</th>
|
||||
<th style={{ padding: '6px 4px', fontWeight: 500 }}>شمردهشده بهعنوان</th>
|
||||
<th style={{ padding: '6px 4px', fontWeight: 500 }}>دقیقه</th>
|
||||
<th style={{ padding: '6px 4px', fontWeight: 500 }}>قیمت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.breakdown.map((row) => (
|
||||
<tr key={row.item_uuid} style={{ borderTop: '1px solid var(--border)' }}>
|
||||
<td style={{ padding: '6px 4px' }}>{row.item_name}</td>
|
||||
<td style={{ padding: '6px 4px', color: 'var(--text-2)' }}>
|
||||
{row.counted_as === 'solo' ? 'مدت تنها (لنگر)' : 'مدت اضافه'}
|
||||
</td>
|
||||
<td style={{ padding: '6px 4px' }}>{row.minutes}</td>
|
||||
<td style={{ padding: '6px 4px' }}>{formatRial(row.price_rials)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
title="حذف گروه"
|
||||
message="آیتمهای گروه حذف نمیشوند؛ فقط قاعدهٔ «حداقل/حداکثر انتخاب» برداشته میشود."
|
||||
confirmLabel="حذف کن"
|
||||
danger
|
||||
loading={remove.isPending}
|
||||
onCancel={() => setDeleting(null)}
|
||||
onConfirm={async () => {
|
||||
await remove.mutateAsync(deleting!);
|
||||
setDeleting(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
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" 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" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="field" 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api, ApiError, type ApiResponse } from '../lib/api';
|
||||
import type { ServiceItem } from '../types';
|
||||
|
||||
/**
|
||||
* کاتالوگ v2 — گروه انتخاب، رابطهٔ آیتمها، و اعتبارسنجی زندهٔ انتخاب.
|
||||
*
|
||||
* `validate` هم مدت و قیمت را میدهد هم خطاها؛ همان اندپوینتی که سایت عمومی میزند.
|
||||
* پیشنمایش فرم از **همان** مسیر میآید تا عددی که اپراتور میبیند با عددی که بیمار
|
||||
* میبیند یکی باشد.
|
||||
*/
|
||||
export interface ItemGroup {
|
||||
uuid: string;
|
||||
name: string;
|
||||
min_select: number;
|
||||
/** `null` یعنی نامحدود — نه صفر */
|
||||
max_select: number | null;
|
||||
items: { uuid: string; name: string }[];
|
||||
}
|
||||
|
||||
export interface ItemRelation {
|
||||
related_item_uuid: string;
|
||||
related_item_name?: string;
|
||||
type: 'incompatible_with' | 'requires';
|
||||
}
|
||||
|
||||
export interface SelectionBreakdownRow {
|
||||
item_uuid: string;
|
||||
item_name: string;
|
||||
counted_as: 'solo' | 'additional';
|
||||
minutes: number;
|
||||
price_rials: number;
|
||||
}
|
||||
|
||||
export interface SelectionValidation {
|
||||
valid: boolean;
|
||||
errors: { code: string; message: string; group_uuid?: string; items?: string[] }[];
|
||||
total_duration_minutes: number;
|
||||
total_price_rials: number;
|
||||
breakdown: SelectionBreakdownRow[];
|
||||
}
|
||||
|
||||
function fail(e: unknown, fallback: string) {
|
||||
toast.error(e instanceof ApiError ? e.message : fallback);
|
||||
}
|
||||
|
||||
export function useServiceGroups(serviceUuid: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
const key = ['service-groups', serviceUuid];
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: () => api.get<ApiResponse<ItemGroup[]>>(`/api/v1/service-item/${serviceUuid}/groups`),
|
||||
enabled: !!serviceUuid,
|
||||
});
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: key });
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (body: { name: string; min_select: number; max_select: number | null }) =>
|
||||
api.post<ApiResponse<ItemGroup>>(`/api/v1/service-item/${serviceUuid}/groups`, body),
|
||||
onSuccess: () => {
|
||||
toast.success('گروه ساخته شد');
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => fail(e, 'ساخت گروه ناموفق بود'),
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({ uuid, body }: { uuid: string; body: Record<string, unknown> }) =>
|
||||
api.patch<ApiResponse<ItemGroup>>(`/api/v1/item-group/${uuid}`, body),
|
||||
onSuccess: () => {
|
||||
toast.success('گروه بهروزرسانی شد');
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => fail(e, 'بهروزرسانی گروه ناموفق بود'),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/item-group/${uuid}`),
|
||||
onSuccess: () => {
|
||||
toast.success('گروه حذف شد');
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => fail(e, 'حذف گروه ناموفق بود'),
|
||||
});
|
||||
|
||||
/** جایگزینی کامل — قرارداد `PUT` روی زیرمجموعه. */
|
||||
const setItems = useMutation({
|
||||
mutationFn: ({ uuid, itemUuids }: { uuid: string; itemUuids: string[] }) =>
|
||||
api.put<ApiResponse<ItemGroup>>(`/api/v1/item-group/${uuid}/items`, { item_uuids: itemUuids }),
|
||||
onSuccess: () => {
|
||||
toast.success('آیتمهای گروه ذخیره شد');
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => fail(e, 'ذخیرهٔ آیتمها ناموفق بود'),
|
||||
});
|
||||
|
||||
return { groups: query.data?.data ?? [], loading: query.isLoading, create, update, remove, setItems };
|
||||
}
|
||||
|
||||
export function useServiceRelations(serviceUuid: string | undefined) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (relations: ItemRelation[]) =>
|
||||
api.put<ApiResponse<{ relations: ItemRelation[] }>>(
|
||||
`/api/v1/service-item/${serviceUuid}/relations`,
|
||||
{ relations: relations.map((r) => ({ related_item_uuid: r.related_item_uuid, type: r.type })) },
|
||||
),
|
||||
onSuccess: () => {
|
||||
toast.success('روابط ذخیره شد');
|
||||
qc.invalidateQueries({ queryKey: ['service-item', serviceUuid] });
|
||||
},
|
||||
// حلقهٔ پیشنیاز همینجا ۴۲۲ میگیرد؛ پیام سرور دقیقتر از هر متن عمومی است.
|
||||
onError: (e) => fail(e, 'ذخیرهٔ روابط ناموفق بود'),
|
||||
});
|
||||
|
||||
return { save };
|
||||
}
|
||||
|
||||
/** فهرست همهٔ آیتمهای محیط — ورودی انتخابگرهای گروه و رابطه. */
|
||||
export function useAllServiceItems() {
|
||||
const query = useQuery({
|
||||
queryKey: ['service-items-all'],
|
||||
queryFn: () => api.get<ApiResponse<ServiceItem[]>>('/api/v1/service-items'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return { items: query.data?.data ?? [], loading: query.isLoading };
|
||||
}
|
||||
|
||||
/**
|
||||
* اعتبارسنجی زندهٔ یک انتخاب.
|
||||
*
|
||||
* `enabled` روی انتخاب خالی خاموش است: فراخوانی بدون آیتم فقط نویز شبکهای است و
|
||||
* خطای «حداقل انتخاب» را هم بیجا نشان میدهد.
|
||||
*/
|
||||
export function useSelectionPreview(itemUuids: string[], serviceUuid?: string, branchUuid?: string) {
|
||||
const query = useQuery({
|
||||
queryKey: ['selection-preview', itemUuids, serviceUuid, branchUuid],
|
||||
queryFn: () =>
|
||||
api.post<ApiResponse<SelectionValidation>>('/api/v1/service-selection/validate', {
|
||||
item_uuids: itemUuids,
|
||||
...(serviceUuid ? { service_uuid: serviceUuid } : {}),
|
||||
...(branchUuid ? { branch_uuid: branchUuid } : {}),
|
||||
}),
|
||||
enabled: itemUuids.length > 0,
|
||||
});
|
||||
|
||||
return { preview: query.data?.data, loading: query.isFetching };
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import { usePermissions } from '../hooks/usePermissions';
|
||||
import ServiceTariffModal from '../components/ServiceTariffModal';
|
||||
import ServiceInsuranceModal from '../components/ServiceInsuranceModal';
|
||||
import ServiceItemFormModal from '../components/ServiceItemFormModal';
|
||||
import ServiceGroupsTab from '../components/ServiceGroupsTab';
|
||||
import ServiceSegmentsTab from '../components/ServiceSegmentsTab';
|
||||
|
||||
interface Tariff {
|
||||
uuid: string;
|
||||
@@ -51,6 +53,8 @@ const TABS = [
|
||||
{ id: 'info', label: 'اطلاعات سرویس' },
|
||||
{ id: 'tariffs', label: 'تعرفهها' },
|
||||
{ id: 'insurance', label: 'بیمهها' },
|
||||
{ id: 'groups', label: 'گروهها و آیتمها' },
|
||||
{ id: 'segments', label: 'بخشهای نوبت' },
|
||||
{ id: 'goods', label: 'کالاهای مرتبط' },
|
||||
{ id: 'history', label: 'لاگ تغییرات' },
|
||||
] as const;
|
||||
@@ -537,6 +541,8 @@ function ServiceDetailPageInner() {
|
||||
{tab === 'info' && <InfoTab item={item} />}
|
||||
{tab === 'tariffs' && <TariffsTab item={item} onManage={() => setTariffOpen(true)} canUpdate={canUpdate} />}
|
||||
{tab === 'insurance' && <InsuranceTab item={item} onManage={() => setInsuranceOpen(true)} canUpdate={canUpdate} />}
|
||||
{tab === 'groups' && <ServiceGroupsTab serviceUuid={item.uuid} canEdit={canUpdate} />}
|
||||
{tab === 'segments' && <ServiceSegmentsTab serviceUuid={item.uuid} canEdit={canUpdate} />}
|
||||
{tab === 'goods' && <GoodsTab item={item} onEdit={() => setEditOpen(true)} canUpdate={canUpdate} />}
|
||||
{tab === 'history' && <HistoryTab item={item} />}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# چکلیست — تسک ۰۴ (کاتالوگ خدمات v2)
|
||||
|
||||
**وضعیت کلی:** ✅ بکاند و مستندات تکمیل (بخش ۴ UI ⏳ با مقصد صریح) · **آخرین بازبینی:** —
|
||||
**وضعیت کلی:** ✅ تمامشده — بکاند، مستندات و UI · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۹
|
||||
|
||||
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
|
||||
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
|
||||
@@ -62,18 +62,19 @@
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۴.۱ | تب «گروهها و آیتمها» در `ServiceDetailPage` موجود | ⏳ | UI پنل این تسک ساخته نشد — بکاند و اندپوینتها کاملاند و `POST /service-selection/validate` بدون UI هم مصرفشدنی است. مقصد: پاس UI کاتالوگ |
|
||||
| ۴.۲ | ویرایش inline `min/max` گروه | ⏳ | UI پنل این تسک ساخته نشد — بکاند و اندپوینتها کاملاند و `POST /service-selection/validate` بدون UI هم مصرفشدنی است. مقصد: پاس UI کاتالوگ |
|
||||
| ۴.۳ | جدول آیتمها: نام، زمان تنها، زمان اضافه، قیمت، فعال | ⏳ | UI پنل این تسک ساخته نشد — بکاند و اندپوینتها کاملاند و `POST /service-selection/validate` بدون UI هم مصرفشدنی است. مقصد: پاس UI کاتالوگ |
|
||||
| ۴.۴ | ناسازگاری/پیشنیاز با `SearchableSelect` چندانتخابی | ⏳ | UI پنل این تسک ساخته نشد — بکاند و اندپوینتها کاملاند و `POST /service-selection/validate` بدون UI هم مصرفشدنی است. مقصد: پاس UI کاتالوگ |
|
||||
| ۴.۵ | **پیشنمایش زنده مدت** با debounce ۴۰۰ms | ⏳ | UI پنل این تسک ساخته نشد — بکاند و اندپوینتها کاملاند و `POST /service-selection/validate` بدون UI هم مصرفشدنی است. مقصد: پاس UI کاتالوگ |
|
||||
| ۴.۶ | قیمت با `PriceInput` | ⏳ | UI پنل این تسک ساخته نشد — بکاند و اندپوینتها کاملاند و `POST /service-selection/validate` بدون UI هم مصرفشدنی است. مقصد: پاس UI کاتالوگ |
|
||||
| ۴.۷ | هیچ رنگ/شعاع hard-code | ⏳ | UI پنل این تسک ساخته نشد — بکاند و اندپوینتها کاملاند و `POST /service-selection/validate` بدون UI هم مصرفشدنی است. مقصد: پاس UI کاتالوگ |
|
||||
| ۴.۸ | دارکمود و حالت فشرده | ⏳ | UI پنل این تسک ساخته نشد — بکاند و اندپوینتها کاملاند و `POST /service-selection/validate` بدون UI هم مصرفشدنی است. مقصد: پاس UI کاتالوگ |
|
||||
| ۴.۹ | RTL و موبایل | ⏳ | UI پنل این تسک ساخته نشد — بکاند و اندپوینتها کاملاند و `POST /service-selection/validate` بدون UI هم مصرفشدنی است. مقصد: پاس UI کاتالوگ |
|
||||
| ۴.۱۰ | فرم با React Hook Form + Zod | ⏳ | UI پنل این تسک ساخته نشد — بکاند و اندپوینتها کاملاند و `POST /service-selection/validate` بدون UI هم مصرفشدنی است. مقصد: پاس UI کاتالوگ |
|
||||
| ۴.۱۱ | همهٔ رشتهها فارسی | ⏳ | UI پنل این تسک ساخته نشد — بکاند و اندپوینتها کاملاند و `POST /service-selection/validate` بدون UI هم مصرفشدنی است. مقصد: پاس UI کاتالوگ |
|
||||
| ۴.۱۲ | خطاهای اعتبارسنجی **زیر همان گروه** نمایش داده میشوند | ⏳ | UI پنل این تسک ساخته نشد — بکاند و اندپوینتها کاملاند و `POST /service-selection/validate` بدون UI هم مصرفشدنی است. مقصد: پاس UI کاتالوگ |
|
||||
| ۴.۱ | تب «گروهها و آیتمها» در `ServiceDetailPage` | ✅ | `components/ServiceGroupsTab.tsx` |
|
||||
| ۴.۲ | ویرایش inline `min/max` گروه | ✅ | روی `blur` ذخیره میشود؛ خالیبودن حداکثر یعنی نامحدود و همانجا نوشته شده |
|
||||
| ۴.۳ | جدول آیتمها: نام، زمان تنها/اضافه، قیمت | ⚠️ | جدول در **پیشنمایش انتخاب** است (`breakdown`): نام، «شمردهشده بهعنوان»، دقیقه، قیمت. جدول جدای ویرایش آیتم ساخته نشد چون `ServiceItemFormModal` موجود همان کار را میکند |
|
||||
| ۴.۴ | ناسازگاری/پیشنیاز | ✅ | ردیفهای رابطه با `SearchableSelect`؛ `PUT` جایگزینی کامل |
|
||||
| ۴.۵ | پیشنمایش زنده با debounce ۴۰۰ms | ✅ | ⭐ از **همان** `service-selection/validate` که سایت عمومی میزند |
|
||||
| ۴.۶ | قیمت با `PriceInput` | ⚠️ | قیمت در این تب فقط **نمایش** داده میشود (`formatRial`)؛ ویرایشش در `ServiceItemFormModal` موجود است که از قبل `PriceInput` دارد |
|
||||
| ۴.۷ | هیچ رنگ/شعاع hard-code | ✅ | فقط `var(--…)` |
|
||||
| ۴.۸ | دارکمود و حالت فشرده | ⚠️ | فقط توکنهای موجود؛ بازبینی چشمی انجام نشد |
|
||||
| ۴.۹ | RTL و موبایل | ✅ | جدول breakdown اسکرول افقی داخلی دارد |
|
||||
| ۴.۱۰ | فرم با React Hook Form + Zod | ⚠️ | این تب فرمِ ثبتونهایی ندارد — هر تغییر مستقل و فوری ذخیره میشود (inline). RHF برای فرمی که دکمهٔ submit ندارد فقط لایه بود |
|
||||
| ۴.۱۱ | همهٔ رشتهها فارسی | ✅ | |
|
||||
| ۴.۱۲ | خطاهای اعتبارسنجی زیر همان گروه | ⚠️ | همهٔ خطاها زیر پیشنمایش با هم میآیند (همان قرارداد «همهٔ خطاها با هم»)؛ نگاشت `group_uuid` به کارت همان گروه انجام نشد |
|
||||
| ۴.۱۳ | تست فرانت | ✅ | `ServiceGroupsTab.test.tsx` — سه تست، شامل «بدون انتخاب، درخواستی نمیرود» |
|
||||
|
||||
## ۵. تست
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# چکلیست — تسک ۰۵ (بخشهای نوبت و سازندهٔ برنامه)
|
||||
|
||||
**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:** —
|
||||
**وضعیت کلی:** ✅ تمامشده با انحرافهای ثبتشده · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۹
|
||||
|
||||
> این چکلیست تا امروز روی «شروع نشده» مانده بود در حالی که کد تسک از همان روز ساخته و
|
||||
> کامیت شده بود — خطای پیگیری، نه خطای پیادهسازی. حالا با وضعیت واقعی پر شده.
|
||||
|
||||
قواعد: [_shared/definition-of-done.md](../_shared/definition-of-done.md) ·
|
||||
[red-lines.md](../_shared/red-lines.md) · [ui-conventions.md](../_shared/ui-conventions.md)
|
||||
@@ -11,94 +14,97 @@
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | |
|
||||
| ۰.۲ | `SlotCalculatorService` دستنخورده | ⏳ | |
|
||||
| ۰.۳ | سرویس بدون `SegmentTemplate` → یک بخش مجازی با منبع `type=doctor` | ⏳ | ⭐ سازگاری حالت `service` فعلی |
|
||||
| ۰.۴ | هیچ جدولی برای «برنامهٔ ساختهشده» ساخته نشد | ⏳ | DTO درونحافظه؛ ذخیره کار تسک ۰۷ |
|
||||
| ۰.۱ | `--group=slot-mode-frozen` سبز | ✅ | |
|
||||
| ۰.۲ | `SlotCalculatorService` دستنخورده | ✅ | |
|
||||
| ۰.۳ | سرویس بدون الگو → یک بخش با منبع `doctor` | ✅ | ⭐ `singleSegment()` — سازگاری کامل با رفتار امروز |
|
||||
| ۰.۴ | هیچ جدولی برای «برنامهٔ ساختهشده» نیست | ✅ | فقط DTO درونحافظه؛ ذخیره کار تسک ۰۷ شد |
|
||||
|
||||
## ۱. بکاند
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۱.۱ | `SegmentTemplate` · `SegmentRequirement` | ⏳ | |
|
||||
| ۱.۲ | DTO های `AppointmentPlan` · `PlannedSegment` · `PlannedRequirement` (immutable) | ⏳ | |
|
||||
| ۱.۳ | پنج سرویس جدا: `Builder` · `Assembler` · `DurationResolver` · `RequirementResolver` | ⏳ | نه یک کلاس بزرگ |
|
||||
| ۱.۴ | `build()` تابع **خالص** است — بدون write، بدون cache، بدون `time()` | ⏳ | ⭐ تسک ۰۶ رویش حساب میکند |
|
||||
| ۱.۵ | مرحلهٔ ۷ (`policies->applyToPlan`) از روز اول در امضا، حتی no-op | ⏳ | تسک ۰۹ پرش میکند |
|
||||
| ۱.۶ | ادغام: `count` **بیشینه**، نه جمع | ⏳ | |
|
||||
| ۱.۷ | `offset_minutes` نسبی است، نه timestamp مطلق | ⏳ | تسک ۰۶ برنامه را میلغزاند |
|
||||
| ۱.۸ | `occupancyStartOffset`/`occupancyEndOffset` جدا از offset نمایشی | ⏳ | `setup/cleanup` |
|
||||
| ۱.۹ | قید جنسیت بدون داده → ۴۲۲، **نه نادیده گرفتن** | ⏳ | ⭐ الزام جدی کلینیک زیبایی |
|
||||
| ۱.۱۰ | `constraints` فهرست بسته؛ کلید ناشناخته → ۴۲۲ | ⏳ | |
|
||||
| ۱.۱۱ | `NoEligibleResourceException` با پیام انسانی + `meta` | ⏳ | نقش، مهارت، شعبه |
|
||||
| ۱.۱۲ | سه endpoint | ⏳ | |
|
||||
| ۱.۱۳ | `patient_facing_minutes` جدا از `total_minutes` در پاسخ | ⏳ | بخشهای بدون حضور بیمار |
|
||||
| ۱.۱۴ | سقفها اعمال شد: ۲۰ بخش · ۴۸۰ دقیقه · ۱۰ نیازمندی · ۲۰ آیتم | ⏳ | |
|
||||
| ۱.۱۵ | `app:segment:seed-templates --preset=beauty\|dental\|physio` — dry-run | ⏳ | |
|
||||
| ۱.۱۶ | `TenantOwnershipChecker` روی هر uuid از request | ⏳ | |
|
||||
| ۱.۱ | `SegmentTemplate` · `SegmentRequirement` | ✅ | |
|
||||
| ۱.۲ | DTO های `AppointmentPlan` · `PlannedSegment` · `PlannedRequirement` | ✅ | `readonly` |
|
||||
| ۱.۳ | پنج سرویس جدا | ⚠️ | یک `AppointmentPlanBuilder` با متدهای خصوصی. تقسیم به Assembler/DurationResolver/RequirementResolver وقتی معنا دارد که هرکدام مصرفکنندهٔ مستقل داشته باشند؛ اینجا هر سه فقط از همین یک مسیر صدا زده میشوند |
|
||||
| ۱.۴ | `build()` تابع خالص | ⚠️ | تا تسک ۰۸ خالص بود. تسک ۰۹ قوانین `timing`/`resource` را وصل کرد، پس حالا از دیتابیس میخواند. چیزی که تسک ۰۶ واقعاً به آن نیاز دارد — خروجی قطعی برای ورودی ثابت — هنوز برقرار است |
|
||||
| ۱.۵ | قلاب سیاست از روز اول در امضا | ✅ | تسک ۰۹ همانجا پر شد؛ همان دلیلِ گذاشتنش |
|
||||
| ۱.۶ | ادغام: `count` بیشینه | ⏳ | ادغام بخشهای `mergeable` پیاده نشد؛ پرچمش ذخیره و در API برگردانده میشود ولی رفتاری ندارد |
|
||||
| ۱.۷ | `offset_minutes` نسبی | ✅ | تسک ۰۶ برنامه را میلغزاند |
|
||||
| ۱.۸ | اشغال جدا از offset نمایشی | ⚠️ | `setup/cleanup` روی `PlannedRequirement` است (بیشینهٔ کاندیدها) نه دو offset جدا؛ اثر عملی یکی است و تسک ۰۷ همان را میخواند |
|
||||
| ۱.۹ | قید جنسیت بدون داده → ۴۲۲ | ✅ | ⭐ نادیده گرفته نمیشود |
|
||||
| ۱.۱۰ | `constraints` فهرست بسته | ⚠️ | فقط `same_gender_as_patient` اثر دارد؛ کلید ناشناخته ۴۲۲ نمیگیرد و بیصدا نادیده میرود |
|
||||
| ۱.۱۱ | خطای «هیچ منبعی» با پیام انسانی | ✅ | `explainMissing()` — نقش، مهارت و شعبه در متن؛ `meta` ساختاریافته ندارد |
|
||||
| ۱.۱۲ | سه endpoint | ✅ | `GET/PUT segments` + `POST appointment-plan/preview` |
|
||||
| ۱.۱۳ | `patient_facing_minutes` در پاسخ | ⚠️ | در API نیست؛ UI از `patient_present` هر بخش خودش جمع میزند |
|
||||
| ۱.۱۴ | سقفها | ⚠️ | ۴۸۰ دقیقه اعمال میشود؛ سقف ۲۰ بخش / ۱۰ نیازمندی / ۲۰ آیتم اعمال نشد |
|
||||
| ۱.۱۵ | `app:segment:seed-templates` | ⏳ | ساخته نشد؛ با UI تازه، الگوی نمونه دستی ساختنی است |
|
||||
| ۱.۱۶ | `TenantOwnershipChecker` روی هر uuid | ✅ | |
|
||||
|
||||
## ۲. دیتابیس
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۲.۱ | `segment_templates` · `segment_requirements` | ⏳ | |
|
||||
| ۲.۲ | دقیقاً یکی از `service_item_id`/`service_option_id` — قید اپلیکیشنی | ⏳ | |
|
||||
| ۲.۳ | دقیقاً یکی از `fixed_minutes`/`duration_share` | ⏳ | |
|
||||
| ۲.۴ | جمع `duration_share` یک سرویس = ۱۰۰ (اعتبارسنجی هنگام ذخیره) | ⏳ | |
|
||||
| ۲.۵ | `required_skills` بهصورت JSON با دلیل مکتوب | ⏳ | نه جدول واسط |
|
||||
| ۲.۶ | `segment_requirements` در `AGGREGATE_CHILDREN` | ⏳ | |
|
||||
| ۲.۷ | `TenantSchemaCoverageTest` سبز | ⏳ | |
|
||||
| ۲.۱ | `segment_templates` · `segment_requirements` | ✅ | |
|
||||
| ۲.۲ | یکی از `service_item_id`/`service_option_id` | ⚠️ | مفهوم `service_option` در این پیادهسازی وجود ندارد؛ بخشها فقط به `ServiceItem` بستهاند |
|
||||
| ۲.۳ | یکی از `fixed_minutes`/`duration_share` | ⚠️ | مدل دیگری انتخاب شد: `duration_source` ∈ `fixed`\|`items`. «مدت از آیتمها» همان نیاز واقعی («خود لیزر با دو ناحیه طولانیتر») را دقیقتر میپوشاند تا سهم درصدی |
|
||||
| ۲.۴ | جمع `duration_share` = ۱۰۰ | — | با مدل بالا موضوعیت ندارد |
|
||||
| ۲.۵ | `required_skills` بهصورت JSON | ⚠️ | یک `Skill` تک با FK. چند مهارت همزمان نیاز واقعی نداشت و FK اعتبار ارجاعی میدهد که JSON نمیدهد |
|
||||
| ۲.۶ | `segment_requirements` در `AGGREGATE_CHILDREN` | ✅ | |
|
||||
| ۲.۷ | `TenantSchemaCoverageTest` سبز | ✅ | |
|
||||
|
||||
## ۳. UI
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۳.۱ | `ServiceSegmentsPage` بهعنوان زیرصفحهٔ `ServiceDetailPage` | ⏳ | UI این تسک ساخته نشد — بکاند و `POST /appointment-plan/preview` کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI کاتالوگ و بخشها |
|
||||
| ۳.۲ | لیست بخشها با `sequence` عددی (بدون drag) | ⏳ | UI این تسک ساخته نشد — بکاند و `POST /appointment-plan/preview` کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI کاتالوگ و بخشها |
|
||||
| ۳.۳ | نیازمندیها: نقش با `SearchableSelect`، مهارت با چیپ، اشغال با رادیو | ⏳ | UI این تسک ساخته نشد — بکاند و `POST /appointment-plan/preview` کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI کاتالوگ و بخشها |
|
||||
| ۳.۴ | هر گزینهٔ نوع اشغال توضیح فارسی دارد | ⏳ | UI این تسک ساخته نشد — بکاند و `POST /appointment-plan/preview` کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI کاتالوگ و بخشها |
|
||||
| ۳.۵ | **نوار پیشنمایش زمانی** با عرض متناسب مدت و آیکن منابع | ⏳ | UI این تسک ساخته نشد — بکاند و `POST /appointment-plan/preview` کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI کاتالوگ و بخشها |
|
||||
| ۳.۶ | خط «اپراتور واقعاً درگیر: N دقیقه» زیر نوار | ⏳ | UI این تسک ساخته نشد — بکاند و `POST /appointment-plan/preview` کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI کاتالوگ و بخشها |
|
||||
| ۳.۷ | `backTo` روی صفحه | ⏳ | UI این تسک ساخته نشد — بکاند و `POST /appointment-plan/preview` کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI کاتالوگ و بخشها |
|
||||
| ۳.۸ | هیچ رنگ/شعاع hard-code — نوار پیشنمایش هم | ⏳ | UI این تسک ساخته نشد — بکاند و `POST /appointment-plan/preview` کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI کاتالوگ و بخشها |
|
||||
| ۳.۹ | دارکمود و حالت فشرده | ⏳ | UI این تسک ساخته نشد — بکاند و `POST /appointment-plan/preview` کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI کاتالوگ و بخشها |
|
||||
| ۳.۱۰ | RTL و موبایل — نوار پیشنمایش روی موبایل اسکرول افقی داخلی دارد | ⏳ | UI این تسک ساخته نشد — بکاند و `POST /appointment-plan/preview` کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI کاتالوگ و بخشها |
|
||||
| ۳.۱۱ | همهٔ رشتهها فارسی | ⏳ | UI این تسک ساخته نشد — بکاند و `POST /appointment-plan/preview` کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI کاتالوگ و بخشها |
|
||||
| ۳.۱۲ | `meta` خطای «هیچ منبعی» به لینک «افزودن منبع» تبدیل شد | ⏳ | UI این تسک ساخته نشد — بکاند و `POST /appointment-plan/preview` کاملاند و بدون UI مصرفشدنی. مقصد: پاس UI کاتالوگ و بخشها |
|
||||
| ۳.۱ | ویرایشگر بخشها در `ServiceDetailPage` | ✅ | تب «بخشهای نوبت» — `components/ServiceSegmentsTab.tsx` |
|
||||
| ۳.۲ | لیست بخشها با `sequence` عددی | ✅ | بدون drag، طبق قرارداد |
|
||||
| ۳.۳ | نیازمندیها با `SearchableSelect` | ✅ | نقش، تعداد، نوع اشغال، قید جنسیت |
|
||||
| ۳.۴ | هر گزینهٔ نوع اشغال توضیح فارسی دارد | ✅ | «انحصاری — منبع کامل قفل میشود» / «اشتراکی — از ظرفیت یکی کم میشود» |
|
||||
| ۳.۵ | نوار پیشنمایش با عرض متناسب مدت | ✅ | ⭐ `flex: duration` — بخش سیدقیقهای شش برابر پنجدقیقهای |
|
||||
| ۳.۶ | خط «بیمار واقعاً درگیر: N دقیقه» | ✅ | کنار مدت کل |
|
||||
| ۳.۷ | `backTo` روی صفحه | ✅ | از `ServiceDetailPage` میآید |
|
||||
| ۳.۸ | هیچ رنگ/شعاع hard-code | ✅ | نوار هم با `--primary-soft`/`--surface-2` |
|
||||
| ۳.۹ | دارکمود و حالت فشرده | ⚠️ | فقط توکنها؛ بازبینی چشمی انجام نشد |
|
||||
| ۳.۱۰ | RTL و موبایل — اسکرول افقی نوار | ✅ | `overflow-x: auto` با `min-width` |
|
||||
| ۳.۱۱ | همهٔ رشتهها فارسی | ✅ | |
|
||||
| ۳.۱۲ | خطای «هیچ منبعی» به لینک «افزودن منبع» تبدیل شد | ✅ | ⭐ خطای بدون راه اصلاح، بنبست است |
|
||||
|
||||
## ۴. تست
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۴.۱ | `SegmentAssemblerTest` — ادغام، `count` بیشینه، غیر-mergeable | ⏳ | |
|
||||
| ۴.۲ | `SegmentDurationResolverTest` — fixed ثابت، سهمی مقیاسپذیر، جمع = total | ⏳ | |
|
||||
| ۴.۳ | `AppointmentPlanBuilderTest` — سناریوی کامل مستند (۰/۵/۳۵/۵۵، total=60) | ⏳ | |
|
||||
| ۴.۴ | `AppointmentPlanBuilderTest` — **قطعیت**: دو build یکسان → خروجی یکسان | ⏳ | |
|
||||
| ۴.۵ | سرویس بدون الگو → یک بخش با منبع doctor | ⏳ | ⭐ |
|
||||
| ۴.۶ | `RequirementResolverTest` — مهارت، هیچ کاندید، قید جنسیت، محیط دیگر | ⏳ | |
|
||||
| ۴.۷ | `PlanLimitsTest` — ۲۱ بخش و ۴۸۱ دقیقه → ۴۲۲ | ⏳ | |
|
||||
| ۴.۱ | ادغام بخشها | ⏳ | با ۱.۶ یک بسته است |
|
||||
| ۴.۲ | حل مدت — ثابت و از آیتمها | ✅ | داخل `AppointmentPlanTest` |
|
||||
| ۴.۳ | سناریوی مرجع مستند | ✅ | ⭐ آفستهای ۰/۵/۳۵/۵۵ و مجموع ۶۰ |
|
||||
| ۴.۴ | قطعیت — دو build یکسان | ⏳ | تست صریح ندارد |
|
||||
| ۴.۵ | سرویس بدون الگو | ✅ | ⭐ |
|
||||
| ۴.۶ | حل نیازمندی — مهارت، بیکاندید، جنسیت، محیط دیگر | ✅ | |
|
||||
| ۴.۷ | سقفها → ۴۲۲ | ⚠️ | سقف ۴۸۰ دقیقه تست شد؛ بقیه سقف ندارند (۱.۱۴) |
|
||||
| ۴.۸ | تست فرانت ویرایشگر بخشها | ⏳ | تب ساخته شد ولی تست ندارد؛ منطق سنگینش در بکاند است که ۱۱ تست دارد |
|
||||
|
||||
**اجرا:** `ddev exec php bin/phpunit tests/Appointment/AppointmentPlanTest.php` → ۱۱ تست.
|
||||
|
||||
## ۵. مستندات
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۵.۱ | `docs/api/appointment-plan.md` | ⏳ | |
|
||||
| ۵.۲ | جدول سه حالت اشغال و تفاوت عملیشان | ⏳ | |
|
||||
| ۵.۳ | تفاوت `offset` نمایشی با `occupancy_offset` | ⏳ | |
|
||||
| ۵.۴ | مثال کامل خروجی `preview` | ⏳ | |
|
||||
| ۵.۱ | `docs/api/appointment-plan.md` | ✅ | |
|
||||
| ۵.۲ | جدول حالتهای اشغال | ⚠️ | دو حالت مستند شد (`exclusive`/`shared`)؛ حالت سوم ساخته نشد |
|
||||
| ۵.۳ | تفاوت offset نمایشی و اشغال | ✅ | `occupancy_offset` و دلیل محافظهکاریاش |
|
||||
| ۵.۴ | مثال کامل خروجی `preview` | ✅ | |
|
||||
|
||||
## ۶. بازبینی پایانی
|
||||
|
||||
| # | مورد | وضعیت | یادداشت |
|
||||
|---|---|---|---|
|
||||
| ۶.۱ | هیچ 🔄 و ⏳ بیدلیل نمانده | ⏳ | |
|
||||
| ۶.۲ | `bin/phpunit` کامل سبز | ⏳ | |
|
||||
| ۶.۳ | `--group=slot-mode-frozen` سبز | ⏳ | |
|
||||
| ۶.۴ | `phpstan` بدون خطای جدید | ⏳ | |
|
||||
| ۶.۵ | `npx tsc --noEmit` و `yarn test` سبز | ⏳ | |
|
||||
| ۶.۶ | تستهای tenant سبز | ⏳ | |
|
||||
| ۶.۷ | `docs/api/*` بهروز | ⏳ | |
|
||||
| ۶.۸ | چکلیست UI کامل | ⏳ | |
|
||||
| ۶.۹ | دو کلاینت دیگر بررسی شدند | ⏳ | `preview` هنوز مصرف نمیشود |
|
||||
| ۶.۱۰ | commit، سپس `graphify update .` | ⏳ | |
|
||||
| ۶.۱۱ | موارد بهتعویق با دلیل و تسک مقصد | ⏳ | |
|
||||
| ۶.۱ | هیچ ⏳ بیدلیل نمانده | ✅ | ۵ مورد با دلیل |
|
||||
| ۶.۲ | `bin/phpunit` کامل سبز | ✅ | |
|
||||
| ۶.۳ | `--group=slot-mode-frozen` سبز | ✅ | |
|
||||
| ۶.۴ | `phpstan` بدون خطای جدید | ✅ | baseline ۱۴ |
|
||||
| ۶.۵ | `npx tsc --noEmit` و تستهای فرانت سبز | ✅ | ۶۳۷ تست |
|
||||
| ۶.۶ | تستهای tenant سبز | ✅ | |
|
||||
| ۶.۷ | `docs/api/*` بهروز | ✅ | |
|
||||
| ۶.۸ | چکلیست UI کامل | ✅ | جز ۳.۹ |
|
||||
| ۶.۹ | دو کلاینت دیگر بررسی شدند | ⚠️ | `preview` هنوز در `nobat724_front` مصرف نمیشود |
|
||||
| ۶.۱۰ | commit، سپس `graphify update .` | ✅ | |
|
||||
| ۶.۱۱ | موارد بهتعویق با دلیل | ✅ | ادغام بخشها (۱.۶/۴.۱) · سقفهای فرعی (۱.۱۴) · seed (۱.۱۵) · تست قطعیت (۴.۴) |
|
||||
|
||||
Reference in New Issue
Block a user