From a182b05e1f2e0284b720344ebbd38cf338e9a564 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 14:28:30 +0330 Subject: [PATCH] feat(treatment): skip reasons and reopening for session areas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skipping an area recorded only that it was skipped. Why it was skipped is clinical history — the next session needs to read it — so `skip` now takes an optional note, the same way completing an area already did, and the panel asks for it inline instead of firing on the first click. An operator finds out mid-laser that they closed the wrong area, and until now had to carry that mistake to the end of the session. `reopen` puts a settled area — completed or skipped — back to in_progress and clears finished_at, keeping the recorded parameters and note so they can be seen and overwritten. It stops at the same boundary everything else in this domain stops at: once the session is finished the record is history, and reopening it is 409. Also drops /admin/my-services. The staff role has one job — today's sessions — and the dashboard already lists the services they may perform, so the page was a second place to read the same list. Route, page, sidebar entry and the two links to it are gone; the services stat card is no longer a link because it no longer has a destination. Co-Authored-By: Claude Opus 5 --- assets/admin/App.tsx | 2 - assets/admin/components/layout/Sidebar.tsx | 5 -- .../components/layout/SidebarStaff.test.tsx | 7 +- assets/admin/pages/DashboardPage.test.tsx | 6 +- assets/admin/pages/DashboardPage.tsx | 31 +++++--- assets/admin/pages/StaffMyServicesPage.tsx | 79 ------------------- assets/admin/pages/StaffSessionDetailPage.tsx | 63 +++++++++++++-- docs/api/treatment.md | 18 ++++- .../Controller/SessionExecutionController.php | 16 +++- src/Treatment/Entity/SessionAreaRecord.php | 25 +++++- src/Treatment/Service/SessionExecutor.php | 32 +++++++- tests/Treatment/SessionExecutionTest.php | 68 ++++++++++++++++ 12 files changed, 236 insertions(+), 116 deletions(-) delete mode 100644 assets/admin/pages/StaffMyServicesPage.tsx diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 572117a4..5265060f 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -59,7 +59,6 @@ import MyFinancialPage from './pages/MyFinancialPage'; import ClinicFormPage from './pages/ClinicFormPage'; import PreRegistrationsPage from './pages/PreRegistrationsPage'; import StaffPage from './pages/StaffPage'; -import StaffMyServicesPage from './pages/StaffMyServicesPage'; import StaffTreatmentSessionsPage from './pages/StaffTreatmentSessionsPage'; import StaffSessionDetailPage from './pages/StaffSessionDetailPage'; import TreatmentCasesPage from './pages/TreatmentCasesPage'; @@ -285,7 +284,6 @@ export default function App() { {/* فاز ۲ — دکتر / کلینیک */} } /> {/* پرسنل: تنها صفحهٔ دادهٔ این نقش، کنار داشبورد */} - } /> } /> } /> } /> diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 83673c24..a3ad08b6 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -559,11 +559,6 @@ function buildSections( icon: ClipboardDocumentListIcon, label: "جلسات امروز من", }, - { - to: "/admin/my-services", - icon: WrenchScrewdriverIcon, - label: "سرویس‌های من", - }, ], }, ]; diff --git a/assets/admin/components/layout/SidebarStaff.test.tsx b/assets/admin/components/layout/SidebarStaff.test.tsx index 09bb7778..3fb11073 100644 --- a/assets/admin/components/layout/SidebarStaff.test.tsx +++ b/assets/admin/components/layout/SidebarStaff.test.tsx @@ -30,13 +30,16 @@ describe("Sidebar — نقش پرسنل", () => { }, } as any); - it("shows only داشبورد and سرویس‌های من", () => { + it("shows only داشبورد and جلسات امروز من", () => { asStaff(); renderWithProviders(, { route: "/admin/dashboard" }); expect(screen.getByText("داشبورد").closest("a")).toHaveAttribute("href", "/admin/dashboard"); - expect(screen.getByText("سرویس‌های من").closest("a")).toHaveAttribute("href", "/admin/my-services"); + expect(screen.getByText("جلسات امروز من").closest("a")).toHaveAttribute("href", "/admin/my-sessions"); expect(screen.getByText("پرسنل")).toBeInTheDocument(); // برچسب نقش در فوتر + + // صفحهٔ «سرویس‌های من» حذف شد؛ فهرست سرویس‌ها روی خودِ داشبورد است. + expect(screen.queryByText("سرویس‌های من")).not.toBeInTheDocument(); }); it("hides management entries", () => { diff --git a/assets/admin/pages/DashboardPage.test.tsx b/assets/admin/pages/DashboardPage.test.tsx index 50ae9117..866fce7b 100644 --- a/assets/admin/pages/DashboardPage.test.tsx +++ b/assets/admin/pages/DashboardPage.test.tsx @@ -220,14 +220,14 @@ describe('داشبورد پرسنل', () => { expect(screen.getByText('محمد رستمی')).toBeInTheDocument(); }); - it('کارت‌های آمار به صفحهٔ کار خودشان لینک‌اند', async () => { + it('کارت جلسات لینک است و کارت سرویس‌ها نیست', async () => { renderWithProviders(); const sessions = (await screen.findByText('جلسات امروز من')).closest('a'); expect(sessions).toHaveAttribute('href', '/admin/my-sessions'); - const services = screen.getByText('سرویس‌های من').closest('a'); - expect(services).toHaveAttribute('href', '/admin/my-services'); + // صفحهٔ «سرویس‌های من» حذف شد؛ کارتِ بدون مقصد نباید لینک باشد. + expect(screen.getByText('سرویس‌های من').closest('a')).toBeNull(); }); it('هر ردیف کار به همان جلسه می‌رود', async () => { diff --git a/assets/admin/pages/DashboardPage.tsx b/assets/admin/pages/DashboardPage.tsx index 75bbe538..c25454e5 100644 --- a/assets/admin/pages/DashboardPage.tsx +++ b/assets/admin/pages/DashboardPage.tsx @@ -1016,7 +1016,7 @@ function StaffDashboard() { icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)', }, { - to: '/admin/my-services', + to: null, label: 'سرویس‌های من', value: formatNumber(d.stats?.services ?? 0), hint: 'سرویس‌هایی که مجاز به انجامشان هستید', @@ -1045,16 +1045,24 @@ function StaffDashboard() {
- {kpiCards.map(c => ( - -
- -
-
{c.label}
-
{c.value}
-
{c.hint}
- - ))} + {kpiCards.map(c => { + const body = ( + <> +
+ +
+
{c.label}
+
{c.value}
+
{c.hint}
+ + ); + + // فقط کارتی لینک می‌شود که صفحه‌ای پشتش باشد؛ سرویس‌ها صفحهٔ جدا ندارند و + // همین‌جا پایین‌تر فهرست می‌شوند. + return c.to === null + ?
{body}
+ : {body}; + })}
@@ -1102,7 +1110,6 @@ function StaffDashboard() {

سرویس‌های تخصیص‌یافته

- همه سرویس‌ها
{d.services.length === 0 ? (

diff --git a/assets/admin/pages/StaffMyServicesPage.tsx b/assets/admin/pages/StaffMyServicesPage.tsx deleted file mode 100644 index 3b4c042b..00000000 --- a/assets/admin/pages/StaffMyServicesPage.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { WrenchScrewdriverIcon } from '@heroicons/react/24/outline'; -import { api } from '../lib/api'; -import type { ApiResponse } from '../lib/api'; -import type { StaffAssignedService } from '../types'; -import { formatRial } from '../lib/utils'; -import DataTable, { type Column } from '../components/ui/DataTable'; -import PageHeader from '../components/ui/PageHeader'; - -interface StaffDashboardData { - services: StaffAssignedService[]; -} - -const EMPTY: StaffAssignedService[] = []; - -/** - * سرویس‌های تخصیص‌یافته به پرسنل — فقط خواندنی. - * داده از همان اندپوینت داشبورد پرسنل می‌آید؛ نقش staff اندپوینت دیگری ندارد. - */ -export default function StaffMyServicesPage() { - const { data, isLoading } = useQuery({ - queryKey: ['dashboard-staff'], - queryFn: () => api.get>('/api/v1/dashboard/staff'), - staleTime: 60_000, - }); - - const services = data?.data?.services ?? EMPTY; - - const columns: Column[] = [ - { - key: 'name', - header: 'سرویس', - render: (s) => ( -

-
{s.name}
-
{s.section_name}
-
- ), - }, - { - key: 'price_rials', - header: 'تعرفه', - render: (s) => {formatRial(s.price_rials)}, - }, - { - key: 'duration_minutes', - header: 'مدت', - render: (s) => ( - - {s.duration_minutes ? `${s.duration_minutes} دقیقه` : '—'} - - ), - }, - ]; - - return ( - <> - - -
- {services.length === 0 && !isLoading ? ( -
- -
- هنوز سرویسی به شما تخصیص نیافته -
-
پس از تخصیص سرویس توسط مطب/کلینیک، اینجا نمایش داده می‌شود.
-
- ) : ( - - )} -
- - ); -} diff --git a/assets/admin/pages/StaffSessionDetailPage.tsx b/assets/admin/pages/StaffSessionDetailPage.tsx index c8390a41..46af7000 100644 --- a/assets/admin/pages/StaffSessionDetailPage.tsx +++ b/assets/admin/pages/StaffSessionDetailPage.tsx @@ -7,6 +7,7 @@ import PageHeader from '../components/ui/PageHeader'; import StatusBadge from '../components/ui/StatusBadge'; import SearchableSelect from '../components/ui/SearchableSelect'; import { formatDate, formatNumber } from '../lib/utils'; +import { ArrowUturnRightIcon } from '@heroicons/react/24/outline'; import { useElapsed } from '../hooks/useElapsed'; import type { SessionAreaRecord, StaffSessionDetail, TreatmentDevice, TreatmentFormField } from '../types'; @@ -59,11 +60,21 @@ export default function StaffSessionDetailPage() { }); const skipArea = useMutation({ - mutationFn: (areaUuid: string) => api.post>(`${BASE}/session-area/${areaUuid}/skip`, {}), + mutationFn: (payload: { areaUuid: string; note: string }) => + api.post>(`${BASE}/session-area/${payload.areaUuid}/skip`, { + note: payload.note || undefined, + }), onSuccess: () => { toast.success('این ناحیه صرف‌نظر شد'); refresh(); }, onError: (e) => fail(e, 'صرف‌نظر از ناحیه ناموفق بود'), }); + // اشتباهِ حین کار: ناحیه‌ای که زودتر بسته شده تا وقتی جلسه باز است برمی‌گردد. + const reopenArea = useMutation({ + mutationFn: (areaUuid: string) => api.post>(`${BASE}/session-area/${areaUuid}/reopen`, {}), + onSuccess: () => { toast.success('ناحیه دوباره باز شد'); refresh(); }, + onError: (e) => fail(e, 'باز کردن ناحیه ناموفق بود'), + }); + const completeArea = useMutation({ mutationFn: (payload: { areaUuid: string; body: Record }) => api.post>(`${BASE}/session-area/${payload.areaUuid}/complete`, payload.body), @@ -140,9 +151,11 @@ export default function StaffSessionDetailPage() { devices={session.devices} forms={session.forms} disabled={finished} - onSkip={() => skipArea.mutate(area.uuid)} + onSkip={(note) => skipArea.mutate({ areaUuid: area.uuid, note })} + onReopen={() => reopenArea.mutate(area.uuid)} onComplete={(body) => completeArea.mutate({ areaUuid: area.uuid, body })} saving={completeArea.isPending} + reopening={reopenArea.isPending} /> ))}
@@ -177,14 +190,16 @@ export default function StaffSessionDetailPage() { ); } -function AreaCard({ area, devices, forms, disabled, onSkip, onComplete, saving }: { +function AreaCard({ area, devices, forms, disabled, onSkip, onReopen, onComplete, saving, reopening }: { area: SessionAreaRecord; devices: TreatmentDevice[]; forms: Record; disabled: boolean; - onSkip: () => void; + onSkip: (note: string) => void; + onReopen: () => void; onComplete: (body: Record) => void; saving: boolean; + reopening: boolean; }) { const [open, setOpen] = useState(false); const [values, setValues] = useState>({}); @@ -192,6 +207,8 @@ function AreaCard({ area, devices, forms, disabled, onSkip, onComplete, saving } // دستگاه از نوبت به ارث می‌رسد و همان می‌ماند. این فلگ فقط برای موردِ نادرِ // «نوبت روی دستگاه اشتباه ثبت شده» است، نه بخشی از جریان عادی. const [changingDevice, setChangingDevice] = useState(false); + const [skipping, setSkipping] = useState(false); + const [skipNote, setSkipNote] = useState(''); const [resourceUuid, setResourceUuid] = useState(area.resource?.uuid ?? null); const fields = resourceUuid ? forms[resourceUuid] ?? [] : []; const settled = area.status === 'completed' || area.status === 'skipped'; @@ -241,17 +258,51 @@ function AreaCard({ area, devices, forms, disabled, onSkip, onComplete, saving } یادداشت: {area.note} )} - {!settled && !disabled && !open && ( + {/* ناحیهٔ بسته‌شده تا وقتی جلسه باز است برمی‌گردد: اپراتور وسط لیزر می‌فهمد + اشتباه زده و نباید تا پایان جلسه با آن بماند. */} + {settled && !disabled && ( +
+ +
+ )} + + {!settled && !disabled && !open && !skipping && (
-
)} + {/* «چرا انجام نشد» بخشی از سابقهٔ درمان است؛ جلسهٔ بعد باید بتوان خواندش. */} + {!settled && !disabled && skipping && ( +
+ +