Files
task-manager/client/src/components/TaskDetailsModal.tsx
T
Paul Nothaft ad50b97f44
continuous-integration/drone/push Build is passing
feat: Add Mark as Done button to task details modal
Add a "Mark as Done" button next to the Save button in the task details
modal. When clicked, if no time has been tracked for the task, a dialog
prompts the user to enter time spent before marking the task as done.
The dialog supports both clock-style (hours/minutes pickers) and manual
(decimal hours) time entry.
2026-01-19 22:12:23 +01:00

1248 lines
52 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from "@/lib/utils";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useQuery } from '@tanstack/react-query';
import { Clock, Timer, FileText, Plus, Edit2, Save, X, Calendar as CalendarIcon, Tag, Link2, Check, LayoutList, Trash2, ArrowRight, CheckCircle2 } from 'lucide-react';
import { Calendar } from '@/components/ui/calendar';
import { format } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { Task, Label } from '@shared/schema';
import { useTranslation } from 'react-i18next';
interface TimeEntry {
id: string;
date: Date;
timeSpent: number; // in minutes
description?: string;
}
interface TaskDetailsModalProps {
isOpen: boolean;
onClose: () => void;
task: Task | null;
onSave: (updatedTask: Task) => void;
labels?: Label[];
onNavigate?: (taskId: string) => void;
}
export default function TaskDetailsModal({
isOpen,
onClose,
task,
onSave,
labels = [],
onNavigate
}: TaskDetailsModalProps) {
const { t, i18n } = useTranslation();
const currentLocale = i18n.language === 'de' ? de : enUS;
// Edit form state
const [editedTitle, setEditedTitle] = useState('');
const [editedDescription, setEditedDescription] = useState('');
const [editedStatus, setEditedStatus] = useState<'todo' | 'inProgress' | 'done'>('todo');
const [editedPriority, setEditedPriority] = useState<'low' | 'medium' | 'high'>('medium');
const [editedLabelId, setEditedLabelId] = useState<string | null>(null);
const [editedDueDate, setEditedDueDate] = useState('');
const [editedStartDate, setEditedStartDate] = useState('');
const [editedEstimatedDuration, setEditedEstimatedDuration] = useState<number | undefined>(undefined);
const [editedDependencies, setEditedDependencies] = useState<string[]>([]);
const [isDependenciesOpen, setIsDependenciesOpen] = useState(false);
// Subtasks state
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
const [isCreatingSubtask, setIsCreatingSubtask] = useState(false);
const [isScheduling, setIsScheduling] = useState(false);
const { data: tasksData } = useQuery<Task[]>({
queryKey: ['/api/tasks'],
});
const tasks = tasksData ?? [];
// Notes state
const [notes, setNotes] = useState('');
const [isEditingNotes, setIsEditingNotes] = useState(false);
// Time reporting state
const [isAddingTime, setIsAddingTime] = useState(false);
const [hours, setHours] = useState(0);
const [minutes, setMinutes] = useState(0);
const [manualHours, setManualHours] = useState('');
const [timeDescription, setTimeDescription] = useState('');
const [activeTimeTab, setActiveTimeTab] = useState('clock');
// Mark as done flow state
const [showTimeRequiredDialog, setShowTimeRequiredDialog] = useState(false);
const [markDoneHours, setMarkDoneHours] = useState(0);
const [markDoneMinutes, setMarkDoneMinutes] = useState(0);
const [markDoneManualHours, setMarkDoneManualHours] = useState('');
const [markDoneTimeTab, setMarkDoneTimeTab] = useState('clock');
// Time entries state - simulate from timeTracked for display
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
useEffect(() => {
if (task && isOpen) {
// Initialize edit form with task data
setEditedTitle(task.title);
setEditedDescription(task.description || '');
setEditedStatus(task.status as 'todo' | 'inProgress' | 'done');
setEditedPriority(task.priority as 'low' | 'medium' | 'high');
setEditedLabelId(task.labelId || null);
setEditedDueDate(task.dueDate ? new Date(task.dueDate).toISOString().split('T')[0] : '');
setEditedStartDate(task.startDate ? new Date(task.startDate).toISOString().split('T')[0] : '');
setEditedEstimatedDuration(task.estimatedDuration || undefined);
setEditedDependencies(task.dependencies || []);
setNotes(task.notes || '');
// Create a single time entry from timeTracked for display purposes
if (task.timeTracked > 0) {
setTimeEntries([{
id: 'tracked-time',
date: new Date(),
timeSpent: task.timeTracked,
description: t('taskDetails.previouslyTrackedTime')
}]);
} else {
setTimeEntries([]);
}
// Reset time input state
setHours(0);
setMinutes(0);
setManualHours('');
setTimeDescription('');
setIsAddingTime(false);
setIsEditingNotes(false);
}
}, [task, isOpen]);
const handleClose = () => {
setNotes('');
setTimeEntries([]);
setIsEditingNotes(false);
setIsAddingTime(false);
setHours(0);
setMinutes(0);
setManualHours('');
setTimeDescription('');
setShowTimeRequiredDialog(false);
setMarkDoneHours(0);
setMarkDoneMinutes(0);
setMarkDoneManualHours('');
onClose();
};
// Handle Mark as Done button click
const handleMarkAsDone = () => {
if (!task) return;
// Check if time has been tracked
if (!task.timeTracked || task.timeTracked === 0) {
// Show dialog to enter time first
setShowTimeRequiredDialog(true);
setMarkDoneHours(0);
setMarkDoneMinutes(0);
setMarkDoneManualHours('');
setMarkDoneTimeTab('clock');
} else {
// Time already tracked, mark as done directly
completeTaskAsDone();
}
};
// Complete the task as done
const completeTaskAsDone = () => {
if (!task) return;
const updatedTask: Task = {
...task,
title: editedTitle,
description: editedDescription || null,
status: 'done',
priority: editedPriority,
labelId: editedLabelId,
dueDate: editedDueDate ? new Date(editedDueDate) : null,
startDate: editedStartDate ? new Date(editedStartDate) : null,
estimatedDuration: editedEstimatedDuration ?? null,
dependencies: editedDependencies
};
onSave(updatedTask);
handleClose();
};
// Handle saving time and then marking as done
const handleSaveTimeAndMarkDone = () => {
if (!task) return;
let totalMinutes = 0;
if (markDoneTimeTab === 'clock') {
totalMinutes = markDoneHours * 60 + markDoneMinutes;
} else {
const parsedHours = parseFloat(markDoneManualHours) || 0;
totalMinutes = Math.round(parsedHours * 60);
}
if (totalMinutes <= 0) return;
// Create updated task with time and done status
const updatedTask: Task = {
...task,
title: editedTitle,
description: editedDescription || null,
status: 'done',
priority: editedPriority,
labelId: editedLabelId,
dueDate: editedDueDate ? new Date(editedDueDate) : null,
startDate: editedStartDate ? new Date(editedStartDate) : null,
estimatedDuration: editedEstimatedDuration ?? null,
dependencies: editedDependencies,
timeTracked: (task.timeTracked || 0) + totalMinutes
};
onSave(updatedTask);
setShowTimeRequiredDialog(false);
handleClose();
};
const handleSaveTaskEdit = () => {
if (!task) return;
const updatedTask: Task = {
...task,
title: editedTitle,
description: editedDescription || null,
status: editedStatus,
priority: editedPriority,
labelId: editedLabelId,
dueDate: editedDueDate ? new Date(editedDueDate) : null,
startDate: editedStartDate ? new Date(editedStartDate) : null,
estimatedDuration: editedEstimatedDuration ?? null,
dependencies: editedDependencies
};
onSave(updatedTask);
};
const handleSaveNotes = () => {
if (!task) return;
const updatedTask = { ...task, notes };
onSave(updatedTask);
setIsEditingNotes(false);
};
const handleSaveTimeEntry = () => {
if (!task) return;
let totalMinutes = 0;
if (activeTimeTab === 'clock') {
totalMinutes = hours * 60 + minutes;
} else {
const parsedHours = parseFloat(manualHours) || 0;
totalMinutes = Math.round(parsedHours * 60);
}
if (totalMinutes <= 0) return;
const newTimeEntry: TimeEntry = {
id: Date.now().toString(),
date: new Date(),
timeSpent: totalMinutes,
description: timeDescription.trim() || undefined
};
const updatedTimeEntries = [...timeEntries, newTimeEntry];
const updatedTimeTracked = (task.timeTracked || 0) + totalMinutes;
const updatedTask = {
...task,
timeTracked: updatedTimeTracked
};
setTimeEntries(updatedTimeEntries);
onSave(updatedTask);
// Reset time input
setHours(0);
setMinutes(0);
setManualHours('');
setTimeDescription('');
setIsAddingTime(false);
console.log(`Time entry saved: ${totalMinutes} minutes for task: ${task.title}`);
};
const handleCreateSubtask = async () => {
if (!task || !newSubtaskTitle.trim()) return;
try {
setIsCreatingSubtask(true);
const subtaskData = {
title: newSubtaskTitle,
description: '',
status: 'todo',
priority: 'medium',
parentTaskId: task.id,
dueDate: null,
estimatedDuration: null
};
await apiRequest('POST', '/api/tasks', subtaskData);
await queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
setNewSubtaskTitle('');
// Switch to the newly created task? Or just show it in the list.
} catch (error) {
console.error('Failed to create subtask:', error);
} finally {
setIsCreatingSubtask(false);
}
};
const handleAutoSchedule = async () => {
if (!task) return;
setIsScheduling(true);
try {
const res = await apiRequest('POST', `/api/tasks/${task.id}/schedule`, {});
const data = await res.json();
if (data.success && data.scheduledDate) {
setEditedDueDate(new Date(data.scheduledDate).toISOString().split('T')[0]);
// Also update the local task object immediately for smoother UX, or let queryClient invalidate
await queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
} else {
console.error("Scheduling failed: ", data.error);
// Could add a toast here ideally
}
} catch (error) {
console.error("Auto-schedule error", error);
} finally {
setIsScheduling(false);
}
};
const subtasks = tasks.filter(t => t.parentTaskId === task?.id);
const handleDeleteTimeEntry = (entryId: string) => {
if (!task || entryId === 'tracked-time') {
// Don't allow deleting the main tracked time entry
return;
}
const entryToDelete = timeEntries.find(entry => entry.id === entryId);
if (!entryToDelete) return;
const updatedTimeEntries = timeEntries.filter(entry => entry.id !== entryId);
const updatedTimeTracked = Math.max(0, (task.timeTracked || 0) - entryToDelete.timeSpent);
const updatedTask = {
...task,
timeTracked: updatedTimeTracked
};
setTimeEntries(updatedTimeEntries);
onSave(updatedTask);
};
const formatTime = (h: number, m: number) => {
return `${h}h ${m}m`;
};
const formatMinutesToHours = (minutes: number) => {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return formatTime(h, m);
};
const getCurrentTimeDisplay = () => {
if (activeTimeTab === 'clock') {
return formatTime(hours, minutes);
} else {
const parsedHours = parseFloat(manualHours) || 0;
const h = Math.floor(parsedHours);
const m = Math.round((parsedHours - h) * 60);
return formatTime(h, m);
}
};
const getTotalTrackedTime = () => {
return task?.timeTracked || 0;
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high': return 'text-red-600 bg-red-50 border-red-200';
case 'medium': return 'text-yellow-600 bg-yellow-50 border-yellow-200';
case 'low': return 'text-green-600 bg-green-50 border-green-200';
default: return 'text-gray-600 bg-gray-50 border-gray-200';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'done': return 'text-green-600 bg-green-50 border-green-200';
case 'inProgress': return 'text-blue-600 bg-blue-50 border-blue-200';
case 'todo': return 'text-gray-600 bg-gray-50 border-gray-200';
default: return 'text-gray-600 bg-gray-50 border-gray-200';
}
};
if (!task) return null;
const parentTask = task.parentTaskId ? tasks.find(t => String(t.id) === String(task.parentTaskId)) : null;
const isSubtask = !!task.parentTaskId;
return (
<>
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="w-[95vw] max-w-lg sm:max-w-2xl max-h-[90vh] overflow-y-auto p-4 sm:p-6">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
{t('taskDetails.title')}
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* Task Information */}
<Card className="p-4">
<div className="space-y-3">
{parentTask && (
<div
className="flex items-center gap-1 text-sm text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-900/30 px-2 py-1 rounded w-fit mb-1 cursor-pointer hover:underline"
onClick={() => onNavigate?.(String(parentTask.id))}
>
<ArrowRight className="w-3 h-3" />
<span className="font-medium">{t('taskDetails.subtaskOf')}</span>
<span>{parentTask.title}</span>
</div>
)}
<h3 className="font-semibold text-lg" data-testid="text-task-details-title">
{task.title}
</h3>
{task.description && (
<p className="text-sm text-muted-foreground" data-testid="text-task-details-description">
{task.description}
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className={`text-xs ${getPriorityColor(task.priority)}`}>
{t(`taskDetails.priorityValue.${task.priority}`)}
</Badge>
<Badge variant="outline" className={`text-xs ${getStatusColor(task.status)}`}>
{t(`taskDetails.statusValue.${task.status}`)}
</Badge>
{getTotalTrackedTime() > 0 && (
<Badge variant="outline" className="text-xs">
{formatMinutesToHours(getTotalTrackedTime())} {t('taskDetails.tracked')}
</Badge>
)}
</div>
</div>
</Card>
<Tabs defaultValue="edit" className="space-y-4">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="edit" data-testid="tab-edit">
<Edit2 className="w-4 h-4 mr-2" />
{t('taskDetails.editTab')}
</TabsTrigger>
<TabsTrigger value="notes" data-testid="tab-notes">
<FileText className="w-4 h-4 mr-2" />
{t('taskDetails.notesTab')}
</TabsTrigger>
<TabsTrigger value="subtasks" data-testid="tab-subtasks">
<LayoutList className="w-4 h-4 mr-2" />
{t('taskDetails.subtasksTab')}
</TabsTrigger>
<TabsTrigger value="time" data-testid="tab-time">
<Clock className="w-4 h-4 mr-2" />
{t('taskDetails.timeTab')}
</TabsTrigger>
</TabsList>
{/* Subtasks Tab */}
<TabsContent value="subtasks" className="space-y-4">
<Card className="p-4">
<h4 className="font-medium mb-4">{t('taskDetails.subtasks')}</h4>
{!isSubtask ? (
<div className="flex gap-2 mb-4">
<Input
placeholder={t('taskDetails.newSubtaskPlaceholder')}
value={newSubtaskTitle}
onChange={(e) => setNewSubtaskTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateSubtask();
}}
/>
<Button
onClick={handleCreateSubtask}
disabled={!newSubtaskTitle.trim() || isCreatingSubtask}
>
<Plus className="w-4 h-4 mr-1" />
{t('taskDetails.createSubtask')}
</Button>
</div>
) : (
<div className="mb-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200 text-sm rounded-md border border-yellow-200 dark:border-yellow-900 flex items-center gap-2">
<LayoutList className="w-4 h-4" />
{t('taskDetails.hierarchyRestriction')}
</div>
)}
<div className="space-y-2">
{subtasks.length === 0 ? (
<div className="text-center text-muted-foreground py-4">
{t('taskDetails.noSubtasks')}
</div>
) : (
subtasks.map(subtask => (
<div
key={subtask.id}
className="flex items-center justify-between p-3 border rounded-md hover:bg-muted/50 transition-colors cursor-pointer"
onClick={() => onNavigate?.(String(subtask.id))}
>
<div className="flex items-center gap-2">
<Badge variant={subtask.status === 'done' ? 'secondary' : 'outline'}>
{t(`taskDetails.statusValue.${subtask.status}`)}
</Badge>
<span className={subtask.status === 'done' ? 'line-through text-muted-foreground' : ''}>
{subtask.title}
</span>
</div>
<ArrowRight className="w-4 h-4 text-muted-foreground" />
</div>
))
)}
</div>
</Card>
</TabsContent>
{/* Edit Tab */}
<TabsContent value="edit" className="space-y-4">
<Card className="p-4">
<h4 className="font-medium mb-4">{t('taskDetails.editTask')}</h4>
<div className="space-y-4">
{/* Title */}
<div>
<label className="text-sm font-medium block mb-2">{t('taskDetails.titleLabel')}</label>
<Input
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
placeholder={t('taskDetails.titlePlaceholder')}
data-testid="input-task-title"
/>
</div>
{/* Description */}
<div>
<label className="text-sm font-medium block mb-2">{t('taskDetails.description')}</label>
<Textarea
value={editedDescription}
onChange={(e) => setEditedDescription(e.target.value)}
placeholder={t('taskDetails.descriptionPlaceholder')}
rows={3}
data-testid="input-task-description"
/>
</div>
{/* Status and Priority */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium block mb-2">{t('taskDetails.status')}</label>
<Select value={editedStatus} onValueChange={(value) => setEditedStatus(value as 'todo' | 'inProgress' | 'done')}>
<SelectTrigger data-testid="select-status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="todo">{t('taskDetails.statusValue.todo')}</SelectItem>
<SelectItem value="inProgress">{t('taskDetails.statusValue.inProgress')}</SelectItem>
<SelectItem value="done">{t('taskDetails.statusValue.done')}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium block mb-2">{t('taskDetails.priority')}</label>
<Select value={editedPriority} onValueChange={(value) => setEditedPriority(value as 'low' | 'medium' | 'high')}>
<SelectTrigger data-testid="select-priority">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{t('taskDetails.priorityValue.low')}</SelectItem>
<SelectItem value="medium">{t('taskDetails.priorityValue.medium')}</SelectItem>
<SelectItem value="high">{t('taskDetails.priorityValue.high')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Label */}
<div>
<label className="text-sm font-medium block mb-2">{t('taskDetails.label')}</label>
<Select value={editedLabelId || 'none'} onValueChange={(value) => setEditedLabelId(value === 'none' ? null : value)}>
<SelectTrigger data-testid="input-label-selector">
<SelectValue placeholder={t('taskDetails.selectLabel')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('taskDetails.noLabel')}</SelectItem>
{labels.map((label) => (
<SelectItem key={label.id} value={label.id}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: label.color }}
/>
{label.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Due Date */}
<div className="grid grid-cols-2 gap-4">
{/* Start Date */}
<div>
<div className="flex items-center justify-between mb-2 h-8">
<label className="text-sm font-medium">{t('taskDetails.startDate')}</label>
</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"w-full justify-start text-left font-normal",
!editedStartDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{editedStartDate ? format(new Date(editedStartDate), "PPP", { locale: i18n.language === 'de' ? de : enUS }) : <span>{t('taskDetails.pickDate')}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={editedStartDate ? new Date(editedStartDate) : undefined}
onSelect={(date) => setEditedStartDate(date ? date.toISOString().split('T')[0] : '')}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
{/* Due Date */}
<div>
<div className="flex items-center justify-between mb-2 h-8">
<label className="text-sm font-medium">{t('taskDetails.dueDate')}</label>
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-xs text-indigo-600 border-indigo-200 hover:text-indigo-700 hover:bg-indigo-50"
onClick={handleAutoSchedule}
disabled={isScheduling}
>
<CalendarIcon className="w-3 h-3 mr-1" />
{isScheduling ? t('taskDetails.scheduling') : t('taskDetails.autoSchedule')}
</Button>
</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"w-full justify-start text-left font-normal",
!editedDueDate && "text-muted-foreground"
)}
data-testid="input-due-date"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{editedDueDate ? format(new Date(editedDueDate), "PPP", { locale: i18n.language === 'de' ? de : enUS }) : <span>{t('taskDetails.pickDate')}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={editedDueDate ? new Date(editedDueDate) : undefined}
onSelect={(date) => setEditedDueDate(date ? date.toISOString().split('T')[0] : '')}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
</div>
{/* Estimated Duration */}
<div>
<label className="text-sm font-medium block mb-2">{t('taskDetails.estimatedDuration')} ({t('planning.minutes')})</label>
<div className="space-y-2">
<Input
type="number"
min="0"
step="5"
placeholder={t('planning.durationPlaceholder')}
value={editedEstimatedDuration || ''}
onChange={(e) => setEditedEstimatedDuration(e.target.value ? parseInt(e.target.value) : undefined)}
data-testid="input-edit-duration"
/>
<div className="flex flex-wrap gap-1">
{[15, 30, 45, 60, 90, 120].map((mins) => (
<Badge
key={mins}
variant={editedEstimatedDuration === mins ? "default" : "outline"}
className="cursor-pointer text-xs"
onClick={() => setEditedEstimatedDuration(mins)}
>
{mins}m
</Badge>
))}
</div>
</div>
</div>
{/* Dependencies */}
<div>
<label className="text-sm font-medium flex items-center gap-2 mb-2">
<Link2 className="w-4 h-4" />
{t('taskDetails.blockedBy')}
</label>
<Popover open={isDependenciesOpen} onOpenChange={setIsDependenciesOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between h-auto min-h-[40px]"
>
<div className="flex flex-wrap gap-1">
{editedDependencies.length > 0 ? (
editedDependencies.map((depId) => {
const depTask = tasks.find((t) => t.id === depId);
return (
<Badge key={depId} variant="secondary" className="mr-1">
{depTask?.title || 'Unknown Task'}
<div
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
setEditedDependencies(editedDependencies.filter((id) => id !== depId));
}}
>
<Plus className="h-3 w-3 rotate-45 hover:text-destructive" />
</div>
</Badge>
);
})
) : (
<span className="text-muted-foreground">{t('dependencies.selectPlaceholder')}</span>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="Search tasks..." />
<CommandList>
<CommandEmpty>No task found.</CommandEmpty>
<CommandGroup heading="Available Tasks">
{tasks
.filter(t => t.id !== task.id && t.status !== 'done') // Cannot depend on self or done tasks (optional logic)
.map((tItem) => (
<CommandItem
key={tItem.id}
value={tItem.title}
onSelect={() => {
if (editedDependencies.includes(tItem.id)) {
setEditedDependencies(editedDependencies.filter((id) => id !== tItem.id));
} else {
setEditedDependencies([...editedDependencies, tItem.id]);
}
}}
>
<div className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
editedDependencies.includes(tItem.id)
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}>
<Check className={cn("h-4 w-4")} />
</div>
<span>{tItem.title}</span>
<Badge variant="outline" className="ml-auto text-[10px] h-4 px-1 py-0 capitalize">
{tItem.status.replace('_', ' ')}
</Badge>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* Save and Mark as Done Buttons */}
<div className="flex gap-2 pt-2">
<Button
onClick={handleSaveTaskEdit}
disabled={!editedTitle.trim()}
data-testid="button-save-task-edit"
>
<Save className="w-4 h-4 mr-1" />
{t('taskDetails.save')}
</Button>
{editedStatus !== 'done' && (
<Button
onClick={handleMarkAsDone}
disabled={!editedTitle.trim()}
variant="default"
className="bg-green-600 hover:bg-green-700"
data-testid="button-mark-as-done"
>
<CheckCircle2 className="w-4 h-4 mr-1" />
{t('taskDetails.markAsDone', 'Mark as Done')}
</Button>
)}
</div>
</div>
</Card>
</TabsContent>
{/* Notes Tab */}
<TabsContent value="notes" className="space-y-4">
<Card className="p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium">{t('taskDetails.taskNotes')}</h4>
{!isEditingNotes && (
<Button
variant="outline"
size="sm"
onClick={() => setIsEditingNotes(true)}
data-testid="button-edit-notes"
>
<Edit2 className="w-4 h-4 mr-1" />
{t('taskDetails.edit')}
</Button>
)}
</div>
{isEditingNotes ? (
<div className="space-y-3">
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t('taskDetails.notesPlaceholder')}
rows={4}
data-testid="textarea-task-notes"
/>
<div className="flex gap-2">
<Button
onClick={handleSaveNotes}
size="sm"
data-testid="button-save-notes"
>
<Save className="w-4 h-4 mr-1" />
{t('taskDetails.save')}
</Button>
<Button
variant="outline"
onClick={() => {
setNotes(task.notes || '');
setIsEditingNotes(false);
}}
size="sm"
data-testid="button-cancel-notes"
>
<X className="w-4 h-4 mr-1" />
{t('taskDetails.cancel')}
</Button>
</div>
</div>
) : (
<div>
{notes.trim() ? (
<div className="whitespace-pre-wrap text-sm" data-testid="text-task-notes">
{notes}
</div>
) : (
<p className="text-sm text-muted-foreground italic">
{t('taskDetails.noNotesYet')}
</p>
)}
</div>
)}
</Card>
</TabsContent>
{/* Time Tracking Tab */}
<TabsContent value="time" className="space-y-4">
{/* Total Time Display */}
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">{t('taskDetails.totalTimeTracked')}</h4>
<p className="text-2xl font-mono text-primary" data-testid="text-total-time">
{formatMinutesToHours(getTotalTrackedTime())}
</p>
</div>
<Button
onClick={() => setIsAddingTime(true)}
disabled={isAddingTime}
data-testid="button-add-time"
>
<Plus className="w-4 h-4 mr-1" />
{t('taskDetails.addTime')}
</Button>
</div>
</Card>
{/* Add Time Section */}
{isAddingTime && (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium">{t('taskDetails.addTimeEntry')}</h4>
<Button
variant="ghost"
size="sm"
onClick={() => setIsAddingTime(false)}
data-testid="button-cancel-add-time"
>
<X className="w-4 h-4" />
</Button>
</div>
<Tabs value={activeTimeTab} onValueChange={setActiveTimeTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="clock" data-testid="tab-clock-picker">
<Clock className="w-4 h-4 mr-2" />
{t('taskDetails.clock')}
</TabsTrigger>
<TabsTrigger value="manual" data-testid="tab-manual-input">
<Timer className="w-4 h-4 mr-2" />
{t('taskDetails.manual')}
</TabsTrigger>
</TabsList>
<TabsContent value="clock" className="space-y-4">
<div className="space-y-4">
<div className="text-center">
<div className="text-2xl font-mono" data-testid="text-clock-display">
{formatTime(hours, minutes)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Hours Picker */}
<div className="space-y-2">
<label className="text-sm font-medium text-center block">Hours</label>
<div className="space-y-1 max-h-32 overflow-y-auto border rounded-md p-2">
{Array.from({ length: 13 }, (_, i) => (
<Button
key={i}
variant={hours === i ? "default" : "ghost"}
size="sm"
className="w-full"
onClick={() => setHours(i)}
data-testid={`button-hour-${i}`}
>
{i}
</Button>
))}
</div>
</div>
{/* Minutes Picker */}
<div className="space-y-2">
<label className="text-sm font-medium text-center block">Minutes</label>
<div className="space-y-1 max-h-32 overflow-y-auto border rounded-md p-2">
{Array.from({ length: 12 }, (_, i) => i * 5).map((minute) => (
<Button
key={minute}
variant={minutes === minute ? "default" : "ghost"}
size="sm"
className="w-full"
onClick={() => setMinutes(minute)}
data-testid={`button-minute-${minute}`}
>
{minute}
</Button>
))}
</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="manual" className="space-y-4">
<div className="space-y-3">
<div className="text-center">
<div className="text-2xl font-mono" data-testid="text-manual-display">
{getCurrentTimeDisplay()}
</div>
</div>
<div>
<label className="text-sm font-medium block mb-2">Hours (decimal)</label>
<Input
type="number"
step="0.25"
min="0"
max="24"
placeholder="e.g., 1.5 for 1 hour 30 minutes"
value={manualHours}
onChange={(e) => setManualHours(e.target.value)}
data-testid="input-manual-hours"
/>
<p className="text-xs text-muted-foreground mt-1">
Examples: 0.5 = 30min, 1.25 = 1h 15min, 2.75 = 2h 45min
</p>
</div>
</div>
</TabsContent>
</Tabs>
<div>
<label className="text-sm font-medium block mb-2">Description (optional)</label>
<Input
value={timeDescription}
onChange={(e) => setTimeDescription(e.target.value)}
placeholder="What did you work on?"
data-testid="input-time-description"
/>
</div>
<div className="flex gap-3">
<Button
onClick={handleSaveTimeEntry}
disabled={activeTimeTab === 'clock' ? hours === 0 && minutes === 0 : !manualHours.trim()}
className="flex-1"
data-testid="button-save-time-entry"
>
<Save className="w-4 h-4 mr-1" />
Save Time
</Button>
</div>
</div>
</Card>
)}
{/* Time Entries List */}
{timeEntries.length > 0 && (
<Card className="p-4">
<h4 className="font-medium mb-3">Time Entries</h4>
<div className="space-y-2 max-h-64 overflow-y-auto">
{timeEntries.map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between p-3 border rounded-lg hover-elevate"
data-testid={`time-entry-${entry.id}`}
>
<div className="flex-1">
<div className="flex items-center gap-3">
<span className="font-mono font-medium">
{formatMinutesToHours(entry.timeSpent)}
</span>
<span className="text-sm text-muted-foreground">
{entry.date.toLocaleDateString()}
</span>
</div>
{entry.description && (
<p className="text-xs text-muted-foreground mt-1">
{entry.description}
</p>
)}
</div>
{entry.id !== 'tracked-time' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteTimeEntry(entry.id)}
data-testid={`button-delete-time-entry-${entry.id}`}
>
<X className="w-3 h-3" />
</Button>
)}
</div>
))}
</div>
</Card>
)}
{timeEntries.length === 0 && !isAddingTime && (
<div className="text-center text-muted-foreground text-sm py-8 border-2 border-dashed border-muted rounded-lg">
No time entries yet. Click "Add Time" to start tracking.
</div>
)}
</TabsContent>
</Tabs>
{/* Close button */}
<div className="flex justify-end pt-4 border-t">
<Button
onClick={handleClose}
variant="outline"
data-testid="button-close-task-details"
>
Close
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Time Required Dialog - shown when marking as done without time */}
<Dialog open={showTimeRequiredDialog} onOpenChange={setShowTimeRequiredDialog}>
<DialogContent className="w-[95vw] max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Clock className="w-5 h-5" />
{t('taskDetails.timeRequired', 'Time Required')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t('taskDetails.timeRequiredDesc', 'Please enter the time spent on this task before marking it as done.')}
</p>
<Tabs value={markDoneTimeTab} onValueChange={setMarkDoneTimeTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="clock">
<Clock className="w-4 h-4 mr-2" />
{t('taskDetails.clock', 'Clock')}
</TabsTrigger>
<TabsTrigger value="manual">
<Timer className="w-4 h-4 mr-2" />
{t('taskDetails.manual', 'Manual')}
</TabsTrigger>
</TabsList>
<TabsContent value="clock" className="space-y-4">
<div className="space-y-4">
<div className="text-center">
<div className="text-2xl font-mono">
{formatTime(markDoneHours, markDoneMinutes)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Hours Picker */}
<div className="space-y-2">
<label className="text-sm font-medium text-center block">{t('taskDetails.hours', 'Hours')}</label>
<div className="space-y-1 max-h-32 overflow-y-auto border rounded-md p-2">
{Array.from({ length: 13 }, (_, i) => (
<Button
key={i}
variant={markDoneHours === i ? "default" : "ghost"}
size="sm"
className="w-full"
onClick={() => setMarkDoneHours(i)}
>
{i}
</Button>
))}
</div>
</div>
{/* Minutes Picker */}
<div className="space-y-2">
<label className="text-sm font-medium text-center block">{t('taskDetails.minutes', 'Minutes')}</label>
<div className="space-y-1 max-h-32 overflow-y-auto border rounded-md p-2">
{Array.from({ length: 12 }, (_, i) => i * 5).map((minute) => (
<Button
key={minute}
variant={markDoneMinutes === minute ? "default" : "ghost"}
size="sm"
className="w-full"
onClick={() => setMarkDoneMinutes(minute)}
>
{minute}
</Button>
))}
</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="manual" className="space-y-4">
<div className="space-y-3">
<div className="text-center">
<div className="text-2xl font-mono">
{(() => {
const parsedHours = parseFloat(markDoneManualHours) || 0;
const h = Math.floor(parsedHours);
const m = Math.round((parsedHours - h) * 60);
return formatTime(h, m);
})()}
</div>
</div>
<div>
<label className="text-sm font-medium block mb-2">{t('taskDetails.hoursDecimal', 'Hours (decimal)')}</label>
<Input
type="number"
step="0.25"
min="0"
max="24"
placeholder="e.g., 1.5 for 1 hour 30 minutes"
value={markDoneManualHours}
onChange={(e) => setMarkDoneManualHours(e.target.value)}
/>
<p className="text-xs text-muted-foreground mt-1">
{t('taskDetails.timeExamples', 'Examples: 0.5 = 30min, 1.25 = 1h 15min')}
</p>
</div>
</div>
</TabsContent>
</Tabs>
<div className="flex gap-2 pt-2">
<Button
onClick={handleSaveTimeAndMarkDone}
disabled={
markDoneTimeTab === 'clock'
? markDoneHours === 0 && markDoneMinutes === 0
: !markDoneManualHours.trim() || parseFloat(markDoneManualHours) <= 0
}
className="flex-1 bg-green-600 hover:bg-green-700"
data-testid="button-save-time-and-done"
>
<CheckCircle2 className="w-4 h-4 mr-1" />
{t('taskDetails.saveAndMarkDone', 'Save & Mark as Done')}
</Button>
<Button
variant="outline"
onClick={() => setShowTimeRequiredDialog(false)}
>
{t('common.cancel', 'Cancel')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}