037d3f4d73
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
421 lines
14 KiB
TypeScript
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>
|
|
);
|
|
} |