Files
task-manager/client/src/components/TaskCard.tsx
T
paul-nothaft 037d3f4d73 Add right-click context menus for task and calendar interactions
Integrates context menus on the task card and calendar views, providing options to edit, start/stop timers, and change task status.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/R3zkvYP
2025-10-24 08:50:06 +00:00

421 lines
14 KiB
TypeScript

import { useTranslation } from 'react-i18next';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { Clock, Calendar, Play, Pause, MoreHorizontal, Edit, Trash2, Timer, CheckCircle, X } from "lucide-react";
import { Task, Label } from '@shared/schema';
import { useQuery } from '@tanstack/react-query';
import { Checkbox } from "@/components/ui/checkbox";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface TaskCardProps {
task: Task;
onStartTimer?: () => void;
onStopTimer?: () => void;
onEdit?: () => void;
onDelete?: () => void;
onStatusChange?: (status: Task['status']) => void;
isDragging?: boolean;
}
export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, isDragging }: TaskCardProps) {
const { t } = useTranslation();
// Fetch labels to get the label color
const { data: labels = [] } = useQuery<Label[]>({
queryKey: ['/api/labels'],
staleTime: 5 * 60 * 1000, // 5 minutes cache
refetchOnWindowFocus: false,
});
// Find the label for this task
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
const handleToggleTimer = () => {
if (task.isTracking) {
onStopTimer?.();
console.log(`Timer stopped for task: ${task.title}`);
} else {
onStartTimer?.();
console.log(`Timer started for task: ${task.title}`);
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high': return 'bg-destructive text-destructive-foreground';
case 'medium': return 'bg-yellow-500 text-white';
default: return 'bg-muted text-muted-foreground';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'done': return 'bg-green-500 text-white';
case 'inProgress': return 'bg-primary text-primary-foreground';
default: return 'bg-muted text-muted-foreground';
}
};
const formatTime = (minutes: number) => {
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
if (mins === 0) {
return `${hours}h`;
}
return `${hours}h ${mins}m`;
};
const handleToggleComplete = (e: React.MouseEvent) => {
e.stopPropagation();
if (task.status === 'done') {
onStatusChange?.('todo');
} else {
onStatusChange?.('done');
}
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<Card
className={`p-4 hover-elevate active-elevate-2 transition-all duration-200 relative ${
isDragging ? 'rotate-1 scale-105 shadow-lg' : ''
} ${task.status === 'done' ? 'opacity-60' : ''}`}
style={taskLabel ? {
borderColor: taskLabel.color,
borderWidth: '2px',
borderStyle: 'solid'
} : {}}
data-testid={`card-task-${task.id}`}
>
<div className="flex items-start gap-3">
{/* Checkbox for quick completion */}
<Tooltip>
<TooltipTrigger asChild>
<Checkbox
checked={task.status === 'done'}
onClick={handleToggleComplete}
data-testid={`checkbox-complete-${task.id}`}
className="mt-0.5"
/>
</TooltipTrigger>
<TooltipContent>
<p>{t('taskCard.toggleComplete')}</p>
</TooltipContent>
</Tooltip>
<div
className="flex-1 min-w-0 cursor-pointer"
onClick={() => {
onEdit?.();
console.log(`Task card clicked: ${task.title}`);
}}
>
<h3 className={`font-medium text-sm leading-tight truncate ${task.status === 'done' ? 'line-through' : ''}`} data-testid={`text-task-title-${task.id}`}>
{task.title}
</h3>
{task.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2" data-testid={`text-task-description-${task.id}`}>
{task.description}
</p>
)}
<div className="flex items-center gap-2 mt-3 flex-wrap">
<Badge
variant="secondary"
className={`text-xs ${getPriorityColor(task.priority)}`}
data-testid={`badge-priority-${task.id}`}
>
{t(`priority.${task.priority}`)}
</Badge>
<Badge
variant="outline"
className={`text-xs ${getStatusColor(task.status)}`}
data-testid={`badge-status-${task.id}`}
>
{t(`status.${task.status}`)}
</Badge>
{task.dueDate && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Calendar className="w-3 h-3" />
<span data-testid={`text-due-date-${task.id}`}>
{task.dueDate.toLocaleDateString()}
</span>
</div>
)}
</div>
{(task.timeTracked > 0 || task.isTracking) && (
<div className="flex items-center gap-1 mt-2 text-xs">
<Clock className={`w-3 h-3 ${task.isTracking ? 'text-primary animate-pulse' : 'text-muted-foreground'}`} />
<span
data-testid={`text-time-tracked-${task.id}`}
className={task.isTracking ? 'text-primary font-medium' : 'text-muted-foreground'}
>
{task.timeTracked > 0 ? formatTime(task.timeTracked) : '0m'}
{task.isTracking && ` (${t('taskCard.running')})`}
</span>
</div>
)}
</div>
<div className="flex items-start gap-1">
{/* Quick delete button */}
{onDelete && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
data-testid={`button-delete-${task.id}`}
className="w-6 h-6 text-muted-foreground hover:text-destructive"
>
<X className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('taskCard.quickDelete')}</p>
</TooltipContent>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
onClick={(e) => e.stopPropagation()}
data-testid={`button-menu-${task.id}`}
className="w-6 h-6"
>
<MoreHorizontal className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit?.();
console.log(`Edit task: ${task.title}`);
}}
data-testid={`menu-edit-${task.id}`}
>
<Edit className="w-4 h-4 mr-2" />
{t('taskCard.editTask')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleToggleTimer();
}}
data-testid={`menu-timer-${task.id}`}
>
<Timer className="w-4 h-4 mr-2" />
{task.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
</DropdownMenuItem>
<DropdownMenuSeparator />
{task.status !== 'done' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('done');
console.log(`Mark task as done: ${task.title}`);
}}
data-testid={`menu-complete-${task.id}`}
>
<CheckCircle className="w-4 h-4 mr-2" />
{t('taskCard.markAsDone')}
</DropdownMenuItem>
)}
{task.status === 'todo' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
console.log(`Start working on: ${task.title}`);
}}
data-testid={`menu-start-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.startWorking')}
</DropdownMenuItem>
)}
{task.status === 'inProgress' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('todo');
console.log(`Move back to todo: ${task.title}`);
}}
data-testid={`menu-todo-${task.id}`}
>
<Clock className="w-4 h-4 mr-2" />
{t('taskCard.moveToTodo')}
</DropdownMenuItem>
)}
{task.status === 'done' && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
console.log(`Reopen task: ${task.title}`);
}}
data-testid={`menu-reopen-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.reopenTask')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
console.log(`Delete task: ${task.title}`);
}}
className="text-destructive"
data-testid={`menu-delete-${task.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('taskCard.deleteTask')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onEdit?.();
}}
data-testid={`context-menu-edit-${task.id}`}
>
<Edit className="w-4 h-4 mr-2" />
{t('taskCard.editTask')}
</ContextMenuItem>
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
handleToggleTimer();
}}
data-testid={`context-menu-timer-${task.id}`}
>
<Timer className="w-4 h-4 mr-2" />
{task.isTracking ? t('taskCard.stopTimer') : t('taskCard.startTimer')}
</ContextMenuItem>
<ContextMenuSeparator />
{task.status !== 'done' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('done');
}}
data-testid={`context-menu-complete-${task.id}`}
>
<CheckCircle className="w-4 h-4 mr-2" />
{t('taskCard.markAsDone')}
</ContextMenuItem>
)}
{task.status === 'todo' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
}}
data-testid={`context-menu-start-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.startWorking')}
</ContextMenuItem>
)}
{task.status === 'inProgress' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('todo');
}}
data-testid={`context-menu-todo-${task.id}`}
>
<Clock className="w-4 h-4 mr-2" />
{t('taskCard.moveToTodo')}
</ContextMenuItem>
)}
{task.status === 'done' && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onStatusChange?.('inProgress');
}}
data-testid={`context-menu-reopen-${task.id}`}
>
<Play className="w-4 h-4 mr-2" />
{t('taskCard.reopenTask')}
</ContextMenuItem>
)}
<ContextMenuSeparator />
{onDelete && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="text-destructive"
data-testid={`context-menu-delete-${task.id}`}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('taskCard.deleteTask')}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
}