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:
paul-nothaft
2025-09-11 14:15:58 +00:00
parent 36453140f3
commit d5688f90b0
9 changed files with 606 additions and 267 deletions
+4
View File
@@ -18,6 +18,10 @@ externalPort = 80
localPort = 35345 localPort = 35345
externalPort = 3002 externalPort = 3002
[[ports]]
localPort = 40293
externalPort = 3001
[[ports]] [[ports]]
localPort = 41353 localPort = 41353
externalPort = 3000 externalPort = 3000
+4 -4
View File
@@ -148,7 +148,7 @@ function App() {
/> />
); );
case 'templates': case 'settings':
return ( return (
<ProjectTemplate <ProjectTemplate
onCreateFromTemplate={handleCreateFromTemplate} onCreateFromTemplate={handleCreateFromTemplate}
@@ -212,7 +212,7 @@ function App() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{currentTab === 'templates' && ( {currentTab === 'settings' && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -236,8 +236,8 @@ function App() {
<BottomNavigation <BottomNavigation
activeTab={currentTab} activeTab={currentTab}
onTabChange={(tab) => { onTabChange={(tab) => {
if (tab === 'templates') { if (tab === 'settings') {
setCurrentTab('templates'); setCurrentTab('settings');
} else { } else {
setCurrentTab(tab); setCurrentTab(tab);
} }
+1 -1
View File
@@ -17,7 +17,7 @@ export default function BottomNavigation({ onTabChange, onCreateTask, activeTab
{ id: 'calendar', label: 'Calendar', icon: Calendar }, { id: 'calendar', label: 'Calendar', icon: Calendar },
{ id: 'create', label: 'Create', icon: Plus, isCreate: true }, { id: 'create', label: 'Create', icon: Plus, isCreate: true },
{ id: 'kanban', label: 'Board', icon: LayoutGrid }, { id: 'kanban', label: 'Board', icon: LayoutGrid },
{ id: 'templates', label: 'Templates', icon: Settings } { id: 'settings', label: 'Settings', icon: Settings }
]; ];
const handleTabClick = (tabId: string, isCreate?: boolean) => { const handleTabClick = (tabId: string, isCreate?: boolean) => {
+209 -3
View File
@@ -4,9 +4,10 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { LayoutGrid, Calendar as CalendarIcon, ChevronLeft, ChevronRight } from 'lucide-react'; 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 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 { interface KanbanBoardProps {
tasks: Task[]; tasks: Task[];
@@ -21,6 +22,13 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate }:
const [currentDate, setCurrentDate] = useState(new Date()); const [currentDate, setCurrentDate] = useState(new Date());
const [draggedTask, setDraggedTask] = useState<string | null>(null); 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 = [ const columns = [
{ id: 'todo', title: 'To Do', status: 'todo' as const }, { id: 'todo', title: 'To Do', status: 'todo' as const },
{ id: 'inProgress', title: 'In Progress', status: 'inProgress' 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'; 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Header */} {/* Header */}
@@ -141,7 +339,7 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate }:
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value={viewMode} className="mt-4"> <TabsContent value="traditional" className="mt-4">
{/* Kanban Columns */} {/* Kanban Columns */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{columns.map((column) => { {columns.map((column) => {
@@ -202,6 +400,14 @@ export default function KanbanBoard({ tasks, onTaskStatusChange, onTaskUpdate }:
})} })}
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="weekly" className="mt-4">
{renderWeeklyCalendar()}
</TabsContent>
<TabsContent value="monthly" className="mt-4">
{renderMonthlyCalendar()}
</TabsContent>
</Tabs> </Tabs>
</div> </div>
); );
+351 -240
View File
@@ -14,70 +14,87 @@ import { Label } from '@shared/schema';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiRequest } from '@/lib/queryClient'; import { apiRequest } from '@/lib/queryClient';
interface TemplateTask { interface ProjectTask {
id: string; id: string;
title: string; title: string;
description?: string; description?: string;
priority: 'low' | 'medium' | 'high'; priority: 'low' | 'medium' | 'high';
dayOffset: number; // Days relative to project start date (negative for before, positive for after) status: 'todo' | 'inProgress' | 'done';
estimatedHours?: number; estimatedHours?: number;
labelId?: string;
} }
interface ProjectTemplate { interface Project {
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
tasks: TemplateTask[]; status: 'planning' | 'active' | 'finished';
category: string; tasks: ProjectTask[];
startDate?: Date;
endDate?: Date;
createdAt: Date;
} }
interface ProjectTemplateProps { interface ProjectTemplateProps {
templates?: ProjectTemplate[];
onCreateFromTemplate?: (templateId: string, startDate: Date, projectName: string) => void; 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', id: '1',
name: 'Website Launch', name: 'Website Redesign',
description: 'Complete website development and launch process', description: 'Complete redesign of company website',
category: 'Development', status: 'active',
startDate: new Date(),
createdAt: new Date(),
tasks: [ tasks: [
{ id: '1', title: 'Project planning', priority: 'high', dayOffset: -14, estimatedHours: 4 }, { id: '1', title: 'Research competitors', priority: 'high', status: 'done', estimatedHours: 4 },
{ id: '2', title: 'Design mockups', priority: 'high', dayOffset: -10, estimatedHours: 16 }, { id: '2', title: 'Create wireframes', priority: 'high', status: 'inProgress', estimatedHours: 8 },
{ id: '3', title: 'Frontend development', priority: 'high', dayOffset: -7, estimatedHours: 40 }, { id: '3', title: 'Design mockups', priority: 'medium', status: 'todo', estimatedHours: 12 },
{ id: '4', title: 'Backend API', priority: 'medium', dayOffset: -5, estimatedHours: 24 }, { id: '4', title: 'Frontend development', priority: 'high', status: 'todo', estimatedHours: 40 }
{ 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: 'product-launch', id: '2',
name: 'Product Launch', name: 'Mobile App Launch',
description: 'Marketing and launch campaign for new product', description: 'Launch new mobile application',
category: 'Marketing', status: 'planning',
createdAt: new Date(),
tasks: [ tasks: [
{ id: '1', title: 'Market research', priority: 'high', dayOffset: -21, estimatedHours: 12 }, { id: '1', title: 'Define requirements', priority: 'high', status: 'todo', estimatedHours: 6 },
{ id: '2', title: 'Create marketing materials', priority: 'medium', dayOffset: -14, estimatedHours: 20 }, { id: '2', title: 'Create user stories', priority: 'medium', status: 'todo', estimatedHours: 8 },
{ id: '3', title: 'Press release', priority: 'medium', dayOffset: -7, estimatedHours: 4 }, { id: '3', title: 'Design UI/UX', priority: 'high', status: 'todo', estimatedHours: 20 }
{ id: '4', title: 'Social media campaign', priority: 'high', dayOffset: -3, estimatedHours: 8 }, ]
{ id: '5', title: 'Launch event', priority: 'high', dayOffset: 0, estimatedHours: 6 } },
{
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({ export default function ProjectTemplate({
templates = defaultTemplates,
onCreateFromTemplate, onCreateFromTemplate,
onCreateTemplate onCreateTemplate
}: ProjectTemplateProps) { }: ProjectTemplateProps) {
const [selectedTemplate, setSelectedTemplate] = useState<ProjectTemplate | null>(null); const [projects, setProjects] = useState<Project[]>(defaultProjects);
const [projectName, setProjectName] = useState(''); const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [startDate, setStartDate] = useState<Date | undefined>(); const [draggedProject, setDraggedProject] = useState<string | null>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateProjectOpen, setIsCreateProjectOpen] = useState(false);
const [isLaunchOpen, setIsLaunchOpen] = useState(false); const [isEditProjectOpen, setIsEditProjectOpen] = useState(false);
const [isCalendarOpen, setIsCalendarOpen] = useState(false); const [newProjectName, setNewProjectName] = useState('');
const [newProjectDescription, setNewProjectDescription] = useState('');
// Label management state // Label management state
const [isLabelDialogOpen, setIsLabelDialogOpen] = useState(false); const [isLabelDialogOpen, setIsLabelDialogOpen] = useState(false);
@@ -126,21 +143,60 @@ export default function ProjectTemplate({
} }
}); });
const handleLaunchProject = () => { const handleCreateProject = () => {
if (selectedTemplate && startDate && projectName.trim()) { if (newProjectName.trim()) {
onCreateFromTemplate?.(selectedTemplate.id, startDate, projectName.trim()); const newProject: Project = {
console.log('Launching project:', { id: Date.now().toString(),
template: selectedTemplate.name, name: newProjectName.trim(),
name: projectName, description: newProjectDescription.trim() || undefined,
startDate: startDate.toLocaleDateString() status: 'planning',
}); tasks: [],
createdAt: new Date()
};
setProjectName(''); setProjects([...projects, newProject]);
setStartDate(undefined); setNewProjectName('');
setSelectedTemplate(null); setNewProjectDescription('');
setIsLaunchOpen(false); 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 // Label management functions
const handleSaveLabel = () => { const handleSaveLabel = () => {
@@ -166,234 +222,289 @@ export default function ProjectTemplate({
} }
}; };
const formatDayOffset = (offset: number) => { const getTotalEstimatedHours = (project: Project) => {
if (offset === 0) return 'Launch day'; return project.tasks.reduce((sum, task) => sum + (task.estimatedHours || 0), 0);
if (offset < 0) return `${Math.abs(offset)} days before`;
return `${offset} days after`;
}; };
const getTotalEstimatedHours = (template: ProjectTemplate) => { const getTaskCompletionRate = (project: Project) => {
return template.tasks.reduce((sum, task) => sum + (task.estimatedHours || 0), 0); 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 getProjectsByStatus = (status: Project['status']) => {
const categories = Array.from(new Set(templates.map(t => t.category))); return projects.filter(project => project.status === status);
return categories.map(category => ({
category,
templates: templates.filter(t => t.category === category)
}));
}; };
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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold" data-testid="text-templates-title"> <h2 className="text-lg font-semibold" data-testid="text-settings-title">
Settings & Templates Settings & Project Management
</h2> </h2>
<Button
onClick={() => setIsCreateProjectOpen(true)}
data-testid="button-create-project"
>
<Plus className="w-4 h-4 mr-2" />
New Project
</Button>
</div> </div>
<Tabs defaultValue="templates" className="space-y-4"> <Tabs defaultValue="projects" className="space-y-4">
<TabsList> <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> <TabsTrigger value="labels" data-testid="tab-labels">Labels</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="templates" className="space-y-6"> <TabsContent value="projects" className="space-y-6">
{/* Templates Section Header */} {/* Project Kanban Board */}
<div className="flex items-center justify-between"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<h3 className="text-md font-medium">Project Templates</h3> {projectColumns.map((column) => {
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}> const columnProjects = getProjectsByStatus(column.status);
<DialogTrigger asChild>
<Button variant="outline" size="sm" data-testid="button-create-template"> return (
<Plus className="w-4 h-4 mr-2" /> <Card
Create Template key={column.id}
</Button> className="p-4"
</DialogTrigger> onDragOver={handleDragOver}
<DialogContent> onDrop={() => handleDrop(column.status)}
<DialogHeader> data-testid={`column-${column.id}`}
<DialogTitle>Create New Template</DialogTitle> >
</DialogHeader> <div className="flex items-center justify-between mb-4">
<div className="p-4 text-center text-muted-foreground"> <h3 className="font-semibold text-sm" data-testid={`text-column-title-${column.id}`}>
Template creation form would go here {column.title}
</div> </h3>
</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>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{template.tasks.length} tasks {columnProjects.length}
</Badge> </Badge>
</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="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-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
<span>{getTotalEstimatedHours(template)}h estimated</span> <span>{getTotalEstimatedHours(project)}h</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<CalendarIcon className="w-3 h-3" /> <Badge variant="outline" className="text-xs">
<span>{Math.abs(Math.min(...template.tasks.map(t => t.dayOffset)))} day timeline</span> {project.tasks.length} tasks
</div> </Badge>
</div> </div>
</div>
<div className="space-y-2"> {project.tasks.length > 0 && (
<div className="text-xs font-medium text-muted-foreground">Sample tasks:</div> <div className="space-y-2">
<div className="space-y-1"> <div className="flex items-center justify-between text-xs">
{template.tasks.slice(0, 3).map((task) => ( <span className="text-muted-foreground">Progress</span>
<div key={task.id} className="flex items-center justify-between text-xs"> <span className="font-medium">{getTaskCompletionRate(project)}%</span>
<span className="truncate flex-1">{task.title}</span> </div>
<Badge variant="outline" className="text-xs ml-2 flex-shrink-0"> <div className="w-full bg-muted rounded-full h-2">
{formatDayOffset(task.dayOffset)} <div
</Badge> 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> </div>
))} </Card>
{template.tasks.length > 3 && ( ))}
<div className="text-xs text-muted-foreground text-center py-1">
+{template.tasks.length - 3} more tasks
</div>
)}
</div>
</div>
<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 {columnProjects.length === 0 && (
size="sm" <div className="text-center text-muted-foreground text-sm py-8 border-2 border-dashed border-muted rounded-lg">
className="flex-1" No projects in {column.title.toLowerCase()}
onClick={() => { </div>
setSelectedTemplate(template); )}
setProjectName(template.name);
setIsLaunchOpen(true);
}}
data-testid={`button-use-${template.id}`}
>
<Play className="w-3 h-3 mr-1" />
Use Template
</Button>
</div> </div>
</div> </Card>
</Card> );
))} })}
</div> </div>
</div> </TabsContent>
))}
{/* Launch Project Dialog */} {/* Create Project Dialog */}
<Dialog open={isLaunchOpen} onOpenChange={setIsLaunchOpen}> <Dialog open={isCreateProjectOpen} onOpenChange={setIsCreateProjectOpen}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle>Create New Project</DialogTitle>
<Play className="w-4 h-4" /> </DialogHeader>
Launch Project from Template <div className="space-y-4 py-4">
</DialogTitle> <div className="space-y-2">
</DialogHeader> <label className="text-sm font-medium">Project Name</label>
{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>
<Input <Input
placeholder="Project name" value={newProjectName}
value={projectName} onChange={(e) => setNewProjectName(e.target.value)}
onChange={(e) => setProjectName(e.target.value)} placeholder="Enter project name"
data-testid="input-project-name" data-testid="input-project-name"
/> />
</div> </div>
<div className="space-y-2">
<div> <label className="text-sm font-medium">Description (Optional)</label>
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}> <Input
<PopoverTrigger asChild> value={newProjectDescription}
<Button onChange={(e) => setNewProjectDescription(e.target.value)}
variant="outline" placeholder="Enter project description"
className="w-full justify-start" data-testid="input-project-description"
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> </div>
</div> </div>
)} <div className="flex gap-2">
</DialogContent> <Button
</Dialog> variant="outline"
</TabsContent> 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 */} {/* Labels Section Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-md font-medium">Task Labels</h3> <h3 className="text-md font-medium">Task Labels</h3>
+37 -19
View File
@@ -5,8 +5,9 @@ import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search, Filter, SortAsc, Calendar, ChevronLeft, ChevronRight } from 'lucide-react'; 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 TaskCard from './TaskCard';
import { useQuery } from '@tanstack/react-query';
import TimeCompletionModal from './TimeCompletionModal'; import TimeCompletionModal from './TimeCompletionModal';
import { addDays, format, isSameDay, startOfToday } from 'date-fns'; import { addDays, format, isSameDay, startOfToday } from 'date-fns';
@@ -31,6 +32,13 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit }: T
task: null 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 // Get 7 days starting from today for calendar
const dates = Array.from({ length: 7 }, (_, i) => addDays(calendarStartDate, i)); const dates = Array.from({ length: 7 }, (_, i) => addDays(calendarStartDate, i));
@@ -371,25 +379,35 @@ export default function TasksWithCalendar({ tasks, onTaskUpdate, onTaskEdit }: T
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{dayTasks.slice(0, 2).map((task) => ( {dayTasks.slice(0, 2).map((task) => {
<div // Find the label for this task
key={task.id} const taskLabel = task.labelId && labels.length > 0 ? labels.find(label => label.id === task.labelId) : null;
draggable
onDragStart={(e) => handleDragStart(task.id, e)} return (
onDragEnd={handleDragEnd} <div
className={`cursor-move ${draggedTask === task.id ? 'opacity-50' : ''}`} key={task.id}
data-testid={`calendar-task-${task.id}`} draggable
> onDragStart={(e) => handleDragStart(task.id, e)}
<Badge onDragEnd={handleDragEnd}
variant="outline" className={`cursor-move ${draggedTask === task.id ? 'opacity-50' : ''}`}
className="w-full justify-start text-xs p-1 h-auto text-left" data-testid={`calendar-task-${task.id}`}
> >
<div className="truncate text-xs"> <Badge
{task.title} variant="outline"
</div> className="w-full justify-start text-xs p-1 h-auto text-left"
</Badge> style={taskLabel ? {
</div> borderColor: taskLabel.color,
))} borderWidth: '2px',
borderStyle: 'solid'
} : {}}
>
<div className="truncate text-xs">
{task.title}
</div>
</Badge>
</div>
);
})}
{dayTasks.length > 2 && ( {dayTasks.length > 2 && (
<Badge variant="secondary" className="w-full justify-center text-xs py-0"> <Badge variant="secondary" className="w-full justify-center text-xs py-0">
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB