Add label system for tasks with custom colors and management options

Introduce label creation, editing, and deletion functionality to the task management system, enabling users to assign colored labels to tasks.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ceced2fc-aa46-458d-ba87-ddd4b7bb1518
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/659922a9-0087-461c-90dd-6d9a58b81d4d/ceced2fc-aa46-458d-ba87-ddd4b7bb1518/XrG8SoT
This commit is contained in:
paul-nothaft
2025-09-11 11:57:40 +00:00
parent 52f29fa101
commit 87cc2b3885
+246 -20
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -7,8 +7,12 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Plus, Calendar as CalendarIcon, Clock, Copy, Play } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Plus, Calendar as CalendarIcon, Clock, Copy, Play, Tag, Edit, Trash2, Palette } from 'lucide-react';
import { Task } from './TaskCard';
import { Label } from '@shared/schema';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiRequest } from '@/lib/queryClient';
interface TemplateTask {
id: string;
@@ -74,6 +78,53 @@ export default function ProjectTemplate({
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isLaunchOpen, setIsLaunchOpen] = useState(false);
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
// Label management state
const [isLabelDialogOpen, setIsLabelDialogOpen] = useState(false);
const [editingLabel, setEditingLabel] = useState<Label | null>(null);
const [labelName, setLabelName] = useState('');
const [labelColor, setLabelColor] = useState('#3B82F6');
const queryClient = useQueryClient();
// Fetch labels
const { data: labels = [], isLoading: labelsLoading } = useQuery({
queryKey: ['/api/labels'],
});
// Create label mutation
const createLabelMutation = useMutation({
mutationFn: (data: { name: string; color: string }) =>
apiRequest('POST', '/api/labels', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
setIsLabelDialogOpen(false);
setLabelName('');
setLabelColor('#3B82F6');
setEditingLabel(null);
}
});
// Update label mutation
const updateLabelMutation = useMutation({
mutationFn: ({ id, ...data }: { id: string; name?: string; color?: string }) =>
apiRequest('PATCH', `/api/labels/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
setIsLabelDialogOpen(false);
setLabelName('');
setLabelColor('#3B82F6');
setEditingLabel(null);
}
});
// Delete label mutation
const deleteLabelMutation = useMutation({
mutationFn: (id: string) => apiRequest('DELETE', `/api/labels/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
}
});
const handleLaunchProject = () => {
if (selectedTemplate && startDate && projectName.trim()) {
@@ -90,6 +141,30 @@ export default function ProjectTemplate({
setIsLaunchOpen(false);
}
};
// 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('Are you sure you want to delete this label?')) {
deleteLabelMutation.mutate(id);
}
};
const formatDayOffset = (offset: number) => {
if (offset === 0) return 'Launch day';
@@ -114,27 +189,38 @@ export default function ProjectTemplate({
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold" data-testid="text-templates-title">
Project Templates
Settings & Templates
</h2>
<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>
<Tabs defaultValue="templates" className="space-y-4">
<TabsList>
<TabsTrigger value="templates" data-testid="tab-templates">Templates</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">
@@ -305,6 +391,146 @@ export default function ProjectTemplate({
)}
</DialogContent>
</Dialog>
</TabsContent>
<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>
<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" />
Create Label
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingLabel ? 'Edit Label' : 'Create New Label'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Input
placeholder="Label name"
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"
>
Cancel
</Button>
<Button
onClick={handleSaveLabel}
disabled={!labelName.trim() || createLabelMutation.isPending || updateLabelMutation.isPending}
className="flex-1"
data-testid="button-save-label"
>
{editingLabel ? 'Update' : 'Create'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{/* Labels List */}
{labelsLoading ? (
<div className="flex items-center justify-center p-8">
<div className="text-muted-foreground">Loading labels...</div>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{labels.map((label: 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">No labels yet</h3>
<p className="text-sm text-muted-foreground mb-4">
Create your first label to organize your tasks by color and category.
</p>
<Button
variant="outline"
onClick={() => setIsLabelDialogOpen(true)}
data-testid="button-create-first-label"
>
<Plus className="w-4 h-4 mr-2" />
Create Label
</Button>
</div>
)}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}