From e08876f7b49f5861d3fcb54341a639891fa255b9 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 2 Aug 2026 13:06:06 +0330 Subject: [PATCH] feat(resources): one tabbed page per resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the resource-first model a resource is the unit of capacity, so its working hours, holidays, services, skills and categories belong to it — not scattered across a list page's modals plus a separate calendar page. /admin/resources/{uuid} now carries six tabs and the active tab lives in the query string, so back and refresh land on the same view. The old /calendar URL redirects to ?tab=hours instead of 404ing. The skills and services modal bodies became panels the tab renders directly; the modals are now thin wrappers, so the list page keeps working unchanged and there is still one implementation of each editor. Co-Authored-By: Claude Opus 5 --- assets/admin/App.tsx | 14 +- .../resources/ResourceCategoriesPanel.tsx | 94 +++++ .../resources/ResourceExceptionsPanel.tsx | 196 +++++++++ .../resources/ResourceServicesModal.tsx | 216 +--------- .../resources/ResourceServicesPanel.tsx | 220 ++++++++++ .../resources/ResourceSkillsModal.tsx | 101 +---- .../resources/ResourceSkillsPanel.tsx | 103 +++++ .../resources/ResourceWorkingHoursPanel.tsx | 188 +++++++++ assets/admin/hooks/useResources.ts | 14 +- assets/admin/pages/ResourceCalendarPage.tsx | 383 ------------------ ...e.test.tsx => ResourceDetailPage.test.tsx} | 96 +++-- assets/admin/pages/ResourceDetailPage.tsx | 196 +++++++++ assets/admin/pages/ResourcesPage.tsx | 4 +- assets/admin/types/index.ts | 2 + docs/api/resource-calendar.md | 4 + 15 files changed, 1126 insertions(+), 705 deletions(-) create mode 100644 assets/admin/components/resources/ResourceCategoriesPanel.tsx create mode 100644 assets/admin/components/resources/ResourceExceptionsPanel.tsx create mode 100644 assets/admin/components/resources/ResourceServicesPanel.tsx create mode 100644 assets/admin/components/resources/ResourceSkillsPanel.tsx create mode 100644 assets/admin/components/resources/ResourceWorkingHoursPanel.tsx delete mode 100644 assets/admin/pages/ResourceCalendarPage.tsx rename assets/admin/pages/{ResourceCalendarPage.test.tsx => ResourceDetailPage.test.tsx} (51%) create mode 100644 assets/admin/pages/ResourceDetailPage.tsx diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 81444b40..e0ceae47 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import { Routes, Route, Navigate, useLocation, useParams } from 'react-router-dom'; import { useAuthStore } from './stores/authStore'; import { usePermissions } from './hooks/usePermissions'; import AdminLayout from './components/layout/AdminLayout'; @@ -84,7 +84,7 @@ import ResourceTypesPage from './pages/ResourceTypesPage'; import CatalogCategoriesPage from './pages/CatalogCategoriesPage'; import SkillsPage from './pages/SkillsPage'; import ResourcePoolsPage from './pages/ResourcePoolsPage'; -import ResourceCalendarPage from './pages/ResourceCalendarPage'; +import ResourceDetailPage from './pages/ResourceDetailPage'; import HolidaysSettingsPage from './pages/HolidaysSettingsPage'; import PatientRecordFormPage from './pages/PatientRecordFormPage'; import PatientDetailPage from './pages/PatientDetailPage'; @@ -132,6 +132,12 @@ function PrivateRoute({ children }: { children: React.ReactNode }) { return <>{children}; } +/** `/resources/{uuid}/calendar` قدیمی → تب «ساعات کاری» صفحهٔ منبع. */ +function ResourceCalendarRedirect() { + const { resourceUuid } = useParams<{ resourceUuid: string }>(); + return ; +} + function PublicRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); return isAuthenticated ? : <>{children}; @@ -297,7 +303,9 @@ export default function App() { } /> } /> } /> - } /> + } /> + {/* تقویم منبع در تب «ساعات کاری» همان صفحه حل شده؛ لینک‌های قدیمی نباید بشکنند. */} + } /> } /> } /> } /> diff --git a/assets/admin/components/resources/ResourceCategoriesPanel.tsx b/assets/admin/components/resources/ResourceCategoriesPanel.tsx new file mode 100644 index 00000000..eedbd7fb --- /dev/null +++ b/assets/admin/components/resources/ResourceCategoriesPanel.tsx @@ -0,0 +1,94 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import SearchableSelect from '../ui/SearchableSelect'; +import { useCatalogCategories } from '../../hooks/useCatalogCategories'; +import type { CatalogCategory, ClinicResource } from '../../types'; + +type Flat = { uuid: string; name: string; depth: number }; + +function flatten(nodes: CatalogCategory[], depth = 0): Flat[] { + return nodes.flatMap((n) => [ + { uuid: n.uuid, name: n.name, depth }, + ...flatten(n.children ?? [], depth + 1), + ]); +} + +/** + * دسته‌بندی یک منبع — فقط **انتخاب** از کاتالوگ سراسری. + * + * ساختن دسته اینجا عمداً ممکن نیست؛ تنها جای ساخت «تنظیمات ← دسته‌بندی‌ها» است، وگرنه + * هر کاربر نسخهٔ خودش از «تمام بدن» را می‌سازد. + */ +export default function ResourceCategoriesPanel({ resource, canUpdate, saving, onSave }: { + resource: ClinicResource | null; + canUpdate: boolean; + saving: boolean; + onSave: (categoryUuids: string[]) => void; +}) { + const { tree, loading } = useCatalogCategories(); + const [chosen, setChosen] = useState([]); + + useEffect(() => { + setChosen((resource?.categories ?? []).map((c) => c.uuid)); + }, [resource]); + + const all = useMemo(() => flatten(tree), [tree]); + const picked = new Set(chosen); + const available = all.filter((c) => !picked.has(c.uuid)); + const nameOf = (uuid: string) => all.find((c) => c.uuid === uuid)?.name ?? uuid; + + return ( +
+

+ دسته‌بندی سراسری است و اینجا فقط انتخاب می‌شود. برای ساخت یا ویرایش به{' '} + تنظیمات ← دسته‌بندی‌ها{' '} + بروید. +

+ + {loading ? ( + در حال بارگذاری... + ) : chosen.length === 0 ? ( +

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

+ ) : ( +
+ {chosen.map((uuid) => ( + + {nameOf(uuid)} + {canUpdate && ( + + )} + + ))} +
+ )} + + {canUpdate && available.length > 0 && ( +
+ + ({ value: c.uuid, label: '— '.repeat(c.depth) + c.name }))} + value={null} + onChange={(v) => v && setChosen((c) => [...c, String(v)])} + placeholder="یک دسته‌بندی انتخاب کنید" + height={38} + /> +
+ )} + + {canUpdate && ( +
+ +
+ )} +
+ ); +} diff --git a/assets/admin/components/resources/ResourceExceptionsPanel.tsx b/assets/admin/components/resources/ResourceExceptionsPanel.tsx new file mode 100644 index 00000000..ceff410e --- /dev/null +++ b/assets/admin/components/resources/ResourceExceptionsPanel.tsx @@ -0,0 +1,196 @@ +import React, { useState } from 'react'; +import ConfirmDialog from '../ui/ConfirmDialog'; +import SearchableSelect from '../ui/SearchableSelect'; +import PersianDateInput from '../ui/PersianDateInput'; +import { useResourceAvailability, useResourceExceptions } from '../../hooks/useResourceCalendar'; +import { formatDate } from '../../lib/utils'; +import { DAY_LABELS } from './ResourceWorkingHoursPanel'; +import type { ResourceException } from '../../types'; + +/** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده می‌شد. */ +const REASON_LABELS: Record = { + national_holiday: 'تعطیل رسمی', + tenant_holiday: 'تعطیلی این محیط', + no_shift: 'شیفتی تعریف نشده', + branch_closed: 'شعبه این روز بسته است', + outside_branch_hours: 'شیفت بیرون از ساعت کاری شعبه', + exception: 'مرخصی یا سرویس', + resource_inactive: 'منبع غیرفعال است', + branch_inactive: 'شعبه غیرفعال است', +}; + +const EXCEPTION_TYPES = [ + { value: 'leave', label: 'مرخصی' }, + { value: 'absence', label: 'غیبت' }, + { value: 'maintenance', label: 'سرویس دوره‌ای' }, + { value: 'closure', label: 'تعطیلی موردی' }, +]; + +/** نیمه‌شبِ امروز به‌صورت timestamp ثانیه‌ای. */ +function todayMidnight(): number { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return Math.floor(d.getTime() / 1000); +} + +/** + * تعطیلات و استثناهای یک منبع، کنار پیش‌نمایش دو هفتهٔ ساعت آزاد. + * + * پیش‌نمایش عمداً «ساعت آزاد» نامیده نشده بلکه «خام» است: نوبت‌های ثبت‌شده در آن + * کسر نشده‌اند و اشتباه گرفتنش با «وقت قابل رزرو» به بیش‌رزروی می‌انجامد. + */ +export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: { + resourceUuid?: string; + canUpdate: boolean; +}) { + const { exceptions, create, remove } = useResourceExceptions(resourceUuid); + const [toDelete, setToDelete] = useState(null); + + const previewFrom = todayMidnight(); + const previewTo = previewFrom + 13 * 86400; + const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo); + + return ( +
+ create.mutate(payload)} + onDelete={setToDelete} + /> + +
+

پیش‌نمایش دو هفته

+

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

+ +
+ {(availability?.days ?? []).map((day) => ( +
+ + {DAY_LABELS[day.day_of_week]} · {formatDate(day.date * 1000)} + + {day.intervals.length === 0 ? ( + + {day.reasons.map((r) => REASON_LABELS[r] ?? r).join('، ') || '—'} + + ) : ( + {day.total_minutes} دقیقه + )} +
+ ))} +
+
+ + toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })} + onCancel={() => setToDelete(null)} + /> +
+ ); +} + +function ExceptionsCard({ + exceptions, canUpdate, saving, onCreate, onDelete, +}: { + exceptions: ResourceException[]; + canUpdate: boolean; + saving: boolean; + onCreate: (payload: { type: string; starts_at: number; ends_at: number; reason?: string | null }) => void; + onDelete: (e: ResourceException) => void; +}) { + const [type, setType] = useState('leave'); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [reason, setReason] = useState(''); + + const toTimestamp = (value: string): number | null => { + if (value === '') return null; + const ms = new Date(`${value}T00:00:00`).getTime(); + return Number.isNaN(ms) ? null : Math.floor(ms / 1000); + }; + + const start = toTimestamp(startDate); + const end = toTimestamp(endDate); + // پایان روزِ انتخاب‌شده، نه آغازش: مرخصیِ «تا سه‌شنبه» شامل خودِ سه‌شنبه است. + const endExclusive = end === null ? null : end + 86400; + const invalid = start === null || endExclusive === null || endExclusive <= start; + + return ( +
+

مرخصی و سرویس

+ + {exceptions.length === 0 ? ( +

استثنایی ثبت نشده است.

+ ) : ( +
+ {exceptions.map((e) => ( +
+ {e.type_label} + + {formatDate(e.starts_at * 1000)} تا {formatDate(e.ends_at * 1000)} + {e.reason ? ` · ${e.reason}` : ''} + + {canUpdate && ( + + )} +
+ ))} +
+ )} + + {canUpdate && ( +
+ setType(v ? String(v) : 'leave')} + placeholder="نوع استثنا" + height={36} + /> + {/* تقویم شمسی، نه `input type=date` میلادی: اپراتور تاریخ را شمسی می‌گوید و + ترجمهٔ ذهنی همان‌جایی است که استثنا یک روز جابه‌جا ثبت می‌شود. */} +
+
+ +
+
+ +
+
+ setReason(e.target.value)} placeholder="توضیح (اختیاری)" /> + +
+ )} +
+ ); +} diff --git a/assets/admin/components/resources/ResourceServicesModal.tsx b/assets/admin/components/resources/ResourceServicesModal.tsx index 71c58e79..c185609c 100644 --- a/assets/admin/components/resources/ResourceServicesModal.tsx +++ b/assets/admin/components/resources/ResourceServicesModal.tsx @@ -1,215 +1,31 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import Modal from '../ui/Modal'; -import SearchableSelect from '../ui/SearchableSelect'; -import { formatRial } from '../../lib/utils'; +import ResourceServicesPanel, { type ServiceOfferingLine } from './ResourceServicesPanel'; import type { ClinicResource, ResourceServiceOffering, ServiceItemOption } from '../../types'; -type Line = { - service_uuid: string; - service_name: string; - duration_minutes: string; - price_rials: string; - active: boolean; - effective_duration_minutes: number | null; - effective_price_rials: number; - duration_source: string | null; - price_source: string; -}; - interface Props { resource: ClinicResource | null; offerings: ResourceServiceOffering[]; services: ServiceItemOption[]; saving: boolean; onClose: () => void; - onSave: (lines: Array<{ service_uuid: string; duration_minutes: string; price_rials: string; active: boolean }>) => void; + onSave: (lines: ServiceOfferingLine[]) => void; } -/** برچسب فارسیِ سطحی که مقدار مؤثر از آن آمده. */ -const SOURCE_LABELS: Record = { - resource_option: 'همین منبع', - resource_service: 'منبع، روی سرویس والد', - branch: 'شعبه', - service_default: 'پیش‌فرض سرویس', -}; - -/** - * سرویس‌هایی که یک منبع ارائه می‌دهد، با مدت و قیمت اختصاصی. - * - * خالی‌گذاشتن مدت یا قیمت یعنی «ارث از سطح بالاتر»، نه صفر — به همین دلیل مقدار مؤثر - * به‌صورت placeholder با برچسب منبعش نشان داده می‌شود، وگرنه کاربر نمی‌فهمد خانهٔ خالی - * یعنی «تنظیم نشده» یا «رایگان». - * - * ذخیره یک PUT است و **جایگزینی کامل**، همان قرارداد مودال مهارت‌ها. - */ -export default function ResourceServicesModal({ resource, offerings, services, saving, onClose, onSave }: Props) { - const [lines, setLines] = useState([]); - - useEffect(() => { - if (!resource) return; - - setLines( - offerings.map((o) => ({ - service_uuid: o.service_uuid, - service_name: o.service_name, - duration_minutes: o.duration_minutes === null ? '' : String(o.duration_minutes), - price_rials: o.price_rials === null ? '' : String(o.price_rials), - active: o.active, - effective_duration_minutes: o.effective_duration_minutes, - effective_price_rials: o.effective_price_rials, - duration_source: o.duration_source, - price_source: o.price_source, - })), - ); - }, [resource, offerings]); - - const chosen = new Set(lines.map((l) => l.service_uuid)); - const available = services.filter((s) => !chosen.has(s.uuid)); - - const patch = (index: number, changes: Partial) => - setLines((l) => l.map((x, i) => (i === index ? { ...x, ...changes } : x))); - +/** همان پنل سرویس‌ها، در قاب مودالِ فهرست منابع. */ +export default function ResourceServicesModal({ + resource, offerings, services, saving, onClose, onSave, +}: Props) { return ( - -
- {services.length === 0 && ( -

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

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

- این منبع هیچ سرویسی ارائه نمی‌دهد. -

- ) : ( -
- {lines.map((line, index) => ( -
-
- {line.service_name} - - - - -
- -
-
- - patch(index, { duration_minutes: e.target.value })} - placeholder={ - line.effective_duration_minutes === null - ? 'تعیین نشده' - : `${line.effective_duration_minutes} — ${SOURCE_LABELS[line.duration_source ?? ''] ?? '—'}` - } - /> -
- -
- - patch(index, { price_rials: e.target.value })} - placeholder={`${formatRial(line.effective_price_rials)} — ${SOURCE_LABELS[line.price_source] ?? '—'}`} - /> -
-
-
- ))} -
- )} - - {available.length > 0 && ( -
- - ({ value: s.uuid, label: s.name }))} - value={null} - onChange={(v) => { - const picked = services.find((s) => s.uuid === String(v)); - if (!picked) return; - - setLines((l) => [ - ...l, - { - service_uuid: picked.uuid, - service_name: picked.name, - duration_minutes: '', - price_rials: '', - active: true, - effective_duration_minutes: picked.duration_minutes ?? null, - effective_price_rials: picked.price_rials ?? 0, - duration_source: 'service_default', - price_source: 'service_default', - }, - ]); - }} - placeholder="یک سرویس انتخاب کنید" - height={38} - /> -
- )} - -

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

- -
- - -
-
+ + ); } diff --git a/assets/admin/components/resources/ResourceServicesPanel.tsx b/assets/admin/components/resources/ResourceServicesPanel.tsx new file mode 100644 index 00000000..12fa9441 --- /dev/null +++ b/assets/admin/components/resources/ResourceServicesPanel.tsx @@ -0,0 +1,220 @@ +import React, { useEffect, useState } from 'react'; +import SearchableSelect from '../ui/SearchableSelect'; +import { formatRial } from '../../lib/utils'; +import type { ClinicResource, ResourceServiceOffering, ServiceItemOption } from '../../types'; + +type Line = { + service_uuid: string; + service_name: string; + duration_minutes: string; + price_rials: string; + active: boolean; + effective_duration_minutes: number | null; + effective_price_rials: number; + duration_source: string | null; + price_source: string; +}; + +export type ServiceOfferingLine = { + service_uuid: string; + duration_minutes: string; + price_rials: string; + active: boolean; +}; + +interface Props { + resource: ClinicResource | null; + offerings: ResourceServiceOffering[]; + services: ServiceItemOption[]; + saving: boolean; + /** وقتی داده نشود دکمهٔ «انصراف» نمایش داده نمی‌شود — حالت تب. */ + onCancel?: () => void; + onSave: (lines: ServiceOfferingLine[]) => void; +} + +/** برچسب فارسیِ سطحی که مقدار مؤثر از آن آمده. */ +const SOURCE_LABELS: Record = { + resource_option: 'همین منبع', + resource_service: 'منبع، روی سرویس والد', + branch: 'شعبه', + service_default: 'پیش‌فرض سرویس', +}; + +/** + * سرویس‌هایی که یک منبع ارائه می‌دهد، با مدت و قیمت اختصاصی. + * + * خالی‌گذاشتن مدت یا قیمت یعنی «ارث از سطح بالاتر»، نه صفر — به همین دلیل مقدار مؤثر + * به‌صورت placeholder با برچسب منبعش نشان داده می‌شود، وگرنه کاربر نمی‌فهمد خانهٔ خالی + * یعنی «تنظیم نشده» یا «رایگان». + * + * ذخیره یک PUT است و **جایگزینی کامل**، همان قرارداد پنل مهارت‌ها. + */ +export default function ResourceServicesPanel({ + resource, offerings, services, saving, onCancel, onSave, +}: Props) { + const [lines, setLines] = useState([]); + + useEffect(() => { + if (!resource) return; + + setLines( + offerings.map((o) => ({ + service_uuid: o.service_uuid, + service_name: o.service_name, + duration_minutes: o.duration_minutes === null ? '' : String(o.duration_minutes), + price_rials: o.price_rials === null ? '' : String(o.price_rials), + active: o.active, + effective_duration_minutes: o.effective_duration_minutes, + effective_price_rials: o.effective_price_rials, + duration_source: o.duration_source, + price_source: o.price_source, + })), + ); + }, [resource, offerings]); + + const chosen = new Set(lines.map((l) => l.service_uuid)); + const available = services.filter((s) => !chosen.has(s.uuid)); + + const patch = (index: number, changes: Partial) => + setLines((l) => l.map((x, i) => (i === index ? { ...x, ...changes } : x))); + + return ( +
+ {services.length === 0 && ( +

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

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

+ این منبع هیچ سرویسی ارائه نمی‌دهد. +

+ ) : ( +
+ {lines.map((line, index) => ( +
+
+ {line.service_name} + + + + +
+ +
+
+ + patch(index, { duration_minutes: e.target.value })} + placeholder={ + line.effective_duration_minutes === null + ? 'تعیین نشده' + : `${line.effective_duration_minutes} — ${SOURCE_LABELS[line.duration_source ?? ''] ?? '—'}` + } + /> +
+ +
+ + patch(index, { price_rials: e.target.value })} + placeholder={`${formatRial(line.effective_price_rials)} — ${SOURCE_LABELS[line.price_source] ?? '—'}`} + /> +
+
+
+ ))} +
+ )} + + {available.length > 0 && ( +
+ + ({ value: s.uuid, label: s.name }))} + value={null} + onChange={(v) => { + const picked = services.find((s) => s.uuid === String(v)); + if (!picked) return; + + setLines((l) => [ + ...l, + { + service_uuid: picked.uuid, + service_name: picked.name, + duration_minutes: '', + price_rials: '', + active: true, + effective_duration_minutes: picked.duration_minutes ?? null, + effective_price_rials: picked.price_rials ?? 0, + duration_source: 'service_default', + price_source: 'service_default', + }, + ]); + }} + placeholder="یک سرویس انتخاب کنید" + height={38} + /> +
+ )} + +

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

+ +
+ {onCancel && ( + + )} + +
+
+ ); +} diff --git a/assets/admin/components/resources/ResourceSkillsModal.tsx b/assets/admin/components/resources/ResourceSkillsModal.tsx index 8ab04557..d0c8eaf6 100644 --- a/assets/admin/components/resources/ResourceSkillsModal.tsx +++ b/assets/admin/components/resources/ResourceSkillsModal.tsx @@ -1,104 +1,27 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import Modal from '../ui/Modal'; -import SearchableSelect from '../ui/SearchableSelect'; +import ResourceSkillsPanel, { type SkillLine } from './ResourceSkillsPanel'; 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; + onSave: (lines: SkillLine[]) => 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/components/resources/ResourceSkillsPanel.tsx b/assets/admin/components/resources/ResourceSkillsPanel.tsx new file mode 100644 index 00000000..c3cc35c3 --- /dev/null +++ b/assets/admin/components/resources/ResourceSkillsPanel.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useState } from 'react'; +import SearchableSelect from '../ui/SearchableSelect'; +import type { ClinicResource, Skill } from '../../types'; + +export type SkillLine = { skill_uuid: string; level: number }; + +interface Props { + resource: ClinicResource | null; + skills: Skill[]; + saving: boolean; + /** وقتی داده نشود دکمهٔ «انصراف» نمایش داده نمی‌شود — حالت تب، که جایی برای بستن ندارد. */ + onCancel?: () => void; + onSave: (lines: SkillLine[]) => void; +} + +/** + * مهارت‌های یک منبع. ذخیره یک PUT است و **جایگزینی کامل**: مهارتی که اینجا نباشد، + * از منبع برداشته می‌شود. + * + * بدنه از مودال جدا شده تا هم در فهرست منابع (مودال) و هم در تب منبع بدون تکرار + * استفاده شود. + */ +export default function ResourceSkillsPanel({ resource, skills, saving, onCancel, 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} + /> +
+ )} + +

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

+ +
+ {onCancel && ( + + )} + +
+
+ ); +} diff --git a/assets/admin/components/resources/ResourceWorkingHoursPanel.tsx b/assets/admin/components/resources/ResourceWorkingHoursPanel.tsx new file mode 100644 index 00000000..f579af9e --- /dev/null +++ b/assets/admin/components/resources/ResourceWorkingHoursPanel.tsx @@ -0,0 +1,188 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; +import { useResourceCalendar } from '../../hooks/useResourceCalendar'; + +/** ۰ = شنبه — همان قرارداد بک‌اند و ساعت کاری شعبه. */ +export const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه']; + +const MINUTES_IN_DAY = 1440; + +type Draft = { start: string; end: string; endOfDay: boolean }; + +function toTime(minute: number): string { + return `${String(Math.floor(minute / 60)).padStart(2, '0')}:${String(minute % 60).padStart(2, '0')}`; +} + +function toMinutes(time: string): number | null { + const m = /^(\d{1,2}):(\d{2})$/.exec(time.trim()); + if (!m) return null; + const minutes = Number(m[1]) * 60 + Number(m[2]); + return minutes >= 0 && minutes <= MINUTES_IN_DAY ? minutes : null; +} + +/** + * شیفت هفتگی یک منبع — روزهای کاری و ساعت هر روز. + * + * ساعت واقعی منبع تقاطع این شیفت‌ها با ساعت کاری شعبه است، نه خودشان؛ پس شیفتِ + * بیرون از ساعت شعبه ذخیره می‌شود ولی در دسترس‌پذیری اثری ندارد. + */ +export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: { + resourceUuid?: string; + canUpdate: boolean; +}) { + const { calendar, loading, save } = useResourceCalendar(resourceUuid); + + const [draft, setDraft] = useState>({}); + const [error, setError] = useState(null); + + useEffect(() => { + if (!calendar) return; + const next: Record = {}; + DAY_LABELS.forEach((_, day) => { + next[day] = (calendar.days[String(day)] ?? []).map((r) => ({ + start: toTime(r.start_minute), + end: r.end_minute === MINUTES_IN_DAY ? '23:59' : toTime(r.end_minute), + endOfDay: r.end_minute === MINUTES_IN_DAY, + })); + }); + setDraft(next); + }, [calendar]); + + const totalShifts = useMemo( + () => Object.values(draft).reduce((sum, rows) => sum + rows.length, 0), + [draft], + ); + + const editRange = (day: number, index: number, patch: Partial) => + setDraft((d) => ({ ...d, [day]: (d[day] ?? []).map((r, i) => (i === index ? { ...r, ...patch } : r)) })); + + const submit = () => { + const days: Record = {}; + + for (const [dayKey, rows] of Object.entries(draft)) { + const parsed: { start_minute: number; end_minute: number }[] = []; + + for (const row of rows) { + const start = toMinutes(row.start); + const end = row.endOfDay ? MINUTES_IN_DAY : toMinutes(row.end); + + if (start === null || end === null) { + setError(`ساعت روز ${DAY_LABELS[Number(dayKey)]} را به شکل ۰۹:۰۰ وارد کنید`); + return; + } + if (end <= start) { + setError(`در روز ${DAY_LABELS[Number(dayKey)]} پایان شیفت باید بعد از شروع آن باشد`); + return; + } + parsed.push({ start_minute: start, end_minute: end }); + } + + days[dayKey] = parsed; + } + + setError(null); + save.mutate(days); + }; + + return ( +
+
+

+ روزهای کاری و ساعت هر روز. ساعت واقعی از تقاطع این شیفت‌ها با ساعت کاری شعبه به‌دست + می‌آید و تعطیلات و مرخصی از آن کسر می‌شود. + {totalShifts > 0 && <> · {totalShifts} شیفت} +

+ {canUpdate && ( + + )} +
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
در حال بارگذاری...
+ ) : ( +
+ {DAY_LABELS.map((label, day) => { + const rows = draft[day] ?? []; + return ( +
+
+
+ {label} + {rows.length === 0 && بدون شیفت} +
+ {canUpdate && ( + + )} +
+ +
+ {rows.map((row, index) => ( +
+ editRange(day, index, { start: e.target.value })} + style={{ width: 116 }} + /> + تا + {row.endOfDay ? ( + ۲۴:۰۰ + ) : ( + editRange(day, index, { end: e.target.value })} + style={{ width: 116 }} + /> + )} + + {canUpdate && ( + + )} +
+ ))} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/assets/admin/hooks/useResources.ts b/assets/admin/hooks/useResources.ts index 2b7082cf..4e641bff 100644 --- a/assets/admin/hooks/useResources.ts +++ b/assets/admin/hooks/useResources.ts @@ -70,10 +70,22 @@ export function useResources(filters: ResourceFilters = {}) { onError: (e) => fail(e, 'ذخیرهٔ مهارت‌ها ناموفق بود'), }); + /** دستهٔ منبع از کاتالوگ سراسری می‌آید؛ ساختنش فقط در «تنظیمات ← دسته‌بندی‌ها» ممکن است. */ + const setCategories = useMutation({ + mutationFn: ({ uuid, categoryUuids }: { uuid: string; categoryUuids: string[] }) => + api.put>(`/api/v1/resource/${uuid}/categories`, { category_uuids: categoryUuids }), + onSuccess: () => { + toast.success('دسته‌بندی‌های منبع ذخیره شد'); + invalidate(); + qc.invalidateQueries({ queryKey: ['resource-detail'] }); + }, + onError: (e) => fail(e, 'ذخیرهٔ دسته‌بندی‌ها ناموفق بود'), + }); + return { resources: query.data?.data ?? [], loading: query.isLoading, - create, update, remove, setSkills, + create, update, remove, setSkills, setCategories, }; } diff --git a/assets/admin/pages/ResourceCalendarPage.tsx b/assets/admin/pages/ResourceCalendarPage.tsx deleted file mode 100644 index 83a07914..00000000 --- a/assets/admin/pages/ResourceCalendarPage.tsx +++ /dev/null @@ -1,383 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { useParams } from 'react-router-dom'; -import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; -import PageHeader from '../components/ui/PageHeader'; -import ConfirmDialog from '../components/ui/ConfirmDialog'; -import SearchableSelect from '../components/ui/SearchableSelect'; -import PersianDateInput from '../components/ui/PersianDateInput'; -import { usePermissions } from '../hooks/usePermissions'; -import { useResources } from '../hooks/useResources'; -import { - useResourceAvailability, useResourceCalendar, useResourceExceptions, -} from '../hooks/useResourceCalendar'; -import { formatDate } from '../lib/utils'; -import type { ResourceException } from '../types'; - -/** ۰ = شنبه — همان قرارداد بک‌اند و ساعت کاری شعبه. */ -const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه']; - -const MINUTES_IN_DAY = 1440; - -/** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده می‌شد. */ -const REASON_LABELS: Record = { - national_holiday: 'تعطیل رسمی', - tenant_holiday: 'تعطیلی این محیط', - no_shift: 'شیفتی تعریف نشده', - branch_closed: 'شعبه این روز بسته است', - outside_branch_hours: 'شیفت بیرون از ساعت کاری شعبه', - exception: 'مرخصی یا سرویس', - resource_inactive: 'منبع غیرفعال است', - branch_inactive: 'شعبه غیرفعال است', -}; - -const EXCEPTION_TYPES = [ - { value: 'leave', label: 'مرخصی' }, - { value: 'absence', label: 'غیبت' }, - { value: 'maintenance', label: 'سرویس دوره‌ای' }, - { value: 'closure', label: 'تعطیلی موردی' }, -]; - -type Draft = { start: string; end: string; endOfDay: boolean }; - -function toTime(minute: number): string { - return `${String(Math.floor(minute / 60)).padStart(2, '0')}:${String(minute % 60).padStart(2, '0')}`; -} - -function toMinutes(time: string): number | null { - const m = /^(\d{1,2}):(\d{2})$/.exec(time.trim()); - if (!m) return null; - const minutes = Number(m[1]) * 60 + Number(m[2]); - return minutes >= 0 && minutes <= MINUTES_IN_DAY ? minutes : null; -} - -/** نیمه‌شبِ امروز به‌صورت timestamp ثانیه‌ای. */ -function todayMidnight(): number { - const d = new Date(); - d.setHours(0, 0, 0, 0); - return Math.floor(d.getTime() / 1000); -} - -/** - * تقویم یک منبع: شیفت هفتگی، استثناها، و پیش‌نمایش ساعت آزاد. - * - * پیش‌نمایش عمداً «ساعت آزاد» نامیده نشده بلکه «خام» است: نوبت‌های ثبت‌شده در آن - * کسر نشده‌اند و اشتباه گرفتنش با «وقت قابل رزرو» به بیش‌رزروی می‌انجامد. - */ -export default function ResourceCalendarPage() { - const { resourceUuid } = useParams<{ resourceUuid: string }>(); - const { calendar, loading, save } = useResourceCalendar(resourceUuid); - const { exceptions, create, remove } = useResourceExceptions(resourceUuid); - const { resources } = useResources(); - const { can } = usePermissions(); - const canUpdate = can('appointment_settings', 'update'); - - const resource = resources.find((r) => r.uuid === resourceUuid); - - const [draft, setDraft] = useState>({}); - const [error, setError] = useState(null); - const [toDelete, setToDelete] = useState(null); - - const previewFrom = todayMidnight(); - const previewTo = previewFrom + 13 * 86400; - const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo); - - useEffect(() => { - if (!calendar) return; - const next: Record = {}; - DAY_LABELS.forEach((_, day) => { - next[day] = (calendar.days[String(day)] ?? []).map((r) => ({ - start: toTime(r.start_minute), - end: r.end_minute === MINUTES_IN_DAY ? '23:59' : toTime(r.end_minute), - endOfDay: r.end_minute === MINUTES_IN_DAY, - })); - }); - setDraft(next); - }, [calendar]); - - const totalShifts = useMemo( - () => Object.values(draft).reduce((sum, rows) => sum + rows.length, 0), - [draft], - ); - - const editRange = (day: number, index: number, patch: Partial) => - setDraft((d) => ({ ...d, [day]: (d[day] ?? []).map((r, i) => (i === index ? { ...r, ...patch } : r)) })); - - const submit = () => { - const days: Record = {}; - - for (const [dayKey, rows] of Object.entries(draft)) { - const parsed: { start_minute: number; end_minute: number }[] = []; - - for (const row of rows) { - const start = toMinutes(row.start); - const end = row.endOfDay ? MINUTES_IN_DAY : toMinutes(row.end); - - if (start === null || end === null) { - setError(`ساعت روز ${DAY_LABELS[Number(dayKey)]} را به شکل ۰۹:۰۰ وارد کنید`); - return; - } - if (end <= start) { - setError(`در روز ${DAY_LABELS[Number(dayKey)]} پایان شیفت باید بعد از شروع آن باشد`); - return; - } - parsed.push({ start_minute: start, end_minute: end }); - } - - days[dayKey] = parsed; - } - - setError(null); - save.mutate(days); - }; - - return ( -
- - {save.isPending ? 'در حال ذخیره...' : 'ذخیرهٔ شیفت‌ها'} - - ) : undefined - } - /> - - {error && ( -
- {error} -
- )} - -
-
-

- شیفت هفتگی {totalShifts > 0 && ({totalShifts} شیفت)} -

- - {loading ? ( -
در حال بارگذاری...
- ) : ( - DAY_LABELS.map((label, day) => { - const rows = draft[day] ?? []; - return ( -
-
-
- {label} - {rows.length === 0 && بدون شیفت} -
- {canUpdate && ( - - )} -
- -
- {rows.map((row, index) => ( -
- editRange(day, index, { start: e.target.value })} - style={{ width: 116 }} - /> - تا - {row.endOfDay ? ( - ۲۴:۰۰ - ) : ( - editRange(day, index, { end: e.target.value })} - style={{ width: 116 }} - /> - )} - - {canUpdate && ( - - )} -
- ))} -
-
- ); - }) - )} -
- -
- create.mutate(payload)} - onDelete={setToDelete} - /> - -
-

پیش‌نمایش دو هفته

-

- ساعت خام — نوبت‌های ثبت‌شده هنوز از آن کسر نشده‌اند. -

- -
- {(availability?.days ?? []).map((day) => ( -
- - {DAY_LABELS[day.day_of_week]} · {formatDate(day.date * 1000)} - - {day.intervals.length === 0 ? ( - - {day.reasons.map((r) => REASON_LABELS[r] ?? r).join('، ') || '—'} - - ) : ( - {day.total_minutes} دقیقه - )} -
- ))} -
-
-
-
- - toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })} - onCancel={() => setToDelete(null)} - /> -
- ); -} - -function ExceptionsCard({ - exceptions, canUpdate, saving, onCreate, onDelete, -}: { - exceptions: ResourceException[]; - canUpdate: boolean; - saving: boolean; - onCreate: (payload: { type: string; starts_at: number; ends_at: number; reason?: string | null }) => void; - onDelete: (e: ResourceException) => void; -}) { - const [type, setType] = useState('leave'); - const [startDate, setStartDate] = useState(''); - const [endDate, setEndDate] = useState(''); - const [reason, setReason] = useState(''); - - const toTimestamp = (value: string): number | null => { - if (value === '') return null; - const ms = new Date(`${value}T00:00:00`).getTime(); - return Number.isNaN(ms) ? null : Math.floor(ms / 1000); - }; - - const start = toTimestamp(startDate); - const end = toTimestamp(endDate); - // پایان روزِ انتخاب‌شده، نه آغازش: مرخصیِ «تا سه‌شنبه» شامل خودِ سه‌شنبه است. - const endExclusive = end === null ? null : end + 86400; - const invalid = start === null || endExclusive === null || endExclusive <= start; - - return ( -
-

مرخصی و سرویس

- - {exceptions.length === 0 ? ( -

استثنایی ثبت نشده است.

- ) : ( -
- {exceptions.map((e) => ( -
- {e.type_label} - - {formatDate(e.starts_at * 1000)} تا {formatDate(e.ends_at * 1000)} - {e.reason ? ` · ${e.reason}` : ''} - - {canUpdate && ( - - )} -
- ))} -
- )} - - {canUpdate && ( -
- setType(v ? String(v) : 'leave')} - placeholder="نوع استثنا" - height={36} - /> - {/* تقویم شمسی، نه `input type=date` میلادی: اپراتور تاریخ را شمسی می‌گوید و - ترجمهٔ ذهنی همان‌جایی است که استثنا یک روز جابه‌جا ثبت می‌شود. */} -
-
- -
-
- -
-
- setReason(e.target.value)} placeholder="توضیح (اختیاری)" /> - -
- )} -
- ); -} diff --git a/assets/admin/pages/ResourceCalendarPage.test.tsx b/assets/admin/pages/ResourceDetailPage.test.tsx similarity index 51% rename from assets/admin/pages/ResourceCalendarPage.test.tsx rename to assets/admin/pages/ResourceDetailPage.test.tsx index 35c311d2..462e3325 100644 --- a/assets/admin/pages/ResourceCalendarPage.test.tsx +++ b/assets/admin/pages/ResourceDetailPage.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../test/utils'; vi.mock('../lib/api', () => ({ @@ -11,15 +12,38 @@ vi.mock('../hooks/usePermissions', () => ({ usePermissions: () => ({ can: () => import { Routes, Route } from 'react-router-dom'; import { api } from '../lib/api'; -import ResourceCalendarPage from './ResourceCalendarPage'; +import ResourceDetailPage from './ResourceDetailPage'; const get = api.get as ReturnType; +const resource = { + uuid: 'r1', + name: 'لیزر دایود', + address_uuid: 'a1', + address_name: 'شعبهٔ مرکزی', + type_uuid: 't1', + type_code: 'device', + type_name: 'دستگاه لیزر', + capacity: 1, + setup_minutes: 5, + cleanup_minutes: 10, + attributes: {}, + subject_kind: null, + subject_uuid: null, + skills: [{ skill_uuid: 's1', skill_name: 'کار با لیزر', level: 4 }], + categories: [{ uuid: 'c-hand', name: 'دست' }], + active: true, + created_at: 0, + updated_at: 0, + upcoming_appointments: 0, +}; + const emptyDays = (): Record => Object.fromEntries(Array.from({ length: 7 }, (_, d) => [String(d), [] as unknown[]])); -function mockApi(days: Record, availabilityDays: unknown[]) { +function mockApi(days: Record = emptyDays(), availabilityDays: unknown[] = []) { get.mockImplementation((path: string) => { + if (path === '/api/v1/resource/r1') return Promise.resolve({ success: true, data: resource }); if (path.endsWith('/calendar')) { return Promise.resolve({ success: true, @@ -32,38 +56,52 @@ function mockApi(days: Record, availabilityDays: unknown[]) { data: { resource_uuid: 'r1', timezone: 'Asia/Tehran', days: availabilityDays }, }); } - if (path.endsWith('/exceptions')) return Promise.resolve({ success: true, data: [] }); - if (path.startsWith('/api/v1/resources')) return Promise.resolve({ success: true, data: [] }); + if (path.includes('/service-categories/tree')) { + return Promise.resolve({ + success: true, + data: [{ uuid: 'c-hand', name: 'دست', sort_order: 0, active: true, children: [] }, + { uuid: 'c-foot', name: 'پا', sort_order: 1, active: true, children: [] }], + }); + } return Promise.resolve({ success: true, data: [] }); }); } -function renderPage() { +function renderPage(route = '/admin/resources/r1') { return renderWithProviders( - } /> + } /> , - { route: '/admin/resources/r1/calendar' }, + { route }, ); } -describe('ResourceCalendarPage', () => { +describe('ResourceDetailPage', () => { beforeEach(() => vi.clearAllMocks()); - it('renders seven days and marks shiftless ones', async () => { - mockApi(emptyDays(), []); + it('اطلاعات منبع را در تب پیش‌فرض نشان می‌دهد', async () => { + mockApi(); renderPage(); + await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument()); + expect(screen.getByText('ظرفیت هم‌زمان')).toBeInTheDocument(); + expect(screen.getByText('کار با لیزر · 4')).toBeInTheDocument(); + }); + + it('تب از URL خوانده می‌شود تا بازگشت و رفرش همان نما را بدهد', async () => { + mockApi(); + renderPage('/admin/resources/r1?tab=hours'); + await waitFor(() => expect(screen.getByText('شنبه')).toBeInTheDocument()); expect(screen.getByText('جمعه')).toBeInTheDocument(); expect(screen.getAllByText('بدون شیفت')).toHaveLength(7); }); - it('shows stored shifts as times', async () => { + it('شیفت ذخیره‌شده را به‌صورت ساعت نشان می‌دهد', async () => { const days = emptyDays(); days['0'] = [{ sequence: 0, start_minute: 540, end_minute: 1020, start_time: '09:00', end_time: '17:00', active: true }]; - mockApi(days, []); - renderPage(); + mockApi(days); + renderPage('/admin/resources/r1?tab=hours'); await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument()); expect(screen.getByDisplayValue('17:00')).toBeInTheDocument(); @@ -73,13 +111,13 @@ describe('ResourceCalendarPage', () => { * دلیلِ خالی بودن روز باید فارسی نشان داده شود؛ نشان دادن کلید خام سرور * («outside_branch_hours») به کاربر یعنی پیام بی‌معنا. */ - it('translates every empty-day reason into Persian', async () => { + it('دلیل خالی بودن روز را فارسی می‌کند', async () => { mockApi(emptyDays(), [ { date: 1785529800, day_of_week: 0, intervals: [], total_minutes: 0, reasons: ['national_holiday'] }, { date: 1785616200, day_of_week: 1, intervals: [], total_minutes: 0, reasons: ['outside_branch_hours'] }, { date: 1785702600, day_of_week: 2, intervals: [], total_minutes: 0, reasons: ['exception'] }, ]); - renderPage(); + renderPage('/admin/resources/r1?tab=exceptions'); await waitFor(() => expect(screen.getByText('تعطیل رسمی')).toBeInTheDocument()); expect(screen.getByText('شیفت بیرون از ساعت کاری شعبه')).toBeInTheDocument(); @@ -87,20 +125,24 @@ describe('ResourceCalendarPage', () => { expect(screen.queryByText('outside_branch_hours')).not.toBeInTheDocument(); }); - it('shows free minutes for a day that has availability', async () => { - mockApi(emptyDays(), [ - { date: 1785529800, day_of_week: 0, intervals: [{ start: 1, end: 2 }], total_minutes: 480, reasons: [] }, - ]); - renderPage(); - - await waitFor(() => expect(screen.getByText('480 دقیقه')).toBeInTheDocument()); - }); - /** پیش‌نمایش نباید «وقت قابل رزرو» خوانده شود — نوبت‌ها هنوز کسر نشده‌اند. */ - it('warns that the preview is raw availability', async () => { - mockApi(emptyDays(), []); - renderPage(); + it('پیش‌نمایش را خام معرفی می‌کند', async () => { + mockApi(); + renderPage('/admin/resources/r1?tab=exceptions'); await waitFor(() => expect(screen.getByText(/نوبت‌های ثبت‌شده هنوز از آن کسر نشده‌اند/)).toBeInTheDocument()); }); + + it('تب دسته‌بندی فقط انتخاب می‌دهد، نه ساخت', async () => { + mockApi(); + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => expect(screen.getByText('شعبهٔ مرکزی')).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'دسته‌بندی‌ها' })); + + await waitFor(() => expect(screen.getByText(/فقط انتخاب می‌شود/)).toBeInTheDocument()); + expect(screen.queryByRole('button', { name: /افزودن دسته‌بندی جدید/ })).not.toBeInTheDocument(); + expect(screen.getByText('تنظیمات ← دسته‌بندی‌ها')).toHaveAttribute('href', '/admin/service-categories'); + }); }); diff --git a/assets/admin/pages/ResourceDetailPage.tsx b/assets/admin/pages/ResourceDetailPage.tsx new file mode 100644 index 00000000..949176b3 --- /dev/null +++ b/assets/admin/pages/ResourceDetailPage.tsx @@ -0,0 +1,196 @@ +import React, { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { PencilIcon } from '@heroicons/react/24/outline'; +import PageHeader from '../components/ui/PageHeader'; +import { ActiveBadge } from '../components/ui/StatusBadge'; +import ResourceFormModal from '../components/resources/ResourceFormModal'; +import ResourceWorkingHoursPanel from '../components/resources/ResourceWorkingHoursPanel'; +import ResourceExceptionsPanel from '../components/resources/ResourceExceptionsPanel'; +import ResourceServicesPanel from '../components/resources/ResourceServicesPanel'; +import ResourceSkillsPanel from '../components/resources/ResourceSkillsPanel'; +import ResourceCategoriesPanel from '../components/resources/ResourceCategoriesPanel'; +import { useUrlState } from '../hooks/useUrlState'; +import { usePermissions } from '../hooks/usePermissions'; +import { useBranches } from '../hooks/useBranches'; +import { useResourceDetail, useResourceServices, useResources, useResourceTypes, useSkills } from '../hooks/useResources'; +import { useAllServiceItems } from '../hooks/useServiceCatalog'; +import type { ClinicResource } from '../types'; + +const TABS = [ + { id: 'info', label: 'اطلاعات' }, + { id: 'hours', label: 'ساعات کاری' }, + { id: 'exceptions', label: 'تعطیلات و استثنا' }, + { id: 'services', label: 'سرویس‌ها' }, + { id: 'skills', label: 'مهارت‌ها' }, + { id: 'categories', label: 'دسته‌بندی‌ها' }, +] as const; +type TabId = typeof TABS[number]['id']; + +const SUBJECT_LABEL: Record = { + doctor: 'پزشک', + staff: 'پرسنل', + room: 'اتاق', +}; + +/** + * یک منبع و همهٔ تنظیمات مستقلش، در یک صفحهٔ تب‌بندی‌شده — همان ساختار صفحهٔ + * «مدیریت نوبت‌دهی» کلینیک. + * + * منبع در مدل Resource-First واحدِ ظرفیت است، پس ساعت کاری و تعطیلات را خودش دارد نه + * فقط پزشکِ پشتش؛ تقویم منبع درون برنامهٔ هفتگی تنگ‌تر می‌شود، آن را گشاد نمی‌کند. + */ +export default function ResourceDetailPage() { + const { resourceUuid } = useParams<{ resourceUuid: string }>(); + const [urlState, setUrlState] = useUrlState({ tab: 'info' }); + const tab = (TABS.some((t) => t.id === urlState.tab) ? urlState.tab : 'info') as TabId; + + const { resource, loading } = useResourceDetail(resourceUuid); + const { branches } = useBranches(); + const { types } = useResourceTypes(); + const { skills } = useSkills(); + const { update, setSkills, setCategories } = useResources(); + const { offerings, save: saveServices } = useResourceServices(resourceUuid); + const { items: serviceOptions } = useAllServiceItems(); + const { can } = usePermissions(); + const canUpdate = can('appointment_settings', 'update'); + + const [editOpen, setEditOpen] = useState(false); + + if (loading) return
در حال بارگذاری...
; + if (!resource) return
منبع یافت نشد.
; + + return ( +
+ setEditOpen(true)}> + ویرایش + + ) : undefined + } + /> + +
+ +
+ +
+ {TABS.map((t) => ( + + ))} +
+ + {tab === 'info' && } + + {tab === 'hours' && } + + {tab === 'exceptions' && } + + {tab === 'services' && ( +
+ saveServices.mutate({ uuid: resource.uuid, services: lines })} + /> +
+ )} + + {tab === 'skills' && ( +
+ setSkills.mutate({ uuid: resource.uuid, skills: lines })} + /> +
+ )} + + {tab === 'categories' && ( + setCategories.mutate({ uuid: resource.uuid, categoryUuids })} + /> + )} + + setEditOpen(false)} + onSave={(payload) => + update.mutate({ uuid: resource.uuid, d: payload }, { onSuccess: () => setEditOpen(false) }) + } + /> +
+ ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function InfoTab({ resource }: { resource: ClinicResource }) { + const attributes = Object.entries(resource.attributes ?? {}); + + return ( +
+ {resource.address_name || '—'} + {resource.type_name} + {resource.capacity} نفر + + {resource.setup_minutes} / {resource.cleanup_minutes} دقیقه + + + {resource.skills.length === 0 ? '—' : ( + + {resource.skills.map((s) => ( + + {s.skill_name} · {s.level} + + ))} + + )} + + + {(resource.categories ?? []).length === 0 ? '—' : ( + + {(resource.categories ?? []).map((c) => ( + {c.name} + ))} + + )} + + {attributes.map(([key, value]) => ( + {String(value)} + ))} +
+ ); +} diff --git a/assets/admin/pages/ResourcesPage.tsx b/assets/admin/pages/ResourcesPage.tsx index b0b0b3d8..d323d421 100644 --- a/assets/admin/pages/ResourcesPage.tsx +++ b/assets/admin/pages/ResourcesPage.tsx @@ -194,8 +194,8 @@ export default function ResourcesPage() { - - تقویم + + تنظیمات {/* مسدودسازی موردی از تقویم جداست: آن الگوی کاری را عوض می‌کند، این یک بازهٔ مشخص را می‌بندد. */} diff --git a/assets/admin/types/index.ts b/assets/admin/types/index.ts index 04a5c092..97b477bf 100644 --- a/assets/admin/types/index.ts +++ b/assets/admin/types/index.ts @@ -989,6 +989,8 @@ export interface ClinicResource { subject_kind: 'doctor' | 'staff' | 'room' | null; subject_uuid: string | null; skills: ResourceSkillLine[]; + /** دستهٔ سراسری؛ فقط انتخاب می‌شود — ساختش در «تنظیمات ← دسته‌بندی‌ها» است */ + categories?: Array<{ uuid: string; name: string }>; active: boolean; created_at: number; updated_at: number; diff --git a/docs/api/resource-calendar.md b/docs/api/resource-calendar.md index 1e021cb4..afa72233 100644 --- a/docs/api/resource-calendar.md +++ b/docs/api/resource-calendar.md @@ -2,6 +2,10 @@ > **Base:** `/api/v1` · **Auth:** JWT · **مجوز:** `appointment_settings` > مکمل [resource.md](resource.md) — منبع آنجا ساخته می‌شود، تقویمش اینجا. +> +> **پنل:** این اندپوینت‌ها در تب‌های «ساعات کاری» و «تعطیلات و استثنا»ی صفحهٔ +> `/admin/resources/{uuid}` مصرف می‌شوند. مسیر قدیمی `/admin/resources/{uuid}/calendar` +> با redirect به `?tab=hours` می‌رود. ---