improvement: UI/UX improved for v1 standalone treatments/cases feature.
This commit is contained in:
@@ -11,7 +11,7 @@ description: Lab Cases tab — list, filters, detail panel, assignment, share QR
|
|||||||
|
|
||||||
## List behavior
|
## List behavior
|
||||||
|
|
||||||
- **Default sort:** `startedAt` / `sentAt` desc (newest first). Lab-origin drafts (`origin === LAB_INTERNAL && !startedAt`) show a **Draft** badge.
|
- **Default sort:** `startedAt` / `sentAt` desc (newest first). Lab-origin drafts (`origin === LAB_INTERNAL && !startedAt`) show a **Draft** badge. All list/detail cards also show **Received** (`CLINIC_DISPATCH`) or **Generated** (`LAB_INTERNAL`) via `LabCaseOriginBadge`.
|
||||||
- **Page size:** `PAGE_SIZE = 10` in `CasesPage.tsx`.
|
- **Page size:** `PAGE_SIZE = 10` in `CasesPage.tsx`.
|
||||||
- **Auto-select:** On tab open / after filter reload, select first list item if none selected; keep selection when still in list; `?caseId=` URL wins.
|
- **Auto-select:** On tab open / after filter reload, select first list item if none selected; keep selection when still in list; `?caseId=` URL wins.
|
||||||
- **Add case:** `TAB_CASES_EDIT` — `POST /cases` then replace the **right pane** with `CaseCreatePanel` (inline wizard, not a modal). Prosthesis-only lines, interactive `FdiToothChart` (same connect/Shift rules as Treatment). Header: referring clinic/dentist, patient name/mobile, optional ACTIVE partner clinic, due date (`AppDateInput`). **Start** (`POST /cases/:id/start`) generates tasks; no `LabCaseSend`, no clinic inbox/`CASE_SENT`.
|
- **Add case:** `TAB_CASES_EDIT` — `POST /cases` then replace the **right pane** with `CaseCreatePanel` (inline wizard, not a modal). Prosthesis-only lines, interactive `FdiToothChart` (same connect/Shift rules as Treatment). Header: referring clinic/dentist, patient name/mobile, optional ACTIVE partner clinic, due date (`AppDateInput`). **Start** (`POST /cases/:id/start`) generates tasks; no `LabCaseSend`, no clinic inbox/`CASE_SENT`.
|
||||||
@@ -41,7 +41,7 @@ List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task
|
|||||||
## Detail panel
|
## Detail panel
|
||||||
|
|
||||||
- Task assignment: `PATCH /cases/:caseId/tasks/:taskId/assign` (`TAB_CASES_EDIT`)
|
- Task assignment: `PATCH /cases/:caseId/tasks/:taskId/assign` (`TAB_CASES_EDIT`)
|
||||||
- Comments: shared `LabCaseCommentsPanel` + `tasksApi` comment routes (`viewerSide="LAB"`). Newest-first; sent/received use logical start/end alignment (RTL-safe). Compact `h-9` composer with primary send + visibility controls. Lab-origin cases (`LAB_INTERNAL`, including after Start) are visible to lab comments/mark-read — do not require `sentAt` / `LabCaseSend`.
|
- Comments: shared `LabCaseCommentsPanel` + `tasksApi` comment routes (`viewerSide="LAB"`). Newest-first; sent/received use logical start/end alignment (RTL-safe). Compact `h-9` composer with primary send + visibility controls. Lab-origin cases (`LAB_INTERNAL`, including after Start) are visible to lab comments/mark-read — do not require `sentAt` / `LabCaseSend`. Generated cases hide clinic-visibility controls (`clinicVisibility={false}`) — there is no clinic inbox.
|
||||||
- Mark read: `POST /notifications/mark-case-read` on select (Cases tab badge)
|
- Mark read: `POST /notifications/mark-case-read` on select (Cases tab badge)
|
||||||
- FDI chart (`CaseToothChartPanel`): prosthesis colors + **connected bridge dots** from `selectionGroupId` (`buildCaseConnectedTeeth` / `buildCaseProsthesisRows` in `caseDetailUtils.ts`).
|
- FDI chart (`CaseToothChartPanel`): prosthesis colors + **connected bridge dots** from `selectionGroupId` (`buildCaseConnectedTeeth` / `buildCaseProsthesisRows` in `caseDetailUtils.ts`).
|
||||||
- **Important + external code** (`TAB_CASES_EDIT`): row is **Important Case** label then checkbox (`Checkbox` `labelPosition="start"`), then optional external-code input (no title; placeholder only). Save code on blur → `PATCH /cases/:id/external-code`.
|
- **Important + external code** (`TAB_CASES_EDIT`): row is **Important Case** label then checkbox (`Checkbox` `labelPosition="start"`), then optional external-code input (no title; placeholder only). Save code on blur → `PATCH /cases/:id/external-code`.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Other sorts use flat list on the frontend; `stepOrder asc` is still a tiebreaker
|
|||||||
|
|
||||||
Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`.
|
Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`.
|
||||||
|
|
||||||
**Grouped comments (`sortBy=date`):** single comments button on `TaskCaseGroupHeader`; panel expands below header (`expandedCommentsCaseId`). Per-task comments button only in **flat** sort (`showCommentsButton={flatMode}`). Shared `LabCaseCommentsPanel` with `viewerSide="LAB"` (newest-first, logical start/end chat align).
|
**Grouped comments (`sortBy=date`):** single comments button on `TaskCaseGroupHeader`; panel expands below header (`expandedCommentsCaseId`). Per-task comments button only in **flat** sort (`showCommentsButton={flatMode}`). Shared `LabCaseCommentsPanel` with `viewerSide="LAB"` (newest-first, logical start/end chat align). Generated cases (`LAB_INTERNAL`) hide clinic-visibility controls.
|
||||||
|
|
||||||
## Prosthesis colors
|
## Prosthesis colors
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`.
|
|||||||
| Param | API | UI |
|
| Param | API | UI |
|
||||||
|-------|-----|-----|
|
|-------|-----|-----|
|
||||||
| `q`, `clinicOrganizationId`, `status` | `GET /tasks` | Search, clinic, status |
|
| `q`, `clinicOrganizationId`, `status` | `GET /tasks` | Search, clinic, status |
|
||||||
|
| `origin` | `GET /tasks` | `CLINIC_DISPATCH` (received) / `LAB_INTERNAL` (generated) |
|
||||||
| `stepCompleted` | `GET /tasks` | Workflow step dropdown |
|
| `stepCompleted` | `GET /tasks` | Workflow step dropdown |
|
||||||
| `pinImportant` | `GET /tasks` | Important first (sort pin) |
|
| `pinImportant` | `GET /tasks` | Important first (sort pin) |
|
||||||
| `assignedToMe` | `GET /tasks` | Only tasks assigned to current user |
|
| `assignedToMe` | `GET /tasks` | Only tasks assigned to current user |
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
1. **Day strip** — `AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Timed appointments show the slot; standalone treatments show i18n “No appointment” and sort after timed cards. Empty unscheduled cards (`details.length === 0`) show a trash control (`DELETE /treatments/:id`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. Strip stays dumb (no draft API). New treatment / Walk-in use shared `Button`.
|
1. **Day strip** — `AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Timed appointments keep treatment-type pastel banners. Standalone cards keep the same size/content but use **detail-chip theming** (neutral border, selected `border-primary bg-primary-soft`, trash with chip delete hover). Empty unscheduled cards (`details.length === 0`) can be deleted (`DELETE /treatments/:id`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. **New treatment** is one shared `Button`: it opens `NewTreatmentPatientPicker` (Walk-in always first, then search). Creating happens only after an explicit patient choice — never from the selected appointment card.
|
||||||
|
|
||||||
2. **Treatment preview** — `TreatmentPreviewCard.tsx` (read-only summary; no load button for current draft)
|
2. **Treatment preview** — `TreatmentPreviewCard.tsx` (read-only summary; no load button for current draft)
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ Use shared `Checkbox` (not native `<input type="checkbox">`) to avoid focus-driv
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Patient search: same workspace patient → no-op; else load latest history into the editor; **no history → auto-create** standalone on the selected day.
|
Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Patient search: same workspace patient → no-op; else load latest history into the editor; **no history → do not auto-create** (history rail empties; dentist uses **New treatment** and picks a patient, including Walk-in).
|
||||||
|
|
||||||
Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`.
|
Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`.
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ frontend/src/
|
|||||||
**Treatment tab:** Preview and editable form are **separate** until the user clicks **Load into workspace** on a history item. See `.cursor/skills/treatment-workspace/SKILL.md` before changing that flow.
|
**Treatment tab:** Preview and editable form are **separate** until the user clicks **Load into workspace** on a history item. See `.cursor/skills/treatment-workspace/SKILL.md` before changing that flow.
|
||||||
|
|
||||||
**Treatment edit / details (quick ref):**
|
**Treatment edit / details (quick ref):**
|
||||||
- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. Empty **no-appointment** strip cards can be deleted (trash; no details). New treatment / Walk-in use shared `Button`.
|
- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. Empty **no-appointment** strip cards can be deleted (trash; no details). **New treatment** opens a patient picker (Walk-in always visible); it does not copy the selected appointment’s patient.
|
||||||
- Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash icon on each detail chip (not in the wizard Content step) — only unsent and not the last line. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`).
|
- Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash icon on each detail chip (not in the wizard Content step) — only unsent and not the last line. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`).
|
||||||
- **Entry wizard:** `WizardStepper` — Teeth → Content → Lab; Lab step only when active detail type is lab-dependent (prosthesis); entering Lab auto-opens shipment draft (no Add-shipment CTA). Content notes field label is **Notes** (clinical). Detail chips ≠ wizard chrome. Switching detail chips resets wizard to Teeth unless `pendingEntryStepRef` requests Lab (e.g. opening a case from the lab shipments rail).
|
- **Entry wizard:** `WizardStepper` — Teeth → Content → Lab; Lab step only when active detail type is lab-dependent (prosthesis); entering Lab auto-opens shipment draft (no Add-shipment CTA). Content notes field label is **Notes** (clinical). Detail chips ≠ wizard chrome. Switching detail chips resets wizard to Teeth unless `pendingEntryStepRef` requests Lab (e.g. opening a case from the lab shipments rail).
|
||||||
- **Tooth selection:** Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; Shift+range selects only (empty circles; overlap absorbs as singles); midline 11–21 / 41–31 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type.
|
- **Tooth selection:** Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; Shift+range selects only (empty circles; overlap absorbs as singles); midline 11–21 / 41–31 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type.
|
||||||
@@ -60,9 +60,9 @@ frontend/src/
|
|||||||
|
|
||||||
**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. Working hours: client IANA `timeZone` on create/update — never `Date#getHours()`/`getDay()` on the UTC server. Logical API errors: `AppException` + `errors.*` (never Nest English throws). See `.cursor/rules/appointments.mdc`, `.cursor/skills/api-errors/SKILL.md`.
|
**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. Working hours: client IANA `timeZone` on create/update — never `Date#getHours()`/`getDay()` on the UTC server. Logical API errors: `AppException` + `errors.*` (never Nest English throws). See `.cursor/rules/appointments.mdc`, `.cursor/skills/api-errors/SKILL.md`.
|
||||||
|
|
||||||
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
|
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; filter by case source (`origin`: received vs generated); prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
|
||||||
|
|
||||||
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. Lab-origin Start has no clinic send; comments and mark-read still work. **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
|
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail) plus **Received** / **Generated** origin badges. Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. Lab-origin Start has no clinic send; comments and mark-read still work (no clinic-visibility toggle). **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
|
||||||
|
|
||||||
**Lab case share link (quick ref):**
|
**Lab case share link (quick ref):**
|
||||||
- Token on first ship → `/{locale}/lab-case/{token}` after login.
|
- Token on first ship → `/{locale}/lab-case/{token}` after login.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { LabTaskStatus } from '@prisma/client';
|
import { LabTaskStatus, LabCaseOrigin } from '@prisma/client';
|
||||||
|
|
||||||
const toBoolean = ({ value }: { value: unknown }) => {
|
const toBoolean = ({ value }: { value: unknown }) => {
|
||||||
if (typeof value === 'boolean') return value;
|
if (typeof value === 'boolean') return value;
|
||||||
@@ -105,6 +105,11 @@ export class ListLabTasksDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
prosthesisTypeCode?: string;
|
prosthesisTypeCode?: string;
|
||||||
|
|
||||||
|
/** CLINIC_DISPATCH = received from clinic; LAB_INTERNAL = generated in the lab. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(LabCaseOrigin)
|
||||||
|
origin?: LabCaseOrigin;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType', 'dueDate'])
|
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType', 'dueDate'])
|
||||||
sortBy?: TaskSortField;
|
sortBy?: TaskSortField;
|
||||||
@@ -194,6 +199,10 @@ export class LocateTaskPageDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
prosthesisTypeCode?: string;
|
prosthesisTypeCode?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(LabCaseOrigin)
|
||||||
|
origin?: LabCaseOrigin;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType', 'dueDate'])
|
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType', 'dueDate'])
|
||||||
sortBy?: TaskSortField;
|
sortBy?: TaskSortField;
|
||||||
|
|||||||
@@ -466,6 +466,7 @@ export class TasksService {
|
|||||||
} satisfies Prisma.LabCaseWhereInput,
|
} satisfies Prisma.LabCaseWhereInput,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...(query.origin ? [{ origin: query.origin } satisfies Prisma.LabCaseWhereInput] : []),
|
||||||
...(query.q?.trim() ? [this.buildTaskSearchWhere(query.q.trim())] : []),
|
...(query.q?.trim() ? [this.buildTaskSearchWhere(query.q.trim())] : []),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -537,6 +538,7 @@ export class TasksService {
|
|||||||
overdue: query.overdue,
|
overdue: query.overdue,
|
||||||
unassignedOnly: query.unassignedOnly,
|
unassignedOnly: query.unassignedOnly,
|
||||||
prosthesisTypeCode: query.prosthesisTypeCode,
|
prosthesisTypeCode: query.prosthesisTypeCode,
|
||||||
|
origin: query.origin,
|
||||||
sortBy: query.sortBy,
|
sortBy: query.sortBy,
|
||||||
sortDir: query.sortDir,
|
sortDir: query.sortDir,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
@@ -797,6 +799,7 @@ export class TasksService {
|
|||||||
: null,
|
: null,
|
||||||
createdAt: task.createdAt.toISOString(),
|
createdAt: task.createdAt.toISOString(),
|
||||||
caseSentAt: (task.labCase.sentAt ?? task.labCase.startedAt)?.toISOString() ?? null,
|
caseSentAt: (task.labCase.sentAt ?? task.labCase.startedAt)?.toISOString() ?? null,
|
||||||
|
origin: task.labCase.origin,
|
||||||
clinic,
|
clinic,
|
||||||
patient,
|
patient,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -499,6 +499,8 @@
|
|||||||
"addCaseSubtitle": "Enter the referring clinic or patient, then add prosthesis lines. Start when the case is ready.",
|
"addCaseSubtitle": "Enter the referring clinic or patient, then add prosthesis lines. Start when the case is ready.",
|
||||||
"startCase": "Start",
|
"startCase": "Start",
|
||||||
"draftBadge": "Draft",
|
"draftBadge": "Draft",
|
||||||
|
"receivedBadge": "Received",
|
||||||
|
"generatedBadge": "Generated",
|
||||||
"referringClinic": "Referring clinic",
|
"referringClinic": "Referring clinic",
|
||||||
"referringDentist": "Dentist",
|
"referringDentist": "Dentist",
|
||||||
"patientName": "Patient name",
|
"patientName": "Patient name",
|
||||||
@@ -542,6 +544,10 @@
|
|||||||
"searchPlaceholder": "Search patient or clinic…",
|
"searchPlaceholder": "Search patient or clinic…",
|
||||||
"filterClinic": "Clinic",
|
"filterClinic": "Clinic",
|
||||||
"filterClinicAll": "All clinics",
|
"filterClinicAll": "All clinics",
|
||||||
|
"filterOrigin": "Case source",
|
||||||
|
"filterOriginAll": "All sources",
|
||||||
|
"filterOriginReceived": "Received",
|
||||||
|
"filterOriginGenerated": "Generated",
|
||||||
"filterStatus": "Status",
|
"filterStatus": "Status",
|
||||||
"filterStatusAll": "All statuses",
|
"filterStatusAll": "All statuses",
|
||||||
"filterStepCompleted": "Step completed",
|
"filterStepCompleted": "Step completed",
|
||||||
@@ -699,7 +705,8 @@
|
|||||||
"noAppointment": "No appointment",
|
"noAppointment": "No appointment",
|
||||||
"walkIn": "Walk-in",
|
"walkIn": "Walk-in",
|
||||||
"newTreatment": "New treatment",
|
"newTreatment": "New treatment",
|
||||||
"newWalkInTreatment": "Walk-in treatment",
|
"newTreatmentPatientPrompt": "Who is this visit for?",
|
||||||
|
"walkInPickerHint": "No named patient — always available, no search needed.",
|
||||||
"errorCreateTreatment": "Could not create treatment.",
|
"errorCreateTreatment": "Could not create treatment.",
|
||||||
"deleteEmptyTreatment": "Delete empty treatment",
|
"deleteEmptyTreatment": "Delete empty treatment",
|
||||||
"confirmDeleteEmptyTreatment": "Delete this treatment? It has no details yet.",
|
"confirmDeleteEmptyTreatment": "Delete this treatment? It has no details yet.",
|
||||||
|
|||||||
@@ -500,6 +500,8 @@
|
|||||||
"addCaseSubtitle": "کلینیک ارجاعدهنده یا بیمار را وارد کنید و خطوط پروتز را اضافه کنید. وقتی آماده بود، شروع کنید.",
|
"addCaseSubtitle": "کلینیک ارجاعدهنده یا بیمار را وارد کنید و خطوط پروتز را اضافه کنید. وقتی آماده بود، شروع کنید.",
|
||||||
"startCase": "شروع",
|
"startCase": "شروع",
|
||||||
"draftBadge": "پیشنویس",
|
"draftBadge": "پیشنویس",
|
||||||
|
"receivedBadge": "دریافتی",
|
||||||
|
"generatedBadge": "ایجادشده",
|
||||||
"referringClinic": "کلینیک ارجاعدهنده",
|
"referringClinic": "کلینیک ارجاعدهنده",
|
||||||
"referringDentist": "دندانپزشک",
|
"referringDentist": "دندانپزشک",
|
||||||
"patientName": "نام بیمار",
|
"patientName": "نام بیمار",
|
||||||
@@ -543,6 +545,10 @@
|
|||||||
"searchPlaceholder": "جستجوی بیمار یا کلینیک…",
|
"searchPlaceholder": "جستجوی بیمار یا کلینیک…",
|
||||||
"filterClinic": "کلینیک",
|
"filterClinic": "کلینیک",
|
||||||
"filterClinicAll": "همه کلینیکها",
|
"filterClinicAll": "همه کلینیکها",
|
||||||
|
"filterOrigin": "منبع پرونده",
|
||||||
|
"filterOriginAll": "همه منابع",
|
||||||
|
"filterOriginReceived": "دریافتی",
|
||||||
|
"filterOriginGenerated": "ایجادشده",
|
||||||
"filterStatus": "وضعیت",
|
"filterStatus": "وضعیت",
|
||||||
"filterStatusAll": "همه وضعیتها",
|
"filterStatusAll": "همه وضعیتها",
|
||||||
"filterStepCompleted": "مرحله تکمیلشده",
|
"filterStepCompleted": "مرحله تکمیلشده",
|
||||||
@@ -700,7 +706,8 @@
|
|||||||
"noAppointment": "بدون نوبت",
|
"noAppointment": "بدون نوبت",
|
||||||
"walkIn": "بدون نوبت (مراجع)",
|
"walkIn": "بدون نوبت (مراجع)",
|
||||||
"newTreatment": "درمان جدید",
|
"newTreatment": "درمان جدید",
|
||||||
"newWalkInTreatment": "درمان بدون نوبت",
|
"newTreatmentPatientPrompt": "این ویزیت برای چه کسی است؟",
|
||||||
|
"walkInPickerHint": "بیمار نامدار نیست — همیشه در دسترس است و نیازی به جستجو ندارد.",
|
||||||
"errorCreateTreatment": "ایجاد درمان ممکن نشد.",
|
"errorCreateTreatment": "ایجاد درمان ممکن نشد.",
|
||||||
"deleteEmptyTreatment": "حذف درمان خالی",
|
"deleteEmptyTreatment": "حذف درمان خالی",
|
||||||
"confirmDeleteEmptyTreatment": "این درمان حذف شود؟ هنوز جزئیاتی ندارد.",
|
"confirmDeleteEmptyTreatment": "این درمان حذف شود؟ هنوز جزئیاتی ندارد.",
|
||||||
|
|||||||
@@ -500,6 +500,8 @@
|
|||||||
"addCaseSubtitle": "Vul de verwijzende kliniek of patiënt in en voeg protheselijnen toe. Start wanneer de case klaar is.",
|
"addCaseSubtitle": "Vul de verwijzende kliniek of patiënt in en voeg protheselijnen toe. Start wanneer de case klaar is.",
|
||||||
"startCase": "Starten",
|
"startCase": "Starten",
|
||||||
"draftBadge": "Concept",
|
"draftBadge": "Concept",
|
||||||
|
"receivedBadge": "Ontvangen",
|
||||||
|
"generatedBadge": "Aangemaakt",
|
||||||
"referringClinic": "Verwijzende kliniek",
|
"referringClinic": "Verwijzende kliniek",
|
||||||
"referringDentist": "Tandarts",
|
"referringDentist": "Tandarts",
|
||||||
"patientName": "Patiëntnaam",
|
"patientName": "Patiëntnaam",
|
||||||
@@ -543,6 +545,10 @@
|
|||||||
"searchPlaceholder": "Zoek patiënt of kliniek…",
|
"searchPlaceholder": "Zoek patiënt of kliniek…",
|
||||||
"filterClinic": "Kliniek",
|
"filterClinic": "Kliniek",
|
||||||
"filterClinicAll": "Alle klinieken",
|
"filterClinicAll": "Alle klinieken",
|
||||||
|
"filterOrigin": "Bron van case",
|
||||||
|
"filterOriginAll": "Alle bronnen",
|
||||||
|
"filterOriginReceived": "Ontvangen",
|
||||||
|
"filterOriginGenerated": "Aangemaakt",
|
||||||
"filterStatus": "Status",
|
"filterStatus": "Status",
|
||||||
"filterStatusAll": "Alle statussen",
|
"filterStatusAll": "Alle statussen",
|
||||||
"filterStepCompleted": "Stap voltooid",
|
"filterStepCompleted": "Stap voltooid",
|
||||||
@@ -699,7 +705,8 @@
|
|||||||
"noAppointment": "Geen afspraak",
|
"noAppointment": "Geen afspraak",
|
||||||
"walkIn": "Inloop",
|
"walkIn": "Inloop",
|
||||||
"newTreatment": "Nieuwe behandeling",
|
"newTreatment": "Nieuwe behandeling",
|
||||||
"newWalkInTreatment": "Inloopbehandeling",
|
"newTreatmentPatientPrompt": "Voor wie is dit bezoek?",
|
||||||
|
"walkInPickerHint": "Geen benoemde patiënt — altijd beschikbaar, zonder zoeken.",
|
||||||
"errorCreateTreatment": "Behandeling aanmaken is mislukt.",
|
"errorCreateTreatment": "Behandeling aanmaken is mislukt.",
|
||||||
"deleteEmptyTreatment": "Lege behandeling verwijderen",
|
"deleteEmptyTreatment": "Lege behandeling verwijderen",
|
||||||
"confirmDeleteEmptyTreatment": "Deze behandeling verwijderen? Er zijn nog geen details.",
|
"confirmDeleteEmptyTreatment": "Deze behandeling verwijderen? Er zijn nog geen details.",
|
||||||
|
|||||||
5
frontend/src/components/lab/labCaseOrigin.ts
Normal file
5
frontend/src/components/lab/labCaseOrigin.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export type LabCaseOriginCode = 'CLINIC_DISPATCH' | 'LAB_INTERNAL';
|
||||||
|
|
||||||
|
export function isLabGeneratedCase(origin?: string | null): boolean {
|
||||||
|
return origin === 'LAB_INTERNAL';
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ export function parseTasksSearchParams(
|
|||||||
if (searchParams.get('unassignedOnly') === '1') {
|
if (searchParams.get('unassignedOnly') === '1') {
|
||||||
partial.unassignedOnly = true;
|
partial.unassignedOnly = true;
|
||||||
}
|
}
|
||||||
|
const origin = searchParams.get('origin');
|
||||||
|
if (origin === 'CLINIC_DISPATCH' || origin === 'LAB_INTERNAL') {
|
||||||
|
partial.origin = origin;
|
||||||
|
}
|
||||||
|
|
||||||
const status = searchParams.get('status');
|
const status = searchParams.get('status');
|
||||||
if (status === 'IN_PROGRESS' || status === 'COMPLETED') {
|
if (status === 'IN_PROGRESS' || status === 'COMPLETED') {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type CaseTaskGroup = {
|
|||||||
caseDueDate: string | null;
|
caseDueDate: string | null;
|
||||||
isCaseOverdue: boolean;
|
isCaseOverdue: boolean;
|
||||||
isImportant: boolean;
|
isImportant: boolean;
|
||||||
|
origin?: 'CLINIC_DISPATCH' | 'LAB_INTERNAL';
|
||||||
prosthesisGroups: ProsthesisTaskGroup[];
|
prosthesisGroups: ProsthesisTaskGroup[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ export function groupTasksForDisplay(
|
|||||||
caseDueDate: task.caseDueDate ?? null,
|
caseDueDate: task.caseDueDate ?? null,
|
||||||
isCaseOverdue: task.isCaseOverdue,
|
isCaseOverdue: task.isCaseOverdue,
|
||||||
isImportant: task.isImportant,
|
isImportant: task.isImportant,
|
||||||
|
origin: task.origin,
|
||||||
prosthesisGroups: [],
|
prosthesisGroups: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type TasksViewState = {
|
|||||||
assignedToMe: boolean;
|
assignedToMe: boolean;
|
||||||
overdueOnly: boolean;
|
overdueOnly: boolean;
|
||||||
unassignedOnly: boolean;
|
unassignedOnly: boolean;
|
||||||
|
origin: '' | 'CLINIC_DISPATCH' | 'LAB_INTERNAL';
|
||||||
prosthesisTypeCode: string;
|
prosthesisTypeCode: string;
|
||||||
page: number;
|
page: number;
|
||||||
highlightTaskId: string | null;
|
highlightTaskId: string | null;
|
||||||
@@ -27,6 +28,7 @@ export const DEFAULT_TASKS_VIEW: TasksViewState = {
|
|||||||
assignedToMe: false,
|
assignedToMe: false,
|
||||||
overdueOnly: false,
|
overdueOnly: false,
|
||||||
unassignedOnly: false,
|
unassignedOnly: false,
|
||||||
|
origin: '',
|
||||||
prosthesisTypeCode: '',
|
prosthesisTypeCode: '',
|
||||||
page: 1,
|
page: 1,
|
||||||
highlightTaskId: null,
|
highlightTaskId: null,
|
||||||
@@ -46,6 +48,7 @@ export function isDefaultTasksView(state: TasksViewState): boolean {
|
|||||||
state.assignedToMe === DEFAULT_TASKS_VIEW.assignedToMe &&
|
state.assignedToMe === DEFAULT_TASKS_VIEW.assignedToMe &&
|
||||||
state.overdueOnly === DEFAULT_TASKS_VIEW.overdueOnly &&
|
state.overdueOnly === DEFAULT_TASKS_VIEW.overdueOnly &&
|
||||||
state.unassignedOnly === DEFAULT_TASKS_VIEW.unassignedOnly &&
|
state.unassignedOnly === DEFAULT_TASKS_VIEW.unassignedOnly &&
|
||||||
|
state.origin === DEFAULT_TASKS_VIEW.origin &&
|
||||||
state.prosthesisTypeCode === DEFAULT_TASKS_VIEW.prosthesisTypeCode &&
|
state.prosthesisTypeCode === DEFAULT_TASKS_VIEW.prosthesisTypeCode &&
|
||||||
state.page === DEFAULT_TASKS_VIEW.page &&
|
state.page === DEFAULT_TASKS_VIEW.page &&
|
||||||
state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId
|
state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '@/components/ui/lab/LabCaseShareQrDialog';
|
} from '@/components/ui/lab/LabCaseShareQrDialog';
|
||||||
import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay';
|
import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay';
|
||||||
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
||||||
|
import { LabCaseOriginBadge } from '@/components/ui/lab/LabCaseOriginBadge';
|
||||||
import {
|
import {
|
||||||
formatToothList,
|
formatToothList,
|
||||||
prosthesisTypeBadgeStyleFromCatalog,
|
prosthesisTypeBadgeStyleFromCatalog,
|
||||||
@@ -165,6 +166,7 @@ export function CaseDetailPanel({
|
|||||||
{formatPatientName(labCase.patient)}
|
{formatPatientName(labCase.patient)}
|
||||||
</h2>
|
</h2>
|
||||||
<LabCaseDueDateBadge dueDate={labCase.dueDate} locale={locale} />
|
<LabCaseDueDateBadge dueDate={labCase.dueDate} locale={locale} />
|
||||||
|
<LabCaseOriginBadge origin={labCase.origin} />
|
||||||
</div>
|
</div>
|
||||||
{!canEditImportant && labCase.isImportant ? (
|
{!canEditImportant && labCase.isImportant ? (
|
||||||
<Badge variant="warning" fixedWidth={false} className="mt-1">
|
<Badge variant="warning" fixedWidth={false} className="mt-1">
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
formatPatientName,
|
formatPatientName,
|
||||||
} from '@/components/lab/caseDetailUtils';
|
} from '@/components/lab/caseDetailUtils';
|
||||||
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
||||||
|
import { LabCaseOriginBadge } from '@/components/ui/lab/LabCaseOriginBadge';
|
||||||
|
import { isLabGeneratedCase } from '@/components/lab/labCaseOrigin';
|
||||||
import { notificationsApi } from '@/lib/api/notifications';
|
import { notificationsApi } from '@/lib/api/notifications';
|
||||||
import { notifyTabBadgesChanged, tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
|
import { notifyTabBadgesChanged, tabBadgesChangedEventName } from '@/lib/tabBadgeUtils';
|
||||||
import { casesApi } from '@/lib/api/cases';
|
import { casesApi } from '@/lib/api/cases';
|
||||||
@@ -529,6 +531,7 @@ export function CasesPage() {
|
|||||||
{t('importantLabel')}
|
{t('importantLabel')}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
<LabCaseOriginBadge origin={item.origin} className="text-[10px]" />
|
||||||
{isDraftCase(item) ? (
|
{isDraftCase(item) ? (
|
||||||
<Badge variant="default" fixedWidth={false}>
|
<Badge variant="default" fixedWidth={false}>
|
||||||
{t('draftBadge')}
|
{t('draftBadge')}
|
||||||
@@ -694,6 +697,7 @@ export function CasesPage() {
|
|||||||
viewerSide="LAB"
|
viewerSide="LAB"
|
||||||
canPost={canEditComments}
|
canPost={canEditComments}
|
||||||
canToggleVisibility={canEditComments}
|
canToggleVisibility={canEditComments}
|
||||||
|
clinicVisibility={!isLabGeneratedCase(selectedCase.origin)}
|
||||||
loadComments={async () => {
|
loadComments={async () => {
|
||||||
const r = await tasksApi.listComments(selectedCaseId);
|
const r = await tasksApi.listComments(selectedCaseId);
|
||||||
setCommentCount(r.data.length);
|
setCommentCount(r.data.length);
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ interface LabCaseCommentsPanelProps {
|
|||||||
viewerSide: LabCaseCommentViewerSide;
|
viewerSide: LabCaseCommentViewerSide;
|
||||||
canPost: boolean;
|
canPost: boolean;
|
||||||
canToggleVisibility: boolean;
|
canToggleVisibility: boolean;
|
||||||
|
/**
|
||||||
|
* When false (lab-generated cases with no clinic send), hide clinic-visibility
|
||||||
|
* controls and status. Defaults to true for clinic-sent cases.
|
||||||
|
*/
|
||||||
|
clinicVisibility?: boolean;
|
||||||
loadComments: () => Promise<LabCaseComment[]>;
|
loadComments: () => Promise<LabCaseComment[]>;
|
||||||
onPost: (body: string, visibleToClinic?: boolean) => Promise<LabCaseComment>;
|
onPost: (body: string, visibleToClinic?: boolean) => Promise<LabCaseComment>;
|
||||||
onToggleVisibility?: (commentId: string, visible: boolean) => Promise<LabCaseComment>;
|
onToggleVisibility?: (commentId: string, visible: boolean) => Promise<LabCaseComment>;
|
||||||
@@ -38,6 +43,7 @@ export function LabCaseCommentsPanel({
|
|||||||
viewerSide,
|
viewerSide,
|
||||||
canPost,
|
canPost,
|
||||||
canToggleVisibility,
|
canToggleVisibility,
|
||||||
|
clinicVisibility = true,
|
||||||
loadComments,
|
loadComments,
|
||||||
onPost,
|
onPost,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
@@ -55,6 +61,8 @@ export function LabCaseCommentsPanel({
|
|||||||
const [visibleToClinic, setVisibleToClinic] = useState(false);
|
const [visibleToClinic, setVisibleToClinic] = useState(false);
|
||||||
const toggleBusy = useAsyncActionById();
|
const toggleBusy = useAsyncActionById();
|
||||||
|
|
||||||
|
const allowClinicVisibility = canToggleVisibility && clinicVisibility;
|
||||||
|
|
||||||
const orderedComments = useMemo(() => sortNewestFirst(comments), [comments]);
|
const orderedComments = useMemo(() => sortNewestFirst(comments), [comments]);
|
||||||
const loadCommentsRef = useRef(loadComments);
|
const loadCommentsRef = useRef(loadComments);
|
||||||
loadCommentsRef.current = loadComments;
|
loadCommentsRef.current = loadComments;
|
||||||
@@ -80,7 +88,7 @@ export function LabCaseCommentsPanel({
|
|||||||
if (!trimmed || !canPost || posting) return;
|
if (!trimmed || !canPost || posting) return;
|
||||||
setPosting(true);
|
setPosting(true);
|
||||||
try {
|
try {
|
||||||
const created = await onPost(trimmed, visibleToClinic);
|
const created = await onPost(trimmed, clinicVisibility ? visibleToClinic : false);
|
||||||
setComments((prev) => sortNewestFirst([created, ...prev]));
|
setComments((prev) => sortNewestFirst([created, ...prev]));
|
||||||
setBody('');
|
setBody('');
|
||||||
setVisibleToClinic(false);
|
setVisibleToClinic(false);
|
||||||
@@ -99,7 +107,7 @@ export function LabCaseCommentsPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggle(comment: LabCaseComment) {
|
async function handleToggle(comment: LabCaseComment) {
|
||||||
if (!onToggleVisibility || !canToggleVisibility) return;
|
if (!onToggleVisibility || !allowClinicVisibility) return;
|
||||||
await toggleBusy.run(comment.id, async () => {
|
await toggleBusy.run(comment.id, async () => {
|
||||||
try {
|
try {
|
||||||
const updated = await onToggleVisibility(comment.id, !comment.visibleToClinic);
|
const updated = await onToggleVisibility(comment.id, !comment.visibleToClinic);
|
||||||
@@ -142,14 +150,14 @@ export function LabCaseCommentsPanel({
|
|||||||
{comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')}
|
{comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')}
|
||||||
{comment.authorName ? ` · ${comment.authorName}` : ''}
|
{comment.authorName ? ` · ${comment.authorName}` : ''}
|
||||||
</span>
|
</span>
|
||||||
{comment.showVisibilityStatus !== false ? (
|
{clinicVisibility && comment.showVisibilityStatus !== false ? (
|
||||||
comment.visibleToClinic ? (
|
comment.visibleToClinic ? (
|
||||||
<span className="text-primary">{t('clinicCanSee')}</span>
|
<span className="text-primary">{t('clinicCanSee')}</span>
|
||||||
) : (
|
) : (
|
||||||
<span>{t('hiddenFromClinic')}</span>
|
<span>{t('hiddenFromClinic')}</span>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
{canToggleVisibility && comment.canToggleVisibility && onToggleVisibility ? (
|
{allowClinicVisibility && comment.canToggleVisibility && onToggleVisibility ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleToggle(comment)}
|
onClick={() => handleToggle(comment)}
|
||||||
@@ -198,7 +206,7 @@ export function LabCaseCommentsPanel({
|
|||||||
className={composerInputClass}
|
className={composerInputClass}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{canToggleVisibility ? (
|
{allowClinicVisibility ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setVisibleToClinic((v) => !v)}
|
onClick={() => setVisibleToClinic((v) => !v)}
|
||||||
|
|||||||
25
frontend/src/components/ui/lab/LabCaseOriginBadge.tsx
Normal file
25
frontend/src/components/ui/lab/LabCaseOriginBadge.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
|
import { isLabGeneratedCase } from '@/components/lab/labCaseOrigin';
|
||||||
|
|
||||||
|
interface LabCaseOriginBadgeProps {
|
||||||
|
origin?: string | null;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LabCaseOriginBadge({ origin, className }: LabCaseOriginBadgeProps) {
|
||||||
|
const t = useTranslations('cases');
|
||||||
|
const generated = isLabGeneratedCase(origin);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={generated ? 'default' : 'success'}
|
||||||
|
fixedWidth={false}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{generated ? t('generatedBadge') : t('receivedBadge')}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { MessageSquare } from 'lucide-react';
|
import { MessageSquare } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/shared/Badge';
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
||||||
|
import { LabCaseOriginBadge } from '@/components/ui/lab/LabCaseOriginBadge';
|
||||||
import type { CaseTaskGroup } from '@/components/lab/taskListGrouping';
|
import type { CaseTaskGroup } from '@/components/lab/taskListGrouping';
|
||||||
import { countCaseTaskProgress } from '@/components/lab/taskListGrouping';
|
import { countCaseTaskProgress } from '@/components/lab/taskListGrouping';
|
||||||
import { formatAppDate, APP_DATE } from '@/lib/i18n/format';
|
import { formatAppDate, APP_DATE } from '@/lib/i18n/format';
|
||||||
@@ -52,6 +53,7 @@ export function TaskCaseGroupHeader({
|
|||||||
{t('importantBadge')}
|
{t('importantBadge')}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
<LabCaseOriginBadge origin={caseGroup.origin} className="text-[10px]" />
|
||||||
</div>
|
</div>
|
||||||
{sentLabel ? (
|
{sentLabel ? (
|
||||||
<p className="text-[11px] text-text-muted">
|
<p className="text-[11px] text-text-muted">
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
labTaskStatusVariant,
|
labTaskStatusVariant,
|
||||||
} from '@/components/lab/labTaskStatusDisplay';
|
} from '@/components/lab/labTaskStatusDisplay';
|
||||||
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
|
||||||
|
import { LabCaseOriginBadge } from '@/components/ui/lab/LabCaseOriginBadge';
|
||||||
|
import { isLabGeneratedCase } from '@/components/lab/labCaseOrigin';
|
||||||
import { formatAppDate, APP_DATE } from '@/lib/i18n/format';
|
import { formatAppDate, APP_DATE } from '@/lib/i18n/format';
|
||||||
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils';
|
||||||
import {
|
import {
|
||||||
@@ -153,6 +155,9 @@ export function TaskRow({
|
|||||||
{t('importantBadge')}
|
{t('importantBadge')}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
{flatMode && !exiting ? (
|
||||||
|
<LabCaseOriginBadge origin={task.origin} className="text-[10px]" />
|
||||||
|
) : null}
|
||||||
{flatMode && task.caseDueDate && !exiting ? (
|
{flatMode && task.caseDueDate && !exiting ? (
|
||||||
<LabCaseDueDateBadge
|
<LabCaseDueDateBadge
|
||||||
dueDate={task.caseDueDate}
|
dueDate={task.caseDueDate}
|
||||||
@@ -220,6 +225,7 @@ export function TaskRow({
|
|||||||
viewerSide="LAB"
|
viewerSide="LAB"
|
||||||
canPost
|
canPost
|
||||||
canToggleVisibility
|
canToggleVisibility
|
||||||
|
clinicVisibility={!isLabGeneratedCase(task.origin)}
|
||||||
loadComments={async () => {
|
loadComments={async () => {
|
||||||
const r = await tasksApi.listComments(task.labCaseId);
|
const r = await tasksApi.listComments(task.labCaseId);
|
||||||
return r.data;
|
return r.data;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { TaskProsthesisGroupHeader } from '@/components/ui/lab/TaskProsthesisGro
|
|||||||
import { TaskRow } from '@/components/ui/lab/TaskRow';
|
import { TaskRow } from '@/components/ui/lab/TaskRow';
|
||||||
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||||||
import { groupTasksForDisplay } from '@/components/lab/taskListGrouping';
|
import { groupTasksForDisplay } from '@/components/lab/taskListGrouping';
|
||||||
|
import { isLabGeneratedCase } from '@/components/lab/labCaseOrigin';
|
||||||
import {
|
import {
|
||||||
buildDefaultLocateParams,
|
buildDefaultLocateParams,
|
||||||
DEFAULT_TASKS_VIEW,
|
DEFAULT_TASKS_VIEW,
|
||||||
@@ -77,6 +78,7 @@ export function TasksPage() {
|
|||||||
const [assignedToMe, setAssignedToMe] = useState(DEFAULT_TASKS_VIEW.assignedToMe);
|
const [assignedToMe, setAssignedToMe] = useState(DEFAULT_TASKS_VIEW.assignedToMe);
|
||||||
const [overdueOnly, setOverdueOnly] = useState(DEFAULT_TASKS_VIEW.overdueOnly);
|
const [overdueOnly, setOverdueOnly] = useState(DEFAULT_TASKS_VIEW.overdueOnly);
|
||||||
const [unassignedOnly, setUnassignedOnly] = useState(DEFAULT_TASKS_VIEW.unassignedOnly);
|
const [unassignedOnly, setUnassignedOnly] = useState(DEFAULT_TASKS_VIEW.unassignedOnly);
|
||||||
|
const [originFilter, setOriginFilter] = useState(DEFAULT_TASKS_VIEW.origin);
|
||||||
const [prosthesisTypeCode, setProsthesisTypeCode] = useState(
|
const [prosthesisTypeCode, setProsthesisTypeCode] = useState(
|
||||||
DEFAULT_TASKS_VIEW.prosthesisTypeCode,
|
DEFAULT_TASKS_VIEW.prosthesisTypeCode,
|
||||||
);
|
);
|
||||||
@@ -118,9 +120,10 @@ export function TasksPage() {
|
|||||||
if (assignedToMe) params.assignedToMe = true;
|
if (assignedToMe) params.assignedToMe = true;
|
||||||
if (overdueOnly) params.overdue = true;
|
if (overdueOnly) params.overdue = true;
|
||||||
if (unassignedOnly) params.unassignedOnly = true;
|
if (unassignedOnly) params.unassignedOnly = true;
|
||||||
|
if (originFilter) params.origin = originFilter;
|
||||||
if (prosthesisTypeCode) params.prosthesisTypeCode = prosthesisTypeCode;
|
if (prosthesisTypeCode) params.prosthesisTypeCode = prosthesisTypeCode;
|
||||||
return params;
|
return params;
|
||||||
}, [page, search, clinicId, statusFilter, stepCompleted, importantOnly, assignedToMe, overdueOnly, unassignedOnly, prosthesisTypeCode, sortBy, sortDir]);
|
}, [page, search, clinicId, statusFilter, stepCompleted, importantOnly, assignedToMe, overdueOnly, unassignedOnly, originFilter, prosthesisTypeCode, sortBy, sortDir]);
|
||||||
|
|
||||||
const displayModel = useMemo(() => groupTasksForDisplay(tasks, sortBy), [tasks, sortBy]);
|
const displayModel = useMemo(() => groupTasksForDisplay(tasks, sortBy), [tasks, sortBy]);
|
||||||
|
|
||||||
@@ -138,6 +141,7 @@ export function TasksPage() {
|
|||||||
assignedToMe,
|
assignedToMe,
|
||||||
overdueOnly,
|
overdueOnly,
|
||||||
unassignedOnly,
|
unassignedOnly,
|
||||||
|
origin: originFilter,
|
||||||
prosthesisTypeCode,
|
prosthesisTypeCode,
|
||||||
page,
|
page,
|
||||||
highlightTaskId,
|
highlightTaskId,
|
||||||
@@ -153,6 +157,7 @@ export function TasksPage() {
|
|||||||
assignedToMe,
|
assignedToMe,
|
||||||
overdueOnly,
|
overdueOnly,
|
||||||
unassignedOnly,
|
unassignedOnly,
|
||||||
|
originFilter,
|
||||||
prosthesisTypeCode,
|
prosthesisTypeCode,
|
||||||
page,
|
page,
|
||||||
highlightTaskId,
|
highlightTaskId,
|
||||||
@@ -166,6 +171,7 @@ export function TasksPage() {
|
|||||||
if (fromUrl.importantOnly !== undefined) setImportantOnly(fromUrl.importantOnly);
|
if (fromUrl.importantOnly !== undefined) setImportantOnly(fromUrl.importantOnly);
|
||||||
if (fromUrl.overdueOnly !== undefined) setOverdueOnly(fromUrl.overdueOnly);
|
if (fromUrl.overdueOnly !== undefined) setOverdueOnly(fromUrl.overdueOnly);
|
||||||
if (fromUrl.unassignedOnly !== undefined) setUnassignedOnly(fromUrl.unassignedOnly);
|
if (fromUrl.unassignedOnly !== undefined) setUnassignedOnly(fromUrl.unassignedOnly);
|
||||||
|
if (fromUrl.origin !== undefined) setOriginFilter(fromUrl.origin);
|
||||||
if (fromUrl.statusFilter !== undefined) setStatusFilter(fromUrl.statusFilter);
|
if (fromUrl.statusFilter !== undefined) setStatusFilter(fromUrl.statusFilter);
|
||||||
if (fromUrl.prosthesisTypeCode !== undefined) setProsthesisTypeCode(fromUrl.prosthesisTypeCode);
|
if (fromUrl.prosthesisTypeCode !== undefined) setProsthesisTypeCode(fromUrl.prosthesisTypeCode);
|
||||||
if (fromUrl.sortBy !== undefined) setSortBy(fromUrl.sortBy);
|
if (fromUrl.sortBy !== undefined) setSortBy(fromUrl.sortBy);
|
||||||
@@ -273,6 +279,7 @@ export function TasksPage() {
|
|||||||
setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe);
|
setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe);
|
||||||
setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly);
|
setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly);
|
||||||
setUnassignedOnly(DEFAULT_TASKS_VIEW.unassignedOnly);
|
setUnassignedOnly(DEFAULT_TASKS_VIEW.unassignedOnly);
|
||||||
|
setOriginFilter(DEFAULT_TASKS_VIEW.origin);
|
||||||
setProsthesisTypeCode(DEFAULT_TASKS_VIEW.prosthesisTypeCode);
|
setProsthesisTypeCode(DEFAULT_TASKS_VIEW.prosthesisTypeCode);
|
||||||
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
|
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
|
||||||
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
|
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
|
||||||
@@ -293,6 +300,7 @@ export function TasksPage() {
|
|||||||
setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe);
|
setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe);
|
||||||
setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly);
|
setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly);
|
||||||
setUnassignedOnly(DEFAULT_TASKS_VIEW.unassignedOnly);
|
setUnassignedOnly(DEFAULT_TASKS_VIEW.unassignedOnly);
|
||||||
|
setOriginFilter(DEFAULT_TASKS_VIEW.origin);
|
||||||
setProsthesisTypeCode(DEFAULT_TASKS_VIEW.prosthesisTypeCode);
|
setProsthesisTypeCode(DEFAULT_TASKS_VIEW.prosthesisTypeCode);
|
||||||
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
|
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
|
||||||
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
|
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
|
||||||
@@ -440,7 +448,7 @@ export function TasksPage() {
|
|||||||
placeholder={t('searchPlaceholder')}
|
placeholder={t('searchPlaceholder')}
|
||||||
/>
|
/>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<div className="grid min-w-[44rem] grid-cols-4 gap-2">
|
<div className="grid min-w-[54rem] grid-cols-5 gap-2">
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
|
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
|
||||||
<select
|
<select
|
||||||
@@ -456,6 +464,22 @@ export function TasksPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-xs text-text-muted">{t('filterOrigin')}</span>
|
||||||
|
<select
|
||||||
|
value={originFilter}
|
||||||
|
onChange={(e) =>
|
||||||
|
applyFilterChange(() =>
|
||||||
|
setOriginFilter(e.target.value as '' | 'CLINIC_DISPATCH' | 'LAB_INTERNAL'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={filterSelectClass}
|
||||||
|
>
|
||||||
|
<option value="">{t('filterOriginAll')}</option>
|
||||||
|
<option value="CLINIC_DISPATCH">{t('filterOriginReceived')}</option>
|
||||||
|
<option value="LAB_INTERNAL">{t('filterOriginGenerated')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
<span className="text-xs text-text-muted">{t('filterStatus')}</span>
|
<span className="text-xs text-text-muted">{t('filterStatus')}</span>
|
||||||
<select
|
<select
|
||||||
@@ -581,6 +605,7 @@ export function TasksPage() {
|
|||||||
viewerSide="LAB"
|
viewerSide="LAB"
|
||||||
canPost
|
canPost
|
||||||
canToggleVisibility
|
canToggleVisibility
|
||||||
|
clinicVisibility={!isLabGeneratedCase(caseGroup.origin)}
|
||||||
loadComments={async () => {
|
loadComments={async () => {
|
||||||
const r = await tasksApi.listComments(caseGroup.labCaseId);
|
const r = await tasksApi.listComments(caseGroup.labCaseId);
|
||||||
return r.data;
|
return r.data;
|
||||||
|
|||||||
@@ -16,6 +16,25 @@ interface DayStripCardProps {
|
|||||||
deleteAriaLabel?: string;
|
deleteAriaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StripCardBody({ item }: { item: DayStripItem }) {
|
||||||
|
const metaClass = item.kind === 'appointment' ? 'opacity-95' : '';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{item.timeLabel ? (
|
||||||
|
<p className={`text-[11px] font-medium tabular-nums ${metaClass}`}>{item.timeLabel}</p>
|
||||||
|
) : (
|
||||||
|
<p className={`text-[11px] font-medium ${metaClass}`}>{item.subtitle}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm font-medium leading-tight truncate mt-0.5">
|
||||||
|
{item.patientFirstName} {item.patientLastName}
|
||||||
|
</p>
|
||||||
|
{item.timeLabel ? (
|
||||||
|
<p className={`text-[11px] mt-0.5 truncate ${metaClass}`}>{item.subtitle}</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DayStripCard({
|
export function DayStripCard({
|
||||||
item,
|
item,
|
||||||
selected,
|
selected,
|
||||||
@@ -25,34 +44,60 @@ export function DayStripCard({
|
|||||||
onDelete,
|
onDelete,
|
||||||
deleteAriaLabel,
|
deleteAriaLabel,
|
||||||
}: DayStripCardProps) {
|
}: DayStripCardProps) {
|
||||||
|
const shellClass = `
|
||||||
|
text-start w-full sm:w-auto sm:min-w-[200px] sm:max-w-[280px] min-h-[52px]
|
||||||
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (item.kind === 'unscheduled') {
|
||||||
|
const selectedClass = selected
|
||||||
|
? 'border-primary bg-primary-soft'
|
||||||
|
: 'border-border/70 hover:border-border hover:bg-background-card/50';
|
||||||
|
const labelClass = selected ? 'font-medium text-text-primary' : 'text-text-secondary';
|
||||||
|
const trashClass = selected
|
||||||
|
? 'border-primary/30 text-text-muted hover:bg-red-500/15 hover:text-red-600'
|
||||||
|
: 'border-border/60 text-text-muted hover:bg-red-500/15 hover:text-red-600';
|
||||||
|
const showDelete = Boolean(canDelete && onDelete);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] border ${selectedClass} ${shellClass}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelect}
|
||||||
|
className={`min-w-0 flex-1 text-start px-3 py-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/45 ${labelClass}`}
|
||||||
|
>
|
||||||
|
<StripCardBody item={item} />
|
||||||
|
</button>
|
||||||
|
{showDelete ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDelete?.();
|
||||||
|
}}
|
||||||
|
aria-label={deleteAriaLabel}
|
||||||
|
title={deleteAriaLabel}
|
||||||
|
className={`
|
||||||
|
shrink-0 inline-flex items-center justify-center border-s px-2 transition-colors
|
||||||
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500/40
|
||||||
|
${trashClass}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const purposeIndex = treatmentCatalog.findIndex((e) => e.code === item.colorCode);
|
const purposeIndex = treatmentCatalog.findIndex((e) => e.code === item.colorCode);
|
||||||
const bannerStyle = treatmentTypeBannerStyle(item.colorCode, purposeIndex < 0 ? 0 : purposeIndex);
|
const bannerStyle = treatmentTypeBannerStyle(item.colorCode, purposeIndex < 0 ? 0 : purposeIndex);
|
||||||
const selectedClass = selected
|
const selectedClass = selected
|
||||||
? 'ring-2 ring-primary ring-offset-2 ring-offset-background-secondary shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]'
|
? 'ring-2 ring-primary ring-offset-2 ring-offset-background-secondary shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]'
|
||||||
: 'hover:brightness-110';
|
: 'hover:brightness-110';
|
||||||
const shellClass = `
|
|
||||||
text-start rounded-[var(--radius-sm)] w-full sm:w-auto sm:min-w-[200px] sm:max-w-[280px] transition-shadow min-h-[52px]
|
|
||||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
|
|
||||||
${selectedClass}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const body = (
|
|
||||||
<>
|
|
||||||
{item.timeLabel ? (
|
|
||||||
<p className="text-[11px] font-medium tabular-nums opacity-95">{item.timeLabel}</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-[11px] font-medium opacity-95">{item.subtitle}</p>
|
|
||||||
)}
|
|
||||||
<p className="text-sm font-medium leading-tight truncate mt-0.5">
|
|
||||||
{item.patientFirstName} {item.patientLastName}
|
|
||||||
</p>
|
|
||||||
{item.timeLabel ? (
|
|
||||||
<p className="text-[11px] opacity-90 mt-0.5 truncate">{item.subtitle}</p>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!canDelete || !onDelete) {
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
as="button"
|
as="button"
|
||||||
@@ -60,38 +105,9 @@ export function DayStripCard({
|
|||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
padding="none"
|
padding="none"
|
||||||
style={bannerStyle}
|
style={bannerStyle}
|
||||||
className={`${shellClass} px-3 py-2`}
|
className={`${shellClass} rounded-[var(--radius-sm)] transition-shadow px-3 py-2 ${selectedClass}`}
|
||||||
>
|
>
|
||||||
{body}
|
<StripCardBody item={item} />
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
padding="none"
|
|
||||||
style={bannerStyle}
|
|
||||||
className={`${shellClass} flex items-stretch overflow-hidden`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onSelect}
|
|
||||||
className="min-w-0 flex-1 text-start px-3 py-2 focus:outline-none"
|
|
||||||
>
|
|
||||||
{body}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onDelete();
|
|
||||||
}}
|
|
||||||
aria-label={deleteAriaLabel}
|
|
||||||
title={deleteAriaLabel}
|
|
||||||
className="shrink-0 self-center pe-2.5 ps-1 opacity-80 hover:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 rounded-[var(--radius-sm)] text-inherit"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-inherit" aria-hidden />
|
|
||||||
</button>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
|
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
|
||||||
|
import { usePatientSearchQuery } from '@/lib/hooks/usePatientSearchQuery';
|
||||||
|
import type { Patient } from '@/types/patient';
|
||||||
|
|
||||||
|
interface NewTreatmentPatientPickerProps {
|
||||||
|
creating?: boolean;
|
||||||
|
onSelectWalkIn: () => void | Promise<void>;
|
||||||
|
onSelectPatient: (patient: Patient) => void | Promise<void>;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewTreatmentPatientPicker({
|
||||||
|
creating = false,
|
||||||
|
onSelectWalkIn,
|
||||||
|
onSelectPatient,
|
||||||
|
onCancel,
|
||||||
|
}: NewTreatmentPatientPickerProps) {
|
||||||
|
const t = useTranslations('treatment');
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
|
const tPatients = useTranslations('patients');
|
||||||
|
const { search, setSearch, patients, loading } = usePatientSearchQuery(!creating);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 rounded-[var(--radius-md)] border border-border/70 bg-background-secondary/40 p-3">
|
||||||
|
<p className="text-sm font-medium text-text-primary">{t('newTreatmentPatientPrompt')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={creating}
|
||||||
|
onClick={() => void onSelectWalkIn()}
|
||||||
|
className="w-full rounded-[var(--radius-md)] border border-primary/40 bg-primary-soft px-3 py-2 text-start transition-colors hover:border-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-text-primary">{t('walkIn')}</p>
|
||||||
|
<p className="text-xs text-text-muted mt-0.5">{t('walkInPickerHint')}</p>
|
||||||
|
</button>
|
||||||
|
<PatientSearchCombobox
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
patients={patients}
|
||||||
|
loading={loading || creating}
|
||||||
|
onSelectPatient={(patient) => {
|
||||||
|
if (creating) return;
|
||||||
|
void onSelectPatient(patient);
|
||||||
|
}}
|
||||||
|
placeholder={tPatients('searchPlaceholder')}
|
||||||
|
emptyResultsMessage={tPatients('noResults')}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="button" variant="ghost" size="sm" disabled={creating} onClick={onCancel}>
|
||||||
|
{tCommon('cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '@/components/ui/treatment/TreatmentLabCasesPanel';
|
} from '@/components/ui/treatment/TreatmentLabCasesPanel';
|
||||||
import { TreatmentRailSection } from '@/components/ui/treatment/TreatmentRailSection';
|
import { TreatmentRailSection } from '@/components/ui/treatment/TreatmentRailSection';
|
||||||
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
|
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
|
||||||
|
import { NewTreatmentPatientPicker } from '@/components/ui/treatment/NewTreatmentPatientPicker';
|
||||||
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
||||||
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
|
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
|
||||||
import { LabShipmentBlockedNotice } from '@/components/ui/treatment/LabShipmentBlockedNotice';
|
import { LabShipmentBlockedNotice } from '@/components/ui/treatment/LabShipmentBlockedNotice';
|
||||||
@@ -370,6 +371,8 @@ export function TreatmentWorkspace({
|
|||||||
'id' | 'firstName' | 'lastName'
|
'id' | 'firstName' | 'lastName'
|
||||||
> | null>(null);
|
> | null>(null);
|
||||||
const [patientSearchBusy, setPatientSearchBusy] = useState(false);
|
const [patientSearchBusy, setPatientSearchBusy] = useState(false);
|
||||||
|
const [newTreatmentPickerOpen, setNewTreatmentPickerOpen] = useState(false);
|
||||||
|
const [creatingStandalone, setCreatingStandalone] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
search: patientSearch,
|
search: patientSearch,
|
||||||
@@ -680,12 +683,16 @@ export function TreatmentWorkspace({
|
|||||||
|
|
||||||
const hydrateFromTreatment = useCallback((treatment: PastTreatment) => {
|
const hydrateFromTreatment = useCallback((treatment: PastTreatment) => {
|
||||||
const mapped = treatment.details.map(mapDetailFromApi);
|
const mapped = treatment.details.map(mapDetailFromApi);
|
||||||
setDetails(mapped);
|
const nextDetails =
|
||||||
|
mapped.length > 0
|
||||||
|
? mapped
|
||||||
|
: [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))];
|
||||||
|
setDetails(nextDetails);
|
||||||
setActiveDetailId((prev) => {
|
setActiveDetailId((prev) => {
|
||||||
const stillExists = mapped.some((d) => d.clientId === prev);
|
const stillExists = nextDetails.some((d) => d.clientId === prev);
|
||||||
return stillExists ? prev : mapped[0]?.clientId ?? prev;
|
return stillExists ? prev : nextDetails[0].clientId;
|
||||||
});
|
});
|
||||||
setSavedSnapshot(serializeDetails(mapped));
|
setSavedSnapshot(serializeDetails(nextDetails));
|
||||||
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
||||||
(treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
|
(treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
|
||||||
);
|
);
|
||||||
@@ -693,7 +700,7 @@ export function TreatmentWorkspace({
|
|||||||
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
|
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
|
||||||
setOrganizationSearch('');
|
setOrganizationSearch('');
|
||||||
setSaveStatus('idle');
|
setSaveStatus('idle');
|
||||||
}, []);
|
}, [treatmentCatalog]);
|
||||||
|
|
||||||
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
|
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
|
||||||
const connectedSelectedTeeth = useMemo(
|
const connectedSelectedTeeth = useMemo(
|
||||||
@@ -1254,14 +1261,18 @@ export function TreatmentWorkspace({
|
|||||||
|
|
||||||
const createStandaloneTreatment = useCallback(
|
const createStandaloneTreatment = useCallback(
|
||||||
async (opts: { patientId?: string; walkIn?: boolean }) => {
|
async (opts: { patientId?: string; walkIn?: boolean }) => {
|
||||||
|
if (creatingStandalone) return;
|
||||||
const ok = await flushDraftSave();
|
const ok = await flushDraftSave();
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
setCreatingStandalone(true);
|
||||||
try {
|
try {
|
||||||
const created = await treatmentsApi.createStandalone({
|
const created = await treatmentsApi.createStandalone({
|
||||||
...opts,
|
...opts,
|
||||||
treatmentAt: selectedDay.toISOString(),
|
treatmentAt: selectedDay.toISOString(),
|
||||||
});
|
});
|
||||||
resetToLiveContext();
|
resetToLiveContext();
|
||||||
|
setSearchedPatient(null);
|
||||||
|
setNewTreatmentPickerOpen(false);
|
||||||
setSelectionLocked(true);
|
setSelectionLocked(true);
|
||||||
setSelectedAppointmentId(null);
|
setSelectedAppointmentId(null);
|
||||||
setSelectedStandaloneId(created.data.id);
|
setSelectedStandaloneId(created.data.id);
|
||||||
@@ -1269,15 +1280,29 @@ export function TreatmentWorkspace({
|
|||||||
prev.some((row) => row.id === created.data.id) ? prev : [...prev, created.data],
|
prev.some((row) => row.id === created.data.id) ? prev : [...prev, created.data],
|
||||||
);
|
);
|
||||||
skipNextGetDraftRef.current = true;
|
skipNextGetDraftRef.current = true;
|
||||||
|
draftHydratingRef.current = true;
|
||||||
hydrateFromTreatment(created.data);
|
hydrateFromTreatment(created.data);
|
||||||
|
draftHydratingRef.current = false;
|
||||||
if (created.data.patientId && !created.data.patient?.isWalkIn) {
|
if (created.data.patientId && !created.data.patient?.isWalkIn) {
|
||||||
await refreshHistory(created.data.patientId);
|
await refreshHistory(created.data.patientId);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
showError(getUserFacingError(error, tErrors, t('errorCreateTreatment')));
|
showError(getUserFacingError(error, tErrors, t('errorCreateTreatment')));
|
||||||
|
} finally {
|
||||||
|
setCreatingStandalone(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[flushDraftSave, selectedDay, resetToLiveContext, hydrateFromTreatment, refreshHistory, showError, t, tErrors],
|
[
|
||||||
|
creatingStandalone,
|
||||||
|
flushDraftSave,
|
||||||
|
selectedDay,
|
||||||
|
resetToLiveContext,
|
||||||
|
hydrateFromTreatment,
|
||||||
|
refreshHistory,
|
||||||
|
showError,
|
||||||
|
t,
|
||||||
|
tErrors,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteStandaloneTreatment = useCallback(
|
const deleteStandaloneTreatment = useCallback(
|
||||||
@@ -1320,6 +1345,7 @@ export function TreatmentWorkspace({
|
|||||||
const patientIdToRefresh = historyPatientId;
|
const patientIdToRefresh = historyPatientId;
|
||||||
resetToLiveContext();
|
resetToLiveContext();
|
||||||
setSearchedPatient(null);
|
setSearchedPatient(null);
|
||||||
|
setNewTreatmentPickerOpen(false);
|
||||||
setSelectionLocked(false);
|
setSelectionLocked(false);
|
||||||
setSelectedAppointmentId(null);
|
setSelectedAppointmentId(null);
|
||||||
setSelectedStandaloneId(null);
|
setSelectedStandaloneId(null);
|
||||||
@@ -1441,21 +1467,9 @@ export function TreatmentWorkspace({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = await treatmentsApi.createStandalone({
|
|
||||||
patientId: patient.id,
|
|
||||||
treatmentAt: selectedDay.toISOString(),
|
|
||||||
});
|
|
||||||
setSelectionLocked(true);
|
|
||||||
setSelectedAppointmentId(null);
|
|
||||||
setSelectedStandaloneId(created.data.id);
|
|
||||||
setStandaloneTreatments((prev) =>
|
|
||||||
prev.some((row) => row.id === created.data.id) ? prev : [...prev, created.data],
|
|
||||||
);
|
|
||||||
skipNextGetDraftRef.current = true;
|
|
||||||
hydrateFromTreatment(created.data);
|
|
||||||
await refreshHistory(patient.id);
|
await refreshHistory(patient.id);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
showError(getUserFacingError(error, tErrors, t('errorCreateTreatment')));
|
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
|
||||||
setSearchedPatient(null);
|
setSearchedPatient(null);
|
||||||
} finally {
|
} finally {
|
||||||
setPatientSearchBusy(false);
|
setPatientSearchBusy(false);
|
||||||
@@ -1468,8 +1482,6 @@ export function TreatmentWorkspace({
|
|||||||
searchedPatient?.id,
|
searchedPatient?.id,
|
||||||
hasLiveContext,
|
hasLiveContext,
|
||||||
loadTreatmentIntoWorkspace,
|
loadTreatmentIntoWorkspace,
|
||||||
selectedDay,
|
|
||||||
hydrateFromTreatment,
|
|
||||||
refreshHistory,
|
refreshHistory,
|
||||||
showError,
|
showError,
|
||||||
t,
|
t,
|
||||||
@@ -2077,36 +2089,26 @@ export function TreatmentWorkspace({
|
|||||||
emptyResultsMessage={tPatients('noResults')}
|
emptyResultsMessage={tPatients('noResults')}
|
||||||
/>
|
/>
|
||||||
{canEdit && !isViewingPastDay ? (
|
{canEdit && !isViewingPastDay ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="space-y-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="lg"
|
||||||
disabled={
|
fullWidth
|
||||||
!(
|
aria-expanded={newTreatmentPickerOpen}
|
||||||
searchedPatient?.id ||
|
onClick={() => setNewTreatmentPickerOpen((open) => !open)}
|
||||||
selectedAppointment?.patientId ||
|
|
||||||
(selectedStandalone && !selectedStandalone.patient?.isWalkIn)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
const patientId =
|
|
||||||
searchedPatient?.id ??
|
|
||||||
selectedAppointment?.patientId ??
|
|
||||||
selectedStandalone?.patientId;
|
|
||||||
if (!patientId) return;
|
|
||||||
return createStandaloneTreatment({ patientId });
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t('newTreatment')}
|
{t('newTreatment')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{newTreatmentPickerOpen ? (
|
||||||
type="button"
|
<NewTreatmentPatientPicker
|
||||||
variant="secondary"
|
creating={creatingStandalone}
|
||||||
size="sm"
|
onSelectWalkIn={() => createStandaloneTreatment({ walkIn: true })}
|
||||||
onClick={() => createStandaloneTreatment({ walkIn: true })}
|
onSelectPatient={(patient) =>
|
||||||
>
|
createStandaloneTreatment({ patientId: patient.id })
|
||||||
{t('newWalkInTreatment')}
|
}
|
||||||
</Button>
|
onCancel={() => setNewTreatmentPickerOpen(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ export interface ListLabTasksParams {
|
|||||||
overdue?: boolean;
|
overdue?: boolean;
|
||||||
unassignedOnly?: boolean;
|
unassignedOnly?: boolean;
|
||||||
prosthesisTypeCode?: string;
|
prosthesisTypeCode?: string;
|
||||||
|
origin?: 'CLINIC_DISPATCH' | 'LAB_INTERNAL';
|
||||||
sentFrom?: string;
|
sentFrom?: string;
|
||||||
sentTo?: string;
|
sentTo?: string;
|
||||||
stepCompleted?: string;
|
stepCompleted?: string;
|
||||||
@@ -217,6 +218,7 @@ export interface LocateTaskPageParams {
|
|||||||
overdue?: boolean;
|
overdue?: boolean;
|
||||||
unassignedOnly?: boolean;
|
unassignedOnly?: boolean;
|
||||||
prosthesisTypeCode?: string;
|
prosthesisTypeCode?: string;
|
||||||
|
origin?: 'CLINIC_DISPATCH' | 'LAB_INTERNAL';
|
||||||
stepCompleted?: string;
|
stepCompleted?: string;
|
||||||
sortBy?: TaskSortField;
|
sortBy?: TaskSortField;
|
||||||
sortDir?: 'asc' | 'desc';
|
sortDir?: 'asc' | 'desc';
|
||||||
@@ -250,6 +252,7 @@ export interface LabTaskListItem {
|
|||||||
lastStatusChangedBy: LabTaskUser | null;
|
lastStatusChangedBy: LabTaskUser | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
caseSentAt: string | null;
|
caseSentAt: string | null;
|
||||||
|
origin?: 'CLINIC_DISPATCH' | 'LAB_INTERNAL';
|
||||||
clinic: { id: string; name: string };
|
clinic: { id: string; name: string };
|
||||||
patient: { id: string; firstName: string; lastName: string };
|
patient: { id: string; firstName: string; lastName: string };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user