Enable editing task details and tracking time within project views
Adds a modal for viewing and editing task notes and time entries, integrates task-level clicks for detail access, and introduces time tracking functionality. 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/P2PZNJ9
This commit is contained in:
@@ -19,7 +19,7 @@ localPort = 35345
|
|||||||
externalPort = 3002
|
externalPort = 3002
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
localPort = 38369
|
localPort = 36505
|
||||||
externalPort = 3001
|
externalPort = 3001
|
||||||
|
|
||||||
[[ports]]
|
[[ports]]
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { Calendar } from '@/components/ui/calendar';
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Plus, Calendar as CalendarIcon, Clock, Copy, Play, Tag, Edit, Trash2, Palette } from 'lucide-react';
|
import { Plus, Calendar as CalendarIcon, Clock, Copy, Play, Tag, Edit, Trash2, Palette, FileText } from 'lucide-react';
|
||||||
import { Label } from '@shared/schema';
|
import { Label } from '@shared/schema';
|
||||||
|
import TaskDetailsModal from '@/components/TaskDetailsModal';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiRequest } from '@/lib/queryClient';
|
import { apiRequest } from '@/lib/queryClient';
|
||||||
|
|
||||||
@@ -19,6 +20,13 @@ const getStartOfToday = () => {
|
|||||||
return new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
return new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface TimeEntry {
|
||||||
|
id: string;
|
||||||
|
date: Date;
|
||||||
|
timeSpent: number; // in minutes
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProjectTask {
|
interface ProjectTask {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -27,6 +35,9 @@ interface ProjectTask {
|
|||||||
status: 'todo' | 'inProgress' | 'done';
|
status: 'todo' | 'inProgress' | 'done';
|
||||||
estimatedHours?: number;
|
estimatedHours?: number;
|
||||||
labelId?: string;
|
labelId?: string;
|
||||||
|
notes?: string;
|
||||||
|
timeTracked?: number; // in minutes
|
||||||
|
timeEntries?: TimeEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
@@ -122,6 +133,10 @@ export default function ProjectTemplate({
|
|||||||
const [labelName, setLabelName] = useState('');
|
const [labelName, setLabelName] = useState('');
|
||||||
const [labelColor, setLabelColor] = useState('#3B82F6');
|
const [labelColor, setLabelColor] = useState('#3B82F6');
|
||||||
|
|
||||||
|
// Task details modal state
|
||||||
|
const [isTaskDetailsOpen, setIsTaskDetailsOpen] = useState(false);
|
||||||
|
const [selectedTaskForDetails, setSelectedTaskForDetails] = useState<ProjectTask | null>(null);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Fetch labels
|
// Fetch labels
|
||||||
@@ -362,6 +377,43 @@ export default function ProjectTemplate({
|
|||||||
setIsEditingTask(true);
|
setIsEditingTask(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Task details modal handlers
|
||||||
|
const handleOpenTaskDetails = (task: ProjectTask, isFromProject: boolean = false) => {
|
||||||
|
setSelectedTaskForDetails(task);
|
||||||
|
setIsTaskDetailsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseTaskDetails = () => {
|
||||||
|
setSelectedTaskForDetails(null);
|
||||||
|
setIsTaskDetailsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTaskDetails = (updatedTask: ProjectTask) => {
|
||||||
|
// Update the task in projectTasks array
|
||||||
|
const taskIndex = projectTasks.findIndex(task => task.id === updatedTask.id);
|
||||||
|
if (taskIndex !== -1) {
|
||||||
|
const updatedTasks = [...projectTasks];
|
||||||
|
updatedTasks[taskIndex] = updatedTask;
|
||||||
|
setProjectTasks(updatedTasks);
|
||||||
|
console.log('Task updated:', updatedTask.title, 'with notes and time tracking');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're in edit project mode, also update the selected project
|
||||||
|
if (selectedProject) {
|
||||||
|
const updatedProject = { ...selectedProject };
|
||||||
|
const projectTaskIndex = updatedProject.tasks.findIndex(task => task.id === updatedTask.id);
|
||||||
|
if (projectTaskIndex !== -1) {
|
||||||
|
updatedProject.tasks[projectTaskIndex] = updatedTask;
|
||||||
|
setSelectedProject(updatedProject);
|
||||||
|
|
||||||
|
// Update the projects array
|
||||||
|
setProjects(projects.map(p =>
|
||||||
|
p.id === selectedProject.id ? updatedProject : p
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveEditedProject = () => {
|
const handleSaveEditedProject = () => {
|
||||||
if (selectedProject) {
|
if (selectedProject) {
|
||||||
const updatedProject = {
|
const updatedProject = {
|
||||||
@@ -1206,7 +1258,11 @@ export default function ProjectTemplate({
|
|||||||
) : (
|
) : (
|
||||||
// Display Mode
|
// Display Mode
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div
|
||||||
|
className="flex-1 cursor-pointer hover-elevate rounded-md p-2 -m-2"
|
||||||
|
onClick={() => handleOpenTaskDetails(task)}
|
||||||
|
data-testid={`clickable-task-${index}`}
|
||||||
|
>
|
||||||
<h4 className="font-medium text-sm">{task.title}</h4>
|
<h4 className="font-medium text-sm">{task.title}</h4>
|
||||||
{task.description && (
|
{task.description && (
|
||||||
<p className="text-xs text-muted-foreground">{task.description}</p>
|
<p className="text-xs text-muted-foreground">{task.description}</p>
|
||||||
@@ -1223,22 +1279,53 @@ export default function ProjectTemplate({
|
|||||||
{task.estimatedHours}h
|
{task.estimatedHours}h
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
{task.timeTracked && task.timeTracked > 0 && (
|
||||||
|
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
|
||||||
|
{Math.floor(task.timeTracked / 60)}h {task.timeTracked % 60}m tracked
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{task.notes && task.notes.trim() && (
|
||||||
|
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200">
|
||||||
|
<FileText className="w-3 h-3 mr-1" />
|
||||||
|
Notes
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleEditTask(index)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleOpenTaskDetails(task);
|
||||||
|
}}
|
||||||
|
data-testid={`button-details-task-${index}`}
|
||||||
|
title="View task details"
|
||||||
|
>
|
||||||
|
<FileText className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEditTask(index);
|
||||||
|
}}
|
||||||
data-testid={`button-edit-task-edit-${index}`}
|
data-testid={`button-edit-task-edit-${index}`}
|
||||||
|
title="Edit task"
|
||||||
>
|
>
|
||||||
<Edit className="w-3 h-3" />
|
<Edit className="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleRemoveTask(index)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemoveTask(index);
|
||||||
|
}}
|
||||||
data-testid={`button-remove-task-edit-${index}`}
|
data-testid={`button-remove-task-edit-${index}`}
|
||||||
|
title="Remove task"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1422,6 +1509,15 @@ export default function ProjectTemplate({
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Task Details Modal */}
|
||||||
|
<TaskDetailsModal
|
||||||
|
isOpen={isTaskDetailsOpen}
|
||||||
|
onClose={handleCloseTaskDetails}
|
||||||
|
task={selectedTaskForDetails}
|
||||||
|
onSave={handleSaveTaskDetails}
|
||||||
|
labels={labels}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
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 { Clock, Timer, FileText, Plus, Edit2, Save, X, Calendar } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ProjectTask {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
priority: 'low' | 'medium' | 'high';
|
||||||
|
status: 'todo' | 'inProgress' | 'done';
|
||||||
|
estimatedHours?: number;
|
||||||
|
labelId?: string;
|
||||||
|
notes?: string;
|
||||||
|
timeTracked?: number; // in minutes
|
||||||
|
timeEntries?: TimeEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimeEntry {
|
||||||
|
id: string;
|
||||||
|
date: Date;
|
||||||
|
timeSpent: number; // in minutes
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskDetailsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
task: ProjectTask | null;
|
||||||
|
onSave: (updatedTask: ProjectTask) => void;
|
||||||
|
labels?: Array<{ id: string; name: string; color: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskDetailsModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
task,
|
||||||
|
onSave,
|
||||||
|
labels = []
|
||||||
|
}: TaskDetailsModalProps) {
|
||||||
|
// 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');
|
||||||
|
|
||||||
|
// Time entries state
|
||||||
|
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (task && isOpen) {
|
||||||
|
setNotes(task.notes || '');
|
||||||
|
setTimeEntries(task.timeEntries || []);
|
||||||
|
// 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('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
timeEntries: updatedTimeEntries,
|
||||||
|
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 handleDeleteTimeEntry = (entryId: string) => {
|
||||||
|
if (!task) 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,
|
||||||
|
timeEntries: updatedTimeEntries,
|
||||||
|
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 timeEntries.reduce((total, entry) => total + entry.timeSpent, 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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FileText className="w-5 h-5" />
|
||||||
|
Task Details
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Task Information */}
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<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)}`}>
|
||||||
|
{task.priority} priority
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className={`text-xs ${getStatusColor(task.status)}`}>
|
||||||
|
{task.status}
|
||||||
|
</Badge>
|
||||||
|
{task.estimatedHours && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{task.estimatedHours}h estimated
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{getTotalTrackedTime() > 0 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{formatMinutesToHours(getTotalTrackedTime())} tracked
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Tabs defaultValue="notes" className="space-y-4">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="notes" data-testid="tab-notes">
|
||||||
|
<FileText className="w-4 h-4 mr-2" />
|
||||||
|
Notes
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="time" data-testid="tab-time">
|
||||||
|
<Clock className="w-4 h-4 mr-2" />
|
||||||
|
Time Tracking
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 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">Task Notes</h4>
|
||||||
|
{!isEditingNotes && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsEditingNotes(true)}
|
||||||
|
data-testid="button-edit-notes"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4 mr-1" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditingNotes ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Add your notes for this task..."
|
||||||
|
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" />
|
||||||
|
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" />
|
||||||
|
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">
|
||||||
|
No notes added yet. Click "Edit" to add notes.
|
||||||
|
</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">Total Time Tracked</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" />
|
||||||
|
Add Time
|
||||||
|
</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">Add Time Entry</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" />
|
||||||
|
Clock
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="manual" data-testid="tab-manual-input">
|
||||||
|
<Timer className="w-4 h-4 mr-2" />
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user