improvement: tasks feature UX fully overhauled.
This commit is contained in:
174
frontend/src/components/ui/lab/TaskRow.tsx
Normal file
174
frontend/src/components/ui/lab/TaskRow.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user