diff --git a/app/(dashboard)/projects/[id]/page.tsx b/app/(dashboard)/projects/[id]/page.tsx index beb7ab9..0555673 100644 --- a/app/(dashboard)/projects/[id]/page.tsx +++ b/app/(dashboard)/projects/[id]/page.tsx @@ -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) {

{project.name}

- + {project.status} - +
-

Key: {project.key}

+

Key: {project.key}

+
+
+ + + + {project.status === 'Active' && ( + <> + + + + )}
- - - - - - Project Details - Information about this project - - -
-

Description

-

{project.description || 'No description provided'}

-
-
-

Created

-

{new Date(project.createdAt).toLocaleDateString()}

-
- {project.updatedAt && ( -
-

Last Updated

-

{new Date(project.updatedAt).toLocaleDateString()}

+
+ + + Description + + +

{project.description || 'No description provided'}

+
+
+ + + + Details + + +
+ Created + {new Date(project.createdAt).toLocaleDateString()}
- )} -
-
+ {project.updatedAt && ( +
+ Updated + {new Date(project.updatedAt).toLocaleDateString()} +
+ )} +
+ Status + + {project.status} + +
+ + +
+ + {/* SignalR Connection Status */} +
+
+ + {connectionState === 'connected' ? 'Real-time updates enabled' : 'Connecting...'} + +
+ + {/* Dialogs */} + {project && ( + <> + + + + )}
); } diff --git a/components/features/projects/ArchiveProjectDialog.tsx b/components/features/projects/ArchiveProjectDialog.tsx new file mode 100644 index 0000000..fd0bf23 --- /dev/null +++ b/components/features/projects/ArchiveProjectDialog.tsx @@ -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 ( + + + + Archive Project + + Are you sure you want to archive{' '} + {projectName}? + + + +
+

+ This action will mark the project as archived, but it can be + restored later. All associated issues and data will be preserved. +

+
+ + + + + +
+
+ ); +} diff --git a/components/features/projects/EditProjectDialog.tsx b/components/features/projects/EditProjectDialog.tsx new file mode 100644 index 0000000..c764c61 --- /dev/null +++ b/components/features/projects/EditProjectDialog.tsx @@ -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; + +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({ + 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 ( + + + + Edit Project + + Update project details. Changes will be saved immediately. + + + +
+ + ( + + Project Name + + + + + The name of your project. + + + + )} + /> + + ( + + Description + + + + + A brief description for your project. + + + + )} + /> + + + + + + + +
+
+ ); +} diff --git a/lib/hooks/useProjectHub.ts b/lib/hooks/useProjectHub.ts index 923ffec..0418e66 100644 --- a/lib/hooks/useProjectHub.ts +++ b/lib/hooks/useProjectHub.ts @@ -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) => {