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>
175 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|