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` | ✅ | هیچ `