feat(frontend): Implement Project Detail Page with edit and archive functionality
Add complete project detail page with real-time updates via SignalR. Changes: - Updated project detail page with edit and archive buttons - Created EditProjectDialog component for updating projects - Created ArchiveProjectDialog component for archiving projects - Integrated SignalR real-time updates (onProjectUpdated, onProjectArchived) - Added SignalR connection status indicator - Enhanced useProjectHub hook to support callback options - Improved UI layout with two-column card grid - Added toast notifications for user feedback Features: - View project details (name, description, status, timestamps) - Edit project name and description - Archive active projects - Real-time updates when project is modified by other users - Automatic redirect when project is archived 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { use } from 'react';
|
||||
import { use, useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Loader2, KanbanSquare } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, Loader2, KanbanSquare, Pencil, Archive } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useProject } from '@/lib/hooks/use-projects';
|
||||
import { useProjectHub } from '@/lib/hooks/useProjectHub';
|
||||
import { EditProjectDialog } from '@/components/features/projects/EditProjectDialog';
|
||||
import { ArchiveProjectDialog } from '@/components/features/projects/ArchiveProjectDialog';
|
||||
import type { Project } from '@/types/project';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ProjectDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -13,7 +21,29 @@ interface ProjectDetailPageProps {
|
||||
|
||||
export default function ProjectDetailPage({ params }: ProjectDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: project, isLoading, error } = useProject(id);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [isArchiveDialogOpen, setIsArchiveDialogOpen] = useState(false);
|
||||
|
||||
// SignalR real-time updates
|
||||
const { connectionState } = useProjectHub(id, {
|
||||
onProjectUpdated: (updatedProject) => {
|
||||
if (updatedProject.id === id) {
|
||||
console.log('[ProjectDetail] Project updated via SignalR:', updatedProject);
|
||||
queryClient.setQueryData(['projects', id], updatedProject);
|
||||
toast.info('Project updated');
|
||||
}
|
||||
},
|
||||
onProjectArchived: (data) => {
|
||||
if (data.ProjectId === id) {
|
||||
console.log('[ProjectDetail] Project archived via SignalR:', data);
|
||||
toast.info('Project has been archived');
|
||||
router.push('/projects');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -44,48 +74,97 @@ export default function ProjectDetailPage({ params }: ProjectDetailPageProps) {
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{project.name}</h1>
|
||||
<span
|
||||
className={`rounded-full px-2 py-1 text-xs font-medium ${
|
||||
project.status === 'Active'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Badge variant={project.status === 'Active' ? 'default' : 'secondary'}>
|
||||
{project.status}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">Key: {project.key}</p>
|
||||
<p className="text-sm text-muted-foreground">Key: {project.key}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/kanban/${project.id}`}>
|
||||
<Button>
|
||||
<Button variant="outline">
|
||||
<KanbanSquare className="mr-2 h-4 w-4" />
|
||||
View Board
|
||||
</Button>
|
||||
</Link>
|
||||
{project.status === 'Active' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setIsEditDialogOpen(true)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => setIsArchiveDialogOpen(true)}>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
Archive
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Description</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm">{project.description || 'No description provided'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Details</CardTitle>
|
||||
<CardDescription>Information about this project</CardDescription>
|
||||
<CardTitle>Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Description</h3>
|
||||
<p className="mt-1">{project.description || 'No description provided'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Created</h3>
|
||||
<p className="mt-1">{new Date(project.createdAt).toLocaleDateString()}</p>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span>{new Date(project.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
{project.updatedAt && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Last Updated</h3>
|
||||
<p className="mt-1">{new Date(project.updatedAt).toLocaleDateString()}</p>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Updated</span>
|
||||
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<Badge variant={project.status === 'Active' ? 'default' : 'secondary'}>
|
||||
{project.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* SignalR Connection Status */}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
connectionState === 'connected' ? 'bg-green-500' : 'bg-gray-400'
|
||||
}`}
|
||||
/>
|
||||
<span>
|
||||
{connectionState === 'connected' ? 'Real-time updates enabled' : 'Connecting...'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Dialogs */}
|
||||
{project && (
|
||||
<>
|
||||
<EditProjectDialog
|
||||
project={project}
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
/>
|
||||
<ArchiveProjectDialog
|
||||
projectId={project.id}
|
||||
projectName={project.name}
|
||||
open={isArchiveDialogOpen}
|
||||
onOpenChange={setIsArchiveDialogOpen}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
81
components/features/projects/ArchiveProjectDialog.tsx
Normal file
81
components/features/projects/ArchiveProjectDialog.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useDeleteProject } from '@/lib/hooks/use-projects';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ArchiveProjectDialogProps {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ArchiveProjectDialog({
|
||||
projectId,
|
||||
projectName,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ArchiveProjectDialogProps) {
|
||||
const router = useRouter();
|
||||
const deleteProject = useDeleteProject();
|
||||
|
||||
const handleArchive = async () => {
|
||||
try {
|
||||
await deleteProject.mutateAsync(projectId);
|
||||
toast.success('Project archived successfully');
|
||||
router.push('/projects');
|
||||
} catch (error) {
|
||||
toast.error('Failed to archive project');
|
||||
console.error('Archive error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Archive Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to archive{' '}
|
||||
<strong className="font-semibold">{projectName}</strong>?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This action will mark the project as archived, but it can be
|
||||
restored later. All associated issues and data will be preserved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleArchive}
|
||||
disabled={deleteProject.isPending}
|
||||
>
|
||||
{deleteProject.isPending ? 'Archiving...' : 'Archive Project'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
139
components/features/projects/EditProjectDialog.tsx
Normal file
139
components/features/projects/EditProjectDialog.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { useUpdateProject } from '@/lib/hooks/use-projects';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Project } from '@/types/project';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const updateProjectSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Project name is required')
|
||||
.max(200, 'Project name cannot exceed 200 characters'),
|
||||
description: z
|
||||
.string()
|
||||
.max(2000, 'Description cannot exceed 2000 characters'),
|
||||
});
|
||||
|
||||
type UpdateProjectFormData = z.infer<typeof updateProjectSchema>;
|
||||
|
||||
interface EditProjectDialogProps {
|
||||
project: Project;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditProjectDialog({
|
||||
project,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: EditProjectDialogProps) {
|
||||
const updateProject = useUpdateProject(project.id);
|
||||
|
||||
const form = useForm<UpdateProjectFormData>({
|
||||
resolver: zodResolver(updateProjectSchema),
|
||||
defaultValues: {
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: UpdateProjectFormData) => {
|
||||
try {
|
||||
await updateProject.mutateAsync(data);
|
||||
toast.success('Project updated successfully');
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error('Failed to update project');
|
||||
console.error('Update error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[525px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update project details. Changes will be saved immediately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Project Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Awesome Project" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The name of your project.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="A brief description of the project..."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A brief description for your project.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateProject.isPending}>
|
||||
{updateProject.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,21 @@ import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { SignalRConnectionManager } from '@/lib/signalr/ConnectionManager';
|
||||
import { SIGNALR_CONFIG } from '@/lib/signalr/config';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import type { Project } from '@/types/project';
|
||||
|
||||
export function useProjectHub(projectId?: string) {
|
||||
interface UseProjectHubOptions {
|
||||
onProjectUpdated?: (project: Project) => void;
|
||||
onProjectArchived?: (data: { ProjectId: string }) => void;
|
||||
onIssueCreated?: (issue: any) => void;
|
||||
onIssueUpdated?: (issue: any) => void;
|
||||
onIssueDeleted?: (data: { IssueId: string }) => void;
|
||||
onIssueStatusChanged?: (data: any) => void;
|
||||
onUserJoinedProject?: (data: any) => void;
|
||||
onUserLeftProject?: (data: any) => void;
|
||||
onTypingIndicator?: (data: { UserId: string; IssueId: string; IsTyping: boolean }) => void;
|
||||
}
|
||||
|
||||
export function useProjectHub(projectId?: string, options?: UseProjectHubOptions) {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const [connectionState, setConnectionState] = useState<
|
||||
'disconnected' | 'connecting' | 'connected' | 'reconnecting'
|
||||
@@ -25,42 +38,49 @@ export function useProjectHub(projectId?: string) {
|
||||
// 监听项目事件
|
||||
manager.on('ProjectUpdated', (data: any) => {
|
||||
console.log('[ProjectHub] Project updated:', data);
|
||||
// TODO: 触发项目数据重新加载
|
||||
options?.onProjectUpdated?.(data);
|
||||
});
|
||||
|
||||
manager.on('ProjectArchived', (data: { ProjectId: string }) => {
|
||||
console.log('[ProjectHub] Project archived:', data);
|
||||
options?.onProjectArchived?.(data);
|
||||
});
|
||||
|
||||
manager.on('IssueCreated', (issue: any) => {
|
||||
console.log('[ProjectHub] Issue created:', issue);
|
||||
// TODO: 添加到看板
|
||||
options?.onIssueCreated?.(issue);
|
||||
});
|
||||
|
||||
manager.on('IssueUpdated', (issue: any) => {
|
||||
console.log('[ProjectHub] Issue updated:', issue);
|
||||
// TODO: 更新看板
|
||||
options?.onIssueUpdated?.(issue);
|
||||
});
|
||||
|
||||
manager.on('IssueDeleted', (data: { IssueId: string }) => {
|
||||
console.log('[ProjectHub] Issue deleted:', data);
|
||||
// TODO: 从看板移除
|
||||
options?.onIssueDeleted?.(data);
|
||||
});
|
||||
|
||||
manager.on('IssueStatusChanged', (data: any) => {
|
||||
console.log('[ProjectHub] Issue status changed:', data);
|
||||
// TODO: 移动看板卡片
|
||||
options?.onIssueStatusChanged?.(data);
|
||||
});
|
||||
|
||||
manager.on('UserJoinedProject', (data: any) => {
|
||||
console.log('[ProjectHub] User joined:', data);
|
||||
options?.onUserJoinedProject?.(data);
|
||||
});
|
||||
|
||||
manager.on('UserLeftProject', (data: any) => {
|
||||
console.log('[ProjectHub] User left:', data);
|
||||
options?.onUserLeftProject?.(data);
|
||||
});
|
||||
|
||||
manager.on(
|
||||
'TypingIndicator',
|
||||
(data: { UserId: string; IssueId: string; IsTyping: boolean }) => {
|
||||
console.log('[ProjectHub] Typing indicator:', data);
|
||||
// TODO: 显示正在输入提示
|
||||
options?.onTypingIndicator?.(data);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -70,7 +90,7 @@ export function useProjectHub(projectId?: string) {
|
||||
unsubscribe();
|
||||
manager.stop();
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
}, [isAuthenticated, options]);
|
||||
|
||||
// 加入项目房间
|
||||
const joinProject = useCallback(async (projectId: string) => {
|
||||
|
||||
Reference in New Issue
Block a user