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:
paul-nothaft
2025-09-11 22:03:27 +00:00
parent d08429e74b
commit 8b5727afb0
3 changed files with 642 additions and 5 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ localPort = 35345
externalPort = 3002
[[ports]]
localPort = 38369
localPort = 36505
externalPort = 3001
[[ports]]
+100 -4
View File
@@ -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>
);
}
+541
View File
@@ -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>
);
}