Files
dyolink/frontend/src/components/ui/lab/TaskRow.tsx

244 lines
8.2 KiB
TypeScript

'use client';
import { Check, FolderOpen, MessageSquare } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Badge } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
import { LAB_TASK_STATUS_SELECT_CLASS } from '@/components/shared/formSelectStyles';
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
import {
canEditLabTaskStatus,
labTaskStatusSelectStyle,
labTaskStatusVariant,
} from '@/components/lab/labTaskStatusDisplay';
import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge';
import {
formatToothList,
prosthesisTypeBadgeStyleFromCatalog,
} from '@/components/treatment/prosthesisTypeDisplay';
import { tasksApi } from '@/lib/api/tasks';
import type { LabTaskListItem, LabTaskStatus } from '@/types/cases';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
interface TaskRowProps {
task: LabTaskListItem;
locale: string;
flatMode: boolean;
highlighted?: boolean;
exiting?: boolean;
canEdit: boolean;
currentUserId?: string;
statusOptions: { value: LabTaskStatus; label: string }[];
updatingTaskId: string | null;
commentsOpen: boolean;
prosthesisCatalog: readonly ProsthesisCatalogEntry[];
onStatusUpdate: (taskId: string, status: LabTaskStatus) => void;
onToggleComments: (taskId: string) => void;
onCommentError: (message: string) => void;
onShowInCase?: (task: LabTaskListItem) => void;
}
function formatPatientName(patient: { firstName: string; lastName: string }) {
return `${patient.firstName} ${patient.lastName}`.trim();
}
export function TaskRow({
task,
locale,
flatMode,
highlighted = false,
exiting = false,
canEdit,
currentUserId,
statusOptions,
updatingTaskId,
commentsOpen,
prosthesisCatalog,
onStatusUpdate,
onToggleComments,
onCommentError,
onShowInCase,
}: TaskRowProps) {
const t = useTranslations('tasks');
const canEditStatus = canEditLabTaskStatus(task, currentUserId, canEdit);
const assignedToOther =
Boolean(task.assignee) && task.assignee!.id !== currentUserId;
const taskDate = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(new Date(task.createdAt));
const rowClassName = [
flatMode ? undefined : 'border-b border-border/40 last:border-b-0',
exiting ? 'task-row-complete-exit' : undefined,
!exiting && highlighted
? 'relative bg-primary/10 ring-2 ring-inset ring-primary/50 shadow-[0_0_16px_rgba(99,102,241,0.2)]'
: undefined,
]
.filter(Boolean)
.join(' ');
const commentsButton =
canEdit && !exiting ? (
<button
type="button"
onClick={() => onToggleComments(task.id)}
className={`inline-flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-md border sm:h-9 sm:w-9 ${
commentsOpen
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-text-muted hover:border-primary/40'
}`}
title={t('commentsButton')}
aria-label={t('commentsButton')}
>
<MessageSquare className="h-4 w-4" />
</button>
) : null;
const statusControl = canEditStatus && !exiting ? (
<select
value={task.status}
disabled={updatingTaskId === task.id || exiting}
onChange={(e) => onStatusUpdate(task.id, e.target.value as LabTaskStatus)}
className={LAB_TASK_STATUS_SELECT_CLASS}
style={labTaskStatusSelectStyle(task.status)}
>
{statusOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : assignedToOther && !exiting ? (
<Badge
variant="default"
fixedWidth={false}
className="h-[44px] w-full text-sm text-center whitespace-normal leading-snug sm:h-9 sm:w-auto sm:max-w-[10.5rem] sm:text-xs"
>
{t('assignedToStaff', { name: task.assignee!.name })}
</Badge>
) : (
<Badge
variant={labTaskStatusVariant(task.status)}
fixedWidth={false}
className="h-[44px] w-full text-sm sm:h-9 sm:w-auto sm:text-xs"
>
{statusOptions.find((opt) => opt.value === task.status)?.label ?? task.status}
</Badge>
);
return (
<li id={`task-row-${task.id}`} className={rowClassName}>
<div
className={`flex flex-col gap-3 px-3 py-3 sm:grid sm:items-center sm:gap-x-3 sm:gap-y-0.5 sm:py-2 ${
flatMode
? 'sm:grid-cols-[minmax(0,1fr)_minmax(10rem,14rem)_auto]'
: 'sm:grid-cols-[minmax(0,1fr)_minmax(10rem,14rem)]'
} ${flatMode ? '' : 'ps-3 sm:ps-5'}`}
>
<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>
{exiting ? (
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-400">
<Check className="h-3.5 w-3.5" />
{t('taskCompletedFlash')}
</span>
) : null}
{flatMode && task.isImportant && !exiting ? (
<Badge variant="warning" fixedWidth={false}>
{t('importantBadge')}
</Badge>
) : null}
{flatMode && task.caseDueDate && !exiting ? (
<LabCaseDueDateBadge
dueDate={task.caseDueDate}
locale={locale}
className="text-[10px]"
/>
) : null}
</div>
{flatMode ? (
<p className="text-[11px] text-text-secondary truncate">
{t('fromClinic', { name: task.clinic.name })} · {formatPatientName(task.patient)}{' '}
· {t('teethLabel', { teeth: formatToothList(task.teeth) })}
</p>
) : null}
<p className="text-[11px] text-text-muted truncate">
<span>{t('taskDate', { date: taskDate })}</span>
{task.lastStatusChangedBy ? (
<>
<span aria-hidden> · </span>
<span>{t('lastUpdatedBy', { name: task.lastStatusChangedBy.name })}</span>
</>
) : null}
</p>
</div>
<div className="flex w-full min-w-0 items-stretch gap-2">
<div className="min-w-0 flex-1">{statusControl}</div>
{commentsButton}
</div>
{flatMode ? (
<div className="flex items-center gap-1.5 shrink-0 justify-between sm:justify-end flex-wrap">
{onShowInCase && !exiting ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs px-2 py-1 h-auto"
onClick={() => onShowInCase(task)}
>
<FolderOpen className="h-3.5 w-3.5 me-1" />
{t('showInCase')}
</Button>
) : null}
<Badge
fixedWidth={false}
truncate
title={task.prosthesisTypeLabel}
style={prosthesisTypeBadgeStyleFromCatalog(
task.prosthesisTypeCode,
prosthesisCatalog,
)}
className="w-full max-w-[8rem] sm:w-[7rem]"
>
{task.prosthesisTypeLabel}
</Badge>
</div>
) : null}
</div>
{commentsOpen && canEdit && !exiting ? (
<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={onCommentError}
/>
</div>
) : null}
</li>
);
}