Files
task-manager/client/src/components/ProjectTemplate.tsx
T
paul-nothaft 102315fa7c Add translations for various project management features
Integrate i18n translation keys for project template page elements, including column titles, buttons, dialogs, and input fields, within the ProjectTemplate component.

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/HdLXOOf
2025-10-24 11:00:26 +00:00

1587 lines
67 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
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, DialogDescription, 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, FileText, ArrowLeft } from 'lucide-react';
import { Label, Task } from '@shared/schema';
import TaskDetailsModal from '@/components/TaskDetailsModal';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiRequest } from '@/lib/queryClient';
// Helper function to get today's date at start of day for proper date comparison
const getStartOfToday = () => {
const today = new Date();
return new Date(today.getFullYear(), today.getMonth(), today.getDate());
};
// Helper function to convert ProjectTask to global Task format
const convertProjectTaskToTask = (projectTask: ProjectTask, projectId: string): Task => {
return {
id: `project-${projectId}-task-${projectTask.id}`, // Generate unique ID
title: projectTask.title,
description: projectTask.description || null,
status: projectTask.status,
priority: projectTask.priority,
dueDate: null, // Projects tasks don't have due dates by default
timeTracked: projectTask.timeTracked || 0,
isTracking: false,
projectId: projectId,
notes: projectTask.notes || null,
labelId: projectTask.labelId || null
};
};
interface TimeEntry {
id: string;
date: Date;
timeSpent: number; // in minutes
description?: string;
}
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 Project {
id: string;
name: string;
description?: string;
status: 'planning' | 'active' | 'finished';
tasks: ProjectTask[];
startDate?: Date;
endDate?: Date;
plannedEndDate?: Date;
createdAt: Date;
}
interface ProjectTemplateProps {
onCreateFromTemplate?: (templateId: string, startDate: Date, projectName: string) => void;
onCreateTemplate?: (template: any) => void;
onCreateTasks?: (tasks: Task[]) => void;
onNavigateToSettings?: () => 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,
onCreateTasks,
onNavigateToSettings
}: ProjectTemplateProps) {
const { t } = useTranslation();
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 [newPlannedEndDate, setNewPlannedEndDate] = useState<Date | undefined>();
const [isPlannedEndDateCalendarOpen, setIsPlannedEndDateCalendarOpen] = useState(false);
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');
// Task details modal state
const [isTaskDetailsOpen, setIsTaskDetailsOpen] = useState(false);
const [selectedTaskForDetails, setSelectedTaskForDetails] = useState<ProjectTask | null>(null);
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 newProjectId = Date.now().toString();
const newProject: Project = {
id: newProjectId,
name: newProjectName.trim(),
description: newProjectDescription.trim() || undefined,
status: 'planning',
tasks: projectTasks,
plannedEndDate: newPlannedEndDate,
createdAt: new Date()
};
// Add project to projects list
setProjects(currentProjects => [...currentProjects, newProject]);
// Convert project tasks to global tasks and add them to global state
if (projectTasks.length > 0 && onCreateTasks) {
const globalTasks = projectTasks.map(task =>
convertProjectTaskToTask(task, newProjectId)
);
onCreateTasks(globalTasks);
console.log('Added', globalTasks.length, 'tasks to global state for project:', newProject.name);
}
// Reset form state
setNewProjectName('');
setNewProjectDescription('');
setNewPlannedEndDate(undefined);
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(currentProjects => currentProjects.filter(p => p.id !== projectId));
}
};
const handleProjectStatusChange = (projectId: string, newStatus: Project['status']) => {
setProjects(currentProjects => currentProjects.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) {
// Create a new project as a duplicate/copy of the closed project
const newProjectId = Date.now().toString();
const duplicatedTasks = clearHistory
? []
: restartingProject.tasks.map(task => ({
...task,
id: `${newProjectId}-task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, // Generate new task ID
status: 'todo' as const // Reset all tasks to todo status
}));
const duplicatedProject: Project = {
...restartingProject,
id: newProjectId, // New unique ID
status: 'planning', // Start as planning
startDate: new Date(), // Set new start date
endDate: undefined, // Clear end date since it's active again
plannedEndDate: newEndDate, // Set the new planned end date
createdAt: new Date(), // Set new creation date
tasks: duplicatedTasks
};
// Add the new project to the projects array without removing the original
setProjects(currentProjects => [...currentProjects, duplicatedProject]);
// Convert project tasks to global tasks and add them to global state
if (duplicatedTasks.length > 0 && onCreateTasks) {
const globalTasks = duplicatedTasks.map(task =>
convertProjectTaskToTask(task, newProjectId)
);
onCreateTasks(globalTasks);
console.log('Added', globalTasks.length, 'tasks to global state for duplicated project:', duplicatedProject.name);
}
setIsRestartProjectOpen(false);
setRestartingProject(null);
setNewEndDate(undefined);
setClearHistory(false);
console.log('Project duplicated:', duplicatedProject.name, 'with', duplicatedProject.tasks.length, 'tasks. Original project remains closed.');
}
};
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);
};
// 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(currentProjects => currentProjects.map(p =>
p.id === selectedProject.id ? updatedProject : p
));
}
}
};
const handleSaveEditedProject = () => {
if (selectedProject) {
const updatedProject = {
...selectedProject,
tasks: projectTasks
};
setProjects(currentProjects => currentProjects.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(t('projectTemplate.deleteConfirm'))) {
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: t('projectTemplate.planning'), status: 'planning' as const, color: 'bg-slate-100' },
{ id: 'active', title: t('projectTemplate.active'), status: 'active' as const, color: 'bg-blue-100' },
{ id: 'finished', title: t('projectTemplate.finished'), status: 'finished' as const, color: 'bg-green-100' }
];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3">
{onNavigateToSettings && (
<Button
variant="ghost"
size="icon"
onClick={onNavigateToSettings}
data-testid="button-back-to-settings"
>
<ArrowLeft className="w-5 h-5" />
</Button>
)}
<h2 className="text-base sm:text-lg font-semibold" data-testid="text-settings-title">
{t('projectTemplate.title')}
</h2>
</div>
<Button
onClick={() => setIsCreateProjectOpen(true)}
data-testid="button-create-project"
size="lg"
className="h-11"
>
<Plus className="w-4 h-4 mr-2" />
{t('projectTemplate.newProject')}
</Button>
</div>
<Tabs defaultValue="projects" className="space-y-3 sm:space-y-4">
<TabsList className="w-full">
<TabsTrigger value="projects" data-testid="tab-projects" className="flex-1">{t('projectTemplate.projects')}</TabsTrigger>
<TabsTrigger value="labels" data-testid="tab-labels" className="flex-1">{t('projectTemplate.labels')}</TabsTrigger>
</TabsList>
<TabsContent value="projects" className="space-y-4 sm:space-y-6">
{/* Project Kanban Board */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 sm:gap-4 lg:gap-6">
{projectColumns.map((column) => {
const columnProjects = getProjectsByStatus(column.status);
return (
<Card
key={column.id}
className="p-3 sm:p-4"
onDragOver={handleDragOver}
onDrop={() => handleDrop(column.status)}
data-testid={`column-${column.id}`}
>
<div className="flex items-center justify-between mb-3 sm: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-2 sm:space-y-3 min-h-[200px] sm:min-h-[300px]">
{columnProjects.map((project) => (
<Card
key={project.id}
draggable
onDragStart={() => handleDragStart(project.id)}
onDragEnd={handleDragEnd}
className={`p-3 sm: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 < getStartOfToday()}
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);
setNewProjectName('');
setNewProjectDescription('');
setNewPlannedEndDate(undefined);
setProjectTasks([]);
setTaskForm({});
setIsEditingTask(false);
}}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('projectTemplate.createProject')}</DialogTitle>
<DialogDescription>
{t('projectTemplate.description')}
</DialogDescription>
</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">{t('projectTemplate.projectName')}</label>
<Input
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder={t('projectTemplate.projectNamePlaceholder')}
data-testid="input-project-name"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('projectTemplate.projectDescription')}</label>
<Input
value={newProjectDescription}
onChange={(e) => setNewProjectDescription(e.target.value)}
placeholder={t('projectTemplate.projectDescriptionPlaceholder')}
data-testid="input-project-description"
/>
</div>
</div>
{/* Planned End Date */}
<div className="space-y-2">
<label className="text-sm font-medium">{t('projectTemplate.plannedEndDate')}</label>
<Popover open={isPlannedEndDateCalendarOpen} onOpenChange={setIsPlannedEndDateCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full md:w-80 justify-start text-left"
data-testid="button-select-planned-end-date"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{newPlannedEndDate ? newPlannedEndDate.toLocaleDateString() : t('projectTemplate.selectPlannedEndDate')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={newPlannedEndDate}
onSelect={(date) => {
setNewPlannedEndDate(date);
setIsPlannedEndDateCalendarOpen(false);
}}
disabled={(date) => date < getStartOfToday()}
initialFocus
/>
</PopoverContent>
</Popover>
{newPlannedEndDate && (
<Button
variant="ghost"
size="sm"
onClick={() => setNewPlannedEndDate(undefined)}
className="text-xs text-muted-foreground hover:text-destructive"
data-testid="button-clear-create-planned-end-date"
>
{t('projectTemplate.clearDate')}
</Button>
)}
</div>
{/* Tasks Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">{t('projectTemplate.projectTasks')}</h3>
<Button
variant="outline"
size="sm"
onClick={handleStartNewTask}
data-testid="button-add-task"
>
<Plus className="w-4 h-4 mr-2" />
{t('projectTemplate.addTask')}
</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">
{t('projectTemplate.noLabelsDescription')}
</div>
)}
</div>
{/* Task Form */}
{isEditingTask && (
<Card className="p-4 border-primary">
<div className="space-y-4">
<h4 className="font-semibold text-sm">
{editingTaskIndex !== null ? t('projectTemplate.edit') : t('projectTemplate.addTask')}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('projectTemplate.taskTitle')}</label>
<Input
value={taskForm.title || ''}
onChange={(e) => setTaskForm({...taskForm, title: e.target.value})}
placeholder={t('projectTemplate.enterTaskTitle')}
data-testid="input-task-title"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('projectTemplate.description')}</label>
<Input
value={taskForm.description || ''}
onChange={(e) => setTaskForm({...taskForm, description: e.target.value})}
placeholder={t('projectTemplate.enterTaskDescription')}
data-testid="input-task-description"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('projectTemplate.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">{t('priority.low')}</SelectItem>
<SelectItem value="medium">{t('priority.medium')}</SelectItem>
<SelectItem value="high">{t('priority.high')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('projectTemplate.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">{t('status.todo')}</SelectItem>
<SelectItem value="inProgress">{t('status.inProgress')}</SelectItem>
<SelectItem value="done">{t('status.done')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t('projectTemplate.estimatedHours')}</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"
>
{t('projectTemplate.cancel')}
</Button>
<Button
onClick={editingTaskIndex !== null ? handleSaveTask : handleAddTask}
disabled={!taskForm.title?.trim()}
className="flex-1"
data-testid="button-save-task"
>
{editingTaskIndex !== null ? t('projectTemplate.save') : t('projectTemplate.addTask')}
</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"
>
{t('projectTemplate.cancel')}
</Button>
<Button
onClick={handleCreateProject}
disabled={!newProjectName.trim()}
className="flex-1"
data-testid="button-save-project"
>
{t('projectTemplate.createProject')} ({projectTasks.length} {t('projectTemplate.tasks', { count: projectTasks.length })})
</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>{t('projectTemplate.edit')}</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>
{/* Planned End Date */}
<div className="space-y-2">
<label className="text-sm font-medium">Planned End Date (Optional)</label>
<Popover open={isPlannedEndDateCalendarOpen} onOpenChange={setIsPlannedEndDateCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full md:w-80 justify-start text-left"
data-testid="button-edit-planned-end-date"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{selectedProject.plannedEndDate
? selectedProject.plannedEndDate.toLocaleDateString()
: "Select planned end date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selectedProject.plannedEndDate}
onSelect={(date) => {
setSelectedProject({ ...selectedProject, plannedEndDate: date });
setIsPlannedEndDateCalendarOpen(false);
}}
disabled={(date) => date < getStartOfToday()}
initialFocus
/>
</PopoverContent>
</Popover>
{selectedProject.plannedEndDate && (
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedProject({ ...selectedProject, plannedEndDate: undefined })}
className="text-xs text-muted-foreground hover:text-destructive"
data-testid="button-clear-planned-end-date"
>
Clear date
</Button>
)}
</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 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>
)}
<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}
{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={(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={(e) => {
e.stopPropagation();
handleRemoveTask(index);
}}
data-testid={`button-remove-task-edit-${index}`}
title="Remove task"
>
<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"
>
{t('projectTemplate.cancel')}
</Button>
<Button
onClick={handleSaveEditedProject}
disabled={!selectedProject?.name.trim()}
className="flex-1"
data-testid="button-update-project"
>
{t('projectTemplate.update')} ({projectTasks.length} {t('projectTemplate.tasks', { count: projectTasks.length })})
</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">{t('settings.labels.title')}</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" />
{t('projectTemplate.createLabel')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingLabel ? t('projectTemplate.editLabel') : t('projectTemplate.createNewLabel')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Input
placeholder={t('projectTemplate.labelNamePlaceholder')}
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"
>
{t('projectTemplate.cancel')}
</Button>
<Button
onClick={handleSaveLabel}
disabled={!labelName.trim() || createLabelMutation.isPending || updateLabelMutation.isPending}
className="flex-1"
data-testid="button-save-label"
>
{editingLabel ? t('projectTemplate.update') : t('projectTemplate.create')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{/* Labels List */}
{labelsLoading ? (
<div className="flex items-center justify-center p-8">
<div className="text-muted-foreground">{t('projectTemplate.loadingLabels')}</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">{t('projectTemplate.noLabelsYet')}</h3>
<p className="text-sm text-muted-foreground mb-4">
{t('projectTemplate.noLabelsDescription')}
</p>
<Button
variant="outline"
onClick={() => setIsLabelDialogOpen(true)}
data-testid="button-create-first-label"
>
<Plus className="w-4 h-4 mr-2" />
{t('projectTemplate.createLabel')}
</Button>
</div>
)}
</div>
)}
</TabsContent>
</Tabs>
{/* Task Details Modal */}
<TaskDetailsModal
isOpen={isTaskDetailsOpen}
onClose={handleCloseTaskDetails}
task={selectedTaskForDetails}
onSave={handleSaveTaskDetails}
labels={labels}
/>
</div>
);
}