feature: Tasks tab added for lab organizations. tasks now can be assigned and their status can be updated by the assignee.
This commit is contained in:
@@ -1,14 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { useToast } from '@/lib/hooks/useToast';
|
||||
import { hasPermission } from '@/components/shared/permissions';
|
||||
import { canEditCases } from '@/components/shared/permissions';
|
||||
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
|
||||
import { casesApi } from '@/lib/api/cases';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
|
||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||
import type {
|
||||
AssignableMember,
|
||||
@@ -28,6 +31,18 @@ const TREATMENT_TYPE_KEYS = {
|
||||
} as const;
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const PRIORITY_OPTIONS = [1, 2, 3, 4, 5] as const;
|
||||
|
||||
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
|
||||
switch (status) {
|
||||
case 'COMPLETED':
|
||||
return 'success';
|
||||
case 'IN_PROGRESS':
|
||||
return 'default';
|
||||
default:
|
||||
return 'warning';
|
||||
}
|
||||
}
|
||||
|
||||
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||
@@ -66,6 +81,7 @@ export default function CasesPage() {
|
||||
const tCommon = useTranslations('common');
|
||||
const { currentOrganization, user } = useAuth();
|
||||
const toast = useToast();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [clinicId, setClinicId] = useState('');
|
||||
@@ -93,7 +109,7 @@ export default function CasesPage() {
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
||||
|
||||
const canEdit = hasPermission(currentOrganization, 'TAB_CASES_EDIT');
|
||||
const canEdit = canEditCases(currentOrganization);
|
||||
const locale = user?.language ?? 'en';
|
||||
|
||||
const treatmentLabel = useCallback(
|
||||
@@ -166,6 +182,13 @@ export default function CasesPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const caseIdFromUrl = searchParams.get('caseId');
|
||||
if (caseIdFromUrl) {
|
||||
setSelectedCaseId(caseIdFromUrl);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
void loadCases({
|
||||
@@ -201,7 +224,7 @@ export default function CasesPage() {
|
||||
|
||||
async function handleTaskUpdate(
|
||||
taskId: string,
|
||||
payload: { assigneeUserId?: string | null; status?: LabTaskStatus },
|
||||
payload: { assigneeUserId?: string | null; priority?: number },
|
||||
) {
|
||||
if (!selectedCaseId || !canEdit) return;
|
||||
|
||||
@@ -225,8 +248,7 @@ export default function CasesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const filterSelectClass =
|
||||
'w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-text-primary';
|
||||
const filterSelectClass = `${FORM_SELECT_CLASS} w-full rounded-md px-3 py-2`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -475,24 +497,29 @@ export default function CasesPage() {
|
||||
{group.tasks.map((task) => (
|
||||
<li
|
||||
key={task.id}
|
||||
className="grid gap-2 sm:grid-cols-[1fr_160px_180px] items-center text-sm rounded bg-background p-2"
|
||||
className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto_88px_180px] items-center text-sm rounded bg-background p-2"
|
||||
>
|
||||
<span>
|
||||
{task.stepOrder}. {task.stepLabel}
|
||||
</span>
|
||||
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
|
||||
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||
task.status}
|
||||
</Badge>
|
||||
<select
|
||||
value={task.status}
|
||||
value={task.priority}
|
||||
disabled={!canEdit || updatingTaskId === task.id}
|
||||
onChange={(e) =>
|
||||
void handleTaskUpdate(task.id, {
|
||||
status: e.target.value as LabTaskStatus,
|
||||
priority: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="rounded border border-border bg-surface px-2 py-1 text-sm disabled:opacity-60"
|
||||
className={FORM_SELECT_CLASS}
|
||||
aria-label={t('priorityLabel')}
|
||||
>
|
||||
{statusOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{PRIORITY_OPTIONS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -504,7 +531,7 @@ export default function CasesPage() {
|
||||
assigneeUserId: e.target.value || null,
|
||||
})
|
||||
}
|
||||
className="rounded border border-border bg-surface px-2 py-1 text-sm disabled:opacity-60"
|
||||
className={FORM_SELECT_CLASS}
|
||||
>
|
||||
<option value="">{t('unassigned')}</option>
|
||||
{members.map((member) => (
|
||||
|
||||
258
frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
Normal file
258
frontend/src/app/[locale]/(dashboard)/tasks/page.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||
import { Badge, type BadgeVariant } from '@/components/ui/shared/Badge';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
|
||||
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
|
||||
import { formatApiErrorMessage } 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, PaginatedLabTasks } from '@/types/cases';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
|
||||
switch (status) {
|
||||
case 'COMPLETED':
|
||||
return 'success';
|
||||
case 'IN_PROGRESS':
|
||||
return 'default';
|
||||
default:
|
||||
return 'warning';
|
||||
}
|
||||
}
|
||||
|
||||
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||
return `${patient.firstName} ${patient.lastName}`.trim();
|
||||
}
|
||||
|
||||
export default function TasksPage() {
|
||||
const t = useTranslations('tasks');
|
||||
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 canView = canViewTasks(currentOrganization);
|
||||
const canEdit = canEditTasks(currentOrganization);
|
||||
const locale = user?.language ?? 'en';
|
||||
const isOwner = Boolean(currentOrganization?.isOwner);
|
||||
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
|
||||
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
|
||||
() => [
|
||||
{ value: 'PENDING', label: t('statusPending') },
|
||||
{ value: 'IN_PROGRESS', label: t('statusInProgress') },
|
||||
{ value: 'COMPLETED', label: t('statusCompleted') },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canView) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await tasksApi.list({ page, limit: PAGE_SIZE });
|
||||
if (cancelled) return;
|
||||
setTasks(response.data.items);
|
||||
setPagination(response.data.pagination);
|
||||
} catch (error: unknown) {
|
||||
if (cancelled) return;
|
||||
showError(formatApiErrorMessage(error, tRef.current('errorLoadList')));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canView, page, showError, setError]);
|
||||
|
||||
async function handleStatusUpdate(taskId: string, status: LabTaskStatus) {
|
||||
if (!canEdit) return;
|
||||
|
||||
setUpdatingTaskId(taskId);
|
||||
setError('');
|
||||
try {
|
||||
await tasksApi.updateStatus(taskId, status);
|
||||
const response = await tasksApi.list({ page, limit: PAGE_SIZE });
|
||||
setTasks(response.data.items);
|
||||
setPagination(response.data.pagination);
|
||||
} catch (error: unknown) {
|
||||
showError(formatApiErrorMessage(error, t('errorUpdateTask')));
|
||||
} finally {
|
||||
setUpdatingTaskId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTaskDate(value: string) {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function sortDateForTask(task: LabTaskListItem) {
|
||||
return task.assignedAt ?? task.createdAt;
|
||||
}
|
||||
|
||||
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-2xl font-semibold text-text-primary">{t('title')}</h1>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{isOwner ? t('subtitleOwner') : t('subtitle')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<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">
|
||||
{isOwner ? t('emptyListOwner') : t('emptyList')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{tasks.map((task) => {
|
||||
const statusEditable =
|
||||
canEdit && (isOwner || task.assigneeUserId === user?.id);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={task.id}
|
||||
className="grid grid-cols-[minmax(0,1fr)_132px_auto] items-center gap-x-3 gap-y-0.5 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary truncate">
|
||||
{task.stepOrder}. {task.stepLabel}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-secondary truncate">
|
||||
{t('fromClinic', { name: task.clinic.name })} ·{' '}
|
||||
{formatPatientName(task.patient)} · {t('toothLabel', { tooth: task.tooth })}
|
||||
</p>
|
||||
<p className="text-[11px] text-text-muted truncate flex flex-wrap items-center gap-x-1 gap-y-0.5">
|
||||
<span>{t('taskDate', { date: formatTaskDate(sortDateForTask(task)) })}</span>
|
||||
{isOwner && (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<Badge
|
||||
variant={task.assignee ? 'success' : 'danger'}
|
||||
fixedWidth={false}
|
||||
>
|
||||
{task.assignee
|
||||
? t('assignedTo', { name: task.assignee.name })
|
||||
: t('unassigned')}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
{statusEditable ? (
|
||||
<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 max-w-[132px]`}
|
||||
>
|
||||
{statusOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Badge variant={taskStatusVariant(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-end">
|
||||
<Badge variant="default" fixedWidth={false}>
|
||||
{t('priorityLabel', { n: task.priority })}
|
||||
</Badge>
|
||||
<TreatmentTypeBadge type={task.treatmentType} />
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<ToastStack {...toastMessages} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user