1
0
Fork 0

feat(projects): add project tracking workspace

This commit is contained in:
Jason Fraley 2026-08-20 19:55:33 -04:00
parent bc6358ce5f
commit 9bf984fc96
35 changed files with 3488 additions and 1 deletions

View file

@ -7,6 +7,7 @@
"dependencies": { "dependencies": {
"@ai-stack/payloadcms": "^3.2.30", "@ai-stack/payloadcms": "^3.2.30",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@payloadcms/db-postgres": "3.88.0", "@payloadcms/db-postgres": "3.88.0",
"@payloadcms/email-nodemailer": "3.88.0", "@payloadcms/email-nodemailer": "3.88.0",

View file

@ -15,6 +15,7 @@
"generate:version-info": "bun run src/scripts/generateVersionInfo.ts", "generate:version-info": "bun run src/scripts/generateVersionInfo.ts",
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint", "lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload", "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", "db": "cross-env NODE_OPTIONS=--no-deprecation pnpm drizzle-kit",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start", "start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"staging": "cross-env NODE_ENV=test NODE_OPTIONS=--no-deprecation next start", "staging": "cross-env NODE_ENV=test NODE_OPTIONS=--no-deprecation next start",
@ -28,6 +29,7 @@
"dependencies": { "dependencies": {
"@ai-stack/payloadcms": "^3.2.30", "@ai-stack/payloadcms": "^3.2.30",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@payloadcms/db-postgres": "3.88.0", "@payloadcms/db-postgres": "3.88.0",
"@payloadcms/email-nodemailer": "3.88.0", "@payloadcms/email-nodemailer": "3.88.0",

View file

@ -71,6 +71,7 @@ export async function createTicket(input: {
description: string; description: string;
category?: TicketCategory; category?: TicketCategory;
priority?: TicketPriority; priority?: TicketPriority;
projectId?: number;
}): Promise<ActionResult<Ticket>> { }): Promise<ActionResult<Ticket>> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
@ -95,6 +96,7 @@ export async function createTicket(input: {
priority, priority,
status: "open", status: "open",
reporter: userId, reporter: userId,
project: input.projectId ?? null,
activity: [ activity: [
{ {
type: "system", type: "system",
@ -102,7 +104,7 @@ export async function createTicket(input: {
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}, },
], ],
}, } as any,
overrideAccess: true, overrideAccess: true,
depth: 0, depth: 0,
})) as unknown as Ticket; })) as unknown as Ticket;

View 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>
);
}

View 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`);
}

View 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>
);
}

View 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>
);
}

View 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 };
}
}

View 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>
);
}

View 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>
);
}

View 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.",
},
},
],
};

View 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.",
},
},
],
};

View 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",
},
},
],
};

View 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." },
},
],
};

View file

@ -239,5 +239,109 @@ export const Tickets: CollectionConfig = {
date: { pickerAppearance: "dayAndTime" }, 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.",
},
},
], ],
}; };

View file

@ -5,7 +5,9 @@ import {
Backpack, Backpack,
Bolt, Bolt,
Crosshair, Crosshair,
Kanban,
LifeBuoy, LifeBuoy,
Search,
Send, Send,
Shield, Shield,
UserCog, UserCog,
@ -47,6 +49,16 @@ const data = {
url: "/helpdesk", url: "/helpdesk",
icon: LifeBuoy, icon: LifeBuoy,
}, },
{
title: "Projects",
url: "/projects",
icon: Kanban,
},
{
title: "Search",
url: "/projects/search",
icon: Search,
},
], ],
navIntelligence: [ navIntelligence: [
{ {

View file

@ -37,6 +37,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
interface CreateTicketDialogProps { interface CreateTicketDialogProps {
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
projectId?: number;
} }
function isTicketCategory(value: string): value is TicketCategory { function isTicketCategory(value: string): value is TicketCategory {
@ -50,6 +51,7 @@ function isTicketPriority(value: string): value is TicketPriority {
export function CreateTicketDialog({ export function CreateTicketDialog({
open: externalOpen, open: externalOpen,
onOpenChange: externalOnOpenChange, onOpenChange: externalOnOpenChange,
projectId,
}: CreateTicketDialogProps = {}) { }: CreateTicketDialogProps = {}) {
const router = useRouter(); const router = useRouter();
const [internalOpen, setInternalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false);
@ -90,6 +92,7 @@ export function CreateTicketDialog({
description: trimmedDescription, description: trimmedDescription,
category, category,
priority, priority,
projectId,
}); });
if (result.success) { if (result.success) {

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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",
};

View file

@ -100,6 +100,10 @@ export interface Config {
'market-listings': MarketListing; 'market-listings': MarketListing;
'market-negotiations': MarketNegotiation; 'market-negotiations': MarketNegotiation;
tickets: Ticket; tickets: Ticket;
projects: Project;
sprints: Sprint;
releases: Release;
labels: Label;
maps: Map; maps: Map;
'narrative-events': NarrativeEvent; 'narrative-events': NarrativeEvent;
'mission-files': MissionFile; 'mission-files': MissionFile;
@ -152,6 +156,10 @@ export interface Config {
'market-listings': MarketListingsSelect<false> | MarketListingsSelect<true>; 'market-listings': MarketListingsSelect<false> | MarketListingsSelect<true>;
'market-negotiations': MarketNegotiationsSelect<false> | MarketNegotiationsSelect<true>; 'market-negotiations': MarketNegotiationsSelect<false> | MarketNegotiationsSelect<true>;
tickets: TicketsSelect<false> | TicketsSelect<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>; maps: MapsSelect<false> | MapsSelect<true>;
'narrative-events': NarrativeEventsSelect<false> | NarrativeEventsSelect<true>; 'narrative-events': NarrativeEventsSelect<false> | NarrativeEventsSelect<true>;
'mission-files': MissionFilesSelect<false> | MissionFilesSelect<true>; 'mission-files': MissionFilesSelect<false> | MissionFilesSelect<true>;
@ -1474,6 +1482,22 @@ export interface Role {
| 'market-negotiations:read' | 'market-negotiations:read'
| 'market-negotiations:update' | 'market-negotiations:update'
| 'market-negotiations:delete' | '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:create'
| 'tickets:read' | 'tickets:read'
| 'tickets:update' | 'tickets:update'
@ -2509,6 +2533,154 @@ export interface Ticket {
* Set automatically when the ticket reaches a closing status. * Set automatically when the ticket reaches a closing status.
*/ */
closedAt?: string | null; 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; updatedAt: string;
createdAt: string; createdAt: string;
} }
@ -2750,6 +2922,22 @@ export interface PayloadLockedDocument {
relationTo: 'tickets'; relationTo: 'tickets';
value: number | Ticket; value: number | Ticket;
} | null) } | 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'; relationTo: 'maps';
value: number | Map; value: number | Map;
@ -3845,6 +4033,71 @@ export interface TicketsSelect<T extends boolean = true> {
id?: T; id?: T;
}; };
closedAt?: 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; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }

View file

@ -44,6 +44,10 @@ import { MarketListings } from "@/collections/market/MarketListings";
import { MarketNegotiations } from "@/collections/market/MarketNegotiations"; import { MarketNegotiations } from "@/collections/market/MarketNegotiations";
import { UserNotifications } from "@/collections/users/UserNotifications"; import { UserNotifications } from "@/collections/users/UserNotifications";
import { Tickets } from "@/collections/tickets/Tickets"; 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 { migrations } from "./migrations";
import { nodemailerAdapter } from "@payloadcms/email-nodemailer"; import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
@ -230,6 +234,12 @@ export default buildConfig({
// Helpdesk // Helpdesk
Tickets, Tickets,
// Projects
Projects,
Sprints,
Releases,
Labels,
// World // World
Maps, Maps,
NarrativeEvents, NarrativeEvents,
@ -320,6 +330,12 @@ export default buildConfig({
// Helpdesk // Helpdesk
[Tickets.slug]: true, [Tickets.slug]: true,
// Projects
[Projects.slug]: true,
[Sprints.slug]: true,
[Releases.slug]: true,
[Labels.slug]: true,
}, },
debugging: true, debugging: true,
generationModels: [ generationModels: [

View file

@ -278,6 +278,42 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
}, },
// ---- Helpdesk ------------------------------------------------------------ // ---- 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", group: "Tickets",
permissions: [ permissions: [
@ -532,6 +568,22 @@ export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) =>
"market-negotiations:read", "market-negotiations:read",
"market-negotiations:update", "market-negotiations:update",
"market-negotiations:delete", "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:create",
"tickets:read", "tickets:read",
"tickets:update", "tickets:update",

View file

@ -90,6 +90,18 @@ export const EventTypes = {
TicketReply: "ticket:reply", TicketReply: "ticket:reply",
TicketStatusChange: "ticket:status-change", TicketStatusChange: "ticket:status-change",
TicketAssign: "ticket:assign", 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; } as const;
export type EventType = (typeof EventTypes)[keyof typeof EventTypes]; export type EventType = (typeof EventTypes)[keyof typeof EventTypes];

View 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();
});
});