diff --git a/bun.lock b/bun.lock index 4022da6..4f739e3 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@ai-stack/payloadcms": "^3.2.30", "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@payloadcms/db-postgres": "3.88.0", "@payloadcms/email-nodemailer": "3.88.0", diff --git a/package.json b/package.json index f69d997..885702d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "generate:version-info": "bun run src/scripts/generateVersionInfo.ts", "lint": "cross-env NODE_OPTIONS=--no-deprecation next lint", "payload": "cross-env NODE_OPTIONS=--no-deprecation payload", + "migrate": "bun run src/scripts/run-migrations.ts", "db": "cross-env NODE_OPTIONS=--no-deprecation pnpm drizzle-kit", "start": "cross-env NODE_OPTIONS=--no-deprecation next start", "staging": "cross-env NODE_ENV=test NODE_OPTIONS=--no-deprecation next start", @@ -28,6 +29,7 @@ "dependencies": { "@ai-stack/payloadcms": "^3.2.30", "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@payloadcms/db-postgres": "3.88.0", "@payloadcms/email-nodemailer": "3.88.0", diff --git a/src/app/(frontend)/helpdesk/actions.ts b/src/app/(frontend)/helpdesk/actions.ts index b41a3a6..1487767 100644 --- a/src/app/(frontend)/helpdesk/actions.ts +++ b/src/app/(frontend)/helpdesk/actions.ts @@ -71,6 +71,7 @@ export async function createTicket(input: { description: string; category?: TicketCategory; priority?: TicketPriority; + projectId?: number; }): Promise> { try { const { payload, user } = await authenticate(); @@ -95,6 +96,7 @@ export async function createTicket(input: { priority, status: "open", reporter: userId, + project: input.projectId ?? null, activity: [ { type: "system", @@ -102,7 +104,7 @@ export async function createTicket(input: { created_at: new Date().toISOString(), }, ], - }, + } as any, overrideAccess: true, depth: 0, })) as unknown as Ticket; diff --git a/src/app/(frontend)/projects/[key]/board/page.tsx b/src/app/(frontend)/projects/[key]/board/page.tsx new file mode 100644 index 0000000..54ee879 --- /dev/null +++ b/src/app/(frontend)/projects/[key]/board/page.tsx @@ -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(); + 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 ( +
+ ({ + 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} + /> +
+ ); +} diff --git a/src/app/(frontend)/projects/[key]/page.tsx b/src/app/(frontend)/projects/[key]/page.tsx new file mode 100644 index 0000000..7d3b3f8 --- /dev/null +++ b/src/app/(frontend)/projects/[key]/page.tsx @@ -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`); +} diff --git a/src/app/(frontend)/projects/[key]/releases/page.tsx b/src/app/(frontend)/projects/[key]/releases/page.tsx new file mode 100644 index 0000000..b987163 --- /dev/null +++ b/src/app/(frontend)/projects/[key]/releases/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/app/(frontend)/projects/[key]/sprints/page.tsx b/src/app/(frontend)/projects/[key]/sprints/page.tsx new file mode 100644 index 0000000..670da5c --- /dev/null +++ b/src/app/(frontend)/projects/[key]/sprints/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/app/(frontend)/projects/actions.ts b/src/app/(frontend)/projects/actions.ts new file mode 100644 index 0000000..aed0719 --- /dev/null +++ b/src/app/(frontend)/projects/actions.ts @@ -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 { + 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 { + 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 = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + try { + const { payload, user } = await authenticate(); + + const name = input.name.trim(); + if (!name) throw new Error("Release name is required."); + + const data: Record = { + 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 }; + } +} diff --git a/src/app/(frontend)/projects/page.tsx b/src/app/(frontend)/projects/page.tsx new file mode 100644 index 0000000..d84fc44 --- /dev/null +++ b/src/app/(frontend)/projects/page.tsx @@ -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 ( +
+ ({ + 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} + /> +
+ ); +} diff --git a/src/app/(frontend)/projects/search/page.tsx b/src/app/(frontend)/projects/search/page.tsx new file mode 100644 index 0000000..2745f32 --- /dev/null +++ b/src/app/(frontend)/projects/search/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/collections/projects/Labels.ts b/src/collections/projects/Labels.ts new file mode 100644 index 0000000..affa031 --- /dev/null +++ b/src/collections/projects/Labels.ts @@ -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.", + }, + }, + ], +}; diff --git a/src/collections/projects/Projects.ts b/src/collections/projects/Projects.ts new file mode 100644 index 0000000..fcd02c0 --- /dev/null +++ b/src/collections/projects/Projects.ts @@ -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.", + }, + }, + ], +}; diff --git a/src/collections/projects/Releases.ts b/src/collections/projects/Releases.ts new file mode 100644 index 0000000..3181b6c --- /dev/null +++ b/src/collections/projects/Releases.ts @@ -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", + }, + }, + ], +}; diff --git a/src/collections/projects/Sprints.ts b/src/collections/projects/Sprints.ts new file mode 100644 index 0000000..90ae66e --- /dev/null +++ b/src/collections/projects/Sprints.ts @@ -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." }, + }, + ], +}; diff --git a/src/collections/tickets/Tickets.ts b/src/collections/tickets/Tickets.ts index b278719..acbacba 100644 --- a/src/collections/tickets/Tickets.ts +++ b/src/collections/tickets/Tickets.ts @@ -239,5 +239,109 @@ export const Tickets: CollectionConfig = { date: { pickerAppearance: "dayAndTime" }, }, }, + + // --- Project tracking fields (Phase 1) --- + { + name: "project", + type: "relationship", + relationTo: "projects", + admin: { + description: "Project this ticket belongs to. Null = standalone/backlog.", + }, + index: true, + }, + { + name: "sprint", + type: "relationship", + relationTo: "sprints", + admin: { + description: "Sprint this ticket is assigned to.", + }, + }, + { + name: "release", + type: "relationship", + relationTo: "releases", + admin: { + description: "Release this ticket targets.", + }, + }, + { + name: "parentTicket", + type: "relationship", + relationTo: "tickets", + admin: { + description: "Parent ticket (for subtasks / epic children).", + }, + }, + { + name: "labels", + type: "relationship", + relationTo: "labels", + hasMany: true, + admin: { + description: "Labels for filtering and organization.", + }, + }, + { + name: "type", + type: "select", + defaultValue: "task", + options: [ + { label: "Task", value: "task" }, + { label: "Bug", value: "bug" }, + { label: "Story", value: "story" }, + { label: "Epic", value: "epic" }, + { label: "Improvement", value: "improvement" }, + ], + admin: { + description: "Issue type.", + }, + }, + { + name: "storyPoints", + type: "number", + min: 0, + admin: { + description: "Fibonacci story point estimate.", + }, + }, + { + name: "estimate", + type: "number", + min: 0, + admin: { + description: "Time estimate in hours.", + }, + }, + { + name: "startDate", + type: "date", + admin: { + description: "When work starts (for Gantt).", + date: { pickerAppearance: "dayAndTime" }, + }, + }, + { + name: "dueDate", + type: "date", + admin: { + description: "When work is due (for Gantt).", + date: { pickerAppearance: "dayAndTime" }, + }, + }, + { + name: "resolution", + type: "select", + options: [ + { label: "Fixed", value: "fixed" }, + { label: "Won't Fix", value: "wontfix" }, + { label: "Duplicate", value: "duplicate" }, + { label: "Invalid", value: "invalid" }, + ], + admin: { + description: "How the ticket was resolved.", + }, + }, ], }; diff --git a/src/components/frontend/blocks/AppSidebar.tsx b/src/components/frontend/blocks/AppSidebar.tsx index cb8dc62..670d939 100644 --- a/src/components/frontend/blocks/AppSidebar.tsx +++ b/src/components/frontend/blocks/AppSidebar.tsx @@ -5,7 +5,9 @@ import { Backpack, Bolt, Crosshair, + Kanban, LifeBuoy, + Search, Send, Shield, UserCog, @@ -47,6 +49,16 @@ const data = { url: "/helpdesk", icon: LifeBuoy, }, + { + title: "Projects", + url: "/projects", + icon: Kanban, + }, + { + title: "Search", + url: "/projects/search", + icon: Search, + }, ], navIntelligence: [ { diff --git a/src/components/frontend/helpdesk/CreateTicketDialog.tsx b/src/components/frontend/helpdesk/CreateTicketDialog.tsx index 0261a37..e0362bd 100644 --- a/src/components/frontend/helpdesk/CreateTicketDialog.tsx +++ b/src/components/frontend/helpdesk/CreateTicketDialog.tsx @@ -37,6 +37,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; interface CreateTicketDialogProps { open?: boolean; onOpenChange?: (open: boolean) => void; + projectId?: number; } function isTicketCategory(value: string): value is TicketCategory { @@ -50,6 +51,7 @@ function isTicketPriority(value: string): value is TicketPriority { export function CreateTicketDialog({ open: externalOpen, onOpenChange: externalOnOpenChange, + projectId, }: CreateTicketDialogProps = {}) { const router = useRouter(); const [internalOpen, setInternalOpen] = useState(false); @@ -90,6 +92,7 @@ export function CreateTicketDialog({ description: trimmedDescription, category, priority, + projectId, }); if (result.success) { diff --git a/src/components/frontend/projects/CreateProjectDialog.tsx b/src/components/frontend/projects/CreateProjectDialog.tsx new file mode 100644 index 0000000..0c945c3 --- /dev/null +++ b/src/components/frontend/projects/CreateProjectDialog.tsx @@ -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(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 ( + { + onOpenChange(nextOpen); + if (!nextOpen) reset(); + }} + > + + + Create project + Set up a project space for tickets, sprints, and releases. + +
{ + event.preventDefault(); + void handleSubmit(); + }} + > +
+ + setName(event.target.value)} + disabled={pending} + /> +
+
+ + setKey(event.target.value.toUpperCase().slice(0, 10))} + disabled={pending} + className="font-mono uppercase" + /> +

Up to 10 characters. Keys are stored in uppercase.

+
+
+ +