b0a2f51927
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
1320 lines
55 KiB
TypeScript
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>
|
|
);
|
|
} |