improvement: tasks feature UX fully overhauled.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/shared/Badge';
|
||||
@@ -12,8 +12,9 @@ import { LabCaseAttachmentsDialog } from '@/components/ui/lab/LabCaseAttachments
|
||||
import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay';
|
||||
import {
|
||||
formatToothList,
|
||||
prosthesisTypeBadgeStyle,
|
||||
prosthesisTypeBadgeStyleFromCatalog,
|
||||
} from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||
import {
|
||||
buildCaseProsthesisRows,
|
||||
formatCaseDateTime,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
latestCaseAttachment,
|
||||
} from '@/components/lab/caseDetailUtils';
|
||||
import type { LabCaseDetail, LabTaskStatus } from '@/types/cases';
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
|
||||
function CaseTaskProgressBar({ completed, total }: { completed: number; total: number }) {
|
||||
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
@@ -77,6 +79,14 @@ export function CaseDetailPanel({
|
||||
}: CaseDetailPanelProps) {
|
||||
const t = useTranslations('cases');
|
||||
const [attachmentsDialogOpen, setAttachmentsDialogOpen] = useState(false);
|
||||
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
void prosthesisCatalogApi
|
||||
.list()
|
||||
.then((response) => setProsthesisCatalog(response.data))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const prosthesisRows = useMemo(() => buildCaseProsthesisRows(labCase), [labCase]);
|
||||
const previewAttachment = useMemo(() => latestCaseAttachment(labCase), [labCase]);
|
||||
@@ -154,6 +164,7 @@ export function CaseDetailPanel({
|
||||
<CaseToothChartPanel
|
||||
details={labCase.detail ? [{ teeth: labCase.detail.teeth }] : []}
|
||||
prosthesisRows={prosthesisRows}
|
||||
prosthesisCatalog={prosthesisCatalog}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
@@ -177,7 +188,7 @@ export function CaseDetailPanel({
|
||||
{labCase.tasksByTooth.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">{t('noTasks')}</p>
|
||||
) : (
|
||||
labCase.tasksByTooth.map((group, groupIndex) => (
|
||||
labCase.tasksByTooth.map((group) => (
|
||||
<div
|
||||
key={`${group.treatmentDetailId}-${group.prosthesisTypeCode}`}
|
||||
className="rounded-md border border-border p-3 space-y-2"
|
||||
@@ -186,7 +197,10 @@ export function CaseDetailPanel({
|
||||
<Badge
|
||||
truncate
|
||||
title={group.prosthesisTypeLabel}
|
||||
style={prosthesisTypeBadgeStyle(group.prosthesisTypeCode, groupIndex)}
|
||||
style={prosthesisTypeBadgeStyleFromCatalog(
|
||||
group.prosthesisTypeCode,
|
||||
prosthesisCatalog,
|
||||
)}
|
||||
>
|
||||
{group.prosthesisTypeLabel}
|
||||
</Badge>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
||||
import { prosthesisTypeColor } from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import { prosthesisTypeColor, prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type { FdiToothId } from '@/types/treatment';
|
||||
|
||||
export interface CaseToothChartDetail {
|
||||
@@ -18,6 +19,7 @@ interface CaseToothChartPanelProps {
|
||||
details: CaseToothChartDetail[];
|
||||
/** Prosthesis mapping from case tasks or toothProsthesis rows. */
|
||||
prosthesisRows: CaseToothChartProsthesisRow[];
|
||||
prosthesisCatalog?: readonly ProsthesisCatalogEntry[];
|
||||
scale?: number;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
@@ -27,6 +29,7 @@ interface CaseToothChartPanelProps {
|
||||
export function CaseToothChartPanel({
|
||||
details,
|
||||
prosthesisRows,
|
||||
prosthesisCatalog,
|
||||
scale = 1,
|
||||
compact = true,
|
||||
className = '',
|
||||
@@ -42,13 +45,15 @@ export function CaseToothChartPanel({
|
||||
const toothColors = useMemo(() => {
|
||||
const colors: Partial<Record<FdiToothId, string>> = {};
|
||||
prosthesisRows.forEach((row, index) => {
|
||||
const color = prosthesisTypeColor(row.prosthesisTypeCode, index);
|
||||
const color = prosthesisCatalog?.length
|
||||
? prosthesisTypeColorFromCatalog(row.prosthesisTypeCode, prosthesisCatalog)
|
||||
: prosthesisTypeColor(row.prosthesisTypeCode, index);
|
||||
for (const tooth of row.teeth) {
|
||||
colors[tooth as FdiToothId] = color;
|
||||
}
|
||||
});
|
||||
return colors;
|
||||
}, [prosthesisRows]);
|
||||
}, [prosthesisRows, prosthesisCatalog]);
|
||||
|
||||
if (selected.size === 0) return null;
|
||||
|
||||
|
||||
54
frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx
Normal file
54
frontend/src/components/ui/lab/TaskCaseGroupHeader.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Badge } from '@/components/ui/shared/Badge';
|
||||
import type { CaseTaskGroup } from '@/components/lab/taskListGrouping';
|
||||
import { countCaseTaskProgress } from '@/components/lab/taskListGrouping';
|
||||
|
||||
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||
}
|
||||
|
||||
interface TaskCaseGroupHeaderProps {
|
||||
caseGroup: CaseTaskGroup;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function TaskCaseGroupHeader({ caseGroup, locale }: TaskCaseGroupHeaderProps) {
|
||||
const t = useTranslations('tasks');
|
||||
const progress = countCaseTaskProgress(caseGroup);
|
||||
|
||||
const sentLabel = caseGroup.caseSentAt
|
||||
? new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(new Date(caseGroup.caseSentAt))
|
||||
: null;
|
||||
|
||||
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="min-w-0 space-y-0.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<p className="text-sm font-semibold text-text-primary truncate">
|
||||
{t('fromClinic', { name: caseGroup.clinic.name })} ·{' '}
|
||||
{formatPatientName(caseGroup.patient)}
|
||||
</p>
|
||||
{caseGroup.isImportant ? (
|
||||
<Badge variant="warning" fixedWidth={false} className="text-[10px]">
|
||||
{t('importantBadge')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{sentLabel ? (
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{t('caseReceivedAt', { date: sentLabel })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted tabular-nums shrink-0">
|
||||
{t('caseTaskProgress', { completed: progress.completed, total: progress.total })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx
Normal file
36
frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Badge } from '@/components/ui/shared/Badge';
|
||||
import { formatToothList, prosthesisTypeBadgeStyleFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import type { ProsthesisTaskGroup } from '@/components/lab/taskListGrouping';
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
|
||||
interface TaskProsthesisGroupHeaderProps {
|
||||
group: ProsthesisTaskGroup;
|
||||
prosthesisCatalog: readonly ProsthesisCatalogEntry[];
|
||||
}
|
||||
|
||||
export function TaskProsthesisGroupHeader({
|
||||
group,
|
||||
prosthesisCatalog,
|
||||
}: TaskProsthesisGroupHeaderProps) {
|
||||
const t = useTranslations('tasks');
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 py-1.5 bg-background-secondary/30 border-b border-border/40">
|
||||
<Badge
|
||||
fixedWidth={false}
|
||||
truncate
|
||||
title={group.prosthesisTypeLabel}
|
||||
style={prosthesisTypeBadgeStyleFromCatalog(group.prosthesisTypeCode, prosthesisCatalog)}
|
||||
className="max-w-[10rem]"
|
||||
>
|
||||
{group.prosthesisTypeLabel}
|
||||
</Badge>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{t('teethLabel', { teeth: formatToothList(group.teeth) })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
frontend/src/components/ui/lab/TaskRow.tsx
Normal file
174
frontend/src/components/ui/lab/TaskRow.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Badge } from '@/components/ui/shared/Badge';
|
||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||||
import {
|
||||
labTaskStatusSelectStyle,
|
||||
labTaskStatusVariant,
|
||||
} from '@/components/lab/labTaskStatusDisplay';
|
||||
import {
|
||||
formatToothList,
|
||||
prosthesisTypeBadgeStyleFromCatalog,
|
||||
} from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import { tasksApi } from '@/lib/api/tasks';
|
||||
import type { LabTaskListItem, LabTaskStatus } from '@/types/cases';
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
|
||||
interface TaskRowProps {
|
||||
task: LabTaskListItem;
|
||||
locale: string;
|
||||
flatMode: boolean;
|
||||
canEdit: boolean;
|
||||
statusOptions: { value: LabTaskStatus; label: string }[];
|
||||
updatingTaskId: string | null;
|
||||
commentsOpen: boolean;
|
||||
prosthesisCatalog: readonly ProsthesisCatalogEntry[];
|
||||
onStatusUpdate: (taskId: string, status: LabTaskStatus) => void;
|
||||
onToggleComments: (taskId: string) => void;
|
||||
onCommentError: (message: string) => void;
|
||||
}
|
||||
|
||||
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||
}
|
||||
|
||||
export function TaskRow({
|
||||
task,
|
||||
locale,
|
||||
flatMode,
|
||||
canEdit,
|
||||
statusOptions,
|
||||
updatingTaskId,
|
||||
commentsOpen,
|
||||
prosthesisCatalog,
|
||||
onStatusUpdate,
|
||||
onToggleComments,
|
||||
onCommentError,
|
||||
}: TaskRowProps) {
|
||||
const t = useTranslations('tasks');
|
||||
const taskDate = new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(new Date(task.createdAt));
|
||||
|
||||
return (
|
||||
<li className={flatMode ? undefined : 'border-b border-border/40 last:border-b-0'}>
|
||||
<div
|
||||
className={`flex flex-col gap-3 px-3 py-3 sm:grid sm:grid-cols-[minmax(0,1fr)_132px_auto] sm:items-center sm:gap-x-3 sm:gap-y-0.5 sm:py-2 ${
|
||||
flatMode ? '' : 'ps-5'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{task.stepOrder}. {task.stepLabel}
|
||||
</p>
|
||||
{flatMode && task.isImportant ? (
|
||||
<Badge variant="warning" fixedWidth={false}>
|
||||
{t('importantBadge')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{flatMode ? (
|
||||
<p className="text-[11px] text-text-secondary truncate">
|
||||
{t('fromClinic', { name: task.clinic.name })} · {formatPatientName(task.patient)}{' '}
|
||||
· {t('teethLabel', { teeth: formatToothList(task.teeth) })}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-[11px] text-text-muted truncate">
|
||||
<span>{t('taskDate', { date: taskDate })}</span>
|
||||
{task.lastStatusChangedBy ? (
|
||||
<>
|
||||
<span aria-hidden> · </span>
|
||||
<span>{t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })}</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex sm:justify-center">
|
||||
{canEdit ? (
|
||||
<select
|
||||
value={task.status}
|
||||
disabled={updatingTaskId === task.id}
|
||||
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>
|
||||
) : (
|
||||
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
|
||||
{statusOptions.find((opt) => opt.value === task.status)?.label ?? task.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0 justify-between sm:justify-end">
|
||||
{canEdit ? (
|
||||
<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
|
||||
fixedWidth={false}
|
||||
truncate
|
||||
title={task.prosthesisTypeLabel}
|
||||
style={prosthesisTypeBadgeStyleFromCatalog(
|
||||
task.prosthesisTypeCode,
|
||||
prosthesisCatalog,
|
||||
)}
|
||||
className="w-full max-w-[8rem] sm:w-[7rem]"
|
||||
>
|
||||
{task.prosthesisTypeLabel}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{commentsOpen && canEdit ? (
|
||||
<div className="px-3 pb-3 border-t border-border/50">
|
||||
<LabCaseCommentsPanel
|
||||
caseId={task.labCaseId}
|
||||
canPost
|
||||
canToggleVisibility
|
||||
loadComments={async () => {
|
||||
const r = await tasksApi.listComments(task.labCaseId);
|
||||
return r.data;
|
||||
}}
|
||||
onPost={async (body, visibleToClinic) => {
|
||||
const r = await tasksApi.addComment(task.labCaseId, {
|
||||
body,
|
||||
visibleToClinic,
|
||||
});
|
||||
return r.data;
|
||||
}}
|
||||
onToggleVisibility={async (commentId, visible) => {
|
||||
const r = await tasksApi.setCommentVisibility(commentId, visible);
|
||||
return r.data;
|
||||
}}
|
||||
onError={onCommentError}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -2,39 +2,31 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/shared/Badge';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||||
import {
|
||||
labTaskStatusSelectStyle,
|
||||
labTaskStatusVariant,
|
||||
} from '@/components/lab/labTaskStatusDisplay';
|
||||
import {
|
||||
formatToothList,
|
||||
prosthesisTypeBadgeStyle,
|
||||
} from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import { TaskCaseGroupHeader } from '@/components/ui/lab/TaskCaseGroupHeader';
|
||||
import { TaskProsthesisGroupHeader } from '@/components/ui/lab/TaskProsthesisGroupHeader';
|
||||
import { TaskRow } from '@/components/ui/lab/TaskRow';
|
||||
import { groupTasksForDisplay } from '@/components/lab/taskListGrouping';
|
||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { useToast } from '@/lib/hooks/useToast';
|
||||
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
||||
import { tasksApi } from '@/lib/api/tasks';
|
||||
import type {
|
||||
LabTaskListItem,
|
||||
LabTaskStatus,
|
||||
ListLabTasksParams,
|
||||
PaginatedLabTasks,
|
||||
TaskFilterOptions,
|
||||
TaskSortField,
|
||||
} from '@/types/cases';
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||
}
|
||||
|
||||
export function TasksPage() {
|
||||
const t = useTranslations('tasks');
|
||||
const tErrors = useTranslations('errors');
|
||||
@@ -48,6 +40,11 @@ export function TasksPage() {
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
});
|
||||
const [filterOptions, setFilterOptions] = useState<TaskFilterOptions>({
|
||||
clinics: [],
|
||||
workflowSteps: [],
|
||||
});
|
||||
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
||||
@@ -56,8 +53,7 @@ export function TasksPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [clinicId, setClinicId] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('IN_PROGRESS');
|
||||
const [sentFrom, setSentFrom] = useState('');
|
||||
const [sentTo, setSentTo] = useState('');
|
||||
const [stepCompleted, setStepCompleted] = useState('');
|
||||
const [sortBy, setSortBy] = useState<TaskSortField>('date');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
@@ -86,18 +82,16 @@ export function TasksPage() {
|
||||
if (search.trim()) params.q = search.trim();
|
||||
if (clinicId) params.clinicOrganizationId = clinicId;
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (sentFrom) params.sentFrom = sentFrom;
|
||||
if (sentTo) params.sentTo = sentTo;
|
||||
if (stepCompleted) params.stepCompleted = stepCompleted;
|
||||
return params;
|
||||
}, [page, search, clinicId, statusFilter, sentFrom, sentTo, sortBy, sortDir]);
|
||||
}, [page, search, clinicId, statusFilter, stepCompleted, sortBy, sortDir]);
|
||||
|
||||
const clinicOptions = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const task of tasks) {
|
||||
map.set(task.clinic.id, task.clinic.name);
|
||||
}
|
||||
return [...map.entries()].map(([id, name]) => ({ id, name }));
|
||||
}, [tasks]);
|
||||
const displayModel = useMemo(
|
||||
() => groupTasksForDisplay(tasks, sortBy),
|
||||
[tasks, sortBy],
|
||||
);
|
||||
|
||||
const groupingDisabled = sortBy !== 'date';
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -111,7 +105,7 @@ export function TasksPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [listParams, showError, setError]);
|
||||
}, [listParams, showError, setError, tErrors]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canView) return;
|
||||
@@ -119,30 +113,58 @@ export function TasksPage() {
|
||||
return () => clearTimeout(timeout);
|
||||
}, [canView, loadTasks, search]);
|
||||
|
||||
async function handleStatusUpdate(taskId: string, status: LabTaskStatus) {
|
||||
if (!canEdit) return;
|
||||
setUpdatingTaskId(taskId);
|
||||
setError('');
|
||||
try {
|
||||
await tasksApi.updateStatus(taskId, status);
|
||||
await loadTasks();
|
||||
} catch (error: unknown) {
|
||||
showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
|
||||
} finally {
|
||||
setUpdatingTaskId(null);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!canView) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const [optionsRes, catalogRes] = await Promise.all([
|
||||
tasksApi.filterOptions(),
|
||||
prosthesisCatalogApi.list(),
|
||||
]);
|
||||
setFilterOptions(optionsRes.data);
|
||||
setProsthesisCatalog(catalogRes.data);
|
||||
} catch {
|
||||
// Non-blocking — filters fall back to empty options.
|
||||
}
|
||||
})();
|
||||
}, [canView]);
|
||||
|
||||
function formatTaskDate(value: string) {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
const handleStatusUpdate = useCallback(
|
||||
async (taskId: string, status: LabTaskStatus) => {
|
||||
if (!canEdit) return;
|
||||
setUpdatingTaskId(taskId);
|
||||
setError('');
|
||||
try {
|
||||
await tasksApi.updateStatus(taskId, status);
|
||||
await loadTasks();
|
||||
} catch (error: unknown) {
|
||||
showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
|
||||
} finally {
|
||||
setUpdatingTaskId(null);
|
||||
}
|
||||
},
|
||||
[canEdit, loadTasks, setError, showError, t, tErrors],
|
||||
);
|
||||
|
||||
const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`;
|
||||
|
||||
const sortHintKey = useMemo(() => {
|
||||
switch (sortBy) {
|
||||
case 'clinic':
|
||||
return 'groupingOffClinic';
|
||||
case 'patient':
|
||||
return 'groupingOffPatient';
|
||||
case 'prosthesis':
|
||||
return 'groupingOffProsthesis';
|
||||
case 'taskType':
|
||||
return 'groupingOffTaskType';
|
||||
case 'status':
|
||||
return 'groupingOffStatus';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [sortBy]);
|
||||
|
||||
if (!isAuthReady) {
|
||||
return <div className="text-sm text-text-muted">{t('loading')}</div>;
|
||||
}
|
||||
@@ -173,7 +195,7 @@ export function TasksPage() {
|
||||
}}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
/>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
|
||||
<select
|
||||
@@ -185,7 +207,7 @@ export function TasksPage() {
|
||||
className={filterSelectClass}
|
||||
>
|
||||
<option value="">{t('filterClinicAll')}</option>
|
||||
{clinicOptions.map((c) => (
|
||||
{filterOptions.clinics.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
@@ -210,6 +232,24 @@ export function TasksPage() {
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-text-muted">{t('filterStepCompleted')}</span>
|
||||
<select
|
||||
value={stepCompleted}
|
||||
onChange={(e) => {
|
||||
setStepCompleted(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className={filterSelectClass}
|
||||
>
|
||||
<option value="">{t('filterStepCompletedAll')}</option>
|
||||
{filterOptions.workflowSteps.map((step) => (
|
||||
<option key={step.code} value={step.code}>
|
||||
{step.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-text-muted">{t('sortBy')}</span>
|
||||
<div className="flex gap-1.5">
|
||||
@@ -236,6 +276,9 @@ export function TasksPage() {
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{groupingDisabled && sortHintKey ? (
|
||||
<p className="text-[11px] text-text-muted">{t(sortHintKey)}</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="surface-card min-h-[280px]">
|
||||
@@ -243,125 +286,65 @@ export function TasksPage() {
|
||||
<p className="p-3 text-sm text-text-muted">{t('loading')}</p>
|
||||
) : tasks.length === 0 ? (
|
||||
<p className="p-3 text-sm text-text-muted">{t('emptyList')}</p>
|
||||
) : displayModel.mode === 'grouped' ? (
|
||||
<div className="divide-y divide-border">
|
||||
{displayModel.cases.map((caseGroup) => (
|
||||
<section key={caseGroup.labCaseId} className="border-b border-border last:border-b-0">
|
||||
<TaskCaseGroupHeader caseGroup={caseGroup} locale={locale} />
|
||||
{caseGroup.prosthesisGroups.map((prosthesisGroup) => (
|
||||
<div
|
||||
key={prosthesisGroup.key}
|
||||
className="border-t border-border/50 first:border-t-0"
|
||||
>
|
||||
<TaskProsthesisGroupHeader
|
||||
group={prosthesisGroup}
|
||||
prosthesisCatalog={prosthesisCatalog}
|
||||
/>
|
||||
<ul>
|
||||
{prosthesisGroup.tasks.map((task) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
locale={locale}
|
||||
flatMode={false}
|
||||
canEdit={canEdit}
|
||||
statusOptions={statusOptions}
|
||||
updatingTaskId={updatingTaskId}
|
||||
commentsOpen={expandedCommentsTaskId === task.id}
|
||||
prosthesisCatalog={prosthesisCatalog}
|
||||
onStatusUpdate={(id, status) => void handleStatusUpdate(id, status)}
|
||||
onToggleComments={(id) =>
|
||||
setExpandedCommentsTaskId((prev) => (prev === id ? null : id))
|
||||
}
|
||||
onCommentError={showError}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{tasks.map((task, index) => {
|
||||
const commentsOpen = expandedCommentsTaskId === task.id;
|
||||
|
||||
return (
|
||||
<li key={task.id}>
|
||||
<div className="flex flex-col gap-3 px-3 py-3 sm:grid sm:grid-cols-[minmax(0,1fr)_132px_auto] sm:items-center sm:gap-x-3 sm:gap-y-0.5 sm:py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{task.stepOrder}. {task.stepLabel}
|
||||
</p>
|
||||
{task.isImportant ? (
|
||||
<Badge variant="warning" fixedWidth={false}>
|
||||
{t('importantBadge')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-[11px] text-text-secondary truncate">
|
||||
{t('fromClinic', { name: task.clinic.name })} ·{' '}
|
||||
{formatPatientName(task.patient)} ·{' '}
|
||||
{t('teethLabel', { teeth: formatToothList(task.teeth) })}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-muted truncate">
|
||||
<span>{t('taskDate', { date: formatTaskDate(task.createdAt) })}</span>
|
||||
{task.lastStatusChangedBy ? (
|
||||
<>
|
||||
<span aria-hidden> · </span>
|
||||
<span>
|
||||
{t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex sm:justify-center">
|
||||
{canEdit ? (
|
||||
<select
|
||||
value={task.status}
|
||||
disabled={updatingTaskId === task.id}
|
||||
onChange={(e) =>
|
||||
void handleStatusUpdate(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>
|
||||
) : (
|
||||
<Badge variant={labTaskStatusVariant(task.status)} fixedWidth={false}>
|
||||
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||
task.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0 justify-between sm:justify-end">
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setExpandedCommentsTaskId(commentsOpen ? null : 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}
|
||||
<Badge
|
||||
fixedWidth={false}
|
||||
truncate
|
||||
title={task.prosthesisTypeLabel}
|
||||
style={prosthesisTypeBadgeStyle(task.prosthesisTypeCode, index)}
|
||||
className="w-full max-w-[8rem] sm:w-[7rem]"
|
||||
>
|
||||
{task.prosthesisTypeLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{commentsOpen && canEdit ? (
|
||||
<div className="px-3 pb-3 border-t border-border/50">
|
||||
<LabCaseCommentsPanel
|
||||
caseId={task.labCaseId}
|
||||
canPost
|
||||
canToggleVisibility
|
||||
loadComments={async () => {
|
||||
const r = await tasksApi.listComments(task.labCaseId);
|
||||
return r.data;
|
||||
}}
|
||||
onPost={async (body, visibleToClinic) => {
|
||||
const r = await tasksApi.addComment(task.labCaseId, {
|
||||
body,
|
||||
visibleToClinic,
|
||||
});
|
||||
return r.data;
|
||||
}}
|
||||
onToggleVisibility={async (commentId, visible) => {
|
||||
const r = await tasksApi.setCommentVisibility(commentId, visible);
|
||||
return r.data;
|
||||
}}
|
||||
onError={showError}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{displayModel.tasks.map((task) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
locale={locale}
|
||||
flatMode
|
||||
canEdit={canEdit}
|
||||
statusOptions={statusOptions}
|
||||
updatingTaskId={updatingTaskId}
|
||||
commentsOpen={expandedCommentsTaskId === task.id}
|
||||
prosthesisCatalog={prosthesisCatalog}
|
||||
onStatusUpdate={(id, status) => void handleStatusUpdate(id, status)}
|
||||
onToggleComments={(id) =>
|
||||
setExpandedCommentsTaskId((prev) => (prev === id ? null : id))
|
||||
}
|
||||
onCommentError={showError}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
@@ -395,7 +378,6 @@ export function TasksPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user