402 lines
15 KiB
TypeScript
402 lines
15 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
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 { 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 { tasksApi } from '@/lib/api/tasks';
|
||
|
|
import type {
|
||
|
|
LabTaskListItem,
|
||
|
|
LabTaskStatus,
|
||
|
|
ListLabTasksParams,
|
||
|
|
PaginatedLabTasks,
|
||
|
|
TaskSortField,
|
||
|
|
} from '@/types/cases';
|
||
|
|
|
||
|
|
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');
|
||
|
|
const { currentOrganization, user, isAuthReady } = useAuth();
|
||
|
|
const { showError, setError, messages: toastMessages } = useToast();
|
||
|
|
|
||
|
|
const [tasks, setTasks] = useState<LabTaskListItem[]>([]);
|
||
|
|
const [pagination, setPagination] = useState<PaginatedLabTasks['pagination']>({
|
||
|
|
page: 1,
|
||
|
|
limit: PAGE_SIZE,
|
||
|
|
total: 0,
|
||
|
|
totalPages: 1,
|
||
|
|
});
|
||
|
|
const [page, setPage] = useState(1);
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
||
|
|
const [expandedCommentsTaskId, setExpandedCommentsTaskId] = useState<string | null>(null);
|
||
|
|
|
||
|
|
const [search, setSearch] = useState('');
|
||
|
|
const [clinicId, setClinicId] = useState('');
|
||
|
|
const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('IN_PROGRESS');
|
||
|
|
const [sentFrom, setSentFrom] = useState('');
|
||
|
|
const [sentTo, setSentTo] = useState('');
|
||
|
|
const [sortBy, setSortBy] = useState<TaskSortField>('date');
|
||
|
|
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||
|
|
|
||
|
|
const canView = canViewTasks(currentOrganization);
|
||
|
|
const canEdit = canEditTasks(currentOrganization);
|
||
|
|
const locale = user?.language ?? 'en';
|
||
|
|
|
||
|
|
const tRef = useRef(t);
|
||
|
|
tRef.current = t;
|
||
|
|
|
||
|
|
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 (sentFrom) params.sentFrom = sentFrom;
|
||
|
|
if (sentTo) params.sentTo = sentTo;
|
||
|
|
return params;
|
||
|
|
}, [page, search, clinicId, statusFilter, sentFrom, sentTo, 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 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]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!canView) return;
|
||
|
|
const timeout = setTimeout(() => void loadTasks(), search ? 300 : 0);
|
||
|
|
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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatTaskDate(value: string) {
|
||
|
|
return new Intl.DateTimeFormat(locale, {
|
||
|
|
year: 'numeric',
|
||
|
|
month: 'short',
|
||
|
|
day: 'numeric',
|
||
|
|
}).format(new Date(value));
|
||
|
|
}
|
||
|
|
|
||
|
|
const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-2 py-1.5 text-sm`;
|
||
|
|
|
||
|
|
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) => {
|
||
|
|
setSearch(v);
|
||
|
|
setPage(1);
|
||
|
|
}}
|
||
|
|
placeholder={t('searchPlaceholder')}
|
||
|
|
/>
|
||
|
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||
|
|
<label className="space-y-1">
|
||
|
|
<span className="text-xs text-text-muted">{t('filterClinic')}</span>
|
||
|
|
<select
|
||
|
|
value={clinicId}
|
||
|
|
onChange={(e) => {
|
||
|
|
setClinicId(e.target.value);
|
||
|
|
setPage(1);
|
||
|
|
}}
|
||
|
|
className={filterSelectClass}
|
||
|
|
>
|
||
|
|
<option value="">{t('filterClinicAll')}</option>
|
||
|
|
{clinicOptions.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) => {
|
||
|
|
setStatusFilter(e.target.value as '' | LabTaskStatus);
|
||
|
|
setPage(1);
|
||
|
|
}}
|
||
|
|
className={filterSelectClass}
|
||
|
|
>
|
||
|
|
<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('sortBy')}</span>
|
||
|
|
<div className="flex gap-1.5">
|
||
|
|
<select
|
||
|
|
value={sortBy}
|
||
|
|
onChange={(e) => setSortBy(e.target.value as TaskSortField)}
|
||
|
|
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>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<section className="surface-card min-h-[280px]">
|
||
|
|
{loading && tasks.length === 0 ? (
|
||
|
|
<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>
|
||
|
|
) : (
|
||
|
|
<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>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</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))}
|
||
|
|
>
|
||
|
|
←
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="secondary"
|
||
|
|
disabled={page >= pagination.totalPages || loading}
|
||
|
|
onClick={() => setPage((p) => p + 1)}
|
||
|
|
>
|
||
|
|
→
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|