Files
ColaFlow-Web/app/(dashboard)/projects/[id]/epics/page.tsx
Yaojia Wang 04ba00d108 fix(frontend): Align Epic field names with backend API
Fix frontend-backend API field mismatches for Epic entity by:
1. Changed Epic.title to Epic.name in type definitions
2. Added Epic.createdBy field (required by backend)
3. Updated all Epic references from epic.title to epic.name
4. Fixed Epic form to use name field and include createdBy

Files modified:
- types/project.ts: Updated Epic, CreateEpicDto, UpdateEpicDto interfaces
- components/epics/epic-form.tsx: Fixed defaultValues to use epic.name
- components/projects/hierarchy-tree.tsx: Replaced epic.title with epic.name
- components/projects/story-form.tsx: Fixed epic dropdown to show epic.name
- app/(dashboard)/projects/[id]/epics/page.tsx: Display epic.name in list
- app/(dashboard)/projects/[id]/page.tsx: Display epic.name in preview
- app/(dashboard)/api-test/page.tsx: Display epic.name in test page

This resolves the 400 Bad Request error when creating Epics caused by
missing 'Name' field (was sending 'title' instead) and missing 'CreatedBy' field.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-05 13:30:48 +01:00

367 lines
12 KiB
TypeScript

'use client';
import { use, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
ArrowLeft,
Plus,
Edit,
Trash2,
Loader2,
ListTodo,
Calendar,
Clock,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useProject } from '@/lib/hooks/use-projects';
import { useEpics, useDeleteEpic } from '@/lib/hooks/use-epics';
import { EpicForm } from '@/components/epics/epic-form';
import { formatDistanceToNow } from 'date-fns';
import { toast } from 'sonner';
import type { Epic, WorkItemStatus, WorkItemPriority } from '@/types/project';
interface EpicsPageProps {
params: Promise<{ id: string }>;
}
export default function EpicsPage({ params }: EpicsPageProps) {
const { id: projectId } = use(params);
const router = useRouter();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingEpic, setEditingEpic] = useState<Epic | null>(null);
const [deletingEpicId, setDeletingEpicId] = useState<string | null>(null);
const { data: project, isLoading: projectLoading } = useProject(projectId);
const { data: epics, isLoading: epicsLoading, error } = useEpics(projectId);
const deleteEpic = useDeleteEpic();
const handleDelete = async () => {
if (!deletingEpicId) return;
try {
await deleteEpic.mutateAsync(deletingEpicId);
setDeletingEpicId(null);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete epic';
toast.error(message);
}
};
const getStatusColor = (status: WorkItemStatus) => {
switch (status) {
case 'Backlog':
return 'secondary';
case 'Todo':
return 'outline';
case 'InProgress':
return 'default';
case 'Done':
return 'success' as any;
default:
return 'secondary';
}
};
const getPriorityColor = (priority: WorkItemPriority) => {
switch (priority) {
case 'Low':
return 'bg-blue-100 text-blue-700 hover:bg-blue-100';
case 'Medium':
return 'bg-yellow-100 text-yellow-700 hover:bg-yellow-100';
case 'High':
return 'bg-orange-100 text-orange-700 hover:bg-orange-100';
case 'Critical':
return 'bg-red-100 text-red-700 hover:bg-red-100';
default:
return 'secondary';
}
};
if (projectLoading || epicsLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-32" />
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-5 w-64" />
</div>
<Skeleton className="h-10 w-32" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-1/2 mt-2" />
</CardHeader>
<CardContent>
<Skeleton className="h-16 w-full" />
</CardContent>
</Card>
))}
</div>
</div>
);
}
if (error || !project) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-destructive">Error Loading Epics</CardTitle>
<CardDescription>
{error instanceof Error ? error.message : 'Failed to load epics'}
</CardDescription>
</CardHeader>
<CardContent className="flex gap-2">
<Button onClick={() => router.back()}>Go Back</Button>
<Button onClick={() => window.location.reload()} variant="outline">
Retry
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href="/projects" className="hover:text-foreground">
Projects
</Link>
<span>/</span>
<Link href={`/projects/${projectId}`} className="hover:text-foreground">
{project.name}
</Link>
<span>/</span>
<span className="text-foreground">Epics</span>
</div>
{/* Header */}
<div className="flex items-start justify-between">
<div className="space-y-2">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link href={`/projects/${projectId}`}>
<ArrowLeft className="h-5 w-5" />
</Link>
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight">Epics</h1>
<p className="text-muted-foreground mt-1">
Manage epics for {project.name}
</p>
</div>
</div>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New Epic
</Button>
</div>
{/* Epics Grid */}
{epics && epics.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{epics.map((epic) => (
<Card
key={epic.id}
className="group transition-all hover:shadow-lg hover:border-primary relative"
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="space-y-2 flex-1">
<Link
href={`/epics/${epic.id}`}
className="block hover:underline"
>
<CardTitle className="line-clamp-2 text-lg">
{epic.name}
</CardTitle>
</Link>
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={getStatusColor(epic.status)}>
{epic.status}
</Badge>
<Badge className={getPriorityColor(epic.priority)}>
{epic.priority}
</Badge>
</div>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.preventDefault();
setEditingEpic(epic);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => {
e.preventDefault();
setDeletingEpicId(epic.id);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{epic.description ? (
<p className="text-sm text-muted-foreground line-clamp-3">
{epic.description}
</p>
) : (
<p className="text-sm text-muted-foreground italic">
No description
</p>
)}
<div className="space-y-2 text-xs text-muted-foreground">
{epic.estimatedHours && (
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>Estimated: {epic.estimatedHours}h</span>
{epic.actualHours && (
<span className="ml-2">
/ Actual: {epic.actualHours}h
</span>
)}
</div>
)}
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>
Created {formatDistanceToNow(new Date(epic.createdAt), { addSuffix: true })}
</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card className="flex flex-col items-center justify-center py-16">
<ListTodo className="h-12 w-12 text-muted-foreground mb-4" />
<CardTitle className="mb-2">No epics yet</CardTitle>
<CardDescription className="mb-4 text-center max-w-md">
Get started by creating your first epic to organize major features and initiatives
</CardDescription>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Epic
</Button>
</Card>
)}
{/* Create Epic Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create New Epic</DialogTitle>
<DialogDescription>
Add a new epic to organize major features and initiatives
</DialogDescription>
</DialogHeader>
<EpicForm
projectId={projectId}
onSuccess={() => setIsCreateDialogOpen(false)}
onCancel={() => setIsCreateDialogOpen(false)}
/>
</DialogContent>
</Dialog>
{/* Edit Epic Dialog */}
<Dialog open={!!editingEpic} onOpenChange={() => setEditingEpic(null)}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Epic</DialogTitle>
<DialogDescription>
Update the epic details
</DialogDescription>
</DialogHeader>
{editingEpic && (
<EpicForm
projectId={projectId}
epic={editingEpic}
onSuccess={() => setEditingEpic(null)}
onCancel={() => setEditingEpic(null)}
/>
)}
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog
open={!!deletingEpicId}
onOpenChange={() => setDeletingEpicId(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the epic
and all its associated stories and tasks.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={deleteEpic.isPending}
>
{deleteEpic.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
'Delete Epic'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}