improvement: tasks feature UX fully overhauled.

This commit is contained in:
2026-07-13 02:09:49 +03:30
parent f28cd06615
commit 4e6ed75844
22 changed files with 833 additions and 210 deletions

View File

@@ -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>
);
}