feat(treatment): skip reasons and reopening for session areas
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
{/* فاز ۲ — دکتر / کلینیک */}
|
||||
<Route path="staff" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['staff', 'view']}><StaffPage /></RoleRoute>} />
|
||||
{/* پرسنل: تنها صفحهٔ دادهٔ این نقش، کنار داشبورد */}
|
||||
<Route path="my-services" element={<RoleRoute roles={['staff']}><StaffMyServicesPage /></RoleRoute>} />
|
||||
<Route path="my-sessions" element={<RoleRoute roles={['staff']}><StaffTreatmentSessionsPage /></RoleRoute>} />
|
||||
<Route path="my-sessions/:uuid" element={<RoleRoute roles={['staff']}><StaffSessionDetailPage /></RoleRoute>} />
|
||||
<Route path="settings-menu" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><SettingsMenuPage /></RoleRoute>} />
|
||||
|
||||
@@ -559,11 +559,6 @@ function buildSections(
|
||||
icon: ClipboardDocumentListIcon,
|
||||
label: "جلسات امروز من",
|
||||
},
|
||||
{
|
||||
to: "/admin/my-services",
|
||||
icon: WrenchScrewdriverIcon,
|
||||
label: "سرویسهای من",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -30,13 +30,16 @@ describe("Sidebar — نقش پرسنل", () => {
|
||||
},
|
||||
} as any);
|
||||
|
||||
it("shows only داشبورد and سرویسهای من", () => {
|
||||
it("shows only داشبورد and جلسات امروز من", () => {
|
||||
asStaff();
|
||||
renderWithProviders(<Sidebar />, { 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", () => {
|
||||
|
||||
@@ -220,14 +220,14 @@ describe('داشبورد پرسنل', () => {
|
||||
expect(screen.getByText('محمد رستمی')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('کارتهای آمار به صفحهٔ کار خودشان لینکاند', async () => {
|
||||
it('کارت جلسات لینک است و کارت سرویسها نیست', async () => {
|
||||
renderWithProviders(<DashboardPage />);
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
<div className="stat-grid">
|
||||
{kpiCards.map(c => (
|
||||
<Link key={c.label} to={c.to} className="stat" style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
|
||||
<div className="ico" style={{ background: c.bg, color: c.color }}>
|
||||
<c.icon style={{ width: 21, height: 21 }} />
|
||||
</div>
|
||||
<div className="lbl">{c.label}</div>
|
||||
<div className="val">{c.value}</div>
|
||||
<div className="hint">{c.hint}</div>
|
||||
</Link>
|
||||
))}
|
||||
{kpiCards.map(c => {
|
||||
const body = (
|
||||
<>
|
||||
<div className="ico" style={{ background: c.bg, color: c.color }}>
|
||||
<c.icon style={{ width: 21, height: 21 }} />
|
||||
</div>
|
||||
<div className="lbl">{c.label}</div>
|
||||
<div className="val">{c.value}</div>
|
||||
<div className="hint">{c.hint}</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// فقط کارتی لینک میشود که صفحهای پشتش باشد؛ سرویسها صفحهٔ جدا ندارند و
|
||||
// همینجا پایینتر فهرست میشوند.
|
||||
return c.to === null
|
||||
? <div key={c.label} className="stat">{body}</div>
|
||||
: <Link key={c.label} to={c.to} className="stat" style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>{body}</Link>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
@@ -1102,7 +1110,6 @@ function StaffDashboard() {
|
||||
<div className="card card-pad" style={{ marginTop: 'var(--gap)' }}>
|
||||
<div className="card-title-row">
|
||||
<h3 style={{ fontSize: 16 }}>سرویسهای تخصیصیافته</h3>
|
||||
<Link to="/admin/my-services" className="link">همه سرویسها</Link>
|
||||
</div>
|
||||
{d.services.length === 0 ? (
|
||||
<p className="muted" style={{ fontSize: 13.5, padding: '1.5rem 0', textAlign: 'center' }}>
|
||||
|
||||
@@ -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<ApiResponse<StaffDashboardData>>('/api/v1/dashboard/staff'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const services = data?.data?.services ?? EMPTY;
|
||||
|
||||
const columns: Column<StaffAssignedService>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'سرویس',
|
||||
render: (s) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{s.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>{s.section_name}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'price_rials',
|
||||
header: 'تعرفه',
|
||||
render: (s) => <span style={{ fontSize: 13 }}>{formatRial(s.price_rials)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'duration_minutes',
|
||||
header: 'مدت',
|
||||
render: (s) => (
|
||||
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>
|
||||
{s.duration_minutes ? `${s.duration_minutes} دقیقه` : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="سرویسهای من"
|
||||
description="سرویسهایی که به شما تخصیص داده شده است"
|
||||
backTo="/admin/dashboard"
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
{services.length === 0 && !isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 24px', color: 'var(--text-3)' }}>
|
||||
<WrenchScrewdriverIcon style={{ width: 48, margin: '0 auto 16px', display: 'block', opacity: 0.4 }} />
|
||||
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8, color: 'var(--text-2)' }}>
|
||||
هنوز سرویسی به شما تخصیص نیافته
|
||||
</div>
|
||||
<div style={{ fontSize: 13 }}>پس از تخصیص سرویس توسط مطب/کلینیک، اینجا نمایش داده میشود.</div>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable columns={columns} data={services} loading={isLoading} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<ApiResponse<unknown>>(`${BASE}/session-area/${areaUuid}/skip`, {}),
|
||||
mutationFn: (payload: { areaUuid: string; note: string }) =>
|
||||
api.post<ApiResponse<unknown>>(`${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<ApiResponse<unknown>>(`${BASE}/session-area/${areaUuid}/reopen`, {}),
|
||||
onSuccess: () => { toast.success('ناحیه دوباره باز شد'); refresh(); },
|
||||
onError: (e) => fail(e, 'باز کردن ناحیه ناموفق بود'),
|
||||
});
|
||||
|
||||
const completeArea = useMutation({
|
||||
mutationFn: (payload: { areaUuid: string; body: Record<string, unknown> }) =>
|
||||
api.post<ApiResponse<unknown>>(`${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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -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<string, TreatmentFormField[]>;
|
||||
disabled: boolean;
|
||||
onSkip: () => void;
|
||||
onSkip: (note: string) => void;
|
||||
onReopen: () => void;
|
||||
onComplete: (body: Record<string, unknown>) => void;
|
||||
saving: boolean;
|
||||
reopening: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
@@ -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<string | null>(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 }
|
||||
<span style={{ fontSize: 12.5, color: 'var(--text-2)' }}>یادداشت: {area.note}</span>
|
||||
)}
|
||||
|
||||
{!settled && !disabled && !open && (
|
||||
{/* ناحیهٔ بستهشده تا وقتی جلسه باز است برمیگردد: اپراتور وسط لیزر میفهمد
|
||||
اشتباه زده و نباید تا پایان جلسه با آن بماند. */}
|
||||
{settled && !disabled && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="btn secondary sm" onClick={onReopen} disabled={reopening}>
|
||||
<ArrowUturnRightIcon style={{ width: 14, height: 14 }} />
|
||||
{reopening ? 'در حال بازکردن…' : 'بازکردن دوباره'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!settled && !disabled && !open && !skipping && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="btn primary sm" onClick={() => setOpen(true)}>
|
||||
ثبت اطلاعات این ناحیه
|
||||
</button>
|
||||
<button type="button" className="btn secondary sm" onClick={onSkip}>
|
||||
<button type="button" className="btn secondary sm" onClick={() => setSkipping(true)}>
|
||||
صرفنظر از این ناحیه
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* «چرا انجام نشد» بخشی از سابقهٔ درمان است؛ جلسهٔ بعد باید بتوان خواندش. */}
|
||||
{!settled && !disabled && skipping && (
|
||||
<div className="field-block">
|
||||
<label htmlFor={`skip-${area.uuid}`}>دلیل صرفنظر <span className="opt">(اختیاری)</span></label>
|
||||
<textarea
|
||||
id={`skip-${area.uuid}`}
|
||||
className="cp-textarea"
|
||||
value={skipNote}
|
||||
onChange={(e) => setSkipNote(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="مثال: پوست این ناحیه تحریک بود"
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button type="button" className="btn secondary sm" onClick={() => onSkip(skipNote.trim())}>
|
||||
ثبت صرفنظر
|
||||
</button>
|
||||
<button type="button" className="btn ghost sm" onClick={() => { setSkipping(false); setSkipNote(''); }}>
|
||||
انصراف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!settled && open && (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{/* دستگاه هنگام ثبت نوبت انتخاب شده و روی رکورد ناحیه نشسته است؛ پرسیدن
|
||||
|
||||
+17
-1
@@ -321,7 +321,8 @@ single-session again. Idempotent: deleting a service that has no protocol still
|
||||
| POST | `/dashboard/staff/treatment-session/{uuid}/finish` | اتمام جلسه |
|
||||
| POST | `/dashboard/staff/session-area/{uuid}/start` | شروع یک ناحیه |
|
||||
| POST | `/dashboard/staff/session-area/{uuid}/complete` | ثبت خواندههای دستگاه |
|
||||
| POST | `/dashboard/staff/session-area/{uuid}/skip` | صرفنظر از ناحیه |
|
||||
| POST | `/dashboard/staff/session-area/{uuid}/skip` | صرفنظر از ناحیه — `note` اختیاری |
|
||||
| POST | `/dashboard/staff/session-area/{uuid}/reopen` | باز کردن دوبارهٔ ناحیهٔ بستهشده |
|
||||
|
||||
### «جلسات امروز من» یک صف است
|
||||
|
||||
@@ -350,6 +351,21 @@ single-session again. Idempotent: deleting a service that has no protocol still
|
||||
|
||||
نتیجه همیشه به محیطِ خودِ پرسنل محدود است.
|
||||
|
||||
### صرفنظر و برگرداندن ناحیه
|
||||
|
||||
`skip` یک `note` اختیاری میگیرد. «چرا این ناحیه انجام نشد» بخشی از سابقهٔ درمان است؛
|
||||
بدونش جلسهٔ بعد فقط یک خلأ بیتوضیح میبیند.
|
||||
|
||||
`reopen` ناحیهٔ بستهشده — چه `completed` چه `skipped` — را برمیگرداند، برای اشتباهی که
|
||||
حین کار معلوم میشود. وضعیت به `in_progress` برمیگردد (یا `pending` اگر هرگز شروع نشده
|
||||
بود) و `finished_at` پاک میشود. `parameters` و `note` میمانند تا اپراتور ببیند چه ثبت
|
||||
شده بود و رویش بنویسد.
|
||||
|
||||
| کد | HTTP | شرط |
|
||||
|---|---|---|
|
||||
| ERR_VALIDATION_001 | 422 | ناحیه باز است و چیزی برای برگرداندن ندارد |
|
||||
| ERR_CONFLICT_001 | 409 | جلسه بسته شده؛ رکورد دیگر سابقه است نه فرم |
|
||||
|
||||
### شروع جلسه
|
||||
|
||||
رکوردِ هر ناحیهٔ پرونده یک بار ساخته میشود، پس فراخوانی دوباره ناحیهٔ تکراری نمیسازد.
|
||||
|
||||
@@ -151,10 +151,22 @@ class SessionExecutionController extends BaseController
|
||||
)->toArray());
|
||||
}
|
||||
|
||||
/** دلیلِ صرفنظر بخشی از سابقه است، پس `note` اینجا هم مثل «اتمام ناحیه» پذیرفته میشود. */
|
||||
#[Route('/api/v1/dashboard/staff/session-area/{uuid}/skip', name: 'staff_session_area_skip', methods: ['POST'])]
|
||||
public function skipArea(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
public function skipArea(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||
{
|
||||
return $this->success($this->executor->skipArea($this->requireAreaRecord($user, $uuid))->toArray());
|
||||
$record = $this->requireAreaRecord($user, $uuid);
|
||||
$data = json_decode($request->getContent(), true) ?? [];
|
||||
$note = is_string($data['note'] ?? null) ? trim($data['note']) : null;
|
||||
|
||||
return $this->success($this->executor->skipArea($record, $note !== '' ? $note : null)->toArray());
|
||||
}
|
||||
|
||||
/** اشتباهِ حین کار: ناحیهای که زودتر بسته شده تا وقتی جلسه باز است برمیگردد. */
|
||||
#[Route('/api/v1/dashboard/staff/session-area/{uuid}/reopen', name: 'staff_session_area_reopen', methods: ['POST'])]
|
||||
public function reopenArea(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||
{
|
||||
return $this->success($this->executor->reopenArea($this->requireAreaRecord($user, $uuid))->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -129,11 +129,32 @@ class SessionAreaRecord
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** بیمار امروز فقط یک ناحیه میخواهد — بقیه صرفنظر میشوند، نه ناتمام رها. */
|
||||
public function skip(): self
|
||||
/**
|
||||
* بیمار امروز فقط یک ناحیه میخواهد — بقیه صرفنظر میشوند، نه ناتمام رها.
|
||||
*
|
||||
* دلیلِ صرفنظر بخشی از سابقهٔ درمان است: «چرا این ناحیه انجام نشد» را جلسهٔ بعد
|
||||
* باید بتوان خواند، وگرنه فقط یک خلأ بیتوضیح میماند.
|
||||
*/
|
||||
public function skip(?string $note = null): self
|
||||
{
|
||||
$this->status = self::STATUS_SKIPPED;
|
||||
$this->finishedAt = time();
|
||||
$this->note = $note;
|
||||
$this->touch();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* برگرداندن ناحیهٔ بستهشده به حالت باز — برای اشتباهِ حین کار.
|
||||
*
|
||||
* `parameters` و `note` پاک نمیشوند تا اپراتور ببیند چه ثبت شده بود و رویش
|
||||
* بنویسد؛ `startedAt` هم میماند چون کار واقعاً شروع شده بود.
|
||||
*/
|
||||
public function reopen(): self
|
||||
{
|
||||
$this->status = $this->startedAt === null ? self::STATUS_PENDING : self::STATUS_IN_PROGRESS;
|
||||
$this->finishedAt = null;
|
||||
$this->touch();
|
||||
|
||||
return $this;
|
||||
|
||||
@@ -104,13 +104,41 @@ final class SessionExecutor
|
||||
}
|
||||
|
||||
/** بیمار امروز فقط یک ناحیه میخواهد — بقیه صرفنظر میشوند، نه ناتمام رها. */
|
||||
public function skipArea(SessionAreaRecord $record): SessionAreaRecord
|
||||
public function skipArea(SessionAreaRecord $record, ?string $note = null): SessionAreaRecord
|
||||
{
|
||||
if ($record->isSettled()) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این ناحیه قبلاً بسته شده است', 422);
|
||||
}
|
||||
|
||||
$record->skip();
|
||||
$record->skip($note);
|
||||
$this->em->flush();
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* باز کردن دوبارهٔ ناحیهای که اشتباه بسته شده.
|
||||
*
|
||||
* فقط تا وقتی جلسه باز است: بعد از بستن جلسه، رکورد سابقهٔ درمان است و ویرایشش
|
||||
* یعنی بازنویسی سند — همان مرزی که `TreatmentCaseEditor` هم رویش ایستاده.
|
||||
*/
|
||||
public function reopenArea(SessionAreaRecord $record): SessionAreaRecord
|
||||
{
|
||||
if (!$record->isSettled()) {
|
||||
throw new AppException(ErrorCodes::ERR_VALIDATION_001, 'این ناحیه باز است', 422);
|
||||
}
|
||||
|
||||
$session = $record->getSession();
|
||||
|
||||
if (in_array($session->getStatus(), [TreatmentSession::STATUS_DONE, TreatmentSession::STATUS_CANCELLED], true)) {
|
||||
throw new AppException(
|
||||
ErrorCodes::ERR_CONFLICT_001,
|
||||
'جلسه بسته شده است؛ ناحیه دیگر قابل تغییر نیست',
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
$record->reopen();
|
||||
$this->em->flush();
|
||||
|
||||
return $record;
|
||||
|
||||
@@ -354,6 +354,74 @@ class SessionExecutionTest extends ApiTestCase
|
||||
self::assertSame(SessionAreaRecord::STATUS_SKIPPED, $body['data']['status']);
|
||||
}
|
||||
|
||||
/** «چرا انجام نشد» بخشی از سابقهٔ درمان است، نه یک خلأ بیتوضیح. */
|
||||
public function testSkippingAnAreaStoresTheReason(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']);
|
||||
$record = $this->areaRecords($s['session'])[1];
|
||||
|
||||
$body = $this->authJson(
|
||||
'POST',
|
||||
'/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/skip',
|
||||
$s['staffUser'],
|
||||
['note' => 'پوست این ناحیه تحریک بود'],
|
||||
);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('پوست این ناحیه تحریک بود', $body['data']['note']);
|
||||
}
|
||||
|
||||
/** اپراتور وسط لیزر میفهمد اشتباه زده؛ نباید تا پایان جلسه با آن بماند. */
|
||||
public function testASettledAreaCanBeReopenedWhileTheSessionIsOpen(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']);
|
||||
$record = $this->areaRecords($s['session'])[0];
|
||||
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/skip', $s['staffUser']);
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/reopen', $s['staffUser']);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertNotSame(SessionAreaRecord::STATUS_SKIPPED, $body['data']['status']);
|
||||
self::assertNull($body['data']['finished_at']);
|
||||
|
||||
// و بعد از باز شدن، دوباره قابل ثبت است.
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/complete', $s['staffUser'], [
|
||||
'resource_uuid' => $s['resource']->getUuid(),
|
||||
'parameters' => ['energy' => 18, 'pulse' => 3, 'shots' => 10],
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
/** بعد از بستن جلسه، رکورد سابقه است و ویرایشش یعنی بازنویسی سند. */
|
||||
public function testAnAreaCannotBeReopenedAfterTheSessionIsFinished(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']);
|
||||
$record = $this->areaRecords($s['session'])[0];
|
||||
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/skip', $s['staffUser']);
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/finish', $s['staffUser']);
|
||||
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/reopen', $s['staffUser']);
|
||||
|
||||
self::assertSame(409, $this->responseCode());
|
||||
}
|
||||
|
||||
/** ناحیهٔ باز چیزی برای برگرداندن ندارد. */
|
||||
public function testAnOpenAreaCannotBeReopened(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/treatment-session/' . $s['session']->getUuid() . '/start', $s['staffUser']);
|
||||
$record = $this->areaRecords($s['session'])[0];
|
||||
|
||||
$this->authJson('POST', '/api/v1/dashboard/staff/session-area/' . $record->getUuid() . '/reopen', $s['staffUser']);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAnAlreadySettledAreaCannotBeCompletedAgain(): void
|
||||
{
|
||||
$s = $this->scenario();
|
||||
|
||||
Reference in New Issue
Block a user