102315fa7c
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
1587 lines
67 KiB
TypeScript
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>
|
|
);
|
|
} |