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>
366 lines
13 KiB
TypeScript
366 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { use, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { useRouter } from 'next/navigation';
|
|
import {
|
|
ArrowLeft,
|
|
Edit,
|
|
Trash2,
|
|
FolderKanban,
|
|
Calendar,
|
|
Loader2,
|
|
ListTodo,
|
|
} 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, useDeleteProject } from '@/lib/hooks/use-projects';
|
|
import { useEpics } from '@/lib/hooks/use-epics';
|
|
import { ProjectForm } from '@/components/projects/project-form';
|
|
import { formatDistanceToNow, format } from 'date-fns';
|
|
import { toast } from 'sonner';
|
|
|
|
interface ProjectDetailPageProps {
|
|
params: Promise<{ id: string }>;
|
|
}
|
|
|
|
export default function ProjectDetailPage({ params }: ProjectDetailPageProps) {
|
|
const { id } = use(params);
|
|
const router = useRouter();
|
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
|
|
const { data: project, isLoading, error } = useProject(id);
|
|
const { data: epics, isLoading: epicsLoading } = useEpics(id);
|
|
const deleteProject = useDeleteProject();
|
|
|
|
const handleDelete = async () => {
|
|
try {
|
|
await deleteProject.mutateAsync(id);
|
|
toast.success('Project deleted successfully');
|
|
router.push('/projects');
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Failed to delete project';
|
|
toast.error(message);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<Skeleton className="h-10 w-24" />
|
|
<div className="flex items-start justify-between">
|
|
<div className="space-y-2 flex-1">
|
|
<Skeleton className="h-10 w-64" />
|
|
<Skeleton className="h-6 w-32" />
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Skeleton className="h-10 w-24" />
|
|
<Skeleton className="h-10 w-24" />
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-6 md:grid-cols-3">
|
|
<div className="md:col-span-2">
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-32" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-24 w-full" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-24" />
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<Skeleton className="h-16 w-full" />
|
|
<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 Project</CardTitle>
|
|
<CardDescription>
|
|
{error instanceof Error ? error.message : 'Project not found'}
|
|
</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 / Back button */}
|
|
<Button variant="ghost" asChild>
|
|
<Link href="/projects">
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back to Projects
|
|
</Link>
|
|
</Button>
|
|
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-3xl font-bold tracking-tight">{project.name}</h1>
|
|
<Badge variant="secondary" className="text-sm">
|
|
{project.key}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center text-sm text-muted-foreground">
|
|
<Calendar className="mr-1 h-4 w-4" />
|
|
Created {formatDistanceToNow(new Date(project.createdAt), { addSuffix: true })}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" onClick={() => setIsEditDialogOpen(true)}>
|
|
<Edit className="mr-2 h-4 w-4" />
|
|
Edit
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => setIsDeleteDialogOpen(true)}
|
|
disabled={deleteProject.isPending}
|
|
>
|
|
{deleteProject.isPending ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
)}
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="grid gap-6 md:grid-cols-3">
|
|
{/* Main content */}
|
|
<div className="md:col-span-2 space-y-6">
|
|
{/* Project details */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Project Details</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div>
|
|
<h3 className="text-sm font-medium mb-1">Description</h3>
|
|
{project.description ? (
|
|
<p className="text-sm text-muted-foreground">{project.description}</p>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground italic">No description provided</p>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4 pt-4 border-t">
|
|
<div>
|
|
<h3 className="text-sm font-medium mb-1">Created</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
{format(new Date(project.createdAt), 'PPP')}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-medium mb-1">Last Updated</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
{format(new Date(project.updatedAt), 'PPP')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Epics preview */}
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle>Epics</CardTitle>
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link href={`/projects/${project.id}/epics`}>View All</Link>
|
|
</Button>
|
|
</div>
|
|
<CardDescription>
|
|
Track major features and initiatives in this project
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{epicsLoading ? (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-16 w-full" />
|
|
))}
|
|
</div>
|
|
) : epics && epics.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{epics.slice(0, 5).map((epic) => (
|
|
<Link
|
|
key={epic.id}
|
|
href={`/epics/${epic.id}`}
|
|
className="block p-3 rounded-lg border hover:bg-accent transition-colors"
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="space-y-1 flex-1">
|
|
<p className="text-sm font-medium line-clamp-1">{epic.name}</p>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className="text-xs">
|
|
{epic.status}
|
|
</Badge>
|
|
<Badge variant="outline" className="text-xs">
|
|
{epic.priority}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
{epics.length > 5 && (
|
|
<p className="text-xs text-muted-foreground text-center pt-2">
|
|
And {epics.length - 5} more...
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8">
|
|
<ListTodo className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
|
|
<p className="text-sm text-muted-foreground">No epics yet</p>
|
|
<Button variant="outline" size="sm" className="mt-4" asChild>
|
|
<Link href={`/projects/${project.id}/epics`}>Create First Epic</Link>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Sidebar */}
|
|
<div className="space-y-6">
|
|
{/* Quick actions */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Quick Actions</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
<Button variant="outline" className="w-full justify-start" asChild>
|
|
<Link href={`/projects/${project.id}/kanban`}>
|
|
<FolderKanban className="mr-2 h-4 w-4" />
|
|
View Kanban Board
|
|
</Link>
|
|
</Button>
|
|
<Button variant="outline" className="w-full justify-start" asChild>
|
|
<Link href={`/projects/${project.id}/epics`}>
|
|
<ListTodo className="mr-2 h-4 w-4" />
|
|
Manage Epics
|
|
</Link>
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Project stats */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Statistics</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">Total Epics</span>
|
|
<span className="text-2xl font-bold">{epics?.length || 0}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">Active</span>
|
|
<span className="text-lg font-semibold">
|
|
{epics?.filter((e) => e.status === 'InProgress').length || 0}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">Completed</span>
|
|
<span className="text-lg font-semibold">
|
|
{epics?.filter((e) => e.status === 'Done').length || 0}
|
|
</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Edit Project Dialog */}
|
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Edit Project</DialogTitle>
|
|
<DialogDescription>
|
|
Update your project details
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<ProjectForm
|
|
project={project}
|
|
onSuccess={() => setIsEditDialogOpen(false)}
|
|
onCancel={() => setIsEditDialogOpen(false)}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This action cannot be undone. This will permanently delete the project
|
|
<span className="font-semibold"> {project.name}</span> and all its associated data
|
|
(epics, stories, and tasks).
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleDelete}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
Delete Project
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
}
|