Files
adiuva/src/renderer/components/tasks/TaskRow.tsx
Roberto Musso 60b76c6d97 feat(floating-ai): step 7 — implement morph animation (FLIP)
Add FLIP animation so the floating chat visually morphs into a newly-created
TaskRow when the AI creates a task. Uses Framer Motion's shared layoutId
across FloatingChat and TaskRow, with LayoutGroup wrapping the app shell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 13:27:23 +01:00

175 lines
5.5 KiB
TypeScript

import { motion } from 'framer-motion';
import { Calendar, User, Pencil, Trash2 } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { PriorityBadge } from './PriorityBadge';
export type TaskItem = {
id: string;
projectId: string | null;
title: string;
description: string | null;
status: string | null;
priority: string | null;
assignee: string | null;
dueDate: number | null;
isAiSuggested: number;
isApproved: number;
projectName: string | null;
clientName: string | null;
subClientName: string | null;
};
export function parseAssignees(raw: string | null): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) return parsed.filter((n): n is string => typeof n === 'string');
} catch { /* plain string fallback */ }
return [raw];
}
function formatDueDate(timestamp: number): string {
const d = new Date(timestamp);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const date = `Due ${months[d.getMonth()]} ${d.getDate()}`;
if (d.getHours() === 0 && d.getMinutes() === 0) return date;
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
return `${date}, ${h}:${m}`;
}
export function TaskRow({
task,
onToggle,
onEdit,
onDelete,
onClick,
hideBreadcrumb,
layoutId,
}: {
task: TaskItem;
onToggle: (id: string, status: string | null) => void;
onEdit?: (task: TaskItem) => void;
onDelete?: (id: string) => void;
onClick?: (task: TaskItem) => void;
hideBreadcrumb?: boolean;
layoutId?: string;
}) {
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);
if (task.subClientName) breadcrumb.push(task.subClientName);
if (task.projectName) breadcrumb.push(task.projectName);
}
const hasMetadata =
task.priority ||
task.dueDate ||
breadcrumb.length > 0 ||
task.assignee;
const Wrapper = layoutId ? motion.div : 'div';
const wrapperProps = layoutId ? { layoutId, layout: true as const } : {};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<Wrapper
{...wrapperProps}
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border select-none transition-colors ${
isDone ? 'bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-900' : 'bg-card border-border'
} ${onClick ? 'cursor-pointer hover:bg-accent/50' : 'cursor-default'}`}
onClick={() => onClick?.(task)}
>
{/* Row 1: checkbox + title + description */}
<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"
/>
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold ${isDone ? 'line-through text-muted-foreground' : ''}`}>
{task.title}
</div>
{task.description && (
<div className="text-sm text-muted-foreground truncate">
{task.description}
</div>
)}
</div>
</div>
{/* Row 2: metadata, indented to align with title text */}
{hasMetadata && (
<div className="flex flex-wrap items-center gap-2 pl-7">
<PriorityBadge priority={task.priority} />
{task.dueDate && (
<Badge variant="outline" className="text-xs gap-1 shrink-0">
<Calendar className="h-3 w-3" />
{formatDueDate(task.dueDate)}
</Badge>
)}
{breadcrumb.length > 0 && (
<Breadcrumb className="shrink-0">
<BreadcrumbList>
{breadcrumb.map((part, i) => (
<BreadcrumbItem key={i}>
{i > 0 && <BreadcrumbSeparator />}
<span className="text-xs">{part}</span>
</BreadcrumbItem>
))}
</BreadcrumbList>
</Breadcrumb>
)}
{task.assignee && (
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<User className="h-3 w-3" />
{parseAssignees(task.assignee).join(', ')}
</div>
)}
</div>
)}
</Wrapper>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => onEdit?.(task)}>
<Pencil className="h-4 w-4 mr-2" />
Edit Task
</ContextMenuItem>
<ContextMenuItem
onSelect={() => onDelete?.(task.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Task
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}