improvement: tasks and cases feature updated based on the new prosthesis types and their steps. the whole assignment proccess removed from the flow.
This commit is contained in:
@@ -17,16 +17,18 @@ import { FORM_SELECT_CLASS } from '@/components/ui/shared/formSelectStyles';
|
||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type {
|
||||
AssignableMember,
|
||||
CasesFilterOptions,
|
||||
LabCaseDetail,
|
||||
LabCaseListItem,
|
||||
LabTaskStatus,
|
||||
PaginatedLabCases,
|
||||
} from '@/types/cases';
|
||||
import {
|
||||
formatToothList,
|
||||
prosthesisTypeBadgeStyle,
|
||||
} from '@/components/ui/treatment/prosthesisTypeDisplay';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const PRIORITY_OPTIONS = [1, 2, 3, 4, 5] as const;
|
||||
|
||||
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
|
||||
switch (status) {
|
||||
@@ -100,7 +102,6 @@ export default function CasesPage() {
|
||||
|
||||
const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);
|
||||
const [selectedCase, setSelectedCase] = useState<LabCaseDetail | null>(null);
|
||||
const [members, setMembers] = useState<AssignableMember[]>([]);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
||||
@@ -115,7 +116,6 @@ export default function CasesPage() {
|
||||
|
||||
const statusOptions: { value: LabTaskStatus; label: string }[] = useMemo(
|
||||
() => [
|
||||
{ value: 'PENDING', label: t('statusPending') },
|
||||
{ value: 'IN_PROGRESS', label: t('statusInProgress') },
|
||||
{ value: 'COMPLETED', label: t('statusCompleted') },
|
||||
],
|
||||
@@ -171,7 +171,6 @@ export default function CasesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
void casesApi.listFilterOptions().then((r) => setFilterOptions(r.data)).catch(() => {});
|
||||
void casesApi.listAssignableMembers().then((r) => setMembers(r.data)).catch(() => {});
|
||||
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only initial fetch
|
||||
}, []);
|
||||
@@ -216,25 +215,14 @@ export default function CasesPage() {
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
async function handleTaskUpdate(
|
||||
taskId: string,
|
||||
payload: { assigneeUserId?: string | null; priority?: number },
|
||||
) {
|
||||
async function handleImportantToggle(taskId: string, isImportant: boolean) {
|
||||
if (!selectedCaseId || !canEdit) return;
|
||||
|
||||
setUpdatingTaskId(taskId);
|
||||
toast.setError('');
|
||||
try {
|
||||
await casesApi.updateTask(selectedCaseId, taskId, payload);
|
||||
await casesApi.setTaskImportant(selectedCaseId, taskId, isImportant);
|
||||
await loadDetail(selectedCaseId);
|
||||
await loadCases({
|
||||
q: search,
|
||||
clinicOrganizationId: clinicId,
|
||||
treatmentType,
|
||||
sentFrom,
|
||||
sentTo,
|
||||
page,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
toast.showError(formatApiErrorMessage(error, t('errorUpdateTask')));
|
||||
} finally {
|
||||
@@ -476,65 +464,65 @@ export default function CasesPage() {
|
||||
{selectedCase.tasksByTooth.length === 0 ? (
|
||||
<p className="text-sm text-text-muted">{t('noTasks')}</p>
|
||||
) : (
|
||||
selectedCase.tasksByTooth.map((group) => (
|
||||
selectedCase.tasksByTooth.map((group, groupIndex) => (
|
||||
<div
|
||||
key={`${group.tooth}-${group.treatmentType}`}
|
||||
key={`${group.treatmentDetailId}-${group.prosthesisTypeCode}`}
|
||||
className="rounded-md border border-border p-3 space-y-2"
|
||||
>
|
||||
<div className="text-sm font-medium text-text-primary">
|
||||
{t('toothGroupTitle', {
|
||||
tooth: group.tooth,
|
||||
prosthesis: group.prosthesisTypeLabel,
|
||||
type: treatmentLabel(group.treatmentType),
|
||||
})}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium border"
|
||||
style={prosthesisTypeBadgeStyle(group.prosthesisTypeCode, groupIndex)}
|
||||
>
|
||||
{group.prosthesisTypeLabel}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{t('toothGroupTitle', {
|
||||
teeth: formatToothList(group.teeth),
|
||||
prosthesis: group.prosthesisTypeLabel,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{group.tasks.map((task) => (
|
||||
<li
|
||||
key={task.id}
|
||||
className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto_88px_180px] items-center text-sm rounded bg-background p-2"
|
||||
className="rounded bg-background p-2 text-sm space-y-1"
|
||||
>
|
||||
<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.priority}
|
||||
disabled={!canEdit || updatingTaskId === task.id}
|
||||
onChange={(e) =>
|
||||
void handleTaskUpdate(task.id, {
|
||||
priority: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className={FORM_SELECT_CLASS}
|
||||
aria-label={t('priorityLabel')}
|
||||
>
|
||||
{PRIORITY_OPTIONS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={task.assigneeUserId ?? ''}
|
||||
disabled={!canEdit || updatingTaskId === task.id}
|
||||
onChange={(e) =>
|
||||
void handleTaskUpdate(task.id, {
|
||||
assigneeUserId: e.target.value || null,
|
||||
})
|
||||
}
|
||||
className={FORM_SELECT_CLASS}
|
||||
>
|
||||
<option value="">{t('unassigned')}</option>
|
||||
{members.map((member) => (
|
||||
<option key={member.userId} value={member.userId}>
|
||||
{member.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
{task.stepOrder}. {task.stepLabel}
|
||||
</span>
|
||||
<Badge variant={taskStatusVariant(task.status)} fixedWidth={false}>
|
||||
{statusOptions.find((opt) => opt.value === task.status)?.label ??
|
||||
task.status}
|
||||
</Badge>
|
||||
{canEdit ? (
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={task.isImportant}
|
||||
disabled={updatingTaskId === task.id}
|
||||
onChange={(e) =>
|
||||
void handleImportantToggle(task.id, e.target.checked)
|
||||
}
|
||||
/>
|
||||
{t('importantLabel')}
|
||||
</label>
|
||||
) : task.isImportant ? (
|
||||
<Badge variant="warning" fixedWidth={false}>
|
||||
{t('importantLabel')}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{task.lastStatusChangedBy
|
||||
? t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })
|
||||
: t('lastUpdatedUnknown')}
|
||||
{task.lastStatusChangedAt
|
||||
? ` · ${formatDateTime(task.lastStatusChangedAt, locale)}`
|
||||
: ''}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
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 { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||
import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge';
|
||||
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||||
import {
|
||||
formatToothList,
|
||||
prosthesisTypeBadgeStyle,
|
||||
} from '@/components/ui/treatment/prosthesisTypeDisplay';
|
||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
@@ -14,20 +21,19 @@ import { useToast } from '@/lib/hooks/useToast';
|
||||
import { tasksApi } from '@/lib/api/tasks';
|
||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||
import { treatmentTypeLabelFromCatalog } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||
import type { LabTaskListItem, LabTaskStatus, PaginatedLabTasks } from '@/types/cases';
|
||||
import type {
|
||||
LabTaskListItem,
|
||||
LabTaskStatus,
|
||||
ListLabTasksParams,
|
||||
PaginatedLabTasks,
|
||||
TaskSortField,
|
||||
} from '@/types/cases';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function taskStatusVariant(status: LabTaskStatus): BadgeVariant {
|
||||
switch (status) {
|
||||
case 'COMPLETED':
|
||||
return 'success';
|
||||
case 'IN_PROGRESS':
|
||||
return 'default';
|
||||
default:
|
||||
return 'warning';
|
||||
}
|
||||
return status === 'COMPLETED' ? 'success' : 'default';
|
||||
}
|
||||
|
||||
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||||
@@ -50,64 +56,94 @@ export default function TasksPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [updatingTaskId, setUpdatingTaskId] = useState<string | null>(null);
|
||||
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
||||
const [expandedCommentsCaseId, setExpandedCommentsCaseId] = useState<string | null>(null);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [clinicId, setClinicId] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'' | LabTaskStatus>('');
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
const [importantOnly, setImportantOnly] = useState(false);
|
||||
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 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],
|
||||
);
|
||||
|
||||
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;
|
||||
} else if (showCompleted) {
|
||||
params.completed = undefined;
|
||||
} else {
|
||||
params.completed = false;
|
||||
}
|
||||
if (importantOnly) params.important = true;
|
||||
if (sentFrom) params.sentFrom = sentFrom;
|
||||
if (sentTo) params.sentTo = sentTo;
|
||||
return params;
|
||||
}, [page, search, clinicId, statusFilter, showCompleted, importantOnly, 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(formatApiErrorMessage(error, tRef.current('errorLoadList')));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [listParams, showError, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
void treatmentCatalogApi.list().then((r) => setTreatmentCatalog(r.data)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
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);
|
||||
const response = await tasksApi.list({ page, limit: PAGE_SIZE });
|
||||
setTasks(response.data.items);
|
||||
setPagination(response.data.pagination);
|
||||
await loadTasks();
|
||||
} catch (error: unknown) {
|
||||
showError(formatApiErrorMessage(error, t('errorUpdateTask')));
|
||||
} finally {
|
||||
@@ -123,9 +159,7 @@ export default function TasksPage() {
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function sortDateForTask(task: LabTaskListItem) {
|
||||
return task.assignedAt ?? task.createdAt;
|
||||
}
|
||||
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>;
|
||||
@@ -144,89 +178,229 @@ export default function TasksPage() {
|
||||
<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>
|
||||
<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-4">
|
||||
<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>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as TaskSortField)}
|
||||
className={filterSelectClass}
|
||||
>
|
||||
<option value="date">{t('sortDate')}</option>
|
||||
<option value="status">{t('sortStatus')}</option>
|
||||
<option value="clinic">{t('sortClinic')}</option>
|
||||
<option value="patient">{t('sortPatient')}</option>
|
||||
<option value="important">{t('sortImportant')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-text-muted"> </span>
|
||||
<select
|
||||
value={sortDir}
|
||||
onChange={(e) => setSortDir(e.target.value as 'asc' | 'desc')}
|
||||
className={filterSelectClass}
|
||||
>
|
||||
<option value="desc">↓</option>
|
||||
<option value="asc">↑</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showCompleted}
|
||||
onChange={(e) => {
|
||||
setShowCompleted(e.target.checked);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
{t('showCompleted')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={importantOnly}
|
||||
onChange={(e) => {
|
||||
setImportantOnly(e.target.checked);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
{t('importantOnly')}
|
||||
</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">
|
||||
{isOwner ? t('emptyListOwner') : t('emptyList')}
|
||||
</p>
|
||||
<p className="p-3 text-sm text-text-muted">{t('emptyList')}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{tasks.map((task) => {
|
||||
const statusEditable =
|
||||
canEdit && (isOwner || task.assigneeUserId === user?.id);
|
||||
{tasks.map((task, index) => {
|
||||
const commentsOpen = expandedCommentsCaseId === task.labCaseId;
|
||||
|
||||
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 })}
|
||||
{task.prosthesisTypeLabel ? ` · ${task.prosthesisTypeLabel}` : ''}
|
||||
</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')}
|
||||
<li key={task.id}>
|
||||
<div 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">
|
||||
<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}
|
||||
<span
|
||||
className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium border"
|
||||
style={prosthesisTypeBadgeStyle(task.prosthesisTypeCode, index)}
|
||||
>
|
||||
{task.prosthesisTypeLabel}
|
||||
</span>
|
||||
</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 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 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>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0 justify-end">
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setExpandedCommentsCaseId(commentsOpen ? null : task.labCaseId)
|
||||
}
|
||||
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}
|
||||
<TreatmentTypeBadge
|
||||
type={task.treatmentType}
|
||||
label={treatmentTypeLabelFromCatalog(task.treatmentType, treatmentCatalog)}
|
||||
/>
|
||||
</div>
|
||||
</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}
|
||||
label={treatmentTypeLabelFromCatalog(task.treatmentType, treatmentCatalog)}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user