175 lines
5.9 KiB
TypeScript
175 lines
5.9 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { MessageSquare } from 'lucide-react';
|
||
|
|
import { useTranslations } from 'next-intl';
|
||
|
|
import { Badge } from '@/components/ui/shared/Badge';
|
||
|
|
import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles';
|
||
|
|
import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel';
|
||
|
|
import {
|
||
|
|
labTaskStatusSelectStyle,
|
||
|
|
labTaskStatusVariant,
|
||
|
|
} from '@/components/lab/labTaskStatusDisplay';
|
||
|
|
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;
|
||
|
|
canEdit: boolean;
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatPatientName(patient: { firstName: string; lastName: string }) {
|
||
|
|
return `${patient.firstName} ${patient.lastName}`.trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
export function TaskRow({
|
||
|
|
task,
|
||
|
|
locale,
|
||
|
|
flatMode,
|
||
|
|
canEdit,
|
||
|
|
statusOptions,
|
||
|
|
updatingTaskId,
|
||
|
|
commentsOpen,
|
||
|
|
prosthesisCatalog,
|
||
|
|
onStatusUpdate,
|
||
|
|
onToggleComments,
|
||
|
|
onCommentError,
|
||
|
|
}: TaskRowProps) {
|
||
|
|
const t = useTranslations('tasks');
|
||
|
|
const taskDate = new Intl.DateTimeFormat(locale, {
|
||
|
|
year: 'numeric',
|
||
|
|
month: 'short',
|
||
|
|
day: 'numeric',
|
||
|
|
}).format(new Date(task.createdAt));
|
||
|
|
|
||
|
|
return (
|
||
|
|
<li className={flatMode ? undefined : 'border-b border-border/40 last:border-b-0'}>
|
||
|
|
<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 ${
|
||
|
|
flatMode ? '' : '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>
|
||
|
|
{flatMode && task.isImportant ? (
|
||
|
|
<Badge variant="warning" fixedWidth={false}>
|
||
|
|
{t('importantBadge')}
|
||
|
|
</Badge>
|
||
|
|
) : 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 sm:justify-center">
|
||
|
|
{canEdit ? (
|
||
|
|
<select
|
||
|
|
value={task.status}
|
||
|
|
disabled={updatingTaskId === task.id}
|
||
|
|
onChange={(e) => onStatusUpdate(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={() => onToggleComments(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}
|
||
|
|
{flatMode ? (
|
||
|
|
<Badge
|
||
|
|
fixedWidth={false}
|
||
|
|
truncate
|
||
|
|
title={task.prosthesisTypeLabel}
|
||
|
|
style={prosthesisTypeBadgeStyleFromCatalog(
|
||
|
|
task.prosthesisTypeCode,
|
||
|
|
prosthesisCatalog,
|
||
|
|
)}
|
||
|
|
className="w-full max-w-[8rem] sm:w-[7rem]"
|
||
|
|
>
|
||
|
|
{task.prosthesisTypeLabel}
|
||
|
|
</Badge>
|
||
|
|
) : null}
|
||
|
|
</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={onCommentError}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
</li>
|
||
|
|
);
|
||
|
|
}
|