fix: task UX polish — card menu, sheet live render, composer align, project link, no comment toast

- TaskCard: replace checkbox toggle with right-click ContextMenu (Edit / Change Status submenu / Delete), matching TaskTableRow flow; status now visible via shared StatusBadge in card footer
- TaskTableRow + TaskCard: add RefreshCw icon to Change Status submenu trigger
- TaskDetailSheet: subscribe to fresh row via tasks.byIds and render liveTask so priority/status chip popovers reflect mutations immediately; invalidate byIds alongside tasks.list on update
- ChatInputBox 'comment' variant: items-end -> items-center so single-line placeholder aligns with send button
- TaskTableRow: remove project-cell click handler and underline; remove onProjectClick prop chain from TaskTable
- TaskDetailSheet header breadcrumb: now a button navigating to /projects?projectId=... (closes sheet first)
- TaskDetailSheet addComment: drop success toast on create, keep error toast and cache invalidation
This commit is contained in:
Roberto
2026-05-08 16:00:55 +02:00
parent 72e09501de
commit efa3051c61
6 changed files with 75 additions and 80 deletions

View File

@@ -34,7 +34,7 @@ const VARIANT_STYLES = {
iconSize: 14,
},
comment: {
container: 'flex items-end gap-2 px-3 py-2',
container: 'flex items-center gap-2 px-3 py-2',
textarea: 'flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground outline-none max-h-32 overflow-y-auto',
button: 'flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed',
iconSize: 14,

View File

@@ -1,9 +1,8 @@
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { Calendar, User, Pencil, Trash2, Sparkles } from 'lucide-react';
import { Calendar, User, Pencil, Trash2, Sparkles, RefreshCw, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
@@ -17,37 +16,21 @@ import {
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
} from '@/components/ui/context-menu';
import { PriorityBadge } from './PriorityBadge';
import { StatusBadge } from './StatusBadge';
import { type TaskItem } from './task-types';
import { useFormatPrefs, formatDueDate } from '@/lib/date';
import { parseAssignees } from './task-utils';
function StatusBadge({ status }: { status: string | null }) {
const { t } = useTranslation();
if (!status) return null;
const label =
status === 'todo' ? t('tasks.toDo') :
status === 'in_progress' ? t('tasks.inProgress') :
status === 'done' ? t('tasks.done') : null;
if (!label) return null;
return (
<Badge
variant="outline"
className={cn(
'text-xs',
status === 'in_progress' && 'border-sky-300 dark:border-sky-800 bg-sky-50 dark:bg-sky-950/30 text-sky-700 dark:text-sky-400',
status === 'done' && 'border-green-300 dark:border-green-800 bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400',
)}
>
{label}
</Badge>
);
}
const STATUSES = ['todo', 'in_progress', 'done'] as const;
export function TaskCard({
task,
onToggle,
onStatusChange,
onEdit,
onDelete,
onClick,
@@ -55,7 +38,7 @@ export function TaskCard({
layoutId,
}: {
task: TaskItem;
onToggle: (id: string, status: string | null) => void;
onStatusChange: (id: string, status: string) => void;
onEdit?: (task: TaskItem) => void;
onDelete?: (id: string) => void;
onClick?: (task: TaskItem) => void;
@@ -66,10 +49,6 @@ export function TaskCard({
const prefs = useFormatPrefs();
const isDone = task.status === 'done';
const checkboxState: boolean | 'indeterminate' =
task.status === 'done' ? true :
task.status === 'in_progress' ? 'indeterminate' : false;
const breadcrumb: string[] = [];
if (!hideBreadcrumb) {
if (task.clientName) breadcrumb.push(task.clientName);
@@ -97,16 +76,10 @@ export function TaskCard({
)}
onClick={() => onClick?.(task)}
>
{/* Header: checkbox + title */}
<div className="flex items-start gap-3">
<Checkbox
checked={checkboxState}
onCheckedChange={() => onToggle(task.id, task.status)}
onClick={(e) => e.stopPropagation()}
className="mt-0.5 shrink-0"
/>
{/* Header: title (no checkbox) */}
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<div className={cn('flex items-center gap-1 text-sm font-medium min-w-0', isDone && 'line-through text-muted-foreground')}>
<div className={cn('flex items-center gap-1.5 text-sm font-medium min-w-0', isDone && 'line-through text-muted-foreground')}>
{task.isAiSuggested ? <Sparkles className="h-3 w-3 shrink-0 text-amber-500" /> : null}
<span className="truncate">{task.title}</span>
</div>
@@ -168,6 +141,20 @@ export function TaskCard({
<Pencil className="h-4 w-4 mr-2" />
{t('common.edit')}
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>
<RefreshCw className="h-4 w-4 mr-2" />
{t('tasks.changeStatus')}
</ContextMenuSubTrigger>
<ContextMenuSubContent>
{STATUSES.map((s) => (
<ContextMenuItem key={s} onSelect={() => onStatusChange(task.id, s)}>
{task.status === s ? <Check className="h-3 w-3 mr-2" /> : <span className="w-5" />}
{t(s === 'todo' ? 'tasks.toDo' : s === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem
onSelect={() => onDelete?.(task.id)}
className="text-destructive focus:text-destructive"

View File

@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next';
import { useNavigate } from '@tanstack/react-router';
import { MoreHorizontal, Pencil, Trash2, ChevronRight, Plus, X } from 'lucide-react';
import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
@@ -32,16 +33,20 @@ interface Props {
export function TaskDetailSheet({ task, open, onOpenChange, onEdit, onDelete }: Props) {
const { t } = useTranslation();
const prefs = useFormatPrefs();
const navigate = useNavigate();
const attachments = useTaskAttachments(task?.id ?? null);
const utils = trpc.useUtils();
const { notify, notifyError } = useNotify();
const { notifyError } = useNotify();
const { data: comments } = trpc.taskComments.list.useQuery(
{ taskId: task?.id ?? '' },
{ enabled: !!task },
);
const { data: fresh } = trpc.tasks.byIds.useQuery(
{ ids: task ? [task.id] : [] },
{ enabled: !!task },
);
const addComment = trpc.taskComments.create.useMutation({
onSuccess: () => {
notify('success', 'toast.comment.created');
if (task) void utils.taskComments.list.invalidate({ taskId: task.id });
},
onError: (err) => notifyError('toast.comment.createError', err),
@@ -50,10 +55,14 @@ export function TaskDetailSheet({ task, open, onOpenChange, onEdit, onDelete }:
onSuccess: () => task && void utils.taskComments.list.invalidate({ taskId: task.id }),
});
const updateTask = trpc.tasks.update.useMutation({
onSuccess: () => void utils.tasks.list.invalidate(),
onSuccess: () => {
void utils.tasks.list.invalidate();
void utils.tasks.byIds.invalidate();
},
onError: (err) => notifyError('toast.task.updateError', err),
});
if (!task) return null;
const liveTask = (fresh?.[0] as TaskItem | undefined) ?? task;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
@@ -64,11 +73,21 @@ export function TaskDetailSheet({ task, open, onOpenChange, onEdit, onDelete }:
{/* Sticky header */}
<div className="px-6 pt-6 pb-4 border-b border-border/40 shrink-0">
<div className="flex items-center justify-between gap-2">
<div className="text-xs text-muted-foreground flex items-center gap-1 min-w-0">
{task.clientName && <span className="truncate">{task.clientName}</span>}
{task.clientName && task.projectName && <ChevronRight className="h-3 w-3 shrink-0" />}
{task.projectName && <span className="text-foreground font-medium truncate">{task.projectName}</span>}
</div>
<button
type="button"
onClick={() => {
if (liveTask.projectId) {
onOpenChange(false);
navigate({ to: '/projects', search: { projectId: liveTask.projectId } });
}
}}
disabled={!liveTask.projectId}
className="text-xs text-muted-foreground flex items-center gap-1 min-w-0 hover:text-foreground transition-colors disabled:cursor-default disabled:hover:text-muted-foreground"
>
{liveTask.clientName && <span className="truncate">{liveTask.clientName}</span>}
{liveTask.clientName && liveTask.projectName && <ChevronRight className="h-3 w-3 shrink-0" />}
{liveTask.projectName && <span className="text-foreground font-medium truncate">{liveTask.projectName}</span>}
</button>
<div className="flex items-center gap-1 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -77,11 +96,11 @@ export function TaskDetailSheet({ task, open, onOpenChange, onEdit, onDelete }:
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => onEdit(task)}>
<DropdownMenuItem onSelect={() => onEdit(liveTask)}>
<Pencil className="h-4 w-4 mr-2" />
{t('common.edit')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onDelete(task.id)} className="text-destructive focus:text-destructive">
<DropdownMenuItem onSelect={() => onDelete(liveTask.id)} className="text-destructive focus:text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
{t('common.delete')}
</DropdownMenuItem>
@@ -94,12 +113,12 @@ export function TaskDetailSheet({ task, open, onOpenChange, onEdit, onDelete }:
</SheetClose>
</div>
</div>
<div className="text-lg font-semibold leading-tight mt-1">{task.title}</div>
<div className="text-lg font-semibold leading-tight mt-1">{liveTask.title}</div>
<div className="flex items-center gap-2 mt-2">
<Popover>
<PopoverTrigger asChild>
<button type="button" className="rounded hover:bg-accent/50 px-1 -mx-1">
<PriorityBadge priority={task.priority} />
<PriorityBadge priority={liveTask.priority} />
</button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="start">
@@ -107,7 +126,7 @@ export function TaskDetailSheet({ task, open, onOpenChange, onEdit, onDelete }:
<button
key={p}
type="button"
onClick={() => updateTask.mutate({ id: task.id, priority: p })}
onClick={() => updateTask.mutate({ id: liveTask.id, priority: p })}
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
>
{t(`tasks.${p}`)}
@@ -118,7 +137,7 @@ export function TaskDetailSheet({ task, open, onOpenChange, onEdit, onDelete }:
<Popover>
<PopoverTrigger asChild>
<button type="button" className="rounded hover:bg-accent/50 px-1 -mx-1">
<StatusBadge status={task.status} />
<StatusBadge status={liveTask.status} />
</button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="start">
@@ -126,7 +145,7 @@ export function TaskDetailSheet({ task, open, onOpenChange, onEdit, onDelete }:
<button
key={s}
type="button"
onClick={() => updateTask.mutate({ id: task.id, status: s })}
onClick={() => updateTask.mutate({ id: liveTask.id, status: s })}
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
>
{t(s === 'todo' ? 'tasks.toDo' : s === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
@@ -142,16 +161,16 @@ export function TaskDetailSheet({ task, open, onOpenChange, onEdit, onDelete }:
<div className="mx-6 mt-4 rounded-lg border border-border/40 bg-background/40 p-4">
<div className="grid grid-cols-2 gap-x-6 gap-y-4">
<PropRow label={t('tasks.assignee')}>
<AssigneeStack assignees={parseAssignees(task.assignee)} />
<AssigneeStack assignees={parseAssignees(liveTask.assignee)} />
</PropRow>
<PropRow label={t('tasks.colDue')}>
{task.dueDate ? formatDueDate(task.dueDate, prefs) : <span className="text-muted-foreground"></span>}
{liveTask.dueDate ? formatDueDate(liveTask.dueDate, prefs) : <span className="text-muted-foreground"></span>}
</PropRow>
<PropRow label={t('tasks.estimate')}>
<span className="text-muted-foreground"></span>
</PropRow>
<PropRow label={t('tasks.created')}>
{task.createdAt ? formatRelative(task.createdAt) : <span className="text-muted-foreground"></span>}
{liveTask.createdAt ? formatRelative(liveTask.createdAt) : <span className="text-muted-foreground"></span>}
</PropRow>
<div className="col-span-2">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">
@@ -184,8 +203,8 @@ export function TaskDetailSheet({ task, open, onOpenChange, onEdit, onDelete }:
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">
{t('tasks.description')}
</div>
{task.description ? (
<div className="text-sm whitespace-pre-wrap">{task.description}</div>
{liveTask.description ? (
<div className="text-sm whitespace-pre-wrap">{liveTask.description}</div>
) : (
<div className="text-sm italic text-muted-foreground">{t('tasks.noDescription')}</div>
)}
@@ -227,11 +246,11 @@ export function TaskDetailSheet({ task, open, onOpenChange, onEdit, onDelete }:
<div className="px-6 py-3 border-t border-border/40 shrink-0">
<div className="rounded-2xl bg-background/70 backdrop-blur-xl border border-border/50 shadow-lg ring-1 ring-border/20 transition-shadow focus-within:shadow-xl focus-within:border-ring/50">
<ChatInputBox
cacheKey={`task-comment-${task.id}`}
cacheKey={`task-comment-${liveTask.id}`}
isStreaming={false}
variant="comment"
placeholder={t('tasks.writeComment')}
onSend={(text) => addComment.mutate({ taskId: task.id, author: 'Me', content: text })}
onSend={(text) => addComment.mutate({ taskId: liveTask.id, author: 'Me', content: text })}
/>
</div>
</div>

View File

@@ -151,10 +151,7 @@ export function TaskListView({
<TaskCard
key={task.id}
task={task}
onToggle={(id, status) => {
const next = status === 'todo' ? 'in_progress' : status === 'in_progress' ? 'done' : 'todo';
updateTask.mutate({ id, status: next });
}}
onStatusChange={(id, status) => updateTask.mutate({ id, status })}
onEdit={setEditTask}
onDelete={(id) => deleteTask.mutate({ id })}
onClick={setViewTask}

View File

@@ -1,5 +1,4 @@
import { useTranslation } from 'react-i18next';
import { useNavigate } from '@tanstack/react-router';
import {
Table,
TableHeader,
@@ -30,7 +29,6 @@ export function TaskTable({
}: Props) {
const { t } = useTranslation();
const prefs = useFormatPrefs();
const navigate = useNavigate();
return (
<div className="rounded-lg border border-border/50 bg-card/65 backdrop-blur-xl shadow-sm overflow-hidden">
@@ -54,7 +52,6 @@ export function TaskTable({
onEdit={() => onEdit(task)}
onDelete={() => onDelete(task.id)}
onStatusChange={(s) => onStatusChange(task.id, s)}
onProjectClick={(projectId) => navigate({ to: '/projects', search: { projectId } })}
prefs={prefs}
/>
))}

View File

@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import { ChevronRight, Pencil, Trash2, Check } from 'lucide-react';
import { ChevronRight, Pencil, Trash2, Check, RefreshCw } from 'lucide-react';
import {
ContextMenu,
ContextMenuTrigger,
@@ -26,7 +26,6 @@ export function TaskTableRow({
onEdit,
onDelete,
onStatusChange,
onProjectClick,
prefs,
}: {
task: TaskItem;
@@ -35,7 +34,6 @@ export function TaskTableRow({
onEdit: () => void;
onDelete: () => void;
onStatusChange: (status: string) => void;
onProjectClick: (projectId: string) => void;
prefs: FormatPrefs;
}) {
const { t } = useTranslation();
@@ -48,18 +46,12 @@ export function TaskTableRow({
<TableRow className="cursor-pointer" onClick={onClick}>
<TableCell className="font-medium max-w-[280px] truncate">{task.title}</TableCell>
{!hideProjectColumn && (
<TableCell
className="text-xs"
onClick={(e) => {
e.stopPropagation();
if (task.projectId) onProjectClick(task.projectId);
}}
>
<TableCell className="text-xs">
{task.clientName && <span className="text-muted-foreground">{task.clientName}</span>}
{task.clientName && task.projectName && (
<ChevronRight className="inline h-3 w-3 mx-1 text-muted-foreground" />
)}
{task.projectName && <span className="hover:underline">{task.projectName}</span>}
{task.projectName && <span>{task.projectName}</span>}
{!task.projectName && <span className="text-muted-foreground"></span>}
</TableCell>
)}
@@ -76,7 +68,10 @@ export function TaskTableRow({
{t('common.edit')}
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>{t('tasks.changeStatus')}</ContextMenuSubTrigger>
<ContextMenuSubTrigger>
<RefreshCw className="h-4 w-4 mr-2" />
{t('tasks.changeStatus')}
</ContextMenuSubTrigger>
<ContextMenuSubContent>
{STATUSES.map((s) => (
<ContextMenuItem key={s} onSelect={() => onStatusChange(s)}>