feat(AppShell): improve token storage message styling for better visibility feat(ProjectDetail): implement skeleton loading state for project details fix(ProjectSidebar): refactor variable declaration for clarity style(PriorityBadge): enhance priority badge colors for better contrast refactor(TaskRow): simplify className handling with utility function fix(TasksPage): replace loader icon with clock icon for in-progress tasks feat(TimelinePage): enhance empty state with descriptive messaging and icon
183 lines
5.7 KiB
TypeScript
183 lines
5.7 KiB
TypeScript
import { Fragment } from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import { Calendar, User, Pencil, Trash2 } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
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={cn(
|
|
'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={cn('text-sm font-medium', 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) => (
|
|
<Fragment key={i}>
|
|
{i > 0 && <BreadcrumbSeparator />}
|
|
<BreadcrumbItem>
|
|
<span className="text-xs">{part}</span>
|
|
</BreadcrumbItem>
|
|
</Fragment>
|
|
))}
|
|
</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>
|
|
);
|
|
}
|