Files
task-manager/client/src/components/ProjectTemplate.tsx
T
paul-nothaft b0a2f51927 Improve task editing and project closing functionality
Refactors the project template component to enable inline task editing, implement temporary task IDs for new tasks, and update the handling of closing projects to duplicate them as templates.

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
2025-09-11 21:39:56 +00:00

1320 lines
55 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
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 { Label } from '@shared/schema';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiRequest } from '@/lib/queryClient';
interface ProjectTask {
id: string;
title: string;
description?: string;
priority: 'low' | 'medium' | 'high';
status: 'todo' | 'inProgress' | 'done';
estimatedHours?: number;
labelId?: string;
}
interface Project {
id: string;
name: string;
description?: string;
status: 'planning' | 'active' | 'finished';
tasks: ProjectTask[];
startDate?: Date;
endDate?: Date;
createdAt: Date;
}
interface ProjectTemplateProps {
onCreateFromTemplate?: (templateId: string, startDate: Date, projectName: string) => void;
onCreateTemplate?: (template: any) => void;
}
const defaultProjects: Project[] = [
{
id: '1',
name: 'Website Redesign',
description: 'Complete redesign of company website',
status: 'active',
startDate: new Date(),
createdAt: new Date(),
tasks: [
{ id: '1', title: 'Research competitors', priority: 'high', status: 'done', estimatedHours: 4 },
{ id: '2', title: 'Create wireframes', priority: 'high', status: 'inProgress', estimatedHours: 8 },
{ id: '3', title: 'Design mockups', priority: 'medium', status: 'todo', estimatedHours: 12 },
{ id: '4', title: 'Frontend development', priority: 'high', status: 'todo', estimatedHours: 40 }
]
},
{
id: '2',
name: 'Mobile App Launch',
description: 'Launch new mobile application',
status: 'planning',
createdAt: new Date(),
tasks: [
{ id: '1', title: 'Define requirements', priority: 'high', status: 'todo', estimatedHours: 6 },
{ id: '2', title: 'Create user stories', priority: 'medium', status: 'todo', estimatedHours: 8 },
{ id: '3', title: 'Design UI/UX', priority: 'high', status: 'todo', estimatedHours: 20 }
]
},
{
id: '3',
name: 'Blog Platform',
description: 'Personal blog with CMS',
status: 'finished',
startDate: new Date('2024-01-01'),
endDate: new Date('2024-03-01'),
createdAt: new Date('2024-01-01'),
tasks: [
{ id: '1', title: 'Setup infrastructure', priority: 'high', status: 'done', estimatedHours: 8 },
{ id: '2', title: 'Develop backend API', priority: 'high', status: 'done', estimatedHours: 32 },
{ id: '3', title: 'Create admin panel', priority: 'medium', status: 'done', estimatedHours: 16 },
{ id: '4', title: 'Deploy and test', priority: 'high', status: 'done', estimatedHours: 4 }
]
}
];
export default function ProjectTemplate({
onCreateFromTemplate,
onCreateTemplate
}: ProjectTemplateProps) {
const [projects, setProjects] = useState<Project[]>(defaultProjects);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [draggedProject, setDraggedProject] = useState<string | null>(null);
const [isCreateProjectOpen, setIsCreateProjectOpen] = useState(false);
const [isEditProjectOpen, setIsEditProjectOpen] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [newProjectDescription, setNewProjectDescription] = useState('');
const [isRestartProjectOpen, setIsRestartProjectOpen] = useState(false);
const [restartingProject, setRestartingProject] = useState<Project | null>(null);
const [newEndDate, setNewEndDate] = useState<Date | undefined>();
const [clearHistory, setClearHistory] = useState(false);
const [isEndDateCalendarOpen, setIsEndDateCalendarOpen] = useState(false);
const [closedProjectsFilter, setClosedProjectsFilter] = useState('');
// Task management state
const [projectTasks, setProjectTasks] = useState<ProjectTask[]>([]);
const [isEditingTask, setIsEditingTask] = useState(false);
const [editingTaskIndex, setEditingTaskIndex] = useState<number | null>(null);
const [taskForm, setTaskForm] = useState<Partial<ProjectTask>>({});
// Label management state
const [isLabelDialogOpen, setIsLabelDialogOpen] = useState(false);
const [editingLabel, setEditingLabel] = useState<Label | null>(null);
const [labelName, setLabelName] = useState('');
const [labelColor, setLabelColor] = useState('#3B82F6');
const queryClient = useQueryClient();
// Fetch labels
const { data: labels = [], isLoading: labelsLoading } = useQuery<Label[]>({
queryKey: ['/api/labels'],
});
// Create label mutation
const createLabelMutation = useMutation({
mutationFn: (data: { name: string; color: string }) =>
apiRequest('POST', '/api/labels', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
setIsLabelDialogOpen(false);
setLabelName('');
setLabelColor('#3B82F6');
setEditingLabel(null);
}
});
// Update label mutation
const updateLabelMutation = useMutation({
mutationFn: ({ id, ...data }: { id: string; name?: string; color?: string }) =>
apiRequest('PATCH', `/api/labels/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
setIsLabelDialogOpen(false);
setLabelName('');
setLabelColor('#3B82F6');
setEditingLabel(null);
}
});
// Delete label mutation
const deleteLabelMutation = useMutation({
mutationFn: (id: string) => apiRequest('DELETE', `/api/labels/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
}
});
const handleCreateProject = () => {
if (newProjectName.trim()) {
const newProject: Project = {
id: Date.now().toString(),
name: newProjectName.trim(),
description: newProjectDescription.trim() || undefined,
status: 'planning',
tasks: projectTasks,
createdAt: new Date()
};
setProjects([...projects, newProject]);
setNewProjectName('');
setNewProjectDescription('');
setProjectTasks([]);
setIsCreateProjectOpen(false);
console.log('Created new project:', newProject.name, 'with', projectTasks.length, 'tasks');
}
};
const handleEditProject = (project: Project) => {
setSelectedProject(project);
setProjectTasks([...project.tasks]);
setIsEditProjectOpen(true);
};
const handleDeleteProject = (projectId: string) => {
if (confirm('Are you sure you want to delete this project?')) {
setProjects(projects.filter(p => p.id !== projectId));
}
};
const handleProjectStatusChange = (projectId: string, newStatus: Project['status']) => {
setProjects(projects.map(project =>
project.id === projectId ? { ...project, status: newStatus } : project
));
};
const handleDragStart = (projectId: string) => {
setDraggedProject(projectId);
};
const handleDragEnd = () => {
setDraggedProject(null);
};
const handleDrop = (status: Project['status']) => {
if (draggedProject) {
handleProjectStatusChange(draggedProject, status);
setDraggedProject(null);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const handleRestartProject = (project: Project) => {
setRestartingProject(project);
setIsRestartProjectOpen(true);
};
const handleConfirmRestart = () => {
if (restartingProject && newEndDate) {
const restartedProject: Project = {
...restartingProject,
status: 'planning',
startDate: new Date(),
endDate: newEndDate,
tasks: clearHistory ? [] : restartingProject.tasks.map(task => ({ ...task, status: 'todo' as const }))
};
setProjects(projects.map(p =>
p.id === restartingProject.id ? restartedProject : p
));
setIsRestartProjectOpen(false);
setRestartingProject(null);
setNewEndDate(undefined);
setClearHistory(false);
console.log('Project restarted:', restartedProject.name, 'Clear history:', clearHistory);
}
};
const getFilteredClosedProjects = () => {
const closedProjects = projects.filter(project => project.status === 'finished');
if (!closedProjectsFilter) return closedProjects;
return closedProjects.filter(project =>
project.name.toLowerCase().includes(closedProjectsFilter.toLowerCase()) ||
(project.description && project.description.toLowerCase().includes(closedProjectsFilter.toLowerCase()))
);
};
// Task management functions
const handleAddTask = () => {
const newTask: ProjectTask = {
id: Date.now().toString(),
title: taskForm.title || 'New Task',
description: taskForm.description || '',
priority: taskForm.priority || 'medium',
status: taskForm.status || 'todo',
estimatedHours: taskForm.estimatedHours || 0,
labelId: taskForm.labelId
};
setProjectTasks([...projectTasks, newTask]);
setTaskForm({});
setIsEditingTask(false);
};
const handleEditTask = (index: number) => {
setEditingTaskIndex(index);
setTaskForm({ ...projectTasks[index] });
setIsEditingTask(true);
};
const handleSaveTask = () => {
if (editingTaskIndex !== null) {
const currentTask = projectTasks[editingTaskIndex];
const updatedTasks = [...projectTasks];
// Check if this is a new task (temporary ID) or existing task
const isNewTask = currentTask.id.startsWith('temp-');
updatedTasks[editingTaskIndex] = {
id: isNewTask ? Date.now().toString() : currentTask.id,
title: taskForm.title || 'New Task',
description: taskForm.description || '',
priority: taskForm.priority || 'medium',
status: taskForm.status || 'todo',
estimatedHours: taskForm.estimatedHours || 0,
labelId: taskForm.labelId
};
setProjectTasks(updatedTasks);
}
setTaskForm({});
setIsEditingTask(false);
setEditingTaskIndex(null);
};
const handleRemoveTask = (index: number) => {
setProjectTasks(projectTasks.filter((_, i) => i !== index));
};
const handleCancelTaskEdit = () => {
// If we're editing a task that has a temporary ID (new task), remove it from the list
if (editingTaskIndex !== null) {
const currentTask = projectTasks[editingTaskIndex];
if (currentTask.id.startsWith('temp-')) {
const updatedTasks = projectTasks.filter((_, index) => index !== editingTaskIndex);
setProjectTasks(updatedTasks);
}
}
setTaskForm({});
setIsEditingTask(false);
setEditingTaskIndex(null);
};
const handleStartNewTask = () => {
// Create a temporary new task and add it to the list in edit mode
const tempTask: ProjectTask = {
id: 'temp-' + Date.now(),
title: '',
description: '',
priority: 'medium',
status: 'todo',
estimatedHours: 0
};
const newTasks = [...projectTasks, tempTask];
setProjectTasks(newTasks);
setEditingTaskIndex(newTasks.length - 1);
setTaskForm({
title: '',
description: '',
priority: 'medium',
status: 'todo',
estimatedHours: 0
});
setIsEditingTask(true);
};
const handleSaveEditedProject = () => {
if (selectedProject) {
const updatedProject = {
...selectedProject,
tasks: projectTasks
};
setProjects(projects.map(p =>
p.id === selectedProject.id ? updatedProject : p
));
setSelectedProject(null);
setProjectTasks([]);
setIsEditProjectOpen(false);
console.log('Updated project:', updatedProject.name, 'with', projectTasks.length, 'tasks');
}
};
// Label management functions
const handleSaveLabel = () => {
if (!labelName.trim()) return;
if (editingLabel) {
updateLabelMutation.mutate({ id: editingLabel.id, name: labelName.trim(), color: labelColor });
} else {
createLabelMutation.mutate({ name: labelName.trim(), color: labelColor });
}
};
const handleEditLabel = (label: Label) => {
setEditingLabel(label);
setLabelName(label.name);
setLabelColor(label.color);
setIsLabelDialogOpen(true);
};
const handleDeleteLabel = (id: string) => {
if (confirm('Are you sure you want to delete this label?')) {
deleteLabelMutation.mutate(id);
}
};
const getTotalEstimatedHours = (project: Project) => {
return project.tasks.reduce((sum, task) => sum + (task.estimatedHours || 0), 0);
};
const getTaskCompletionRate = (project: Project) => {
if (project.tasks.length === 0) return 0;
const completedTasks = project.tasks.filter(task => task.status === 'done').length;
return Math.round((completedTasks / project.tasks.length) * 100);
};
const getProjectsByStatus = (status: Project['status']) => {
return projects.filter(project => project.status === status);
};
const projectColumns = [
{ id: 'planning', title: 'Planning', status: 'planning' as const, color: 'bg-slate-100' },
{ id: 'active', title: 'Active', status: 'active' as const, color: 'bg-blue-100' },
{ id: 'finished', title: 'Finished', status: 'finished' as const, color: 'bg-green-100' }
];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold" data-testid="text-settings-title">
Settings & Project Management
</h2>
<Button
onClick={() => setIsCreateProjectOpen(true)}
data-testid="button-create-project"
>
<Plus className="w-4 h-4 mr-2" />
New Project
</Button>
</div>
<Tabs defaultValue="projects" className="space-y-4">
<TabsList>
<TabsTrigger value="projects" data-testid="tab-projects">Projects</TabsTrigger>
<TabsTrigger value="labels" data-testid="tab-labels">Labels</TabsTrigger>
</TabsList>
<TabsContent value="projects" className="space-y-6">
{/* Project Kanban Board */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{projectColumns.map((column) => {
const columnProjects = getProjectsByStatus(column.status);
return (
<Card
key={column.id}
className="p-4"
onDragOver={handleDragOver}
onDrop={() => handleDrop(column.status)}
data-testid={`column-${column.id}`}
>
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-sm" data-testid={`text-column-title-${column.id}`}>
{column.title}
</h3>
<Badge variant="secondary" className="text-xs">
{columnProjects.length}
</Badge>
</div>
<div className="space-y-3 min-h-[400px]">
{columnProjects.map((project) => (
<Card
key={project.id}
draggable
onDragStart={() => handleDragStart(project.id)}
onDragEnd={handleDragEnd}
className={`p-4 cursor-move hover-elevate transition-all ${
draggedProject === project.id ? 'opacity-50 scale-95' : ''
}`}
data-testid={`project-card-${project.id}`}
>
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-semibold text-sm" data-testid={`text-project-name-${project.id}`}>
{project.name}
</h4>
{project.description && (
<p className="text-xs text-muted-foreground mt-1">
{project.description}
</p>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditProject(project)}
data-testid={`button-edit-${project.id}`}
>
<Edit className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteProject(project.id)}
data-testid={`button-delete-${project.id}`}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{getTotalEstimatedHours(project)}h</span>
</div>
<div className="flex items-center gap-1">
<Badge variant="outline" className="text-xs">
{project.tasks.length} tasks
</Badge>
</div>
</div>
{project.tasks.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Progress</span>
<span className="font-medium">{getTaskCompletionRate(project)}%</span>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${getTaskCompletionRate(project)}%` }}
/>
</div>
</div>
)}
{project.startDate && (
<div className="text-xs text-muted-foreground">
Started: {project.startDate.toLocaleDateString()}
</div>
)}
</div>
</Card>
))}
{columnProjects.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8 border-2 border-dashed border-muted rounded-lg">
No projects in {column.title.toLowerCase()}
</div>
)}
</div>
</Card>
);
})}
</div>
{/* Closed Projects Section */}
<div className="space-y-4 mt-8">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Closed Projects</h3>
<Input
placeholder="Filter closed projects..."
value={closedProjectsFilter}
onChange={(e) => setClosedProjectsFilter(e.target.value)}
className="max-w-xs"
data-testid="input-filter-closed-projects"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{getFilteredClosedProjects().map((project) => (
<Card key={project.id} className="p-4 hover-elevate" data-testid={`closed-project-card-${project.id}`}>
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-semibold text-sm" data-testid={`text-closed-project-name-${project.id}`}>
{project.name}
</h4>
{project.description && (
<p className="text-xs text-muted-foreground mt-1">
{project.description}
</p>
)}
</div>
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200">
Finished
</Badge>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{getTotalEstimatedHours(project)}h</span>
</div>
<div className="flex items-center gap-1">
<Badge variant="outline" className="text-xs">
{project.tasks.length} tasks
</Badge>
</div>
</div>
{project.endDate && (
<div className="text-xs text-muted-foreground">
Finished: {project.endDate.toLocaleDateString()}
</div>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleRestartProject(project)}
className="w-full"
data-testid={`button-restart-${project.id}`}
>
<Play className="w-3 h-3 mr-2" />
Start Again
</Button>
</div>
</Card>
))}
{getFilteredClosedProjects().length === 0 && (
<div className="col-span-full text-center text-muted-foreground text-sm py-8">
{closedProjectsFilter ? 'No closed projects match your filter' : 'No closed projects yet'}
</div>
)}
</div>
</div>
</TabsContent>
{/* Restart Project Dialog */}
<Dialog open={isRestartProjectOpen} onOpenChange={setIsRestartProjectOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Restart Project</DialogTitle>
</DialogHeader>
{restartingProject && (
<div className="space-y-4 py-4">
<div className="p-3 bg-muted rounded-lg">
<div className="text-sm font-medium">{restartingProject.name}</div>
<div className="text-xs text-muted-foreground">
{restartingProject.tasks.length} tasks {getTotalEstimatedHours(restartingProject)}h estimated
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Planned End Date</label>
<Popover open={isEndDateCalendarOpen} onOpenChange={setIsEndDateCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full justify-start text-left"
data-testid="button-select-end-date"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{newEndDate ? newEndDate.toLocaleDateString() : "Select planned end date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={newEndDate}
onSelect={(date) => {
setNewEndDate(date);
setIsEndDateCalendarOpen(false);
}}
disabled={(date) => date < new Date()}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="clearHistory"
checked={clearHistory}
onChange={(e) => setClearHistory(e.target.checked)}
className="rounded border-gray-300"
data-testid="checkbox-clear-history"
/>
<label htmlFor="clearHistory" className="text-sm">
Clear project history (remove all tasks and start fresh)
</label>
</div>
</div>
)}
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => {
setIsRestartProjectOpen(false);
setRestartingProject(null);
setNewEndDate(undefined);
setClearHistory(false);
}}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleConfirmRestart}
disabled={!newEndDate}
className="flex-1"
data-testid="button-confirm-restart"
>
Start Again
</Button>
</div>
</DialogContent>
</Dialog>
{/* Create Project Dialog */}
<Dialog open={isCreateProjectOpen} onOpenChange={() => {
setIsCreateProjectOpen(false);
setProjectTasks([]);
setTaskForm({});
setIsEditingTask(false);
}}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create New Project</DialogTitle>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Project Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Project Name</label>
<Input
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="Enter project name"
data-testid="input-project-name"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Description (Optional)</label>
<Input
value={newProjectDescription}
onChange={(e) => setNewProjectDescription(e.target.value)}
placeholder="Enter project description"
data-testid="input-project-description"
/>
</div>
</div>
{/* Tasks Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Project Tasks</h3>
<Button
variant="outline"
size="sm"
onClick={handleStartNewTask}
data-testid="button-add-task"
>
<Plus className="w-4 h-4 mr-2" />
Add Task
</Button>
</div>
{/* Task List */}
<div className="space-y-2 max-h-64 overflow-y-auto">
{projectTasks.map((task, index) => (
<Card key={task.id} className="p-3">
<div className="flex items-center justify-between">
<div className="flex-1">
<h4 className="font-medium text-sm">{task.title}</h4>
{task.description && (
<p className="text-xs text-muted-foreground">{task.description}</p>
)}
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{task.priority}
</Badge>
<Badge variant="outline" className="text-xs">
{task.status}
</Badge>
{task.estimatedHours ? (
<Badge variant="outline" className="text-xs">
{task.estimatedHours}h
</Badge>
) : null}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditTask(index)}
data-testid={`button-edit-task-${index}`}
>
<Edit className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveTask(index)}
data-testid={`button-remove-task-${index}`}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
</Card>
))}
{projectTasks.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8 border-2 border-dashed border-muted rounded-lg">
No tasks added yet. Click "Add Task" to get started.
</div>
)}
</div>
{/* Task Form */}
{isEditingTask && (
<Card className="p-4 border-primary">
<div className="space-y-4">
<h4 className="font-semibold text-sm">
{editingTaskIndex !== null ? 'Edit Task' : 'New Task'}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Task Title</label>
<Input
value={taskForm.title || ''}
onChange={(e) => setTaskForm({...taskForm, title: e.target.value})}
placeholder="Enter task title"
data-testid="input-task-title"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Description</label>
<Input
value={taskForm.description || ''}
onChange={(e) => setTaskForm({...taskForm, description: e.target.value})}
placeholder="Enter task description"
data-testid="input-task-description"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Priority</label>
<Select
value={taskForm.priority || 'medium'}
onValueChange={(value: ProjectTask['priority']) =>
setTaskForm({...taskForm, priority: value})
}
>
<SelectTrigger data-testid="select-task-priority">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Status</label>
<Select
value={taskForm.status || 'todo'}
onValueChange={(value: ProjectTask['status']) =>
setTaskForm({...taskForm, status: value})
}
>
<SelectTrigger data-testid="select-task-status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="todo">To Do</SelectItem>
<SelectItem value="inProgress">In Progress</SelectItem>
<SelectItem value="done">Done</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Estimated Hours</label>
<Input
type="number"
value={taskForm.estimatedHours || ''}
onChange={(e) => setTaskForm({...taskForm, estimatedHours: Number(e.target.value)})}
placeholder="0"
min="0"
data-testid="input-task-hours"
/>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleCancelTaskEdit}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={editingTaskIndex !== null ? handleSaveTask : handleAddTask}
disabled={!taskForm.title?.trim()}
className="flex-1"
data-testid="button-save-task"
>
{editingTaskIndex !== null ? 'Save Changes' : 'Add Task'}
</Button>
</div>
</div>
</Card>
)}
</div>
</div>
<div className="flex gap-2 pt-4 border-t">
<Button
variant="outline"
onClick={() => {
setIsCreateProjectOpen(false);
setProjectTasks([]);
setTaskForm({});
setIsEditingTask(false);
}}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleCreateProject}
disabled={!newProjectName.trim()}
className="flex-1"
data-testid="button-save-project"
>
Create Project ({projectTasks.length} tasks)
</Button>
</div>
</DialogContent>
</Dialog>
{/* Edit Project Dialog */}
<Dialog open={isEditProjectOpen} onOpenChange={() => {
setIsEditProjectOpen(false);
setSelectedProject(null);
setProjectTasks([]);
setTaskForm({});
setIsEditingTask(false);
}}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Project</DialogTitle>
</DialogHeader>
{selectedProject && (
<div className="space-y-6 py-4">
{/* Project Details */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Project Name</label>
<Input
value={selectedProject.name}
onChange={(e) => setSelectedProject({ ...selectedProject, name: e.target.value })}
placeholder="Enter project name"
data-testid="input-edit-project-name"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Description (Optional)</label>
<Input
value={selectedProject.description || ''}
onChange={(e) => setSelectedProject({ ...selectedProject, description: e.target.value })}
placeholder="Enter project description"
data-testid="input-edit-project-description"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Status</label>
<Select
value={selectedProject.status}
onValueChange={(value: Project['status']) =>
setSelectedProject({ ...selectedProject, status: value })
}
>
<SelectTrigger data-testid="select-project-status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="planning">Planning</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="finished">Finished</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Tasks Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Project Tasks</h3>
<Button
variant="outline"
size="sm"
onClick={handleStartNewTask}
data-testid="button-add-task-edit"
>
<Plus className="w-4 h-4 mr-2" />
Add Task
</Button>
</div>
{/* Task List with Inline Editing */}
<div className="space-y-2 max-h-64 overflow-y-auto">
{projectTasks.map((task, index) => (
<Card key={task.id} className={`p-3 ${editingTaskIndex === index ? 'border-primary' : ''}`}>
{editingTaskIndex === index ? (
// Inline Edit Mode
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Task Title</label>
<Input
value={taskForm.title || ''}
onChange={(e) => setTaskForm({...taskForm, title: e.target.value})}
placeholder="Enter task title"
className="text-sm"
data-testid={`input-inline-task-title-${index}`}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Description</label>
<Input
value={taskForm.description || ''}
onChange={(e) => setTaskForm({...taskForm, description: e.target.value})}
placeholder="Enter task description"
className="text-sm"
data-testid={`input-inline-task-description-${index}`}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Priority</label>
<Select
value={taskForm.priority || 'medium'}
onValueChange={(value: ProjectTask['priority']) =>
setTaskForm({...taskForm, priority: value})
}
>
<SelectTrigger className="text-sm" data-testid={`select-inline-task-priority-${index}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Status</label>
<Select
value={taskForm.status || 'todo'}
onValueChange={(value: ProjectTask['status']) =>
setTaskForm({...taskForm, status: value})
}
>
<SelectTrigger className="text-sm" data-testid={`select-inline-task-status-${index}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="todo">To Do</SelectItem>
<SelectItem value="inProgress">In Progress</SelectItem>
<SelectItem value="done">Done</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1 md:col-span-2">
<label className="text-xs font-medium text-muted-foreground">Estimated Hours</label>
<Input
type="number"
value={taskForm.estimatedHours || ''}
onChange={(e) => setTaskForm({...taskForm, estimatedHours: Number(e.target.value)})}
placeholder="0"
min="0"
className="text-sm max-w-32"
data-testid={`input-inline-task-hours-${index}`}
/>
</div>
</div>
<div className="flex items-center gap-2 justify-end pt-2 border-t">
<Button
variant="outline"
size="sm"
onClick={handleCancelTaskEdit}
data-testid={`button-cancel-inline-edit-${index}`}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleSaveTask}
disabled={!taskForm.title?.trim()}
data-testid={`button-save-inline-edit-${index}`}
>
Save Changes
</Button>
</div>
</div>
) : (
// Display Mode
<div className="flex items-center justify-between">
<div className="flex-1">
<h4 className="font-medium text-sm">{task.title}</h4>
{task.description && (
<p className="text-xs text-muted-foreground">{task.description}</p>
)}
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{task.priority}
</Badge>
<Badge variant="outline" className="text-xs">
{task.status}
</Badge>
{task.estimatedHours ? (
<Badge variant="outline" className="text-xs">
{task.estimatedHours}h
</Badge>
) : null}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditTask(index)}
data-testid={`button-edit-task-edit-${index}`}
>
<Edit className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveTask(index)}
data-testid={`button-remove-task-edit-${index}`}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
)}
</Card>
))}
{projectTasks.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8 border-2 border-dashed border-muted rounded-lg">
No tasks in this project yet. Click "Add Task" to get started.
</div>
)}
</div>
</div>
</div>
)}
<div className="flex gap-2 pt-4 border-t">
<Button
variant="outline"
onClick={() => {
setIsEditProjectOpen(false);
setSelectedProject(null);
setProjectTasks([]);
setTaskForm({});
setIsEditingTask(false);
}}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleSaveEditedProject}
disabled={!selectedProject?.name.trim()}
className="flex-1"
data-testid="button-update-project"
>
Update Project ({projectTasks.length} tasks)
</Button>
</div>
</DialogContent>
</Dialog>
<TabsContent value="labels" className="space-y-6">
{/* Labels Section Header */}
<div className="flex items-center justify-between">
<h3 className="text-md font-medium">Task Labels</h3>
<Dialog open={isLabelDialogOpen} onOpenChange={setIsLabelDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" data-testid="button-create-label">
<Plus className="w-4 h-4 mr-2" />
Create Label
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingLabel ? 'Edit Label' : 'Create New Label'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Input
placeholder="Label name"
value={labelName}
onChange={(e) => setLabelName(e.target.value)}
data-testid="input-label-name"
/>
</div>
<div>
<div className="flex items-center gap-3">
<input
type="color"
value={labelColor}
onChange={(e) => setLabelColor(e.target.value)}
className="w-12 h-8 rounded border cursor-pointer"
data-testid="input-label-color"
/>
<Input
value={labelColor}
onChange={(e) => setLabelColor(e.target.value)}
placeholder="#3B82F6"
className="flex-1"
data-testid="input-label-color-text"
/>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button
variant="outline"
onClick={() => {
setIsLabelDialogOpen(false);
setEditingLabel(null);
setLabelName('');
setLabelColor('#3B82F6');
}}
className="flex-1"
data-testid="button-cancel-label"
>
Cancel
</Button>
<Button
onClick={handleSaveLabel}
disabled={!labelName.trim() || createLabelMutation.isPending || updateLabelMutation.isPending}
className="flex-1"
data-testid="button-save-label"
>
{editingLabel ? 'Update' : 'Create'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{/* Labels List */}
{labelsLoading ? (
<div className="flex items-center justify-center p-8">
<div className="text-muted-foreground">Loading labels...</div>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{labels.map((label) => (
<Card
key={label.id}
className="p-4 hover-elevate active-elevate-2"
style={{ borderLeft: `4px solid ${label.color}` }}
data-testid={`label-${label.id}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: label.color }}
/>
<span className="font-medium text-sm" data-testid={`text-label-name-${label.id}`}>
{label.name}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleEditLabel(label)}
className="w-6 h-6"
data-testid={`button-edit-${label.id}`}
>
<Edit className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteLabel(label.id)}
className="w-6 h-6 text-destructive hover:text-destructive"
data-testid={`button-delete-${label.id}`}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
</Card>
))}
{labels.length === 0 && (
<div className="col-span-full flex flex-col items-center justify-center p-8 text-center">
<Tag className="w-12 h-12 text-muted-foreground mb-4" />
<h3 className="font-medium text-muted-foreground mb-2">No labels yet</h3>
<p className="text-sm text-muted-foreground mb-4">
Create your first label to organize your tasks by color and category.
</p>
<Button
variant="outline"
onClick={() => setIsLabelDialogOpen(true)}
data-testid="button-create-first-label"
>
<Plus className="w-4 h-4 mr-2" />
Create Label
</Button>
</div>
)}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}