feat: Implement AI Chat Agent, Email Notifications, and UI enhancements
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -34,7 +34,11 @@ const convertProjectTaskToTask = (projectTask: ProjectTask, projectId: string):
|
||||
isTracking: false,
|
||||
projectId: projectId,
|
||||
notes: projectTask.notes || null,
|
||||
labelId: projectTask.labelId || null
|
||||
labelId: projectTask.labelId || null,
|
||||
energyLevel: 'medium',
|
||||
estimatedDuration: (projectTask.estimatedHours || 0) * 60,
|
||||
dependencies: [],
|
||||
userId: null
|
||||
};
|
||||
};
|
||||
|
||||
@@ -121,8 +125,8 @@ const defaultProjects: Project[] = [
|
||||
}
|
||||
];
|
||||
|
||||
export default function ProjectTemplate({
|
||||
onCreateFromTemplate,
|
||||
export default function ProjectTemplate({
|
||||
onCreateFromTemplate,
|
||||
onCreateTemplate,
|
||||
onCreateTasks,
|
||||
onNavigateToSettings
|
||||
@@ -143,33 +147,33 @@ export default function ProjectTemplate({
|
||||
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 }) =>
|
||||
mutationFn: (data: { name: string; color: string }) =>
|
||||
apiRequest('POST', '/api/labels', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
|
||||
@@ -179,7 +183,7 @@ export default function ProjectTemplate({
|
||||
setEditingLabel(null);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Update label mutation
|
||||
const updateLabelMutation = useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: string; name?: string; color?: string }) =>
|
||||
@@ -192,7 +196,7 @@ export default function ProjectTemplate({
|
||||
setEditingLabel(null);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Delete label mutation
|
||||
const deleteLabelMutation = useMutation({
|
||||
mutationFn: (id: string) => apiRequest('DELETE', `/api/labels/${id}`),
|
||||
@@ -213,19 +217,19 @@ export default function ProjectTemplate({
|
||||
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 =>
|
||||
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('');
|
||||
@@ -249,7 +253,7 @@ export default function ProjectTemplate({
|
||||
};
|
||||
|
||||
const handleProjectStatusChange = (projectId: string, newStatus: Project['status']) => {
|
||||
setProjects(currentProjects => currentProjects.map(project =>
|
||||
setProjects(currentProjects => currentProjects.map(project =>
|
||||
project.id === projectId ? { ...project, status: newStatus } : project
|
||||
));
|
||||
};
|
||||
@@ -282,15 +286,15 @@ export default function ProjectTemplate({
|
||||
if (restartingProject && newEndDate) {
|
||||
// Create a new project as a duplicate/copy of the closed project
|
||||
const newProjectId = Date.now().toString();
|
||||
|
||||
const duplicatedTasks = clearHistory
|
||||
? []
|
||||
|
||||
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
|
||||
}));
|
||||
|
||||
...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
|
||||
@@ -301,19 +305,19 @@ export default function ProjectTemplate({
|
||||
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 =>
|
||||
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);
|
||||
@@ -325,7 +329,7 @@ export default function ProjectTemplate({
|
||||
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()))
|
||||
@@ -343,7 +347,7 @@ export default function ProjectTemplate({
|
||||
estimatedHours: taskForm.estimatedHours || 0,
|
||||
labelId: taskForm.labelId
|
||||
};
|
||||
|
||||
|
||||
setProjectTasks([...projectTasks, newTask]);
|
||||
setTaskForm({});
|
||||
setIsEditingTask(false);
|
||||
@@ -359,10 +363,10 @@ export default function ProjectTemplate({
|
||||
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',
|
||||
@@ -372,10 +376,10 @@ export default function ProjectTemplate({
|
||||
estimatedHours: taskForm.estimatedHours || 0,
|
||||
labelId: taskForm.labelId
|
||||
};
|
||||
|
||||
|
||||
setProjectTasks(updatedTasks);
|
||||
}
|
||||
|
||||
|
||||
setTaskForm({});
|
||||
setIsEditingTask(false);
|
||||
setEditingTaskIndex(null);
|
||||
@@ -394,7 +398,7 @@ export default function ProjectTemplate({
|
||||
setProjectTasks(updatedTasks);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setTaskForm({});
|
||||
setIsEditingTask(false);
|
||||
setEditingTaskIndex(null);
|
||||
@@ -410,7 +414,7 @@ export default function ProjectTemplate({
|
||||
status: 'todo',
|
||||
estimatedHours: 0
|
||||
};
|
||||
|
||||
|
||||
const newTasks = [...projectTasks, tempTask];
|
||||
setProjectTasks(newTasks);
|
||||
setEditingTaskIndex(newTasks.length - 1);
|
||||
@@ -452,9 +456,9 @@ export default function ProjectTemplate({
|
||||
if (projectTaskIndex !== -1) {
|
||||
updatedProject.tasks[projectTaskIndex] = updatedTask;
|
||||
setSelectedProject(updatedProject);
|
||||
|
||||
|
||||
// Update the projects array
|
||||
setProjects(currentProjects => currentProjects.map(p =>
|
||||
setProjects(currentProjects => currentProjects.map(p =>
|
||||
p.id === selectedProject.id ? updatedProject : p
|
||||
));
|
||||
}
|
||||
@@ -467,36 +471,36 @@ export default function ProjectTemplate({
|
||||
...selectedProject,
|
||||
tasks: projectTasks
|
||||
};
|
||||
|
||||
setProjects(currentProjects => currentProjects.map(p =>
|
||||
|
||||
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);
|
||||
@@ -542,7 +546,7 @@ export default function ProjectTemplate({
|
||||
{t('projectTemplate.title')}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
<Button
|
||||
onClick={() => setIsCreateProjectOpen(true)}
|
||||
data-testid="button-create-project"
|
||||
size="lg"
|
||||
@@ -564,7 +568,7 @@ export default function ProjectTemplate({
|
||||
<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}
|
||||
@@ -581,7 +585,7 @@ export default function ProjectTemplate({
|
||||
{columnProjects.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2 sm:space-y-3 min-h-[200px] sm:min-h-[300px]">
|
||||
{columnProjects.map((project) => (
|
||||
<Card
|
||||
@@ -589,9 +593,8 @@ export default function ProjectTemplate({
|
||||
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' : ''
|
||||
}`}
|
||||
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">
|
||||
@@ -606,7 +609,7 @@ export default function ProjectTemplate({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -662,7 +665,7 @@ export default function ProjectTemplate({
|
||||
</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()}
|
||||
@@ -686,7 +689,7 @@ export default function ProjectTemplate({
|
||||
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}`}>
|
||||
@@ -702,7 +705,7 @@ export default function ProjectTemplate({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200">
|
||||
Finished
|
||||
</Badge>
|
||||
@@ -739,7 +742,7 @@ export default function ProjectTemplate({
|
||||
</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'}
|
||||
@@ -763,7 +766,7 @@ export default function ProjectTemplate({
|
||||
{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}>
|
||||
@@ -846,7 +849,7 @@ export default function ProjectTemplate({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('projectTemplate.createProject')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('projectTemplate.description')}
|
||||
{t('projectTemplate.pageDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6 py-4">
|
||||
@@ -972,7 +975,7 @@ export default function ProjectTemplate({
|
||||
</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')}
|
||||
@@ -987,34 +990,34 @@ export default function ProjectTemplate({
|
||||
<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})}
|
||||
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})}
|
||||
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})
|
||||
onValueChange={(value: ProjectTask['priority']) =>
|
||||
setTaskForm({ ...taskForm, priority: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger data-testid="select-task-priority">
|
||||
@@ -1027,13 +1030,13 @@ export default function ProjectTemplate({
|
||||
</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})
|
||||
onValueChange={(value: ProjectTask['status']) =>
|
||||
setTaskForm({ ...taskForm, status: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger data-testid="select-task-status">
|
||||
@@ -1046,20 +1049,20 @@ export default function ProjectTemplate({
|
||||
</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)})}
|
||||
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"
|
||||
@@ -1145,7 +1148,7 @@ export default function ProjectTemplate({
|
||||
<label className="text-sm font-medium">{t('projectTemplate.status')}</label>
|
||||
<Select
|
||||
value={selectedProject.status}
|
||||
onValueChange={(value: Project['status']) =>
|
||||
onValueChange={(value: Project['status']) =>
|
||||
setSelectedProject({ ...selectedProject, status: value })
|
||||
}
|
||||
>
|
||||
@@ -1172,8 +1175,8 @@ export default function ProjectTemplate({
|
||||
data-testid="button-edit-planned-end-date"
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{selectedProject.plannedEndDate
|
||||
? selectedProject.plannedEndDate.toLocaleDateString()
|
||||
{selectedProject.plannedEndDate
|
||||
? selectedProject.plannedEndDate.toLocaleDateString()
|
||||
: t('projectTemplate.selectPlannedEndDate')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -1230,30 +1233,30 @@ export default function ProjectTemplate({
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('projectTemplate.taskTitle')}</label>
|
||||
<Input
|
||||
value={taskForm.title || ''}
|
||||
onChange={(e) => setTaskForm({...taskForm, title: e.target.value})}
|
||||
onChange={(e) => setTaskForm({ ...taskForm, title: e.target.value })}
|
||||
placeholder={t('projectTemplate.enterTaskTitle')}
|
||||
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">{t('projectTemplate.description')}</label>
|
||||
<Input
|
||||
value={taskForm.description || ''}
|
||||
onChange={(e) => setTaskForm({...taskForm, description: e.target.value})}
|
||||
onChange={(e) => setTaskForm({ ...taskForm, description: e.target.value })}
|
||||
placeholder={t('projectTemplate.enterTaskDescription')}
|
||||
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">{t('projectTemplate.priority')}</label>
|
||||
<Select
|
||||
value={taskForm.priority || 'medium'}
|
||||
onValueChange={(value: ProjectTask['priority']) =>
|
||||
setTaskForm({...taskForm, priority: value})
|
||||
onValueChange={(value: ProjectTask['priority']) =>
|
||||
setTaskForm({ ...taskForm, priority: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="text-sm" data-testid={`select-inline-task-priority-${index}`}>
|
||||
@@ -1266,13 +1269,13 @@ export default function ProjectTemplate({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('projectTemplate.status')}</label>
|
||||
<Select
|
||||
value={taskForm.status || 'todo'}
|
||||
onValueChange={(value: ProjectTask['status']) =>
|
||||
setTaskForm({...taskForm, status: value})
|
||||
onValueChange={(value: ProjectTask['status']) =>
|
||||
setTaskForm({ ...taskForm, status: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="text-sm" data-testid={`select-inline-task-status-${index}`}>
|
||||
@@ -1285,13 +1288,13 @@ export default function ProjectTemplate({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">{t('projectTemplate.estimatedHours')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={taskForm.estimatedHours || ''}
|
||||
onChange={(e) => setTaskForm({...taskForm, estimatedHours: Number(e.target.value)})}
|
||||
onChange={(e) => setTaskForm({ ...taskForm, estimatedHours: Number(e.target.value) })}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
className="text-sm max-w-32"
|
||||
@@ -1299,7 +1302,7 @@ export default function ProjectTemplate({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-2 justify-end pt-2 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1322,8 +1325,8 @@ export default function ProjectTemplate({
|
||||
) : (
|
||||
// Display Mode
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className="flex-1 cursor-pointer hover-elevate rounded-md p-2 -m-2"
|
||||
<div
|
||||
className="flex-1 cursor-pointer hover-elevate rounded-md p-2 -m-2"
|
||||
onClick={() => handleOpenTaskDetails(task)}
|
||||
data-testid={`clickable-task-${index}`}
|
||||
>
|
||||
@@ -1398,7 +1401,7 @@ export default function ProjectTemplate({
|
||||
)}
|
||||
</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.noTasksInProject')}
|
||||
@@ -1436,150 +1439,165 @@ export default function ProjectTemplate({
|
||||
</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"
|
||||
/>
|
||||
{/* 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>
|
||||
</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>
|
||||
</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">
|
||||
{/* 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="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEditLabel(label)}
|
||||
className="w-6 h-6"
|
||||
data-testid={`button-edit-${label.id}`}
|
||||
variant="outline"
|
||||
onClick={() => setIsLabelDialogOpen(true)}
|
||||
data-testid="button-create-first-label"
|
||||
>
|
||||
<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" />
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('projectTemplate.createLabel')}
|
||||
</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>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Task Details Modal */}
|
||||
<TaskDetailsModal
|
||||
isOpen={isTaskDetailsOpen}
|
||||
onClose={handleCloseTaskDetails}
|
||||
task={selectedTaskForDetails}
|
||||
onSave={handleSaveTaskDetails}
|
||||
task={selectedTaskForDetails ? convertProjectTaskToTask(selectedTaskForDetails, selectedProject?.id || 'temp') : null}
|
||||
onSave={(updatedTask) => {
|
||||
// Convert back to ProjectTask
|
||||
const projectTask: ProjectTask = {
|
||||
id: selectedTaskForDetails?.id || updatedTask.id, // Keep original ID if possible
|
||||
title: updatedTask.title,
|
||||
description: updatedTask.description || undefined,
|
||||
status: updatedTask.status as any,
|
||||
priority: updatedTask.priority as any,
|
||||
estimatedHours: updatedTask.estimatedDuration ? Math.round(updatedTask.estimatedDuration / 60) : 0,
|
||||
labelId: updatedTask.labelId || undefined,
|
||||
notes: updatedTask.notes || undefined,
|
||||
timeTracked: updatedTask.timeTracked,
|
||||
timeEntries: [] // Simplify for now
|
||||
};
|
||||
handleSaveTaskDetails(projectTask);
|
||||
}}
|
||||
labels={labels}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user