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:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user