From 96f698be98a653de7ade563dee3bd31af4fa25a8 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 19 Aug 2026 16:07:32 +0330 Subject: [PATCH] improvement: UI/UX improved for v1 standalone treatments/cases feature. --- .cursor/skills/lab-cases/SKILL.md | 4 +- .cursor/skills/lab-tasks/SKILL.md | 3 +- .cursor/skills/treatment-workspace/SKILL.md | 4 +- AGENTS.md | 6 +- backend/src/modules/tasks/dto/tasks.dto.ts | 11 +- backend/src/modules/tasks/tasks.service.ts | 3 + frontend/messages/en.json | 9 +- frontend/messages/fa.json | 9 +- frontend/messages/nl.json | 9 +- frontend/src/components/lab/labCaseOrigin.ts | 5 + .../components/lab/parseTasksSearchParams.ts | 4 + .../src/components/lab/taskListGrouping.ts | 2 + .../src/components/lab/tasksViewDefaults.ts | 3 + .../src/components/ui/lab/CaseDetailPanel.tsx | 2 + frontend/src/components/ui/lab/CasesPage.tsx | 4 + .../ui/lab/LabCaseCommentsPanel.tsx | 18 ++- .../components/ui/lab/LabCaseOriginBadge.tsx | 25 ++++ .../components/ui/lab/TaskCaseGroupHeader.tsx | 2 + frontend/src/components/ui/lab/TaskRow.tsx | 6 + frontend/src/components/ui/lab/TasksPage.tsx | 29 +++- .../components/ui/treatment/DayStripCard.tsx | 128 ++++++++++-------- .../treatment/NewTreatmentPatientPicker.tsx | 58 ++++++++ .../ui/treatment/TreatmentWorkspace.tsx | 94 ++++++------- frontend/src/types/cases.ts | 3 + 24 files changed, 320 insertions(+), 121 deletions(-) create mode 100644 frontend/src/components/lab/labCaseOrigin.ts create mode 100644 frontend/src/components/ui/lab/LabCaseOriginBadge.tsx create mode 100644 frontend/src/components/ui/treatment/NewTreatmentPatientPicker.tsx diff --git a/.cursor/skills/lab-cases/SKILL.md b/.cursor/skills/lab-cases/SKILL.md index 6e75933..933ae67 100644 --- a/.cursor/skills/lab-cases/SKILL.md +++ b/.cursor/skills/lab-cases/SKILL.md @@ -11,7 +11,7 @@ description: Lab Cases tab — list, filters, detail panel, assignment, share QR ## 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`. - **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`. @@ -41,7 +41,7 @@ List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task ## Detail panel - 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) - 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`. diff --git a/.cursor/skills/lab-tasks/SKILL.md b/.cursor/skills/lab-tasks/SKILL.md index 22e2915..53489bb 100644 --- a/.cursor/skills/lab-tasks/SKILL.md +++ b/.cursor/skills/lab-tasks/SKILL.md @@ -26,7 +26,7 @@ Other sorts use flat list on the frontend; `stepOrder asc` is still a tiebreaker 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 @@ -39,6 +39,7 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. | Param | API | UI | |-------|-----|-----| | `q`, `clinicOrganizationId`, `status` | `GET /tasks` | Search, clinic, status | +| `origin` | `GET /tasks` | `CLINIC_DISPATCH` (received) / `LAB_INTERNAL` (generated) | | `stepCompleted` | `GET /tasks` | Workflow step dropdown | | `pinImportant` | `GET /tasks` | Important first (sort pin) | | `assignedToMe` | `GET /tasks` | Only tasks assigned to current user | diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index 8b4b81a..c507c7a 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -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) @@ -198,7 +198,7 @@ Use shared `Checkbox` (not native ``) 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`. diff --git a/AGENTS.md b/AGENTS.md index 5b98bbe..7f79181 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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`). - **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. @@ -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`. -**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):** - Token on first ship → `/{locale}/lab-case/{token}` after login. diff --git a/backend/src/modules/tasks/dto/tasks.dto.ts b/backend/src/modules/tasks/dto/tasks.dto.ts index 531d0ff..07ad335 100644 --- a/backend/src/modules/tasks/dto/tasks.dto.ts +++ b/backend/src/modules/tasks/dto/tasks.dto.ts @@ -11,7 +11,7 @@ import { Min, } from 'class-validator'; import { Transform } from 'class-transformer'; -import { LabTaskStatus } from '@prisma/client'; +import { LabTaskStatus, LabCaseOrigin } from '@prisma/client'; const toBoolean = ({ value }: { value: unknown }) => { if (typeof value === 'boolean') return value; @@ -105,6 +105,11 @@ export class ListLabTasksDto { @IsString() prosthesisTypeCode?: string; + /** CLINIC_DISPATCH = received from clinic; LAB_INTERNAL = generated in the lab. */ + @IsOptional() + @IsEnum(LabCaseOrigin) + origin?: LabCaseOrigin; + @IsOptional() @IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType', 'dueDate']) sortBy?: TaskSortField; @@ -194,6 +199,10 @@ export class LocateTaskPageDto { @IsString() prosthesisTypeCode?: string; + @IsOptional() + @IsEnum(LabCaseOrigin) + origin?: LabCaseOrigin; + @IsOptional() @IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType', 'dueDate']) sortBy?: TaskSortField; diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index 2ad158a..efd45f1 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -466,6 +466,7 @@ export class TasksService { } satisfies Prisma.LabCaseWhereInput, ] : []), + ...(query.origin ? [{ origin: query.origin } satisfies Prisma.LabCaseWhereInput] : []), ...(query.q?.trim() ? [this.buildTaskSearchWhere(query.q.trim())] : []), ], }; @@ -537,6 +538,7 @@ export class TasksService { overdue: query.overdue, unassignedOnly: query.unassignedOnly, prosthesisTypeCode: query.prosthesisTypeCode, + origin: query.origin, sortBy: query.sortBy, sortDir: query.sortDir, limit: query.limit, @@ -797,6 +799,7 @@ export class TasksService { : null, createdAt: task.createdAt.toISOString(), caseSentAt: (task.labCase.sentAt ?? task.labCase.startedAt)?.toISOString() ?? null, + origin: task.labCase.origin, clinic, patient, }; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index bdaa5b0..bc88e6d 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -499,6 +499,8 @@ "addCaseSubtitle": "Enter the referring clinic or patient, then add prosthesis lines. Start when the case is ready.", "startCase": "Start", "draftBadge": "Draft", + "receivedBadge": "Received", + "generatedBadge": "Generated", "referringClinic": "Referring clinic", "referringDentist": "Dentist", "patientName": "Patient name", @@ -542,6 +544,10 @@ "searchPlaceholder": "Search patient or clinic…", "filterClinic": "Clinic", "filterClinicAll": "All clinics", + "filterOrigin": "Case source", + "filterOriginAll": "All sources", + "filterOriginReceived": "Received", + "filterOriginGenerated": "Generated", "filterStatus": "Status", "filterStatusAll": "All statuses", "filterStepCompleted": "Step completed", @@ -699,7 +705,8 @@ "noAppointment": "No appointment", "walkIn": "Walk-in", "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.", "deleteEmptyTreatment": "Delete empty treatment", "confirmDeleteEmptyTreatment": "Delete this treatment? It has no details yet.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 5b15057..ea3e9bb 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -500,6 +500,8 @@ "addCaseSubtitle": "کلینیک ارجاع‌دهنده یا بیمار را وارد کنید و خطوط پروتز را اضافه کنید. وقتی آماده بود، شروع کنید.", "startCase": "شروع", "draftBadge": "پیش‌نویس", + "receivedBadge": "دریافتی", + "generatedBadge": "ایجادشده", "referringClinic": "کلینیک ارجاع‌دهنده", "referringDentist": "دندان‌پزشک", "patientName": "نام بیمار", @@ -543,6 +545,10 @@ "searchPlaceholder": "جستجوی بیمار یا کلینیک…", "filterClinic": "کلینیک", "filterClinicAll": "همه کلینیک‌ها", + "filterOrigin": "منبع پرونده", + "filterOriginAll": "همه منابع", + "filterOriginReceived": "دریافتی", + "filterOriginGenerated": "ایجادشده", "filterStatus": "وضعیت", "filterStatusAll": "همه وضعیت‌ها", "filterStepCompleted": "مرحله تکمیل‌شده", @@ -700,7 +706,8 @@ "noAppointment": "بدون نوبت", "walkIn": "بدون نوبت (مراجع)", "newTreatment": "درمان جدید", - "newWalkInTreatment": "درمان بدون نوبت", + "newTreatmentPatientPrompt": "این ویزیت برای چه کسی است؟", + "walkInPickerHint": "بیمار نام‌دار نیست — همیشه در دسترس است و نیازی به جستجو ندارد.", "errorCreateTreatment": "ایجاد درمان ممکن نشد.", "deleteEmptyTreatment": "حذف درمان خالی", "confirmDeleteEmptyTreatment": "این درمان حذف شود؟ هنوز جزئیاتی ندارد.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 83a246e..70f44e4 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -500,6 +500,8 @@ "addCaseSubtitle": "Vul de verwijzende kliniek of patiënt in en voeg protheselijnen toe. Start wanneer de case klaar is.", "startCase": "Starten", "draftBadge": "Concept", + "receivedBadge": "Ontvangen", + "generatedBadge": "Aangemaakt", "referringClinic": "Verwijzende kliniek", "referringDentist": "Tandarts", "patientName": "Patiëntnaam", @@ -543,6 +545,10 @@ "searchPlaceholder": "Zoek patiënt of kliniek…", "filterClinic": "Kliniek", "filterClinicAll": "Alle klinieken", + "filterOrigin": "Bron van case", + "filterOriginAll": "Alle bronnen", + "filterOriginReceived": "Ontvangen", + "filterOriginGenerated": "Aangemaakt", "filterStatus": "Status", "filterStatusAll": "Alle statussen", "filterStepCompleted": "Stap voltooid", @@ -699,7 +705,8 @@ "noAppointment": "Geen afspraak", "walkIn": "Inloop", "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.", "deleteEmptyTreatment": "Lege behandeling verwijderen", "confirmDeleteEmptyTreatment": "Deze behandeling verwijderen? Er zijn nog geen details.", diff --git a/frontend/src/components/lab/labCaseOrigin.ts b/frontend/src/components/lab/labCaseOrigin.ts new file mode 100644 index 0000000..0fe2a6c --- /dev/null +++ b/frontend/src/components/lab/labCaseOrigin.ts @@ -0,0 +1,5 @@ +export type LabCaseOriginCode = 'CLINIC_DISPATCH' | 'LAB_INTERNAL'; + +export function isLabGeneratedCase(origin?: string | null): boolean { + return origin === 'LAB_INTERNAL'; +} diff --git a/frontend/src/components/lab/parseTasksSearchParams.ts b/frontend/src/components/lab/parseTasksSearchParams.ts index 4858519..4ab6df1 100644 --- a/frontend/src/components/lab/parseTasksSearchParams.ts +++ b/frontend/src/components/lab/parseTasksSearchParams.ts @@ -16,6 +16,10 @@ export function parseTasksSearchParams( if (searchParams.get('unassignedOnly') === '1') { partial.unassignedOnly = true; } + const origin = searchParams.get('origin'); + if (origin === 'CLINIC_DISPATCH' || origin === 'LAB_INTERNAL') { + partial.origin = origin; + } const status = searchParams.get('status'); if (status === 'IN_PROGRESS' || status === 'COMPLETED') { diff --git a/frontend/src/components/lab/taskListGrouping.ts b/frontend/src/components/lab/taskListGrouping.ts index 95710f2..4308451 100644 --- a/frontend/src/components/lab/taskListGrouping.ts +++ b/frontend/src/components/lab/taskListGrouping.ts @@ -17,6 +17,7 @@ export type CaseTaskGroup = { caseDueDate: string | null; isCaseOverdue: boolean; isImportant: boolean; + origin?: 'CLINIC_DISPATCH' | 'LAB_INTERNAL'; prosthesisGroups: ProsthesisTaskGroup[]; }; @@ -52,6 +53,7 @@ export function groupTasksForDisplay( caseDueDate: task.caseDueDate ?? null, isCaseOverdue: task.isCaseOverdue, isImportant: task.isImportant, + origin: task.origin, prosthesisGroups: [], }); } diff --git a/frontend/src/components/lab/tasksViewDefaults.ts b/frontend/src/components/lab/tasksViewDefaults.ts index 8350846..7f34766 100644 --- a/frontend/src/components/lab/tasksViewDefaults.ts +++ b/frontend/src/components/lab/tasksViewDefaults.ts @@ -11,6 +11,7 @@ export type TasksViewState = { assignedToMe: boolean; overdueOnly: boolean; unassignedOnly: boolean; + origin: '' | 'CLINIC_DISPATCH' | 'LAB_INTERNAL'; prosthesisTypeCode: string; page: number; highlightTaskId: string | null; @@ -27,6 +28,7 @@ export const DEFAULT_TASKS_VIEW: TasksViewState = { assignedToMe: false, overdueOnly: false, unassignedOnly: false, + origin: '', prosthesisTypeCode: '', page: 1, highlightTaskId: null, @@ -46,6 +48,7 @@ export function isDefaultTasksView(state: TasksViewState): boolean { state.assignedToMe === DEFAULT_TASKS_VIEW.assignedToMe && state.overdueOnly === DEFAULT_TASKS_VIEW.overdueOnly && state.unassignedOnly === DEFAULT_TASKS_VIEW.unassignedOnly && + state.origin === DEFAULT_TASKS_VIEW.origin && state.prosthesisTypeCode === DEFAULT_TASKS_VIEW.prosthesisTypeCode && state.page === DEFAULT_TASKS_VIEW.page && state.highlightTaskId === DEFAULT_TASKS_VIEW.highlightTaskId diff --git a/frontend/src/components/ui/lab/CaseDetailPanel.tsx b/frontend/src/components/ui/lab/CaseDetailPanel.tsx index db27f4f..1ff5a46 100644 --- a/frontend/src/components/ui/lab/CaseDetailPanel.tsx +++ b/frontend/src/components/ui/lab/CaseDetailPanel.tsx @@ -17,6 +17,7 @@ import { } from '@/components/ui/lab/LabCaseShareQrDialog'; import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay'; import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge'; +import { LabCaseOriginBadge } from '@/components/ui/lab/LabCaseOriginBadge'; import { formatToothList, prosthesisTypeBadgeStyleFromCatalog, @@ -165,6 +166,7 @@ export function CaseDetailPanel({ {formatPatientName(labCase.patient)} + {!canEditImportant && labCase.isImportant ? ( diff --git a/frontend/src/components/ui/lab/CasesPage.tsx b/frontend/src/components/ui/lab/CasesPage.tsx index 20e5ab9..ae281e2 100644 --- a/frontend/src/components/ui/lab/CasesPage.tsx +++ b/frontend/src/components/ui/lab/CasesPage.tsx @@ -16,6 +16,8 @@ import { formatPatientName, } from '@/components/lab/caseDetailUtils'; 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 { notifyTabBadgesChanged, tabBadgesChangedEventName } from '@/lib/tabBadgeUtils'; import { casesApi } from '@/lib/api/cases'; @@ -529,6 +531,7 @@ export function CasesPage() { {t('importantLabel')} ) : null} + {isDraftCase(item) ? ( {t('draftBadge')} @@ -694,6 +697,7 @@ export function CasesPage() { viewerSide="LAB" canPost={canEditComments} canToggleVisibility={canEditComments} + clinicVisibility={!isLabGeneratedCase(selectedCase.origin)} loadComments={async () => { const r = await tasksApi.listComments(selectedCaseId); setCommentCount(r.data.length); diff --git a/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx b/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx index fda6b4f..de8b4cb 100644 --- a/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx +++ b/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx @@ -14,6 +14,11 @@ interface LabCaseCommentsPanelProps { viewerSide: LabCaseCommentViewerSide; canPost: 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; onPost: (body: string, visibleToClinic?: boolean) => Promise; onToggleVisibility?: (commentId: string, visible: boolean) => Promise; @@ -38,6 +43,7 @@ export function LabCaseCommentsPanel({ viewerSide, canPost, canToggleVisibility, + clinicVisibility = true, loadComments, onPost, onToggleVisibility, @@ -55,6 +61,8 @@ export function LabCaseCommentsPanel({ const [visibleToClinic, setVisibleToClinic] = useState(false); const toggleBusy = useAsyncActionById(); + const allowClinicVisibility = canToggleVisibility && clinicVisibility; + const orderedComments = useMemo(() => sortNewestFirst(comments), [comments]); const loadCommentsRef = useRef(loadComments); loadCommentsRef.current = loadComments; @@ -80,7 +88,7 @@ export function LabCaseCommentsPanel({ if (!trimmed || !canPost || posting) return; setPosting(true); try { - const created = await onPost(trimmed, visibleToClinic); + const created = await onPost(trimmed, clinicVisibility ? visibleToClinic : false); setComments((prev) => sortNewestFirst([created, ...prev])); setBody(''); setVisibleToClinic(false); @@ -99,7 +107,7 @@ export function LabCaseCommentsPanel({ } async function handleToggle(comment: LabCaseComment) { - if (!onToggleVisibility || !canToggleVisibility) return; + if (!onToggleVisibility || !allowClinicVisibility) return; await toggleBusy.run(comment.id, async () => { try { const updated = await onToggleVisibility(comment.id, !comment.visibleToClinic); @@ -142,14 +150,14 @@ export function LabCaseCommentsPanel({ {comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')} {comment.authorName ? ` · ${comment.authorName}` : ''} - {comment.showVisibilityStatus !== false ? ( + {clinicVisibility && comment.showVisibilityStatus !== false ? ( comment.visibleToClinic ? ( {t('clinicCanSee')} ) : ( {t('hiddenFromClinic')} ) ) : null} - {canToggleVisibility && comment.canToggleVisibility && onToggleVisibility ? ( + {allowClinicVisibility && comment.canToggleVisibility && onToggleVisibility ? (