improvement: sorts and filters updated for tasks feature.

This commit is contained in:
2026-07-13 13:22:38 +03:30
parent 4e6ed75844
commit 0d073f1ec0
16 changed files with 666 additions and 102 deletions

View File

@@ -3,12 +3,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import { SearchBar } from '@/components/ui/shared/SearchBar';
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 {
buildDefaultLocateParams,
DEFAULT_TASKS_VIEW,
isDefaultTasksView,
TASK_COMPLETE_EXIT_MS,
} from '@/components/lab/tasksViewDefaults';
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
@@ -31,7 +39,7 @@ export function TasksPage() {
const t = useTranslations('tasks');
const tErrors = useTranslations('errors');
const { currentOrganization, user, isAuthReady } = useAuth();
const { showError, setError, messages: toastMessages } = useToast();
const { showError, showSuccess, setError } = useToast();
const [tasks, setTasks] = useState<LabTaskListItem[]>([]);
const [pagination, setPagination] = useState<PaginatedLabTasks['pagination']>({
@@ -45,17 +53,25 @@ export function TasksPage() {
workflowSteps: [],
});
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
const [page, setPage] = useState(1);
const [page, setPage] = useState(DEFAULT_TASKS_VIEW.page);
const [loading, setLoading] = useState(false);
const [locatingCase, setLocatingCase] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
const [exitingTaskIds, setExitingTaskIds] = useState<Set<string>>(() => new Set());
const [expandedCommentsTaskId, setExpandedCommentsTaskId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [clinicId, setClinicId] = useState('');
const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('IN_PROGRESS');
const [stepCompleted, setStepCompleted] = useState('');
const [sortBy, setSortBy] = useState<TaskSortField>('date');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const [search, setSearch] = useState(DEFAULT_TASKS_VIEW.search);
const [clinicId, setClinicId] = useState(DEFAULT_TASKS_VIEW.clinicId);
const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>(
DEFAULT_TASKS_VIEW.statusFilter,
);
const [stepCompleted, setStepCompleted] = useState(DEFAULT_TASKS_VIEW.stepCompleted);
const [importantOnly, setImportantOnly] = useState(DEFAULT_TASKS_VIEW.importantOnly);
const [sortBy, setSortBy] = useState<TaskSortField>(DEFAULT_TASKS_VIEW.sortBy);
const [sortDir, setSortDir] = useState<'asc' | 'desc'>(DEFAULT_TASKS_VIEW.sortDir);
const [highlightTaskId, setHighlightTaskId] = useState<string | null>(
DEFAULT_TASKS_VIEW.highlightTaskId,
);
const canView = canViewTasks(currentOrganization);
const canEdit = canEditTasks(currentOrganization);
@@ -63,6 +79,7 @@ export function TasksPage() {
const tRef = useRef(t);
tRef.current = t;
const loadTasksRef = useRef<() => Promise<void>>(async () => {});
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
() => [
@@ -83,16 +100,41 @@ export function TasksPage() {
if (clinicId) params.clinicOrganizationId = clinicId;
if (statusFilter) params.status = statusFilter;
if (stepCompleted) params.stepCompleted = stepCompleted;
if (importantOnly) params.pinImportant = true;
return params;
}, [page, search, clinicId, statusFilter, stepCompleted, sortBy, sortDir]);
}, [page, search, clinicId, statusFilter, stepCompleted, importantOnly, sortBy, sortDir]);
const displayModel = useMemo(
() => groupTasksForDisplay(tasks, sortBy),
[tasks, sortBy],
);
const displayModel = useMemo(() => groupTasksForDisplay(tasks, sortBy), [tasks, sortBy]);
const groupingDisabled = sortBy !== 'date';
const viewState = useMemo(
() => ({
search,
clinicId,
statusFilter,
stepCompleted,
sortBy,
sortDir,
importantOnly,
page,
highlightTaskId,
}),
[
search,
clinicId,
statusFilter,
stepCompleted,
sortBy,
sortDir,
importantOnly,
page,
highlightTaskId,
],
);
const showReset = !isDefaultTasksView(viewState);
const loadTasks = useCallback(async () => {
setLoading(true);
setError('');
@@ -107,6 +149,8 @@ export function TasksPage() {
}
}, [listParams, showError, setError, tErrors]);
loadTasksRef.current = loadTasks;
useEffect(() => {
if (!canView) return;
const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0);
@@ -129,6 +173,66 @@ export function TasksPage() {
})();
}, [canView]);
useEffect(() => {
if (!highlightTaskId || loading || locatingCase) return;
const frame = requestAnimationFrame(() => {
const el = document.getElementById(`task-row-${highlightTaskId}`);
scrollWithinMainScrollContainer(el, { padding: 24 });
});
return () => cancelAnimationFrame(frame);
}, [highlightTaskId, loading, locatingCase, tasks, displayModel]);
const clearFocus = useCallback(() => {
setHighlightTaskId(null);
}, []);
const resetView = useCallback(() => {
setSearch(DEFAULT_TASKS_VIEW.search);
setClinicId(DEFAULT_TASKS_VIEW.clinicId);
setStatusFilter(DEFAULT_TASKS_VIEW.statusFilter);
setStepCompleted(DEFAULT_TASKS_VIEW.stepCompleted);
setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly);
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
setPage(DEFAULT_TASKS_VIEW.page);
setHighlightTaskId(DEFAULT_TASKS_VIEW.highlightTaskId);
setExpandedCommentsTaskId(null);
}, []);
const handleShowInCase = useCallback(
async (task: LabTaskListItem) => {
setLocatingCase(true);
setError('');
setSearch(DEFAULT_TASKS_VIEW.search);
setClinicId(DEFAULT_TASKS_VIEW.clinicId);
setStatusFilter(DEFAULT_TASKS_VIEW.statusFilter);
setStepCompleted(DEFAULT_TASKS_VIEW.stepCompleted);
setImportantOnly(DEFAULT_TASKS_VIEW.importantOnly);
setSortBy(DEFAULT_TASKS_VIEW.sortBy);
setSortDir(DEFAULT_TASKS_VIEW.sortDir);
setExpandedCommentsTaskId(null);
setHighlightTaskId(task.id);
try {
const response = await tasksApi.locatePage(buildDefaultLocateParams(task.id, PAGE_SIZE));
if (!response.data.found) {
showError(t('showInCaseNotFound'));
setHighlightTaskId(null);
setPage(1);
return;
}
setPage(response.data.page);
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('showInCaseError')));
setHighlightTaskId(null);
setPage(1);
} finally {
setLocatingCase(false);
}
},
[setError, showError, t, tErrors],
);
const handleStatusUpdate = useCallback(
async (taskId: string, status: LabTaskStatus) => {
if (!canEdit) return;
@@ -136,14 +240,34 @@ export function TasksPage() {
setError('');
try {
await tasksApi.updateStatus(taskId, status);
await loadTasks();
const willLeaveList = status === 'COMPLETED' && statusFilter === 'IN_PROGRESS';
if (willLeaveList) {
setTasks((prev) =>
prev.map((task) => (task.id === taskId ? { ...task, status: 'COMPLETED' } : task)),
);
setExitingTaskIds((prev) => new Set(prev).add(taskId));
showSuccess(t('taskCompletedToast'));
window.setTimeout(() => {
setExitingTaskIds((prev) => {
const next = new Set(prev);
next.delete(taskId);
return next;
});
void loadTasksRef.current();
}, TASK_COMPLETE_EXIT_MS);
} else {
await loadTasks();
}
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
} finally {
setUpdatingTaskId(null);
}
},
[canEdit, loadTasks, setError, showError, 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`;
@@ -165,6 +289,37 @@ export function TasksPage() {
}
}, [sortBy]);
const applyFilterChange = useCallback(
(apply: () => void) => {
apply();
setPage(1);
clearFocus();
},
[clearFocus],
);
const renderTaskRow = (task: LabTaskListItem, flatMode: boolean) => (
<TaskRow
key={task.id}
task={task}
locale={locale}
flatMode={flatMode}
highlighted={highlightTaskId === task.id}
exiting={exitingTaskIds.has(task.id)}
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}
onShowInCase={flatMode ? (row) => void handleShowInCase(row) : undefined}
/>
);
if (!isAuthReady) {
return <div className="text-sm text-text-muted">{t('loading')}</div>;
}
@@ -189,10 +344,7 @@ export function TasksPage() {
<SearchBar
embedded
value={search}
onChange={(v) => {
setSearch(v);
setPage(1);
}}
onChange={(v) => applyFilterChange(() => setSearch(v))}
placeholder={t('searchPlaceholder')}
/>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
@@ -200,10 +352,7 @@ export function TasksPage() {
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
<select
value={clinicId}
onChange={(e) => {
setClinicId(e.target.value);
setPage(1);
}}
onChange={(e) => applyFilterChange(() => setClinicId(e.target.value))}
className={filterSelectClass}
>
<option value="">{t('filterClinicAll')}</option>
@@ -218,28 +367,21 @@ export function TasksPage() {
<span className="text-xs text-text-muted">{t('filterStatus')}</span>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value as '' | LabTaskStatus);
setPage(1);
}}
onChange={(e) =>
applyFilterChange(() => setStatusFilter(e.target.value as '' | LabTaskStatus))
}
className={filterSelectClass}
>
<option value="IN_PROGRESS">{t('statusInProgress')}</option>
<option value="COMPLETED">{t('statusCompleted')}</option>
<option value="">{t('filterStatusAll')}</option>
{statusOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</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);
}}
onChange={(e) => applyFilterChange(() => setStepCompleted(e.target.value))}
className={filterSelectClass}
>
<option value="">{t('filterStepCompletedAll')}</option>
@@ -255,7 +397,10 @@ export function TasksPage() {
<div className="flex gap-1.5">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as TaskSortField)}
onChange={(e) => {
setSortBy(e.target.value as TaskSortField);
clearFocus();
}}
className={`${filterSelectClass} min-w-0 flex-1`}
>
<option value="date">{t('sortDate')}</option>
@@ -276,14 +421,29 @@ export function TasksPage() {
</div>
</label>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<Checkbox
checked={importantOnly}
onChange={(checked) => applyFilterChange(() => setImportantOnly(checked))}
label={t('importantOnly')}
className="text-xs [&_span:last-child]:text-xs"
/>
{showReset ? (
<Button type="button" variant="ghost" size="sm" onClick={resetView}>
{t('resetView')}
</Button>
) : null}
</div>
{groupingDisabled && sortHintKey ? (
<p className="text-[11px] text-text-muted">{t(sortHintKey)}</p>
) : null}
</section>
<section className="surface-card min-h-[280px]">
{loading && tasks.length === 0 ? (
<p className="p-3 text-sm text-text-muted">{t('loading')}</p>
{(loading || locatingCase) && tasks.length === 0 ? (
<p className="p-3 text-sm text-text-muted">
{locatingCase ? t('locatingCase') : t('loading')}
</p>
) : tasks.length === 0 ? (
<p className="p-3 text-sm text-text-muted">{t('emptyList')}</p>
) : displayModel.mode === 'grouped' ? (
@@ -301,24 +461,7 @@ export function TasksPage() {
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}
/>
))}
{prosthesisGroup.tasks.map((task) => renderTaskRow(task, false))}
</ul>
</div>
))}
@@ -327,29 +470,12 @@ export function TasksPage() {
</div>
) : (
<ul className="divide-y divide-border">
{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}
/>
))}
{displayModel.tasks.map((task) => renderTaskRow(task, true))}
</ul>
)}
</section>
{pagination.totalPages > 1 && (
{pagination.totalPages > 1 ? (
<div className="flex items-center justify-between gap-3 flex-wrap">
<p className="text-sm text-text-muted">
{t('pageSummary', {
@@ -363,7 +489,10 @@ export function TasksPage() {
type="button"
variant="secondary"
disabled={page <= 1 || loading}
onClick={() => setPage((p) => Math.max(1, p - 1))}
onClick={() => {
setPage((p) => Math.max(1, p - 1));
clearFocus();
}}
>
</Button>
@@ -371,13 +500,16 @@ export function TasksPage() {
type="button"
variant="secondary"
disabled={page >= pagination.totalPages || loading}
onClick={() => setPage((p) => p + 1)}
onClick={() => {
setPage((p) => p + 1);
clearFocus();
}}
>
</Button>
</div>
</div>
)}
) : null}
</div>
);
}