BacklogListView.tsxā¢13.6 kB
import { useEffect, useState } from 'react';
import { Link, useParams, useNavigate } from 'react-router-dom';
import { Plus, Edit, Trash, ChevronDown, ChevronUp, Calendar, PlayCircle } from 'lucide-react';
import { api } from '../utils/api';
import type { Epic, Story, EntityStatus, Priority, Sprint } from '../types';
import EpicFormModal from './EpicFormModal';
import StoryFormModal from './StoryFormModal';
import RelationshipManager from './RelationshipManager';
import NotesPanel from './NotesPanel';
import SprintFormModal from './SprintFormModal';
const statusColors: Record<EntityStatus, string> = {
todo: 'bg-gray-200 text-gray-700',
in_progress: 'bg-blue-100 text-blue-700',
review: 'bg-yellow-100 text-yellow-700',
done: 'bg-green-100 text-green-700',
blocked: 'bg-red-100 text-red-700',
};
const priorityColors: Record<Priority, string> = {
low: 'text-gray-500',
medium: 'text-blue-600',
high: 'text-orange-600',
critical: 'text-red-600 font-bold',
};
interface BacklogListViewProps {
projectId?: number | null;
}
export default function BacklogListView({ projectId: projectIdProp }: BacklogListViewProps) {
const { projectId: projectIdParam } = useParams<{ projectId: string }>();
const projectId = projectIdParam ? parseInt(projectIdParam) : projectIdProp;
const navigate = useNavigate();
const [epics, setEpics] = useState<Epic[]>([]);
const [stories, setStories] = useState<Story[]>([]);
const [sprints, setSprints] = useState<Sprint[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({
epic_id: '',
status: '',
priority: '',
sprint_id: '',
});
const [epicModalOpen, setEpicModalOpen] = useState(false);
const [storyModalOpen, setStoryModalOpen] = useState(false);
const [sprintModalOpen, setSprintModalOpen] = useState(false);
const [editingEpic, setEditingEpic] = useState<Epic | null>(null);
const [editingStory, setEditingStory] = useState<Story | null>(null);
const [expandedStoryId, setExpandedStoryId] = useState<number | null>(null);
useEffect(() => {
loadData();
}, [filters, projectId]);
async function loadData() {
try {
setLoading(true);
const filterParams: any = Object.fromEntries(
Object.entries(filters).filter(([_, v]) => v !== '')
);
if (projectId) {
filterParams.project_id = projectId;
}
const [epicsData, storiesData, sprintsData] = await Promise.all([
api.epics.list(projectId ? { project_id: projectId } : {}),
api.stories.list(filterParams),
projectId ? api.sprints.list({ project_id: projectId }) : Promise.resolve([]),
]);
setEpics(epicsData);
setStories(storiesData);
setSprints(sprintsData);
} catch (error) {
console.error('Failed to load data:', error);
} finally {
setLoading(false);
}
}
const handleDeleteStory = async (id: number) => {
if (!confirm('Are you sure you want to delete this story?')) return;
try {
await api.stories.delete(id);
loadData();
} catch (error) {
console.error('Failed to delete story:', error);
}
};
const handleEditStory = (story: Story) => {
setEditingStory(story);
setStoryModalOpen(true);
};
const handleNewEpic = () => {
setEditingEpic(null);
setEpicModalOpen(true);
};
const handleNewStory = () => {
setEditingStory(null);
setStoryModalOpen(true);
};
const handleNewSprint = () => {
setSprintModalOpen(true);
};
const handleViewActiveSprint = () => {
const activeSprint = sprints.find(s => s.status === 'active');
if (activeSprint && projectId) {
navigate(`/project/${projectId}/sprint/${activeSprint.id}`);
}
};
const getEpicName = (epicId: number | null) => {
if (!epicId) return 'No Epic';
return epics.find(e => e.id === epicId)?.title || `Epic #${epicId}`;
};
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">Loading...</div>
</div>
);
}
if (!projectId) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">Please select a project to view backlog</div>
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="bg-white shadow-sm border-b px-4 md:px-6 py-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<h2 className="text-xl md:text-2xl font-bold text-gray-800">Backlog</h2>
<p className="text-sm text-gray-500 mt-1">
{stories.length} stories across {epics.length} epics ⢠{sprints.length} sprints
</p>
</div>
<div className="flex flex-wrap gap-2 w-full sm:w-auto">
<button
onClick={handleNewEpic}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm"
>
<Plus size={16} />
<span className="hidden sm:inline">New</span> Epic
</button>
<button
onClick={handleNewStory}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm"
>
<Plus size={16} />
<span className="hidden sm:inline">New</span> Story
</button>
<button
onClick={handleNewSprint}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm"
>
<Calendar size={16} />
<span className="hidden sm:inline">New</span> Sprint
</button>
{sprints.some(s => s.status === 'active') && (
<button
onClick={handleViewActiveSprint}
className="flex items-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 text-sm"
>
<PlayCircle size={16} />
Active Sprint
</button>
)}
</div>
</div>
</div>
{/* Filters */}
<div className="bg-white border-b px-4 md:px-6 py-3 flex flex-wrap gap-2 md:gap-4">
<select
value={filters.sprint_id}
onChange={(e) => setFilters({ ...filters, sprint_id: e.target.value })}
className="px-3 py-2 border rounded-lg text-sm"
>
<option value="">All Stories</option>
<option value="backlog">Backlog (No Sprint)</option>
{sprints.map(sprint => (
<option key={sprint.id} value={sprint.id}>
{sprint.name} ({sprint.status})
</option>
))}
</select>
<select
value={filters.epic_id}
onChange={(e) => setFilters({ ...filters, epic_id: e.target.value })}
className="px-3 py-2 border rounded-lg text-sm"
>
<option value="">All Epics</option>
{epics.map(epic => (
<option key={epic.id} value={epic.id}>{epic.title}</option>
))}
</select>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-3 py-2 border rounded-lg text-sm"
>
<option value="">All Statuses</option>
<option value="todo">Todo</option>
<option value="in_progress">In Progress</option>
<option value="review">Review</option>
<option value="done">Done</option>
<option value="blocked">Blocked</option>
</select>
<select
value={filters.priority}
onChange={(e) => setFilters({ ...filters, priority: e.target.value })}
className="px-3 py-2 border rounded-lg text-sm"
>
<option value="">All Priorities</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
<button
onClick={() => setFilters({ epic_id: '', status: '', priority: '', sprint_id: '' })}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
>
Clear Filters
</button>
</div>
{/* Story List */}
<div className="flex-1 overflow-auto p-4 md:p-6">
<div className="max-w-6xl mx-auto space-y-3">
{stories.length === 0 ? (
<div className="text-center py-12 text-gray-500">
No stories found
</div>
) : (
stories.map(story => (
<div
key={story.id}
className="bg-white rounded-lg border p-3 md:p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2 mb-2">
<Link
to={projectId ? `/project/${projectId}/story/${story.id}` : '#'}
className="text-base md:text-lg font-semibold text-gray-800 hover:text-blue-600 break-words"
>
{story.title}
</Link>
<span className={`px-2 py-1 rounded text-xs whitespace-nowrap ${statusColors[story.status]}`}>
{story.status.replace('_', ' ')}
</span>
<span className={`text-xs md:text-sm whitespace-nowrap ${priorityColors[story.priority]}`}>
{story.priority.toUpperCase()}
</span>
</div>
<p className="text-sm text-gray-600 mb-3 break-words">{story.description}</p>
{story.acceptance_criteria && (
<div className="mb-3 p-3 bg-gray-50 rounded border-l-4 border-green-500">
<div className="text-xs font-semibold text-gray-700 mb-1">Acceptance Criteria:</div>
<p className="text-sm text-gray-700 whitespace-pre-wrap">{story.acceptance_criteria}</p>
</div>
)}
<div className="flex flex-wrap items-center gap-2 md:gap-4 text-xs text-gray-500">
<span>#{story.id}</span>
<span className="break-all">{getEpicName(story.epic_id)}</span>
{story.points && <span>{story.points} points</span>}
</div>
</div>
<div className="flex flex-col sm:flex-row gap-1 sm:gap-2 shrink-0">
<button
onClick={() => handleEditStory(story)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded"
title="Edit story"
>
<Edit size={16} />
</button>
<button
onClick={() => handleDeleteStory(story.id)}
className="p-2 text-red-600 hover:bg-red-50 rounded"
title="Delete story"
>
<Trash size={16} />
</button>
</div>
</div>
{/* Expand/Collapse Button */}
<button
onClick={() => setExpandedStoryId(expandedStoryId === story.id ? null : story.id)}
className="w-full mt-3 py-2 text-sm text-gray-600 hover:bg-gray-50 rounded border border-gray-200 flex items-center justify-center gap-2"
>
{expandedStoryId === story.id ? (
<>
<ChevronUp size={16} />
Hide Details
</>
) : (
<>
<ChevronDown size={16} />
View Details
</>
)}
</button>
{/* Expandable Details Section */}
{expandedStoryId === story.id && (
<div className="mt-4 space-y-4 border-t pt-4">
<RelationshipManager
entityType="story"
entityId={story.id}
projectId={projectId}
/>
<NotesPanel
entityType="story"
entityId={story.id}
projectId={projectId}
/>
</div>
)}
</div>
))
)}
</div>
</div>
{/* Modals */}
<EpicFormModal
isOpen={epicModalOpen}
onClose={() => setEpicModalOpen(false)}
onSave={loadData}
epic={editingEpic}
projectId={projectId}
/>
<StoryFormModal
isOpen={storyModalOpen}
onClose={() => setStoryModalOpen(false)}
onSave={loadData}
story={editingStory}
projectId={projectId}
/>
{projectId && (
<SprintFormModal
isOpen={sprintModalOpen}
onClose={() => setSprintModalOpen(false)}
onSuccess={loadData}
projectId={projectId}
/>
)}
</div>
);
}