Compare commits
4 Commits
ea67d90880
...
16174e271b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16174e271b | ||
|
|
99ba4c4b1a | ||
|
|
358ee9b7f4 | ||
|
|
bb3a93bfdc |
@@ -56,9 +56,9 @@ function LoginContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded bg-red-50 p-3 text-sm text-red-600">
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3 text-sm text-destructive">
|
||||||
{(error as { response?: { data?: { message?: string } } })
|
{(error as { response?: { data?: { message?: string } } })
|
||||||
?.response?.data?.message || 'Login failed. Please try again.'}
|
?.response?.data?.message || 'Login failed. Please check your credentials and try again.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ function LoginContent() {
|
|||||||
placeholder="your-company"
|
placeholder="your-company"
|
||||||
/>
|
/>
|
||||||
{errors.tenantSlug && (
|
{errors.tenantSlug && (
|
||||||
<p className="mt-1 text-sm text-red-600">{errors.tenantSlug.message}</p>
|
<p className="mt-1 text-sm text-destructive">{errors.tenantSlug.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ function LoginContent() {
|
|||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{errors.email && (
|
||||||
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
|
<p className="mt-1 text-sm text-destructive">{errors.email.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ function LoginContent() {
|
|||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
/>
|
/>
|
||||||
{errors.password && (
|
{errors.password && (
|
||||||
<p className="mt-1 text-sm text-red-600">
|
<p className="mt-1 text-sm text-destructive">
|
||||||
{errors.password.message}
|
{errors.password.message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -54,10 +54,10 @@ export default function RegisterPage() {
|
|||||||
className="mt-8 space-y-6 rounded-lg bg-white p-8 shadow"
|
className="mt-8 space-y-6 rounded-lg bg-white p-8 shadow"
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded bg-red-50 p-3 text-sm text-red-600">
|
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3 text-sm text-destructive">
|
||||||
{(error as { response?: { data?: { message?: string } } })
|
{(error as { response?: { data?: { message?: string } } })
|
||||||
?.response?.data?.message ||
|
?.response?.data?.message ||
|
||||||
'Registration failed. Please try again.'}
|
'Registration failed. Please check your information and try again.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ export default function RegisterPage() {
|
|||||||
placeholder="John Doe"
|
placeholder="John Doe"
|
||||||
/>
|
/>
|
||||||
{errors.fullName && (
|
{errors.fullName && (
|
||||||
<p className="mt-1 text-sm text-red-600">
|
<p className="mt-1 text-sm text-destructive">
|
||||||
{errors.fullName.message}
|
{errors.fullName.message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -87,7 +87,7 @@ export default function RegisterPage() {
|
|||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{errors.email && (
|
||||||
<p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
|
<p className="mt-1 text-sm text-destructive">{errors.email.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ export default function RegisterPage() {
|
|||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
/>
|
/>
|
||||||
{errors.password && (
|
{errors.password && (
|
||||||
<p className="mt-1 text-sm text-red-600">
|
<p className="mt-1 text-sm text-destructive">
|
||||||
{errors.password.message}
|
{errors.password.message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -120,7 +120,7 @@ export default function RegisterPage() {
|
|||||||
placeholder="Acme Inc."
|
placeholder="Acme Inc."
|
||||||
/>
|
/>
|
||||||
{errors.tenantName && (
|
{errors.tenantName && (
|
||||||
<p className="mt-1 text-sm text-red-600">
|
<p className="mt-1 text-sm text-destructive">
|
||||||
{errors.tenantName.message}
|
{errors.tenantName.message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { Plus, FolderKanban, Archive, TrendingUp, ArrowRight } from 'lucide-react';
|
import { Plus, FolderKanban, Archive, TrendingUp, ArrowRight } from 'lucide-react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -15,16 +15,19 @@ export default function DashboardPage() {
|
|||||||
const { data: projects, isLoading } = useProjects();
|
const { data: projects, isLoading } = useProjects();
|
||||||
|
|
||||||
// Calculate statistics
|
// Calculate statistics
|
||||||
const stats = {
|
const stats = useMemo(() => ({
|
||||||
totalProjects: projects?.length || 0,
|
totalProjects: projects?.length || 0,
|
||||||
activeProjects: projects?.length || 0, // TODO: Add status field to Project model
|
activeProjects: projects?.length || 0, // TODO: Add status field to Project model
|
||||||
archivedProjects: 0, // TODO: Add status field to Project model
|
archivedProjects: 0, // TODO: Add status field to Project model
|
||||||
};
|
}), [projects]);
|
||||||
|
|
||||||
// Get recent projects (sort by creation time, take first 5)
|
// Get recent projects (sort by creation time, take first 5)
|
||||||
const recentProjects = projects
|
const recentProjects = useMemo(() => {
|
||||||
?.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
return projects
|
||||||
.slice(0, 5) || [];
|
?.slice()
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
.slice(0, 5) || [];
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Header } from '@/components/layout/Header';
|
|||||||
import { Sidebar } from '@/components/layout/Sidebar';
|
import { Sidebar } from '@/components/layout/Sidebar';
|
||||||
import { useUIStore } from '@/stores/ui-store';
|
import { useUIStore } from '@/stores/ui-store';
|
||||||
import { AuthGuard } from '@/components/providers/AuthGuard';
|
import { AuthGuard } from '@/components/providers/AuthGuard';
|
||||||
|
import { SkipLink } from '@/components/ui/skip-link';
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@@ -14,11 +15,13 @@ export default function DashboardLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
|
<SkipLink />
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header />
|
<Header />
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main
|
<main
|
||||||
|
id="main-content"
|
||||||
className={`flex-1 transition-all duration-200 ${
|
className={`flex-1 transition-all duration-200 ${
|
||||||
sidebarOpen ? 'ml-64' : 'ml-0'
|
sidebarOpen ? 'ml-64' : 'ml-0'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { use, useState } from 'react';
|
import { use, useState, useCallback } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
@@ -62,7 +62,7 @@ export default function EpicsPage({ params }: EpicsPageProps) {
|
|||||||
const { data: epics, isLoading: epicsLoading, error } = useEpics(projectId);
|
const { data: epics, isLoading: epicsLoading, error } = useEpics(projectId);
|
||||||
const deleteEpic = useDeleteEpic();
|
const deleteEpic = useDeleteEpic();
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = useCallback(async () => {
|
||||||
if (!deletingEpicId) return;
|
if (!deletingEpicId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -72,9 +72,9 @@ export default function EpicsPage({ params }: EpicsPageProps) {
|
|||||||
const message = error instanceof Error ? error.message : 'Failed to delete epic';
|
const message = error instanceof Error ? error.message : 'Failed to delete epic';
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
};
|
}, [deletingEpicId, deleteEpic]);
|
||||||
|
|
||||||
const getStatusColor = (status: WorkItemStatus) => {
|
const getStatusColor = useCallback((status: WorkItemStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'Backlog':
|
case 'Backlog':
|
||||||
return 'secondary';
|
return 'secondary';
|
||||||
@@ -87,9 +87,9 @@ export default function EpicsPage({ params }: EpicsPageProps) {
|
|||||||
default:
|
default:
|
||||||
return 'secondary';
|
return 'secondary';
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const getPriorityColor = (priority: WorkItemPriority) => {
|
const getPriorityColor = useCallback((priority: WorkItemPriority) => {
|
||||||
switch (priority) {
|
switch (priority) {
|
||||||
case 'Low':
|
case 'Low':
|
||||||
return 'bg-blue-100 text-blue-700 hover:bg-blue-100';
|
return 'bg-blue-100 text-blue-700 hover:bg-blue-100';
|
||||||
@@ -102,7 +102,7 @@ export default function EpicsPage({ params }: EpicsPageProps) {
|
|||||||
default:
|
default:
|
||||||
return 'secondary';
|
return 'secondary';
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
if (projectLoading || epicsLoading) {
|
if (projectLoading || epicsLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
DragStartEvent,
|
DragStartEvent,
|
||||||
closestCorners,
|
closestCorners,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { useProjectStories } from '@/lib/hooks/use-stories';
|
import { useProjectStories } from '@/lib/hooks/use-stories';
|
||||||
import { useEpics } from '@/lib/hooks/use-epics';
|
import { useEpics } from '@/lib/hooks/use-epics';
|
||||||
import { useChangeStoryStatus } from '@/lib/hooks/use-stories';
|
import { useChangeStoryStatus } from '@/lib/hooks/use-stories';
|
||||||
@@ -20,6 +20,7 @@ import { KanbanColumn } from '@/components/features/kanban/KanbanColumn';
|
|||||||
import { StoryCard } from '@/components/features/kanban/StoryCard';
|
import { StoryCard } from '@/components/features/kanban/StoryCard';
|
||||||
import { CreateStoryDialog } from '@/components/features/stories/CreateStoryDialog';
|
import { CreateStoryDialog } from '@/components/features/stories/CreateStoryDialog';
|
||||||
import type { Story, WorkItemStatus } from '@/types/project';
|
import type { Story, WorkItemStatus } from '@/types/project';
|
||||||
|
import { logger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
{ id: 'Backlog', title: 'Backlog', color: 'bg-gray-100' },
|
{ id: 'Backlog', title: 'Backlog', color: 'bg-gray-100' },
|
||||||
@@ -42,7 +43,7 @@ export default function KanbanPage() {
|
|||||||
|
|
||||||
// SignalR real-time updates
|
// SignalR real-time updates
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { isConnected } = useSignalRConnection();
|
useSignalRConnection(); // Establish connection
|
||||||
const changeStatusMutation = useChangeStoryStatus();
|
const changeStatusMutation = useChangeStoryStatus();
|
||||||
|
|
||||||
// Subscribe to SignalR events for real-time updates
|
// Subscribe to SignalR events for real-time updates
|
||||||
@@ -50,15 +51,15 @@ export default function KanbanPage() {
|
|||||||
{
|
{
|
||||||
// Story events (3 events)
|
// Story events (3 events)
|
||||||
'StoryCreated': (event: any) => {
|
'StoryCreated': (event: any) => {
|
||||||
console.log('[Kanban] Story created:', event);
|
logger.debug('[Kanban] Story created:', event);
|
||||||
queryClient.invalidateQueries({ queryKey: ['project-stories', projectId] });
|
queryClient.invalidateQueries({ queryKey: ['project-stories', projectId] });
|
||||||
},
|
},
|
||||||
'StoryUpdated': (event: any) => {
|
'StoryUpdated': (event: any) => {
|
||||||
console.log('[Kanban] Story updated:', event);
|
logger.debug('[Kanban] Story updated:', event);
|
||||||
queryClient.invalidateQueries({ queryKey: ['project-stories', projectId] });
|
queryClient.invalidateQueries({ queryKey: ['project-stories', projectId] });
|
||||||
},
|
},
|
||||||
'StoryDeleted': (event: any) => {
|
'StoryDeleted': (event: any) => {
|
||||||
console.log('[Kanban] Story deleted:', event);
|
logger.debug('[Kanban] Story deleted:', event);
|
||||||
queryClient.invalidateQueries({ queryKey: ['project-stories', projectId] });
|
queryClient.invalidateQueries({ queryKey: ['project-stories', projectId] });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -82,12 +83,12 @@ export default function KanbanPage() {
|
|||||||
Done: stories.filter((s) => s.status === 'Done'),
|
Done: stories.filter((s) => s.status === 'Done'),
|
||||||
}), [stories]);
|
}), [stories]);
|
||||||
|
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
const story = stories.find((s) => s.id === event.active.id);
|
const story = stories.find((s) => s.id === event.active.id);
|
||||||
setActiveStory(story || null);
|
setActiveStory(story || null);
|
||||||
};
|
}, [stories]);
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
setActiveStory(null);
|
setActiveStory(null);
|
||||||
|
|
||||||
@@ -97,10 +98,10 @@ export default function KanbanPage() {
|
|||||||
const story = stories.find((s) => s.id === active.id);
|
const story = stories.find((s) => s.id === active.id);
|
||||||
|
|
||||||
if (story && story.status !== newStatus) {
|
if (story && story.status !== newStatus) {
|
||||||
console.log(`[Kanban] Changing story ${story.id} status to ${newStatus}`);
|
logger.debug(`[Kanban] Changing story ${story.id} status to ${newStatus}`);
|
||||||
changeStatusMutation.mutate({ id: story.id, status: newStatus });
|
changeStatusMutation.mutate({ id: story.id, status: newStatus });
|
||||||
}
|
}
|
||||||
};
|
}, [stories, changeStatusMutation]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Plus, FolderKanban, Calendar } from 'lucide-react';
|
import { Plus, FolderKanban, Calendar, AlertCircle } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { useProjects } from '@/lib/hooks/use-projects';
|
import { useProjects } from '@/lib/hooks/use-projects';
|
||||||
import { ProjectForm } from '@/components/projects/project-form';
|
import { ProjectForm } from '@/components/projects/project-form';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { EmptyState } from '@/components/ui/empty-state';
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
@@ -52,19 +53,15 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<EmptyState
|
||||||
<Card className="w-full max-w-md">
|
icon={AlertCircle}
|
||||||
<CardHeader>
|
title="Failed to load projects"
|
||||||
<CardTitle className="text-destructive">Error Loading Projects</CardTitle>
|
description={error instanceof Error ? error.message : 'An error occurred while loading projects. Please try again.'}
|
||||||
<CardDescription>
|
action={{
|
||||||
{error instanceof Error ? error.message : 'Failed to load projects'}
|
label: 'Retry',
|
||||||
</CardDescription>
|
onClick: () => window.location.reload(),
|
||||||
</CardHeader>
|
}}
|
||||||
<CardContent>
|
/>
|
||||||
<Button onClick={() => window.location.reload()}>Retry</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,17 +118,15 @@ export default function ProjectsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="flex flex-col items-center justify-center py-16">
|
<EmptyState
|
||||||
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
|
icon={FolderKanban}
|
||||||
<CardTitle className="mb-2">No projects yet</CardTitle>
|
title="No projects yet"
|
||||||
<CardDescription className="mb-4">
|
description="Get started by creating your first project to organize your work and track progress."
|
||||||
Get started by creating your first project
|
action={{
|
||||||
</CardDescription>
|
label: 'Create Project',
|
||||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
onClick: () => setIsCreateDialogOpen(true),
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
}}
|
||||||
Create Project
|
/>
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create Project Dialog */}
|
{/* Create Project Dialog */}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import "./globals.css";
|
|||||||
import { QueryProvider } from "@/lib/providers/query-provider";
|
import { QueryProvider } from "@/lib/providers/query-provider";
|
||||||
import { SignalRProvider } from "@/lib/signalr/SignalRContext";
|
import { SignalRProvider } from "@/lib/signalr/SignalRContext";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -30,12 +31,14 @@ export default function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<QueryProvider>
|
<ErrorBoundary>
|
||||||
<SignalRProvider>
|
<QueryProvider>
|
||||||
{children}
|
<SignalRProvider>
|
||||||
<Toaster position="top-right" />
|
{children}
|
||||||
</SignalRProvider>
|
<Toaster position="top-right" />
|
||||||
</QueryProvider>
|
</SignalRProvider>
|
||||||
|
</QueryProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
59
components/ErrorBoundary.tsx
Normal file
59
components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface ErrorFallbackProps {
|
||||||
|
error: Error;
|
||||||
|
resetErrorBoundary: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorFallback({ error, resetErrorBoundary }: ErrorFallbackProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center p-4">
|
||||||
|
<AlertCircle className="h-16 w-16 text-destructive mb-4" />
|
||||||
|
<h2 className="text-2xl font-bold mb-2">Something went wrong</h2>
|
||||||
|
<p className="text-muted-foreground mb-4 text-center max-w-md">
|
||||||
|
{error.message || 'An unexpected error occurred'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={resetErrorBoundary}>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => window.location.href = '/'}
|
||||||
|
>
|
||||||
|
Go to Dashboard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBoundary({ children }: ErrorBoundaryProps) {
|
||||||
|
return (
|
||||||
|
<ReactErrorBoundary
|
||||||
|
FallbackComponent={ErrorFallback}
|
||||||
|
onReset={() => {
|
||||||
|
// Optional: Reset application state here
|
||||||
|
// For now, we'll just reload the current page
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
onError={(error, errorInfo) => {
|
||||||
|
// Log error to console in development
|
||||||
|
console.error('Error caught by boundary:', error, errorInfo);
|
||||||
|
|
||||||
|
// In production, you could send this to an error tracking service
|
||||||
|
// like Sentry, LogRocket, etc.
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ReactErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
import { TaskCard } from './TaskCard';
|
import { TaskCard } from './TaskCard';
|
||||||
import type { LegacyKanbanBoard } from '@/types/kanban';
|
import type { LegacyKanbanBoard } from '@/types/kanban';
|
||||||
|
|
||||||
@@ -9,13 +10,17 @@ interface KanbanBoardProps {
|
|||||||
|
|
||||||
// Legacy KanbanBoard component using old Kanban type
|
// Legacy KanbanBoard component using old Kanban type
|
||||||
// For new Issue-based Kanban, use the page at /projects/[id]/kanban
|
// For new Issue-based Kanban, use the page at /projects/[id]/kanban
|
||||||
export function KanbanBoard({ board }: KanbanBoardProps) {
|
export const KanbanBoard = React.memo(function KanbanBoard({ board }: KanbanBoardProps) {
|
||||||
|
const totalTasks = useMemo(() => {
|
||||||
|
return board.columns.reduce((acc, col) => acc + col.tasks.length, 0);
|
||||||
|
}, [board.columns]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-2xl font-bold">{board.projectName}</h2>
|
<h2 className="text-2xl font-bold">{board.projectName}</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Total tasks: {board.columns.reduce((acc, col) => acc + col.tasks.length, 0)}
|
Total tasks: {totalTasks}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
@@ -48,4 +53,4 @@ export function KanbanBoard({ board }: KanbanBoardProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -14,7 +15,7 @@ interface KanbanColumnProps {
|
|||||||
taskCounts?: Record<string, number>; // Map of storyId -> taskCount
|
taskCounts?: Record<string, number>; // Map of storyId -> taskCount
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanColumn({ id, title, stories, epicNames = {}, taskCounts = {} }: KanbanColumnProps) {
|
export const KanbanColumn = React.memo(function KanbanColumn({ id, title, stories, epicNames = {}, taskCounts = {} }: KanbanColumnProps) {
|
||||||
const { setNodeRef } = useDroppable({ id });
|
const { setNodeRef } = useDroppable({ id });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,4 +48,4 @@ export function KanbanColumn({ id, title, stories, epicNames = {}, taskCounts =
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
@@ -7,7 +8,6 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Story } from '@/types/project';
|
import { Story } from '@/types/project';
|
||||||
import { FileText, FolderKanban, Clock, CheckSquare } from 'lucide-react';
|
import { FileText, FolderKanban, Clock, CheckSquare } from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
interface StoryCardProps {
|
interface StoryCardProps {
|
||||||
story: Story;
|
story: Story;
|
||||||
@@ -15,7 +15,7 @@ interface StoryCardProps {
|
|||||||
taskCount?: number;
|
taskCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StoryCard({ story, epicName, taskCount }: StoryCardProps) {
|
export const StoryCard = React.memo(function StoryCard({ story, epicName, taskCount }: StoryCardProps) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||||
useSortable({ id: story.id });
|
useSortable({ id: story.id });
|
||||||
|
|
||||||
@@ -140,4 +140,4 @@ export function StoryCard({ story, epicName, taskCount }: StoryCardProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import { Clock, User } from 'lucide-react';
|
import { Clock, User } from 'lucide-react';
|
||||||
import type { TaskCard as TaskCardType } from '@/types/kanban';
|
import type { TaskCard as TaskCardType } from '@/types/kanban';
|
||||||
@@ -9,7 +10,7 @@ interface TaskCardProps {
|
|||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskCard({ task, isDragging = false }: TaskCardProps) {
|
export const TaskCard = React.memo(function TaskCard({ task, isDragging = false }: TaskCardProps) {
|
||||||
const priorityColors = {
|
const priorityColors = {
|
||||||
Low: 'bg-blue-100 text-blue-700',
|
Low: 'bg-blue-100 text-blue-700',
|
||||||
Medium: 'bg-yellow-100 text-yellow-700',
|
Medium: 'bg-yellow-100 text-yellow-700',
|
||||||
@@ -59,4 +60,4 @@ export function TaskCard({ task, isDragging = false }: TaskCardProps) {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -72,13 +72,23 @@ function EpicNode({ epic, onEpicClick, onStoryClick, onTaskClick }: EpicNodeProp
|
|||||||
return (
|
return (
|
||||||
<div className="border rounded-lg">
|
<div className="border rounded-lg">
|
||||||
<div
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
className="flex items-center gap-2 p-3 hover:bg-accent cursor-pointer"
|
className="flex items-center gap-2 p-3 hover:bg-accent cursor-pointer"
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0"
|
className="h-6 w-6 p-0"
|
||||||
|
aria-label={isExpanded ? 'Collapse epic' : 'Expand epic'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsExpanded(!isExpanded);
|
setIsExpanded(!isExpanded);
|
||||||
@@ -91,19 +101,20 @@ function EpicNode({ epic, onEpicClick, onStoryClick, onTaskClick }: EpicNodeProp
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Folder className="h-5 w-5 text-blue-500" />
|
<Folder className="h-5 w-5 text-blue-500" aria-hidden="true" />
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<button
|
||||||
className="font-semibold hover:underline"
|
className="font-semibold hover:underline text-left bg-transparent border-0 p-0 cursor-pointer"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onEpicClick?.(epic);
|
onEpicClick?.(epic);
|
||||||
}}
|
}}
|
||||||
|
aria-label={`View epic: ${epic.name}`}
|
||||||
>
|
>
|
||||||
{epic.name}
|
{epic.name}
|
||||||
</span>
|
</button>
|
||||||
<StatusBadge status={epic.status} />
|
<StatusBadge status={epic.status} />
|
||||||
<PriorityBadge priority={epic.priority} />
|
<PriorityBadge priority={epic.priority} />
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +126,7 @@ function EpicNode({ epic, onEpicClick, onStoryClick, onTaskClick }: EpicNodeProp
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{epic.estimatedHours && (
|
{epic.estimatedHours && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground" aria-label={`Estimated: ${epic.estimatedHours} hours${epic.actualHours ? `, Actual: ${epic.actualHours} hours` : ''}`}>
|
||||||
{epic.estimatedHours}h
|
{epic.estimatedHours}h
|
||||||
{epic.actualHours && ` / ${epic.actualHours}h`}
|
{epic.actualHours && ` / ${epic.actualHours}h`}
|
||||||
</div>
|
</div>
|
||||||
@@ -164,13 +175,23 @@ function StoryNode({ story, onStoryClick, onTaskClick }: StoryNodeProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="border-l-2 border-muted pl-3">
|
<div className="border-l-2 border-muted pl-3">
|
||||||
<div
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer"
|
className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer"
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0"
|
className="h-6 w-6 p-0"
|
||||||
|
aria-label={isExpanded ? 'Collapse story' : 'Expand story'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsExpanded(!isExpanded);
|
setIsExpanded(!isExpanded);
|
||||||
@@ -183,19 +204,20 @@ function StoryNode({ story, onStoryClick, onTaskClick }: StoryNodeProps) {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<FileText className="h-4 w-4 text-green-500" />
|
<FileText className="h-4 w-4 text-green-500" aria-hidden="true" />
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<button
|
||||||
className="font-medium hover:underline"
|
className="font-medium hover:underline text-left bg-transparent border-0 p-0 cursor-pointer"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onStoryClick?.(story);
|
onStoryClick?.(story);
|
||||||
}}
|
}}
|
||||||
|
aria-label={`View story: ${story.title}`}
|
||||||
>
|
>
|
||||||
{story.title}
|
{story.title}
|
||||||
</span>
|
</button>
|
||||||
<StatusBadge status={story.status} size="sm" />
|
<StatusBadge status={story.status} size="sm" />
|
||||||
<PriorityBadge priority={story.priority} size="sm" />
|
<PriorityBadge priority={story.priority} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
@@ -207,7 +229,7 @@ function StoryNode({ story, onStoryClick, onTaskClick }: StoryNodeProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{story.estimatedHours && (
|
{story.estimatedHours && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground" aria-label={`Estimated: ${story.estimatedHours} hours${story.actualHours ? `, Actual: ${story.actualHours} hours` : ''}`}>
|
||||||
{story.estimatedHours}h
|
{story.estimatedHours}h
|
||||||
{story.actualHours && ` / ${story.actualHours}h`}
|
{story.actualHours && ` / ${story.actualHours}h`}
|
||||||
</div>
|
</div>
|
||||||
@@ -242,14 +264,23 @@ interface TaskNodeProps {
|
|||||||
function TaskNode({ task, onTaskClick }: TaskNodeProps) {
|
function TaskNode({ task, onTaskClick }: TaskNodeProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer border-l-2 border-muted pl-3"
|
className="flex items-center gap-2 p-2 hover:bg-accent rounded cursor-pointer border-l-2 border-muted pl-3"
|
||||||
onClick={() => onTaskClick?.(task)}
|
onClick={() => onTaskClick?.(task)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onTaskClick?.(task);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={`View task: ${task.title}`}
|
||||||
>
|
>
|
||||||
<CheckSquare className="h-4 w-4 text-purple-500" />
|
<CheckSquare className="h-4 w-4 text-purple-500" aria-hidden="true" />
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium hover:underline">{task.title}</span>
|
<span className="text-sm font-medium">{task.title}</span>
|
||||||
<StatusBadge status={task.status} size="xs" />
|
<StatusBadge status={task.status} size="xs" />
|
||||||
<PriorityBadge priority={task.priority} size="xs" />
|
<PriorityBadge priority={task.priority} size="xs" />
|
||||||
</div>
|
</div>
|
||||||
@@ -259,7 +290,7 @@ function TaskNode({ task, onTaskClick }: TaskNodeProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.estimatedHours && (
|
{task.estimatedHours && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground" aria-label={`Estimated: ${task.estimatedHours} hours${task.actualHours ? `, Actual: ${task.actualHours} hours` : ''}`}>
|
||||||
{task.estimatedHours}h
|
{task.estimatedHours}h
|
||||||
{task.actualHours && ` / ${task.actualHours}h`}
|
{task.actualHours && ` / ${task.actualHours}h`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
44
components/ui/empty-state.tsx
Normal file
44
components/ui/empty-state.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
variant?: 'default' | 'outline' | 'secondary';
|
||||||
|
};
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
className
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
'flex flex-col items-center justify-center py-12 px-4',
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
<Icon className="h-16 w-16 text-muted-foreground/50 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">{title}</h3>
|
||||||
|
<p className="text-muted-foreground text-center mb-6 max-w-sm">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
{action && (
|
||||||
|
<Button
|
||||||
|
onClick={action.onClick}
|
||||||
|
variant={action.variant || 'default'}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
components/ui/loading.tsx
Normal file
37
components/ui/loading.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface LoadingProps {
|
||||||
|
className?: string;
|
||||||
|
text?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Loading({ className, text, size = 'md' }: LoadingProps) {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'h-4 w-4',
|
||||||
|
md: 'h-6 w-6',
|
||||||
|
lg: 'h-8 w-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center justify-center', className)}>
|
||||||
|
<Loader2 className={cn(sizeClasses[size], 'animate-spin mr-2')} />
|
||||||
|
{text && <span className="text-muted-foreground">{text}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full page loading component
|
||||||
|
export function LoadingPage({ text = 'Loading...' }: { text?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[400px] items-center justify-center">
|
||||||
|
<Loading text={text} size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline loading for buttons or small areas
|
||||||
|
export function LoadingInline({ text }: { text?: string }) {
|
||||||
|
return <Loading text={text} size="sm" />;
|
||||||
|
}
|
||||||
10
components/ui/skip-link.tsx
Normal file
10
components/ui/skip-link.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export function SkipLink() {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Skip to main content
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,29 @@
|
|||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
import nextTs from "eslint-config-next/typescript";
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
import jsxA11y from "eslint-plugin-jsx-a11y";
|
||||||
|
|
||||||
const eslintConfig = defineConfig([
|
const eslintConfig = defineConfig([
|
||||||
...nextVitals,
|
...nextVitals,
|
||||||
...nextTs,
|
...nextTs,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// Enable recommended jsx-a11y rules (plugin already included in nextVitals)
|
||||||
|
...jsxA11y.configs.recommended.rules,
|
||||||
|
// Enforce stricter accessibility rules
|
||||||
|
"jsx-a11y/anchor-is-valid": "error",
|
||||||
|
"jsx-a11y/alt-text": "error",
|
||||||
|
"jsx-a11y/aria-props": "error",
|
||||||
|
"jsx-a11y/aria-proptypes": "error",
|
||||||
|
"jsx-a11y/aria-unsupported-elements": "error",
|
||||||
|
"jsx-a11y/role-has-required-aria-props": "error",
|
||||||
|
"jsx-a11y/role-supports-aria-props": "error",
|
||||||
|
"jsx-a11y/label-has-associated-control": "error",
|
||||||
|
"jsx-a11y/click-events-have-key-events": "warn",
|
||||||
|
"jsx-a11y/no-static-element-interactions": "warn",
|
||||||
|
"jsx-a11y/interactive-supports-focus": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
// Override default ignores of eslint-config-next.
|
// Override default ignores of eslint-config-next.
|
||||||
globalIgnores([
|
globalIgnores([
|
||||||
// Default ignores of eslint-config-next:
|
// Default ignores of eslint-config-next:
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { projectsApi } from '@/lib/api/projects';
|
import { projectsApi } from '@/lib/api/projects';
|
||||||
import type { Project, CreateProjectDto, UpdateProjectDto } from '@/types/project';
|
import type { Project, CreateProjectDto, UpdateProjectDto } from '@/types/project';
|
||||||
|
import { logger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
export function useProjects(page = 1, pageSize = 20) {
|
export function useProjects(page = 1, pageSize = 20) {
|
||||||
return useQuery<Project[]>({
|
return useQuery<Project[]>({
|
||||||
queryKey: ['projects', page, pageSize],
|
queryKey: ['projects', page, pageSize],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log('[useProjects] Fetching projects...', { page, pageSize });
|
logger.debug('[useProjects] Fetching projects...', { page, pageSize });
|
||||||
try {
|
try {
|
||||||
const result = await projectsApi.getAll(page, pageSize);
|
const result = await projectsApi.getAll(page, pageSize);
|
||||||
console.log('[useProjects] Fetch successful:', result);
|
logger.debug('[useProjects] Fetch successful:', result);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[useProjects] Fetch failed:', error);
|
logger.error('[useProjects] Fetch failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,19 +3,20 @@ import { storiesApi } from '@/lib/api/pm';
|
|||||||
import { epicsApi } from '@/lib/api/pm';
|
import { epicsApi } from '@/lib/api/pm';
|
||||||
import type { Story, CreateStoryDto, UpdateStoryDto, WorkItemStatus } from '@/types/project';
|
import type { Story, CreateStoryDto, UpdateStoryDto, WorkItemStatus } from '@/types/project';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { logger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
// ==================== Query Hooks ====================
|
// ==================== Query Hooks ====================
|
||||||
export function useStories(epicId?: string) {
|
export function useStories(epicId?: string) {
|
||||||
return useQuery<Story[]>({
|
return useQuery<Story[]>({
|
||||||
queryKey: ['stories', epicId],
|
queryKey: ['stories', epicId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log('[useStories] Fetching stories...', { epicId });
|
logger.debug('[useStories] Fetching stories...', { epicId });
|
||||||
try {
|
try {
|
||||||
const result = await storiesApi.list(epicId);
|
const result = await storiesApi.list(epicId);
|
||||||
console.log('[useStories] Fetch successful:', result);
|
logger.debug('[useStories] Fetch successful:', result);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[useStories] Fetch failed:', error);
|
logger.error('[useStories] Fetch failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -33,12 +34,12 @@ export function useProjectStories(projectId?: string) {
|
|||||||
throw new Error('projectId is required');
|
throw new Error('projectId is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[useProjectStories] Fetching all stories for project...', { projectId });
|
logger.debug('[useProjectStories] Fetching all stories for project...', { projectId });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First fetch all epics for the project
|
// First fetch all epics for the project
|
||||||
const epics = await epicsApi.list(projectId);
|
const epics = await epicsApi.list(projectId);
|
||||||
console.log('[useProjectStories] Epics fetched:', epics.length);
|
logger.debug('[useProjectStories] Epics fetched:', epics.length);
|
||||||
|
|
||||||
// Then fetch stories for each epic
|
// Then fetch stories for each epic
|
||||||
const storiesPromises = epics.map((epic) => storiesApi.list(epic.id));
|
const storiesPromises = epics.map((epic) => storiesApi.list(epic.id));
|
||||||
@@ -46,11 +47,11 @@ export function useProjectStories(projectId?: string) {
|
|||||||
|
|
||||||
// Flatten the array of arrays into a single array
|
// Flatten the array of arrays into a single array
|
||||||
const allStories = storiesArrays.flat();
|
const allStories = storiesArrays.flat();
|
||||||
console.log('[useProjectStories] Total stories fetched:', allStories.length);
|
logger.debug('[useProjectStories] Total stories fetched:', allStories.length);
|
||||||
|
|
||||||
return allStories;
|
return allStories;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[useProjectStories] Fetch failed:', error);
|
logger.error('[useProjectStories] Fetch failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -84,7 +85,7 @@ export function useCreateStory() {
|
|||||||
toast.success('Story created successfully!');
|
toast.success('Story created successfully!');
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
console.error('[useCreateStory] Error:', error);
|
logger.error('[useCreateStory] Error:', error);
|
||||||
toast.error(error.response?.data?.detail || 'Failed to create story');
|
toast.error(error.response?.data?.detail || 'Failed to create story');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -109,7 +110,7 @@ export function useUpdateStory() {
|
|||||||
return { previousStory };
|
return { previousStory };
|
||||||
},
|
},
|
||||||
onError: (error: any, variables, context) => {
|
onError: (error: any, variables, context) => {
|
||||||
console.error('[useUpdateStory] Error:', error);
|
logger.error('[useUpdateStory] Error:', error);
|
||||||
|
|
||||||
if (context?.previousStory) {
|
if (context?.previousStory) {
|
||||||
queryClient.setQueryData(['stories', variables.id], context.previousStory);
|
queryClient.setQueryData(['stories', variables.id], context.previousStory);
|
||||||
@@ -138,7 +139,7 @@ export function useDeleteStory() {
|
|||||||
toast.success('Story deleted successfully!');
|
toast.success('Story deleted successfully!');
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
console.error('[useDeleteStory] Error:', error);
|
logger.error('[useDeleteStory] Error:', error);
|
||||||
toast.error(error.response?.data?.detail || 'Failed to delete story');
|
toast.error(error.response?.data?.detail || 'Failed to delete story');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -163,7 +164,7 @@ export function useChangeStoryStatus() {
|
|||||||
return { previousStory };
|
return { previousStory };
|
||||||
},
|
},
|
||||||
onError: (error: any, variables, context) => {
|
onError: (error: any, variables, context) => {
|
||||||
console.error('[useChangeStoryStatus] Error:', error);
|
logger.error('[useChangeStoryStatus] Error:', error);
|
||||||
|
|
||||||
if (context?.previousStory) {
|
if (context?.previousStory) {
|
||||||
queryClient.setQueryData(['stories', variables.id], context.previousStory);
|
queryClient.setQueryData(['stories', variables.id], context.previousStory);
|
||||||
@@ -193,7 +194,7 @@ export function useAssignStory() {
|
|||||||
toast.success('Story assigned successfully!');
|
toast.success('Story assigned successfully!');
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
console.error('[useAssignStory] Error:', error);
|
logger.error('[useAssignStory] Error:', error);
|
||||||
toast.error(error.response?.data?.detail || 'Failed to assign story');
|
toast.error(error.response?.data?.detail || 'Failed to assign story');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,19 +2,20 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { tasksApi } from '@/lib/api/pm';
|
import { tasksApi } from '@/lib/api/pm';
|
||||||
import type { Task, CreateTaskDto, UpdateTaskDto, WorkItemStatus } from '@/types/project';
|
import type { Task, CreateTaskDto, UpdateTaskDto, WorkItemStatus } from '@/types/project';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { logger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
// ==================== Query Hooks ====================
|
// ==================== Query Hooks ====================
|
||||||
export function useTasks(storyId?: string) {
|
export function useTasks(storyId?: string) {
|
||||||
return useQuery<Task[]>({
|
return useQuery<Task[]>({
|
||||||
queryKey: ['tasks', storyId],
|
queryKey: ['tasks', storyId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log('[useTasks] Fetching tasks...', { storyId });
|
logger.debug('[useTasks] Fetching tasks...', { storyId });
|
||||||
try {
|
try {
|
||||||
const result = await tasksApi.list(storyId);
|
const result = await tasksApi.list(storyId);
|
||||||
console.log('[useTasks] Fetch successful:', result);
|
logger.debug('[useTasks] Fetch successful:', result);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[useTasks] Fetch failed:', error);
|
logger.error('[useTasks] Fetch failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -47,7 +48,7 @@ export function useCreateTask() {
|
|||||||
toast.success('Task created successfully!');
|
toast.success('Task created successfully!');
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
console.error('[useCreateTask] Error:', error);
|
logger.error('[useCreateTask] Error:', error);
|
||||||
toast.error(error.response?.data?.detail || 'Failed to create task');
|
toast.error(error.response?.data?.detail || 'Failed to create task');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -72,7 +73,7 @@ export function useUpdateTask() {
|
|||||||
return { previousTask };
|
return { previousTask };
|
||||||
},
|
},
|
||||||
onError: (error: any, variables, context) => {
|
onError: (error: any, variables, context) => {
|
||||||
console.error('[useUpdateTask] Error:', error);
|
logger.error('[useUpdateTask] Error:', error);
|
||||||
|
|
||||||
if (context?.previousTask) {
|
if (context?.previousTask) {
|
||||||
queryClient.setQueryData(['tasks', variables.id], context.previousTask);
|
queryClient.setQueryData(['tasks', variables.id], context.previousTask);
|
||||||
@@ -101,7 +102,7 @@ export function useDeleteTask() {
|
|||||||
toast.success('Task deleted successfully!');
|
toast.success('Task deleted successfully!');
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
console.error('[useDeleteTask] Error:', error);
|
logger.error('[useDeleteTask] Error:', error);
|
||||||
toast.error(error.response?.data?.detail || 'Failed to delete task');
|
toast.error(error.response?.data?.detail || 'Failed to delete task');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -126,7 +127,7 @@ export function useChangeTaskStatus() {
|
|||||||
return { previousTask };
|
return { previousTask };
|
||||||
},
|
},
|
||||||
onError: (error: any, variables, context) => {
|
onError: (error: any, variables, context) => {
|
||||||
console.error('[useChangeTaskStatus] Error:', error);
|
logger.error('[useChangeTaskStatus] Error:', error);
|
||||||
|
|
||||||
if (context?.previousTask) {
|
if (context?.previousTask) {
|
||||||
queryClient.setQueryData(['tasks', variables.id], context.previousTask);
|
queryClient.setQueryData(['tasks', variables.id], context.previousTask);
|
||||||
@@ -156,7 +157,7 @@ export function useAssignTask() {
|
|||||||
toast.success('Task assigned successfully!');
|
toast.success('Task assigned successfully!');
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
console.error('[useAssignTask] Error:', error);
|
logger.error('[useAssignTask] Error:', error);
|
||||||
toast.error(error.response?.data?.detail || 'Failed to assign task');
|
toast.error(error.response?.data?.detail || 'Failed to assign task');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState, useCallback, useRef } from 'react';
|
|||||||
import { SignalRConnectionManager } from '@/lib/signalr/ConnectionManager';
|
import { SignalRConnectionManager } from '@/lib/signalr/ConnectionManager';
|
||||||
import { SIGNALR_CONFIG } from '@/lib/signalr/config';
|
import { SIGNALR_CONFIG } from '@/lib/signalr/config';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import { logger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -32,14 +33,14 @@ export function useNotificationHub() {
|
|||||||
|
|
||||||
// 监听通知事件
|
// 监听通知事件
|
||||||
manager.on('Notification', (notification: Notification) => {
|
manager.on('Notification', (notification: Notification) => {
|
||||||
console.log('[NotificationHub] Received notification:', notification);
|
logger.debug('[NotificationHub] Received notification:', notification);
|
||||||
setNotifications((prev) => [notification, ...prev].slice(0, 50)); // 保留最近 50 条
|
setNotifications((prev) => [notification, ...prev].slice(0, 50)); // 保留最近 50 条
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on(
|
manager.on(
|
||||||
'NotificationRead',
|
'NotificationRead',
|
||||||
(data: { NotificationId: string; ReadAt: string }) => {
|
(data: { NotificationId: string; ReadAt: string }) => {
|
||||||
console.log('[NotificationHub] Notification read:', data);
|
logger.debug('[NotificationHub] Notification read:', data);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ export function useNotificationHub() {
|
|||||||
try {
|
try {
|
||||||
await managerRef.current.invoke('MarkAsRead', notificationId);
|
await managerRef.current.invoke('MarkAsRead', notificationId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
logger.error(
|
||||||
'[NotificationHub] Error marking notification as read:',
|
'[NotificationHub] Error marking notification as read:',
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SignalRConnectionManager } from '@/lib/signalr/ConnectionManager';
|
|||||||
import { SIGNALR_CONFIG } from '@/lib/signalr/config';
|
import { SIGNALR_CONFIG } from '@/lib/signalr/config';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import type { ProjectHubEventCallbacks } from '@/lib/signalr/types';
|
import type { ProjectHubEventCallbacks } from '@/lib/signalr/types';
|
||||||
|
import { logger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
// Re-export for backward compatibility
|
// Re-export for backward compatibility
|
||||||
interface UseProjectHubOptions extends ProjectHubEventCallbacks {}
|
interface UseProjectHubOptions extends ProjectHubEventCallbacks {}
|
||||||
@@ -30,17 +31,17 @@ export function useProjectHub(projectId?: string, options?: UseProjectHubOptions
|
|||||||
// PROJECT EVENTS (3)
|
// PROJECT EVENTS (3)
|
||||||
// ============================================
|
// ============================================
|
||||||
manager.on('ProjectCreated', (data: any) => {
|
manager.on('ProjectCreated', (data: any) => {
|
||||||
console.log('[ProjectHub] Project created:', data);
|
logger.debug('[ProjectHub] Project created:', data);
|
||||||
options?.onProjectCreated?.(data);
|
options?.onProjectCreated?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('ProjectUpdated', (data: any) => {
|
manager.on('ProjectUpdated', (data: any) => {
|
||||||
console.log('[ProjectHub] Project updated:', data);
|
logger.debug('[ProjectHub] Project updated:', data);
|
||||||
options?.onProjectUpdated?.(data);
|
options?.onProjectUpdated?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('ProjectArchived', (data: any) => {
|
manager.on('ProjectArchived', (data: any) => {
|
||||||
console.log('[ProjectHub] Project archived:', data);
|
logger.debug('[ProjectHub] Project archived:', data);
|
||||||
options?.onProjectArchived?.(data);
|
options?.onProjectArchived?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,17 +49,17 @@ export function useProjectHub(projectId?: string, options?: UseProjectHubOptions
|
|||||||
// EPIC EVENTS (3)
|
// EPIC EVENTS (3)
|
||||||
// ============================================
|
// ============================================
|
||||||
manager.on('EpicCreated', (data: any) => {
|
manager.on('EpicCreated', (data: any) => {
|
||||||
console.log('[ProjectHub] Epic created:', data);
|
logger.debug('[ProjectHub] Epic created:', data);
|
||||||
options?.onEpicCreated?.(data);
|
options?.onEpicCreated?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('EpicUpdated', (data: any) => {
|
manager.on('EpicUpdated', (data: any) => {
|
||||||
console.log('[ProjectHub] Epic updated:', data);
|
logger.debug('[ProjectHub] Epic updated:', data);
|
||||||
options?.onEpicUpdated?.(data);
|
options?.onEpicUpdated?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('EpicDeleted', (data: any) => {
|
manager.on('EpicDeleted', (data: any) => {
|
||||||
console.log('[ProjectHub] Epic deleted:', data);
|
logger.debug('[ProjectHub] Epic deleted:', data);
|
||||||
options?.onEpicDeleted?.(data);
|
options?.onEpicDeleted?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,17 +67,17 @@ export function useProjectHub(projectId?: string, options?: UseProjectHubOptions
|
|||||||
// STORY EVENTS (3)
|
// STORY EVENTS (3)
|
||||||
// ============================================
|
// ============================================
|
||||||
manager.on('StoryCreated', (data: any) => {
|
manager.on('StoryCreated', (data: any) => {
|
||||||
console.log('[ProjectHub] Story created:', data);
|
logger.debug('[ProjectHub] Story created:', data);
|
||||||
options?.onStoryCreated?.(data);
|
options?.onStoryCreated?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('StoryUpdated', (data: any) => {
|
manager.on('StoryUpdated', (data: any) => {
|
||||||
console.log('[ProjectHub] Story updated:', data);
|
logger.debug('[ProjectHub] Story updated:', data);
|
||||||
options?.onStoryUpdated?.(data);
|
options?.onStoryUpdated?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('StoryDeleted', (data: any) => {
|
manager.on('StoryDeleted', (data: any) => {
|
||||||
console.log('[ProjectHub] Story deleted:', data);
|
logger.debug('[ProjectHub] Story deleted:', data);
|
||||||
options?.onStoryDeleted?.(data);
|
options?.onStoryDeleted?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,22 +85,22 @@ export function useProjectHub(projectId?: string, options?: UseProjectHubOptions
|
|||||||
// TASK EVENTS (4)
|
// TASK EVENTS (4)
|
||||||
// ============================================
|
// ============================================
|
||||||
manager.on('TaskCreated', (data: any) => {
|
manager.on('TaskCreated', (data: any) => {
|
||||||
console.log('[ProjectHub] Task created:', data);
|
logger.debug('[ProjectHub] Task created:', data);
|
||||||
options?.onTaskCreated?.(data);
|
options?.onTaskCreated?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('TaskUpdated', (data: any) => {
|
manager.on('TaskUpdated', (data: any) => {
|
||||||
console.log('[ProjectHub] Task updated:', data);
|
logger.debug('[ProjectHub] Task updated:', data);
|
||||||
options?.onTaskUpdated?.(data);
|
options?.onTaskUpdated?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('TaskDeleted', (data: any) => {
|
manager.on('TaskDeleted', (data: any) => {
|
||||||
console.log('[ProjectHub] Task deleted:', data);
|
logger.debug('[ProjectHub] Task deleted:', data);
|
||||||
options?.onTaskDeleted?.(data);
|
options?.onTaskDeleted?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('TaskAssigned', (data: any) => {
|
manager.on('TaskAssigned', (data: any) => {
|
||||||
console.log('[ProjectHub] Task assigned:', data);
|
logger.debug('[ProjectHub] Task assigned:', data);
|
||||||
options?.onTaskAssigned?.(data);
|
options?.onTaskAssigned?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,22 +108,22 @@ export function useProjectHub(projectId?: string, options?: UseProjectHubOptions
|
|||||||
// LEGACY ISSUE EVENTS (Backward Compatibility)
|
// LEGACY ISSUE EVENTS (Backward Compatibility)
|
||||||
// ============================================
|
// ============================================
|
||||||
manager.on('IssueCreated', (data: any) => {
|
manager.on('IssueCreated', (data: any) => {
|
||||||
console.log('[ProjectHub] Issue created:', data);
|
logger.debug('[ProjectHub] Issue created:', data);
|
||||||
options?.onIssueCreated?.(data);
|
options?.onIssueCreated?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('IssueUpdated', (data: any) => {
|
manager.on('IssueUpdated', (data: any) => {
|
||||||
console.log('[ProjectHub] Issue updated:', data);
|
logger.debug('[ProjectHub] Issue updated:', data);
|
||||||
options?.onIssueUpdated?.(data);
|
options?.onIssueUpdated?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('IssueDeleted', (data: any) => {
|
manager.on('IssueDeleted', (data: any) => {
|
||||||
console.log('[ProjectHub] Issue deleted:', data);
|
logger.debug('[ProjectHub] Issue deleted:', data);
|
||||||
options?.onIssueDeleted?.(data);
|
options?.onIssueDeleted?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('IssueStatusChanged', (data: any) => {
|
manager.on('IssueStatusChanged', (data: any) => {
|
||||||
console.log('[ProjectHub] Issue status changed:', data);
|
logger.debug('[ProjectHub] Issue status changed:', data);
|
||||||
options?.onIssueStatusChanged?.(data);
|
options?.onIssueStatusChanged?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,17 +131,17 @@ export function useProjectHub(projectId?: string, options?: UseProjectHubOptions
|
|||||||
// USER COLLABORATION EVENTS
|
// USER COLLABORATION EVENTS
|
||||||
// ============================================
|
// ============================================
|
||||||
manager.on('UserJoinedProject', (data: any) => {
|
manager.on('UserJoinedProject', (data: any) => {
|
||||||
console.log('[ProjectHub] User joined:', data);
|
logger.debug('[ProjectHub] User joined:', data);
|
||||||
options?.onUserJoinedProject?.(data);
|
options?.onUserJoinedProject?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('UserLeftProject', (data: any) => {
|
manager.on('UserLeftProject', (data: any) => {
|
||||||
console.log('[ProjectHub] User left:', data);
|
logger.debug('[ProjectHub] User left:', data);
|
||||||
options?.onUserLeftProject?.(data);
|
options?.onUserLeftProject?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('TypingIndicator', (data: any) => {
|
manager.on('TypingIndicator', (data: any) => {
|
||||||
console.log('[ProjectHub] Typing indicator:', data);
|
logger.debug('[ProjectHub] Typing indicator:', data);
|
||||||
options?.onTypingIndicator?.(data);
|
options?.onTypingIndicator?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,9 +159,9 @@ export function useProjectHub(projectId?: string, options?: UseProjectHubOptions
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await managerRef.current.invoke('JoinProject', projectId);
|
await managerRef.current.invoke('JoinProject', projectId);
|
||||||
console.log(`[ProjectHub] Joined project ${projectId}`);
|
logger.debug(`[ProjectHub] Joined project ${projectId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ProjectHub] Error joining project:', error);
|
logger.error('[ProjectHub] Error joining project:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -170,9 +171,9 @@ export function useProjectHub(projectId?: string, options?: UseProjectHubOptions
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await managerRef.current.invoke('LeaveProject', projectId);
|
await managerRef.current.invoke('LeaveProject', projectId);
|
||||||
console.log(`[ProjectHub] Left project ${projectId}`);
|
logger.debug(`[ProjectHub] Left project ${projectId}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ProjectHub] Error leaving project:', error);
|
logger.error('[ProjectHub] Error leaving project:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -189,7 +190,7 @@ export function useProjectHub(projectId?: string, options?: UseProjectHubOptions
|
|||||||
isTyping
|
isTyping
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ProjectHub] Error sending typing indicator:', error);
|
logger.error('[ProjectHub] Error sending typing indicator:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { SignalRConnectionManager, ConnectionState } from './ConnectionManager';
|
|||||||
import { SIGNALR_CONFIG } from './config';
|
import { SIGNALR_CONFIG } from './config';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { logger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// TYPE DEFINITIONS
|
// TYPE DEFINITIONS
|
||||||
@@ -110,12 +111,12 @@ export function SignalRProvider({
|
|||||||
|
|
||||||
const connect = useCallback(async () => {
|
const connect = useCallback(async () => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
console.warn('[SignalRContext] Cannot connect: user not authenticated');
|
logger.warn('[SignalRContext] Cannot connect: user not authenticated');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (managerRef.current?.state === 'connected') {
|
if (managerRef.current?.state === 'connected') {
|
||||||
console.log('[SignalRContext] Already connected');
|
logger.debug('[SignalRContext] Already connected');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +149,7 @@ export function SignalRProvider({
|
|||||||
try {
|
try {
|
||||||
await manager.start();
|
await manager.start();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SignalRContext] Connection error:', error);
|
logger.error('[SignalRContext] Connection error:', error);
|
||||||
if (showToasts) {
|
if (showToasts) {
|
||||||
toast.error('Failed to connect to real-time updates');
|
toast.error('Failed to connect to real-time updates');
|
||||||
}
|
}
|
||||||
|
|||||||
23
package-lock.json
generated
23
package-lock.json
generated
@@ -31,6 +31,7 @@
|
|||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
"react-error-boundary": "^6.0.0",
|
||||||
"react-hook-form": "^7.66.0",
|
"react-hook-form": "^7.66.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.1",
|
"eslint-config-next": "16.0.1",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
@@ -256,6 +258,15 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.28.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||||
|
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.27.2",
|
"version": "7.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||||
@@ -6837,6 +6848,18 @@
|
|||||||
"react": "^19.2.0"
|
"react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-error-boundary": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.66.0",
|
"version": "7.66.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
"react-error-boundary": "^6.0.0",
|
||||||
"react-hook-form": "^7.66.0",
|
"react-hook-form": "^7.66.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.1",
|
"eslint-config-next": "16.0.1",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
Reference in New Issue
Block a user