From 26425bec31306145cb786a08f2bb8ee42fbb2ee0 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 1 Aug 2026 22:57:55 +0330 Subject: [PATCH] Manage a resource's services from the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "سرویس‌ها" action on each resource row opens a modal listing what that resource performs, with its own duration and price. It follows the skills modal exactly — same PUT-replaces-everything contract, same components, no new page and no new route. Leaving a cell empty means inherit, so the effective value is shown as the placeholder along with where it came from: "40 — service default", "1,800,000 toman — branch". Without that the user cannot tell an unset field from a zero, which is the one thing this screen has to communicate. Two backend adjustments came out of wiring it up: - the offering filter in findEligible is now scoped to the requirement's resource type. Registering lasers for a service was making the room in the same plan ineligible and breaking the whole booking — "who performs this" is about the performing role, not about rooms and support resources. The seeder caught this immediately. - the scenario seeder now creates offerings and resource categories, so the demo data exercises this model instead of leaving every resource empty. Three vitest tests: inherited value with its source, saving an override, and clearing back to inheritance. Verified in the browser at 1440 dark, 1440 compact and 390 mobile — the last with no horizontal scroll. Panel suite 648 green across 98 files, tsc clean, encore build succeeds. Co-Authored-By: Claude Opus 5 --- .../resources/ResourceServicesModal.test.tsx | 81 +++++++ .../resources/ResourceServicesModal.tsx | 215 ++++++++++++++++++ assets/admin/hooks/useResources.ts | 32 ++- assets/admin/pages/ResourcesPage.tsx | 26 ++- assets/admin/types/index.ts | 22 ++ .../task-15-resource-first-model/checklist.md | 40 ++-- .../Repository/ClinicResourceRepository.php | 4 +- .../ResourceServiceOfferingRepository.php | 36 ++- src/Shared/Command/BookingEngineSeeder.php | 40 ++++ src/Shared/Command/SeedScenariosCommand.php | 2 +- 10 files changed, 463 insertions(+), 35 deletions(-) create mode 100644 assets/admin/components/resources/ResourceServicesModal.test.tsx create mode 100644 assets/admin/components/resources/ResourceServicesModal.tsx diff --git a/assets/admin/components/resources/ResourceServicesModal.test.tsx b/assets/admin/components/resources/ResourceServicesModal.test.tsx new file mode 100644 index 00000000..f3089d29 --- /dev/null +++ b/assets/admin/components/resources/ResourceServicesModal.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../../test/utils'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import ResourceServicesModal from './ResourceServicesModal'; + +const resource = { uuid: 'r-1', name: 'دستگاه شمارهٔ ۲' } as never; + +const services = [ + { uuid: 's-1', name: 'لیزر پا', duration_minutes: 30, price_rials: 8_000_000 }, + { uuid: 's-2', name: 'لیزر دست', duration_minutes: 20, price_rials: 5_000_000 }, +]; + +/** ردیفی که مدتش را خودِ منبع گفته ولی قیمتش را از سرویس ارث برده. */ +const offerings = [ + { + service_uuid: 's-1', + service_name: 'لیزر پا', + duration_minutes: 15, + price_rials: null, + active: true, + effective_duration_minutes: 15, + effective_price_rials: 8_000_000, + duration_source: 'resource_option', + price_source: 'service_default', + }, +]; + +function props(overrides: Record = {}) { + return { + resource, + offerings: offerings as never, + services: services as never, + saving: false, + onClose: vi.fn(), + onSave: vi.fn(), + ...overrides, + }; +} + +describe('ResourceServicesModal', () => { + it('نشان می‌دهد مقدار ارث‌بری‌شده از کجا آمده', () => { + renderWithProviders(); + + // مدت را خودِ منبع گفته، پس در خانه است. + expect(screen.getByDisplayValue('15')).toBeInTheDocument(); + + // قیمت تنظیم نشده: خانه خالی است و مقدار مؤثر با برچسبِ منبعش در placeholder. + const price = screen.getByPlaceholderText(/پیش‌فرض سرویس/); + expect(price).toHaveValue(''); + }); + + it('override را ذخیره می‌کند', async () => { + const onSave = vi.fn(); + renderWithProviders(); + + const price = screen.getByPlaceholderText(/پیش‌فرض سرویس/); + await userEvent.type(price, '9500000'); + await userEvent.click(screen.getByRole('button', { name: 'ذخیره' })); + + expect(onSave).toHaveBeenCalledWith([ + { service_uuid: 's-1', duration_minutes: '15', price_rials: '9500000', active: true }, + ]); + }); + + it('پاک‌کردن مقدار یعنی بازگشت به ارث، نه صفر', async () => { + const onSave = vi.fn(); + renderWithProviders(); + + await userEvent.clear(screen.getByDisplayValue('15')); + await userEvent.click(screen.getByRole('button', { name: 'ذخیره' })); + + // رشتهٔ خالی به سرور می‌رود و سرور آن را `null` می‌فهمد — نه `0`. + expect(onSave).toHaveBeenCalledWith([ + { service_uuid: 's-1', duration_minutes: '', price_rials: '', active: true }, + ]); + }); +}); diff --git a/assets/admin/components/resources/ResourceServicesModal.tsx b/assets/admin/components/resources/ResourceServicesModal.tsx new file mode 100644 index 00000000..71c58e79 --- /dev/null +++ b/assets/admin/components/resources/ResourceServicesModal.tsx @@ -0,0 +1,215 @@ +import React, { useEffect, useState } from 'react'; +import Modal from '../ui/Modal'; +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; +}; + +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; +} + +/** برچسب فارسیِ سطحی که مقدار مؤثر از آن آمده. */ +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))); + + 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/hooks/useResources.ts b/assets/admin/hooks/useResources.ts index 0b861551..2b7082cf 100644 --- a/assets/admin/hooks/useResources.ts +++ b/assets/admin/hooks/useResources.ts @@ -2,7 +2,7 @@ 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, + ClinicResource, ResourcePool, ResourcePayload, ResourceServiceOffering, ResourceType, Skill, } from '../types'; /** @@ -77,6 +77,36 @@ export function useResources(filters: ResourceFilters = {}) { }; } +/** + * سرویس‌هایی که یک منبع ارائه می‌دهد. + * + * مقدار مؤثر و منبعِ هر عدد از سرور می‌آید، نه از محاسبهٔ فرانت: زنجیرهٔ حل چهار سطح + * دارد و بازسازی‌اش اینجا یعنی دو پیاده‌سازی که با هم واگرا می‌شوند. + */ +export function useResourceServices(resourceUuid?: string) { + const qc = useQueryClient(); + const key = ['resource-services', resourceUuid]; + + const query = useQuery({ + queryKey: key, + queryFn: () => api.get>(`/api/v1/resource/${resourceUuid}/services`), + enabled: !!resourceUuid, + }); + + const save = useMutation({ + mutationFn: ({ uuid, services }: { uuid: string; services: Array> }) => + api.put>(`/api/v1/resource/${uuid}/services`, { services }), + onSuccess: () => { + toast.success('سرویس‌های منبع ذخیره شد'); + qc.invalidateQueries({ queryKey: ['resource-services'] }); + qc.invalidateQueries({ queryKey: [RESOURCES_KEY] }); + }, + onError: (e) => fail(e, 'ذخیرهٔ سرویس‌ها ناموفق بود'), + }); + + return { offerings: query.data?.data ?? [], loading: query.isLoading, save }; +} + export function useResourceTypes() { const qc = useQueryClient(); const invalidate = () => qc.invalidateQueries({ queryKey: TYPES_KEY }); diff --git a/assets/admin/pages/ResourcesPage.tsx b/assets/admin/pages/ResourcesPage.tsx index d618255d..b0b0b3d8 100644 --- a/assets/admin/pages/ResourcesPage.tsx +++ b/assets/admin/pages/ResourcesPage.tsx @@ -10,9 +10,11 @@ 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 { useResourceServices, useResources, useResourceTypes, useSkills } from '../hooks/useResources'; import ResourceFormModal from '../components/resources/ResourceFormModal'; import ResourceSkillsModal from '../components/resources/ResourceSkillsModal'; +import ResourceServicesModal from '../components/resources/ResourceServicesModal'; +import { useAllServiceItems } from '../hooks/useServiceCatalog'; import type { ClinicResource } from '../types'; /** برچسب فارسی پلِ هر منبع؛ `null` یعنی تجهیزاتی که پشتش موجودیت دیگری نیست. */ @@ -49,9 +51,13 @@ export default function ResourcesPage() { const [editing, setEditing] = useState<{ open: boolean; resource: ClinicResource | null }>({ open: false, resource: null }); const [skillsFor, setSkillsFor] = useState(null); + const [servicesFor, setServicesFor] = useState(null); const [blocksFor, setBlocksFor] = useState(null); const [toDelete, setToDelete] = useState(null); + const { offerings, save: saveServices } = useResourceServices(servicesFor?.uuid); + const { items: serviceOptions } = useAllServiceItems(); + const rows = useMemo(() => { const q = urlState.search.trim(); return q === '' ? resources : resources.filter((r) => `${r.name} ${r.type_name}`.includes(q)); @@ -185,6 +191,9 @@ export default function ResourcesPage() { + تقویم @@ -233,6 +242,21 @@ export default function ResourcesPage() { } /> + setServicesFor(null)} + onSave={(lines) => + servicesFor && + saveServices.mutate( + { uuid: servicesFor.uuid, services: lines }, + { onSuccess: () => setServicesFor(null) }, + ) + } + /> + { to: number; rows: T[]; } + +/** یک ردیف «این منبع این سرویس را می‌دهد» با مقدار مؤثر و منبعِ هر عدد. */ +export interface ResourceServiceOffering { + service_uuid: string; + service_name: string; + /** null یعنی ارث از سطح بالاتر، نه صفر */ + duration_minutes: number | null; + price_rials: number | null; + active: boolean; + effective_duration_minutes: number | null; + effective_price_rials: number; + duration_source: string | null; + price_source: string; +} + +/** گزینهٔ انتخاب سرویس در مودال منبع. */ +export interface ServiceItemOption { + uuid: string; + name: string; + duration_minutes?: number | null; + price_rials?: number | null; +} diff --git a/docs/new_feture/taskes/task-15-resource-first-model/checklist.md b/docs/new_feture/taskes/task-15-resource-first-model/checklist.md index 8dc06ddc..53d5af74 100644 --- a/docs/new_feture/taskes/task-15-resource-first-model/checklist.md +++ b/docs/new_feture/taskes/task-15-resource-first-model/checklist.md @@ -10,16 +10,16 @@ | # | مورد | وضعیت | یادداشت | |---|---|---|---| -| ۰.۱ | هیچ تم/پالت/فونت/کتابخانهٔ CSS تازه‌ای ساخته نشد | ⏳ | | -| ۰.۲ | رنگ‌ها فقط از توکن‌های `styles.css` — هیچ hex خام در کد جدید | ⏳ | | -| ۰.۳ | کامپوننت از `components/ui/` — `` خام صفر | ✅ | `SearchableSelect` برای انتخاب سرویس؛ صفر `