improvement: treatment preview wizard optimized. comments component updated and unified accross the app.
This commit is contained in:
@@ -8,8 +8,10 @@ alwaysApply: false
|
|||||||
|
|
||||||
- **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load.
|
- **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load.
|
||||||
- **Current draft** preview: heading “Current draft”; no load button while editing live.
|
- **Current draft** preview: heading “Current draft”; no load button while editing live.
|
||||||
- **Entry wizard:** Teeth → Content → Lab via `WizardStepper` (not detail chips). Lab step only for lab-dependent (prosthesis) active detail. Appointment purpose does not gate type or Next.
|
- **Entry wizard:** Teeth → Content → Lab via `WizardStepper` (not detail chips). Lab step only for lab-dependent (prosthesis) active detail; entering Lab auto-opens shipment draft. Add detail ungated. Appointment purpose does not gate type or Next.
|
||||||
- **Tooth groups:** Shift+same-arch range = connected (linked dots); Ctrl/⌘+same-arch range = singles (one lab row each); click in connected span → collapse to that single only. `toothSelectionGroups.ts`; prune lab `toothProsthesis` on change.
|
- **Tooth groups:** Shift+same-arch range = connected (linked dots); Ctrl/⌘+same-arch range = singles (one lab row each); click in connected span → collapse to that single only. `toothSelectionGroups.ts`; prune lab `toothProsthesis` on change.
|
||||||
|
- **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Prosthesis type: stacked below `md`, 50/50 same-row from `md`. Content clinical field = **Notes** (not case comments).
|
||||||
|
- **Lab comments:** shared `LabCaseCommentsPanel` (newest-first; sent=`justify-start`, received=`justify-end`; `viewerSide`) across Treatment / Cases / Tasks / share. Use logical `text-start`/`text-end`, not left/right.
|
||||||
- **Detail chrome:** chips + Add at top; **Remove** = trash on chip (unsent, >1 detail; disabled when day-locked / no edit / uploading). No delete in Content step.
|
- **Detail chrome:** chips + Add at top; **Remove** = trash on chip (unsent, >1 detail; disabled when day-locked / no edit / uploading). No delete in Content step.
|
||||||
- **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch.
|
- **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch.
|
||||||
- **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all.
|
- **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all.
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ Task status updates use **`PATCH /tasks/:id`** (not token routes) — same assig
|
|||||||
|
|
||||||
**Cases detail header:** attachment preview **left**, QR thumb **right**, same row (`w-24 sm:w-32`). QR opens dialog (large QR + URL + copy); no inline copy on panel. Only when `shareUrl` present (sent case).
|
**Cases detail header:** attachment preview **left**, QR thumb **right**, same row (`w-24 sm:w-32`). QR opens dialog (large QR + URL + copy); no inline copy on panel. Only when `shareUrl` present (sent case).
|
||||||
|
|
||||||
**Share focus page:** grouped tasks (reuse `TaskRow`, `TaskCaseGroupHeader`); comments section via `LabCaseCommentsPanel` + token API adapters. Access denied → inline message (`asApiError` for `LAB_CASE_ACCESS_DENIED`).
|
**Share focus page:** grouped tasks (reuse `TaskRow`, `TaskCaseGroupHeader`); comments via `LabCaseCommentsPanel` + token API (`viewerSide` from `session.accessMode`). Newest-first; sent/received use logical start/end. Access denied → inline message (`asApiError` for `LAB_CASE_ACCESS_DENIED`).
|
||||||
|
|
||||||
## Auth redirect (logged out → login → back)
|
## Auth redirect (logged out → login → back)
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,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
|
- 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.
|
||||||
- Mark read: `POST /notifications/mark-case-read` on select (Cases tab badge)
|
- Mark read: `POST /notifications/mark-case-read` on select (Cases tab badge)
|
||||||
- **Share link (sent cases):** `shareUrl` on detail; attachment preview left + QR thumb right; `LabCaseShareQrDialog` (`react-qr-code`). Full flow: `.cursor/skills/lab-case-share-link/SKILL.md`.
|
- **Share link (sent cases):** `shareUrl` on detail; attachment preview left + QR thumb right; `LabCaseShareQrDialog` (`react-qr-code`). Full flow: `.cursor/skills/lab-case-share-link/SKILL.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -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}`).
|
**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).
|
||||||
|
|
||||||
## Prosthesis colors
|
## Prosthesis colors
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ Right-column entry uses `WizardStepper` (`components/ui/shared/WizardStepper.tsx
|
|||||||
| Step | UI | Notes |
|
| Step | UI | Notes |
|
||||||
|------|-----|--------|
|
|------|-----|--------|
|
||||||
| **Teeth** | `FdiToothChart` | Shift+click connected range; Ctrl/⌘+click single range; linked dots |
|
| **Teeth** | `FdiToothChart` | Shift+click connected range; Ctrl/⌘+click single range; linked dots |
|
||||||
| **Content** | `TreatmentDetailsEditor` fields only (`showChrome={false}`) | Type / notes / attachments — **no delete button** |
|
| **Content** | `TreatmentDetailsEditor` fields only (`showChrome={false}`) | Type / **Notes** / attachments — **no delete button** |
|
||||||
| **Lab** | `LabCasesDispatchPanel` | Shown in stepper **only** when active detail type is lab-dependent (`labDependentCodes`) |
|
| **Lab** | `LabCasesDispatchPanel` | Shown in stepper **only** when active detail type is lab-dependent (`labDependentCodes`). Entering Lab **auto-ensures** a shipment draft (no “Add lab shipment” click). Add detail stays ungated. Due date beside title (RTL via logical end). Prosthesis dropdown same-row from `md`. |
|
||||||
|
|
||||||
- Detail type may differ from appointment purpose (purpose only defaults new details).
|
- Detail type may differ from appointment purpose (purpose only defaults new details).
|
||||||
- Next/Back navigate visible steps; leaving prosthesis while on Lab returns to Content.
|
- Next/Back navigate visible steps; leaving prosthesis while on Lab returns to Content.
|
||||||
@@ -61,6 +61,7 @@ Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted
|
|||||||
- Prevent browser selection artifacts (`select-none`, modifier `preventDefault` on mousedown).
|
- Prevent browser selection artifacts (`select-none`, modifier `preventDefault` on mousedown).
|
||||||
- On group change, prune/remap `labCase.toothProsthesis` via `pruneToothProsthesisForGroups`.
|
- On group change, prune/remap `labCase.toothProsthesis` via `pruneToothProsthesisForGroups`.
|
||||||
- Connected UI label: `ConnectedSelectionBadge` (shared `Badge` + primary tint) in dispatch + lab case lists.
|
- Connected UI label: `ConnectedSelectionBadge` (shared `Badge` + primary tint) in dispatch + lab case lists.
|
||||||
|
- Lab case comments: shared `LabCaseCommentsPanel` — newest-first; **sent** = `justify-start` / `text-start`, **received** = `justify-end` / `text-end` (RTL-safe); require `viewerSide: 'LAB' | 'CLINIC'`. Used in Cases, Tasks, share focus, and Treatment (`DetailLabCaseCommentsSection`). Composer: `h-9` input + primary send / visibility buttons (white icons; Send mirrored in RTL).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -123,12 +124,11 @@ Saved lab-dependent detail with **no teeth** can autosave but **cannot** create
|
|||||||
|
|
||||||
## Lab case comments on details
|
## Lab case comments on details
|
||||||
|
|
||||||
|
Comments for a shipment live in the Lab dispatch panel (and Cases/Tasks/share), not on Content notes.
|
||||||
|
|
||||||
|
- Unsent: deferred composer in `DetailLabCaseCommentsSection` (posts with Send to lab).
|
||||||
Below each detail in the editor when the linked lab case is **in progress** (not all tasks completed):
|
- Sent / in progress: live composer until tasks complete (`canPostComments`).
|
||||||
|
- UI: `DetailLabCaseCommentsSection` → `LabCaseCommentsPanel` + `treatmentsApi` comment endpoints (`viewerSide="CLINIC"`).
|
||||||
- `canCommentOnDetailLabCase(detail)` — requires `sentAt`, `labCaseId`, and `!isLabCaseCompleted(taskProgress)`.
|
|
||||||
- UI: `DetailLabCaseCommentsSection` → existing `LabCaseCommentsPanel` + `treatmentsApi` comment endpoints.
|
|
||||||
- Backend includes `tasks: { select: { id, status } }` on lab cases; `mapDetail` exposes `taskProgress: { completed, total }`.
|
- Backend includes `tasks: { select: { id, status } }` on lab cases; `mapDetail` exposes `taskProgress: { completed, total }`.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ frontend/src/
|
|||||||
**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.
|
- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form.
|
||||||
- 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). Detail chips ≠ wizard chrome (chips stay bordered tabs; stepper is numbered 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.
|
||||||
- **Tooth selection:** Shift+click same-arch range = connected group (linked dots); Ctrl/⌘+click same range = individual singles (one lab row each); plain click on a connected tooth collapses the whole span to that single tooth (no peel). Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge` (shared `Badge` + primary tint).
|
- **Tooth selection:** Shift+click same-arch range = connected group (linked dots); Ctrl/⌘+click same range = individual singles (one lab row each); plain click on a connected tooth collapses the whole span to that single tooth (no peel). Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge` (shared `Badge` + primary tint).
|
||||||
|
- **Lab dispatch layout:** due date beside title (`justify-between`, logical start/end for RTL); prosthesis type on same row as teeth from `md:` up (stacked on mobile).
|
||||||
|
|
||||||
**Treatment lab rules (quick ref):**
|
**Treatment lab rules (quick ref):**
|
||||||
- Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add.
|
- Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add.
|
||||||
@@ -54,7 +55,7 @@ frontend/src/
|
|||||||
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
|
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
|
||||||
- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name).
|
- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name).
|
||||||
- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit).
|
- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit).
|
||||||
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read.
|
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`.
|
||||||
|
|
||||||
**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. See `.cursor/rules/appointments.mdc`.
|
**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. See `.cursor/rules/appointments.mdc`.
|
||||||
|
|
||||||
|
|||||||
@@ -669,8 +669,8 @@
|
|||||||
"detailsSaveHint": "Lab dispatch is configured separately below.",
|
"detailsSaveHint": "Lab dispatch is configured separately below.",
|
||||||
"addCase": "Add case",
|
"addCase": "Add case",
|
||||||
"caseLabel": "Case {n}",
|
"caseLabel": "Case {n}",
|
||||||
"comments": "Comments",
|
"comments": "Notes",
|
||||||
"commentsPlaceholder": "Write clinical notes for this case…",
|
"commentsPlaceholder": "Write clinical notes for this detail…",
|
||||||
"treatmentType": "Treatment type",
|
"treatmentType": "Treatment type",
|
||||||
"treatmentTypePlaceholder": "Select treatment type…",
|
"treatmentTypePlaceholder": "Select treatment type…",
|
||||||
"treatmentTypeNotSelected": "Type not selected",
|
"treatmentTypeNotSelected": "Type not selected",
|
||||||
|
|||||||
@@ -670,8 +670,8 @@
|
|||||||
"detailsSaveHint": "ارسال لاب در بخش جداگانه زیر پیکربندی میشود.",
|
"detailsSaveHint": "ارسال لاب در بخش جداگانه زیر پیکربندی میشود.",
|
||||||
"addCase": "افزودن پرونده",
|
"addCase": "افزودن پرونده",
|
||||||
"caseLabel": "پرونده {n}",
|
"caseLabel": "پرونده {n}",
|
||||||
"comments": "نظرات",
|
"comments": "یادداشتها",
|
||||||
"commentsPlaceholder": "یادداشتهای بالینی این پرونده را بنویسید...",
|
"commentsPlaceholder": "یادداشتهای بالینی این جزئیات را بنویسید…",
|
||||||
"treatmentType": "نوع درمان",
|
"treatmentType": "نوع درمان",
|
||||||
"treatmentTypePlaceholder": "نوع درمان را انتخاب کنید…",
|
"treatmentTypePlaceholder": "نوع درمان را انتخاب کنید…",
|
||||||
"treatmentTypeNotSelected": "نوع انتخاب نشده",
|
"treatmentTypeNotSelected": "نوع انتخاب نشده",
|
||||||
|
|||||||
@@ -669,8 +669,8 @@
|
|||||||
"detailsSaveHint": "Lab-dispatch wordt hieronder apart geconfigureerd.",
|
"detailsSaveHint": "Lab-dispatch wordt hieronder apart geconfigureerd.",
|
||||||
"addCase": "Case toevoegen",
|
"addCase": "Case toevoegen",
|
||||||
"caseLabel": "Case {n}",
|
"caseLabel": "Case {n}",
|
||||||
"comments": "Opmerkingen",
|
"comments": "Notities",
|
||||||
"commentsPlaceholder": "Schrijf klinische notities voor deze case...",
|
"commentsPlaceholder": "Schrijf klinische notities voor dit detail…",
|
||||||
"treatmentType": "Behandeltype",
|
"treatmentType": "Behandeltype",
|
||||||
"treatmentTypePlaceholder": "Selecteer behandeltype…",
|
"treatmentTypePlaceholder": "Selecteer behandeltype…",
|
||||||
"treatmentTypeNotSelected": "Type niet geselecteerd",
|
"treatmentTypeNotSelected": "Type niet geselecteerd",
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ export function CaseTasksFocusView({ token }: CaseTasksFocusViewProps) {
|
|||||||
<section className="surface-card p-3 sm:p-4 min-w-0 overflow-x-hidden">
|
<section className="surface-card p-3 sm:p-4 min-w-0 overflow-x-hidden">
|
||||||
<LabCaseCommentsPanel
|
<LabCaseCommentsPanel
|
||||||
caseId={session.labCaseId}
|
caseId={session.labCaseId}
|
||||||
|
viewerSide={session.accessMode === 'lab' ? 'LAB' : 'CLINIC'}
|
||||||
canPost={canPostComments}
|
canPost={canPostComments}
|
||||||
canToggleVisibility={canToggleCommentVisibility}
|
canToggleVisibility={canToggleCommentVisibility}
|
||||||
loadComments={async () => {
|
loadComments={async () => {
|
||||||
|
|||||||
@@ -535,6 +535,7 @@ export function CasesPage() {
|
|||||||
<section id="case-comments" className="scroll-mt-4 border-t border-border pt-4">
|
<section id="case-comments" className="scroll-mt-4 border-t border-border pt-4">
|
||||||
<LabCaseCommentsPanel
|
<LabCaseCommentsPanel
|
||||||
caseId={selectedCaseId}
|
caseId={selectedCaseId}
|
||||||
|
viewerSide="LAB"
|
||||||
canPost={canEditComments}
|
canPost={canEditComments}
|
||||||
canToggleVisibility={canEditComments}
|
canToggleVisibility={canEditComments}
|
||||||
loadComments={async () => {
|
loadComments={async () => {
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, type KeyboardEvent } from 'react';
|
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Eye, EyeOff, Send } from 'lucide-react';
|
import { Eye, EyeOff, Send } from 'lucide-react';
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { useAsyncActionById } from '@/lib/hooks/useAsyncAction';
|
import { useAsyncActionById } from '@/lib/hooks/useAsyncAction';
|
||||||
import type { LabCaseComment } from '@/types/cases';
|
import type { LabCaseComment } from '@/types/cases';
|
||||||
|
|
||||||
|
export type LabCaseCommentViewerSide = 'LAB' | 'CLINIC';
|
||||||
|
|
||||||
interface LabCaseCommentsPanelProps {
|
interface LabCaseCommentsPanelProps {
|
||||||
caseId: string;
|
caseId: string;
|
||||||
|
viewerSide: LabCaseCommentViewerSide;
|
||||||
canPost: boolean;
|
canPost: boolean;
|
||||||
canToggleVisibility: boolean;
|
canToggleVisibility: boolean;
|
||||||
loadComments: () => Promise<LabCaseComment[]>;
|
loadComments: () => Promise<LabCaseComment[]>;
|
||||||
@@ -24,8 +27,15 @@ interface LabCaseCommentsPanelProps {
|
|||||||
onComposerValueChange?: (value: string) => void;
|
onComposerValueChange?: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortNewestFirst(items: LabCaseComment[]): LabCaseComment[] {
|
||||||
|
return [...items].sort(
|
||||||
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LabCaseCommentsPanel({
|
export function LabCaseCommentsPanel({
|
||||||
caseId,
|
caseId,
|
||||||
|
viewerSide,
|
||||||
canPost,
|
canPost,
|
||||||
canToggleVisibility,
|
canToggleVisibility,
|
||||||
loadComments,
|
loadComments,
|
||||||
@@ -45,17 +55,19 @@ export function LabCaseCommentsPanel({
|
|||||||
const [visibleToClinic, setVisibleToClinic] = useState(false);
|
const [visibleToClinic, setVisibleToClinic] = useState(false);
|
||||||
const toggleBusy = useAsyncActionById();
|
const toggleBusy = useAsyncActionById();
|
||||||
|
|
||||||
|
const orderedComments = useMemo(() => sortNewestFirst(comments), [comments]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const items = await loadComments();
|
const items = await loadComments();
|
||||||
setComments(items);
|
setComments(sortNewestFirst(items));
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
onError?.(getUserFacingError(error, tErrors, t('errorLoad')));
|
onError?.(getUserFacingError(error, tErrors, t('errorLoad')));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [loadComments, onError, t]);
|
}, [loadComments, onError, t, tErrors]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -67,7 +79,7 @@ export function LabCaseCommentsPanel({
|
|||||||
setPosting(true);
|
setPosting(true);
|
||||||
try {
|
try {
|
||||||
const created = await onPost(trimmed, visibleToClinic);
|
const created = await onPost(trimmed, visibleToClinic);
|
||||||
setComments((prev) => [...prev, created]);
|
setComments((prev) => sortNewestFirst([created, ...prev]));
|
||||||
setBody('');
|
setBody('');
|
||||||
setVisibleToClinic(false);
|
setVisibleToClinic(false);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -96,8 +108,11 @@ export function LabCaseCommentsPanel({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const composerInputClass =
|
||||||
|
'min-w-0 flex-1 h-9 rounded-md border border-border bg-surface px-3 py-1.5 text-sm leading-tight resize-none';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 min-w-0">
|
||||||
<h4 className="text-sm font-medium text-text-primary">{t('title')}</h4>
|
<h4 className="text-sm font-medium text-text-primary">{t('title')}</h4>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -105,15 +120,22 @@ export function LabCaseCommentsPanel({
|
|||||||
) : comments.length === 0 ? (
|
) : comments.length === 0 ? (
|
||||||
<p className="text-xs text-text-muted">{t('empty')}</p>
|
<p className="text-xs text-text-muted">{t('empty')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2 max-h-48 overflow-y-auto">
|
<div className="max-h-48 overflow-y-auto overflow-x-hidden rounded-md border border-border bg-background p-2 space-y-2">
|
||||||
{comments.map((comment) => (
|
{orderedComments.map((comment) => {
|
||||||
<li
|
const mine = comment.authorSide === viewerSide;
|
||||||
key={comment.id}
|
return (
|
||||||
className="rounded-md border border-border bg-background p-2 text-sm"
|
<div
|
||||||
>
|
key={comment.id}
|
||||||
<div className="flex items-start justify-between gap-2">
|
className={`flex min-w-0 ${mine ? 'justify-start' : 'justify-end'}`}
|
||||||
<div className="min-w-0 flex-1">
|
>
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-text-muted">
|
<div
|
||||||
|
className={`max-w-[85%] min-w-0 text-sm break-words ${mine ? 'text-start' : 'text-end'}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-text-muted ${
|
||||||
|
mine ? 'justify-start' : 'justify-end'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<span className="font-medium text-text-secondary">
|
<span className="font-medium text-text-secondary">
|
||||||
{comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')}
|
{comment.authorSide === 'LAB' ? t('labAuthor') : t('clinicAuthor')}
|
||||||
{comment.authorName ? ` · ${comment.authorName}` : ''}
|
{comment.authorName ? ` · ${comment.authorName}` : ''}
|
||||||
@@ -125,32 +147,32 @@ export function LabCaseCommentsPanel({
|
|||||||
<span>{t('hiddenFromClinic')}</span>
|
<span>{t('hiddenFromClinic')}</span>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
|
{canToggleVisibility && comment.canToggleVisibility && onToggleVisibility ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggle(comment)}
|
||||||
|
disabled={toggleBusy.pendingId !== null}
|
||||||
|
className={`shrink-0 p-0.5 rounded hover:bg-border text-text-muted disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||||
|
toggleBusy.pendingId === comment.id ? 'animate-pulse' : ''
|
||||||
|
}`}
|
||||||
|
title={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
|
||||||
|
aria-label={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
|
||||||
|
aria-busy={toggleBusy.pendingId === comment.id || undefined}
|
||||||
|
>
|
||||||
|
{comment.visibleToClinic ? (
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-text-primary whitespace-pre-wrap">{comment.body}</p>
|
<p className="mt-0.5 text-text-primary whitespace-pre-wrap">{comment.body}</p>
|
||||||
</div>
|
</div>
|
||||||
{canToggleVisibility && comment.canToggleVisibility && onToggleVisibility ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleToggle(comment)}
|
|
||||||
disabled={toggleBusy.pendingId !== null}
|
|
||||||
className={`shrink-0 p-1 rounded hover:bg-border text-text-muted disabled:opacity-40 disabled:cursor-not-allowed ${
|
|
||||||
toggleBusy.pendingId === comment.id ? 'animate-pulse' : ''
|
|
||||||
}`}
|
|
||||||
title={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
|
|
||||||
aria-label={comment.visibleToClinic ? t('makeHidden') : t('makeVisible')}
|
|
||||||
aria-busy={toggleBusy.pendingId === comment.id || undefined}
|
|
||||||
>
|
|
||||||
{comment.visibleToClinic ? (
|
|
||||||
<Eye className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<EyeOff className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
);
|
||||||
))}
|
})}
|
||||||
</ul>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canPost && deferSubmit ? (
|
{canPost && deferSubmit ? (
|
||||||
@@ -159,46 +181,48 @@ export function LabCaseCommentsPanel({
|
|||||||
value={composerValue ?? ''}
|
value={composerValue ?? ''}
|
||||||
onChange={(e) => onComposerValueChange?.(e.target.value)}
|
onChange={(e) => onComposerValueChange?.(e.target.value)}
|
||||||
placeholder={t('placeholder')}
|
placeholder={t('placeholder')}
|
||||||
rows={2}
|
rows={1}
|
||||||
className="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm resize-none"
|
className="w-full h-9 rounded-md border border-border bg-surface px-3 py-1.5 text-sm leading-tight resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : canPost ? (
|
) : canPost ? (
|
||||||
<div className="flex items-end gap-2 border-t border-border pt-2">
|
<div className="flex items-center gap-2 border-t border-border pt-2 min-w-0">
|
||||||
<textarea
|
<textarea
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={(e) => setBody(e.target.value)}
|
||||||
onKeyDown={handleComposerKeyDown}
|
onKeyDown={handleComposerKeyDown}
|
||||||
placeholder={t('placeholder')}
|
placeholder={t('placeholder')}
|
||||||
rows={2}
|
rows={1}
|
||||||
className="min-w-0 flex-1 rounded-md border border-border bg-surface px-3 py-2 text-sm resize-none"
|
className={composerInputClass}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-1 pb-1">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
{canToggleVisibility ? (
|
{canToggleVisibility ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setVisibleToClinic((v) => !v)}
|
onClick={() => setVisibleToClinic((v) => !v)}
|
||||||
className={`shrink-0 p-2 rounded-md border transition-colors ${
|
className={`shrink-0 h-9 w-9 inline-flex items-center justify-center rounded-md bg-primary text-white transition-colors hover:opacity-90 ${
|
||||||
visibleToClinic
|
visibleToClinic ? '' : 'opacity-50'
|
||||||
? 'border-primary/40 bg-primary/10 text-primary'
|
|
||||||
: 'border-border text-text-muted hover:text-text-primary'
|
|
||||||
}`}
|
}`}
|
||||||
title={visibleToClinic ? t('composerVisible') : t('composerHidden')}
|
title={visibleToClinic ? t('composerVisible') : t('composerHidden')}
|
||||||
aria-label={visibleToClinic ? t('composerVisible') : t('composerHidden')}
|
aria-label={visibleToClinic ? t('composerVisible') : t('composerHidden')}
|
||||||
aria-pressed={visibleToClinic}
|
aria-pressed={visibleToClinic}
|
||||||
>
|
>
|
||||||
{visibleToClinic ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
{visibleToClinic ? (
|
||||||
|
<Eye className="h-4 w-4 text-white stroke-current" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="h-4 w-4 text-white stroke-current" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handlePost()}
|
onClick={() => handlePost()}
|
||||||
disabled={posting || !body.trim()}
|
disabled={posting || !body.trim()}
|
||||||
className="shrink-0 p-2 rounded-md bg-primary text-white transition-colors hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
|
className="shrink-0 h-9 w-9 inline-flex items-center justify-center rounded-md bg-primary text-white transition-colors hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
title={t('send')}
|
title={t('send')}
|
||||||
aria-label={t('send')}
|
aria-label={t('send')}
|
||||||
>
|
>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4 text-white stroke-current fill-none rtl:-scale-x-100" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ export function TaskRow({
|
|||||||
<div className="px-3 pb-3 border-t border-border/50">
|
<div className="px-3 pb-3 border-t border-border/50">
|
||||||
<LabCaseCommentsPanel
|
<LabCaseCommentsPanel
|
||||||
caseId={task.labCaseId}
|
caseId={task.labCaseId}
|
||||||
|
viewerSide="LAB"
|
||||||
canPost
|
canPost
|
||||||
canToggleVisibility
|
canToggleVisibility
|
||||||
loadComments={async () => {
|
loadComments={async () => {
|
||||||
|
|||||||
@@ -538,6 +538,7 @@ export function TasksPage() {
|
|||||||
<div className="border-b border-border/50 px-3 pb-3">
|
<div className="border-b border-border/50 px-3 pb-3">
|
||||||
<LabCaseCommentsPanel
|
<LabCaseCommentsPanel
|
||||||
caseId={caseGroup.labCaseId}
|
caseId={caseGroup.labCaseId}
|
||||||
|
viewerSide="LAB"
|
||||||
canPost
|
canPost
|
||||||
canToggleVisibility
|
canToggleVisibility
|
||||||
loadComments={async () => {
|
loadComments={async () => {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function DetailLabCaseCommentsSection({
|
|||||||
<div className="border-t border-border/60 pt-4">
|
<div className="border-t border-border/60 pt-4">
|
||||||
<LabCaseCommentsPanel
|
<LabCaseCommentsPanel
|
||||||
caseId={labCaseId}
|
caseId={labCaseId}
|
||||||
|
viewerSide="CLINIC"
|
||||||
canPost={canPost}
|
canPost={canPost}
|
||||||
canToggleVisibility={false}
|
canToggleVisibility={false}
|
||||||
deferSubmit={deferSubmit}
|
deferSubmit={deferSubmit}
|
||||||
|
|||||||
@@ -341,7 +341,7 @@ export function FdiToothChart({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-start gap-1.5 sm:items-end shrink-0">
|
<div className="flex flex-col items-start gap-1.5 sm:items-end shrink-0">
|
||||||
{headerControl}
|
{headerControl}
|
||||||
<p className="text-[11px] text-text-secondary tabular-nums sm:text-right">
|
<p className="text-[11px] text-text-secondary tabular-nums sm:text-end">
|
||||||
{t('selectedLabel')}{' '}
|
{t('selectedLabel')}{' '}
|
||||||
{selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
|
{selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -45,19 +45,10 @@ interface LabCasesDispatchPanelProps {
|
|||||||
canInviteLab?: boolean;
|
canInviteLab?: boolean;
|
||||||
onInviteLab?: () => void;
|
onInviteLab?: () => void;
|
||||||
sendBusyId: string | null;
|
sendBusyId: string | null;
|
||||||
onAddLabCase: () => void | Promise<void>;
|
|
||||||
onSendLabCase: (labCase: LabCaseDraft, comment?: string) => void | Promise<void>;
|
onSendLabCase: (labCase: LabCaseDraft, comment?: string) => void | Promise<void>;
|
||||||
onCommentError?: (message: string) => void;
|
onCommentError?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sentDetailClientIds(labCases: LabCaseDraft[]): Set<string> {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
for (const lc of labCases) {
|
|
||||||
if (lc.sentAt && lc.detailClientId) ids.add(lc.detailClientId);
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProsthesisGroupRow = {
|
type ProsthesisGroupRow = {
|
||||||
groupId: string;
|
groupId: string;
|
||||||
kind: 'connected' | 'single';
|
kind: 'connected' | 'single';
|
||||||
@@ -129,7 +120,6 @@ export function LabCasesDispatchPanel({
|
|||||||
canInviteLab = false,
|
canInviteLab = false,
|
||||||
onInviteLab,
|
onInviteLab,
|
||||||
sendBusyId,
|
sendBusyId,
|
||||||
onAddLabCase,
|
|
||||||
onSendLabCase,
|
onSendLabCase,
|
||||||
onCommentError,
|
onCommentError,
|
||||||
}: LabCasesDispatchPanelProps) {
|
}: LabCasesDispatchPanelProps) {
|
||||||
@@ -157,8 +147,6 @@ export function LabCasesDispatchPanel({
|
|||||||
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
|
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
|
||||||
|
|
||||||
const detailAlreadyInShipment = Boolean(labCaseForActiveDetail);
|
const detailAlreadyInShipment = Boolean(labCaseForActiveDetail);
|
||||||
const canAddLabShipment =
|
|
||||||
!detailAlreadyInShipment && !sentDetailClientIds(labCases).has(activeDetailId);
|
|
||||||
|
|
||||||
const sent = Boolean(activeLabCase?.sentAt);
|
const sent = Boolean(activeLabCase?.sentAt);
|
||||||
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
|
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
|
||||||
@@ -289,23 +277,14 @@ export function LabCasesDispatchPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderShipmentCardHeader() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-3 border-b border-border/60 pb-3 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<p className="text-xs font-semibold text-text-primary">{t('labShipmentIncludedDetails')}</p>
|
|
||||||
{renderDueDateField()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderDueDateField() {
|
function renderDueDateField() {
|
||||||
if (!activeLabCase) return null;
|
if (!activeLabCase) return null;
|
||||||
const inputValue = toDateInputValue(activeLabCase.dueDate);
|
const inputValue = toDateInputValue(activeLabCase.dueDate);
|
||||||
const dueDateInputId = `lab-case-due-date-${activeLabCase.clientId}`;
|
const dueDateInputId = `lab-case-due-date-${activeLabCase.clientId}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 sm:text-end">
|
<div className="w-full min-w-0 sm:w-auto sm:shrink-0 sm:text-end">
|
||||||
<div className="flex items-center gap-2 sm:justify-end">
|
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||||
<label
|
<label
|
||||||
htmlFor={dueDateInputId}
|
htmlFor={dueDateInputId}
|
||||||
className="text-xs font-medium text-text-secondary shrink-0"
|
className="text-xs font-medium text-text-secondary shrink-0"
|
||||||
@@ -323,7 +302,7 @@ export function LabCasesDispatchPanel({
|
|||||||
onBlur={(committed) => {
|
onBlur={(committed) => {
|
||||||
if (sent) void handleSentDueDateBlur(committed);
|
if (sent) void handleSentDueDateBlur(committed);
|
||||||
}}
|
}}
|
||||||
className={`${FORM_SELECT_CLASS} w-full max-w-[11rem] rounded-md py-1.5 text-sm`}
|
className={`${FORM_SELECT_CLASS} w-full min-w-0 max-w-full sm:max-w-[11rem] rounded-md py-1.5 text-sm`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{sent && caseFullyComplete && activeLabCase.dueDate ? (
|
{sent && caseFullyComplete && activeLabCase.dueDate ? (
|
||||||
@@ -335,7 +314,6 @@ export function LabCasesDispatchPanel({
|
|||||||
|
|
||||||
function renderIncludedDetailSummary() {
|
function renderIncludedDetailSummary() {
|
||||||
if (!activeDetail) return null;
|
if (!activeDetail) return null;
|
||||||
const typeLabel = treatmentTypeLabelFromCatalog(activeDetail.treatmentType, treatmentCatalog);
|
|
||||||
|
|
||||||
if (activeDetail.treatmentType !== 'prosthesis' || !activeLabCase) {
|
if (activeDetail.treatmentType !== 'prosthesis' || !activeLabCase) {
|
||||||
return (
|
return (
|
||||||
@@ -375,9 +353,6 @@ export function LabCasesDispatchPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2 space-y-1">
|
<div className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2 space-y-1">
|
||||||
<p className="text-text-primary">
|
|
||||||
{t('detailLabel', { n: activeDetailNumber })} · {typeLabel}
|
|
||||||
</p>
|
|
||||||
{rows.map((g) => (
|
{rows.map((g) => (
|
||||||
<p
|
<p
|
||||||
key={g.groupId}
|
key={g.groupId}
|
||||||
@@ -401,31 +376,19 @@ export function LabCasesDispatchPanel({
|
|||||||
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
|
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="surface-card p-4 space-y-4">
|
<div className="surface-card p-3 sm:p-4 space-y-4 min-w-0 overflow-x-hidden">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h3 className="text-sm font-semibold text-text-primary">{t('labDispatchTitle')}</h3>
|
<h3 className="text-sm font-semibold text-text-primary">{t('labDispatchTitle')}</h3>
|
||||||
<p className="text-xs text-text-muted mt-0.5">
|
<p className="text-xs text-text-muted mt-0.5">
|
||||||
{t('labDispatchSubtitle')} {t('labDispatchSendHint')}
|
{t('labDispatchSubtitle')} {t('labDispatchSendHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{canAddLabShipment && (
|
{detailAlreadyInShipment ? renderDueDateField() : null}
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="primary"
|
|
||||||
disabled={!canEdit || disabled}
|
|
||||||
onClick={onAddLabCase}
|
|
||||||
>
|
|
||||||
{t('addLabShipment')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!detailAlreadyInShipment ? (
|
{activeLabCase ? (
|
||||||
<p className="text-xs text-text-muted">{t('labDispatchEmpty')}</p>
|
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-3 sm:p-4 bg-background-secondary/30 min-w-0">
|
||||||
) : activeLabCase ? (
|
|
||||||
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-3 sm:p-4 bg-background-secondary/30">
|
|
||||||
{renderShipmentCardHeader()}
|
|
||||||
{sent ? (
|
{sent ? (
|
||||||
<>
|
<>
|
||||||
{renderIncludedDetailSummary()}
|
{renderIncludedDetailSummary()}
|
||||||
@@ -589,39 +552,38 @@ export function LabCasesDispatchPanel({
|
|||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={row.groupId}
|
key={row.groupId}
|
||||||
className="block text-xs text-text-muted space-y-1 rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/40 px-3 py-2"
|
className="block text-xs text-text-muted rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/40 px-3 py-2 min-w-0"
|
||||||
>
|
>
|
||||||
<span className="flex flex-wrap items-center gap-2 text-text-secondary">
|
<span className="grid grid-cols-1 gap-2 md:grid-cols-2 md:items-center md:gap-3">
|
||||||
{row.kind === 'connected' ? <ConnectedSelectionBadge /> : null}
|
<span className="flex flex-wrap items-center gap-2 text-text-secondary min-w-0 break-words">
|
||||||
<span>
|
{row.kind === 'connected' ? <ConnectedSelectionBadge /> : null}
|
||||||
{row.kind === 'connected'
|
<span className="min-w-0">
|
||||||
? t('prosthesisConnectedLabel')
|
{row.kind === 'connected'
|
||||||
: t('prosthesisColTooth')}
|
? t('prosthesisConnectedLabel')
|
||||||
{': '}
|
: t('prosthesisColTooth')}
|
||||||
<span className="text-text-primary">{row.teeth.join(', ')}</span>
|
{': '}
|
||||||
</span>
|
<span className="text-text-primary">{row.teeth.join(', ')}</span>
|
||||||
<span className="text-text-muted">
|
</span>
|
||||||
· {t('detailLabel', { n: row.detailNumber })}
|
|
||||||
</span>
|
</span>
|
||||||
|
<select
|
||||||
|
value={current}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => setGroupProsthesis(row, e.target.value)}
|
||||||
|
className={`${FORM_SELECT_CLASS} w-full min-w-0`}
|
||||||
|
aria-label={
|
||||||
|
row.kind === 'connected'
|
||||||
|
? t('prosthesisConnectedLabel')
|
||||||
|
: t('prosthesisColType')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">{t('prosthesisSelectPlaceholder')}</option>
|
||||||
|
{prosthesisOptions.map((opt) => (
|
||||||
|
<option key={opt.code} value={opt.code}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</span>
|
</span>
|
||||||
<select
|
|
||||||
value={current}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => setGroupProsthesis(row, e.target.value)}
|
|
||||||
className={`${FORM_SELECT_CLASS} w-full mt-1`}
|
|
||||||
aria-label={
|
|
||||||
row.kind === 'connected'
|
|
||||||
? t('prosthesisConnectedLabel')
|
|
||||||
: t('prosthesisColType')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">{t('prosthesisSelectPlaceholder')}</option>
|
|
||||||
{prosthesisOptions.map((opt) => (
|
|
||||||
<option key={opt.code} value={opt.code}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -221,9 +221,9 @@ export function TreatmentDetailsEditor({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
placeholder={t('commentsPlaceholder')}
|
placeholder={t('commentsPlaceholder')}
|
||||||
rows={5}
|
rows={2}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[120px]"
|
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[60px]"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -1547,6 +1547,27 @@ export function TreatmentWorkspace({
|
|||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Auto-open shipment draft when entering Lab (no manual "Add lab shipment" click).
|
||||||
|
useEffect(() => {
|
||||||
|
if (entryStep !== 'lab') return;
|
||||||
|
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||||
|
const activeDetail = details.find((d) => d.clientId === activeDetailId);
|
||||||
|
if (!activeDetail || !isDetailReadyForLabDispatch(activeDetail, labDependentCodes)) return;
|
||||||
|
if (isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes)) return;
|
||||||
|
const hasDraft = labCaseDrafts.some((lc) => lc.detailClientId === activeDetailId);
|
||||||
|
if (hasDraft) return;
|
||||||
|
void handleAddLabCase();
|
||||||
|
}, [
|
||||||
|
activeDetailId,
|
||||||
|
canEditTreatmentForDay,
|
||||||
|
details,
|
||||||
|
entryStep,
|
||||||
|
handleAddLabCase,
|
||||||
|
labCaseDrafts,
|
||||||
|
labDependentCodes,
|
||||||
|
selectedAppointment,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSendLabCase = useCallback(
|
const handleSendLabCase = useCallback(
|
||||||
async (labCase: LabCaseDraft, comment?: string) => {
|
async (labCase: LabCaseDraft, comment?: string) => {
|
||||||
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||||
@@ -2058,7 +2079,6 @@ export function TreatmentWorkspace({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
sendBusyId={sendBusyId}
|
sendBusyId={sendBusyId}
|
||||||
onAddLabCase={() => handleAddLabCase()}
|
|
||||||
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
|
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
|
||||||
onCommentError={showError}
|
onCommentError={showError}
|
||||||
canInviteLab={canAccessOrganizations}
|
canInviteLab={canAccessOrganizations}
|
||||||
|
|||||||
Reference in New Issue
Block a user