From 04d32225593f6ef8bdaf08eb59a34c45fa13dfd5 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 30 Jul 2026 17:51:38 +0330 Subject: [PATCH] feat(resource): admin UI for resources, types, skills and pools, plus real API docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four pages on the existing design system: a resources list whose branch/type/skill/ status filters live in the URL and go straight to the server, and three supporting pages for types, skills and pools. Filtering client-side over a list the server had already filtered would have been a second source of truth, so the page does neither. The pool members dialog only offers resources from the pool's own branch and type — the same rule the server enforces with 422, applied early so the user never reaches the error. Skill assignment and pool membership are both full replacements, and both say so in the dialog, because a partial-looking save that silently drops rows is worse than an explicit one. Wiring that was missing: deactivating a staff member through PATCH /api/v1/staff/{uuid}/toggle now closes their resource too. Without it an inactive operator would still have shown up in availability search. It is an explicit call rather than a Doctrine lifecycle callback, since callbacks do not fire for getArrayResult() — which is how every admin list is built — and that asymmetry is its own bug. The reverse does not hold: closing a resource does not deactivate the person, who may be purely administrative. docs/api/resource.md documents all sixteen endpoints with responses captured from real curl runs against ddev, including the 422 bodies for person-capacity and non-scalar attributes. staff.md gains a "relationship to resources" section stating that job_title is not a skill. tenancy.md contrasts these aggregate children — whose roots do carry a tenant pair — with the branch_working_hours case from task 01, where the root was global and the classification was wrong. Also fixed a pre-existing flaky test: NumericFieldNormalizerTest guarded its random mobile against collision on the never-reset db_test but not its random national code, so a full-suite run could fail with 422 and close the EntityManager, taking an unrelated test down with it. Both are now guarded, and the assertion prints the server's response instead of a bare "422 is not 201". Verified: phpunit 1119 tests / 3113 assertions green; slot-mode frozen contract green; phpstan 14 errors before and after, none in touched files; tsc clean; vitest 88 files / 617 tests green. Co-Authored-By: Claude Opus 5 (1M context) --- assets/admin/App.tsx | 8 + .../components/layout/SettingsLayout.tsx | 3 +- .../resources/ResourceFormModal.tsx | 205 ++++++++++ .../resources/ResourceSkillsModal.tsx | 104 +++++ assets/admin/hooks/useResources.ts | 182 +++++++++ assets/admin/pages/ResourcePoolsPage.tsx | 316 +++++++++++++++ assets/admin/pages/ResourceTypesPage.tsx | 188 +++++++++ assets/admin/pages/ResourcesPage.test.tsx | 110 ++++++ assets/admin/pages/ResourcesPage.tsx | 230 +++++++++++ assets/admin/pages/SkillsPage.tsx | 157 ++++++++ docs/api/README.md | 1 + docs/api/resource.md | 366 ++++++++++++++++++ docs/api/staff.md | 17 + docs/architecture/tenancy.md | 5 + .../_shared/branch-is-doctor-address.md | 15 + .../task-02-resource-model/checklist.md | 116 +++--- src/Staff/Controller/StaffController.php | 7 + tests/Resource/BackfillResourceTest.php | 37 ++ tests/Shared/NumericFieldNormalizerTest.php | 27 +- 19 files changed, 2028 insertions(+), 66 deletions(-) create mode 100644 assets/admin/components/resources/ResourceFormModal.tsx create mode 100644 assets/admin/components/resources/ResourceSkillsModal.tsx create mode 100644 assets/admin/hooks/useResources.ts create mode 100644 assets/admin/pages/ResourcePoolsPage.tsx create mode 100644 assets/admin/pages/ResourceTypesPage.tsx create mode 100644 assets/admin/pages/ResourcesPage.test.tsx create mode 100644 assets/admin/pages/ResourcesPage.tsx create mode 100644 assets/admin/pages/SkillsPage.tsx create mode 100644 docs/api/resource.md diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 9f68d72f..9f2ba0c6 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -77,6 +77,10 @@ import InventoryPage from './pages/InventoryPage'; import BranchesPage from './pages/BranchesPage'; import BranchWorkingHoursPage from './pages/BranchWorkingHoursPage'; import BranchRoomsPage from './pages/BranchRoomsPage'; +import ResourcesPage from './pages/ResourcesPage'; +import ResourceTypesPage from './pages/ResourceTypesPage'; +import SkillsPage from './pages/SkillsPage'; +import ResourcePoolsPage from './pages/ResourcePoolsPage'; import PatientRecordFormPage from './pages/PatientRecordFormPage'; import PatientDetailPage from './pages/PatientDetailPage'; import PaymentSuccessPage from './pages/PaymentSuccessPage'; @@ -281,6 +285,10 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/SettingsLayout.tsx b/assets/admin/components/layout/SettingsLayout.tsx index 093ed4a7..33cca747 100644 --- a/assets/admin/components/layout/SettingsLayout.tsx +++ b/assets/admin/components/layout/SettingsLayout.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { CreditCardIcon, UserIcon, CalendarDaysIcon, BuildingOffice2Icon, BanknotesIcon, UsersIcon, ShieldCheckIcon, - TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, + TagIcon, ChatBubbleLeftRightIcon, UserCircleIcon, UserPlusIcon, ReceiptPercentIcon, MapPinIcon, CubeIcon, } from '@heroicons/react/24/outline'; import PurchaseSubscriptionSidebar from './PurchaseSubscriptionSidebar'; @@ -30,6 +30,7 @@ export const SETTINGS_MENU: SettingsMenuItem[] = [ { key: 'appointment', label: 'مدیریت نوبت دهی', icon: CalendarDaysIcon, to: '/admin/settings/appointment-settings', roles: ['clinic'], perm: ['appointment_settings', 'view'] }, { key: 'clinic-doctors', label: 'پزشکان کلینیک', icon: BuildingOffice2Icon, to: '/admin/settings/clinic-doctors', roles: ['clinic'], perm: ['clinic_doctors', 'view'] }, { key: 'branches', label: 'شعبه‌ها و اتاق‌ها', icon: MapPinIcon, to: '/admin/branches', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, + { key: 'resources', label: 'منابع', icon: CubeIcon, to: '/admin/resources', roles: ['doctor', 'clinic'], perm: ['appointment_settings', 'view'] }, { key: 'payment', label: 'مدیریت پرداخت', icon: BanknotesIcon, to: '/admin/my-financial', perm: ['payments', 'view'] }, { key: 'secretary', label: 'مدیریت منشی', icon: UsersIcon, to: '/admin/my-secretaries' }, { key: 'staff', label: 'پرسنل', icon: UserPlusIcon, to: '/admin/staff', perm: ['staff', 'view'] }, diff --git a/assets/admin/components/resources/ResourceFormModal.tsx b/assets/admin/components/resources/ResourceFormModal.tsx new file mode 100644 index 00000000..d19f0bf1 --- /dev/null +++ b/assets/admin/components/resources/ResourceFormModal.tsx @@ -0,0 +1,205 @@ +import React, { useEffect, useState } from 'react'; +import Modal from '../ui/Modal'; +import SearchableSelect from '../ui/SearchableSelect'; +import type { Branch, ClinicResource, ResourcePayload, ResourceType } from '../../types'; + +/** کلیدهای شناخته‌شدهٔ ویژگی — قرارداد است نه اجبار؛ سرور هر کلید snake_case را می‌پذیرد. */ +const KNOWN_ATTRIBUTES = ['gender', 'device_model', 'floor', 'brand']; + +type AttributeRow = { key: string; value: string }; + +interface Props { + open: boolean; + resource: ClinicResource | null; + branches: Branch[]; + types: ResourceType[]; + saving: boolean; + onClose: () => void; + onSave: (payload: ResourcePayload) => void; +} + +export default function ResourceFormModal({ + open, resource, branches, types, saving, onClose, onSave, +}: Props) { + const [name, setName] = useState(''); + const [addressUuid, setAddressUuid] = useState(null); + const [typeUuid, setTypeUuid] = useState(null); + const [capacity, setCapacity] = useState('1'); + const [setupMinutes, setSetupMinutes] = useState('0'); + const [cleanupMinutes, setCleanupMinutes] = useState('0'); + const [active, setActive] = useState(true); + const [attributes, setAttributes] = useState([]); + + useEffect(() => { + if (!open) return; + setName(resource?.name ?? ''); + setAddressUuid(resource?.address_uuid ?? null); + setTypeUuid(resource?.type_uuid ?? null); + setCapacity(String(resource?.capacity ?? 1)); + setSetupMinutes(String(resource?.setup_minutes ?? 0)); + setCleanupMinutes(String(resource?.cleanup_minutes ?? 0)); + setActive(resource?.active ?? true); + setAttributes( + Object.entries(resource?.attributes ?? {}).map(([key, value]) => ({ key, value: String(value) })), + ); + }, [open, resource]); + + const isEdit = resource !== null; + const parsedCapacity = Number(capacity); + const invalid = + name.trim() === '' || + (!isEdit && (!addressUuid || !typeUuid)) || + !Number.isFinite(parsedCapacity) || + parsedCapacity < 1; + + const submit = () => { + const attrs: Record = {}; + attributes.forEach(({ key, value }) => { + if (key.trim() !== '') attrs[key.trim()] = value; + }); + + const payload: ResourcePayload = { + name: name.trim(), + capacity: parsedCapacity, + setup_minutes: Number(setupMinutes) || 0, + cleanup_minutes: Number(cleanupMinutes) || 0, + attributes: attrs, + active, + }; + + // شعبه و نوع فقط هنگام ساخت فرستاده می‌شوند؛ جفت محیطِ منبع از آدرس مشتق شده و + // جابه‌جا کردنش یعنی همان منبع در محیط دیگری ظاهر شود. + if (!isEdit) { + payload.address_uuid = addressUuid!; + payload.type_uuid = typeUuid!; + } + + onSave(payload); + }; + + return ( + +
+ + setName(e.target.value)} placeholder="لیزر آلکساندرایت ۱" /> + + +
+ + ({ value: b.uuid, label: b.name || 'بدون نام' }))} + value={addressUuid} + onChange={(v) => setAddressUuid(v ? String(v) : null)} + placeholder="شعبه را انتخاب کنید" + isDisabled={isEdit} + height={38} + /> + + + ({ value: t.uuid, label: t.name }))} + value={typeUuid} + onChange={(v) => setTypeUuid(v ? String(v) : null)} + placeholder="نوع را انتخاب کنید" + isDisabled={isEdit} + height={38} + /> + +
+ + {isEdit && ( +

+ شعبه و نوع منبع پس از ساخت تغییر نمی‌کنند؛ برای جابه‌جایی، منبع تازه بسازید. +

+ )} + +
+ + setCapacity(e.target.value)} /> + + + setSetupMinutes(e.target.value)} /> + + + setCleanupMinutes(e.target.value)} /> + +
+ +

+ ظرفیت یعنی چند بیمار هم‌زمان — اتاق تزریق سه‌تخته یک منبع با ظرفیت ۳ است، نه سه منبع. + آماده‌سازی و تمیزکاری جزو نوبت بیمار نیستند ولی منبع را اشغال می‌کنند. +

+ +
+
+ + +
+ + {attributes.map((row, index) => ( +
+ + setAttributes((a) => a.map((r, i) => (i === index ? { ...r, key: e.target.value } : r))) + } + style={{ flex: 1 }} + /> + + setAttributes((a) => a.map((r, i) => (i === index ? { ...r, value: e.target.value } : r))) + } + style={{ flex: 1 }} + /> + +
+ ))} + + + {KNOWN_ATTRIBUTES.map((k) => +
+ + + +
+ + +
+
+
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/assets/admin/components/resources/ResourceSkillsModal.tsx b/assets/admin/components/resources/ResourceSkillsModal.tsx new file mode 100644 index 00000000..8ab04557 --- /dev/null +++ b/assets/admin/components/resources/ResourceSkillsModal.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useState } from 'react'; +import Modal from '../ui/Modal'; +import SearchableSelect from '../ui/SearchableSelect'; +import type { ClinicResource, Skill } from '../../types'; + +type Line = { skill_uuid: string; level: number }; + +interface Props { + resource: ClinicResource | null; + skills: Skill[]; + saving: boolean; + onClose: () => void; + onSave: (lines: Line[]) => void; +} + +/** + * مهارت‌های یک منبع. ذخیره یک PUT است و **جایگزینی کامل**: مهارتی که اینجا نباشد، + * از منبع برداشته می‌شود. + */ +export default function ResourceSkillsModal({ resource, skills, saving, onClose, onSave }: Props) { + const [lines, setLines] = useState([]); + + useEffect(() => { + if (!resource) return; + setLines(resource.skills.map((s) => ({ skill_uuid: s.skill_uuid, level: s.level }))); + }, [resource]); + + const chosen = new Set(lines.map((l) => l.skill_uuid)); + const available = skills.filter((s) => !chosen.has(s.uuid)); + + const nameOf = (uuid: string) => skills.find((s) => s.uuid === uuid)?.name ?? uuid; + + return ( + +
+ {skills.length === 0 && ( +

+ هنوز هیچ مهارتی تعریف نشده است. اول از صفحهٔ «مهارت‌ها» یکی بسازید. +

+ )} + + {lines.length === 0 ? ( +

این منبع هیچ مهارتی ندارد.

+ ) : ( +
+ {lines.map((line, index) => ( +
+ {nameOf(line.skill_uuid)} + +
+ ({ value: String(lv), label: String(lv) }))} + value={String(line.level)} + onChange={(v) => + setLines((l) => l.map((x, i) => (i === index ? { ...x, level: Number(v) || 1 } : x))) + } + placeholder="سطح" + height={36} + /> +
+ +
+ ))} +
+ )} + + {available.length > 0 && ( +
+ + ({ value: s.uuid, label: s.name }))} + value={null} + onChange={(v) => v && setLines((l) => [...l, { skill_uuid: String(v), level: 1 }])} + placeholder="یک مهارت انتخاب کنید" + height={38} + /> +
+ )} + +

+ ذخیره کل فهرست را جایگزین می‌کند؛ مهارتی که اینجا نباشد از منبع برداشته می‌شود. +

+ +
+ + +
+
+
+ ); +} diff --git a/assets/admin/hooks/useResources.ts b/assets/admin/hooks/useResources.ts new file mode 100644 index 00000000..16b90a88 --- /dev/null +++ b/assets/admin/hooks/useResources.ts @@ -0,0 +1,182 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { api, ApiError, type ApiResponse } from '../lib/api'; +import type { + ClinicResource, ResourcePool, ResourcePayload, ResourceType, Skill, +} from '../types'; + +/** + * منابع: هر چیزی که ممکن است اشغال باشد. هر منبع مال یک شعبه است، و شعبه همان + * آدرس محل نوبت‌دهی است — پس فیلترها با `address_uuid` کار می‌کنند نه `branch_id`. + */ +const RESOURCES_KEY = 'resources'; +const TYPES_KEY = ['resource-types']; +const SKILLS_KEY = ['skills']; +const POOLS_KEY = ['resource-pools']; + +function fail(e: unknown, fallback: string) { + toast.error(e instanceof ApiError ? e.message : fallback); +} + +export type ResourceFilters = { + address_uuid?: string; + type_uuid?: string; + skill_uuid?: string; + active?: string; +}; + +function toQuery(filters: ResourceFilters): string { + const params = new URLSearchParams(); + Object.entries(filters).forEach(([k, v]) => { + if (v) params.set(k, v); + }); + const qs = params.toString(); + return qs === '' ? '' : `?${qs}`; +} + +export function useResources(filters: ResourceFilters = {}) { + const qc = useQueryClient(); + const invalidate = () => qc.invalidateQueries({ queryKey: [RESOURCES_KEY] }); + + const query = useQuery({ + queryKey: [RESOURCES_KEY, filters], + queryFn: () => api.get>(`/api/v1/resources${toQuery(filters)}`), + }); + + const create = useMutation({ + mutationFn: (d: ResourcePayload) => api.post>('/api/v1/resource', d), + onSuccess: () => { toast.success('منبع افزوده شد'); invalidate(); }, + onError: (e) => fail(e, 'افزودن منبع ناموفق بود'), + }); + + const update = useMutation({ + mutationFn: ({ uuid, d }: { uuid: string; d: ResourcePayload }) => + api.patch>(`/api/v1/resource/${uuid}`, d), + onSuccess: () => { toast.success('منبع به‌روزرسانی شد'); invalidate(); }, + onError: (e) => fail(e, 'به‌روزرسانی منبع ناموفق بود'), + }); + + const remove = useMutation({ + mutationFn: (uuid: string) => api.delete>(`/api/v1/resource/${uuid}`), + onSuccess: () => { toast.success('منبع حذف شد'); invalidate(); }, + onError: (e) => fail(e, 'حذف منبع ناموفق بود'), + }); + + /** جایگزینی کامل: مهارتی که در بدنه نیست، برداشته می‌شود. */ + const setSkills = useMutation({ + mutationFn: ({ uuid, skills }: { uuid: string; skills: { skill_uuid: string; level: number }[] }) => + api.put>(`/api/v1/resource/${uuid}/skills`, { skills }), + onSuccess: () => { toast.success('مهارت‌ها ذخیره شد'); invalidate(); }, + onError: (e) => fail(e, 'ذخیرهٔ مهارت‌ها ناموفق بود'), + }); + + return { + resources: query.data?.data ?? [], + loading: query.isLoading, + create, update, remove, setSkills, + }; +} + +export function useResourceTypes() { + const qc = useQueryClient(); + const invalidate = () => qc.invalidateQueries({ queryKey: TYPES_KEY }); + + const query = useQuery({ + queryKey: TYPES_KEY, + queryFn: () => api.get>('/api/v1/resource-types'), + }); + + const create = useMutation({ + mutationFn: (d: { code: string; name: string }) => + api.post>('/api/v1/resource-types', d), + onSuccess: () => { toast.success('نوع منبع افزوده شد'); invalidate(); }, + onError: (e) => fail(e, 'افزودن نوع منبع ناموفق بود'), + }); + + const update = useMutation({ + mutationFn: ({ uuid, d }: { uuid: string; d: { name?: string; active?: boolean } }) => + api.patch>(`/api/v1/resource-type/${uuid}`, d), + onSuccess: () => { toast.success('نوع منبع به‌روزرسانی شد'); invalidate(); }, + onError: (e) => fail(e, 'به‌روزرسانی ناموفق بود'), + }); + + const remove = useMutation({ + mutationFn: (uuid: string) => api.delete>(`/api/v1/resource-type/${uuid}`), + onSuccess: () => { toast.success('نوع منبع حذف شد'); invalidate(); }, + onError: (e) => fail(e, 'حذف نوع منبع ناموفق بود'), + }); + + return { types: query.data?.data ?? [], loading: query.isLoading, create, update, remove }; +} + +export function useSkills() { + const qc = useQueryClient(); + const invalidate = () => qc.invalidateQueries({ queryKey: SKILLS_KEY }); + + const query = useQuery({ + queryKey: SKILLS_KEY, + queryFn: () => api.get>('/api/v1/skills'), + }); + + const create = useMutation({ + mutationFn: (d: { name: string }) => api.post>('/api/v1/skills', d), + onSuccess: () => { toast.success('مهارت افزوده شد'); invalidate(); }, + onError: (e) => fail(e, 'افزودن مهارت ناموفق بود'), + }); + + const update = useMutation({ + mutationFn: ({ uuid, d }: { uuid: string; d: { name?: string; active?: boolean } }) => + api.patch>(`/api/v1/skill/${uuid}`, d), + onSuccess: () => { toast.success('مهارت به‌روزرسانی شد'); invalidate(); }, + onError: (e) => fail(e, 'به‌روزرسانی ناموفق بود'), + }); + + const remove = useMutation({ + mutationFn: (uuid: string) => api.delete>(`/api/v1/skill/${uuid}`), + onSuccess: () => { toast.success('مهارت حذف شد'); invalidate(); }, + // پیام سرور دقیق است («به N منبع داده شده»)، پس همان نشان داده می‌شود. + onError: (e) => fail(e, 'حذف مهارت ناموفق بود'), + }); + + return { skills: query.data?.data ?? [], loading: query.isLoading, create, update, remove }; +} + +export function useResourcePools() { + const qc = useQueryClient(); + const invalidate = () => qc.invalidateQueries({ queryKey: POOLS_KEY }); + + const query = useQuery({ + queryKey: POOLS_KEY, + queryFn: () => api.get>('/api/v1/resource-pools'), + }); + + const create = useMutation({ + mutationFn: (d: { address_uuid: string; type_uuid: string; name: string }) => + api.post>('/api/v1/resource-pools', d), + onSuccess: () => { toast.success('استخر افزوده شد'); invalidate(); }, + onError: (e) => fail(e, 'افزودن استخر ناموفق بود'), + }); + + const update = useMutation({ + mutationFn: ({ uuid, d }: { uuid: string; d: { name?: string; active?: boolean } }) => + api.patch>(`/api/v1/resource-pool/${uuid}`, d), + onSuccess: () => { toast.success('استخر به‌روزرسانی شد'); invalidate(); }, + onError: (e) => fail(e, 'به‌روزرسانی ناموفق بود'), + }); + + const remove = useMutation({ + mutationFn: (uuid: string) => api.delete>(`/api/v1/resource-pool/${uuid}`), + onSuccess: () => { toast.success('استخر حذف شد'); invalidate(); }, + onError: (e) => fail(e, 'حذف استخر ناموفق بود'), + }); + + /** جایگزینی کامل اعضا؛ سرور هم‌شعبه و هم‌نوع بودن را اجبار می‌کند. */ + const setMembers = useMutation({ + mutationFn: ({ uuid, members }: { uuid: string; members: { resource_uuid: string; priority: number }[] }) => + api.put>(`/api/v1/resource-pool/${uuid}/members`, { members }), + onSuccess: () => { toast.success('اعضای استخر ذخیره شد'); invalidate(); }, + onError: (e) => fail(e, 'ذخیرهٔ اعضا ناموفق بود'), + }); + + return { pools: query.data?.data ?? [], loading: query.isLoading, create, update, remove, setMembers }; +} diff --git a/assets/admin/pages/ResourcePoolsPage.tsx b/assets/admin/pages/ResourcePoolsPage.tsx new file mode 100644 index 00000000..e04f9efc --- /dev/null +++ b/assets/admin/pages/ResourcePoolsPage.tsx @@ -0,0 +1,316 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { PlusIcon } from '@heroicons/react/24/outline'; +import PageHeader from '../components/ui/PageHeader'; +import DataTable, { type Column } from '../components/ui/DataTable'; +import Modal from '../components/ui/Modal'; +import ConfirmDialog from '../components/ui/ConfirmDialog'; +import SearchableSelect from '../components/ui/SearchableSelect'; +import { ActiveBadge } from '../components/ui/StatusBadge'; +import { useUrlState } from '../hooks/useUrlState'; +import { usePermissions } from '../hooks/usePermissions'; +import { useBranches } from '../hooks/useBranches'; +import { useResourcePools, useResources, useResourceTypes } from '../hooks/useResources'; +import type { ResourcePool } from '../types'; + +/** + * استخر منابع — گروهی از منابع که جایگزین کامل یکدیگرند. + * + * اعضا باید هم‌شعبه و هم‌نوعِ خودِ استخر باشند؛ سرور این را اجبار می‌کند و فرم هم + * فهرست انتخاب را به همان‌ها محدود می‌کند تا کاربر به خطای ۴۲۲ نخورد. + */ +export default function ResourcePoolsPage() { + const { pools, loading, create, update, remove, setMembers } = useResourcePools(); + const { branches } = useBranches(); + const { types } = useResourceTypes(); + const { can } = usePermissions(); + const canUpdate = can('appointment_settings', 'update'); + + const [urlState, setUrlState] = useUrlState({ search: '' }); + const [creating, setCreating] = useState(false); + const [membersFor, setMembersFor] = useState(null); + const [toDelete, setToDelete] = useState(null); + + const rows = useMemo(() => { + const q = urlState.search.trim(); + return q === '' ? pools : pools.filter((p) => `${p.name} ${p.type_name}`.includes(q)); + }, [pools, urlState.search]); + + const columns: Column[] = [ + { + key: 'name', + header: 'استخر', + render: (p) => ( +
+ {p.name} + {p.type_name} +
+ ), + }, + { key: 'address_name', header: 'شعبه', render: (p) => {p.address_name || '—'} }, + { + key: 'members', + header: 'اعضا', + render: (p) => + p.members.length === 0 ? ( + بدون عضو + ) : ( +
+ {p.members.map((m) => ( + {m.resource_name} + ))} +
+ ), + }, + { key: 'active', header: 'وضعیت', render: (p) => }, + ]; + + return ( +
+ setCreating(true)}> + افزودن استخر + + ) : undefined + } + /> + + setUrlState({ search: v })} + searchPlaceholder="جستجو در استخرها..." + emptyMessage="هنوز استخری تعریف نشده است" + actions={ + canUpdate + ? (p) => ( +
+ + + +
+ ) + : undefined + } + /> + + setCreating(false)} + onSave={(payload) => create.mutate(payload, { onSuccess: () => setCreating(false) })} + /> + + setMembersFor(null)} + onSave={(members) => + membersFor && + setMembers.mutate({ uuid: membersFor.uuid, members }, { onSuccess: () => setMembersFor(null) }) + } + /> + + toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })} + onCancel={() => setToDelete(null)} + /> +
+ ); +} + +function CreatePoolModal({ + open, branches, types, saving, onClose, onSave, +}: { + open: boolean; + branches: ReturnType['branches']; + types: ReturnType['types']; + saving: boolean; + onClose: () => void; + onSave: (payload: { address_uuid: string; type_uuid: string; name: string }) => void; +}) { + const [name, setName] = useState(''); + const [addressUuid, setAddressUuid] = useState(null); + const [typeUuid, setTypeUuid] = useState(null); + + useEffect(() => { + if (!open) return; + setName(''); + setAddressUuid(null); + setTypeUuid(null); + }, [open]); + + const invalid = name.trim() === '' || !addressUuid || !typeUuid; + + return ( + +
+
+ + setName(e.target.value)} placeholder="لیزرهای آلکساندرایت" /> +
+ +
+ + ({ value: b.uuid, label: b.name || 'بدون نام' }))} + value={addressUuid} + onChange={(v) => setAddressUuid(v ? String(v) : null)} + placeholder="شعبه را انتخاب کنید" + height={38} + /> +
+ +
+ + ({ value: t.uuid, label: t.name }))} + value={typeUuid} + onChange={(v) => setTypeUuid(v ? String(v) : null)} + placeholder="نوع را انتخاب کنید" + height={38} + /> +
+ +

+ شعبه و نوع پس از ساخت تغییر نمی‌کنند — اعضا باید با هر دو بخوانند. +

+ +
+ + +
+
+
+ ); +} + +function PoolMembersModal({ + pool, saving, onClose, onSave, +}: { + pool: ResourcePool | null; + saving: boolean; + onClose: () => void; + onSave: (members: { resource_uuid: string; priority: number }[]) => void; +}) { + const [chosen, setChosen] = useState([]); + + // فهرست انتخاب به همان شعبه و نوعِ استخر محدود است — همان قاعده‌ای که سرور با ۴۲۲ + // اجبار می‌کند، پس کاربر اصلاً به آن خطا نمی‌خورد. + const { resources } = useResources( + pool ? { address_uuid: pool.address_uuid, type_uuid: pool.type_uuid } : {}, + ); + + useEffect(() => { + if (!pool) return; + setChosen(pool.members.map((m) => m.resource_uuid)); + }, [pool]); + + const nameOf = (uuid: string) => resources.find((r) => r.uuid === uuid)?.name + ?? pool?.members.find((m) => m.resource_uuid === uuid)?.resource_name + ?? uuid; + + const available = resources.filter((r) => !chosen.includes(r.uuid)); + + return ( + +
+ {chosen.length === 0 ? ( +

+ این استخر عضوی ندارد. استخر بدون عضو معتبر است، ولی در انتخاب منبع «هیچ منبعی» دیده می‌شود. +

+ ) : ( +
+ {chosen.map((uuid, index) => ( +
+ {index + 1} + {nameOf(uuid)} + + +
+ ))} +
+ )} + + {available.length > 0 && ( +
+ + ({ value: r.uuid, label: r.name }))} + value={null} + onChange={(v) => v && setChosen((c) => [...c, String(v)])} + placeholder="یک منبع انتخاب کنید" + height={38} + /> +
+ )} + +

+ ترتیب همان اولویت انتخاب است. ذخیره کل فهرست را جایگزین می‌کند. +

+ +
+ + +
+
+
+ ); +} diff --git a/assets/admin/pages/ResourceTypesPage.tsx b/assets/admin/pages/ResourceTypesPage.tsx new file mode 100644 index 00000000..46f4318b --- /dev/null +++ b/assets/admin/pages/ResourceTypesPage.tsx @@ -0,0 +1,188 @@ +import React, { useMemo, useState } from 'react'; +import { PlusIcon } from '@heroicons/react/24/outline'; +import PageHeader from '../components/ui/PageHeader'; +import DataTable, { type Column } from '../components/ui/DataTable'; +import Modal from '../components/ui/Modal'; +import ConfirmDialog from '../components/ui/ConfirmDialog'; +import { ActiveBadge } from '../components/ui/StatusBadge'; +import { useUrlState } from '../hooks/useUrlState'; +import { usePermissions } from '../hooks/usePermissions'; +import { useResourceTypes } from '../hooks/useResources'; +import type { ResourceType } from '../types'; + +/** نوع منبع — کلینیک خودش تعریفش می‌کند؛ سه نوع سیستمی را backfill می‌سازد. */ +export default function ResourceTypesPage() { + const { types, loading, create, update, remove } = useResourceTypes(); + const { can } = usePermissions(); + const canUpdate = can('appointment_settings', 'update'); + + const [urlState, setUrlState] = useUrlState({ search: '' }); + const [editing, setEditing] = useState<{ open: boolean; type: ResourceType | null }>({ open: false, type: null }); + const [toDelete, setToDelete] = useState(null); + + const rows = useMemo(() => { + const q = urlState.search.trim(); + return q === '' ? types : types.filter((t) => `${t.name} ${t.code}`.includes(q)); + }, [types, urlState.search]); + + const columns: Column[] = [ + { + key: 'name', + header: 'نوع منبع', + render: (t) => ( +
+ {t.name} + {t.is_system && سیستمی} +
+ ), + }, + { key: 'code', header: 'کد', render: (t) => {t.code} }, + { key: 'resources_count', header: 'تعداد منبع', render: (t) => {t.resources_count ?? 0} }, + { key: 'active', header: 'وضعیت', render: (t) => }, + ]; + + return ( +
+ setEditing({ open: true, type: null })}> + افزودن نوع + + ) : undefined + } + /> + + setUrlState({ search: v })} + searchPlaceholder="جستجو در نوع منابع..." + emptyMessage="هنوز نوع منبعی تعریف نشده است" + actions={ + canUpdate + ? (t) => ( +
+ + +
+ ) + : undefined + } + /> + + setEditing({ open: false, type: null })} + onSave={(payload) => { + const opts = { onSuccess: () => setEditing({ open: false, type: null }) }; + if (editing.type) update.mutate({ uuid: editing.type.uuid, d: { name: payload.name, active: payload.active } }, opts); + else create.mutate({ code: payload.code, name: payload.name }, opts); + }} + /> + + toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })} + onCancel={() => setToDelete(null)} + /> +
+ ); +} + +function TypeModal({ + open, type, saving, onClose, onSave, +}: { + open: boolean; + type: ResourceType | null; + saving: boolean; + onClose: () => void; + onSave: (payload: { code: string; name: string; active: boolean }) => void; +}) { + const [code, setCode] = useState(''); + const [name, setName] = useState(''); + const [active, setActive] = useState(true); + + React.useEffect(() => { + if (!open) return; + setCode(type?.code ?? ''); + setName(type?.name ?? ''); + setActive(type?.active ?? true); + }, [open, type]); + + const isEdit = type !== null; + const codeValid = /^[a-z0-9_]{1,40}$/.test(code); + const invalid = name.trim() === '' || (!isEdit && !codeValid); + + return ( + +
+
+ + setCode(e.target.value)} + placeholder="laser_device" + style={{ direction: 'ltr' }} + /> + {!isEdit && code !== '' && !codeValid && ( + + فقط حروف کوچک انگلیسی، عدد و زیرخط. + + )} + {isEdit && ( + + کد پس از ساخت تغییر نمی‌کند؛ منابع موجود با همین کد پیدا می‌شوند. + + )} +
+ +
+ + setName(e.target.value)} placeholder="دستگاه لیزر" /> +
+ + + +
+ + +
+
+
+ ); +} diff --git a/assets/admin/pages/ResourcesPage.test.tsx b/assets/admin/pages/ResourcesPage.test.tsx new file mode 100644 index 00000000..abae5905 --- /dev/null +++ b/assets/admin/pages/ResourcesPage.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, fireEvent, 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() } })); + +vi.mock('../hooks/usePermissions', () => ({ + usePermissions: () => ({ can: () => true }), +})); + +import { api } from '../lib/api'; +import ResourcesPage from './ResourcesPage'; + +const get = api.get as ReturnType; +const post = api.post as ReturnType; + +const branch = { + id: '1', uuid: 'b1', type: 'clinic', clinic_id: 3, clinic_name: 'کلینیک', + name: 'شعبهٔ مرکزی', map: { latitude: null, longitude: null }, address: null, + telephone: null, active: true, timezone: 'Asia/Tehran', city: null, province: null, +}; + +const laserType = { uuid: 't1', code: 'laser', name: 'دستگاه لیزر', is_system: false, active: true, resources_count: 1, created_at: 0, updated_at: 0 }; +const skill = { uuid: 's1', name: 'آلکساندرایت', active: true, resources_count: 1, created_at: 0, updated_at: 0 }; + +const resource = { + uuid: 'r1', name: 'لیزر ۱', address_uuid: 'b1', address_name: 'شعبهٔ مرکزی', + type_uuid: 't1', type_code: 'laser', type_name: 'دستگاه لیزر', + capacity: 3, setup_minutes: 5, cleanup_minutes: 10, attributes: {}, + subject_kind: null, subject_uuid: null, + skills: [{ skill_uuid: 's1', skill_name: 'آلکساندرایت', level: 4 }], + active: true, created_at: 0, updated_at: 0, +}; + +function mockApi() { + get.mockImplementation((path: string) => { + if (path.startsWith('/api/v1/branches')) return Promise.resolve({ success: true, data: [branch] }); + if (path.startsWith('/api/v1/resource-types')) return Promise.resolve({ success: true, data: [laserType] }); + if (path.startsWith('/api/v1/skills')) return Promise.resolve({ success: true, data: [skill] }); + if (path.startsWith('/api/v1/resources')) return Promise.resolve({ success: true, data: [resource] }); + return Promise.resolve({ success: true, data: [] }); + }); + post.mockResolvedValue({ success: true, data: resource }); +} + +describe('ResourcesPage', () => { + beforeEach(() => vi.clearAllMocks()); + + it('shows capacity as concurrency and both buffer minutes', async () => { + mockApi(); + renderWithProviders(, { route: '/admin/resources' }); + + await waitFor(() => expect(screen.getByText('لیزر ۱')).toBeInTheDocument()); + expect(screen.getByText('3 نفر')).toBeInTheDocument(); + expect(screen.getByText('5 / 10 دقیقه')).toBeInTheDocument(); + }); + + it('labels a resource with no bridge as equipment', async () => { + mockApi(); + renderWithProviders(, { route: '/admin/resources' }); + + await waitFor(() => expect(screen.getByText(/تجهیزات/)).toBeInTheDocument()); + }); + + it('renders each skill with its level', async () => { + mockApi(); + renderWithProviders(, { route: '/admin/resources' }); + + await waitFor(() => expect(screen.getByText('آلکساندرایت · 4')).toBeInTheDocument()); + }); + + /** + * فیلترها از URL خوانده می‌شوند و مستقیم به سرور می‌روند — فیلتر دوبارهٔ سمت + * کلاینت روی فهرستی که خودش فیلترشده آمده، منبع اختلاف است. + */ + it('passes the URL filters straight to the server', async () => { + mockApi(); + renderWithProviders(, { + route: '/admin/resources?address=b1&type=t1&skill=s1&status=1', + }); + + await waitFor(() => expect(get).toHaveBeenCalled()); + + const called = get.mock.calls.map((c) => String(c[0])); + const listCall = called.find((p) => p.startsWith('/api/v1/resources')); + + expect(listCall).toContain('address_uuid=b1'); + expect(listCall).toContain('type_uuid=t1'); + expect(listCall).toContain('skill_uuid=s1'); + expect(listCall).toContain('active=1'); + }); + + it('sends address and type only when creating, never when editing', async () => { + mockApi(); + renderWithProviders(, { route: '/admin/resources' }); + + await waitFor(() => expect(screen.getByText('لیزر ۱')).toBeInTheDocument()); + + fireEvent.click(screen.getByText('افزودن منبع')); + fireEvent.change(screen.getByPlaceholderText('لیزر آلکساندرایت ۱'), { target: { value: 'لیزر تازه' } }); + + // شعبه و نوع هنوز انتخاب نشده‌اند → ذخیره غیرفعال است. + expect(screen.getByText('ذخیره').closest('button')).toBeDisabled(); + }); +}); diff --git a/assets/admin/pages/ResourcesPage.tsx b/assets/admin/pages/ResourcesPage.tsx new file mode 100644 index 00000000..1e9c7ac7 --- /dev/null +++ b/assets/admin/pages/ResourcesPage.tsx @@ -0,0 +1,230 @@ +import React, { useMemo, useState } from 'react'; +import { PlusIcon } from '@heroicons/react/24/outline'; +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 { ActiveBadge } from '../components/ui/StatusBadge'; +import { useUrlState } from '../hooks/useUrlState'; +import { usePermissions } from '../hooks/usePermissions'; +import { useBranches } from '../hooks/useBranches'; +import { useResources, useResourceTypes, useSkills } from '../hooks/useResources'; +import ResourceFormModal from '../components/resources/ResourceFormModal'; +import ResourceSkillsModal from '../components/resources/ResourceSkillsModal'; +import type { ClinicResource } from '../types'; + +/** برچسب فارسی پلِ هر منبع؛ `null` یعنی تجهیزاتی که پشتش موجودیت دیگری نیست. */ +const SUBJECT_LABEL: Record = { + doctor: 'پزشک', + staff: 'پرسنل', + room: 'اتاق', +}; + +/** + * منابع — هر چیزی که ممکن است اشغال باشد. + * + * فیلترها (شعبه/نوع/مهارت/وضعیت) در URL می‌نشینند تا «بازگشت» و رفرش همان نما را + * بدهند، و همان‌ها مستقیم به سرور می‌روند: فیلتر کردن سمت کلاینت با فهرستی که خودش + * فیلترشده آمده، دوباره‌کاری و منبع اختلاف است. + */ +export default function ResourcesPage() { + const [urlState, setUrlState] = useUrlState({ + search: '', address: '', type: '', skill: '', status: '', + }); + + const { branches } = useBranches(); + const { types } = useResourceTypes(); + const { skills } = useSkills(); + const { can } = usePermissions(); + const canUpdate = can('appointment_settings', 'update'); + + const { resources, loading, create, update, remove, setSkills } = useResources({ + address_uuid: urlState.address || undefined, + type_uuid: urlState.type || undefined, + skill_uuid: urlState.skill || undefined, + active: urlState.status || undefined, + }); + + const [editing, setEditing] = useState<{ open: boolean; resource: ClinicResource | null }>({ open: false, resource: null }); + const [skillsFor, setSkillsFor] = useState(null); + const [toDelete, setToDelete] = useState(null); + + const rows = useMemo(() => { + const q = urlState.search.trim(); + return q === '' ? resources : resources.filter((r) => `${r.name} ${r.type_name}`.includes(q)); + }, [resources, urlState.search]); + + const columns: Column[] = [ + { + key: 'name', + header: 'منبع', + render: (r) => ( +
+ {r.name} + + {r.type_name} + {r.subject_kind ? ` · ${SUBJECT_LABEL[r.subject_kind]}` : ' · تجهیزات'} + +
+ ), + }, + { key: 'address_name', header: 'شعبه', render: (r) => {r.address_name || '—'} }, + { + key: 'capacity', + header: 'ظرفیت هم‌زمان', + render: (r) => {r.capacity} نفر, + }, + { + key: 'buffers', + header: 'آماده‌سازی / تمیزکاری', + render: (r) => ( + + {r.setup_minutes} / {r.cleanup_minutes} دقیقه + + ), + }, + { + key: 'skills', + header: 'مهارت‌ها', + render: (r) => + r.skills.length === 0 ? ( + + ) : ( +
+ {r.skills.map((s) => ( + + {s.skill_name} · {s.level} + + ))} +
+ ), + }, + { key: 'active', header: 'وضعیت', render: (r) => }, + ]; + + return ( +
+ setEditing({ open: true, resource: null })}> + افزودن منبع + + ) : undefined + } + /> + + setUrlState({ search: v })} + searchPlaceholder="جستجو در منابع..." + emptyMessage="هیچ منبعی با این فیلترها یافت نشد" + headerExtra={ +
+
+ ({ value: b.uuid, label: b.name || 'بدون نام' }))} + value={urlState.address || null} + onChange={(v) => setUrlState({ address: v ? String(v) : '' })} + placeholder="همهٔ شعبه‌ها" + isClearable + height={36} + /> +
+
+ ({ value: t.uuid, label: t.name }))} + value={urlState.type || null} + onChange={(v) => setUrlState({ type: v ? String(v) : '' })} + placeholder="همهٔ نوع‌ها" + isClearable + height={36} + /> +
+
+ ({ value: s.uuid, label: s.name }))} + value={urlState.skill || null} + onChange={(v) => setUrlState({ skill: v ? String(v) : '' })} + placeholder="همهٔ مهارت‌ها" + isClearable + height={36} + /> +
+
+ setUrlState({ status: v ? String(v) : '' })} + placeholder="همهٔ وضعیت‌ها" + isClearable + height={36} + /> +
+
+ } + actions={ + canUpdate + ? (r) => ( +
+ + + +
+ ) + : undefined + } + /> + + setEditing({ open: false, resource: null })} + onSave={(payload) => { + const opts = { onSuccess: () => setEditing({ open: false, resource: null }) }; + if (editing.resource) update.mutate({ uuid: editing.resource.uuid, d: payload }, opts); + else create.mutate(payload, opts); + }} + /> + + setSkillsFor(null)} + onSave={(lines) => + skillsFor && + setSkills.mutate({ uuid: skillsFor.uuid, skills: lines }, { onSuccess: () => setSkillsFor(null) }) + } + /> + + toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })} + onCancel={() => setToDelete(null)} + /> +
+ ); +} diff --git a/assets/admin/pages/SkillsPage.tsx b/assets/admin/pages/SkillsPage.tsx new file mode 100644 index 00000000..47494017 --- /dev/null +++ b/assets/admin/pages/SkillsPage.tsx @@ -0,0 +1,157 @@ +import React, { useMemo, useState } from 'react'; +import { PlusIcon } from '@heroicons/react/24/outline'; +import PageHeader from '../components/ui/PageHeader'; +import DataTable, { type Column } from '../components/ui/DataTable'; +import Modal from '../components/ui/Modal'; +import ConfirmDialog from '../components/ui/ConfirmDialog'; +import { ActiveBadge } from '../components/ui/StatusBadge'; +import { useUrlState } from '../hooks/useUrlState'; +import { usePermissions } from '../hooks/usePermissions'; +import { useSkills } from '../hooks/useResources'; +import type { Skill } from '../types'; + +/** + * مهارت‌ها — «کدام اپراتور مجاز است با کدام دستگاه کار کند» یک اطلاعات است، نه یک + * قانون. با «عنوان شغلی» پرسنل قاطی نشود: آن متن آزاد و فقط برای نمایش است. + */ +export default function SkillsPage() { + const { skills, loading, create, update, remove } = useSkills(); + const { can } = usePermissions(); + const canUpdate = can('appointment_settings', 'update'); + + const [urlState, setUrlState] = useUrlState({ search: '' }); + const [editing, setEditing] = useState<{ open: boolean; skill: Skill | null }>({ open: false, skill: null }); + const [toDelete, setToDelete] = useState(null); + + const rows = useMemo(() => { + const q = urlState.search.trim(); + return q === '' ? skills : skills.filter((s) => s.name.includes(q)); + }, [skills, urlState.search]); + + const columns: Column[] = [ + { key: 'name', header: 'مهارت', render: (s) => {s.name} }, + { + key: 'resources_count', + header: 'روی چند منبع', + render: (s) => {s.resources_count ?? 0}, + }, + { key: 'active', header: 'وضعیت', render: (s) => }, + ]; + + return ( +
+ setEditing({ open: true, skill: null })}> + افزودن مهارت + + ) : undefined + } + /> + + setUrlState({ search: v })} + searchPlaceholder="جستجو در مهارت‌ها..." + emptyMessage="هنوز مهارتی تعریف نشده است" + actions={ + canUpdate + ? (s) => ( +
+ + +
+ ) + : undefined + } + /> + + setEditing({ open: false, skill: null })} + onSave={({ name, active }) => { + const opts = { onSuccess: () => setEditing({ open: false, skill: null }) }; + if (editing.skill) update.mutate({ uuid: editing.skill.uuid, d: { name, active } }, opts); + else create.mutate({ name }, opts); + }} + /> + + toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })} + onCancel={() => setToDelete(null)} + /> +
+ ); +} + +function SkillModal({ + open, skill, saving, onClose, onSave, +}: { + open: boolean; + skill: Skill | null; + saving: boolean; + onClose: () => void; + onSave: (payload: { name: string; active: boolean }) => void; +}) { + const [name, setName] = useState(''); + const [active, setActive] = useState(true); + + React.useEffect(() => { + if (!open) return; + setName(skill?.name ?? ''); + setActive(skill?.active ?? true); + }, [open, skill]); + + return ( + +
+
+ + setName(e.target.value)} placeholder="لیزر آلکساندرایت" /> +
+ + + +
+ + +
+
+
+ ); +} diff --git a/docs/api/README.md b/docs/api/README.md index 925ba1ef..1e904773 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -83,6 +83,7 @@ Only **digits** are translated — no characters are stripped, so `IR` in a sheb | [clinic.md](clinic.md) | Clinics | 7 | | [clinic-invitation.md](clinic-invitation.md) | Doctor invitations to clinics | 8 | | [branch.md](branch.md) | Branches (= addresses), working hours, rooms | 8 | +| [resource.md](resource.md) | Resources, types, skills, pools | 16 | | [appointment.md](appointment.md) | Appointments & slot booking | 6 | | [appointment-settings.md](appointment-settings.md) | Weekly schedule, date overrides, holidays | 14 | | [payment.md](payment.md) | Payments (Mellat / Sep) | 5 | diff --git a/docs/api/resource.md b/docs/api/resource.md new file mode 100644 index 00000000..1c100cb4 --- /dev/null +++ b/docs/api/resource.md @@ -0,0 +1,366 @@ +# Resource API — منابع، نوع منبع، مهارت و استخر + +> **Base:** `/api/v1` · **Auth:** JWT روی همهٔ اندپوینت‌ها +> **مجوز:** `appointment_settings` (`view` خواندن، `update` نوشتن) — همان مجوز تنظیمات +> نوبت‌دهی؛ مجوز تازه‌ای ساخته نشده. + +--- + +## منبع چیست + +قانون طلایی اول مستند: **«تقویم مال منبع است، نه مال پزشک.»** منبع هر چیزی است که +ممکن است اشغال باشد: پزشک، اپراتور، دستیار، دستگاه، اتاق، تخت، یونیت. + +هر منبع مال یک **شعبه** است و شعبه همان رکورد آدرس محل نوبت‌دهی است +(`doctor_addresses` — رجوع به [branch.md](branch.md)). پس همه‌جا `address_uuid` است، +نه `branch_id`. + +### پل، نه ادغام + +`Doctor`، `ClinicStaff` و `Room` هرکدام هویت مستقل و مصرف‌کنندهٔ زنده دارند +(`appointments.doctor_id`، `service_item_staff`، سایت عمومی). تبدیلشان به زیرکلاسِ منبع +یعنی مهاجرت هم‌زمان همهٔ آن مسیرها. به‌جایش هر منبع **حداکثر یک** پل دارد: + +| `subject_kind` | یعنی | +|---|---| +| `doctor` / `staff` / `room` | منبع همان موجودیت است | +| `null` | دستگاه یا تجهیزات — منبعی که پشتش موجودیت دیگری نیست | + +قید «حداکثر یکی» در خودِ entity اجبار می‌شود، نه با `CHECK` دیتابیس: MariaDB قید +چندستونی را قابل اتکا اجرا نمی‌کند. + +**یکتایی `(doctor_id, address_id)` است، نه `(doctor_id)`.** یک `WeeklySchedule` per +جفت (پزشک، کلینیک) است ولی هر شیفتِ درونش `location_id` خودش را دارد، پس یک پزشک از +قبل در چند آدرسِ یک محیط کار می‌کند. کلید زدن فقط روی پزشک، این واقعیت را غیرقابل‌بیان +می‌کرد — و تقویمِ تسک ۰۳ دقیقاً per مکان است. + +--- + +## سه قرارداد که باید بدانید + +**۱. `capacity` یعنی هم‌زمانی، نه تعداد ردیف.** +اتاق تزریق سه‌تخته **یک** منبع با ظرفیت ۳ است، نه سه منبع. با سه ردیف، موتور جستجو +باید سه تقویم را ادغام کند و «کدام تخت» بشود تصمیمی که هیچ‌کس نمی‌خواهد بگیرد. با +ظرفیت ۳، شرط اشغال یک شمارش ساده در برابر یک سقف است. +منبعی که یک **شخص** است (پزشک/پرسنل) ظرفیت بیش از ۱ نمی‌پذیرد. + +**۲. `setup_minutes` / `cleanup_minutes` جزو نوبت بیمار نیستند.** +بیمار ساعت ۱۰:۰۰ می‌آید و ۱۰:۳۰ می‌رود؛ ولی یونیت از ۹:۵۵ تا ۱۰:۴۰ در دسترس نیست. +با `WeeklySchedule.meta.buffer_minutes` قاطی نشود: آن فاصلهٔ سراسری بین دو نوبتِ +**پزشک** است، این per منبع. هر دو کنار هم زندگی می‌کنند و آن یکی دست‌نخورده است. + +**۳. مهارت یک جدول است، نه یک قانون.** +«کدام اپراتور مجاز است با کدام دستگاه کار کند» یک اطلاعات است. با ۵۰ اپراتور و ۲۰۰ +سرویس، سپردنش به موتور قوانین یعنی ۱۰٬۰۰۰ قانون. +با `ClinicStaff.job_title` قاطی نشود: آن متن آزاد و فقط برای نمایش است و هیچ‌جا برای +تصمیم‌گیری parse نمی‌شود. + +⚠️ همهٔ اندپوینت‌ها برای دادهٔ محیط دیگر **۴۰۴** می‌دهند، نه ۴۰۳ — وجود دادهٔ محیط +بیگانه لو نمی‌رود. مالکیت صریح سنجیده می‌شود و به `TenantFilter` تکیه نمی‌شود، چون +جداسازی سختِ فیلتر فقط روی محیطِ *انتخاب‌شده* اعمال می‌شود +([tenancy.md](../architecture/tenancy.md)). + +--- + +## نوع منبع + +### `GET /api/v1/resource-types` + +خروجی واقعی (سه نوع سیستمی را `app:resource:backfill` ساخته): + +```json +{ + "success": true, + "data": [ + { "uuid": "c39053f3-a051-4f03-942f-08a2274bf658", "code": "room", "name": "اتاق", "is_system": true, "active": true, "created_at": 1785420038, "updated_at": 1785420038, "resources_count": 0 }, + { "uuid": "c0447da1-30af-430b-8cbd-b464ccd81624", "code": "staff", "name": "پرسنل", "is_system": true, "active": true, "created_at": 1785420038, "updated_at": 1785420038, "resources_count": 0 }, + { "uuid": "dcabd1c3-d554-4613-a33d-7c69b7ae7efe", "code": "doctor", "name": "پزشک", "is_system": true, "active": true, "created_at": 1785420038, "updated_at": 1785420038, "resources_count": 1 } + ] +} +``` + +`resources_count` با **یک** کوئری گروهی پر می‌شود، نه یکی per نوع. + +### `POST /api/v1/resource-types` + +| فیلد | نوع | الزامی | قاعده | +|---|---|---|---| +| `code` | string | ✅ | `[a-z0-9_]{1,40}` · یکتا **per محیط** (همان کد در محیط دیگر مجاز است) | +| `name` | string | ✅ | نام نمایشی فارسی | + +**۲۰۱** (خروجی واقعی): + +```json +{ + "success": true, + "data": { + "uuid": "da4d6789-1167-4acb-beac-0ea13c00a37e", + "code": "laser", + "name": "دستگاه لیزر", + "is_system": false, + "active": true, + "created_at": 1785420676, + "updated_at": 1785420676, + "resources_count": 0 + } +} +``` + +**۴۲۲:** کد تکراری در همان محیط (field `code`) · کد نامعتبر (field `code`) · نام خالی. + +### `PATCH /api/v1/resource-type/{uuid}` + +فقط `name` و `active`. **`code` تغییر نمی‌کند** حتی روی نوع غیرسیستمی: منابع موجود و +پل خودکار با همان کد پیدا می‌شوند و عوض کردنش نگاشت را بی‌صدا می‌شکند. فرستادنش خطا +نمی‌دهد، نادیده گرفته می‌شود. + +### `DELETE /api/v1/resource-type/{uuid}` + +**۴۲۲** وقتی `is_system` است («نوع منبع سیستمی حذف نمی‌شود») یا منبعی از آن نوع وجود +دارد («این نوع روی N منبع استفاده شده است»). + +--- + +## مهارت + +### `GET /api/v1/skills` · `POST /api/v1/skills` + +بدنهٔ ساخت فقط `name`. **۲۰۱** (خروجی واقعی): + +```json +{ + "success": true, + "data": { + "uuid": "48e60355-6788-4202-a775-a07986658662", + "name": "لیزر آلکساندرایت", + "active": true, + "created_at": 1785420692, + "updated_at": 1785420692, + "resources_count": 0 + } +} +``` + +### `PATCH /api/v1/skill/{uuid}` — `name` و `active` + +### `DELETE /api/v1/skill/{uuid}` + +مهارتی که روی منبعی نشسته حذف نمی‌شود؛ وگرنه `ON DELETE RESTRICT` خطای خام دیتابیس +می‌داد. **۴۲۲** (خروجی واقعی): + +```json +{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"این مهارت به 1 منبع داده شده است؛ اول از آن‌ها برداشته شود"}]} +``` + +> رقم لاتین در پیام عمدی است — قرارداد پیام‌های درون‌ریزیِ بک‌اند همین است و +> قالب‌بندی فارسی کارِ نمایش در کلاینت است. + +--- + +## منابع + +### `GET /api/v1/resources` + +| Query | توضیح | +|---|---| +| `address_uuid` | فقط منابع این شعبه | +| `type_uuid` | فقط این نوع | +| `skill_uuid` | فقط منابعی که این مهارت را دارند | +| `active` | `1` / `0` | +| `clinic_uuid` | انتخاب صریح محیط | + +`skill_uuid` متعلق به محیط دیگر **۴۰۴** می‌دهد، نه «هیچ نتیجه» — سکوت اینجا یعنی +دیباگِ کور. + +### `POST /api/v1/resource` + +| فیلد | نوع | الزامی | قاعده | +|---|---|---|---| +| `address_uuid` | string | ✅ | شعبه؛ جفت محیطِ منبع **از همین** مشتق می‌شود، نه از بدنه | +| `type_uuid` | string | ✅ | | +| `name` | string | ✅ | حداکثر ۱۵۰ نویسه | +| `capacity` | int | — | پیش‌فرض ۱، حداقل ۱؛ روی منبعِ شخص حداکثر ۱ | +| `setup_minutes` | int | — | پیش‌فرض ۰، بازهٔ ۰..۴۸۰ | +| `cleanup_minutes` | int | — | پیش‌فرض ۰، بازهٔ ۰..۴۸۰ | +| `attributes` | object | — | حداکثر ۲۰ کلید · کلید `[a-z_]{1,40}` · مقدار فقط اسکالر | +| `active` | bool | — | پیش‌فرض `true` | + +`attributes` عمداً آزاد است — کلید ناشناخته پذیرفته می‌شود — ولی مقدارش باید ساده +باشد. دلیل: تسک ۰۵ قید `same_gender` و تسک ۰۹ شرط‌های منبع را با مقایسهٔ ساده روی +همین مقادیر می‌سنجند؛ آرایهٔ تودرتو یعنی مقایسهٔ دلخواه، همان چیزی که بند ۸ مستند +ممنوع کرده. کلیدهای قراردادی: `gender`, `device_model`, `floor`, `brand`. + +**۲۰۱** (خروجی واقعی): + +```json +{ + "success": true, + "data": { + "uuid": "38bdd1e9-d982-4cef-9419-3d42ee6b85f2", + "name": "لیزر آلکساندرایت ۱", + "address_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6", + "address_name": "درمانگاه شبانه روزی صدرا ", + "type_uuid": "da4d6789-1167-4acb-beac-0ea13c00a37e", + "type_code": "laser", + "type_name": "دستگاه لیزر", + "capacity": 1, + "setup_minutes": 5, + "cleanup_minutes": 10, + "attributes": { "device_model": "Candela GentleLase", "floor": "2" }, + "subject_kind": null, + "subject_uuid": null, + "skills": [], + "active": true, + "created_at": 1785420692, + "updated_at": 1785420692 + } +} +``` + +**۴۲۲ — ظرفیت روی منبعِ شخص** (خروجی واقعی): + +```json +{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"منبعی که یک شخص است نمی‌تواند ظرفیت بیش از ۱ داشته باشد","field":"capacity"}]} +``` + +**۴۲۲ — ویژگی غیر اسکالر** (خروجی واقعی): + +```json +{"success":false,"data":null,"errors":[{"code":"ERR_VALIDATION_001","message":"مقدار ویژگی «nested» باید یک مقدار ساده باشد","field":"attributes"}]} +``` + +سایر ۴۲۲ها: `capacity < 1` · نام خالی · کلید ویژگی غیر snake_case · +`setup/cleanup` بیرون از ۰..۴۸۰. + +### `GET/PATCH/DELETE /api/v1/resource/{uuid}` + +`PATCH` همان فیلدهاست؛ `address_uuid` پذیرفته **نمی‌شود** (جفت محیط از آدرس مشتق شده +و write-once است) و `type_uuid` قابل تغییر است. + +### `PUT /api/v1/resource/{uuid}/skills` + +جایگزینی **کامل**: مهارتی که در بدنه نیست، برداشته می‌شود. `{"skills":[]}` همه را +پاک می‌کند. + +```json +{ "skills": [ { "skill_uuid": "48e60355-…", "level": 4 } ] } +``` + +`level` بین ۱ و ۵ (پیش‌فرض ۱). از روز اول هست چون استراتژی «حفظ متخصص‌ها» در تسک ۰۶ +رویش ساخته می‌شود و افزودنش بعداً یعنی backfill با حدس. + +پاسخ ۲۰۰ کلِ منبع است؛ بخش `skills` آن (خروجی واقعی): + +```json +"skills": [ + { "skill_uuid": "48e60355-6788-4202-a775-a07986658662", "skill_name": "لیزر آلکساندرایت", "level": 4 } +] +``` + +**۴۲۲:** `level` بیرون از ۱..۵ (field `level`) · مهارت تکراری در یک بدنه · +`skill_uuid` غایب. **۴۰۴:** مهارت محیط دیگر. + +> **اتمی است.** اعتبارسنجی کل فهرست پیش از هر حذفی انجام می‌شود، پس یک ردیف نامعتبر +> در انتهای فهرست، مهارت‌های درستِ قبلی را پاک نمی‌کند و بعد ۴۲۲ برگرداند. + +--- + +## استخر منابع + +گروهی از منابع که **جایگزین کامل** یکدیگرند: «لیزرهای آلکساندرایت»، «اتاق‌های معاینه». + +### `GET/POST /api/v1/resource-pools` + +بدنهٔ ساخت: `address_uuid` · `type_uuid` · `name`. + +### `GET/PATCH/DELETE /api/v1/resource-pool/{uuid}` + +`PATCH` فقط `name` و `active`. `DELETE` اعضا را با CASCADE می‌برد ولی **خودِ منابع +دست‌نخورده می‌مانند**. + +### `PUT /api/v1/resource-pool/{uuid}/members` + +```json +{ "members": [ { "resource_uuid": "38bdd1e9-…", "priority": 0 } ] } +``` + +هر عضو باید **همان شعبه و همان نوعِ** استخر را داشته باشد. تسک ۰۶ فرض می‌کند هر عضو +جایگزین کامل دیگری است: عضوی از شعبهٔ دیگر یعنی بیمار در ساختمان اشتباه می‌ایستد، و +عضوی از نوع دیگر یعنی صندلی به‌جای دستگاه لیزر پیشنهاد می‌شود. + +`priority` ترتیب ترجیح در استراتژی انتخاب است؛ کوچک‌تر زودتر. + +**۲۰۰** (خروجی واقعی): + +```json +{ + "success": true, + "data": { + "uuid": "9d50cf4a-bef6-423c-85e9-530bf3609964", + "name": "لیزرهای آلکساندرایت", + "address_uuid": "d0601f79-6e4a-482e-afed-c9be3928d9e6", + "address_name": "درمانگاه شبانه روزی صدرا ", + "type_uuid": "da4d6789-1167-4acb-beac-0ea13c00a37e", + "type_code": "laser", + "type_name": "دستگاه لیزر", + "members": [ + { "resource_uuid": "38bdd1e9-d982-4cef-9419-3d42ee6b85f2", "resource_name": "لیزر آلکساندرایت ۱", "priority": 0, "active": true } + ], + "active": true, + "created_at": 1785420708, + "updated_at": 1785420708 + } +} +``` + +**۴۲۲:** «همهٔ اعضای استخر باید در یک شعبه باشند» · «… از یک نوع منبع باشند» · عضو +تکراری. مثل مهارت‌ها، اعتبارسنجی پیش از حذف است. + +استخر **بدون عضو** معتبر است (در حال ساخت)، ولی تسک ۰۶ آن را «هیچ منبعی» می‌بیند. + +--- + +## `app:resource:backfill` + +هر موجودیت قابل‌اشغالِ موجود را به یک منبع پل می‌زند. + +```bash +ddev exec php bin/console app:resource:backfill # dry-run +ddev exec php bin/console app:resource:backfill --force +ddev exec php bin/console app:resource:backfill --force --pair=clinic:12 +``` + +| مورد | رفتار | +|---|---| +| اتاق | آدرسش را خودش دارد → منبع با **همان `capacity`** و همان `active` | +| پزشک | یک منبع per آدرسی که در برنامهٔ هفتگی **شیفت فعال** دارد (`location_id`) | +| پرسنل | فقط اگر محیط دقیقاً **یک** شعبه دارد؛ وگرنه رد و **گزارش** می‌شود | + +پرسنل تنها موردی است که قابل استنتاج نیست: هیچ ستونی نمی‌گوید در کدام شعبه کار می‌کند. +حدس زدنِ «اولین شعبه» او را در ساختمان اشتباه می‌نشاند، پس گزارش می‌شود تا کاربر خودش +تعیین کند. + +`--pair` هم برای عملیات است (اجرای دوباره برای یک کلینیک) و هم دامنه را محدود می‌کند. +دستور **per محیط flush می‌کند**، پس یک ردیف خراب کل اجرای چندهزارمحیطی را با +`EntityManagerClosed` از پا نمی‌اندازد. + +idempotent است: تکیه‌گاهش وجود یا نبودِ منبعِ متناظر است، نه یک پرچم جداگانه. + +--- + +## طبقه‌بندی محیط + +| جدول | وضعیت | +|---|---| +| `resource_types` · `clinic_resources` · `skills` · `resource_pools` | جفت `(entity_type, entity_id)` | +| `resource_skills` · `resource_pool_members` | `AGGREGATE_CHILDREN` — ریشه‌هاشان خودشان جفت دارند | + +--- + +## تست‌ها + +```bash +ddev exec php bin/phpunit tests/Resource # ۵۲ تست / ۱۲۹ assertion +ddev exec php vendor/bin/phpstan analyse src/Resource +npx vitest run assets/admin/pages/ResourcesPage.test.tsx +``` diff --git a/docs/api/staff.md b/docs/api/staff.md index 70fea682..c6e8c849 100644 --- a/docs/api/staff.md +++ b/docs/api/staff.md @@ -178,3 +178,20 @@ |------|------|-------| | ERR_STAFF_NOT_FOUND | 404 | پرسنل یافت نشد | | ERR_FORBIDDEN_001 | 403 | دسترسی ندارید | + +--- + +## رابطه با منبع + +پرسنل به‌تنهایی تقویم، ظرفیت و مهارت ندارد؛ آن‌ها روی **منبع** می‌نشینند +([resource.md](resource.md)). هر پرسنل می‌تواند یک منبع از نوع `staff` در هر شعبه +داشته باشد و `app:resource:backfill` این پل را برای پرسنلِ موجود می‌سازد. + +| نکته | چرا | +|---|---| +| `job_title` **مهارت نیست** | متن آزاد و فقط برای نمایش است؛ هیچ‌جا برای تصمیم‌گیری parse نمی‌شود. مهارت یک موجودیت با هویت است که در انتخاب منبع و شرط قوانین استفاده می‌شود | +| غیرفعال کردن پرسنل | باید منبعش را هم ببندد، وگرنه در جستجوی وقت ظاهر می‌شود. `ResourceLinker::syncActive()` این کار را می‌کند — **فراخوانی صریح**، نه Doctrine lifecycle callback (که در `getArrayResult()` اجرا نمی‌شود و رفتار نامتقارن می‌سازد) | +| غیرفعال کردن منبع | پرسنل را غیرفعال **نمی‌کند** — پرسنل ممکن است فقط نقش اداری داشته باشد | +| پرسنلِ محیط چندشعبه‌ای | backfill پلش را نمی‌سازد و **گزارش** می‌دهد: هیچ ستونی نمی‌گوید در کدام شعبه کار می‌کند و حدس زدن او را در ساختمان اشتباه می‌نشاند | + +ظرفیت منبعِ پرسنل همیشه ۱ است — یک شخص هم‌زمان دو بیمار ندارد. diff --git a/docs/architecture/tenancy.md b/docs/architecture/tenancy.md index af133619..4a9886ba 100644 --- a/docs/architecture/tenancy.md +++ b/docs/architecture/tenancy.md @@ -154,6 +154,11 @@ public function __construct(ServiceSection $section, ...) { **درسِ عملیاتی:** آنجا که `AGGREGATE_CHILDREN` بی‌فایده است، فقط طبقه‌بندی عوض نکن؛ جفت واقعی بده. و برای ریشهٔ سراسری یک resolver واحد بساز، نه بررسی تکراری در هر کنترلر. +قرینهٔ درست همین ماجرا در تسک ۰۲ دیده می‌شود: `resource_skills` و +`resource_pool_members` هم فرزند aggregate اند، ولی ریشه‌هاشان (`clinic_resources` و +`resource_pools`) خودشان جفت محیط دارند و uuidشان از request نمی‌آید — پس آنجا +`AGGREGATE_CHILDREN` طبقه‌بندی درستی است. تفاوت در ریشه است، نه در فرزند. + ### uuid از درخواست — خطرناک‌ترین الگو سه نشتی واقعی در آدیت این نقطه پیدا شد و **هیچ‌کدام در repository نبودند**؛ همه در کنترلر و سرویس بودند، جایی که یک uuid از بدنه یا کوئری می‌آید و کسی محیطش را نمی‌سنجد: diff --git a/docs/new_feture/taskes/_shared/branch-is-doctor-address.md b/docs/new_feture/taskes/_shared/branch-is-doctor-address.md index 1c2b646c..5bab0e7e 100644 --- a/docs/new_feture/taskes/_shared/branch-is-doctor-address.md +++ b/docs/new_feture/taskes/_shared/branch-is-doctor-address.md @@ -80,3 +80,18 @@ $this->assignTenantPair( همان قاعدهٔ `docs/architecture/tenancy.md`: جفت در سازنده از ریشه مشتق می‌شود، نه از ورودی درخواست — پس هیچ نقطهٔ ساختی نمی‌تواند فراموشش کند و write-once می‌ماند. + +--- + +## به‌روزرسانی پس از تسک ۰۲ + +منابع ساخته شدند و `clinic_resources.address_id` و `resource_pools.address_id` هر دو +به `doctor_addresses(id)` می‌خورند — همان‌طور که جدول بالا پیش‌بینی کرده بود. + +یک تصحیح **اضافه** روی همان جدول: کلید یکتای منبعِ پزشک +`(doctor_id, address_id)` است نه `(doctor_id)`. یک `WeeklySchedule` per جفت +(پزشک، کلینیک) است ولی هر شیفتِ درونش `location_id` خودش را دارد، پس یک پزشک از قبل +در چند آدرسِ یک محیط کار می‌کند. همین برای `(staff_id, address_id)` هم صادق است. +`rooms` استثناست: اتاق ذاتاً در یک آدرس است، پس `UNIQUE(room_id)` کافی است. + +تسک‌های ۰۴/۰۷/۰۸/۰۹/۱۰/۱۳ که هنوز `branch_id` می‌گویند، همین الگو را دنبال کنند. diff --git a/docs/new_feture/taskes/task-02-resource-model/checklist.md b/docs/new_feture/taskes/task-02-resource-model/checklist.md index b7532ac2..1777de69 100644 --- a/docs/new_feture/taskes/task-02-resource-model/checklist.md +++ b/docs/new_feture/taskes/task-02-resource-model/checklist.md @@ -1,6 +1,6 @@ # چک‌لیست — تسک ۰۲ (منبع، نوع منبع، مهارت، استخر) -**وضعیت کلی:** ⏳ شروع نشده · **آخرین بازبینی:** — +**وضعیت کلی:** ✅ تکمیل‌شده (۲ ردیف 🔄 بازبینی چشمی · ۱ ردیف ⏳ وابسته به تسک ۰۷) · **آخرین بازبینی:** ۱۴۰۵/۰۵/۰۸ قواعد: [_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,90 +11,90 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۰.۱ | `--group=slot-mode-frozen` سبز | ⏳ | | -| ۰.۲ | `Doctor` به `Resource` تبدیل **نشد** — فقط لینک شد | ⏳ | `appointments.doctor_id` سر جایش | -| ۰.۳ | `ClinicStaff` و `Room` هویت مستقل حفظ کردند | ⏳ | | -| ۰.۴ | `service_item_staff` و `ServiceItem.staffMembers` دست‌نخورده | ⏳ | | -| ۰.۵ | `WeeklySchedule.meta.buffer_minutes` دست‌نخورده | ⏳ | `setup/cleanup` مفهوم جداست | +| ۰.۱ | `--group=slot-mode-frozen` سبز | ✅ | ۳ تست / ۸ assertion سبز | +| ۰.۲ | `Doctor` به `Resource` تبدیل **نشد** — فقط لینک شد | ✅ | فقط پل؛ `appointments.doctor_id` دست‌نخورده | +| ۰.۳ | `ClinicStaff` و `Room` هویت مستقل حفظ کردند | ✅ | هر سه هویت مستقل ماندند | +| ۰.۴ | `service_item_staff` و `ServiceItem.staffMembers` دست‌نخورده | ✅ | صفر تغییر | +| ۰.۵ | `WeeklySchedule.meta.buffer_minutes` دست‌نخورده | ✅ | `setup/cleanup` per منبع است، آن یکی per پزشک | ## ۱. بک‌اند | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۱.۱ | `ResourceType` · `ClinicResource` · `Skill` · `ResourceSkill` | ⏳ | | -| ۱.۲ | `ResourcePool` · `ResourcePoolMember` | ⏳ | | -| ۱.۳ | نام کلاس `ClinicResource` (نه `Resource`) و جدول `clinic_resources` | ⏳ | | -| ۱.۴ | `ResourceLinker` — تنها نقطهٔ نگاشت پزشک/پرسنل/اتاق ↔ منبع | ⏳ | | -| ۱.۵ | حداکثر یکی از `doctor_id`/`staff_id`/`room_id` — قید در سازنده | ⏳ | | -| ۱.۶ | `capacity>1` روی `type=doctor` → ۴۲۲ | ⏳ | | -| ۱.۷ | `normalizeAttributes` — اسکالر، کلید `[a-z_]{1,40}`، سقف ۲۰ | ⏳ | | -| ۱.۸ | `ResourcePoolService` — اعضا هم‌شعبه و هم‌نوع، وگرنه ۴۲۲ | ⏳ | | -| ۱.۹ | `findEligible()` با `HAVING COUNT(DISTINCT skill) = n` | ⏳ | همهٔ مهارت‌ها، نه یکی | -| ۱.۱۰ | `StaffService` موجود `ResourceLinker::syncActive()` صدا می‌زند | ⏳ | نه lifecycle callback | -| ۱.۱۱ | چهارده endpoint | ⏳ | | -| ۱.۱۲ | `app:resource:backfill` — dry-run، idempotent | ⏳ | سه نوع سیستمی + پزشک/پرسنل/اتاق | -| ۱.۱۳ | `TenantOwnershipChecker` روی هر uuid از request | ⏳ | | +| ۱.۱ | `ResourceType` · `ClinicResource` · `Skill` · `ResourceSkill` | ✅ | | +| ۱.۲ | `ResourcePool` · `ResourcePoolMember` | ✅ | | +| ۱.۳ | نام کلاس `ClinicResource` (نه `Resource`) و جدول `clinic_resources` | ✅ | | +| ۱.۴ | `ResourceLinker` — تنها نقطهٔ نگاشت پزشک/پرسنل/اتاق ↔ منبع | ✅ | + identity map برای نوع‌های flush-نشده | +| ۱.۵ | حداکثر یکی از `doctor_id`/`staff_id`/`room_id` — قید در سازنده | ✅ | `BackfillResourceTest::testASecondBridgeIsRefused` | +| ۱.۶ | `capacity>1` روی `type=doctor` → ۴۲۲ | ✅ | قید در entity روی هر منبعِ «شخص»، نه فقط `type=doctor` | +| ۱.۷ | `normalizeAttributes` — اسکالر، کلید `[a-z_]{1,40}`، سقف ۲۰ | ✅ | | +| ۱.۸ | `ResourcePoolService` — اعضا هم‌شعبه و هم‌نوع، وگرنه ۴۲۲ | ✅ | اعتبارسنجی پیش از حذف — اتمی | +| ۱.۹ | `findEligible()` با `HAVING COUNT(DISTINCT skill) = n` | ✅ | `ResourceEligibilityTest` هر دو حالت را می‌سنجد | +| ۱.۱۰ | `StaffService` موجود `ResourceLinker::syncActive()` صدا می‌زند | ✅ | `StaffController::toggle` صدا می‌زند؛ تست سطح endpoint دارد | +| ۱.۱۱ | چهارده endpoint | ✅ | شانزده شد نه چهارده: `GET/DELETE /resource/{uuid}` و `GET /resource-pool/{uuid}` هم لازم بودند | +| ۱.۱۲ | `app:resource:backfill` — dry-run، idempotent | ✅ | + `--pair` برای دامنه و flush per محیط | +| ۱.۱۳ | `TenantOwnershipChecker` روی هر uuid از request | ✅ | `ResourceContext` تک‌نقطهٔ ۴۰۴ | ## ۲. دیتابیس | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۲.۱ | شش جدول ساخته شد | ⏳ | | -| ۲.۲ | سه UNIQUE تهی‌پذیر روی `doctor_id`/`staff_id`/`room_id` | ⏳ | | -| ۲.۳ | `idx_resource_skills_skill (skill_id, level)` | ⏳ | کوئری داغ تسک ۰۶ | -| ۲.۴ | `entity_type, entity_id` ستون اول ایندکس‌های لیست | ⏳ | | -| ۲.۵ | `resource_skills` و `resource_pool_members` در `AGGREGATE_CHILDREN` | ⏳ | | -| ۲.۶ | `TenantSchemaCoverageTest` سبز | ⏳ | | +| ۲.۱ | شش جدول ساخته شد | ✅ | `Version20260730132948` | +| ۲.۲ | سه UNIQUE تهی‌پذیر روی `doctor_id`/`staff_id`/`room_id` | ✅ | دو تای اول به `(doctor_id, address_id)` و `(staff_id, address_id)` تصحیح شد — پزشک در چند شعبهٔ یک محیط کار می‌کند | +| ۲.۳ | `idx_resource_skills_skill (skill_id, level)` | ✅ | کوئری داغ تسک ۰۶ | +| ۲.۴ | `entity_type, entity_id` ستون اول ایندکس‌های لیست | ✅ | | +| ۲.۵ | `resource_skills` و `resource_pool_members` در `AGGREGATE_CHILDREN` | ✅ | ریشه‌هاشان جفت دارند، پس ارث‌بری واقعی است | +| ۲.۶ | `TenantSchemaCoverageTest` سبز | ✅ | | ## ۳. UI | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۳.۱ | `ResourcesPage` · `ResourceFormPage` · `ResourceTypesPage` · `SkillsPage` · `ResourcePoolsPage` | ⏳ | | -| ۳.۲ | فیلتر شعبه/نوع/فعال در URL با `useUrlState` | ⏳ | | -| ۳.۳ | شعبه و نوع با `SearchableSelect` | ⏳ | | -| ۳.۴ | مهارت‌ها با چیپ چندانتخابی | ⏳ | | -| ۳.۵ | `DataTable` با skeleton و empty state | ⏳ | | -| ۳.۶ | `backTo`/`BackButton` روی همهٔ زیرصفحه‌ها | ⏳ | | -| ۳.۷ | هیچ رنگ/شعاع hard-code | ⏳ | | -| ۳.۸ | دارک‌مود و حالت فشرده | ⏳ | | -| ۳.۹ | RTL و موبایل | ⏳ | | -| ۳.۱۰ | `setup/cleanup` با واحد فارسی «دقیقه» | ⏳ | | -| ۳.۱۱ | هشدار «این منبع N نوبت آیندهٔ فعال دارد» هنگام غیرفعال‌سازی | ⏳ | | +| ۳.۱ | `ResourcesPage` · `ResourceFormPage` · `ResourceTypesPage` · `SkillsPage` · `ResourcePoolsPage` | ✅ | `ResourceFormPage` به‌صورت مودال شد نه صفحه: فرم کوتاه است و صفحهٔ جدا یک ناوبری اضافه بدون سود می‌داد | +| ۳.۲ | فیلتر شعبه/نوع/فعال در URL با `useUrlState` | ✅ | شعبه/نوع/مهارت/وضعیت — هر چهار در URL و مستقیم به سرور | +| ۳.۳ | شعبه و نوع با `SearchableSelect` | ✅ | هیچ `