diff --git a/apps/web/src/app/(authenticated)/projects/[id]/page.tsx b/apps/web/src/app/(authenticated)/projects/[id]/page.tsx new file mode 100644 index 0000000..1e79f2b --- /dev/null +++ b/apps/web/src/app/(authenticated)/projects/[id]/page.tsx @@ -0,0 +1,491 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import type { ReactElement } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { ArrowLeft } from "lucide-react"; + +import { MosaicSpinner } from "@/components/ui/MosaicSpinner"; +import { fetchProject, type ProjectDetail } from "@/lib/api/projects"; +import { useWorkspaceId } from "@/lib/hooks"; + +interface BadgeStyle { + label: string; + bg: string; + color: string; +} + +interface StatusBadgeProps { + style: BadgeStyle; +} + +interface MetaItemProps { + label: string; + value: string; +} + +function getProjectStatusStyle(status: string): BadgeStyle { + switch (status) { + case "PLANNING": + return { label: "Planning", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" }; + case "ACTIVE": + return { label: "Active", bg: "rgba(20,184,166,0.15)", color: "var(--success)" }; + case "PAUSED": + return { label: "Paused", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" }; + case "COMPLETED": + return { label: "Completed", bg: "rgba(139,92,246,0.15)", color: "var(--purple)" }; + case "ARCHIVED": + return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + default: + return { label: status, bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + } +} + +function getPriorityStyle(priority: string | null | undefined): BadgeStyle { + switch (priority) { + case "HIGH": + return { label: "High", bg: "rgba(229,72,77,0.15)", color: "var(--danger)" }; + case "MEDIUM": + return { label: "Medium", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" }; + case "LOW": + return { label: "Low", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + default: + return { label: "Unspecified", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + } +} + +function getTaskStatusStyle(status: string): BadgeStyle { + switch (status) { + case "NOT_STARTED": + return { label: "Not Started", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" }; + case "IN_PROGRESS": + return { label: "In Progress", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" }; + case "PAUSED": + return { label: "Paused", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + case "COMPLETED": + return { label: "Completed", bg: "rgba(20,184,166,0.15)", color: "var(--success)" }; + case "ARCHIVED": + return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + default: + return { label: status, bg: "rgba(143,157,183,0.15)", color: "var(--muted)" }; + } +} + +function formatDate(iso: string | null | undefined): string { + if (!iso) return "Not set"; + + try { + return new Date(iso).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + } catch { + return iso; + } +} + +function formatDateTime(iso: string | null | undefined): string { + if (!iso) return "Not set"; + + try { + return new Date(iso).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); + } catch { + return iso; + } +} + +function toFriendlyErrorMessage(error: unknown): string { + const fallback = "We had trouble loading this project. Please try again when you're ready."; + + if (!(error instanceof Error)) { + return fallback; + } + + const message = error.message.trim(); + if (message.toLowerCase().includes("not found")) { + return "Project not found. It may have been deleted or you may not have access to it."; + } + + return message || fallback; +} + +function StatusBadge({ style: statusStyle }: StatusBadgeProps): ReactElement { + return ( + + {statusStyle.label} + + ); +} + +function MetaItem({ label, value }: MetaItemProps): ReactElement { + return ( +
+

{label}

+

{value}

+
+ ); +} + +export default function ProjectDetailPage(): ReactElement { + const router = useRouter(); + const params = useParams<{ id: string | string[] }>(); + const workspaceId = useWorkspaceId(); + const rawProjectId = params.id; + const projectId = Array.isArray(rawProjectId) ? (rawProjectId[0] ?? null) : rawProjectId; + + const [project, setProject] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const loadProject = useCallback(async (id: string, wsId: string): Promise => { + try { + setIsLoading(true); + setError(null); + const data = await fetchProject(id, wsId); + setProject(data); + } catch (err: unknown) { + console.error("[ProjectDetail] Failed to fetch project:", err); + setProject(null); + setError(toFriendlyErrorMessage(err)); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + if (!projectId) { + setProject(null); + setError("The project link is invalid. Please return to the projects page."); + setIsLoading(false); + return; + } + + if (!workspaceId) { + setProject(null); + setError("Select a workspace to view this project."); + setIsLoading(false); + return; + } + + const id = projectId; + const wsId = workspaceId; + let cancelled = false; + + async function load(): Promise { + try { + setIsLoading(true); + setError(null); + const data = await fetchProject(id, wsId); + if (!cancelled) { + setProject(data); + } + } catch (err: unknown) { + console.error("[ProjectDetail] Failed to fetch project:", err); + if (!cancelled) { + setProject(null); + setError(toFriendlyErrorMessage(err)); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + } + + void load(); + + return (): void => { + cancelled = true; + }; + }, [projectId, workspaceId]); + + function handleRetry(): void { + if (!projectId || !workspaceId) return; + void loadProject(projectId, workspaceId); + } + + function handleBack(): void { + router.push("/projects"); + } + + const projectStatus = project ? getProjectStatusStyle(project.status) : null; + const projectPriority = project ? getPriorityStyle(project.priority) : null; + const dueDate = project?.dueDate ?? project?.endDate; + const creator = + project?.creator.name && project.creator.name.trim().length > 0 + ? `${project.creator.name} (${project.creator.email})` + : (project?.creator.email ?? "Unknown"); + + return ( +
+ + + {isLoading ? ( +
+ +
+ ) : error !== null ? ( +
+

{error}

+
+ + +
+
+ ) : project === null ? ( +
+

Project details are not available.

+
+ ) : ( +
+
+
+
+

+ {project.name} +

+
+
+ {projectStatus && } + {projectPriority && } +
+
+ + {project.description ? ( +

+ {project.description} +

+ ) : ( +

+ No description provided. +

+ )} + +
+ + + + + + +
+
+ +
+

+ Tasks ({String(project._count.tasks)}) +

+ + {project.tasks.length === 0 ? ( +

+ No tasks yet for this project. +

+ ) : ( +
+ {project.tasks.map((task, index) => ( +
+
+
+

+ {task.title} +

+

+ Due: {formatDate(task.dueDate)} +

+
+
+ + +
+
+
+ ))} +
+ )} +
+ +
+

+ Events ({String(project._count.events)}) +

+ + {project.events.length === 0 ? ( +

+ No events scheduled for this project. +

+ ) : ( +
+ {project.events.map((event, index) => ( +
+

+ {event.title} +

+

+ {formatDateTime(event.startTime)} - {formatDateTime(event.endTime)} +

+
+ ))} +
+ )} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/lib/api/projects.ts b/apps/web/src/lib/api/projects.ts index 8d68448..d746d91 100644 --- a/apps/web/src/lib/api/projects.ts +++ b/apps/web/src/lib/api/projects.ts @@ -25,7 +25,9 @@ export interface Project { name: string; description: string | null; status: ProjectStatus; + priority?: string | null; startDate: string | null; + dueDate?: string | null; endDate: string | null; creatorId: string; domainId: string | null; @@ -35,6 +37,54 @@ export interface Project { updatedAt: string; } +/** + * Minimal creator details included on project detail response + */ +export interface ProjectCreator { + id: string; + name: string | null; + email: string; +} + +/** + * Task row included on project detail response + */ +export interface ProjectTaskSummary { + id: string; + title: string; + status: string; + priority: string; + dueDate: string | null; +} + +/** + * Event row included on project detail response + */ +export interface ProjectEventSummary { + id: string; + title: string; + startTime: string; + endTime: string | null; +} + +/** + * Counts included on project detail response + */ +export interface ProjectDetailCounts { + tasks: number; + events: number; +} + +/** + * Single-project response with related details + */ +export interface ProjectDetail extends Project { + creator: ProjectCreator; + tasks: ProjectTaskSummary[]; + events: ProjectEventSummary[]; + _count: ProjectDetailCounts; +} + /** * DTO for creating a new project */ @@ -72,8 +122,8 @@ export async function fetchProjects(workspaceId?: string): Promise { /** * Fetch a single project by ID */ -export async function fetchProject(id: string, workspaceId?: string): Promise { - return apiGet(`/api/projects/${id}`, workspaceId); +export async function fetchProject(id: string, workspaceId?: string): Promise { + return apiGet(`/api/projects/${id}`, workspaceId); } /**