diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 03bad35..dfd5253 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -13,6 +13,7 @@ import { useSearchParams } from 'next/navigation'; const loginSchema = z.object({ email: z.string().email('Invalid email address'), password: z.string().min(8, 'Password must be at least 8 characters'), + tenantSlug: z.string().min(1, 'Tenant slug is required'), }); type LoginForm = z.infer; @@ -60,6 +61,20 @@ export default function LoginPage() { )} +
+ + + {errors.tenantSlug && ( +

{errors.tenantSlug.message}

+ )} +
+
{ - 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'); - } - }, - }); + const { data: project, isLoading, error } = useProject(id); + const { data: epics, isLoading: epicsLoading } = useEpics(id); + const deleteProject = useDeleteProject(); + + const handleDelete = async () => { + try { + await deleteProject.mutateAsync(id); + toast.success('Project deleted successfully'); + router.push('/projects'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to delete project'; + toast.error(message); + } + }; if (isLoading) { return ( -
- +
+ +
+
+ + +
+
+ + +
+
+
+
+ + + + + + + + +
+ + + + + + + + + +
); } if (error || !project) { return ( -
-

- Project not found or failed to load. -

+
+ + + Error Loading Project + + {error instanceof Error ? error.message : 'Project not found'} + + + + + + +
); } return (
-
+ {/* Breadcrumb / Back button */} + + + Back to Projects -
+ + + {/* Header */} +
+

{project.name}

- - {project.status} + + {project.key}
-

Key: {project.key}

+
+ + Created {formatDistanceToNow(new Date(project.createdAt), { addSuffix: true })} +
- - - - {project.status === 'Active' && ( - <> - - - - )} + +
-
- - - Description - - -

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

-
-
- - - - Details - - -
- Created - {new Date(project.createdAt).toLocaleDateString()} -
- {project.updatedAt && ( -
- Updated - {new Date(project.updatedAt).toLocaleDateString()} + {/* Content */} +
+ {/* Main content */} +
+ {/* Project details */} + + + Project Details + + +
+

Description

+ {project.description ? ( +

{project.description}

+ ) : ( +

No description provided

+ )}
- )} -
- Status - - {project.status} - -
-
-
+
+
+

Created

+

+ {format(new Date(project.createdAt), 'PPP')} +

+
+
+

Last Updated

+

+ {format(new Date(project.updatedAt), 'PPP')} +

+
+
+ + + + {/* Epics preview */} + + +
+ Epics + +
+ + Track major features and initiatives in this project + +
+ + {epicsLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : epics && epics.length > 0 ? ( +
+ {epics.slice(0, 5).map((epic) => ( + +
+
+

{epic.title}

+
+ + {epic.status} + + + {epic.priority} + +
+
+
+ + ))} + {epics.length > 5 && ( +

+ And {epics.length - 5} more... +

+ )} +
+ ) : ( +
+ +

No epics yet

+ +
+ )} +
+
+
+ + {/* Sidebar */} +
+ {/* Quick actions */} + + + Quick Actions + + + + + + + + {/* Project stats */} + + + Statistics + + +
+ Total Epics + {epics?.length || 0} +
+
+ Active + + {epics?.filter((e) => e.status === 'InProgress').length || 0} + +
+
+ Completed + + {epics?.filter((e) => e.status === 'Done').length || 0} + +
+
+
+
- {/* SignalR Connection Status */} -
-
- - {connectionState === 'connected' ? 'Real-time updates enabled' : 'Connecting...'} - -
- - {/* Dialogs */} - {project && ( - <> - + + + Edit Project + + Update your project details + + + setIsEditDialogOpen(false)} + onCancel={() => setIsEditDialogOpen(false)} /> - - - )} + + + + {/* Delete Confirmation Dialog */} + + + + Are you sure? + + This action cannot be undone. This will permanently delete the project + {project.name} and all its associated data + (epics, stories, and tasks). + + + + Cancel + + Delete Project + + + +
); } diff --git a/app/(dashboard)/projects/page.tsx b/app/(dashboard)/projects/page.tsx index 7006783..e671e3e 100644 --- a/app/(dashboard)/projects/page.tsx +++ b/app/(dashboard)/projects/page.tsx @@ -2,69 +2,66 @@ import { useState } from 'react'; import Link from 'next/link'; -import { Plus, Loader2 } from 'lucide-react'; +import { Plus, FolderKanban, Calendar } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; import { useProjects } from '@/lib/hooks/use-projects'; -import { CreateProjectDialog } from '@/components/features/projects/CreateProjectDialog'; +import { ProjectForm } from '@/components/projects/project-form'; +import { formatDistanceToNow } from 'date-fns'; export default function ProjectsPage() { const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const { data: projects, isLoading, error } = useProjects(); - // Log state for debugging - console.log('[ProjectsPage] State:', { - isLoading, - error, - projects, - apiUrl: process.env.NEXT_PUBLIC_API_URL, - }); - if (isLoading) { return ( -
- +
+
+
+ + +
+ +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + + + + ))} +
); } if (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000/api/v1'; - - console.error('[ProjectsPage] Error loading projects:', error); - return ( -
+
- Failed to Load Projects - Unable to connect to the backend API + Error Loading Projects + + {error instanceof Error ? error.message : 'Failed to load projects'} + - -
-

Error Details:

-

{errorMessage}

-
-
-

API URL:

-

{apiUrl}

-
-
-

Troubleshooting Steps:

-
    -
  • Check if the backend server is running
  • -
  • Verify the API URL in .env.local
  • -
  • Check browser console (F12) for detailed errors
  • -
  • Check network tab (F12) for failed requests
  • -
-
- + +
@@ -73,10 +70,11 @@ export default function ProjectsPage() { return (
+ {/* Header */}

Projects

-

+

Manage your projects and track progress

@@ -86,51 +84,71 @@ export default function ProjectsPage() {
-
- {projects?.map((project) => ( - - - -
-
- {project.name} - {project.key} + {/* Projects Grid */} + {projects && projects.length > 0 ? ( +
+ {projects.map((project) => ( + + + +
+
+ {project.name} +
+ {project.key} +
+
+
- - {project.status} - -
- - -

- {project.description} -

-
- - - ))} + + + {project.description ? ( +

+ {project.description} +

+ ) : ( +

+ No description +

+ )} +
+ + Created {formatDistanceToNow(new Date(project.createdAt), { addSuffix: true })} +
+
+ + + ))} +
+ ) : ( + + + No projects yet + + Get started by creating your first project + + + + )} - {!projects || projects.length === 0 ? ( - - -

- No projects yet. Create your first project to get started. -

-
-
- ) : null} -
- - + {/* Create Project Dialog */} + + + + Create New Project + + Add a new project to organize your work and track progress + + + setIsCreateDialogOpen(false)} + onCancel={() => setIsCreateDialogOpen(false)} + /> + +
); } diff --git a/app/layout.tsx b/app/layout.tsx index 1a035a8..2984359 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { QueryProvider } from "@/lib/providers/query-provider"; import { SignalRProvider } from "@/components/providers/SignalRProvider"; +import { Toaster } from "@/components/ui/sonner"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -30,7 +31,10 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > - {children} + + {children} + + diff --git a/components/projects/project-form.tsx b/components/projects/project-form.tsx new file mode 100644 index 0000000..3926860 --- /dev/null +++ b/components/projects/project-form.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { Button } from '@/components/ui/button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { useCreateProject, useUpdateProject } from '@/lib/hooks/use-projects'; +import type { Project } from '@/types/project'; +import { toast } from 'sonner'; +import { Loader2 } from 'lucide-react'; + +const projectSchema = z.object({ + name: z + .string() + .min(1, 'Name is required') + .max(100, 'Name must be less than 100 characters'), + key: z + .string() + .min(3, 'Key must be at least 3 characters') + .max(10, 'Key must be less than 10 characters') + .regex(/^[A-Z]+$/, 'Key must be uppercase letters only'), + description: z + .string() + .max(500, 'Description must be less than 500 characters') + .optional(), +}); + +type ProjectFormValues = z.infer; + +interface ProjectFormProps { + project?: Project; + onSuccess?: () => void; + onCancel?: () => void; +} + +export function ProjectForm({ project, onSuccess, onCancel }: ProjectFormProps) { + const isEditing = !!project; + const createProject = useCreateProject(); + const updateProject = useUpdateProject(project?.id || ''); + + const form = useForm({ + resolver: zodResolver(projectSchema), + defaultValues: { + name: project?.name || '', + key: project?.key || '', + description: project?.description || '', + }, + }); + + async function onSubmit(data: ProjectFormValues) { + try { + if (isEditing) { + await updateProject.mutateAsync(data); + toast.success('Project updated successfully'); + } else { + await createProject.mutateAsync(data); + toast.success('Project created successfully'); + } + onSuccess?.(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Operation failed'; + toast.error(message); + } + } + + const isLoading = createProject.isPending || updateProject.isPending; + + return ( +
+ + ( + + Project Name * + + + + + The display name for your project + + + + )} + /> + + ( + + Project Key * + + { + // Auto-uppercase + const value = e.target.value.toUpperCase(); + field.onChange(value); + }} + maxLength={10} + /> + + + 3-10 uppercase letters (used in issue IDs like COLA-123) + + + + )} + /> + + ( + + Description + +