improvement/ux-overhaul up #61
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description: Lab Tasks tab — sort, grouping, filters, prosthesis colors
|
description: Lab Tasks tab — sort, grouping, filters, prosthesis colors
|
||||||
globs: frontend/src/components/ui/lab/TasksPage.tsx,frontend/src/components/ui/lab/Task*.tsx,frontend/src/components/ui/lab/Case*.tsx,frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx,frontend/src/components/lab/taskListGrouping.ts,frontend/src/components/lab/labCaseDueDateDisplay.ts,frontend/src/components/treatment/prosthesisTypeDisplay.ts,frontend/src/components/shared/catalog-type-colors.ts,backend/src/modules/tasks/**,backend/src/common/lab-case-due-date.ts,backend/src/modules/treatments/**
|
globs: frontend/src/components/ui/lab/TasksPage.tsx,frontend/src/components/ui/lab/Task*.tsx,frontend/src/components/ui/lab/Case*.tsx,frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx,frontend/src/components/lab/taskListGrouping.ts,frontend/src/components/lab/labCaseDueDateDisplay.ts,frontend/src/components/shared/formSelectStyles.ts,frontend/src/components/treatment/prosthesisTypeDisplay.ts,frontend/src/components/shared/catalog-type-colors.ts,backend/src/modules/tasks/**,backend/src/common/lab-case-due-date.ts,backend/src/modules/treatments/**
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ alwaysApply: false
|
|||||||
- **Important first:** `pinImportant=true` (sort pin, not filter).
|
- **Important first:** `pinImportant=true` (sort pin, not filter).
|
||||||
- **Task assignment:** assign in Cases (`TAB_CASES_EDIT`); status edit on Tasks only for assignee or unassigned tasks; others see “Assigned to {name}”.
|
- **Task assignment:** assign in Cases (`TAB_CASES_EDIT`); status edit on Tasks only for assignee or unassigned tasks; others see “Assigned to {name}”.
|
||||||
- **Case due dates:** clinic sets in Treatment lab dispatch; lab sees on Cases/Tasks; `overdue` filter + `sortBy=dueDate` on Tasks.
|
- **Case due dates:** clinic sets in Treatment lab dispatch; lab sees on Cases/Tasks; `overdue` filter + `sortBy=dueDate` on Tasks.
|
||||||
|
- **Mobile UX:** `LAB_TASK_STATUS_SELECT_CLASS` (44px tap target on small screens); `TaskCaseGroupHeader` sticky while scrolling grouped tasks; filter selects use same touch sizing on Tasks.
|
||||||
- **Show in case:** `GET /tasks/locate-page` finds page in full list; highlight + scroll.
|
- **Show in case:** `GET /tasks/locate-page` finds page in full list; highlight + scroll.
|
||||||
|
|
||||||
Full map: `.cursor/skills/lab-tasks/SKILL.md`
|
Full map: `.cursor/skills/lab-tasks/SKILL.md`
|
||||||
|
|||||||
@@ -75,7 +75,16 @@ List items include `caseSentAt`, `caseDueDate`, `isCaseOverdue`, `assignee`, `as
|
|||||||
- **Clinic set:** Treatment lab dispatch panel — date input on unsent shipment (saved with draft/send); on sent cases, blur saves via `PATCH /treatments/lab-cases/:labCaseId/due-date`.
|
- **Clinic set:** Treatment lab dispatch panel — date input on unsent shipment (saved with draft/send); on sent cases, blur saves via `PATCH /treatments/lab-cases/:labCaseId/due-date`.
|
||||||
- **Edit lock:** Clinic cannot change due date after **all** tasks are completed (`taskProgress.completed === taskProgress.total`).
|
- **Edit lock:** Clinic cannot change due date after **all** tasks are completed (`taskProgress.completed === taskProgress.total`).
|
||||||
- **Lab display:** Cases list + detail; Tasks case group header when grouped by date.
|
- **Lab display:** Cases list + detail; Tasks case group header when grouped by date.
|
||||||
- **Utils:** `backend/src/common/lab-case-due-date.ts`, `frontend/src/components/lab/labCaseDueDateDisplay.ts`.
|
- **Utils:** `backend/src/common/lab-case-due-date.ts`, `frontend/src/components/lab/labCaseDueDateDisplay.ts`, `LabCaseDueDateBadge`.
|
||||||
|
|
||||||
|
## Mobile UX (Tasks + Treatment dispatch)
|
||||||
|
|
||||||
|
Keep changes minimal — match existing `sm:` breakpoint patterns elsewhere in the app.
|
||||||
|
|
||||||
|
- **Task status control:** `LAB_TASK_STATUS_SELECT_CLASS` in [`formSelectStyles.ts`](frontend/src/components/shared/formSelectStyles.ts) — full-width, `min-h-[44px]`, `text-base` on mobile; compact on `sm+`. Read-only status / assignee badges match height on mobile.
|
||||||
|
- **Sticky case header:** `TaskCaseGroupHeader` uses `sticky top-0 z-10` + translucent background when `sortBy=date` (grouped view). Sticks within dashboard `<main>` scroll.
|
||||||
|
- **Tasks filters:** filter `<select>`s use the same 44px mobile height as other form controls.
|
||||||
|
- **Treatment lab dispatch:** shipment card `p-3 sm:p-4`; **Send to lab** is `w-full sm:w-auto`.
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,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 case comments** on a detail when sent and lab case tasks are not all `COMPLETED` (`taskProgress` from API).
|
- **Lab case comments** on a detail when sent and lab case tasks are not all `COMPLETED` (`taskProgress` from API).
|
||||||
|
|
||||||
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from `PROSTHESIS_TYPE_COLORS` via 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 — see `.cursor/skills/lab-tasks/SKILL.md`.
|
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from `PROSTHESIS_TYPE_COLORS` via 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; **mobile:** larger task status controls, sticky case header when grouped — see `.cursor/skills/lab-tasks/SKILL.md`.
|
||||||
|
|
||||||
## Backend layout
|
## Backend layout
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
/** Shared native select styling — readable in light and dark themes */
|
|
||||||
export const FORM_SELECT_CLASS =
|
export const FORM_SELECT_CLASS =
|
||||||
'form-select rounded border border-border bg-background-card text-text-primary px-2 py-1 text-sm disabled:opacity-60 focus:outline-none focus:ring-2 focus:ring-primary/35';
|
'form-select rounded border border-border bg-background-card text-text-primary px-2 py-1 text-sm disabled:opacity-60 focus:outline-none focus:ring-2 focus:ring-primary/35';
|
||||||
|
|
||||||
|
/** Lab task status control — larger tap target on small screens. */
|
||||||
|
export const LAB_TASK_STATUS_SELECT_CLASS = `${FORM_SELECT_CLASS} h-[44px] w-full py-2 text-base font-medium sm:h-9 sm:py-1 sm:text-sm sm:max-w-none`;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function TaskCaseGroupHeader({ caseGroup, locale }: TaskCaseGroupHeaderPr
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2.5 bg-background-secondary/60 border-b border-border/80">
|
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-2 border-b border-border/80 bg-background-secondary/95 px-3 py-2.5 backdrop-blur-sm supports-[backdrop-filter]:bg-background-secondary/80">
|
||||||
<div className="min-w-0 space-y-0.5">
|
<div className="min-w-0 space-y-0.5">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<p className="text-sm font-semibold text-text-primary truncate">
|
<p className="text-sm font-semibold text-text-primary truncate">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Check, FolderOpen, MessageSquare } from 'lucide-react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Badge } from '@/components/ui/shared/Badge';
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
import { LAB_TASK_STATUS_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||||
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||||||
import {
|
import {
|
||||||
canEditLabTaskStatus,
|
canEditLabTaskStatus,
|
||||||
@@ -79,12 +79,63 @@ export function TaskRow({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
|
const commentsButton =
|
||||||
|
canEdit && !exiting ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleComments(task.id)}
|
||||||
|
className={`inline-flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-md border sm:h-9 sm:w-9 ${
|
||||||
|
commentsOpen
|
||||||
|
? 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-border text-text-muted hover:border-primary/40'
|
||||||
|
}`}
|
||||||
|
title={t('commentsButton')}
|
||||||
|
aria-label={t('commentsButton')}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
const statusControl = canEditStatus && !exiting ? (
|
||||||
|
<select
|
||||||
|
value={task.status}
|
||||||
|
disabled={updatingTaskId === task.id || exiting}
|
||||||
|
onChange={(e) => onStatusUpdate(task.id, e.target.value as LabTaskStatus)}
|
||||||
|
className={LAB_TASK_STATUS_SELECT_CLASS}
|
||||||
|
style={labTaskStatusSelectStyle(task.status)}
|
||||||
|
>
|
||||||
|
{statusOptions.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : assignedToOther && !exiting ? (
|
||||||
|
<Badge
|
||||||
|
variant="default"
|
||||||
|
fixedWidth={false}
|
||||||
|
className="h-[44px] w-full text-sm text-center whitespace-normal leading-snug sm:h-9 sm:w-auto sm:max-w-[10.5rem] sm:text-xs"
|
||||||
|
>
|
||||||
|
{t('assignedToStaff', { name: task.assignee!.name })}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
variant={labTaskStatusVariant(task.status)}
|
||||||
|
fixedWidth={false}
|
||||||
|
className="h-[44px] w-full text-sm sm:h-9 sm:w-auto sm:text-xs"
|
||||||
|
>
|
||||||
|
{statusOptions.find((opt) => opt.value === task.status)?.label ?? task.status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li id={`task-row-${task.id}`} className={rowClassName}>
|
<li id={`task-row-${task.id}`} className={rowClassName}>
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col gap-3 px-3 py-3 sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(8.5rem,11rem)_auto] sm:items-center sm:gap-x-3 sm:gap-y-0.5 sm:py-2 ${
|
className={`flex flex-col gap-3 px-3 py-3 sm:grid sm:items-center sm:gap-x-3 sm:gap-y-0.5 sm:py-2 ${
|
||||||
flatMode ? '' : 'ps-5'
|
flatMode
|
||||||
}`}
|
? 'sm:grid-cols-[minmax(0,1fr)_minmax(10rem,14rem)_auto]'
|
||||||
|
: 'sm:grid-cols-[minmax(0,1fr)_minmax(10rem,14rem)]'
|
||||||
|
} ${flatMode ? '' : 'ps-3 sm:ps-5'}`}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
@@ -127,42 +178,14 @@ export function TaskRow({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="flex w-full min-w-0 items-stretch gap-2">
|
||||||
className={`flex sm:justify-center pe-2 sm:pe-3 shrink-0 ${
|
<div className="min-w-0 flex-1">{statusControl}</div>
|
||||||
assignedToOther ? 'mb-1 sm:mb-0' : ''
|
{commentsButton}
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{canEditStatus && !exiting ? (
|
|
||||||
<select
|
|
||||||
value={task.status}
|
|
||||||
disabled={updatingTaskId === task.id || exiting}
|
|
||||||
onChange={(e) => onStatusUpdate(task.id, e.target.value as LabTaskStatus)}
|
|
||||||
className={`${FORM_SELECT_CLASS} w-full sm:max-w-[132px] font-medium`}
|
|
||||||
style={labTaskStatusSelectStyle(task.status)}
|
|
||||||
>
|
|
||||||
{statusOptions.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : assignedToOther && !exiting ? (
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
fixedWidth={false}
|
|
||||||
className="text-center whitespace-normal leading-snug max-w-[10.5rem]"
|
|
||||||
>
|
|
||||||
{t('assignedToStaff', { name: task.assignee!.name })}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
|
|
||||||
{statusOptions.find((opt) => opt.value === task.status)?.label ?? task.status}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0 justify-between sm:justify-end flex-wrap ps-1 sm:ps-0">
|
{flatMode ? (
|
||||||
{flatMode && onShowInCase && !exiting ? (
|
<div className="flex items-center gap-1.5 shrink-0 justify-between sm:justify-end flex-wrap">
|
||||||
|
{onShowInCase && !exiting ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -174,21 +197,6 @@ export function TaskRow({
|
|||||||
{t('showInCase')}
|
{t('showInCase')}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{canEdit && !exiting ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onToggleComments(task.id)}
|
|
||||||
className={`p-1.5 rounded border ${
|
|
||||||
commentsOpen
|
|
||||||
? 'border-primary bg-primary/10 text-primary'
|
|
||||||
: 'border-border text-text-muted hover:border-primary/40'
|
|
||||||
}`}
|
|
||||||
title={t('commentsButton')}
|
|
||||||
>
|
|
||||||
<MessageSquare className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{flatMode ? (
|
|
||||||
<Badge
|
<Badge
|
||||||
fixedWidth={false}
|
fixedWidth={false}
|
||||||
truncate
|
truncate
|
||||||
@@ -201,8 +209,8 @@ export function TaskRow({
|
|||||||
>
|
>
|
||||||
{task.prosthesisTypeLabel}
|
{task.prosthesisTypeLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{commentsOpen && canEdit && !exiting ? (
|
{commentsOpen && canEdit && !exiting ? (
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ export function TasksPage() {
|
|||||||
[canEdit, loadTasks, setError, showError, showSuccess, statusFilter, t, tErrors],
|
[canEdit, loadTasks, setError, showError, showSuccess, statusFilter, t, tErrors],
|
||||||
);
|
);
|
||||||
|
|
||||||
const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`;
|
const filterSelectClass = `${FORM_SELECT_CLASS} w-full min-h-[44px] rounded-md px-2 py-2 text-base sm:min-h-0 sm:py-1.5 sm:text-sm`;
|
||||||
|
|
||||||
const sortHintKey = useMemo(() => {
|
const sortHintKey = useMemo(() => {
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
@@ -467,7 +467,7 @@ export function TasksPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="surface-card min-h-[280px]">
|
<section className="surface-card min-h-[280px] overflow-x-hidden">
|
||||||
{(loading || locatingCase) && tasks.length === 0 ? (
|
{(loading || locatingCase) && tasks.length === 0 ? (
|
||||||
<p className="p-3 text-sm text-text-muted">
|
<p className="p-3 text-sm text-text-muted">
|
||||||
{locatingCase ? t('locatingCase') : t('loading')}
|
{locatingCase ? t('locatingCase') : t('loading')}
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ export function LabCasesDispatchPanel({
|
|||||||
{!detailAlreadyInShipment ? (
|
{!detailAlreadyInShipment ? (
|
||||||
<p className="text-xs text-text-muted">{t('labDispatchEmpty')}</p>
|
<p className="text-xs text-text-muted">{t('labDispatchEmpty')}</p>
|
||||||
) : activeLabCase ? (
|
) : activeLabCase ? (
|
||||||
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
|
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-3 sm:p-4 bg-background-secondary/30">
|
||||||
{renderShipmentCardHeader()}
|
{renderShipmentCardHeader()}
|
||||||
{sent ? (
|
{sent ? (
|
||||||
<>
|
<>
|
||||||
@@ -513,6 +513,7 @@ export function LabCasesDispatchPanel({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
disabled={
|
disabled={
|
||||||
disabled ||
|
disabled ||
|
||||||
sendBusyId === activeLabCase.clientId ||
|
sendBusyId === activeLabCase.clientId ||
|
||||||
|
|||||||
Reference in New Issue
Block a user