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
|
||||
|
||||
[[ports]]
|
||||
localPort = 38369
|
||||
localPort = 36505
|
||||
externalPort = 3001
|
||||
|
||||
[[ports]]
|
||||
|
||||
@@ -8,8 +8,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
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 TaskDetailsModal from '@/components/TaskDetailsModal';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiRequest } from '@/lib/queryClient';
|
||||
|
||||
@@ -19,6 +20,13 @@ const getStartOfToday = () => {
|
||||
return new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
};
|
||||
|
||||
interface TimeEntry {
|
||||
id: string;
|
||||
date: Date;
|
||||
timeSpent: number; // in minutes
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ProjectTask {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -27,6 +35,9 @@ interface ProjectTask {
|
||||
status: 'todo' | 'inProgress' | 'done';
|
||||
estimatedHours?: number;
|
||||
labelId?: string;
|
||||
notes?: string;
|
||||
timeTracked?: number; // in minutes
|
||||
timeEntries?: TimeEntry[];
|
||||
}
|
||||
|
||||
interface Project {
|
||||
@@ -122,6 +133,10 @@ export default function ProjectTemplate({
|
||||
const [labelName, setLabelName] = useState('');
|
||||
const [labelColor, setLabelColor] = useState('#3B82F6');
|
||||
|
||||
// Task details modal state
|
||||
const [isTaskDetailsOpen, setIsTaskDetailsOpen] = useState(false);
|
||||
const [selectedTaskForDetails, setSelectedTaskForDetails] = useState<ProjectTask | null>(null);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch labels
|
||||
@@ -362,6 +377,43 @@ export default function ProjectTemplate({
|
||||
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 = () => {
|
||||
if (selectedProject) {
|
||||
const updatedProject = {
|
||||
@@ -1206,7 +1258,11 @@ export default function ProjectTemplate({
|
||||
) : (
|
||||
// Display Mode
|
||||
<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>
|
||||
{task.description && (
|
||||
<p className="text-xs text-muted-foreground">{task.description}</p>
|
||||
@@ -1223,22 +1279,53 @@ export default function ProjectTemplate({
|
||||
{task.estimatedHours}h
|
||||
</Badge>
|
||||
) : 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 className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
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}`}
|
||||
title="Edit task"
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveTask(index)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveTask(index);
|
||||
}}
|
||||
data-testid={`button-remove-task-edit-${index}`}
|
||||
title="Remove task"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
@@ -1422,6 +1509,15 @@ export default function ProjectTemplate({
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Task Details Modal */}
|
||||
<TaskDetailsModal
|
||||
isOpen={isTaskDetailsOpen}
|
||||
onClose={handleCloseTaskDetails}
|
||||
task={selectedTaskForDetails}
|
||||
onSave={handleSaveTaskDetails}
|
||||
labels={labels}
|
||||
/>
|
||||
</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