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 ? ( handleToggle(comment)} @@ -198,7 +206,7 @@ export function LabCaseCommentsPanel({ className={composerInputClass} /> - {canToggleVisibility ? ( + {allowClinicVisibility ? ( setVisibleToClinic((v) => !v)} diff --git a/frontend/src/components/ui/lab/LabCaseOriginBadge.tsx b/frontend/src/components/ui/lab/LabCaseOriginBadge.tsx new file mode 100644 index 0000000..ab1580f --- /dev/null +++ b/frontend/src/components/ui/lab/LabCaseOriginBadge.tsx @@ -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 ( + + {generated ? t('generatedBadge') : t('receivedBadge')} + + ); +} diff --git a/frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx b/frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx index 76548d0..4c51e1d 100644 --- a/frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx +++ b/frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx @@ -4,6 +4,7 @@ import { useTranslations } from 'next-intl'; import { MessageSquare } from 'lucide-react'; import { Badge } from '@/components/ui/shared/Badge'; import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge'; +import { LabCaseOriginBadge } from '@/components/ui/lab/LabCaseOriginBadge'; import type { CaseTaskGroup } from '@/components/lab/taskListGrouping'; import { countCaseTaskProgress } from '@/components/lab/taskListGrouping'; import { formatAppDate, APP_DATE } from '@/lib/i18n/format'; @@ -52,6 +53,7 @@ export function TaskCaseGroupHeader({ {t('importantBadge')} ) : null} + {sentLabel ? ( diff --git a/frontend/src/components/ui/lab/TaskRow.tsx b/frontend/src/components/ui/lab/TaskRow.tsx index 82df63d..9eac175 100644 --- a/frontend/src/components/ui/lab/TaskRow.tsx +++ b/frontend/src/components/ui/lab/TaskRow.tsx @@ -12,6 +12,8 @@ import { labTaskStatusVariant, } from '@/components/lab/labTaskStatusDisplay'; 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 { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils'; import { @@ -153,6 +155,9 @@ export function TaskRow({ {t('importantBadge')} ) : null} + {flatMode && !exiting ? ( + + ) : null} {flatMode && task.caseDueDate && !exiting ? ( { const r = await tasksApi.listComments(task.labCaseId); return r.data; diff --git a/frontend/src/components/ui/lab/TasksPage.tsx b/frontend/src/components/ui/lab/TasksPage.tsx index 7faac59..e4b7625 100644 --- a/frontend/src/components/ui/lab/TasksPage.tsx +++ b/frontend/src/components/ui/lab/TasksPage.tsx @@ -12,6 +12,7 @@ import { TaskProsthesisGroupHeader } from '@/components/ui/lab/TaskProsthesisGro import { TaskRow } from '@/components/ui/lab/TaskRow'; import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; import { groupTasksForDisplay } from '@/components/lab/taskListGrouping'; +import { isLabGeneratedCase } from '@/components/lab/labCaseOrigin'; import { buildDefaultLocateParams, DEFAULT_TASKS_VIEW, @@ -77,6 +78,7 @@ export function TasksPage() { const [assignedToMe, setAssignedToMe] = useState(DEFAULT_TASKS_VIEW.assignedToMe); const [overdueOnly, setOverdueOnly] = useState(DEFAULT_TASKS_VIEW.overdueOnly); const [unassignedOnly, setUnassignedOnly] = useState(DEFAULT_TASKS_VIEW.unassignedOnly); + const [originFilter, setOriginFilter] = useState(DEFAULT_TASKS_VIEW.origin); const [prosthesisTypeCode, setProsthesisTypeCode] = useState( DEFAULT_TASKS_VIEW.prosthesisTypeCode, ); @@ -118,9 +120,10 @@ export function TasksPage() { if (assignedToMe) params.assignedToMe = true; if (overdueOnly) params.overdue = true; if (unassignedOnly) params.unassignedOnly = true; + if (originFilter) params.origin = originFilter; if (prosthesisTypeCode) params.prosthesisTypeCode = prosthesisTypeCode; 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]); @@ -138,6 +141,7 @@ export function TasksPage() { assignedToMe, overdueOnly, unassignedOnly, + origin: originFilter, prosthesisTypeCode, page, highlightTaskId, @@ -153,6 +157,7 @@ export function TasksPage() { assignedToMe, overdueOnly, unassignedOnly, + originFilter, prosthesisTypeCode, page, highlightTaskId, @@ -166,6 +171,7 @@ export function TasksPage() { if (fromUrl.importantOnly !== undefined) setImportantOnly(fromUrl.importantOnly); if (fromUrl.overdueOnly !== undefined) setOverdueOnly(fromUrl.overdueOnly); if (fromUrl.unassignedOnly !== undefined) setUnassignedOnly(fromUrl.unassignedOnly); + if (fromUrl.origin !== undefined) setOriginFilter(fromUrl.origin); if (fromUrl.statusFilter !== undefined) setStatusFilter(fromUrl.statusFilter); if (fromUrl.prosthesisTypeCode !== undefined) setProsthesisTypeCode(fromUrl.prosthesisTypeCode); if (fromUrl.sortBy !== undefined) setSortBy(fromUrl.sortBy); @@ -273,6 +279,7 @@ export function TasksPage() { setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe); setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly); setUnassignedOnly(DEFAULT_TASKS_VIEW.unassignedOnly); + setOriginFilter(DEFAULT_TASKS_VIEW.origin); setProsthesisTypeCode(DEFAULT_TASKS_VIEW.prosthesisTypeCode); setSortBy(DEFAULT_TASKS_VIEW.sortBy); setSortDir(DEFAULT_TASKS_VIEW.sortDir); @@ -293,6 +300,7 @@ export function TasksPage() { setAssignedToMe(DEFAULT_TASKS_VIEW.assignedToMe); setOverdueOnly(DEFAULT_TASKS_VIEW.overdueOnly); setUnassignedOnly(DEFAULT_TASKS_VIEW.unassignedOnly); + setOriginFilter(DEFAULT_TASKS_VIEW.origin); setProsthesisTypeCode(DEFAULT_TASKS_VIEW.prosthesisTypeCode); setSortBy(DEFAULT_TASKS_VIEW.sortBy); setSortDir(DEFAULT_TASKS_VIEW.sortDir); @@ -440,7 +448,7 @@ export function TasksPage() { placeholder={t('searchPlaceholder')} /> - + {t('filterClinic')} + + {t('filterOrigin')} + + applyFilterChange(() => + setOriginFilter(e.target.value as '' | 'CLINIC_DISPATCH' | 'LAB_INTERNAL'), + ) + } + className={filterSelectClass} + > + {t('filterOriginAll')} + {t('filterOriginReceived')} + {t('filterOriginGenerated')} + + {t('filterStatus')} { const r = await tasksApi.listComments(caseGroup.labCaseId); return r.data; diff --git a/frontend/src/components/ui/treatment/DayStripCard.tsx b/frontend/src/components/ui/treatment/DayStripCard.tsx index 741add7..b3d4450 100644 --- a/frontend/src/components/ui/treatment/DayStripCard.tsx +++ b/frontend/src/components/ui/treatment/DayStripCard.tsx @@ -16,6 +16,25 @@ interface DayStripCardProps { deleteAriaLabel?: string; } +function StripCardBody({ item }: { item: DayStripItem }) { + const metaClass = item.kind === 'appointment' ? 'opacity-95' : ''; + return ( + <> + {item.timeLabel ? ( + {item.timeLabel} + ) : ( + {item.subtitle} + )} + + {item.patientFirstName} {item.patientLastName} + + {item.timeLabel ? ( + {item.subtitle} + ) : null} + > + ); +} + export function DayStripCard({ item, selected, @@ -25,73 +44,70 @@ export function DayStripCard({ onDelete, deleteAriaLabel, }: 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 ( + + + + + {showDelete ? ( + { + 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} + `} + > + + + ) : null} + + ); + } + const purposeIndex = treatmentCatalog.findIndex((e) => e.code === item.colorCode); const bannerStyle = treatmentTypeBannerStyle(item.colorCode, purposeIndex < 0 ? 0 : purposeIndex); 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)]' : '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 ? ( - {item.timeLabel} - ) : ( - {item.subtitle} - )} - - {item.patientFirstName} {item.patientLastName} - - {item.timeLabel ? ( - {item.subtitle} - ) : null} - > - ); - - if (!canDelete || !onDelete) { - return ( - - {body} - - ); - } return ( - - {body} - - { - 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" - > - - + ); } diff --git a/frontend/src/components/ui/treatment/NewTreatmentPatientPicker.tsx b/frontend/src/components/ui/treatment/NewTreatmentPatientPicker.tsx new file mode 100644 index 0000000..0be070d --- /dev/null +++ b/frontend/src/components/ui/treatment/NewTreatmentPatientPicker.tsx @@ -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; + onSelectPatient: (patient: Patient) => void | Promise; + 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 ( + + {t('newTreatmentPatientPrompt')} + 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" + > + {t('walkIn')} + {t('walkInPickerHint')} + + { + if (creating) return; + void onSelectPatient(patient); + }} + placeholder={tPatients('searchPlaceholder')} + emptyResultsMessage={tPatients('noResults')} + /> + + + {tCommon('cancel')} + + + + ); +} diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 97ae58f..8915130 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -13,6 +13,7 @@ import { } from '@/components/ui/treatment/TreatmentLabCasesPanel'; import { TreatmentRailSection } from '@/components/ui/treatment/TreatmentRailSection'; import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip'; +import { NewTreatmentPatientPicker } from '@/components/ui/treatment/NewTreatmentPatientPicker'; import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel'; import { LabShipmentBlockedNotice } from '@/components/ui/treatment/LabShipmentBlockedNotice'; @@ -370,6 +371,8 @@ export function TreatmentWorkspace({ 'id' | 'firstName' | 'lastName' > | null>(null); const [patientSearchBusy, setPatientSearchBusy] = useState(false); + const [newTreatmentPickerOpen, setNewTreatmentPickerOpen] = useState(false); + const [creatingStandalone, setCreatingStandalone] = useState(false); const { search: patientSearch, @@ -680,12 +683,16 @@ export function TreatmentWorkspace({ const hydrateFromTreatment = useCallback((treatment: PastTreatment) => { const mapped = treatment.details.map(mapDetailFromApi); - setDetails(mapped); + const nextDetails = + mapped.length > 0 + ? mapped + : [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))]; + setDetails(nextDetails); setActiveDetailId((prev) => { - const stillExists = mapped.some((d) => d.clientId === prev); - return stillExists ? prev : mapped[0]?.clientId ?? prev; + const stillExists = nextDetails.some((d) => d.clientId === prev); + return stillExists ? prev : nextDetails[0].clientId; }); - setSavedSnapshot(serializeDetails(mapped)); + setSavedSnapshot(serializeDetails(nextDetails)); const mappedLabCases = withoutEmptyLabCaseDrafts( (treatment.labCases ?? []).map(mapLabCaseDraftFromApi), ); @@ -693,7 +700,7 @@ export function TreatmentWorkspace({ setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null); setOrganizationSearch(''); setSaveStatus('idle'); - }, []); + }, [treatmentCatalog]); const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]); const connectedSelectedTeeth = useMemo( @@ -1254,14 +1261,18 @@ export function TreatmentWorkspace({ const createStandaloneTreatment = useCallback( async (opts: { patientId?: string; walkIn?: boolean }) => { + if (creatingStandalone) return; const ok = await flushDraftSave(); if (!ok) return; + setCreatingStandalone(true); try { const created = await treatmentsApi.createStandalone({ ...opts, treatmentAt: selectedDay.toISOString(), }); resetToLiveContext(); + setSearchedPatient(null); + setNewTreatmentPickerOpen(false); setSelectionLocked(true); setSelectedAppointmentId(null); setSelectedStandaloneId(created.data.id); @@ -1269,15 +1280,29 @@ export function TreatmentWorkspace({ prev.some((row) => row.id === created.data.id) ? prev : [...prev, created.data], ); skipNextGetDraftRef.current = true; + draftHydratingRef.current = true; hydrateFromTreatment(created.data); + draftHydratingRef.current = false; if (created.data.patientId && !created.data.patient?.isWalkIn) { await refreshHistory(created.data.patientId); } } catch (error: unknown) { 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( @@ -1320,6 +1345,7 @@ export function TreatmentWorkspace({ const patientIdToRefresh = historyPatientId; resetToLiveContext(); setSearchedPatient(null); + setNewTreatmentPickerOpen(false); setSelectionLocked(false); setSelectedAppointmentId(null); setSelectedStandaloneId(null); @@ -1441,21 +1467,9 @@ export function TreatmentWorkspace({ 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); } catch (error: unknown) { - showError(getUserFacingError(error, tErrors, t('errorCreateTreatment'))); + showError(getUserFacingError(error, tErrors, t('errorLoadHistory'))); setSearchedPatient(null); } finally { setPatientSearchBusy(false); @@ -1468,8 +1482,6 @@ export function TreatmentWorkspace({ searchedPatient?.id, hasLiveContext, loadTreatmentIntoWorkspace, - selectedDay, - hydrateFromTreatment, refreshHistory, showError, t, @@ -2077,36 +2089,26 @@ export function TreatmentWorkspace({ emptyResultsMessage={tPatients('noResults')} /> {canEdit && !isViewingPastDay ? ( - + { - const patientId = - searchedPatient?.id ?? - selectedAppointment?.patientId ?? - selectedStandalone?.patientId; - if (!patientId) return; - return createStandaloneTreatment({ patientId }); - }} + size="lg" + fullWidth + aria-expanded={newTreatmentPickerOpen} + onClick={() => setNewTreatmentPickerOpen((open) => !open)} > {t('newTreatment')} - createStandaloneTreatment({ walkIn: true })} - > - {t('newWalkInTreatment')} - + {newTreatmentPickerOpen ? ( + createStandaloneTreatment({ walkIn: true })} + onSelectPatient={(patient) => + createStandaloneTreatment({ patientId: patient.id }) + } + onCancel={() => setNewTreatmentPickerOpen(false)} + /> + ) : null} ) : null} diff --git a/frontend/src/types/cases.ts b/frontend/src/types/cases.ts index ea3a1b2..cb9139a 100644 --- a/frontend/src/types/cases.ts +++ b/frontend/src/types/cases.ts @@ -196,6 +196,7 @@ export interface ListLabTasksParams { overdue?: boolean; unassignedOnly?: boolean; prosthesisTypeCode?: string; + origin?: 'CLINIC_DISPATCH' | 'LAB_INTERNAL'; sentFrom?: string; sentTo?: string; stepCompleted?: string; @@ -217,6 +218,7 @@ export interface LocateTaskPageParams { overdue?: boolean; unassignedOnly?: boolean; prosthesisTypeCode?: string; + origin?: 'CLINIC_DISPATCH' | 'LAB_INTERNAL'; stepCompleted?: string; sortBy?: TaskSortField; sortDir?: 'asc' | 'desc'; @@ -250,6 +252,7 @@ export interface LabTaskListItem { lastStatusChangedBy: LabTaskUser | null; createdAt: string; caseSentAt: string | null; + origin?: 'CLINIC_DISPATCH' | 'LAB_INTERNAL'; clinic: { id: string; name: string }; patient: { id: string; firstName: string; lastName: string }; }
diff --git a/frontend/src/components/ui/lab/TaskRow.tsx b/frontend/src/components/ui/lab/TaskRow.tsx index 82df63d..9eac175 100644 --- a/frontend/src/components/ui/lab/TaskRow.tsx +++ b/frontend/src/components/ui/lab/TaskRow.tsx @@ -12,6 +12,8 @@ import { labTaskStatusVariant, } from '@/components/lab/labTaskStatusDisplay'; 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 { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils'; import { @@ -153,6 +155,9 @@ export function TaskRow({ {t('importantBadge')}
{item.timeLabel}
{item.subtitle}
+ {item.patientFirstName} {item.patientLastName} +
- {item.patientFirstName} {item.patientLastName} -
{t('newTreatmentPatientPrompt')}
{t('walkIn')}
{t('walkInPickerHint')}