Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion frontend/app/profile/_AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
"use client";

import { usePathname, useRouter } from "next/navigation";
import { useState, useTransition } from "react";

import { signOutFromKeycloak } from "@/app/actions";
import { LeftRail } from "@/components/shared/LeftRail";
import { TopBar } from "@/components/shared/TopBar";

const ROUTE_MAP: Record<string, string> = {
"/projects": "my-projects",
};

interface AppShellProps {
userInitials: string;
userHue?: 0 | 1 | 2 | 3;
Expand All @@ -22,9 +27,21 @@ export function AppShell({
userHue = 1,
children,
}: AppShellProps) {
const pathname = usePathname();
const router = useRouter();
const [drawerOpen, setDrawerOpen] = useState(false);
const [, startTransition] = useTransition();

const activeRoute = ROUTE_MAP[pathname] ?? undefined;

function handleNavigate(routeId: string) {
const destinations: Record<string, string> = {
"my-projects": "/projects",
};
const path = destinations[routeId];
if (path) router.push(path);
}

function handleSignOut() {
startTransition(() => {
signOutFromKeycloak();
Expand All @@ -40,7 +57,12 @@ export function AppShell({
onSignOut={handleSignOut}
/>
<div className="flex">
<LeftRail open={drawerOpen} onClose={() => setDrawerOpen(false)} />
<LeftRail
route={activeRoute}
onNavigate={handleNavigate}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
/>
<div className="flex-1 min-w-0">{children}</div>
</div>
</>
Expand Down
71 changes: 71 additions & 0 deletions frontend/app/projects/[projectId]/_SubmitForAnalysisButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use client";

import { useState, useTransition } from "react";

import { cn } from "@/lib/utils";

import { submitForAnalysis } from "./actions";

interface SubmitForAnalysisButtonProps {
projectId: string;
/** Disable the button when the project status does not permit reanalysis. */
disabled?: boolean;
}

/**
* Triggers LLM team analysis for a project by submitting it to the recommendation queue.
* @param projectId - The project to submit
* @param disabled - When true the button is inert (e.g. for Closed / TeamConfirmed projects)
*/
export function SubmitForAnalysisButton({
projectId,
disabled = false,
}: SubmitForAnalysisButtonProps) {
const [isPending, startTransition] = useTransition();
const [feedback, setFeedback] = useState<{
type: "success" | "error";
message: string;
} | null>(null);

function handleClick() {
setFeedback(null);
startTransition(async () => {
const result = await submitForAnalysis(projectId);
if (result.error) {
setFeedback({ type: "error", message: result.error });
} else {
setFeedback({
type: "success",
message: "Submitted — analysis is queued and will complete shortly.",
});
}
});
}

return (
<div className="flex flex-col gap-2">
<button
onClick={handleClick}
disabled={disabled || isPending}
className={cn(
"px-4 py-2 text-[14px] font-medium rounded-sm transition-colors duration-[120ms]",
disabled || isPending
? "bg-portland-stone text-ink-muted cursor-not-allowed"
: "bg-nottingham-blue text-paper hover:bg-nottingham-blue/90",
)}
>
{isPending ? "Submitting…" : "Submit for analysis"}
</button>
{feedback && (
<p
className={cn(
"text-[13px]",
feedback.type === "success" ? "text-green-700" : "text-red-600",
)}
>
{feedback.message}
</p>
)}
</div>
);
}
29 changes: 29 additions & 0 deletions frontend/app/projects/[projectId]/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"use server";

import { triggerRecommendation } from "@/lib/api/projects";
import { ApiError } from "@/lib/api/request";
import { getAccessToken } from "@/lib/auth";

/**
* Submits the project to the LLM analysis queue.
* @param projectId - Project ID to submit
* @returns An empty object on success, or `{ error }` on failure
*/
export async function submitForAnalysis(
projectId: string,
): Promise<{ error?: string }> {
const token = await getAccessToken();
if (!token) return { error: "Not authenticated." };

try {
await triggerRecommendation(projectId, token);
return {};
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
return {
error: "Analysis already queued or project is not in a valid state.",
};
}
return { error: "Failed to submit for analysis. Please try again." };
}
}
161 changes: 161 additions & 0 deletions frontend/app/projects/[projectId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";

import { fetchProject } from "@/lib/api/projects";
import type { ProjectStatus } from "@/lib/api/types";
import { getAccessToken } from "@/lib/auth";
import { cn } from "@/lib/utils";

import { SubmitForAnalysisButton } from "./_SubmitForAnalysisButton";

function toRelativeDate(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const days = Math.floor(diff / 86_400_000);
if (days === 0) return "Today";
if (days === 1) return "Yesterday";
if (days < 30) return `${days} days ago`;
const months = Math.floor(days / 30);
return months === 1 ? "1 month ago" : `${months} months ago`;
}

const STATUS_STYLES: Record<ProjectStatus, string> = {
Draft: "bg-portland-stone text-ink-soft",
Open: "bg-nottingham-blue-5 text-nottingham-blue",
TeamConfirmed: "bg-green-100 text-green-800",
Closed: "bg-portland-stone text-ink-muted",
};

/**
* Displays full project detail and allows the owner to submit the project for LLM analysis.
*/
export default async function ProjectDetailPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const { projectId } = await params;

const token = await getAccessToken();
if (!token) redirect("/");

let project;
try {
project = await fetchProject(projectId, token);
} catch {
notFound();
}

const canSubmit = project.status === "Draft" || project.status === "Open";

return (
<main className="bg-paper px-6 py-10 min-h-[calc(100vh-60px)]">
<div className="max-w-3xl mx-auto flex flex-col gap-8">
{/* Back link */}
<Link
href="/projects"
className="text-[13px] text-ink-soft hover:text-ink transition-colors duration-[120ms] w-fit"
>
← Projects
</Link>

{/* Header */}
<div className="flex flex-col gap-3">
<div className="flex items-start gap-3 flex-wrap">
<h1 className="text-[22px] font-semibold tracking-[-0.02em] text-ink flex-1">
{project.title}
</h1>
<span
className={cn(
"px-2 py-0.5 text-[12px] font-medium rounded-pill shrink-0",
STATUS_STYLES[project.status],
)}
>
{project.status}
</span>
</div>

{/* Metadata row */}
<div className="flex flex-wrap gap-x-5 gap-y-1 text-[13px] text-ink-soft">
<span>Team size: {project.desiredTeamSize}</span>
<span>Timeline: {project.timeline}</span>
<span>Created by {project.createdByUser.displayName}</span>
<span>{toRelativeDate(project.createdAt)}</span>
</div>
</div>

{/* Description */}
<p className="text-[15px] text-ink leading-relaxed whitespace-pre-wrap">
{project.description}
</p>

{/* LLM analysis */}
<div className="flex flex-col gap-2 border border-[var(--border)] rounded-sm p-5">
<h2 className="text-[15px] font-semibold text-ink">Team analysis</h2>
{project.recommendations.length > 0 && (
<p className="text-[13px] text-ink-soft">
{project.recommendations.length === 1
? "1 analysis has been run for this project."
: `${project.recommendations.length} analyses have been run for this project.`}
</p>
)}
{canSubmit ? (
<SubmitForAnalysisButton projectId={project.id} />
) : (
<p className="text-[13px] text-ink-muted">
Analysis is not available for projects with status{" "}
<strong>{project.status}</strong>.
</p>
)}
</div>

{/* Teams */}
{project.teams.length > 0 && (
<div className="flex flex-col gap-3">
<h2 className="text-[15px] font-semibold text-ink">
Proposed teams
</h2>
<div className="flex flex-col gap-3">
{project.teams.map((team) => (
<div
key={team.id}
className="border border-[var(--border)] rounded-sm p-4 flex flex-col gap-2"
>
<div className="flex items-center justify-between">
<span className="text-[13px] font-medium text-ink">
Team
</span>
<span className="text-[12px] text-ink-soft">
{team.status}
</span>
</div>
{team.members.length > 0 ? (
<ul className="flex flex-col gap-1">
{team.members.map((member) => (
<li
key={member.id}
className="text-[13px] text-ink-soft"
>
{member.user.displayName}
{member.projectRole && (
<span className="text-ink-muted">
{" "}
— {member.projectRole}
</span>
)}
</li>
))}
</ul>
) : (
<p className="text-[13px] text-ink-muted">
No members yet.
</p>
)}
</div>
))}
</div>
</div>
)}
</div>
</main>
);
}
33 changes: 31 additions & 2 deletions frontend/lib/api/projects.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { apiRequest } from "./request";
import type { Project, ProjectStatus } from "./types";
import { apiRequest, apiRequestNoContent } from "./request";
import type { Project, ProjectDetail, ProjectStatus } from "./types";

/**
* Fetches all projects, optionally filtered by status.
Expand All @@ -14,3 +14,32 @@ export function fetchProjects(
const query = status ? `?status=${status}` : "";
return apiRequest<Project[]>(`/api/projects${query}`, token);
}

/**
* Fetches full project detail including teams and recommendations.
* @param projectId - Project ID
* @param token - Bearer token from the session
* @returns Project detail with teams and recommendations
*/
export function fetchProject(
projectId: string,
token: string,
): Promise<ProjectDetail> {
return apiRequest<ProjectDetail>(`/api/projects/${projectId}`, token);
}

/**
* Dispatches a team recommendation request for the specified project to the LLM service.
* @param projectId - Project ID
* @param token - Bearer token from the session
*/
export async function triggerRecommendation(
projectId: string,
token: string,
): Promise<void> {
await apiRequestNoContent(
`/api/projects/${projectId}/recommendations`,
token,
{ method: "POST" },
);
}
Loading
Loading