Files
mock/components/ProductBacklog.tsx
Your Name bf1dbe39a8 Initial commit: Agile Project Manager
- Complete React + TypeScript application with Vite
- Dashboard with charts and metrics visualization
- Kanban board with drag-and-drop functionality
- Product backlog management with user stories
- Sprint planning and tracking features
- Comprehensive UI component library (shadcn/ui)
- Tailwind CSS styling with dark mode support
- Context-based state management
- Mock data for immediate testing and demonstration
2025-10-24 17:58:09 -07:00

361 lines
13 KiB
TypeScript

import React, { useState } from 'react';
import { useApp } from '../contexts/AppContext';
import { UserStory } from '../types';
import { Card } from './ui/card';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
import { Input } from './ui/input';
import { Textarea } from './ui/textarea';
import { Label } from './ui/label';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { GripVertical, CheckCircle2, Plus } from 'lucide-react';
interface DraggableStoryProps {
story: UserStory;
index: number;
moveStory: (fromIndex: number, toIndex: number) => void;
onEdit: (story: UserStory) => void;
}
const DraggableStory: React.FC<DraggableStoryProps> = ({ story, index, moveStory, onEdit }) => {
const [{ isDragging }, drag] = useDrag(() => ({
type: 'STORY',
item: { index },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}));
const [, drop] = useDrop(() => ({
accept: 'STORY',
hover: (item: { index: number }) => {
if (item.index !== index) {
moveStory(item.index, index);
item.index = index;
}
},
}));
const getStatusColor = (status: UserStory['status']) => {
switch (status) {
case 'sprint-ready':
return 'bg-green-500';
case 'in-sprint':
return 'bg-blue-500';
default:
return 'bg-gray-400';
}
};
return (
<div ref={(node) => drag(drop(node))} style={{ opacity: isDragging ? 0.5 : 1 }}>
<Card className="p-4 cursor-move hover:shadow-md transition-shadow">
<div className="flex items-start gap-3">
<GripVertical className="h-5 w-5 text-muted-foreground mt-1 shrink-0" />
<div className="flex-1 space-y-2">
<div className="flex items-start justify-between gap-2">
<h3 className="flex-1">{story.title}</h3>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline">{story.storyPoints} pts</Badge>
<Badge variant="outline">Value: {story.businessValue}</Badge>
<div className={`w-3 h-3 rounded-full ${getStatusColor(story.status)}`} />
</div>
</div>
<p className="text-sm text-muted-foreground">{story.description}</p>
{story.acceptanceCriteria && (
<div className="text-sm">
<span className="font-medium">Acceptance Criteria: </span>
<span className="text-muted-foreground">{story.acceptanceCriteria}</span>
</div>
)}
<div className="flex items-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={() => onEdit(story)}>
Edit
</Button>
{story.status === 'backlog' && (
<Button
variant="default"
size="sm"
onClick={() => onEdit(story)}
>
<CheckCircle2 className="h-4 w-4 mr-1" />
Mark Sprint Ready
</Button>
)}
</div>
</div>
</div>
</Card>
</div>
);
};
export const ProductBacklog: React.FC = () => {
const { userStories, reorderUserStories, updateUserStory, addUserStory } = useApp();
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [selectedStory, setSelectedStory] = useState<UserStory | null>(null);
const [formData, setFormData] = useState({
title: '',
description: '',
storyPoints: 0,
businessValue: 0,
acceptanceCriteria: ''
});
const backlogStories = userStories
.filter(story => story.status !== 'in-sprint')
.sort((a, b) => a.priority - b.priority);
const moveStory = (fromIndex: number, toIndex: number) => {
const stories = [...backlogStories];
const [movedStory] = stories.splice(fromIndex, 1);
stories.splice(toIndex, 0, movedStory);
const updatedStories = stories.map((story, index) => ({
...story,
priority: index + 1
}));
const otherStories = userStories.filter(story => story.status === 'in-sprint');
reorderUserStories([...updatedStories, ...otherStories]);
};
const handleEdit = (story: UserStory) => {
setSelectedStory(story);
setFormData({
title: story.title,
description: story.description,
storyPoints: story.storyPoints,
businessValue: story.businessValue,
acceptanceCriteria: story.acceptanceCriteria
});
setEditDialogOpen(true);
};
const handleSave = () => {
if (selectedStory) {
updateUserStory(selectedStory.id, formData);
setEditDialogOpen(false);
setSelectedStory(null);
}
};
const handleCreate = () => {
addUserStory({
...formData,
status: 'backlog',
priority: userStories.length + 1
});
setCreateDialogOpen(false);
setFormData({
title: '',
description: '',
storyPoints: 0,
businessValue: 0,
acceptanceCriteria: ''
});
};
const handleMarkSprintReady = () => {
if (selectedStory) {
updateUserStory(selectedStory.id, { status: 'sprint-ready' });
setEditDialogOpen(false);
setSelectedStory(null);
}
};
return (
<DndProvider backend={HTML5Backend}>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1>Product Backlog</h1>
<p className="text-muted-foreground">
Drag and drop to prioritize backlog items
</p>
</div>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
New User Story
</Button>
</div>
<div className="flex gap-4 mb-4">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-gray-400" />
<span className="text-sm">Backlog</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-sm">Sprint Ready</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500" />
<span className="text-sm">In Sprint</span>
</div>
</div>
<div className="space-y-3">
{backlogStories.map((story, index) => (
<DraggableStory
key={story.id}
story={story}
index={index}
moveStory={moveStory}
onEdit={handleEdit}
/>
))}
</div>
{backlogStories.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
No items in backlog. Create a new user story to get started.
</div>
)}
{/* Edit Dialog */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Edit User Story</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-title">Title</Label>
<Input
id="edit-title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<Textarea
id="edit-description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-points">Story Points</Label>
<Input
id="edit-points"
type="number"
value={formData.storyPoints}
onChange={(e) => setFormData({ ...formData, storyPoints: parseInt(e.target.value) || 0 })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-value">Business Value</Label>
<Input
id="edit-value"
type="number"
value={formData.businessValue}
onChange={(e) => setFormData({ ...formData, businessValue: parseInt(e.target.value) || 0 })}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="edit-criteria">Acceptance Criteria</Label>
<Textarea
id="edit-criteria"
value={formData.acceptanceCriteria}
onChange={(e) => setFormData({ ...formData, acceptanceCriteria: e.target.value })}
rows={3}
/>
</div>
</div>
<div className="flex justify-between">
{selectedStory?.status === 'backlog' && (
<Button onClick={handleMarkSprintReady}>
<CheckCircle2 className="h-4 w-4 mr-2" />
Mark Sprint Ready
</Button>
)}
<div className="flex gap-2 ml-auto">
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Create Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Create User Story</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="create-title">Title</Label>
<Input
id="create-title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="As a user, I want to..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-description">Description</Label>
<Textarea
id="create-description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
placeholder="Detailed description of the user story"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="create-points">Story Points</Label>
<Input
id="create-points"
type="number"
value={formData.storyPoints || ''}
onChange={(e) => setFormData({ ...formData, storyPoints: parseInt(e.target.value) || 0 })}
placeholder="1-13"
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-value">Business Value</Label>
<Input
id="create-value"
type="number"
value={formData.businessValue || ''}
onChange={(e) => setFormData({ ...formData, businessValue: parseInt(e.target.value) || 0 })}
placeholder="1-10"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="create-criteria">Acceptance Criteria</Label>
<Textarea
id="create-criteria"
value={formData.acceptanceCriteria}
onChange={(e) => setFormData({ ...formData, acceptanceCriteria: e.target.value })}
rows={3}
placeholder="Define what 'done' means for this story"
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreate}>Create</Button>
</div>
</DialogContent>
</Dialog>
</div>
</DndProvider>
);
};