feat(projects): add project tracking workspace
This commit is contained in:
parent
bc6358ce5f
commit
9bf984fc96
35 changed files with 3488 additions and 1 deletions
1
bun.lock
1
bun.lock
|
|
@ -7,6 +7,7 @@
|
|||
"dependencies": {
|
||||
"@ai-stack/payloadcms": "^3.2.30",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@payloadcms/db-postgres": "3.88.0",
|
||||
"@payloadcms/email-nodemailer": "3.88.0",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"generate:version-info": "bun run src/scripts/generateVersionInfo.ts",
|
||||
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
|
||||
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
||||
"migrate": "bun run src/scripts/run-migrations.ts",
|
||||
"db": "cross-env NODE_OPTIONS=--no-deprecation pnpm drizzle-kit",
|
||||
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
|
||||
"staging": "cross-env NODE_ENV=test NODE_OPTIONS=--no-deprecation next start",
|
||||
|
|
@ -28,6 +29,7 @@
|
|||
"dependencies": {
|
||||
"@ai-stack/payloadcms": "^3.2.30",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@payloadcms/db-postgres": "3.88.0",
|
||||
"@payloadcms/email-nodemailer": "3.88.0",
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export async function createTicket(input: {
|
|||
description: string;
|
||||
category?: TicketCategory;
|
||||
priority?: TicketPriority;
|
||||
projectId?: number;
|
||||
}): Promise<ActionResult<Ticket>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
|
@ -95,6 +96,7 @@ export async function createTicket(input: {
|
|||
priority,
|
||||
status: "open",
|
||||
reporter: userId,
|
||||
project: input.projectId ?? null,
|
||||
activity: [
|
||||
{
|
||||
type: "system",
|
||||
|
|
@ -102,7 +104,7 @@ export async function createTicket(input: {
|
|||
created_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as Ticket;
|
||||
|
|
|
|||
154
src/app/(frontend)/projects/[key]/board/page.tsx
Normal file
154
src/app/(frontend)/projects/[key]/board/page.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import config from "@payload-config";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getPayload } from "payload";
|
||||
import { ProjectBoardView } from "@/components/frontend/projects/ProjectBoardView";
|
||||
|
||||
export const metadata = {
|
||||
title: "Board — Polaris Task Force",
|
||||
};
|
||||
|
||||
const KANBAN_STATUSES = [
|
||||
{ status: "open", label: "Open" },
|
||||
{ status: "in_progress", label: "In Progress" },
|
||||
{ status: "in_review", label: "In Review" },
|
||||
{ status: "in_testing", label: "Testing" },
|
||||
{ status: "resolved", label: "Done" },
|
||||
] as const;
|
||||
|
||||
export default async function BoardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ key: string }>;
|
||||
}) {
|
||||
const { key } = await params;
|
||||
const headers = await nextHeaders();
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||
|
||||
if (!user) {
|
||||
redirect(`/login?returnTo=/projects/${key}/board`);
|
||||
}
|
||||
|
||||
const projectResult = await payload.find({
|
||||
collection: "projects" as any,
|
||||
where: { key: { equals: key.toUpperCase() } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
if (projectResult.docs.length === 0) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const project = projectResult.docs[0] as any;
|
||||
|
||||
const [ticketResult, sprintResult, labelResult] = await Promise.all([
|
||||
payload.find({
|
||||
collection: "tickets" as any,
|
||||
where: { project: { equals: project.id } },
|
||||
depth: 2,
|
||||
limit: 500,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
payload.find({
|
||||
collection: "sprints" as any,
|
||||
where: { project: { equals: project.id } },
|
||||
sort: "-startDate",
|
||||
depth: 0,
|
||||
limit: 50,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
payload.find({
|
||||
collection: "labels" as any,
|
||||
where: {
|
||||
or: [
|
||||
{ project: { equals: project.id } },
|
||||
{ project: { equals: null } },
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 100,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const ticketsByStatus = new Map<string, any[]>();
|
||||
for (const col of KANBAN_STATUSES) {
|
||||
ticketsByStatus.set(col.status, []);
|
||||
}
|
||||
|
||||
for (const ticket of ticketResult.docs) {
|
||||
const t = ticket as any;
|
||||
const status = t.status || "open";
|
||||
const col = ticketsByStatus.get(status);
|
||||
if (col) {
|
||||
col.push({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
type: t.type || "task",
|
||||
storyPoints: t.storyPoints,
|
||||
labels: (t.labels || []).map((l: any) =>
|
||||
typeof l === "object" && l
|
||||
? { id: l.id, name: l.name, color: l.color }
|
||||
: { id: l, name: "", color: "#6366f1" },
|
||||
),
|
||||
assignee: t.assignee
|
||||
? typeof t.assignee === "object"
|
||||
? {
|
||||
id: t.assignee.id,
|
||||
displayName:
|
||||
t.assignee.payloadDisplayName || t.assignee.username,
|
||||
}
|
||||
: { id: t.assignee, displayName: `User #${t.assignee}` }
|
||||
: null,
|
||||
reporter: t.reporter
|
||||
? typeof t.reporter === "object"
|
||||
? {
|
||||
id: t.reporter.id,
|
||||
displayName:
|
||||
t.reporter.payloadDisplayName || t.reporter.username,
|
||||
}
|
||||
: { id: t.reporter, displayName: `User #${t.reporter}` }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const columns = KANBAN_STATUSES.map((col) => ({
|
||||
status: col.status,
|
||||
label: col.label,
|
||||
tickets: ticketsByStatus.get(col.status) || [],
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-5">
|
||||
<ProjectBoardView
|
||||
project={{
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
key: project.key,
|
||||
}}
|
||||
columns={columns}
|
||||
sprints={sprintResult.docs.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
startDate: s.startDate,
|
||||
endDate: s.endDate,
|
||||
}))}
|
||||
labels={labelResult.docs.map((l: any) => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
color: l.color,
|
||||
project: l.project,
|
||||
}))}
|
||||
currentUserId={user.id as number}
|
||||
isAdmin={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/app/(frontend)/projects/[key]/page.tsx
Normal file
33
src/app/(frontend)/projects/[key]/page.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import config from "@payload-config";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getPayload } from "payload";
|
||||
|
||||
export default async function ProjectPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ key: string }>;
|
||||
}) {
|
||||
const { key } = await params;
|
||||
const headers = await nextHeaders();
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||
|
||||
if (!user) {
|
||||
redirect(`/login?returnTo=/projects/${key}`);
|
||||
}
|
||||
|
||||
const result = await payload.find({
|
||||
collection: "projects" as any,
|
||||
where: { key: { equals: key.toUpperCase() } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
if (result.docs.length === 0) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
redirect(`/projects/${key}/board`);
|
||||
}
|
||||
98
src/app/(frontend)/projects/[key]/releases/page.tsx
Normal file
98
src/app/(frontend)/projects/[key]/releases/page.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import config from "@payload-config";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getPayload } from "payload";
|
||||
import { ReleasesView } from "@/components/frontend/projects/ReleasesView";
|
||||
|
||||
export const metadata = {
|
||||
title: "Releases — Polaris Task Force",
|
||||
};
|
||||
|
||||
export default async function ReleasesPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ key: string }>;
|
||||
}) {
|
||||
const { key } = await params;
|
||||
const headers = await nextHeaders();
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||
|
||||
if (!user) {
|
||||
redirect(`/login?returnTo=/projects/${key}/releases`);
|
||||
}
|
||||
|
||||
const projectResult = await payload.find({
|
||||
collection: "projects" as any,
|
||||
where: { key: { equals: key.toUpperCase() } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
if (projectResult.docs.length === 0) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const project = projectResult.docs[0] as any;
|
||||
|
||||
const [releaseResult, ticketResult] = await Promise.all([
|
||||
payload.find({
|
||||
collection: "releases" as any,
|
||||
where: { project: { equals: project.id } },
|
||||
sort: "-releaseDate",
|
||||
depth: 0,
|
||||
limit: 100,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
payload.find({
|
||||
collection: "tickets" as any,
|
||||
where: { project: { equals: project.id } },
|
||||
depth: 0,
|
||||
limit: 500,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const tickets = ticketResult.docs as any[];
|
||||
|
||||
const releases = releaseResult.docs.map((r: any) => {
|
||||
const releaseTickets = tickets.filter((ticket: any) => {
|
||||
const releaseId = typeof ticket.release === "object" && ticket.release !== null
|
||||
? ticket.release.id
|
||||
: ticket.release;
|
||||
return releaseId === r.id;
|
||||
});
|
||||
|
||||
const ticketsTotal = releaseTickets.length;
|
||||
const ticketsResolved = releaseTickets.filter(
|
||||
(ticket: any) => ticket.status === "resolved",
|
||||
).length;
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
status: r.status,
|
||||
targetVersion: r.targetVersion,
|
||||
releaseDate: r.releaseDate,
|
||||
description: r.description,
|
||||
createdAt: r.createdAt,
|
||||
ticketsTotal,
|
||||
ticketsResolved,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-5">
|
||||
<ReleasesView
|
||||
project={{
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
key: project.key,
|
||||
}}
|
||||
releases={releases}
|
||||
isAdmin={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
src/app/(frontend)/projects/[key]/sprints/page.tsx
Normal file
104
src/app/(frontend)/projects/[key]/sprints/page.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import config from "@payload-config";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getPayload } from "payload";
|
||||
import { SprintsView } from "@/components/frontend/projects/SprintsView";
|
||||
|
||||
export const metadata = {
|
||||
title: "Sprints — Polaris Task Force",
|
||||
};
|
||||
|
||||
export default async function SprintsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ key: string }>;
|
||||
}) {
|
||||
const { key } = await params;
|
||||
const headers = await nextHeaders();
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||
|
||||
if (!user) {
|
||||
redirect(`/login?returnTo=/projects/${key}/sprints`);
|
||||
}
|
||||
|
||||
const projectResult = await payload.find({
|
||||
collection: "projects" as any,
|
||||
where: { key: { equals: key.toUpperCase() } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
if (projectResult.docs.length === 0) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const project = projectResult.docs[0] as any;
|
||||
|
||||
const [sprintResult, ticketResult] = await Promise.all([
|
||||
payload.find({
|
||||
collection: "sprints" as any,
|
||||
where: { project: { equals: project.id } },
|
||||
sort: "-startDate",
|
||||
depth: 0,
|
||||
limit: 100,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
payload.find({
|
||||
collection: "tickets" as any,
|
||||
where: { project: { equals: project.id } },
|
||||
depth: 0,
|
||||
limit: 500,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const tickets = ticketResult.docs as any[];
|
||||
|
||||
const sprints = sprintResult.docs.map((sprint: any) => {
|
||||
const sprintTickets = tickets.filter(
|
||||
(ticket: any) => {
|
||||
const sprintId = typeof ticket.sprint === "object" && ticket.sprint !== null
|
||||
? ticket.sprint.id
|
||||
: ticket.sprint;
|
||||
return sprintId === sprint.id;
|
||||
},
|
||||
);
|
||||
|
||||
const storyPointsTotal = sprintTickets.reduce(
|
||||
(sum: number, ticket: any) => sum + (ticket.storyPoints ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const storyPointsCompleted = sprintTickets
|
||||
.filter((ticket: any) => ticket.status === "resolved")
|
||||
.reduce((sum: number, ticket: any) => sum + (ticket.storyPoints ?? 0), 0);
|
||||
|
||||
return {
|
||||
id: sprint.id,
|
||||
name: sprint.name,
|
||||
status: sprint.status,
|
||||
goal: sprint.goal,
|
||||
startDate: sprint.startDate,
|
||||
endDate: sprint.endDate,
|
||||
capacity: sprint.capacity,
|
||||
storyPointsTotal,
|
||||
storyPointsCompleted,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-5">
|
||||
<SprintsView
|
||||
project={{
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
key: project.key,
|
||||
}}
|
||||
sprints={sprints}
|
||||
isAdmin={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
290
src/app/(frontend)/projects/actions.ts
Normal file
290
src/app/(frontend)/projects/actions.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
"use server";
|
||||
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { plaintextToLexical } from "@/lib/tickets/lexical";
|
||||
|
||||
export interface ActionResult<T = undefined> {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
async function authenticate() {
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config: payloadConfig });
|
||||
const { headers } = await import("next/headers");
|
||||
const hdrs = await headers();
|
||||
const { user } = await payload.auth({ headers: hdrs, canSetHeaders: false });
|
||||
if (!user) throw new Error("Unauthorized");
|
||||
return { payload, user };
|
||||
}
|
||||
|
||||
export async function createProject(input: {
|
||||
name: string;
|
||||
key: string;
|
||||
description?: string;
|
||||
leadId?: number;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
const name = input.name.trim();
|
||||
if (!name) throw new Error("Project name is required.");
|
||||
|
||||
let key = input.key.trim().toUpperCase();
|
||||
if (!key) throw new Error("Project key is required.");
|
||||
if (key.length > 10) throw new Error("Project key must be 10 characters or fewer.");
|
||||
if (!/^[A-Z0-9]+$/.test(key)) throw new Error("Project key must be uppercase alphanumeric.");
|
||||
|
||||
// Check uniqueness
|
||||
const existing = await payload.find({
|
||||
collection: "projects" as any,
|
||||
where: { key: { equals: key } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
if (existing.docs.length > 0) throw new Error(`Project key "${key}" already exists.`);
|
||||
|
||||
const description = input.description?.trim();
|
||||
const data: Record<string, any> = {
|
||||
name,
|
||||
key,
|
||||
status: "active",
|
||||
lead: input.leadId ?? null,
|
||||
};
|
||||
if (description) {
|
||||
data.description = plaintextToLexical(description);
|
||||
}
|
||||
|
||||
const project = await payload.create({
|
||||
collection: "projects" as any,
|
||||
data,
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.ProjectCreate,
|
||||
message: `Project "${name}" (${key}) created`,
|
||||
actor: user.id as number,
|
||||
});
|
||||
|
||||
return { success: true, data: project };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return message === "Unauthorized"
|
||||
? { success: false, error: "You must be logged in." }
|
||||
: { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSprint(input: {
|
||||
name: string;
|
||||
projectId: number;
|
||||
goal?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
capacity?: number;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
const name = input.name.trim();
|
||||
if (!name) throw new Error("Sprint name is required.");
|
||||
|
||||
const sprint = await payload.create({
|
||||
collection: "sprints" as any,
|
||||
data: {
|
||||
name,
|
||||
project: input.projectId,
|
||||
goal: input.goal?.trim() || null,
|
||||
startDate: input.startDate || null,
|
||||
endDate: input.endDate || null,
|
||||
status: "planning",
|
||||
capacity: input.capacity ?? null,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.SprintCreate,
|
||||
message: `Sprint "${name}" created`,
|
||||
actor: user.id as number,
|
||||
});
|
||||
|
||||
return { success: true, data: sprint };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return message === "Unauthorized"
|
||||
? { success: false, error: "You must be logged in." }
|
||||
: { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function startSprint(input: {
|
||||
sprintId: number;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
const sprint = await payload.update({
|
||||
collection: "sprints" as any,
|
||||
id: input.sprintId,
|
||||
data: { status: "active" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.SprintStart,
|
||||
message: `Sprint "${(sprint as any).name}" started`,
|
||||
actor: user.id as number,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return message === "Unauthorized"
|
||||
? { success: false, error: "You must be logged in." }
|
||||
: { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeSprint(input: {
|
||||
sprintId: number;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
const sprint = await payload.update({
|
||||
collection: "sprints" as any,
|
||||
id: input.sprintId,
|
||||
data: { status: "completed" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.SprintComplete,
|
||||
message: `Sprint "${(sprint as any).name}" completed`,
|
||||
actor: user.id as number,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return message === "Unauthorized"
|
||||
? { success: false, error: "You must be logged in." }
|
||||
: { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function startRelease(input: {
|
||||
releaseId: number;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
const release = await payload.update({
|
||||
collection: "releases" as any,
|
||||
id: input.releaseId,
|
||||
data: { status: "in_progress" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.ReleaseStart,
|
||||
message: `Release "${(release as any).name}" started`,
|
||||
actor: user.id as number,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return message === "Unauthorized"
|
||||
? { success: false, error: "You must be logged in." }
|
||||
: { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeRelease(input: {
|
||||
releaseId: number;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
const release = await payload.update({
|
||||
collection: "releases" as any,
|
||||
id: input.releaseId,
|
||||
data: { status: "released", releaseDate: new Date().toISOString() },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.ReleaseComplete,
|
||||
message: `Release "${(release as any).name}" released`,
|
||||
actor: user.id as number,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return message === "Unauthorized"
|
||||
? { success: false, error: "You must be logged in." }
|
||||
: { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRelease(input: {
|
||||
name: string;
|
||||
projectId: number;
|
||||
description?: string;
|
||||
targetVersion?: string;
|
||||
releaseDate?: string;
|
||||
}): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
|
||||
const name = input.name.trim();
|
||||
if (!name) throw new Error("Release name is required.");
|
||||
|
||||
const data: Record<string, any> = {
|
||||
name,
|
||||
project: input.projectId,
|
||||
status: "draft",
|
||||
targetVersion: input.targetVersion?.trim() || null,
|
||||
releaseDate: input.releaseDate || null,
|
||||
};
|
||||
if (input.description?.trim()) {
|
||||
data.description = plaintextToLexical(input.description.trim());
|
||||
}
|
||||
|
||||
const release = await payload.create({
|
||||
collection: "releases" as any,
|
||||
data,
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
await emitGameEvent(payload, {
|
||||
type: EventTypes.ReleaseCreate,
|
||||
message: `Release "${name}" created`,
|
||||
actor: user.id as number,
|
||||
});
|
||||
|
||||
return { success: true, data: release };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return message === "Unauthorized"
|
||||
? { success: false, error: "You must be logged in." }
|
||||
: { success: false, error: message };
|
||||
}
|
||||
}
|
||||
52
src/app/(frontend)/projects/page.tsx
Normal file
52
src/app/(frontend)/projects/page.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import config from "@payload-config";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getPayload } from "payload";
|
||||
import { ProjectsView } from "@/components/frontend/projects/ProjectsView";
|
||||
|
||||
export const metadata = {
|
||||
title: "Projects — Polaris Task Force",
|
||||
};
|
||||
|
||||
export default async function ProjectsPage() {
|
||||
const headers = await nextHeaders();
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||
|
||||
if (!user) {
|
||||
redirect("/login?returnTo=/projects");
|
||||
}
|
||||
|
||||
const projects = await payload.find({
|
||||
collection: "projects" as any,
|
||||
sort: "name",
|
||||
depth: 1,
|
||||
limit: 100,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-5">
|
||||
<ProjectsView
|
||||
projects={projects.docs.map((p: any) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
key: p.key,
|
||||
status: p.status,
|
||||
lead: p.lead
|
||||
? {
|
||||
id: p.lead.id,
|
||||
displayName:
|
||||
p.lead.payloadDisplayName || p.lead.username,
|
||||
}
|
||||
: null,
|
||||
description: p.description,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
}))}
|
||||
currentUserId={user.id as number}
|
||||
isAdmin={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
src/app/(frontend)/projects/search/page.tsx
Normal file
71
src/app/(frontend)/projects/search/page.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import config from "@payload-config";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getPayload } from "payload";
|
||||
import { ProjectSearchView } from "@/components/frontend/projects/ProjectSearchView";
|
||||
|
||||
export const metadata = {
|
||||
title: "Search — Polaris Task Force",
|
||||
};
|
||||
|
||||
export default async function SearchPage() {
|
||||
const headers = await nextHeaders();
|
||||
const payload = await getPayload({ config });
|
||||
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||
|
||||
if (!user) {
|
||||
redirect("/login?returnTo=/projects/search");
|
||||
}
|
||||
|
||||
const [ticketResult, labelResult] = await Promise.all([
|
||||
payload.find({
|
||||
collection: "tickets" as any,
|
||||
where: { project: { exists: true } },
|
||||
depth: 2,
|
||||
limit: 500,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
payload.find({
|
||||
collection: "labels" as any,
|
||||
depth: 0,
|
||||
limit: 200,
|
||||
overrideAccess: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const tickets = (ticketResult.docs as any[]).map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
type: t.type || "task",
|
||||
storyPoints: t.storyPoints,
|
||||
project: t.project
|
||||
? typeof t.project === "object"
|
||||
? { id: t.project.id, name: t.project.name, key: t.project.key }
|
||||
: { id: t.project, name: `Project #${t.project}`, key: `P${t.project}` }
|
||||
: { id: 0, name: "No project", key: "NONE" },
|
||||
labels: (t.labels || []).map((l: any) =>
|
||||
typeof l === "object" && l
|
||||
? { id: l.id, name: l.name, color: l.color }
|
||||
: { id: l, name: "", color: "#6366f1" },
|
||||
),
|
||||
assignee: t.assignee
|
||||
? typeof t.assignee === "object"
|
||||
? { id: t.assignee.id, displayName: t.assignee.payloadDisplayName || t.assignee.username }
|
||||
: { id: t.assignee, displayName: `User #${t.assignee}` }
|
||||
: null,
|
||||
}));
|
||||
|
||||
const labels = (labelResult.docs as any[]).map((l) => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
color: l.color,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-5">
|
||||
<ProjectSearchView tickets={tickets} allLabels={labels} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/collections/projects/Labels.ts
Normal file
49
src/collections/projects/Labels.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { CollectionConfig } from "payload";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
|
||||
export const Labels: CollectionConfig = {
|
||||
slug: "labels",
|
||||
admin: {
|
||||
group: "Projects",
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "color", "project"],
|
||||
},
|
||||
access: {
|
||||
read: ({ req }) => !!req.user,
|
||||
create: async ({ req }) => {
|
||||
if (!req.user) return false;
|
||||
return await hasPermission(req.payload, req.user, "labels:create");
|
||||
},
|
||||
update: async ({ req }) => {
|
||||
if (!req.user) return false;
|
||||
return await hasPermission(req.payload, req.user, "labels:update");
|
||||
},
|
||||
delete: async ({ req }) => {
|
||||
if (!req.user) return false;
|
||||
return await hasPermission(req.payload, req.user, "labels:delete");
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "color",
|
||||
type: "text",
|
||||
defaultValue: "#6366f1",
|
||||
admin: {
|
||||
description: "Hex color code.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project",
|
||||
type: "relationship",
|
||||
relationTo: "projects",
|
||||
admin: {
|
||||
description: "Null for global labels.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
66
src/collections/projects/Projects.ts
Normal file
66
src/collections/projects/Projects.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type { CollectionConfig } from "payload";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
|
||||
export const Projects: CollectionConfig = {
|
||||
slug: "projects",
|
||||
admin: {
|
||||
group: "Projects",
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "key", "status", "lead", "updatedAt"],
|
||||
listSearchableFields: ["name", "key"],
|
||||
},
|
||||
access: {
|
||||
read: ({ req }) => !!req.user,
|
||||
create: ({ req }) => !!req.user,
|
||||
update: async ({ req }) => {
|
||||
if (!req.user) return false;
|
||||
return await hasPermission(req.payload, req.user, "projects:update");
|
||||
},
|
||||
delete: async ({ req }) => {
|
||||
if (!req.user) return false;
|
||||
return await hasPermission(req.payload, req.user, "projects:delete");
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "key",
|
||||
type: "text",
|
||||
required: true,
|
||||
unique: true,
|
||||
admin: {
|
||||
description: "Short project key (e.g. PTF). Max 10 uppercase alphanumeric characters.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
type: "richText",
|
||||
},
|
||||
{
|
||||
name: "lead",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
defaultValue: "active",
|
||||
options: [
|
||||
{ label: "Active", value: "active" },
|
||||
{ label: "Archived", value: "archived" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "defaultAssignee",
|
||||
type: "relationship",
|
||||
relationTo: "users",
|
||||
admin: {
|
||||
description: "Default assignee for new tickets in this project.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
68
src/collections/projects/Releases.ts
Normal file
68
src/collections/projects/Releases.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { CollectionConfig } from "payload";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
|
||||
export const Releases: CollectionConfig = {
|
||||
slug: "releases",
|
||||
admin: {
|
||||
group: "Projects",
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "project", "targetVersion", "status", "releaseDate"],
|
||||
},
|
||||
access: {
|
||||
read: ({ req }) => !!req.user,
|
||||
create: async ({ req }) => {
|
||||
if (!req.user) return false;
|
||||
return await hasPermission(req.payload, req.user, "releases:create");
|
||||
},
|
||||
update: async ({ req }) => {
|
||||
if (!req.user) return false;
|
||||
return await hasPermission(req.payload, req.user, "releases:update");
|
||||
},
|
||||
delete: async ({ req }) => {
|
||||
if (!req.user) return false;
|
||||
return await hasPermission(req.payload, req.user, "releases:delete");
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "project",
|
||||
type: "relationship",
|
||||
relationTo: "projects",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
type: "richText",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
defaultValue: "draft",
|
||||
options: [
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "In Progress", value: "in_progress" },
|
||||
{ label: "Released", value: "released" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "releaseDate",
|
||||
type: "date",
|
||||
admin: {
|
||||
date: { pickerAppearance: "dayAndTime" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "targetVersion",
|
||||
type: "text",
|
||||
admin: {
|
||||
description: "Semver string, e.g. 2.3.0",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
69
src/collections/projects/Sprints.ts
Normal file
69
src/collections/projects/Sprints.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import type { CollectionConfig } from "payload";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
|
||||
export const Sprints: CollectionConfig = {
|
||||
slug: "sprints",
|
||||
admin: {
|
||||
group: "Projects",
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "project", "status", "startDate", "endDate"],
|
||||
},
|
||||
access: {
|
||||
read: ({ req }) => !!req.user,
|
||||
create: async ({ req }) => {
|
||||
if (!req.user) return false;
|
||||
return await hasPermission(req.payload, req.user, "sprints:create");
|
||||
},
|
||||
update: async ({ req }) => {
|
||||
if (!req.user) return false;
|
||||
return await hasPermission(req.payload, req.user, "sprints:update");
|
||||
},
|
||||
delete: async ({ req }) => {
|
||||
if (!req.user) return false;
|
||||
return await hasPermission(req.payload, req.user, "sprints:delete");
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "project",
|
||||
type: "relationship",
|
||||
relationTo: "projects",
|
||||
required: true,
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: "goal",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "startDate",
|
||||
type: "date",
|
||||
admin: { date: { pickerAppearance: "dayAndTime" } },
|
||||
},
|
||||
{
|
||||
name: "endDate",
|
||||
type: "date",
|
||||
admin: { date: { pickerAppearance: "dayAndTime" } },
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
type: "select",
|
||||
defaultValue: "planning",
|
||||
options: [
|
||||
{ label: "Planning", value: "planning" },
|
||||
{ label: "Active", value: "active" },
|
||||
{ label: "Completed", value: "completed" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "capacity",
|
||||
type: "number",
|
||||
admin: { description: "Story point capacity for this sprint." },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -239,5 +239,109 @@ export const Tickets: CollectionConfig = {
|
|||
date: { pickerAppearance: "dayAndTime" },
|
||||
},
|
||||
},
|
||||
|
||||
// --- Project tracking fields (Phase 1) ---
|
||||
{
|
||||
name: "project",
|
||||
type: "relationship",
|
||||
relationTo: "projects",
|
||||
admin: {
|
||||
description: "Project this ticket belongs to. Null = standalone/backlog.",
|
||||
},
|
||||
index: true,
|
||||
},
|
||||
{
|
||||
name: "sprint",
|
||||
type: "relationship",
|
||||
relationTo: "sprints",
|
||||
admin: {
|
||||
description: "Sprint this ticket is assigned to.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "release",
|
||||
type: "relationship",
|
||||
relationTo: "releases",
|
||||
admin: {
|
||||
description: "Release this ticket targets.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "parentTicket",
|
||||
type: "relationship",
|
||||
relationTo: "tickets",
|
||||
admin: {
|
||||
description: "Parent ticket (for subtasks / epic children).",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "labels",
|
||||
type: "relationship",
|
||||
relationTo: "labels",
|
||||
hasMany: true,
|
||||
admin: {
|
||||
description: "Labels for filtering and organization.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
type: "select",
|
||||
defaultValue: "task",
|
||||
options: [
|
||||
{ label: "Task", value: "task" },
|
||||
{ label: "Bug", value: "bug" },
|
||||
{ label: "Story", value: "story" },
|
||||
{ label: "Epic", value: "epic" },
|
||||
{ label: "Improvement", value: "improvement" },
|
||||
],
|
||||
admin: {
|
||||
description: "Issue type.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "storyPoints",
|
||||
type: "number",
|
||||
min: 0,
|
||||
admin: {
|
||||
description: "Fibonacci story point estimate.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "estimate",
|
||||
type: "number",
|
||||
min: 0,
|
||||
admin: {
|
||||
description: "Time estimate in hours.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "startDate",
|
||||
type: "date",
|
||||
admin: {
|
||||
description: "When work starts (for Gantt).",
|
||||
date: { pickerAppearance: "dayAndTime" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dueDate",
|
||||
type: "date",
|
||||
admin: {
|
||||
description: "When work is due (for Gantt).",
|
||||
date: { pickerAppearance: "dayAndTime" },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resolution",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Fixed", value: "fixed" },
|
||||
{ label: "Won't Fix", value: "wontfix" },
|
||||
{ label: "Duplicate", value: "duplicate" },
|
||||
{ label: "Invalid", value: "invalid" },
|
||||
],
|
||||
admin: {
|
||||
description: "How the ticket was resolved.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import {
|
|||
Backpack,
|
||||
Bolt,
|
||||
Crosshair,
|
||||
Kanban,
|
||||
LifeBuoy,
|
||||
Search,
|
||||
Send,
|
||||
Shield,
|
||||
UserCog,
|
||||
|
|
@ -47,6 +49,16 @@ const data = {
|
|||
url: "/helpdesk",
|
||||
icon: LifeBuoy,
|
||||
},
|
||||
{
|
||||
title: "Projects",
|
||||
url: "/projects",
|
||||
icon: Kanban,
|
||||
},
|
||||
{
|
||||
title: "Search",
|
||||
url: "/projects/search",
|
||||
icon: Search,
|
||||
},
|
||||
],
|
||||
navIntelligence: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|||
interface CreateTicketDialogProps {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
projectId?: number;
|
||||
}
|
||||
|
||||
function isTicketCategory(value: string): value is TicketCategory {
|
||||
|
|
@ -50,6 +51,7 @@ function isTicketPriority(value: string): value is TicketPriority {
|
|||
export function CreateTicketDialog({
|
||||
open: externalOpen,
|
||||
onOpenChange: externalOnOpenChange,
|
||||
projectId,
|
||||
}: CreateTicketDialogProps = {}) {
|
||||
const router = useRouter();
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
|
|
@ -90,6 +92,7 @@ export function CreateTicketDialog({
|
|||
description: trimmedDescription,
|
||||
category,
|
||||
priority,
|
||||
projectId,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
|
|
|
|||
147
src/components/frontend/projects/CreateProjectDialog.tsx
Normal file
147
src/components/frontend/projects/CreateProjectDialog.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { createProject } from "@/app/(frontend)/projects/actions";
|
||||
|
||||
interface CreateProjectDialogProps {
|
||||
readonly open: boolean;
|
||||
readonly onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CreateProjectDialog({ open, onOpenChange }: CreateProjectDialogProps) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [key, setKey] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function reset() {
|
||||
setName("");
|
||||
setKey("");
|
||||
setDescription("");
|
||||
setPending(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const trimmedName = name.trim();
|
||||
const trimmedKey = key.trim().toUpperCase();
|
||||
if (!trimmedName) {
|
||||
setError("Project name is required.");
|
||||
return;
|
||||
}
|
||||
if (!trimmedKey) {
|
||||
setError("Project key is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await createProject({
|
||||
name: trimmedName,
|
||||
key: trimmedKey,
|
||||
description: description.trim(),
|
||||
});
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Could not create the project.");
|
||||
setPending(false);
|
||||
return;
|
||||
}
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
toast.success("Project created");
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Could not create the project.");
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
onOpenChange(nextOpen);
|
||||
if (!nextOpen) reset();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create project</DialogTitle>
|
||||
<DialogDescription>Set up a project space for tickets, sprints, and releases.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="project-name">Name</Label>
|
||||
<Input
|
||||
id="project-name"
|
||||
placeholder="Project name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="project-key">Key</Label>
|
||||
<Input
|
||||
id="project-key"
|
||||
placeholder="POLARIS"
|
||||
maxLength={10}
|
||||
value={key}
|
||||
onChange={(event) => setKey(event.target.value.toUpperCase().slice(0, 10))}
|
||||
disabled={pending}
|
||||
className="font-mono uppercase"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Up to 10 characters. Keys are stored in uppercase.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="project-description">Description</Label>
|
||||
<Textarea
|
||||
id="project-description"
|
||||
rows={4}
|
||||
placeholder="What is this project about?"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Creating..." : "Create project"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
157
src/components/frontend/projects/CreateReleaseDialog.tsx
Normal file
157
src/components/frontend/projects/CreateReleaseDialog.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { createRelease } from "@/app/(frontend)/projects/actions";
|
||||
|
||||
interface CreateReleaseDialogProps {
|
||||
readonly projectId: number;
|
||||
readonly open: boolean;
|
||||
readonly onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CreateReleaseDialog({ projectId, open, onOpenChange }: CreateReleaseDialogProps) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [targetVersion, setTargetVersion] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [releaseDate, setReleaseDate] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function reset() {
|
||||
setName("");
|
||||
setTargetVersion("");
|
||||
setDescription("");
|
||||
setReleaseDate("");
|
||||
setPending(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setError("Release name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await createRelease({
|
||||
projectId,
|
||||
name: trimmedName,
|
||||
targetVersion: targetVersion.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
releaseDate: releaseDate || undefined,
|
||||
});
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Could not create the release.");
|
||||
setPending(false);
|
||||
return;
|
||||
}
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
toast.success("Release created");
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Could not create the release.");
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
onOpenChange(nextOpen);
|
||||
if (!nextOpen) reset();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create release</DialogTitle>
|
||||
<DialogDescription>Track a version milestone for this project.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="release-name">Name</Label>
|
||||
<Input
|
||||
id="release-name"
|
||||
placeholder="Operation Nightfall"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="release-version">Target version</Label>
|
||||
<Input
|
||||
id="release-version"
|
||||
placeholder="v1.0.0"
|
||||
value={targetVersion}
|
||||
onChange={(event) => setTargetVersion(event.target.value)}
|
||||
disabled={pending}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="release-date">Release date</Label>
|
||||
<Input
|
||||
id="release-date"
|
||||
type="date"
|
||||
value={releaseDate}
|
||||
onChange={(event) => setReleaseDate(event.target.value)}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="release-description">Description</Label>
|
||||
<Textarea
|
||||
id="release-description"
|
||||
rows={4}
|
||||
placeholder="What belongs in this release?"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Creating..." : "Create release"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
177
src/components/frontend/projects/CreateSprintDialog.tsx
Normal file
177
src/components/frontend/projects/CreateSprintDialog.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { createSprint } from "@/app/(frontend)/projects/actions";
|
||||
|
||||
interface CreateSprintDialogProps {
|
||||
readonly projectId: number;
|
||||
readonly open: boolean;
|
||||
readonly onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CreateSprintDialog({ projectId, open, onOpenChange }: CreateSprintDialogProps) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [goal, setGoal] = useState("");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [capacity, setCapacity] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function reset() {
|
||||
setName("");
|
||||
setGoal("");
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setCapacity("");
|
||||
setPending(false);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const trimmedName = name.trim();
|
||||
const parsedCapacity = capacity ? Number(capacity) : null;
|
||||
if (!trimmedName) {
|
||||
setError("Sprint name is required.");
|
||||
return;
|
||||
}
|
||||
if (parsedCapacity !== null && (!Number.isInteger(parsedCapacity) || parsedCapacity < 0)) {
|
||||
setError("Capacity must be a whole number of points.");
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await createSprint({
|
||||
projectId,
|
||||
name: trimmedName,
|
||||
goal: goal.trim() || undefined,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
capacity: parsedCapacity ?? undefined,
|
||||
});
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Could not create the sprint.");
|
||||
setPending(false);
|
||||
return;
|
||||
}
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
toast.success("Sprint created");
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "Could not create the sprint.");
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
onOpenChange(nextOpen);
|
||||
if (!nextOpen) reset();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create sprint</DialogTitle>
|
||||
<DialogDescription>Define a focused delivery window for this project.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="sprint-name">Name</Label>
|
||||
<Input
|
||||
id="sprint-name"
|
||||
placeholder="Sprint 01"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="sprint-goal">Goal</Label>
|
||||
<Textarea
|
||||
id="sprint-goal"
|
||||
rows={3}
|
||||
placeholder="What should this sprint deliver?"
|
||||
value={goal}
|
||||
onChange={(event) => setGoal(event.target.value)}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="sprint-start">Start date</Label>
|
||||
<Input
|
||||
id="sprint-start"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(event) => setStartDate(event.target.value)}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="sprint-end">End date</Label>
|
||||
<Input
|
||||
id="sprint-end"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(event) => setEndDate(event.target.value)}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="sprint-capacity">Capacity</Label>
|
||||
<Input
|
||||
id="sprint-capacity"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="40"
|
||||
value={capacity}
|
||||
onChange={(event) => setCapacity(event.target.value)}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Creating..." : "Create sprint"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
198
src/components/frontend/projects/KanbanBoard.tsx
Normal file
198
src/components/frontend/projects/KanbanBoard.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import type { DragEndEvent, DragStartEvent, UniqueIdentifier } from "@dnd-kit/core";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { FilterIcon } from "lucide-react";
|
||||
import { updateTicketStatus } from "@/app/(frontend)/helpdesk/actions";
|
||||
import { TICKET_STATUSES, type TicketStatus } from "@/lib/tickets/ticketMeta";
|
||||
import { KanbanColumn } from "./KanbanColumn";
|
||||
import { KanbanFilters } from "./KanbanFilters";
|
||||
import { KanbanTicketOverlay } from "./KanbanTicketCard";
|
||||
|
||||
export interface KanbanTicket {
|
||||
readonly id: number;
|
||||
readonly title: string;
|
||||
readonly status: string;
|
||||
readonly priority: string;
|
||||
readonly type: string;
|
||||
readonly storyPoints?: number | null;
|
||||
readonly sprintId?: number | null;
|
||||
readonly sprint?: { readonly id: number } | number | null;
|
||||
readonly labels: readonly { id: number; name: string; color: string }[];
|
||||
readonly assignee: { id: number; displayName: string } | null;
|
||||
readonly reporter: { id: number; displayName: string } | null;
|
||||
}
|
||||
|
||||
export interface KanbanColumn {
|
||||
readonly status: string;
|
||||
readonly label: string;
|
||||
readonly tickets: readonly KanbanTicket[];
|
||||
}
|
||||
|
||||
export type SprintFilter = "all" | number;
|
||||
|
||||
export interface KanbanFiltersProps {
|
||||
readonly sprints: readonly { id: number; name: string; status: string; startDate?: string; endDate?: string }[];
|
||||
readonly labels: readonly { id: number; name: string; color: string; project?: number | null }[];
|
||||
readonly sprintFilter: SprintFilter;
|
||||
readonly selectedLabelIds: readonly number[];
|
||||
readonly onSprintFilterChange: (value: SprintFilter) => void;
|
||||
readonly onLabelToggle: (labelId: number) => void;
|
||||
}
|
||||
|
||||
export interface KanbanBoardProps {
|
||||
readonly project: { id: number; name: string; key: string };
|
||||
readonly columns: readonly KanbanColumn[];
|
||||
readonly sprints: readonly { id: number; name: string; status: string; startDate?: string; endDate?: string }[];
|
||||
readonly labels: readonly { id: number; name: string; color: string; project?: number | null }[];
|
||||
readonly currentUserId: number;
|
||||
readonly isAdmin: boolean;
|
||||
readonly showFilters?: boolean;
|
||||
readonly sprintFilter?: SprintFilter;
|
||||
readonly selectedLabelIds?: readonly number[];
|
||||
readonly onSprintFilterChange?: (value: SprintFilter) => void;
|
||||
readonly onLabelToggle?: (labelId: number) => void;
|
||||
}
|
||||
|
||||
function toNumber(value: UniqueIdentifier): number | null {
|
||||
if (typeof value === "number") return Number.isInteger(value) ? value : null;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function toTicketStatus(value: UniqueIdentifier): TicketStatus | null {
|
||||
const status = String(value);
|
||||
return TICKET_STATUSES.some((option) => option.value === status)
|
||||
? TICKET_STATUSES.find((option) => option.value === status)?.value ?? null
|
||||
: null;
|
||||
}
|
||||
|
||||
function ticketSprintId(ticket: KanbanTicket): number | null {
|
||||
if (ticket.sprintId != null) return ticket.sprintId;
|
||||
if (typeof ticket.sprint === "number") return ticket.sprint;
|
||||
return ticket.sprint?.id ?? null;
|
||||
}
|
||||
|
||||
export function KanbanBoard({
|
||||
project,
|
||||
columns,
|
||||
sprints,
|
||||
labels,
|
||||
currentUserId: _currentUserId,
|
||||
isAdmin: _isAdmin,
|
||||
showFilters = true,
|
||||
sprintFilter: controlledSprintFilter,
|
||||
selectedLabelIds: controlledLabelIds,
|
||||
onSprintFilterChange,
|
||||
onLabelToggle,
|
||||
}: KanbanBoardProps) {
|
||||
const router = useRouter();
|
||||
const [internalSprintFilter, setInternalSprintFilter] = useState<SprintFilter>("all");
|
||||
const [internalLabelIds, setInternalLabelIds] = useState<readonly number[]>([]);
|
||||
const [activeTicket, setActiveTicket] = useState<KanbanTicket | null>(null);
|
||||
const sprintFilter = controlledSprintFilter ?? internalSprintFilter;
|
||||
const selectedLabelIds = controlledLabelIds ?? internalLabelIds;
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const allTickets = useMemo(() => columns.flatMap((column) => column.tickets), [columns]);
|
||||
const ticketsById = useMemo(() => new Map(allTickets.map((ticket) => [ticket.id, ticket])), [allTickets]);
|
||||
const filteredTicketsByColumn = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
columns.map((column) => [
|
||||
column.status,
|
||||
column.tickets.filter((ticket) => {
|
||||
const matchesSprint = sprintFilter === "all" || ticketSprintId(ticket) === sprintFilter;
|
||||
const matchesLabels =
|
||||
selectedLabelIds.length === 0 || ticket.labels.some((label) => selectedLabelIds.includes(label.id));
|
||||
return matchesSprint && matchesLabels;
|
||||
}),
|
||||
]),
|
||||
),
|
||||
[columns, selectedLabelIds, sprintFilter],
|
||||
);
|
||||
|
||||
function setSprintFilter(value: SprintFilter) {
|
||||
onSprintFilterChange?.(value);
|
||||
if (!onSprintFilterChange) setInternalSprintFilter(value);
|
||||
}
|
||||
|
||||
function toggleLabel(labelId: number) {
|
||||
onLabelToggle?.(labelId);
|
||||
if (!onLabelToggle) {
|
||||
setInternalLabelIds((current) =>
|
||||
current.includes(labelId) ? current.filter((id) => id !== labelId) : [...current, labelId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
const ticketId = toNumber(event.active.id);
|
||||
setActiveTicket(ticketId === null ? null : (ticketsById.get(ticketId) ?? null));
|
||||
}
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
const ticketId = toNumber(event.active.id);
|
||||
const nextStatus = event.over ? toTicketStatus(event.over.id) : null;
|
||||
setActiveTicket(null);
|
||||
if (ticketId === null || nextStatus === null) return;
|
||||
|
||||
const ticket = ticketsById.get(ticketId);
|
||||
if (!ticket || ticket.status === nextStatus) return;
|
||||
|
||||
try {
|
||||
const result = await updateTicketStatus(ticketId, nextStatus);
|
||||
if (!result.success) {
|
||||
toast.error(result.error ?? "Could not update the ticket status.");
|
||||
return;
|
||||
}
|
||||
toast.success(`Ticket moved to ${nextStatus.replaceAll("_", " ")}`);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Could not update the ticket status.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-4" aria-label={`${project.name} Kanban board`}>
|
||||
{showFilters && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FilterIcon className="size-4" data-icon="inline-start" />
|
||||
<span>Board filters</span>
|
||||
</div>
|
||||
<KanbanFilters
|
||||
sprints={sprints}
|
||||
labels={labels}
|
||||
sprintFilter={sprintFilter}
|
||||
selectedLabelIds={selectedLabelIds}
|
||||
onSprintFilterChange={setSprintFilter}
|
||||
onLabelToggle={toggleLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={(event) => void handleDragEnd(event)} onDragCancel={() => setActiveTicket(null)}>
|
||||
<div className="flex min-w-0 gap-4 overflow-x-auto pb-2" role="region" aria-label="Kanban columns" tabIndex={0}>
|
||||
{columns.map((column) => (
|
||||
<KanbanColumn key={column.status} column={column} tickets={filteredTicketsByColumn.get(column.status) ?? []} />
|
||||
))}
|
||||
</div>
|
||||
<DragOverlay>{activeTicket ? <KanbanTicketOverlay ticket={activeTicket} /> : null}</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/components/frontend/projects/KanbanColumn.tsx
Normal file
44
src/components/frontend/projects/KanbanColumn.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { KanbanTicketCard } from "./KanbanTicketCard";
|
||||
import type { KanbanColumn as KanbanColumnData, KanbanTicket } from "./KanbanBoard";
|
||||
|
||||
interface KanbanColumnProps {
|
||||
readonly column: KanbanColumnData;
|
||||
readonly tickets: readonly KanbanTicket[];
|
||||
}
|
||||
|
||||
export function KanbanColumn({ column, tickets }: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: column.status });
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={setNodeRef}
|
||||
aria-label={`${column.label} column`}
|
||||
className={cn(
|
||||
"flex min-h-[24rem] min-w-[300px] w-[300px] flex-shrink-0 flex-col rounded-lg border bg-muted/20 p-3 transition-colors",
|
||||
isOver && "border-primary ring-2 ring-primary/30",
|
||||
)}
|
||||
>
|
||||
<header className="mb-3 flex items-center justify-between gap-2">
|
||||
<h3 className="truncate text-sm font-semibold">{column.label}</h3>
|
||||
<Badge variant="secondary" className="font-mono text-[10px]">
|
||||
{tickets.length}
|
||||
</Badge>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col gap-2" role="list" aria-label={`${column.label} tickets`}>
|
||||
{tickets.map((ticket) => (
|
||||
<KanbanTicketCard key={ticket.id} ticket={ticket} />
|
||||
))}
|
||||
{tickets.length === 0 && (
|
||||
<div className="flex flex-1 items-center justify-center rounded-md border border-dashed border-border/70 p-4 text-center text-xs text-muted-foreground">
|
||||
Drop a ticket here
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
106
src/components/frontend/projects/KanbanFilters.tsx
Normal file
106
src/components/frontend/projects/KanbanFilters.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"use client";
|
||||
|
||||
import { TagsIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { KanbanFiltersProps, SprintFilter } from "./KanbanBoard";
|
||||
|
||||
function sprintFilterValue(value: string): SprintFilter {
|
||||
if (value === "all") return "all";
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) ? parsed : "all";
|
||||
}
|
||||
|
||||
function activeSprints(sprints: KanbanFiltersProps["sprints"]) {
|
||||
const active = sprints.filter((sprint) => ["planned", "active", "in_progress"].includes(sprint.status));
|
||||
return active.length > 0 ? active : sprints;
|
||||
}
|
||||
|
||||
export function KanbanFilters({
|
||||
sprints,
|
||||
labels,
|
||||
sprintFilter,
|
||||
selectedLabelIds,
|
||||
onSprintFilterChange,
|
||||
onLabelToggle,
|
||||
}: KanbanFiltersProps) {
|
||||
const selectedLabelCount = selectedLabelIds.length;
|
||||
const sprintOptions = activeSprints(sprints);
|
||||
const sprintLabel =
|
||||
sprintFilter === "all"
|
||||
? "All sprints"
|
||||
: sprintOptions.find((sprint) => sprint.id === sprintFilter)?.name;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={String(sprintFilter)}
|
||||
onValueChange={(value) => onSprintFilterChange(sprintFilterValue(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[190px]" aria-label="Filter by sprint">
|
||||
<SelectValue placeholder={sprintLabel ?? "Select sprint"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Sprint</SelectLabel>
|
||||
<SelectItem value="all">All sprints</SelectItem>
|
||||
{sprintOptions.map((sprint) => (
|
||||
<SelectItem key={sprint.id} value={String(sprint.id)}>
|
||||
{sprint.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" aria-label="Filter by label">
|
||||
<TagsIcon data-icon="inline-start" />
|
||||
Labels
|
||||
{selectedLabelCount > 0 && <Badge variant="secondary">{selectedLabelCount}</Badge>}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuLabel>Filter by label</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{labels.length === 0 ? (
|
||||
<span className="block px-2 py-1.5 text-sm text-muted-foreground">No labels yet</span>
|
||||
) : (
|
||||
labels.map((label) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={label.id}
|
||||
checked={selectedLabelIds.includes(label.id)}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={() => onLabelToggle(label.id)}
|
||||
>
|
||||
<span
|
||||
className="mr-2 size-2 rounded-full border border-border"
|
||||
style={{ backgroundColor: label.color }}
|
||||
/>
|
||||
{label.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
src/components/frontend/projects/KanbanTicketCard.tsx
Normal file
163
src/components/frontend/projects/KanbanTicketCard.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemFooter,
|
||||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import {
|
||||
PRIORITY_LABEL,
|
||||
PRIORITY_STYLES,
|
||||
STATUS_LABEL,
|
||||
STATUS_STYLES,
|
||||
type TicketPriority,
|
||||
type TicketStatus,
|
||||
} from "@/lib/tickets/ticketMeta";
|
||||
import { TYPE_LABEL, TYPE_STYLES } from "@/lib/projects/projectMeta";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { KanbanTicket } from "./KanbanBoard";
|
||||
|
||||
interface KanbanTicketCardProps {
|
||||
readonly ticket: KanbanTicket;
|
||||
readonly overlay?: boolean;
|
||||
}
|
||||
|
||||
function isTicketStatus(value: string): value is TicketStatus {
|
||||
return value in STATUS_STYLES;
|
||||
}
|
||||
|
||||
function isTicketPriority(value: string): value is TicketPriority {
|
||||
return value in PRIORITY_STYLES;
|
||||
}
|
||||
|
||||
function initials(displayName: string): string {
|
||||
const parts = displayName.trim().split(/\s+/).filter(Boolean);
|
||||
const first = parts[0]?.[0] ?? "?";
|
||||
const last = parts.length > 1 ? (parts.at(-1)?.[0] ?? "") : "";
|
||||
return `${first}${last}`.toUpperCase();
|
||||
}
|
||||
|
||||
function badgeStyle(value: string, styles: Readonly<Record<string, string>>): string {
|
||||
return styles[value] ?? "bg-muted/50 text-muted-foreground border-border";
|
||||
}
|
||||
|
||||
function ticketTypeLabel(type: string): string {
|
||||
return TYPE_LABEL[type] ?? type.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function KanbanTicketCardBody({ ticket, overlay }: KanbanTicketCardProps) {
|
||||
const priorityLabel = isTicketPriority(ticket.priority)
|
||||
? PRIORITY_LABEL[ticket.priority]
|
||||
: ticket.priority;
|
||||
const statusStyle = isTicketStatus(ticket.status) ? STATUS_STYLES[ticket.status] : "";
|
||||
const statusLabel = isTicketStatus(ticket.status)
|
||||
? STATUS_LABEL[ticket.status]
|
||||
: ticket.status.replaceAll("_", " ");
|
||||
|
||||
return (
|
||||
<Item
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"items-start gap-3 bg-background/50 transition-colors hover:bg-accent/50",
|
||||
overlay && "opacity-90 shadow-lg",
|
||||
)}
|
||||
>
|
||||
<ItemHeader className="items-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px] uppercase tracking-wide",
|
||||
TYPE_STYLES[ticket.type] ?? "bg-muted/50 text-muted-foreground border-border",
|
||||
)}
|
||||
>
|
||||
{ticketTypeLabel(ticket.type)}
|
||||
</Badge>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">#{ticket.id}</span>
|
||||
</ItemHeader>
|
||||
|
||||
<ItemContent className="w-full min-w-0 gap-2">
|
||||
<ItemTitle className="w-full min-w-0 text-sm font-semibold">
|
||||
<Link href={`/helpdesk/${ticket.id}`} className="line-clamp-2 hover:underline">
|
||||
{ticket.title}
|
||||
</Link>
|
||||
</ItemTitle>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="outline" className={cn("text-[10px] uppercase tracking-wide", statusStyle)}>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-[10px] uppercase tracking-wide", badgeStyle(ticket.priority, PRIORITY_STYLES))}
|
||||
>
|
||||
{priorityLabel}
|
||||
</Badge>
|
||||
{ticket.storyPoints != null && (
|
||||
<Badge variant="secondary" className="font-mono text-[10px]">
|
||||
{ticket.storyPoints} pts
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</ItemContent>
|
||||
|
||||
<ItemFooter className="w-full min-w-0 flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{ticket.assignee ? (
|
||||
<>
|
||||
<Avatar className="size-5">
|
||||
<AvatarFallback className="text-[9px]">{initials(ticket.assignee.displayName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate">{ticket.assignee.displayName}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Unassigned</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1" aria-label="Ticket labels">
|
||||
{ticket.labels.map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
title={label.name}
|
||||
aria-label={label.name}
|
||||
className="size-2 rounded-full border border-border"
|
||||
style={{ backgroundColor: label.color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{ticket.reporter && <span className="max-w-full truncate">reported by {ticket.reporter.displayName}</span>}
|
||||
</ItemFooter>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanTicketCard({ ticket, overlay = false }: KanbanTicketCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: ticket.id });
|
||||
const style = transform ? { transform: CSS.Translate.toString(transform) } : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={cn("touch-none outline-none", isDragging && "opacity-40")}
|
||||
>
|
||||
<KanbanTicketCardBody ticket={ticket} overlay={overlay} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanTicketOverlay({ ticket }: { readonly ticket: KanbanTicket }) {
|
||||
return (
|
||||
<div className="touch-none">
|
||||
<KanbanTicketCardBody ticket={ticket} overlay />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/components/frontend/projects/ProjectBoardView.tsx
Normal file
103
src/components/frontend/projects/ProjectBoardView.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeftIcon, ListPlusIcon, PlusIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CreateTicketDialog } from "@/components/frontend/helpdesk/CreateTicketDialog";
|
||||
import {
|
||||
KanbanBoard,
|
||||
type KanbanColumn,
|
||||
type SprintFilter,
|
||||
} from "@/components/frontend/projects/KanbanBoard";
|
||||
import { KanbanFilters } from "@/components/frontend/projects/KanbanFilters";
|
||||
|
||||
export interface ProjectBoardViewProps {
|
||||
readonly project: { id: number; name: string; key: string };
|
||||
readonly columns: readonly KanbanColumn[];
|
||||
readonly sprints: readonly { id: number; name: string; status: string }[];
|
||||
readonly labels: readonly { id: number; name: string; color: string }[];
|
||||
readonly currentUserId: number;
|
||||
readonly isAdmin: boolean;
|
||||
}
|
||||
|
||||
export function ProjectBoardView({
|
||||
project,
|
||||
columns,
|
||||
sprints,
|
||||
labels,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
}: ProjectBoardViewProps) {
|
||||
const [createTicketOpen, setCreateTicketOpen] = useState(false);
|
||||
const [sprintFilter, setSprintFilter] = useState<SprintFilter>("all");
|
||||
const [selectedLabelIds, setSelectedLabelIds] = useState<readonly number[]>([]);
|
||||
|
||||
function toggleLabel(labelId: number) {
|
||||
setSelectedLabelIds((current) =>
|
||||
current.includes(labelId) ? current.filter((id) => id !== labelId) : [...current, labelId],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<Button asChild variant="ghost" size="sm" className="w-fit px-0 text-muted-foreground hover:text-foreground">
|
||||
<Link href="/projects">
|
||||
<ArrowLeftIcon data-icon="inline-start" />
|
||||
Projects
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="truncate text-lg font-semibold">{project.name}</h1>
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
{project.key}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Organize tickets across the project workflow.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateTicketOpen(true)}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
New ticket
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-y border-border/70 py-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<ListPlusIcon className="size-4 text-muted-foreground" />
|
||||
Board filters
|
||||
</div>
|
||||
<KanbanFilters
|
||||
sprints={sprints}
|
||||
labels={labels}
|
||||
sprintFilter={sprintFilter}
|
||||
selectedLabelIds={selectedLabelIds}
|
||||
onSprintFilterChange={setSprintFilter}
|
||||
onLabelToggle={toggleLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<KanbanBoard
|
||||
project={project}
|
||||
columns={columns}
|
||||
sprints={sprints}
|
||||
labels={labels}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
showFilters={false}
|
||||
sprintFilter={sprintFilter}
|
||||
selectedLabelIds={selectedLabelIds}
|
||||
onSprintFilterChange={setSprintFilter}
|
||||
onLabelToggle={toggleLabel}
|
||||
/>
|
||||
|
||||
<CreateTicketDialog open={createTicketOpen} onOpenChange={setCreateTicketOpen} projectId={project.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
232
src/components/frontend/projects/ProjectSearchView.tsx
Normal file
232
src/components/frontend/projects/ProjectSearchView.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { BadgeIcon, SearchIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SearchTicket {
|
||||
readonly id: number;
|
||||
readonly title: string;
|
||||
readonly status: string;
|
||||
readonly priority: string;
|
||||
readonly type: string;
|
||||
readonly storyPoints?: number | null;
|
||||
readonly project: { readonly id: number; readonly name: string; readonly key: string };
|
||||
readonly labels: readonly { id: number; name: string; color: string }[];
|
||||
readonly assignee: { id: number; displayName: string } | null;
|
||||
}
|
||||
|
||||
export interface LabelOption {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly color: string;
|
||||
}
|
||||
|
||||
export interface ProjectSearchViewProps {
|
||||
readonly tickets: readonly SearchTicket[];
|
||||
readonly allLabels: readonly LabelOption[];
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Readonly<Record<string, string>> = {
|
||||
open: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
||||
in_progress: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
||||
in_review: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
in_testing: "bg-purple-500/15 text-purple-400 border-purple-500/30",
|
||||
resolved: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
};
|
||||
|
||||
const PRIORITY_STYLES: Readonly<Record<string, string>> = {
|
||||
low: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
||||
medium: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
||||
high: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
critical: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
};
|
||||
|
||||
function labelFor(value: string): string {
|
||||
return value
|
||||
.replaceAll("_", " ")
|
||||
.replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
export function ProjectSearchView({ tickets, allLabels }: ProjectSearchViewProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedLabelIds, setSelectedLabelIds] = useState<readonly number[]>([]);
|
||||
|
||||
function toggleLabel(labelId: number) {
|
||||
setSelectedLabelIds((current) =>
|
||||
current.includes(labelId) ? current.filter((id) => id !== labelId) : [...current, labelId],
|
||||
);
|
||||
}
|
||||
|
||||
const query = search.trim().toLowerCase();
|
||||
|
||||
const filteredTickets = useMemo(() => {
|
||||
return tickets.filter((ticket) => {
|
||||
const matchesSearch = !query
|
||||
|| ticket.title.toLowerCase().includes(query)
|
||||
|| ticket.project.name.toLowerCase().includes(query)
|
||||
|| ticket.project.key.toLowerCase().includes(query);
|
||||
|
||||
const matchesLabels =
|
||||
selectedLabelIds.length === 0
|
||||
|| ticket.labels.some((label) => selectedLabelIds.includes(label.id));
|
||||
|
||||
return matchesSearch && matchesLabels;
|
||||
});
|
||||
}, [tickets, query, selectedLabelIds]);
|
||||
|
||||
const groupedByProject = useMemo(() => {
|
||||
const groups = new Map<string, { project: SearchTicket["project"]; tickets: SearchTicket[] }>();
|
||||
for (const ticket of filteredTickets) {
|
||||
const key = `${ticket.project.id}`;
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.tickets.push(ticket);
|
||||
} else {
|
||||
groups.set(key, { project: ticket.project, tickets: [ticket] });
|
||||
}
|
||||
}
|
||||
return Array.from(groups.values()).sort((a, b) => a.project.name.localeCompare(b.project.name));
|
||||
}, [filteredTickets]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Cross-project search
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Search tickets across all projects and filter by label.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="relative w-full lg:max-w-md">
|
||||
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search tickets"
|
||||
className="pl-8"
|
||||
placeholder="Search by title, project name, or key..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{allLabels.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<BadgeIcon className="size-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Filter by label:</span>
|
||||
{allLabels.map((label) => (
|
||||
<button
|
||||
key={label.id}
|
||||
type="button"
|
||||
onClick={() => toggleLabel(label.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors",
|
||||
selectedLabelIds.includes(label.id)
|
||||
? "border-foreground/30 bg-foreground/10 text-foreground"
|
||||
: "border-border text-muted-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: label.color }}
|
||||
/>
|
||||
{label.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredTickets.length === 0 ? (
|
||||
<Item variant="outline">
|
||||
<ItemContent className="items-center gap-1">
|
||||
<ItemTitle>{tickets.length === 0 ? "No tickets found" : "No matching tickets"}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{tickets.length === 0
|
||||
? "There are no project tickets yet."
|
||||
: "Try a different search or label filter."}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4" role="region" aria-label="Search results">
|
||||
{groupedByProject.map(({ project, tickets: projectTickets }) => (
|
||||
<div key={project.id} className="flex flex-col gap-2">
|
||||
<Link
|
||||
href={`/projects/${project.key}/board`}
|
||||
className="group flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Badge variant="outline" className="font-mono text-[10px]">
|
||||
{project.key}
|
||||
</Badge>
|
||||
<span className="group-hover:underline">{project.name}</span>
|
||||
<span className="text-xs text-muted-foreground">({projectTickets.length})</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col gap-1 pl-4" role="list">
|
||||
{projectTickets.map((ticket) => (
|
||||
<Item key={ticket.id} variant="outline" size="sm" role="listitem">
|
||||
<ItemHeader className="items-center">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ItemTitle className="truncate text-sm font-medium">{ticket.title}</ItemTitle>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"shrink-0 text-[10px] uppercase tracking-wide",
|
||||
STATUS_STYLES[ticket.status] ?? "bg-muted/50 text-muted-foreground border-border",
|
||||
)}
|
||||
>
|
||||
{labelFor(ticket.status)}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"shrink-0 text-[10px] uppercase tracking-wide",
|
||||
PRIORITY_STYLES[ticket.priority] ?? "bg-muted/50 text-muted-foreground border-border",
|
||||
)}
|
||||
>
|
||||
{labelFor(ticket.priority)}
|
||||
</Badge>
|
||||
</div>
|
||||
</ItemHeader>
|
||||
<ItemContent className="w-full min-w-0 gap-1">
|
||||
{ticket.labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ticket.labels.map((label) => (
|
||||
<Badge
|
||||
key={label.id}
|
||||
variant="secondary"
|
||||
className="text-[10px]"
|
||||
>
|
||||
<span
|
||||
className="mr-1 size-1.5 rounded-full"
|
||||
style={{ backgroundColor: label.color }}
|
||||
/>
|
||||
{label.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
src/components/frontend/projects/ProjectsView.tsx
Normal file
178
src/components/frontend/projects/ProjectsView.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { FolderKanbanIcon, PlusIcon, SearchIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemFooter,
|
||||
ItemHeader,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { CreateProjectDialog } from "@/components/frontend/projects/CreateProjectDialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ProjectListItem {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly key: string;
|
||||
readonly status: string;
|
||||
readonly lead: { id: number; displayName: string } | null;
|
||||
readonly description?: string | null;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProjectsViewProps {
|
||||
readonly projects: readonly ProjectListItem[];
|
||||
readonly currentUserId: number;
|
||||
readonly isAdmin: boolean;
|
||||
}
|
||||
|
||||
const PROJECT_STATUS_STYLES: Readonly<Record<string, string>> = {
|
||||
active: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
planning: "bg-sky-500/15 text-sky-400 border-sky-500/30",
|
||||
on_hold: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
completed: "bg-violet-500/15 text-violet-400 border-violet-500/30",
|
||||
archived: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
||||
};
|
||||
|
||||
function labelFor(value: string): string {
|
||||
return value
|
||||
.replaceAll("_", " ")
|
||||
.replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
function dateFor(value: string): string {
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function ProjectsView({ projects, currentUserId: _currentUserId, isAdmin }: ProjectsViewProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const query = search.trim().toLowerCase();
|
||||
const filteredProjects = useMemo(
|
||||
() =>
|
||||
projects.filter((project) =>
|
||||
query
|
||||
? `${project.name} ${project.key} ${project.description ?? ""}`.toLowerCase().includes(query)
|
||||
: true,
|
||||
),
|
||||
[projects, query],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Projects
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">{projects.length} total</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Plan work, track progress, and ship with your team.
|
||||
</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Create project
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative w-full lg:max-w-xs">
|
||||
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search projects"
|
||||
className="pl-8"
|
||||
placeholder="Search projects..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filteredProjects.length === 0 ? (
|
||||
<Item variant="outline">
|
||||
<ItemMedia variant="icon">
|
||||
<FolderKanbanIcon className="text-muted-foreground" />
|
||||
</ItemMedia>
|
||||
<ItemContent className="items-center gap-1">
|
||||
<ItemTitle>
|
||||
{projects.length === 0 ? "No projects yet" : "No matching projects"}
|
||||
</ItemTitle>
|
||||
<ItemDescription>
|
||||
{projects.length === 0
|
||||
? "Create a project to start organizing your work."
|
||||
: "Try a different search phrase."}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
{filteredProjects.map((project) => (
|
||||
<Link
|
||||
key={project.id}
|
||||
href={`/projects/${project.key}/board`}
|
||||
className="group block h-full"
|
||||
>
|
||||
<Item
|
||||
variant="outline"
|
||||
className="h-full min-h-44 items-start transition-colors group-hover:bg-accent/50"
|
||||
>
|
||||
<ItemHeader className="items-center">
|
||||
<Badge variant="outline" className="font-mono text-[10px] tracking-wide">
|
||||
{project.key}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px] uppercase tracking-wide",
|
||||
PROJECT_STATUS_STYLES[project.status] ??
|
||||
"bg-muted/50 text-muted-foreground border-border",
|
||||
)}
|
||||
>
|
||||
{labelFor(project.status)}
|
||||
</Badge>
|
||||
</ItemHeader>
|
||||
<ItemContent className="w-full min-w-0 flex-1 gap-2">
|
||||
<ItemTitle className="line-clamp-2 text-base font-semibold">
|
||||
{project.name}
|
||||
</ItemTitle>
|
||||
{project.description && typeof project.description === "string" && (
|
||||
<ItemDescription>{project.description}</ItemDescription>
|
||||
)}
|
||||
{!project.description && typeof project.description !== "string" && (
|
||||
<ItemDescription>No description provided.</ItemDescription>
|
||||
)}
|
||||
</ItemContent>
|
||||
<ItemFooter className="w-full flex-wrap items-end gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
Lead: {project.lead?.displayName ?? "Unassigned"}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[10px]">
|
||||
Created {dateFor(project.createdAt)}
|
||||
</span>
|
||||
</ItemFooter>
|
||||
</Item>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateProjectDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
src/components/frontend/projects/ReleasesView.tsx
Normal file
198
src/components/frontend/projects/ReleasesView.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { CalendarIcon, PackageOpenIcon, PlayIcon, RocketIcon, PlusIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemFooter,
|
||||
ItemHeader,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { CreateReleaseDialog } from "@/components/frontend/projects/CreateReleaseDialog";
|
||||
import { startRelease, completeRelease } from "@/app/(frontend)/projects/actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ReleaseItem {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly status: string;
|
||||
readonly targetVersion?: string | null;
|
||||
readonly releaseDate?: string | null;
|
||||
readonly description?: string | null;
|
||||
readonly createdAt: string;
|
||||
readonly ticketsTotal: number;
|
||||
readonly ticketsResolved: number;
|
||||
}
|
||||
|
||||
export interface ReleasesViewProps {
|
||||
readonly project: { id: number; name: string; key: string };
|
||||
readonly releases: readonly ReleaseItem[];
|
||||
readonly isAdmin: boolean;
|
||||
}
|
||||
|
||||
const RELEASE_STATUS_STYLES: Readonly<Record<string, string>> = {
|
||||
draft: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
||||
in_progress: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
||||
released: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
};
|
||||
|
||||
function labelFor(value: string): string {
|
||||
return value
|
||||
.replaceAll("_", " ")
|
||||
.replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
function dateFor(value: string | null | undefined): string {
|
||||
if (!value) return "TBD";
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function ReleasesView({ project, releases, isAdmin }: ReleasesViewProps) {
|
||||
const router = useRouter();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
async function handleStartRelease(releaseId: number) {
|
||||
const result = await startRelease({ releaseId });
|
||||
if (result.success) {
|
||||
toast.success("Release started");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error ?? "Failed to start release");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCompleteRelease(releaseId: number) {
|
||||
const result = await completeRelease({ releaseId });
|
||||
if (result.success) {
|
||||
toast.success("Release completed");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error ?? "Failed to complete release");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Releases</h2>
|
||||
<span className="text-xs text-muted-foreground">{releases.length} total</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Group project work into clear delivery milestones.</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Create release
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{releases.length === 0 ? (
|
||||
<Item variant="outline">
|
||||
<ItemMedia variant="icon">
|
||||
<PackageOpenIcon className="text-muted-foreground" />
|
||||
</ItemMedia>
|
||||
<ItemContent className="items-center gap-1">
|
||||
<ItemTitle>No releases yet</ItemTitle>
|
||||
<ItemDescription>Create a release to start tracking delivery milestones.</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2" role="list" aria-label={`${project.name} releases`}>
|
||||
{releases.map((release) => {
|
||||
const progressPercent =
|
||||
release.ticketsTotal > 0
|
||||
? Math.round((release.ticketsResolved / release.ticketsTotal) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Item key={release.id} variant="outline" size="sm" role="listitem" className="items-start gap-3">
|
||||
<ItemHeader className="items-center">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ItemTitle className="truncate text-base font-semibold">{release.name}</ItemTitle>
|
||||
{release.targetVersion && (
|
||||
<Badge variant="secondary" className="shrink-0 font-mono text-[10px]">
|
||||
{release.targetVersion}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px] uppercase tracking-wide",
|
||||
RELEASE_STATUS_STYLES[release.status] ?? "bg-muted/50 text-muted-foreground border-border",
|
||||
)}
|
||||
>
|
||||
{labelFor(release.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex gap-1">
|
||||
{release.status === "draft" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void handleStartRelease(release.id)}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
<PlayIcon className="size-3" />
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{release.status === "in_progress" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void handleCompleteRelease(release.id)}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
<RocketIcon className="size-3" />
|
||||
Release
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ItemHeader>
|
||||
|
||||
<ItemContent className="w-full min-w-0 gap-2">
|
||||
<ItemDescription>{release.description || "No description provided."}</ItemDescription>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>Tickets: {release.ticketsResolved} / {release.ticketsTotal} resolved</span>
|
||||
{release.ticketsTotal > 0 && <span>{progressPercent}%</span>}
|
||||
</div>
|
||||
{release.ticketsTotal > 0 && <Progress value={progressPercent} className="h-1.5" />}
|
||||
</div>
|
||||
</ItemContent>
|
||||
|
||||
<ItemFooter className="w-full flex-wrap justify-start gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarIcon className="size-3" />
|
||||
{release.releaseDate ? `Released ${dateFor(release.releaseDate)}` : `Created ${dateFor(release.createdAt)}`}
|
||||
</span>
|
||||
</ItemFooter>
|
||||
</Item>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateReleaseDialog projectId={project.id} open={createOpen} onOpenChange={setCreateOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
src/components/frontend/projects/SprintsView.tsx
Normal file
200
src/components/frontend/projects/SprintsView.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { CalendarIcon, PlayIcon, CheckCircleIcon, PlusIcon, TimerIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemFooter,
|
||||
ItemHeader,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { CreateSprintDialog } from "@/components/frontend/projects/CreateSprintDialog";
|
||||
import { startSprint, completeSprint } from "@/app/(frontend)/projects/actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SprintItem {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly status: string;
|
||||
readonly goal?: string | null;
|
||||
readonly startDate?: string | null;
|
||||
readonly endDate?: string | null;
|
||||
readonly capacity?: number | null;
|
||||
readonly storyPointsCompleted: number;
|
||||
readonly storyPointsTotal: number;
|
||||
}
|
||||
|
||||
export interface SprintsViewProps {
|
||||
readonly project: { id: number; name: string; key: string };
|
||||
readonly sprints: readonly SprintItem[];
|
||||
readonly isAdmin: boolean;
|
||||
}
|
||||
|
||||
const SPRINT_STATUS_STYLES: Readonly<Record<string, string>> = {
|
||||
planning: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
||||
active: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
completed: "bg-violet-500/15 text-violet-400 border-violet-500/30",
|
||||
};
|
||||
|
||||
function labelFor(value: string): string {
|
||||
return value
|
||||
.replaceAll("_", " ")
|
||||
.replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
function dateFor(value: string | null | undefined): string {
|
||||
if (!value) return "TBD";
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function SprintsView({ project, sprints, isAdmin }: SprintsViewProps) {
|
||||
const router = useRouter();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
async function handleStartSprint(sprintId: number) {
|
||||
const result = await startSprint({ sprintId });
|
||||
if (result.success) {
|
||||
toast.success("Sprint started");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error ?? "Failed to start sprint");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCompleteSprint(sprintId: number) {
|
||||
const result = await completeSprint({ sprintId });
|
||||
if (result.success) {
|
||||
toast.success("Sprint completed");
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error ?? "Failed to complete sprint");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Sprints</h2>
|
||||
<span className="text-xs text-muted-foreground">{sprints.length} total</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Plan focused delivery windows and track progress.</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Create sprint
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sprints.length === 0 ? (
|
||||
<Item variant="outline">
|
||||
<ItemMedia variant="icon">
|
||||
<TimerIcon className="text-muted-foreground" />
|
||||
</ItemMedia>
|
||||
<ItemContent className="items-center gap-1">
|
||||
<ItemTitle>No sprints yet</ItemTitle>
|
||||
<ItemDescription>Create a sprint to start planning your work.</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2" role="list" aria-label={`${project.name} sprints`}>
|
||||
{sprints.map((sprint) => {
|
||||
const hasCapacity = sprint.capacity != null && sprint.capacity > 0;
|
||||
const usedPoints = hasCapacity ? Math.min(sprint.storyPointsTotal, sprint.capacity!) : 0;
|
||||
const progressPercent = hasCapacity ? Math.round((usedPoints / sprint.capacity!) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Item key={sprint.id} variant="outline" size="sm" role="listitem" className="items-start gap-3">
|
||||
<ItemHeader className="items-center">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ItemTitle className="truncate text-base font-semibold">{sprint.name}</ItemTitle>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-[10px] uppercase tracking-wide",
|
||||
SPRINT_STATUS_STYLES[sprint.status] ?? "bg-muted/50 text-muted-foreground border-border",
|
||||
)}
|
||||
>
|
||||
{labelFor(sprint.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex gap-1">
|
||||
{sprint.status === "planning" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void handleStartSprint(sprint.id)}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
<PlayIcon className="size-3" />
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{sprint.status === "active" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void handleCompleteSprint(sprint.id)}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
<CheckCircleIcon className="size-3" />
|
||||
Complete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ItemHeader>
|
||||
|
||||
<ItemContent className="w-full min-w-0 gap-2">
|
||||
{sprint.goal && <ItemDescription>{sprint.goal}</ItemDescription>}
|
||||
|
||||
{hasCapacity && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>Story points: {sprint.storyPointsTotal} / {sprint.capacity}</span>
|
||||
<span>{progressPercent}%</span>
|
||||
</div>
|
||||
<Progress value={progressPercent} className="h-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasCapacity && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Story points: {sprint.storyPointsTotal}
|
||||
</div>
|
||||
)}
|
||||
</ItemContent>
|
||||
|
||||
<ItemFooter className="w-full flex-wrap justify-start gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarIcon className="size-3" />
|
||||
{dateFor(sprint.startDate)} — {dateFor(sprint.endDate)}
|
||||
</span>
|
||||
</ItemFooter>
|
||||
</Item>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateSprintDialog projectId={project.id} open={createOpen} onOpenChange={setCreateOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/lib/projects/projectMeta.ts
Normal file
21
src/lib/projects/projectMeta.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export const PROJECT_TICKET_TYPES = [
|
||||
{ label: "Task", value: "task" },
|
||||
{ label: "Bug", value: "bug" },
|
||||
{ label: "Story", value: "story" },
|
||||
{ label: "Epic", value: "epic" },
|
||||
{ label: "Improvement", value: "improvement" },
|
||||
{ label: "Subtask", value: "subtask" },
|
||||
] as const;
|
||||
|
||||
export const TYPE_LABEL: Readonly<Record<string, string>> = Object.fromEntries(
|
||||
PROJECT_TICKET_TYPES.map((type) => [type.value, type.label]),
|
||||
);
|
||||
|
||||
export const TYPE_STYLES: Readonly<Record<string, string>> = {
|
||||
task: "bg-slate-500/15 text-slate-400 border-slate-500/30",
|
||||
bug: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
story: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
epic: "bg-violet-500/15 text-violet-400 border-violet-500/30",
|
||||
improvement: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
subtask: "bg-sky-500/15 text-sky-400 border-sky-500/30",
|
||||
};
|
||||
|
|
@ -100,6 +100,10 @@ export interface Config {
|
|||
'market-listings': MarketListing;
|
||||
'market-negotiations': MarketNegotiation;
|
||||
tickets: Ticket;
|
||||
projects: Project;
|
||||
sprints: Sprint;
|
||||
releases: Release;
|
||||
labels: Label;
|
||||
maps: Map;
|
||||
'narrative-events': NarrativeEvent;
|
||||
'mission-files': MissionFile;
|
||||
|
|
@ -152,6 +156,10 @@ export interface Config {
|
|||
'market-listings': MarketListingsSelect<false> | MarketListingsSelect<true>;
|
||||
'market-negotiations': MarketNegotiationsSelect<false> | MarketNegotiationsSelect<true>;
|
||||
tickets: TicketsSelect<false> | TicketsSelect<true>;
|
||||
projects: ProjectsSelect<false> | ProjectsSelect<true>;
|
||||
sprints: SprintsSelect<false> | SprintsSelect<true>;
|
||||
releases: ReleasesSelect<false> | ReleasesSelect<true>;
|
||||
labels: LabelsSelect<false> | LabelsSelect<true>;
|
||||
maps: MapsSelect<false> | MapsSelect<true>;
|
||||
'narrative-events': NarrativeEventsSelect<false> | NarrativeEventsSelect<true>;
|
||||
'mission-files': MissionFilesSelect<false> | MissionFilesSelect<true>;
|
||||
|
|
@ -1474,6 +1482,22 @@ export interface Role {
|
|||
| 'market-negotiations:read'
|
||||
| 'market-negotiations:update'
|
||||
| 'market-negotiations:delete'
|
||||
| 'projects:create'
|
||||
| 'projects:read'
|
||||
| 'projects:update'
|
||||
| 'projects:delete'
|
||||
| 'labels:create'
|
||||
| 'labels:read'
|
||||
| 'labels:update'
|
||||
| 'labels:delete'
|
||||
| 'sprints:create'
|
||||
| 'sprints:read'
|
||||
| 'sprints:update'
|
||||
| 'sprints:delete'
|
||||
| 'releases:create'
|
||||
| 'releases:read'
|
||||
| 'releases:update'
|
||||
| 'releases:delete'
|
||||
| 'tickets:create'
|
||||
| 'tickets:read'
|
||||
| 'tickets:update'
|
||||
|
|
@ -2509,6 +2533,154 @@ export interface Ticket {
|
|||
* Set automatically when the ticket reaches a closing status.
|
||||
*/
|
||||
closedAt?: string | null;
|
||||
/**
|
||||
* Project this ticket belongs to. Null = standalone/backlog.
|
||||
*/
|
||||
project?: (number | null) | Project;
|
||||
/**
|
||||
* Sprint this ticket is assigned to.
|
||||
*/
|
||||
sprint?: (number | null) | Sprint;
|
||||
/**
|
||||
* Release this ticket targets.
|
||||
*/
|
||||
release?: (number | null) | Release;
|
||||
/**
|
||||
* Parent ticket (for subtasks / epic children).
|
||||
*/
|
||||
parentTicket?: (number | null) | Ticket;
|
||||
/**
|
||||
* Labels for filtering and organization.
|
||||
*/
|
||||
labels?: (number | Label)[] | null;
|
||||
/**
|
||||
* Issue type.
|
||||
*/
|
||||
type?: ('task' | 'bug' | 'story' | 'epic' | 'improvement') | null;
|
||||
/**
|
||||
* Fibonacci story point estimate.
|
||||
*/
|
||||
storyPoints?: number | null;
|
||||
/**
|
||||
* Time estimate in hours.
|
||||
*/
|
||||
estimate?: number | null;
|
||||
/**
|
||||
* When work starts (for Gantt).
|
||||
*/
|
||||
startDate?: string | null;
|
||||
/**
|
||||
* When work is due (for Gantt).
|
||||
*/
|
||||
dueDate?: string | null;
|
||||
/**
|
||||
* How the ticket was resolved.
|
||||
*/
|
||||
resolution?: ('fixed' | 'wontfix' | 'duplicate' | 'invalid') | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "projects".
|
||||
*/
|
||||
export interface Project {
|
||||
id: number;
|
||||
name: string;
|
||||
/**
|
||||
* Short project key (e.g. PTF). Max 10 uppercase alphanumeric characters.
|
||||
*/
|
||||
key: string;
|
||||
description?: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
lead?: (number | null) | User;
|
||||
status?: ('active' | 'archived') | null;
|
||||
/**
|
||||
* Default assignee for new tickets in this project.
|
||||
*/
|
||||
defaultAssignee?: (number | null) | User;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "sprints".
|
||||
*/
|
||||
export interface Sprint {
|
||||
id: number;
|
||||
name: string;
|
||||
project: number | Project;
|
||||
goal?: string | null;
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
status?: ('planning' | 'active' | 'completed') | null;
|
||||
/**
|
||||
* Story point capacity for this sprint.
|
||||
*/
|
||||
capacity?: number | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "releases".
|
||||
*/
|
||||
export interface Release {
|
||||
id: number;
|
||||
name: string;
|
||||
project: number | Project;
|
||||
description?: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ('ltr' | 'rtl') | null;
|
||||
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
} | null;
|
||||
status?: ('draft' | 'in_progress' | 'released') | null;
|
||||
releaseDate?: string | null;
|
||||
/**
|
||||
* Semver string, e.g. 2.3.0
|
||||
*/
|
||||
targetVersion?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "labels".
|
||||
*/
|
||||
export interface Label {
|
||||
id: number;
|
||||
name: string;
|
||||
/**
|
||||
* Hex color code.
|
||||
*/
|
||||
color?: string | null;
|
||||
/**
|
||||
* Null for global labels.
|
||||
*/
|
||||
project?: (number | null) | Project;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
@ -2750,6 +2922,22 @@ export interface PayloadLockedDocument {
|
|||
relationTo: 'tickets';
|
||||
value: number | Ticket;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'projects';
|
||||
value: number | Project;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'sprints';
|
||||
value: number | Sprint;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'releases';
|
||||
value: number | Release;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'labels';
|
||||
value: number | Label;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'maps';
|
||||
value: number | Map;
|
||||
|
|
@ -3845,6 +4033,71 @@ export interface TicketsSelect<T extends boolean = true> {
|
|||
id?: T;
|
||||
};
|
||||
closedAt?: T;
|
||||
project?: T;
|
||||
sprint?: T;
|
||||
release?: T;
|
||||
parentTicket?: T;
|
||||
labels?: T;
|
||||
type?: T;
|
||||
storyPoints?: T;
|
||||
estimate?: T;
|
||||
startDate?: T;
|
||||
dueDate?: T;
|
||||
resolution?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "projects_select".
|
||||
*/
|
||||
export interface ProjectsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
key?: T;
|
||||
description?: T;
|
||||
lead?: T;
|
||||
status?: T;
|
||||
defaultAssignee?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "sprints_select".
|
||||
*/
|
||||
export interface SprintsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
project?: T;
|
||||
goal?: T;
|
||||
startDate?: T;
|
||||
endDate?: T;
|
||||
status?: T;
|
||||
capacity?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "releases_select".
|
||||
*/
|
||||
export interface ReleasesSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
project?: T;
|
||||
description?: T;
|
||||
status?: T;
|
||||
releaseDate?: T;
|
||||
targetVersion?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "labels_select".
|
||||
*/
|
||||
export interface LabelsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
color?: T;
|
||||
project?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,10 @@ import { MarketListings } from "@/collections/market/MarketListings";
|
|||
import { MarketNegotiations } from "@/collections/market/MarketNegotiations";
|
||||
import { UserNotifications } from "@/collections/users/UserNotifications";
|
||||
import { Tickets } from "@/collections/tickets/Tickets";
|
||||
import { Projects } from "@/collections/projects/Projects";
|
||||
import { Sprints } from "@/collections/projects/Sprints";
|
||||
import { Releases } from "@/collections/projects/Releases";
|
||||
import { Labels } from "@/collections/projects/Labels";
|
||||
import { migrations } from "./migrations";
|
||||
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
||||
import nodemailer from "nodemailer";
|
||||
|
|
@ -230,6 +234,12 @@ export default buildConfig({
|
|||
// Helpdesk
|
||||
Tickets,
|
||||
|
||||
// Projects
|
||||
Projects,
|
||||
Sprints,
|
||||
Releases,
|
||||
Labels,
|
||||
|
||||
// World
|
||||
Maps,
|
||||
NarrativeEvents,
|
||||
|
|
@ -320,6 +330,12 @@ export default buildConfig({
|
|||
|
||||
// Helpdesk
|
||||
[Tickets.slug]: true,
|
||||
|
||||
// Projects
|
||||
[Projects.slug]: true,
|
||||
[Sprints.slug]: true,
|
||||
[Releases.slug]: true,
|
||||
[Labels.slug]: true,
|
||||
},
|
||||
debugging: true,
|
||||
generationModels: [
|
||||
|
|
|
|||
|
|
@ -278,6 +278,42 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
|
|||
},
|
||||
|
||||
// ---- Helpdesk ------------------------------------------------------------
|
||||
{
|
||||
group: "Projects",
|
||||
permissions: [
|
||||
{ value: "projects:create", label: "Create" },
|
||||
{ value: "projects:read", label: "Read" },
|
||||
{ value: "projects:update", label: "Update" },
|
||||
{ value: "projects:delete", label: "Delete" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Labels",
|
||||
permissions: [
|
||||
{ value: "labels:create", label: "Create" },
|
||||
{ value: "labels:read", label: "Read" },
|
||||
{ value: "labels:update", label: "Update" },
|
||||
{ value: "labels:delete", label: "Delete" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Sprints",
|
||||
permissions: [
|
||||
{ value: "sprints:create", label: "Create" },
|
||||
{ value: "sprints:read", label: "Read" },
|
||||
{ value: "sprints:update", label: "Update" },
|
||||
{ value: "sprints:delete", label: "Delete" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Releases",
|
||||
permissions: [
|
||||
{ value: "releases:create", label: "Create" },
|
||||
{ value: "releases:read", label: "Read" },
|
||||
{ value: "releases:update", label: "Update" },
|
||||
{ value: "releases:delete", label: "Delete" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Tickets",
|
||||
permissions: [
|
||||
|
|
@ -532,6 +568,22 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
|
|||
"market-negotiations:read",
|
||||
"market-negotiations:update",
|
||||
"market-negotiations:delete",
|
||||
"projects:create",
|
||||
"projects:read",
|
||||
"projects:update",
|
||||
"projects:delete",
|
||||
"sprints:create",
|
||||
"sprints:read",
|
||||
"sprints:update",
|
||||
"sprints:delete",
|
||||
"labels:create",
|
||||
"labels:read",
|
||||
"labels:update",
|
||||
"labels:delete",
|
||||
"releases:create",
|
||||
"releases:read",
|
||||
"releases:update",
|
||||
"releases:delete",
|
||||
"tickets:create",
|
||||
"tickets:read",
|
||||
"tickets:update",
|
||||
|
|
|
|||
|
|
@ -90,6 +90,18 @@ export const EventTypes = {
|
|||
TicketReply: "ticket:reply",
|
||||
TicketStatusChange: "ticket:status-change",
|
||||
TicketAssign: "ticket:assign",
|
||||
|
||||
// Projects
|
||||
ProjectCreate: "project:create",
|
||||
ProjectUpdate: "project:update",
|
||||
SprintCreate: "sprint:create",
|
||||
SprintUpdate: "sprint:update",
|
||||
SprintStart: "sprint:start",
|
||||
SprintComplete: "sprint:complete",
|
||||
ReleaseCreate: "release:create",
|
||||
ReleaseUpdate: "release:update",
|
||||
ReleaseStart: "release:start",
|
||||
ReleaseComplete: "release:complete",
|
||||
} as const;
|
||||
|
||||
export type EventType = (typeof EventTypes)[keyof typeof EventTypes];
|
||||
|
|
|
|||
52
tests/int/mission-card.int.spec.ts
Normal file
52
tests/int/mission-card.int.spec.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { createElement } from "react";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { MissionCard } from "@/components/frontend/intelligence/MissionCard";
|
||||
|
||||
const mission = {
|
||||
id: 1,
|
||||
name: "Operation Nightfall",
|
||||
codeName: "OP:NIGHTFALL",
|
||||
summary: "Secure the extraction corridor.",
|
||||
operationType: "main" as const,
|
||||
classification: {
|
||||
missionType: "PvE" as const,
|
||||
startDateTime: "2026-08-20T19:30:00.000Z",
|
||||
estimatedDuration: 90,
|
||||
map: 2,
|
||||
campaign: 3,
|
||||
},
|
||||
ownershipAndStatus: {
|
||||
status: "Scheduled" as const,
|
||||
},
|
||||
missionRoles: {
|
||||
maxPlayers: 12,
|
||||
},
|
||||
coverImage: {
|
||||
id: 8,
|
||||
updatedAt: "2026-08-20T00:00:00.000Z",
|
||||
createdAt: "2026-08-20T00:00:00.000Z",
|
||||
url: "/media/nightfall.webp",
|
||||
},
|
||||
};
|
||||
|
||||
describe("MissionCard", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("uses the cover image by default and keeps full metadata visible", () => {
|
||||
render(createElement(MissionCard, { mission, showFullDetails: true }));
|
||||
|
||||
const image = document.querySelector("img");
|
||||
expect(image?.getAttribute("src")).toBe("/media/nightfall.webp");
|
||||
expect(screen.getByText("1h 30m")).toBeTruthy();
|
||||
expect(screen.getByText("12 slots")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses the cover image when the default variant is requested", () => {
|
||||
render(createElement(MissionCard, { mission, variant: "default" }));
|
||||
|
||||
expect(document.querySelector("img")).toBeNull();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue