8e8b96fcde
continuous-integration/drone/push Build is passing
- Globales overflow-x: hidden auf html/body/#root in index.css - App.tsx: overflow-x-hidden und min-w-0 auf Hauptcontainer - TaskCard: Eltern-Task-Titel mit truncate/min-w-0 im Flex-Layout - KanbanBoard: min-w-0 overflow-hidden auf Spalten-Cards - FiveMinuteStarter: truncate auf Task-Titel - HyperfocusGuard: truncate auf Task-Buttons - TaskBreakdownModal: truncate auf Task- und Subtask-Titel - NotificationsPage: truncate + min-w-0 auf Benachrichtigungs-Titel
705 lines
26 KiB
TypeScript
705 lines
26 KiB
TypeScript
import { useTranslation } from 'react-i18next';
|
||
import { useState } from 'react';
|
||
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, Lock, CornerDownRight, Share2, Users } from "lucide-react";
|
||
import { Task, Label, User } from '@shared/schema';
|
||
import { ShareTaskModal } from './ShareTaskModal';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { motion, PanInfo, useAnimation } from 'framer-motion';
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipTrigger,
|
||
} from "@/components/ui/tooltip";
|
||
import { triggerConfetti } from "@/lib/confetti";
|
||
import { playSuccessSound } from "@/lib/sounds";
|
||
import { simulateAIDecomposition } from "@/lib/ai-simulator";
|
||
import { Wand2, Loader2 } from "lucide-react";
|
||
import { apiRequest } from "@/lib/queryClient";
|
||
import { useToast } from "@/hooks/use-toast";
|
||
|
||
|
||
|
||
interface TaskCardProps {
|
||
task: Task;
|
||
onStartTimer?: () => void;
|
||
onStopTimer?: () => void;
|
||
onEdit?: () => void;
|
||
onDelete?: () => void;
|
||
onStatusChange?: (status: Task['status']) => void;
|
||
onUpdate?: (updates: Partial<Task>) => void;
|
||
onAutoSchedule?: () => void;
|
||
isDragging?: boolean;
|
||
}
|
||
|
||
function SharedTaskIcon({ task }: { task: Task }) {
|
||
const { t } = useTranslation();
|
||
const { data: user } = useQuery<User>({ queryKey: ["/api/user"], retry: false });
|
||
|
||
const isOwner = user?.id === task.userId;
|
||
|
||
// If owner, fetch shared users
|
||
const { data: sharedUsers } = useQuery<any[]>({
|
||
queryKey: [`/api/tasks/${task.id}/shared-users`],
|
||
enabled: !!isOwner && !!task.id,
|
||
retry: false
|
||
});
|
||
|
||
if (!user) return null;
|
||
|
||
// Case 1: Owner and task is shared
|
||
if (isOwner && sharedUsers && sharedUsers.length > 0) {
|
||
const names = sharedUsers.map(u => u.username).join(", ");
|
||
return (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-indigo-100 dark:bg-indigo-900 cursor-help">
|
||
<Share2 className="w-3 h-3 text-indigo-600 dark:text-indigo-300" />
|
||
</div>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
<p>{t('share.sharedWith')}: {names}</p>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
|
||
// Case 2: Not owner (Shared WITH me)
|
||
if (!isOwner && task.userId) {
|
||
return (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<div className="flex items-center justify-center w-5 h-5 rounded-full bg-orange-100 dark:bg-orange-900 cursor-help">
|
||
<Users className="w-3 h-3 text-orange-600 dark:text-orange-300" />
|
||
</div>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
<p>{t('share.sharedWithMe') || "Shared with me"}</p>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function SharedMenuItem({ task, onShare }: { task: Task, onShare: () => void }) {
|
||
const { t } = useTranslation();
|
||
const { data: user } = useQuery<User>({ queryKey: ["/api/user"], retry: false });
|
||
|
||
// Only owner can share
|
||
if (user?.id !== task.userId) return null;
|
||
|
||
return (
|
||
<DropdownMenuItem
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onShare();
|
||
}}
|
||
>
|
||
<Share2 className="w-4 h-4 mr-2" />
|
||
{t('share.taskTitle')}
|
||
</DropdownMenuItem>
|
||
);
|
||
}
|
||
|
||
export default function TaskCard({ task, onStartTimer, onStopTimer, onEdit, onDelete, onStatusChange, onUpdate, onAutoSchedule, isDragging }: TaskCardProps) {
|
||
const { t } = useTranslation();
|
||
const { toast } = useToast();
|
||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
|
||
|
||
const handleAIMagic = async (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
if (isAnalyzing) return;
|
||
|
||
setIsAnalyzing(true);
|
||
try {
|
||
const aiContent = await simulateAIDecomposition(task.title);
|
||
const newDescription = (task.description || '') + aiContent;
|
||
|
||
onUpdate?.({ description: newDescription });
|
||
triggerConfetti(0.5, 0.5); // Small burst for "Magic"
|
||
playSuccessSound();
|
||
} catch (err) {
|
||
console.error(err);
|
||
} finally {
|
||
setIsAnalyzing(false);
|
||
}
|
||
};
|
||
|
||
const { data: allTasks = [] } = useQuery<Task[]>({
|
||
queryKey: ['/api/tasks'],
|
||
staleTime: 5 * 60 * 1000,
|
||
});
|
||
|
||
const blockingTasks = task.dependencies?.map(dId => allTasks.find(t => t.id === dId)).filter(t => t && t.status !== 'done') || [];
|
||
const isBlocked = blockingTasks.length > 0;
|
||
|
||
const subtasks = allTasks.filter(t => t.parentTaskId === task.id);
|
||
const completedSubtasks = subtasks.filter(t => t.status === 'done');
|
||
|
||
// 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`;
|
||
};
|
||
|
||
/* AI Follow-up Check */
|
||
const checkFollowUp = async () => {
|
||
// Fire and forget - don't block UI
|
||
try {
|
||
const res = await apiRequest("POST", "/api/ai/analyze-completion", { taskId: task.id });
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.needed && data.title) {
|
||
toast({
|
||
title: t('ai.followUpSuggestion', 'Follow-up Suggested'),
|
||
description: `${data.title} - ${data.description || ''}`,
|
||
action: (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={async (e) => {
|
||
e.stopPropagation(); // prevent toast click from doing generic things
|
||
// Create the task immediately
|
||
try {
|
||
await apiRequest("POST", "/api/tasks", {
|
||
title: data.title,
|
||
description: data.description,
|
||
status: "todo",
|
||
priority: "medium",
|
||
labelId: task.labelId, // Inherit label
|
||
dueDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() // Tomorrow
|
||
});
|
||
toast({ title: t('ai.taskCreated', 'Follow-up task created!') });
|
||
// Invalidate queries to show new task
|
||
// queryClient.invalidateQueries... (Need queryClient access)
|
||
// Since we don't have queryClient here easily without import useQueryClient
|
||
// We can just reload or rely on auto-refetch
|
||
window.location.reload(); // Crude but effective for now, or useQueryClient
|
||
} catch (err) {
|
||
toast({ title: t('ai.errorCreating', 'Failed to create task'), variant: "destructive" });
|
||
}
|
||
}}
|
||
>
|
||
{t('common.create', 'Create')}
|
||
</Button>
|
||
),
|
||
duration: 8000,
|
||
});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error("Follow-up check failed", e);
|
||
}
|
||
};
|
||
|
||
const handleToggleComplete = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
if (isBlocked) return;
|
||
if (task.status === 'done') {
|
||
onStatusChange?.('todo');
|
||
} else {
|
||
triggerConfetti(e.clientX / window.innerWidth, e.clientY / window.innerHeight);
|
||
playSuccessSound();
|
||
onStatusChange?.('done');
|
||
// Trigger AI check
|
||
checkFollowUp();
|
||
}
|
||
};
|
||
|
||
const controls = useAnimation();
|
||
|
||
const handleDragEnd = async (event: any, info: PanInfo) => {
|
||
if (info.offset.x > 100) {
|
||
if (isBlocked) {
|
||
controls.start({ x: 0 });
|
||
return;
|
||
}
|
||
// Swiped right -> Complete
|
||
triggerConfetti(0.5, 0.5);
|
||
playSuccessSound();
|
||
onStatusChange?.('done');
|
||
await controls.start({ x: 500, opacity: 0 });
|
||
} else if (info.offset.x < -100 && onDelete) {
|
||
// Swiped left -> Delete (optional, maybe just shake for now)
|
||
onDelete();
|
||
} else {
|
||
controls.start({ x: 0 });
|
||
}
|
||
};
|
||
|
||
/* Retrieve parent task if this is a subtask */
|
||
const parentTask = task.parentTaskId
|
||
? allTasks.find(t => String(t.id) === String(task.parentTaskId))
|
||
: null;
|
||
|
||
return (
|
||
<motion.div
|
||
drag="x"
|
||
dragConstraints={{ left: -50, right: 150 }} // Limit swipe distance
|
||
dragElastic={0.1}
|
||
onDragEnd={handleDragEnd}
|
||
animate={controls}
|
||
whileDrag={{ scale: 1.02, cursor: 'grabbing' }}
|
||
className="touch-pan-y" // Allow vertical scroll, horizontal swipe
|
||
>
|
||
<ContextMenu>
|
||
<ContextMenuTrigger asChild>
|
||
<Card
|
||
className={`p-5 hover-elevate active-elevate-2 transition-all duration-300 relative hover:-translate-y-1 hover:shadow-xl ${isDragging ? 'rotate-1 scale-105 shadow-lg' : ''
|
||
} ${task.status === 'done' ? 'opacity-60' : ''} ${parentTask ? 'border-l-4 border-l-indigo-400 bg-indigo-50/10' : ''}`}
|
||
style={taskLabel ? {
|
||
borderColor: taskLabel.color,
|
||
borderWidth: '2px',
|
||
borderStyle: 'solid',
|
||
borderLeftWidth: parentTask ? '4px' : '2px', // Make left border thicker if subtask
|
||
borderLeftColor: parentTask ? '#818cf8' : taskLabel.color // Indigo for subtask
|
||
} : {}}
|
||
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"
|
||
disabled={isBlocked}
|
||
/>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
{isBlocked ? (
|
||
<p className="text-destructive font-medium">
|
||
{t('taskCard.blockedBy', { tasks: blockingTasks.map(t => t?.title).join(", ") })}
|
||
</p>
|
||
) : (
|
||
<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}`);
|
||
}}
|
||
>
|
||
{/* Parent Task Link rendered in title now */}
|
||
<h3 className={`font-medium text-sm leading-tight truncate ${task.status === 'done' ? 'line-through' : ''}`} data-testid={`text-task-title-${task.id}`}>
|
||
{parentTask ? (
|
||
<span className="flex items-center gap-1 min-w-0">
|
||
<span className="text-muted-foreground font-normal truncate shrink">{parentTask.title}</span>
|
||
<span className="text-muted-foreground shrink-0">›</span>
|
||
<span className="truncate shrink">{task.title}</span>
|
||
</span>
|
||
) : (
|
||
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>
|
||
|
||
{/* Shared Status Icon */}
|
||
<SharedTaskIcon task={task} />
|
||
|
||
{(task.dueDate || task.startDate) && (
|
||
<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.startDate ? `${new Date(task.startDate).toLocaleDateString()} - ` : ''}
|
||
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : ''}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{task.energyLevel && task.energyLevel !== 'medium' && (
|
||
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
|
||
{t(`energy.${task.energyLevel}`)}
|
||
</Badge>
|
||
)}
|
||
|
||
{task.estimatedDuration && (
|
||
<Badge variant="outline" className="text-xs text-muted-foreground border-dashed">
|
||
⏳ {formatTime(task.estimatedDuration)}
|
||
</Badge>
|
||
)}
|
||
|
||
{subtasks.length > 0 && (
|
||
<Badge variant="secondary" className="text-xs">
|
||
{completedSubtasks.length}/{subtasks.length} Subtasks
|
||
</Badge>
|
||
)}
|
||
|
||
{isBlocked && (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Badge variant="outline" className="text-xs border-destructive text-destructive gap-1 animate-pulse">
|
||
<Lock className="w-3 h-3" />
|
||
Blocked
|
||
</Badge>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
<p>Waiting for: {blockingTasks.map(t => t?.title).join(", ")}</p>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
)}
|
||
</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>
|
||
|
||
{onAutoSchedule && !task.dueDate && (
|
||
<DropdownMenuItem
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onAutoSchedule();
|
||
}}
|
||
className="text-indigo-600 dark:text-indigo-400"
|
||
data-testid={`menu-schedule-${task.id}`}
|
||
>
|
||
<Wand2 className="w-4 h-4 mr-2" />
|
||
{t('taskDetails.autoSchedule', 'Auto-Schedule')}
|
||
</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>
|
||
|
||
{/* Share Option - Check if owner */}
|
||
<SharedMenuItem task={task} onShare={() => setIsShareModalOpen(true)} />
|
||
|
||
<DropdownMenuSeparator />
|
||
|
||
<DropdownMenuItem
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (isBlocked) return;
|
||
onStatusChange?.('done');
|
||
console.log(`Mark task as done: ${task.title}`);
|
||
}}
|
||
disabled={isBlocked}
|
||
data-testid={`menu-complete-${task.id}`}
|
||
>
|
||
{isBlocked ? <Lock className="w-4 h-4 mr-2" /> : <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>
|
||
|
||
<ShareTaskModal
|
||
taskId={task.id}
|
||
open={isShareModalOpen}
|
||
onOpenChange={setIsShareModalOpen}
|
||
/>
|
||
</motion.div>
|
||
);
|
||
} |