516 lines
18 KiB
TypeScript
516 lines
18 KiB
TypeScript
'use client';
|
|
|
|
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';
|
|
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;
|
|
|
|
export function TasksPage() {
|
|
const t = useTranslations('tasks');
|
|
const tErrors = useTranslations('errors');
|
|
const { currentOrganization, user, isAuthReady } = useAuth();
|
|
const { showError, showSuccess, setError } = useToast();
|
|
|
|
const [tasks, setTasks] = useState<LabTaskListItem[]>([]);
|
|
const [pagination, setPagination] = useState<PaginatedLabTasks['pagination']>({
|
|
page: 1,
|
|
limit: PAGE_SIZE,
|
|
total: 0,
|
|
totalPages: 1,
|
|
});
|
|
const [filterOptions, setFilterOptions] = useState<TaskFilterOptions>({
|
|
clinics: [],
|
|
workflowSteps: [],
|
|
});
|
|
const [prosthesisCatalog, setProsthesisCatalog] = useState<ProsthesisCatalogEntry[]>([]);
|
|
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(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);
|
|
const locale = user?.language ?? 'en';
|
|
|
|
const tRef = useRef(t);
|
|
tRef.current = t;
|
|
const loadTasksRef = useRef<() => Promise<void>>(async () => {});
|
|
|
|
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
|
|
() => [
|
|
{ value: 'IN_PROGRESS', label: t('statusInProgress') },
|
|
{ value: 'COMPLETED', label: t('statusCompleted') },
|
|
],
|
|
[t],
|
|
);
|
|
|
|
const listParams = useMemo((): ListLabTasksParams => {
|
|
const params: ListLabTasksParams = {
|
|
page,
|
|
limit: PAGE_SIZE,
|
|
sortBy,
|
|
sortDir,
|
|
};
|
|
if (search.trim()) params.q = search.trim();
|
|
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, importantOnly, sortBy, sortDir]);
|
|
|
|
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('');
|
|
try {
|
|
const response = await tasksApi.list(listParams);
|
|
setTasks(response.data.items);
|
|
setPagination(response.data.pagination);
|
|
} catch (error: unknown) {
|
|
showError(getUserFacingError(error, tErrors, tRef.current('errorLoadList')));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [listParams, showError, setError, tErrors]);
|
|
|
|
loadTasksRef.current = loadTasks;
|
|
|
|
useEffect(() => {
|
|
if (!canView) return;
|
|
const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0);
|
|
return () => clearTimeout(timeout);
|
|
}, [canView, loadTasks, search]);
|
|
|
|
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]);
|
|
|
|
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;
|
|
setUpdatingTaskId(taskId);
|
|
setError('');
|
|
try {
|
|
await tasksApi.updateStatus(taskId, status);
|
|
|
|
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, showSuccess, statusFilter, 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]);
|
|
|
|
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>;
|
|
}
|
|
|
|
if (!canView) {
|
|
return (
|
|
<div className="surface-card p-6 max-w-xl">
|
|
<h2 className="text-lg font-semibold text-text-primary">{t('noPermissionTitle')}</h2>
|
|
<p className="text-sm text-text-secondary mt-2">{t('noPermissionBody')}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<header className="space-y-1">
|
|
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
|
<p className="text-sm text-text-secondary">{t('subtitle')}</p>
|
|
</header>
|
|
|
|
<section className="surface-card p-3 space-y-3">
|
|
<SearchBar
|
|
embedded
|
|
value={search}
|
|
onChange={(v) => applyFilterChange(() => setSearch(v))}
|
|
placeholder={t('searchPlaceholder')}
|
|
/>
|
|
<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
|
|
value={clinicId}
|
|
onChange={(e) => applyFilterChange(() => setClinicId(e.target.value))}
|
|
className={filterSelectClass}
|
|
>
|
|
<option value="">{t('filterClinicAll')}</option>
|
|
{filterOptions.clinics.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-xs text-text-muted">{t('filterStatus')}</span>
|
|
<select
|
|
value={statusFilter}
|
|
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>
|
|
</select>
|
|
</label>
|
|
<label className="space-y-1">
|
|
<span className="text-xs text-text-muted">{t('filterStepCompleted')}</span>
|
|
<select
|
|
value={stepCompleted}
|
|
onChange={(e) => applyFilterChange(() => setStepCompleted(e.target.value))}
|
|
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">
|
|
<select
|
|
value={sortBy}
|
|
onChange={(e) => {
|
|
setSortBy(e.target.value as TaskSortField);
|
|
clearFocus();
|
|
}}
|
|
className={`${filterSelectClass} min-w-0 flex-1`}
|
|
>
|
|
<option value="date">{t('sortDate')}</option>
|
|
<option value="clinic">{t('sortClinic')}</option>
|
|
<option value="patient">{t('sortPatient')}</option>
|
|
<option value="prosthesis">{t('sortProsthesis')}</option>
|
|
<option value="taskType">{t('sortTaskType')}</option>
|
|
</select>
|
|
<select
|
|
value={sortDir}
|
|
onChange={(e) => setSortDir(e.target.value as 'asc' | 'desc')}
|
|
className={`${FORM_SELECT_CLASS} w-14 shrink-0 rounded-md px-2 py-1.5 text-sm`}
|
|
aria-label={t('sortDirection')}
|
|
>
|
|
<option value="desc">↓</option>
|
|
<option value="asc">↑</option>
|
|
</select>
|
|
</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 || 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' ? (
|
|
<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) => renderTaskRow(task, false))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</section>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<ul className="divide-y divide-border">
|
|
{displayModel.tasks.map((task) => renderTaskRow(task, true))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
{pagination.totalPages > 1 ? (
|
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
|
<p className="text-sm text-text-muted">
|
|
{t('pageSummary', {
|
|
page: pagination.page,
|
|
totalPages: pagination.totalPages,
|
|
total: pagination.total,
|
|
})}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
disabled={page <= 1 || loading}
|
|
onClick={() => {
|
|
setPage((p) => Math.max(1, p - 1));
|
|
clearFocus();
|
|
}}
|
|
>
|
|
←
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
disabled={page >= pagination.totalPages || loading}
|
|
onClick={() => {
|
|
setPage((p) => p + 1);
|
|
clearFocus();
|
|
}}
|
|
>
|
|
→
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|