bugfix: no-detail treatment ui made some confusion. it is improved. at least to a degree.

This commit is contained in:
2026-08-22 19:36:37 +03:30
parent f52ad6b84a
commit 50725b7b3f
9 changed files with 27 additions and 43 deletions

View File

@@ -10,14 +10,14 @@ alwaysApply: false
- **Current draft** preview: omitted in live editing (form is the source). History browse still uses preview + **Load into workspace**. - **Current draft** preview: omitted in live editing (form is the source). History browse still uses preview + **Load into workspace**.
- **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row, bordered chart (Cases chrome), then full-width auto-growing Notes. No stepper unless prosthesis — then `WizardStepper` Treatment → Lab with Back/Next. Chip switches reset to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail open). Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments. - **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row, bordered chart (Cases chrome), then full-width auto-growing Notes. No stepper unless prosthesis — then `WizardStepper` Treatment → Lab with Back/Next. Chip switches reset to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail open). Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments.
- **Tooth hits:** unrotated full cell, `pointerdown` only (not `click` too — double-toggles). Glyph `pointer-events-none`; nest scale/hover inside the rotate wrapper. Groups: `toothSelectionGroups.ts` (never 1-tooth connected); prune lab `toothProsthesis` on change. - **Tooth hits:** unrotated full cell, `pointerdown` only (not `click` too — double-toggles). Glyph `pointer-events-none`; nest scale/hover inside the rotate wrapper. Groups: `toothSelectionGroups.ts` (never 1-tooth connected); prune lab `toothProsthesis` on change.
- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner once typed (sync live draft onto the open card); untyped keep chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`. - **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner from the **first details type only** (live draft for the open card); empty first line keeps chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`.
- **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 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. - **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 (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Empty details persist as `[]`. New treatment seeds one blank detail; load of empty stays `[]`. No delete in type/notes fields. - **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Last-line confirm: plan will be empty until Add. Empty `[]` shows `noDetails` (not the type-first overlay). New treatment seeds one blank detail. No delete in type/notes fields.
- **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.
- **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`. - **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`.
- **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — same inline amber banner on Treatment and Lab (`labBlockedBannerClass`); toast on add shipment. Empty plan (`[]`) still shows type-first overlay (not a blank Add-only card). - **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — same inline amber banner on Treatment and Lab (`labBlockedBannerClass`); toast on add shipment.
- **Edit gating:** `canEditTreatmentForDay` = permission + appointment + not past day + `live` mode. Sent detail locks that line; Add still OK same day. Upload rejects sent (`TREATMENT_DETAIL_SENT`). - **Edit gating:** `canEditTreatmentForDay` = permission + appointment + not past day + `live` mode. Sent detail locks that line; Add still OK same day. Upload rejects sent (`TREATMENT_DETAIL_SENT`).
- **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted. New dispatch lines start empty (lab + apply-all / per-tooth prosthesis type). Chips = last 3 **sent** labs (`labDispatchDefaults.ts`) — never auto-select. Clear the search box when switching details. - **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted. New dispatch lines start empty (lab + apply-all / per-tooth prosthesis type). Chips = last 3 **sent** labs (`labDispatchDefaults.ts`) — never auto-select. Clear the search box when switching details.
- **Scroll:** `scrollWithinMainScrollContainer`; shared `Checkbox` only. - **Scroll:** `scrollWithinMainScrollContainer`; shared `Checkbox` only.

View File

@@ -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`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner once a treatment type is selected (live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous cards type). Until typed they keep chip theming. Trash inherits banner ink on typed cards. Strip trash only when `areUnscheduledDetailsStripDeletable` (no type/teeth/notes/attachments, including `[]`). 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. New treatment seeds one blank detail so the type field is ready; a persisted empty plan hydrates as `[]` until Add. 1. **Day strip**`AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner from the **first details type only** (`unscheduledStripColorCode`; live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous cards type). Empty first line → chip theming even if later lines are typed. Trash inherits banner ink on typed cards. Strip trash only when `areUnscheduledDetailsStripDeletable` (no type/teeth/notes/attachments, including `[]`). 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. New treatment seeds one blank detail so the type field is ready; a persisted empty plan hydrates as `[]` until Add (`noDetails` copy — not the type-first overlay). Last-line chip delete confirms the plan will be empty until Add.
2. **Treatment preview**`TreatmentPreviewCard.tsx` (history browse only; omitted for the live draft) 2. **Treatment preview**`TreatmentPreviewCard.tsx` (history browse only; omitted for the live draft)

View File

@@ -45,7 +45,7 @@ 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. **New treatment** opens a patient picker (Walk-in always visible); it does not copy the selected appointments patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add. - Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. **New treatment** opens a patient picker (Walk-in always visible); it does not copy the selected appointments patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add.
- **Unscheduled strip cards** use the same treatment-type banner as appointments once a type is selected (live draft for the open card). Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`). - **Unscheduled strip cards** use the same treatment-type banner as appointments from the **first details type only** (`unscheduledStripColorCode`; live draft for the open card). Empty first line → chip theming even if later lines are typed. Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`).
- Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash on chip (unsent, including last line; day/edit gates apply). Empty details persist as `[]`. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`). - Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash on chip (unsent, including last line; day/edit gates apply). Empty details persist as `[]`. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`).
- **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row, then bordered FDI chart (Cases chrome/scale), then full-width auto-growing **Notes**. No wizard for non-lab types. Prosthesis: `WizardStepper` Treatment → Lab with Back/Next. Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on Lab tab). Switching chips resets to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail). Lab dispatch keeps comments. - **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row, then bordered FDI chart (Cases chrome/scale), then full-width auto-growing **Notes**. No wizard for non-lab types. Prosthesis: `WizardStepper` Treatment → Lab with Back/Next. Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on Lab tab). Switching chips resets to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail). Lab dispatch keeps comments.
- **Tooth selection:** Hit target is the unrotated cell (`pointerdown` only — do not also bind `click`). Glyph is `pointer-events-none`; nest hover/selected scale inside the rotate wrapper. Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; Shift+range selects only (empty circles; overlap absorbs as singles); midline 1121 / 4131 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type. - **Tooth selection:** Hit target is the unrotated cell (`pointerdown` only — do not also bind `click`). Glyph is `pointer-events-none`; nest hover/selected scale inside the rotate wrapper. Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; Shift+range selects only (empty circles; overlap absorbs as singles); midline 1121 / 4131 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.

View File

@@ -717,6 +717,7 @@
"detailsTitle": "Treatment details", "detailsTitle": "Treatment details",
"addDetail": "Add detail", "addDetail": "Add detail",
"confirmRemoveDetail": "Remove this treatment detail?", "confirmRemoveDetail": "Remove this treatment detail?",
"confirmRemoveLastDetail": "This is the last detail. Removing it leaves the plan empty until you add another. Continue?",
"removeDetailAria": "Remove detail {n}", "removeDetailAria": "Remove detail {n}",
"detailLabel": "Detail {n}", "detailLabel": "Detail {n}",
"detailSentBadge": "sent", "detailSentBadge": "sent",

View File

@@ -718,6 +718,7 @@
"detailsTitle": "جزئیات درمان", "detailsTitle": "جزئیات درمان",
"addDetail": "افزودن جزئیات", "addDetail": "افزودن جزئیات",
"confirmRemoveDetail": "این جزئیات درمان حذف شود؟", "confirmRemoveDetail": "این جزئیات درمان حذف شود؟",
"confirmRemoveLastDetail": "این آخرین جزئیات است. با حذف آن برنامه خالی می‌ماند تا جزئیات جدیدی اضافه کنید. ادامه می‌دهید؟",
"removeDetailAria": "حذف جزئیات {n}", "removeDetailAria": "حذف جزئیات {n}",
"detailLabel": "جزئیات {n}", "detailLabel": "جزئیات {n}",
"detailSentBadge": "ارسال‌شده", "detailSentBadge": "ارسال‌شده",

View File

@@ -717,6 +717,7 @@
"detailsTitle": "Behandeldetails", "detailsTitle": "Behandeldetails",
"addDetail": "Detail toevoegen", "addDetail": "Detail toevoegen",
"confirmRemoveDetail": "Dit behandelingsdetail verwijderen?", "confirmRemoveDetail": "Dit behandelingsdetail verwijderen?",
"confirmRemoveLastDetail": "Dit is het laatste detail. Als u het verwijdert, blijft het plan leeg tot u een nieuw detail toevoegt. Doorgaan?",
"removeDetailAria": "Detail {n} verwijderen", "removeDetailAria": "Detail {n} verwijderen",
"detailLabel": "Detail {n}", "detailLabel": "Detail {n}",
"detailSentBadge": "verzonden", "detailSentBadge": "verzonden",

View File

@@ -12,3 +12,10 @@ export type DayStripItem = {
subtitle: string; subtitle: string;
canDelete?: boolean; canDelete?: boolean;
}; };
/** Unscheduled card banner: first lines type only. Empty / missing type → no color. */
export function unscheduledStripColorCode(
details: readonly { treatmentType?: string | null }[],
): string {
return details[0]?.treatmentType?.trim() ?? '';
}

View File

@@ -28,7 +28,7 @@ interface TreatmentDetailsEditorProps {
canEdit: boolean; canEdit: boolean;
saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error'; saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
uploadBusy: boolean; uploadBusy: boolean;
onAddDetail: (initialType?: string) => void; onAddDetail: () => void;
onRemoveDetail?: (detailClientId: string) => void; onRemoveDetail?: (detailClientId: string) => void;
onUploadFiles: (files: File[], onProgress: (percent: number) => void) => Promise<void>; onUploadFiles: (files: File[], onProgress: (percent: number) => void) => Promise<void>;
onRemoveAttachment?: (attachmentId: string) => void; onRemoveAttachment?: (attachmentId: string) => void;
@@ -113,7 +113,7 @@ export function TreatmentDetailsEditor({
type="button" type="button"
variant="primary" variant="primary"
disabled={!canEdit || disabled} disabled={!canEdit || disabled}
onClick={() => onAddDetail()} onClick={onAddDetail}
fullWidth fullWidth
className="sm:w-auto shrink-0" className="sm:w-auto shrink-0"
> >
@@ -269,34 +269,7 @@ export function TreatmentDetailsEditor({
{footer ? <div className="pt-1">{footer}</div> : null} {footer ? <div className="pt-1">{footer}</div> : null}
</div> </div>
) : showFields && !activeDetail ? ( ) : showFields && !activeDetail ? (
<div className="space-y-3"> <p className="text-sm text-text-muted">{t('noDetails')}</p>
<Dropdown
label={t('treatmentType')}
value=""
onChange={(e) => {
const nextType = e.target.value;
if (nextType) onAddDetail(nextType);
}}
disabled={!canEdit || disabled}
>
<option value="">{t('treatmentTypePlaceholder')}</option>
{treatmentCatalog.map((entry, index) => (
<option key={entry.code} value={entry.code} style={treatmentTypeOptionStyle(entry.code, index)}>
{entry.label}
</option>
))}
</Dropdown>
{chart ? (
<div className="relative min-w-0 w-full">
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-[var(--radius-md)] bg-background-card/70 px-4">
<p className="text-sm text-text-secondary text-center">{chartLockMessage}</p>
</div>
<div className="pointer-events-none opacity-40">{chart}</div>
</div>
) : chartLockMessage ? (
<p className="text-sm text-text-secondary">{chartLockMessage}</p>
) : null}
</div>
) : null} ) : null}
</div> </div>
); );

View File

@@ -32,7 +32,7 @@ import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import { treatmentsApi } from '@/lib/api/treatments'; import { treatmentsApi } from '@/lib/api/treatments';
import { notificationsApi } from '@/lib/api/notifications'; import { notificationsApi } from '@/lib/api/notifications';
import { pickAutoAppointment } from '@/components/shared/treatmentSelection'; import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
import type { DayStripItem } from '@/components/treatment/dayStrip'; import { unscheduledStripColorCode, type DayStripItem } from '@/components/treatment/dayStrip';
import { import {
areDetailsPersistable, areDetailsPersistable,
defaultTreatmentTypeForAppointment, defaultTreatmentTypeForAppointment,
@@ -603,8 +603,8 @@ export function TreatmentWorkspace({
setStandaloneTreatments((prev) => { setStandaloneTreatments((prev) => {
const current = prev.find((row) => row.id === selectedStandaloneId); const current = prev.find((row) => row.id === selectedStandaloneId);
if (!current) return prev; if (!current) return prev;
const prevColor = current.details.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? ''; const prevColor = unscheduledStripColorCode(current.details);
const nextColor = nextDetails.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? ''; const nextColor = unscheduledStripColorCode(nextDetails);
if ( if (
prevColor === nextColor && prevColor === nextColor &&
areUnscheduledDetailsStripDeletable(current.details) === areUnscheduledDetailsStripDeletable(current.details) ===
@@ -635,8 +635,7 @@ export function TreatmentWorkspace({
const sourceDetails = tr.id === selectedStandaloneId && !draftHydratingRef.current const sourceDetails = tr.id === selectedStandaloneId && !draftHydratingRef.current
? details ? details
: tr.details; : tr.details;
const typedDetail = sourceDetails.find((d) => Boolean(d.treatmentType?.trim())); const colorCode = unscheduledStripColorCode(sourceDetails);
const colorCode = typedDetail?.treatmentType?.trim() ?? '';
return { return {
kind: 'unscheduled' as const, kind: 'unscheduled' as const,
id: tr.id, id: tr.id,
@@ -1887,7 +1886,9 @@ export function TreatmentWorkspace({
if (idx < 0) return; if (idx < 0) return;
const target = details[idx]; const target = details[idx];
if (!target || isDetailLocked(target)) return; if (!target || isDetailLocked(target)) return;
if (!window.confirm(t('confirmRemoveDetail'))) return; if (!window.confirm(
details.length <= 1 ? t('confirmRemoveLastDetail') : t('confirmRemoveDetail'),
)) return;
const nextDetails = details.filter((d) => d.clientId !== detailClientId); const nextDetails = details.filter((d) => d.clientId !== detailClientId);
const nextActive = const nextActive =
@@ -2342,8 +2343,8 @@ export function TreatmentWorkspace({
canEdit={canEdit} canEdit={canEdit}
saveStatus={saveStatus} saveStatus={saveStatus}
uploadBusy={uploadBusyDetailId === activeDetailId} uploadBusy={uploadBusyDetailId === activeDetailId}
onAddDetail={(initialType) => { onAddDetail={() => {
const next = newDetail(initialType); const next = newDetail();
setDetails((prev) => [...prev, next]); setDetails((prev) => [...prev, next]);
setActiveDetailId(next.clientId); setActiveDetailId(next.clientId);
setEntryStep('treatment'); setEntryStep('treatment');