244 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|