Rename "Templates" tab to "Settings" and enhance calendar functionality
Refactors the application's navigation by renaming the 'templates' tab to 'settings'. It also introduces several enhancements to the calendar view, including fetching and displaying labels with custom colors, enabling drag-and-drop functionality for tasks onto specific dates, and improving the rendering of weekly calendar views with better date and task handling. The `ProjectTemplate` component is updated to `Project` with new interfaces and default data. Replit-Commit-Author: Agent Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/oa49Utu
This commit is contained in:
@@ -18,6 +18,10 @@ externalPort = 80
|
||||
localPort = 35345
|
||||
externalPort = 3002
|
||||
|
||||
[[ports]]
|
||||
localPort = 40293
|
||||
externalPort = 3001
|
||||
|
||||
[[ports]]
|
||||
localPort = 41353
|
||||
externalPort = 3000
|
||||
|
||||
+4
-4
@@ -148,7 +148,7 @@ function App() {
|
||||
/>
|
||||
);
|
||||
|
||||
case 'templates':
|
||||
case 'settings':
|
||||
return (
|
||||
<ProjectTemplate
|
||||
onCreateFromTemplate={handleCreateFromTemplate}
|
||||
@@ -212,7 +212,7 @@ function App() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{currentTab === 'templates' && (
|
||||
{currentTab === 'settings' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -236,8 +236,8 @@ function App() {
|
||||
<BottomNavigation
|
||||
activeTab={currentTab}
|
||||
onTabChange={(tab) => {
|
||||
if (tab === 'templates') {
|
||||
setCurrentTab('templates');
|
||||
if (tab === 'settings') {
|
||||
setCurrentTab('settings');
|
||||
} else {
|
||||
setCurrentTab(tab);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function BottomNavigation({ onTabChange, onCreateTask, activeTab
|
||||
{ id: 'calendar', label: 'Calendar', icon: Calendar },
|
||||
{ id: 'create', label: 'Create', icon: Plus, isCreate: true },
|
||||
{ id: 'kanban', label: 'Board', icon: LayoutGrid },
|
||||
{ id: 'templates', label: 'Templates', icon: Settings }
|
||||
{ id: 'settings', label: 'Settings', icon: Settings }
|
||||
];
|
||||
|
||||
const handleTabClick = (tabId: string, isCreate?: boolean) => {
|
||||
|
||||
@@ -4,9 +4,10 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { LayoutGrid, Calendar as CalendarIcon, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Task } from './TaskCard';
|
||||
import { Task, Label } from '@shared/schema';
|
||||
import TaskCard from './TaskCard';
|
||||
import { format, startOfWeek, endOfWeek, startOfMonth, endOfMonth, addWeeks, addMonths, isWithinInterval } from 'date-fns';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { format, startOfWeek, endOfWeek, startOfMonth, endOfMonth, addWeeks, addMonths, isWithinInterval, addDays, isSameDay, getDaysInMonth } from 'date-fns';
|
||||
|
||||
interface KanbanBoardProps {
|
||||
tasks: Task[];
|
||||
@@ -21,6 +22,13 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate }:
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [draggedTask, setDraggedTask] = useState<string | null>(null);
|
||||
|
||||
// Fetch labels to get label colors
|
||||
const { data: labels = [] } = useQuery<Label[]>({
|
||||
queryKey: ['/api/labels'],
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes cache
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ id: 'todo', title: 'To Do', status: 'todo' as const },
|
||||
{ id: 'inProgress', title: 'In Progress', status: 'inProgress' as const },
|
||||
@@ -84,6 +92,196 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate }:
|
||||
return 'All Tasks';
|
||||
};
|
||||
|
||||
const getTasksForDate = (date: Date) => {
|
||||
return tasks.filter(task =>
|
||||
task.dueDate && isSameDay(task.dueDate, date)
|
||||
);
|
||||
};
|
||||
|
||||
const handleDateDrop = (date: Date) => {
|
||||
if (draggedTask) {
|
||||
const task = tasks.find(t => t.id === draggedTask);
|
||||
if (task) {
|
||||
onTaskUpdate?.(draggedTask, { dueDate: date });
|
||||
console.log(`Task ${draggedTask} moved to ${format(date, 'yyyy-MM-dd')}`);
|
||||
}
|
||||
setDraggedTask(null);
|
||||
}
|
||||
};
|
||||
|
||||
const renderWeeklyCalendar = () => {
|
||||
const weekStart = startOfWeek(currentDate);
|
||||
const dates = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{dates.map((date, index) => {
|
||||
const dayTasks = getTasksForDate(date);
|
||||
const isToday = isSameDay(date, new Date());
|
||||
const isWeekend = date.getDay() === 0 || date.getDay() === 6;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
className={`p-3 min-h-[200px] transition-all ${
|
||||
isToday ? 'ring-2 ring-primary' : ''
|
||||
} ${
|
||||
isWeekend ? 'bg-muted/30' : ''
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={() => handleDateDrop(date)}
|
||||
data-testid={`calendar-date-${format(date, 'yyyy-MM-dd')}`}
|
||||
>
|
||||
<div className="text-center mb-2">
|
||||
<div className="text-xs text-muted-foreground font-medium">
|
||||
{format(date, 'EEE')}
|
||||
</div>
|
||||
<div className={`text-sm font-semibold ${
|
||||
isToday ? 'text-primary' : ''
|
||||
}`}>
|
||||
{format(date, 'd')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{dayTasks.slice(0, 3).map((task) => {
|
||||
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(task.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`cursor-move ${draggedTask === task.id ? 'opacity-50' : ''}`}
|
||||
data-testid={`calendar-task-${task.id}`}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="w-full justify-start text-xs p-1 h-auto"
|
||||
style={taskLabel ? {
|
||||
borderColor: taskLabel.color,
|
||||
borderWidth: '2px',
|
||||
borderStyle: 'solid'
|
||||
} : {}}
|
||||
>
|
||||
<div className="truncate flex-1 text-left">
|
||||
{task.title}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{dayTasks.length > 3 && (
|
||||
<Badge variant="secondary" className="w-full justify-center text-xs">
|
||||
+{dayTasks.length - 3} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMonthlyCalendar = () => {
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
const calendarStart = startOfWeek(monthStart);
|
||||
const calendarEnd = endOfWeek(monthEnd);
|
||||
|
||||
const dates = [];
|
||||
let currentDateIterator = calendarStart;
|
||||
|
||||
while (currentDateIterator <= calendarEnd) {
|
||||
dates.push(currentDateIterator);
|
||||
currentDateIterator = addDays(currentDateIterator, 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Month grid header */}
|
||||
<div className="grid grid-cols-7 gap-2 mb-2">
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
||||
<div key={day} className="text-center text-xs font-medium text-muted-foreground p-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Month grid */}
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{dates.map((date, index) => {
|
||||
const dayTasks = getTasksForDate(date);
|
||||
const isToday = isSameDay(date, new Date());
|
||||
const isCurrentMonth = date >= monthStart && date <= monthEnd;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
className={`p-2 min-h-[100px] transition-all ${
|
||||
isToday ? 'ring-2 ring-primary' : ''
|
||||
} ${
|
||||
!isCurrentMonth ? 'bg-muted/50 text-muted-foreground' : ''
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={() => handleDateDrop(date)}
|
||||
data-testid={`calendar-date-${format(date, 'yyyy-MM-dd')}`}
|
||||
>
|
||||
<div className="text-center mb-1">
|
||||
<div className={`text-xs font-semibold ${
|
||||
isToday ? 'text-primary' : ''
|
||||
}`}>
|
||||
{format(date, 'd')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{dayTasks.slice(0, 2).map((task) => {
|
||||
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(task.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`cursor-move ${draggedTask === task.id ? 'opacity-50' : ''}`}
|
||||
data-testid={`calendar-task-${task.id}`}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="w-full justify-start text-xs p-1 h-auto"
|
||||
style={taskLabel ? {
|
||||
borderColor: taskLabel.color,
|
||||
borderWidth: '2px',
|
||||
borderStyle: 'solid'
|
||||
} : {}}
|
||||
>
|
||||
<div className="truncate flex-1 text-left">
|
||||
{task.title}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{dayTasks.length > 2 && (
|
||||
<Badge variant="secondary" className="w-full justify-center text-xs">
|
||||
+{dayTasks.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
@@ -141,7 +339,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate }:
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={viewMode} className="mt-4">
|
||||
<TabsContent value="traditional" className="mt-4">
|
||||
{/* Kanban Columns */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{columns.map((column) => {
|
||||
@@ -202,6 +400,14 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate }:
|
||||
})}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="weekly" className="mt-4">
|
||||
{renderWeeklyCalendar()}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="monthly" className="mt-4">
|
||||
{renderMonthlyCalendar()}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,70 +14,87 @@ import { Label } from '@shared/schema';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiRequest } from '@/lib/queryClient';
|
||||
|
||||
interface TemplateTask {
|
||||
interface ProjectTask {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
dayOffset: number; // Days relative to project start date (negative for before, positive for after)
|
||||
status: 'todo' | 'inProgress' | 'done';
|
||||
estimatedHours?: number;
|
||||
labelId?: string;
|
||||
}
|
||||
|
||||
interface ProjectTemplate {
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
tasks: TemplateTask[];
|
||||
category: string;
|
||||
status: 'planning' | 'active' | 'finished';
|
||||
tasks: ProjectTask[];
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface ProjectTemplateProps {
|
||||
templates?: ProjectTemplate[];
|
||||
onCreateFromTemplate?: (templateId: string, startDate: Date, projectName: string) => void;
|
||||
onCreateTemplate?: (template: Omit<ProjectTemplate, 'id'>) => void;
|
||||
onCreateTemplate?: (template: any) => void;
|
||||
}
|
||||
|
||||
const defaultTemplates: ProjectTemplate[] = [
|
||||
const defaultProjects: Project[] = [
|
||||
{
|
||||
id: 'web-project',
|
||||
name: 'Website Launch',
|
||||
description: 'Complete website development and launch process',
|
||||
category: 'Development',
|
||||
id: '1',
|
||||
name: 'Website Redesign',
|
||||
description: 'Complete redesign of company website',
|
||||
status: 'active',
|
||||
startDate: new Date(),
|
||||
createdAt: new Date(),
|
||||
tasks: [
|
||||
{ id: '1', title: 'Project planning', priority: 'high', dayOffset: -14, estimatedHours: 4 },
|
||||
{ id: '2', title: 'Design mockups', priority: 'high', dayOffset: -10, estimatedHours: 16 },
|
||||
{ id: '3', title: 'Frontend development', priority: 'high', dayOffset: -7, estimatedHours: 40 },
|
||||
{ id: '4', title: 'Backend API', priority: 'medium', dayOffset: -5, estimatedHours: 24 },
|
||||
{ id: '5', title: 'Testing and QA', priority: 'high', dayOffset: -2, estimatedHours: 8 },
|
||||
{ id: '6', title: 'Deploy to production', priority: 'high', dayOffset: 0, estimatedHours: 2 }
|
||||
{ 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: 'product-launch',
|
||||
name: 'Product Launch',
|
||||
description: 'Marketing and launch campaign for new product',
|
||||
category: 'Marketing',
|
||||
id: '2',
|
||||
name: 'Mobile App Launch',
|
||||
description: 'Launch new mobile application',
|
||||
status: 'planning',
|
||||
createdAt: new Date(),
|
||||
tasks: [
|
||||
{ id: '1', title: 'Market research', priority: 'high', dayOffset: -21, estimatedHours: 12 },
|
||||
{ id: '2', title: 'Create marketing materials', priority: 'medium', dayOffset: -14, estimatedHours: 20 },
|
||||
{ id: '3', title: 'Press release', priority: 'medium', dayOffset: -7, estimatedHours: 4 },
|
||||
{ id: '4', title: 'Social media campaign', priority: 'high', dayOffset: -3, estimatedHours: 8 },
|
||||
{ id: '5', title: 'Launch event', priority: 'high', dayOffset: 0, estimatedHours: 6 }
|
||||
{ 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({
|
||||
templates = defaultTemplates,
|
||||
onCreateFromTemplate,
|
||||
onCreateTemplate
|
||||
}: ProjectTemplateProps) {
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<ProjectTemplate | null>(null);
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [startDate, setStartDate] = useState<Date | undefined>();
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isLaunchOpen, setIsLaunchOpen] = useState(false);
|
||||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||||
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('');
|
||||
|
||||
// Label management state
|
||||
const [isLabelDialogOpen, setIsLabelDialogOpen] = useState(false);
|
||||
@@ -126,22 +143,61 @@ export default function ProjectTemplate({
|
||||
}
|
||||
});
|
||||
|
||||
const handleLaunchProject = () => {
|
||||
if (selectedTemplate && startDate && projectName.trim()) {
|
||||
onCreateFromTemplate?.(selectedTemplate.id, startDate, projectName.trim());
|
||||
console.log('Launching project:', {
|
||||
template: selectedTemplate.name,
|
||||
name: projectName,
|
||||
startDate: startDate.toLocaleDateString()
|
||||
});
|
||||
const handleCreateProject = () => {
|
||||
if (newProjectName.trim()) {
|
||||
const newProject: Project = {
|
||||
id: Date.now().toString(),
|
||||
name: newProjectName.trim(),
|
||||
description: newProjectDescription.trim() || undefined,
|
||||
status: 'planning',
|
||||
tasks: [],
|
||||
createdAt: new Date()
|
||||
};
|
||||
|
||||
setProjectName('');
|
||||
setStartDate(undefined);
|
||||
setSelectedTemplate(null);
|
||||
setIsLaunchOpen(false);
|
||||
setProjects([...projects, newProject]);
|
||||
setNewProjectName('');
|
||||
setNewProjectDescription('');
|
||||
setIsCreateProjectOpen(false);
|
||||
console.log('Created new project:', newProject.name);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditProject = (project: Project) => {
|
||||
setSelectedProject(project);
|
||||
setIsEditProjectOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteProject = (projectId: string) => {
|
||||
if (confirm('Are you sure you want to delete this project?')) {
|
||||
setProjects(projects.filter(p => p.id !== projectId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleProjectStatusChange = (projectId: string, newStatus: Project['status']) => {
|
||||
setProjects(projects.map(project =>
|
||||
project.id === projectId ? { ...project, status: newStatus } : project
|
||||
));
|
||||
};
|
||||
|
||||
const handleDragStart = (projectId: string) => {
|
||||
setDraggedProject(projectId);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedProject(null);
|
||||
};
|
||||
|
||||
const handleDrop = (status: Project['status']) => {
|
||||
if (draggedProject) {
|
||||
handleProjectStatusChange(draggedProject, status);
|
||||
setDraggedProject(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// Label management functions
|
||||
const handleSaveLabel = () => {
|
||||
if (!labelName.trim()) return;
|
||||
@@ -166,234 +222,289 @@ export default function ProjectTemplate({
|
||||
}
|
||||
};
|
||||
|
||||
const formatDayOffset = (offset: number) => {
|
||||
if (offset === 0) return 'Launch day';
|
||||
if (offset < 0) return `${Math.abs(offset)} days before`;
|
||||
return `${offset} days after`;
|
||||
const getTotalEstimatedHours = (project: Project) => {
|
||||
return project.tasks.reduce((sum, task) => sum + (task.estimatedHours || 0), 0);
|
||||
};
|
||||
|
||||
const getTotalEstimatedHours = (template: ProjectTemplate) => {
|
||||
return template.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 getTemplatesByCategory = () => {
|
||||
const categories = Array.from(new Set(templates.map(t => t.category)));
|
||||
return categories.map(category => ({
|
||||
category,
|
||||
templates: templates.filter(t => t.category === category)
|
||||
}));
|
||||
const getProjectsByStatus = (status: Project['status']) => {
|
||||
return projects.filter(project => project.status === status);
|
||||
};
|
||||
|
||||
const projectColumns = [
|
||||
{ id: 'planning', title: 'Planning', status: 'planning' as const, color: 'bg-slate-100' },
|
||||
{ id: 'active', title: 'Active', status: 'active' as const, color: 'bg-blue-100' },
|
||||
{ id: 'finished', title: 'Finished', status: 'finished' as const, color: 'bg-green-100' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold" data-testid="text-templates-title">
|
||||
Settings & Templates
|
||||
<h2 className="text-lg font-semibold" data-testid="text-settings-title">
|
||||
Settings & Project Management
|
||||
</h2>
|
||||
<Button
|
||||
onClick={() => setIsCreateProjectOpen(true)}
|
||||
data-testid="button-create-project"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Project
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="templates" className="space-y-4">
|
||||
<Tabs defaultValue="projects" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="templates" data-testid="tab-templates">Templates</TabsTrigger>
|
||||
<TabsTrigger value="projects" data-testid="tab-projects">Projects</TabsTrigger>
|
||||
<TabsTrigger value="labels" data-testid="tab-labels">Labels</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="templates" className="space-y-6">
|
||||
{/* Templates Section Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-md font-medium">Project Templates</h3>
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" data-testid="button-create-template">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Template
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Template</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
Template creation form would go here
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Templates by Category */}
|
||||
{getTemplatesByCategory().map(({ category, templates: categoryTemplates }) => (
|
||||
<div key={category} className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{category}
|
||||
</h3>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{categoryTemplates.map((template) => (
|
||||
<Card key={template.id} className="p-4 hover-elevate active-elevate-2" data-testid={`template-${template.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-template-name-${template.id}`}>
|
||||
{template.name}
|
||||
</h4>
|
||||
{template.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{template.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<TabsContent value="projects" className="space-y-6">
|
||||
{/* Project Kanban Board */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{projectColumns.map((column) => {
|
||||
const columnProjects = getProjectsByStatus(column.status);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={column.id}
|
||||
className="p-4"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={() => handleDrop(column.status)}
|
||||
data-testid={`column-${column.id}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-sm" data-testid={`text-column-title-${column.id}`}>
|
||||
{column.title}
|
||||
</h3>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{template.tasks.length} tasks
|
||||
{columnProjects.length}
|
||||
</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(template)}h estimated</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<CalendarIcon className="w-3 h-3" />
|
||||
<span>{Math.abs(Math.min(...template.tasks.map(t => t.dayOffset)))} day timeline</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 min-h-[400px]">
|
||||
{columnProjects.map((project) => (
|
||||
<Card
|
||||
key={project.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(project.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`p-4 cursor-move hover-elevate transition-all ${
|
||||
draggedProject === project.id ? 'opacity-50 scale-95' : ''
|
||||
}`}
|
||||
data-testid={`project-card-${project.id}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-sm" data-testid={`text-project-name-${project.id}`}>
|
||||
{project.name}
|
||||
</h4>
|
||||
{project.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Sample tasks:</div>
|
||||
<div className="space-y-1">
|
||||
{template.tasks.slice(0, 3).map((task) => (
|
||||
<div key={task.id} className="flex items-center justify-between text-xs">
|
||||
<span className="truncate flex-1">{task.title}</span>
|
||||
<Badge variant="outline" className="text-xs ml-2 flex-shrink-0">
|
||||
{formatDayOffset(task.dayOffset)}
|
||||
</Badge>
|
||||
<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>
|
||||
))}
|
||||
{template.tasks.length > 3 && (
|
||||
<div className="text-xs text-muted-foreground text-center py-1">
|
||||
+{template.tasks.length - 3} more tasks
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
console.log('Copying template:', template.name);
|
||||
}}
|
||||
data-testid={`button-copy-${template.id}`}
|
||||
>
|
||||
<Copy className="w-3 h-3 mr-1" />
|
||||
Copy
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setSelectedTemplate(template);
|
||||
setProjectName(template.name);
|
||||
setIsLaunchOpen(true);
|
||||
}}
|
||||
data-testid={`button-use-${template.id}`}
|
||||
>
|
||||
<Play className="w-3 h-3 mr-1" />
|
||||
Use Template
|
||||
</Button>
|
||||
{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>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
{/* Launch Project Dialog */}
|
||||
<Dialog open={isLaunchOpen} onOpenChange={setIsLaunchOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Play className="w-4 h-4" />
|
||||
Launch Project from Template
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedTemplate && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-muted rounded-lg">
|
||||
<div className="text-sm font-medium">{selectedTemplate.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{selectedTemplate.tasks.length} tasks • {getTotalEstimatedHours(selectedTemplate)}h estimated
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* Create Project Dialog */}
|
||||
<Dialog open={isCreateProjectOpen} onOpenChange={setIsCreateProjectOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Project</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Project Name</label>
|
||||
<Input
|
||||
placeholder="Project name"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
placeholder="Enter project name"
|
||||
data-testid="input-project-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
data-testid="button-project-start-date"
|
||||
>
|
||||
<CalendarIcon className="w-4 h-4 mr-2" />
|
||||
{startDate ? startDate.toLocaleDateString() : 'Select start date'}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={startDate}
|
||||
onSelect={(date) => {
|
||||
setStartDate(date);
|
||||
setIsCalendarOpen(false);
|
||||
}}
|
||||
disabled={(date) => date < new Date()}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsLaunchOpen(false)}
|
||||
className="flex-1"
|
||||
data-testid="button-cancel-launch"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLaunchProject}
|
||||
disabled={!projectName.trim() || !startDate}
|
||||
className="flex-1"
|
||||
data-testid="button-launch-project"
|
||||
>
|
||||
Launch Project
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Description (Optional)</label>
|
||||
<Input
|
||||
value={newProjectDescription}
|
||||
onChange={(e) => setNewProjectDescription(e.target.value)}
|
||||
placeholder="Enter project description"
|
||||
data-testid="input-project-description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TabsContent>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCreateProjectOpen(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateProject}
|
||||
disabled={!newProjectName.trim()}
|
||||
className="flex-1"
|
||||
data-testid="button-save-project"
|
||||
>
|
||||
Create Project
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TabsContent value="labels" className="space-y-6">
|
||||
{/* Edit Project Dialog */}
|
||||
<Dialog open={isEditProjectOpen} onOpenChange={setIsEditProjectOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Project</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedProject && (
|
||||
<div className="space-y-4 py-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>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsEditProjectOpen(false);
|
||||
setSelectedProject(null);
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (selectedProject) {
|
||||
setProjects(projects.map(p =>
|
||||
p.id === selectedProject.id ? selectedProject : p
|
||||
));
|
||||
setIsEditProjectOpen(false);
|
||||
setSelectedProject(null);
|
||||
console.log('Updated project:', selectedProject.name);
|
||||
}
|
||||
}}
|
||||
disabled={!selectedProject?.name.trim()}
|
||||
className="flex-1"
|
||||
data-testid="button-update-project"
|
||||
>
|
||||
Update Project
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TabsContent value="labels" className="space-y-6">
|
||||
{/* Labels Section Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-md font-medium">Task Labels</h3>
|
||||
|
||||
@@ -5,8 +5,9 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Search, Filter, SortAsc, Calendar, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Task } from '@shared/schema';
|
||||
import { Task, Label } from '@shared/schema';
|
||||
import TaskCard from './TaskCard';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import TimeCompletionModal from './TimeCompletionModal';
|
||||
import { addDays, format, isSameDay, startOfToday } from 'date-fns';
|
||||
|
||||
@@ -31,6 +32,13 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit }: T
|
||||
task: null
|
||||
});
|
||||
|
||||
// Fetch labels to get label colors
|
||||
const { data: labels = [] } = useQuery<Label[]>({
|
||||
queryKey: ['/api/labels'],
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes cache
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Get 7 days starting from today for calendar
|
||||
const dates = Array.from({ length: 7 }, (_, i) => addDays(calendarStartDate, i));
|
||||
|
||||
@@ -371,25 +379,35 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit }: T
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{dayTasks.slice(0, 2).map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(task.id, e)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`cursor-move ${draggedTask === task.id ? 'opacity-50' : ''}`}
|
||||
data-testid={`calendar-task-${task.id}`}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="w-full justify-start text-xs p-1 h-auto text-left"
|
||||
{dayTasks.slice(0, 2).map((task) => {
|
||||
// Find the label for this task
|
||||
const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(task.id, e)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`cursor-move ${draggedTask === task.id ? 'opacity-50' : ''}`}
|
||||
data-testid={`calendar-task-${task.id}`}
|
||||
>
|
||||
<div className="truncate text-xs">
|
||||
{task.title}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="w-full justify-start text-xs p-1 h-auto text-left"
|
||||
style={taskLabel ? {
|
||||
borderColor: taskLabel.color,
|
||||
borderWidth: '2px',
|
||||
borderStyle: 'solid'
|
||||
} : {}}
|
||||
>
|
||||
<div className="truncate text-xs">
|
||||
{task.title}
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{dayTasks.length > 2 && (
|
||||
<Badge variant="secondary" className="w-full justify-center text-xs py-0">
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Reference in New Issue
Block a user