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