Compare commits
11 commits
bc6358ce5f
...
2a0668fc03
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a0668fc03 | |||
| 643e282fe7 | |||
| f69c7ca0b4 | |||
| 6d58451978 | |||
| 282ca9eddf | |||
| 485e26bbda | |||
| 65dcbddeea | |||
| 4c03784c68 | |||
| 1a86a3a8b5 | |||
| 3f4403d151 | |||
| 9bf984fc96 |
78 changed files with 22835 additions and 520 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import config from "./src/payload.config";
|
import config from "@payload-config";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|
@ -22,10 +22,10 @@ mission-files
|
||||||
|
|
||||||
# --- Tests + Playwright artifacts (not needed for the production image) ---
|
# --- Tests + Playwright artifacts (not needed for the production image) ---
|
||||||
tests
|
tests
|
||||||
test-results
|
.archived/test-results
|
||||||
playwright-report
|
.archived/playwright-report
|
||||||
blob-report
|
blob-report
|
||||||
playwright-report
|
.archived/playwright-report
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
test.env
|
test.env
|
||||||
playwright.config.ts
|
playwright.config.ts
|
||||||
|
|
@ -56,8 +56,8 @@ AGENTS.md
|
||||||
DEPLOYMENT.md
|
DEPLOYMENT.md
|
||||||
docs
|
docs
|
||||||
Writerside
|
Writerside
|
||||||
repro-account.ts
|
.archived/repro-account.ts
|
||||||
reset-test-user-password.ts
|
.archived/reset-test-user-password.ts
|
||||||
pnpm-lock.yaml.bk
|
pnpm-lock.yaml.bk
|
||||||
.yarnrc
|
.yarnrc
|
||||||
.npmrc
|
.npmrc
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
DATABASE_URI=postgres://ptfapp:polaristaskforcedb@127.0.0.1:5432/ptf-app-stg
|
|
||||||
PAYLOAD_SECRET=b5ac11acc77ea8427d10fcd0
|
|
||||||
# Added by Payload
|
|
||||||
|
|
||||||
LAST_UPDATED=
|
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -42,13 +42,15 @@ yarn-error.log*
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
.env
|
.env
|
||||||
|
.env.stg
|
||||||
|
.env.prd
|
||||||
|
|
||||||
/media
|
/media
|
||||||
|
|
||||||
# Playwright
|
# Playwright
|
||||||
node_modules/
|
node_modules/
|
||||||
/test-results/
|
/.archived/test-results/
|
||||||
/playwright-report/
|
/.archived/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
|
|
||||||
|
|
|
||||||
74
.todo/tasks.json
Normal file
74
.todo/tasks.json
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "TASK_1",
|
||||||
|
"priority": "LOW",
|
||||||
|
"tags": [
|
||||||
|
"Game::Market"
|
||||||
|
],
|
||||||
|
"title": "fix: cannot list locker items on market",
|
||||||
|
"description": "",
|
||||||
|
"status": "TODO",
|
||||||
|
"date": {
|
||||||
|
"date": {
|
||||||
|
"year": 2026,
|
||||||
|
"month": 8,
|
||||||
|
"day": 20
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"hour": 16,
|
||||||
|
"minute": 2,
|
||||||
|
"second": 14,
|
||||||
|
"nano": 427428729
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isImported": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TASK_3",
|
||||||
|
"priority": "LOW",
|
||||||
|
"tags": [
|
||||||
|
"App::Helpdesk"
|
||||||
|
],
|
||||||
|
"title": "feat: editable helpdesk tickets",
|
||||||
|
"description": "",
|
||||||
|
"status": "TODO",
|
||||||
|
"date": {
|
||||||
|
"date": {
|
||||||
|
"year": 2026,
|
||||||
|
"month": 8,
|
||||||
|
"day": 20
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"hour": 16,
|
||||||
|
"minute": 3,
|
||||||
|
"second": 1,
|
||||||
|
"nano": 462798021
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isImported": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TASK_8",
|
||||||
|
"priority": "LOW",
|
||||||
|
"tags": [
|
||||||
|
"App::Helpdesk"
|
||||||
|
],
|
||||||
|
"title": "feat: ticket upvotes",
|
||||||
|
"description": "Functionality for people to upvote the features they want to be prioritized for adding, or bugs to be fixed",
|
||||||
|
"status": "TODO",
|
||||||
|
"date": {
|
||||||
|
"date": {
|
||||||
|
"year": 2026,
|
||||||
|
"month": 8,
|
||||||
|
"day": 20
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"hour": 16,
|
||||||
|
"minute": 3,
|
||||||
|
"second": 28,
|
||||||
|
"nano": 849363122
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isImported": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -283,8 +283,8 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
- `.env` — local dev (PostgreSQL connection string + PAYLOAD_SECRET)
|
- `.env` — local dev (PostgreSQL connection string + PAYLOAD_SECRET)
|
||||||
- `.env.example` — template. `DATABASE_URI` line still shows MongoDB (outdated — trust `.env.test` for the real Postgres format), but `APP_URL` + `GAME_TICK_NOTIFY_SECRET` are current and required by the game tick.
|
- `.env.example` — template. `DATABASE_URI` line still shows MongoDB (outdated — trust `.env.stg` for the real Postgres format), but `APP_URL` + `GAME_TICK_NOTIFY_SECRET` are current and required by the game tick.
|
||||||
- `.env.test` — test/staging database
|
- `.env.stg` — test/staging database
|
||||||
- `test.env` — NODE_OPTIONS for playwright (loaded by playwright config)
|
- `test.env` — NODE_OPTIONS for playwright (loaded by playwright config)
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
@ -294,7 +294,7 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Next.js 16 framework traps (each one caused a real failed fix — see "Next.js 16 hard rules" above): `searchParams`/`params` are Promises and must be awaited; `cookies().set()` throws outside Server Actions/Route Handlers; `x-invoke-path` does not carry the real pathname in layouts.
|
- Next.js 16 framework traps (each one caused a real failed fix — see "Next.js 16 hard rules" above): `searchParams`/`params` are Promises and must be awaited; `cookies().set()` throws outside Server Actions/Route Handlers; `x-invoke-path` does not carry the real pathname in layouts.
|
||||||
- `.env.example` shows MongoDB URI but the app uses PostgreSQL — trust `DATABASE_URI` format in `.env.test` as the real reference.
|
- `.env.example` shows MongoDB URI but the app uses PostgreSQL — trust `DATABASE_URI` format in `.env.stg` as the real reference.
|
||||||
- `bun run build` passes `--max-old-space-size=8000` — the build is memory-intensive.
|
- `bun run build` passes `--max-old-space-size=8000` — the build is memory-intensive.
|
||||||
- The `devturbo` script uses Turbopack; `dev` and `devsafe` use webpack. These are different bundlers with different behavior.
|
- The `devturbo` script uses Turbopack; `dev` and `devsafe` use webpack. These are different bundlers with different behavior.
|
||||||
- Playwright tests auto-start the dev server — make sure port 3000 is free before running e2e.
|
- Playwright tests auto-start the dev server — make sure port 3000 is free before running e2e.
|
||||||
|
|
|
||||||
10
DESIGN.md
10
DESIGN.md
|
|
@ -67,6 +67,16 @@ Spacing follows Tailwind's 4px scale. Existing frontend stacks use `gap-1`, `gap
|
||||||
- **Motion**: no section-level motion.
|
- **Motion**: no section-level motion.
|
||||||
- **Layout**: vertical stack.
|
- **Layout**: vertical stack.
|
||||||
|
|
||||||
|
### Mission card
|
||||||
|
|
||||||
|
- **Structure**: linked `Item` with `ItemHeader`, `ItemMedia`, `ItemTitle`, `ItemContent`, and `ItemDescription` primitives.
|
||||||
|
- **Variants**: `default` uses the neutral outline surface; `image` adds an optional cover image with a translucent gradient overlay. The contextual default is `image` when a cover image URL is available.
|
||||||
|
- **Metadata**: compact cards show operation/status, date/time, map, player slots, and campaign; full cards additionally show estimated duration.
|
||||||
|
- **States**: default, image, hover, focus-visible, and reduced-content/full-detail display.
|
||||||
|
- **Accessibility**: the entire card is one keyboard-reachable link; decorative cover images use empty alt text and are hidden from assistive technology.
|
||||||
|
- **Motion**: existing 200ms scale, border, and foreground transitions communicate the linked-card affordance; no decorative animation.
|
||||||
|
- **Layout**: responsive metadata wraps on narrow screens while the image layer stays absolutely positioned behind content.
|
||||||
|
|
||||||
### Preference row
|
### Preference row
|
||||||
|
|
||||||
- **Structure**: `label.flex.items-center.justify-between` with text on the left and a shadcn control on the right.
|
- **Structure**: `label.flex.items-center.justify-between` with text on the left and a shadcn control on the right.
|
||||||
|
|
|
||||||
14
TODO.md
14
TODO.md
|
|
@ -14,9 +14,11 @@
|
||||||
|
|
||||||
## MVP – Priority Deadline by 8/15
|
## MVP – Priority Deadline by 8/15
|
||||||
|
|
||||||
|
[!NOTE] Some pieces of this application were not implemented by this date and have been pushed to the next release.
|
||||||
|
|
||||||
This application must be available to my users by 8/15. The minimum viable product is defined as:
|
This application must be available to my users by 8/15. The minimum viable product is defined as:
|
||||||
|
|
||||||
- [ ] Working authentication (login, logout, forgot password without using email)
|
- [x] Working authentication (login, logout, forgot password without using email)
|
||||||
- [ ] Automatically generated profile with customization options for user on the front-end
|
- [ ] Automatically generated profile with customization options for user on the front-end
|
||||||
- [ ] Full permissions and ACL system for all components
|
- [ ] Full permissions and ACL system for all components
|
||||||
- [ ] ALL ADMIN UI access should be blocked behind a "trusted" role.
|
- [ ] ALL ADMIN UI access should be blocked behind a "trusted" role.
|
||||||
|
|
@ -31,11 +33,11 @@ This application must be available to my users by 8/15. The minimum viable produ
|
||||||
|
|
||||||
### Logistics (core of the application)
|
### Logistics (core of the application)
|
||||||
|
|
||||||
- [ ] Logistics front-end
|
- [x] Logistics front-end
|
||||||
- [ ] Working shipments screen
|
- [x] Working shipments screen
|
||||||
- [ ] Working structures screen
|
- [x] Working structures screen
|
||||||
- [ ] Working assets screen
|
- [x] Working assets screen
|
||||||
- [ ] Working vehicles screen
|
- [x] Working vehicles screen
|
||||||
- [ ] Banking system
|
- [ ] Banking system
|
||||||
- [ ] Working market screen
|
- [ ] Working market screen
|
||||||
|
|
||||||
|
|
|
||||||
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",
|
||||||
|
|
|
||||||
19
reset-seq.sql
Normal file
19
reset-seq.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
SELECT
|
||||||
|
'SELECT SETVAL(' ||
|
||||||
|
quote_literal(quote_ident(sequence_namespace.nspname) || '.' || quote_ident(class_sequence.relname)) ||
|
||||||
|
', COALESCE(MAX(' ||quote_ident(pg_attribute.attname)|| '), 1) ) FROM ' ||
|
||||||
|
quote_ident(table_namespace.nspname)|| '.'||quote_ident(class_table.relname)|| ';'
|
||||||
|
FROM pg_depend
|
||||||
|
INNER JOIN pg_class AS class_sequence
|
||||||
|
ON class_sequence.oid = pg_depend.objid
|
||||||
|
AND class_sequence.relkind = 'S'
|
||||||
|
INNER JOIN pg_class AS class_table
|
||||||
|
ON class_table.oid = pg_depend.refobjid
|
||||||
|
INNER JOIN pg_attribute
|
||||||
|
ON pg_attribute.attrelid = class_table.oid
|
||||||
|
AND pg_depend.refobjsubid = pg_attribute.attnum
|
||||||
|
INNER JOIN pg_namespace as table_namespace
|
||||||
|
ON table_namespace.oid = class_table.relnamespace
|
||||||
|
INNER JOIN pg_namespace AS sequence_namespace
|
||||||
|
ON sequence_namespace.oid = class_sequence.relnamespace
|
||||||
|
ORDER BY sequence_namespace.nspname, class_sequence.relname;
|
||||||
|
|
@ -4,6 +4,7 @@ import config from "@payload-config";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
import { broadcastToUser } from "@/lib/realtime/bus";
|
||||||
|
|
||||||
interface ActionResult<T = undefined> {
|
interface ActionResult<T = undefined> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
@ -100,6 +101,11 @@ export async function submitFlappyScore(input: {
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
});
|
});
|
||||||
|
broadcastToUser("profile-update", Number(user.id), {
|
||||||
|
reason: "xp",
|
||||||
|
xpGained: runXp,
|
||||||
|
profileId: profile.id,
|
||||||
|
});
|
||||||
|
|
||||||
if (passes > 0) {
|
if (passes > 0) {
|
||||||
await emitGameEvent(payload, {
|
await emitGameEvent(payload, {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import {
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemDescription, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
|
import { MissionCard } from "@/components/frontend/intelligence/MissionCard";
|
||||||
|
import { statusColors as missionStatusColors } from "@/lib/intelligence/missionColors";
|
||||||
|
|
||||||
interface CampaignDetailPageProps {
|
interface CampaignDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
|
|
@ -31,16 +33,6 @@ const factionTypeColors: Record<string, string> = {
|
||||||
special: "bg-purple-500/20 text-purple-400",
|
special: "bg-purple-500/20 text-purple-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
const missionStatusColors: Record<string, string> = {
|
|
||||||
Concept: "bg-muted text-muted-foreground",
|
|
||||||
Planning: "bg-muted text-muted-foreground",
|
|
||||||
Ready: "bg-blue-500/20 text-blue-400",
|
|
||||||
Scheduled: "bg-amber-500/20 text-amber-400",
|
|
||||||
Active: "bg-green-500/20 text-green-400",
|
|
||||||
Completed: "bg-emerald-500/20 text-emerald-400",
|
|
||||||
Cancelled: "bg-red-500/20 text-red-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function CampaignDetailPage({ params }: CampaignDetailPageProps) {
|
export default async function CampaignDetailPage({ params }: CampaignDetailPageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
|
|
@ -198,40 +190,12 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
|
||||||
{missions.length > 0 ? (
|
{missions.length > 0 ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{missions.map((mission) => (
|
{missions.map((mission) => (
|
||||||
<Link key={mission.id} href={`/intelligence/missions/${mission.id}`}>
|
<MissionCard
|
||||||
<Item
|
key={mission.id}
|
||||||
variant="outline"
|
mission={mission}
|
||||||
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01] group relative border border-transparent hover:border-white/75"
|
variant="image"
|
||||||
>
|
showFullDetails={false}
|
||||||
<div className="absolute inset-0 pointer-events-none opacity-25 overflow-hidden after:bg-linear-0 after:bg-linear-180 after:from-transparent after:to-black/42 after:inset-0 after:z-10 after:absolute group-hover:after:opacity-0 after:transition-opacity">
|
/>
|
||||||
<img
|
|
||||||
src={(mission.coverImage as Media)?.url ?? undefined}
|
|
||||||
className="w-full h-full aspect-video object-cover object-center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ItemHeader>
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<span className="font-mono text-xs text-muted-foreground">
|
|
||||||
{mission.codeName}
|
|
||||||
</span>
|
|
||||||
<ItemTitle>{mission.name}</ItemTitle>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${
|
|
||||||
missionStatusColors[mission.ownershipAndStatus.status] ??
|
|
||||||
"bg-muted text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{mission.ownershipAndStatus.status}
|
|
||||||
</span>
|
|
||||||
</ItemHeader>
|
|
||||||
<ItemContent>
|
|
||||||
<ItemDescription className="line-clamp-1">
|
|
||||||
{mission.summary}
|
|
||||||
</ItemDescription>
|
|
||||||
</ItemContent>
|
|
||||||
</Item>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
ShieldIcon,
|
ShieldIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
|
import { MissionCard } from "@/components/frontend/intelligence/MissionCard";
|
||||||
import { formatAmount } from "@/lib/banking/format";
|
import { formatAmount } from "@/lib/banking/format";
|
||||||
|
|
||||||
interface FactionDetailPageProps {
|
interface FactionDetailPageProps {
|
||||||
|
|
@ -36,16 +37,6 @@ const typeLabels: Record<string, string> = {
|
||||||
special: "Special",
|
special: "Special",
|
||||||
};
|
};
|
||||||
|
|
||||||
const missionStatusColors: Record<string, string> = {
|
|
||||||
Concept: "bg-muted text-muted-foreground",
|
|
||||||
Planning: "bg-muted text-muted-foreground",
|
|
||||||
Ready: "bg-blue-500/20 text-blue-400",
|
|
||||||
Scheduled: "bg-amber-500/20 text-amber-400",
|
|
||||||
Active: "bg-green-500/20 text-green-400",
|
|
||||||
Completed: "bg-emerald-500/20 text-emerald-400",
|
|
||||||
Cancelled: "bg-red-500/20 text-red-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
const campaignStatusColors: Record<string, string> = {
|
const campaignStatusColors: Record<string, string> = {
|
||||||
concept: "bg-muted text-muted-foreground",
|
concept: "bg-muted text-muted-foreground",
|
||||||
inProgress: "bg-blue-500/20 text-blue-400",
|
inProgress: "bg-blue-500/20 text-blue-400",
|
||||||
|
|
@ -134,13 +125,16 @@ export default async function FactionDetailPage({ params }: FactionDetailPagePro
|
||||||
where: { "factionsInvolved.faction": { equals: faction.id } },
|
where: { "factionsInvolved.faction": { equals: faction.id } },
|
||||||
sort: "-classification.startDateTime",
|
sort: "-classification.startDateTime",
|
||||||
limit: 100,
|
limit: 100,
|
||||||
depth: 0,
|
depth: 1,
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
name: true,
|
||||||
codeName: true,
|
codeName: true,
|
||||||
summary: true,
|
summary: true,
|
||||||
|
operationType: true,
|
||||||
ownershipAndStatus: true,
|
ownershipAndStatus: true,
|
||||||
classification: true,
|
classification: true,
|
||||||
|
missionRoles: true,
|
||||||
|
coverImage: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => ({ docs: [] as Mission[] }));
|
.catch(() => ({ docs: [] as Mission[] }));
|
||||||
|
|
@ -303,34 +297,7 @@ export default async function FactionDetailPage({ params }: FactionDetailPagePro
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{missions.map((mission) => (
|
{missions.map((mission) => (
|
||||||
<Link key={mission.id} href={`/intelligence/missions/${mission.id}`}>
|
<MissionCard key={mission.id} mission={mission} showFullDetails={false} />
|
||||||
<Item
|
|
||||||
variant="outline"
|
|
||||||
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01]"
|
|
||||||
>
|
|
||||||
<ItemHeader>
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<span className="font-mono text-xs text-muted-foreground">
|
|
||||||
{mission.codeName}
|
|
||||||
</span>
|
|
||||||
<ItemTitle>{mission.name}</ItemTitle>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${
|
|
||||||
missionStatusColors[mission.ownershipAndStatus.status] ??
|
|
||||||
"bg-muted text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{mission.ownershipAndStatus.status}
|
|
||||||
</span>
|
|
||||||
</ItemHeader>
|
|
||||||
<ItemContent>
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
|
||||||
{mission.summary}
|
|
||||||
</p>
|
|
||||||
</ItemContent>
|
|
||||||
</Item>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { CommandPalette } from "@/components/command-palette/CommandPalette";
|
||||||
import { CommandPaletteProvider } from "@/components/command-palette/CommandPaletteContext";
|
import { CommandPaletteProvider } from "@/components/command-palette/CommandPaletteContext";
|
||||||
import { KeyboardShortcutsProvider } from "@/components/command-palette/KeyboardShortcutsProvider";
|
import { KeyboardShortcutsProvider } from "@/components/command-palette/KeyboardShortcutsProvider";
|
||||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||||
|
import { Rank } from "@/payload-types";
|
||||||
|
|
||||||
// Skip static prerendering of this layout (and all pages under (frontend)/).
|
// Skip static prerendering of this layout (and all pages under (frontend)/).
|
||||||
// The layout calls `getPayload()` at render time to resolve the current user,
|
// The layout calls `getPayload()` at render time to resolve the current user,
|
||||||
|
|
@ -58,6 +59,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
|
|
||||||
let isIntelligence = false;
|
let isIntelligence = false;
|
||||||
let isLogistics = false;
|
let isLogistics = false;
|
||||||
|
let currentRank = "";
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
[isIntelligence, isLogistics] = await Promise.all([
|
[isIntelligence, isLogistics] = await Promise.all([
|
||||||
|
|
@ -69,6 +71,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
collection: "profiles",
|
collection: "profiles",
|
||||||
where: { user: { equals: user.id } },
|
where: { user: { equals: user.id } },
|
||||||
select: {
|
select: {
|
||||||
|
rank: true,
|
||||||
progression: {
|
progression: {
|
||||||
awards: true,
|
awards: true,
|
||||||
scoreboard: true,
|
scoreboard: true,
|
||||||
|
|
@ -90,6 +93,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
|
|
||||||
const userXp = profileRes.docs[0]?.progression?.xp ?? 0;
|
const userXp = profileRes.docs[0]?.progression?.xp ?? 0;
|
||||||
xpLevel = resolveLevel(userXp, experienceRes.docs);
|
xpLevel = resolveLevel(userXp, experienceRes.docs);
|
||||||
|
|
||||||
|
currentRank = (profileRes.docs[0]?.rank as Rank).abbreviation;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -107,6 +112,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
||||||
user={{
|
user={{
|
||||||
displayName: user.payloadDisplayName ?? "Guest",
|
displayName: user.payloadDisplayName ?? "Guest",
|
||||||
username: user.username ?? "Not logged in",
|
username: user.username ?? "Not logged in",
|
||||||
|
rank: currentRank,
|
||||||
}}
|
}}
|
||||||
xpLevel={xpLevel}
|
xpLevel={xpLevel}
|
||||||
adminUrl={payloadConfig.routes.admin}
|
adminUrl={payloadConfig.routes.admin}
|
||||||
|
|
|
||||||
|
|
@ -114,21 +114,28 @@ export default async function HomePage() {
|
||||||
missionRoles: briefingMission.missionRoles
|
missionRoles: briefingMission.missionRoles
|
||||||
? { maxPlayers: briefingMission.missionRoles.maxPlayers }
|
? { maxPlayers: briefingMission.missionRoles.maxPlayers }
|
||||||
: null,
|
: null,
|
||||||
|
coverImage: briefingMission.coverImage,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const upcomingMissions = upcomingRes.docs.map((m) => ({
|
const upcomingMissions = upcomingRes.docs.map((m) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
codeName: m.codeName,
|
codeName: m.codeName,
|
||||||
|
summary: m.summary,
|
||||||
|
operationType: m.operationType,
|
||||||
classification: {
|
classification: {
|
||||||
|
missionType: m.classification.missionType,
|
||||||
startDateTime: m.classification.startDateTime,
|
startDateTime: m.classification.startDateTime,
|
||||||
|
estimatedDuration: m.classification.estimatedDuration,
|
||||||
map: m.classification.map as number | Map,
|
map: m.classification.map as number | Map,
|
||||||
campaign: m.classification.campaign as number | Campaign,
|
campaign: m.classification.campaign as number | Campaign,
|
||||||
},
|
},
|
||||||
ownershipAndStatus: {
|
ownershipAndStatus: {
|
||||||
status: m.ownershipAndStatus.status,
|
status: m.ownershipAndStatus.status,
|
||||||
},
|
},
|
||||||
|
missionRoles: m.missionRoles ? { maxPlayers: m.missionRoles.maxPlayers } : null,
|
||||||
|
coverImage: m.coverImage,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Fetch recent events
|
// Fetch recent events
|
||||||
|
|
@ -198,7 +205,7 @@ export default async function HomePage() {
|
||||||
<div className="p-5 flex flex-col gap-6">
|
<div className="p-5 flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h1 className="text-lg font-semibold">
|
<h1 className="text-lg font-semibold">
|
||||||
Welcome back, {profileUser?.displayName ?? user.username}
|
Welcome back, {(profile?.rank as Rank).name}. {/*{profileUser?.displayName ?? user.username}*/}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">Here's your operations dashboard.</p>
|
<p className="text-sm text-muted-foreground">Here's your operations dashboard.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
160
src/app/(frontend)/projects/[key]/board/page.tsx
Normal file
160
src/app/(frontend)/projects/[key]/board/page.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
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";
|
||||||
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Board — Polaris Task Force",
|
||||||
|
};
|
||||||
|
|
||||||
|
const KANBAN_STATUSES = [
|
||||||
|
{ status: "open", label: "Open" },
|
||||||
|
{ status: "planning", label: "Planning" },
|
||||||
|
{ status: "in_progress", label: "In Progress" },
|
||||||
|
{ status: "in_review", label: "In Review" },
|
||||||
|
{ status: "waiting_on_reporter", label: "Waiting on Reporter" },
|
||||||
|
{ status: "in_testing", label: "Testing" },
|
||||||
|
{ status: "resolved", label: "Done" },
|
||||||
|
{ status: "canceled", label: "Canceled" },
|
||||||
|
{ status: "closed", label: "Closed" },
|
||||||
|
] 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 canManageTickets = await hasPermission(payload, user, "tickets:staff");
|
||||||
|
|
||||||
|
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={canManageTickets}
|
||||||
|
/>
|
||||||
|
</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`);
|
||||||
|
}
|
||||||
102
src/app/(frontend)/projects/[key]/releases/page.tsx
Normal file
102
src/app/(frontend)/projects/[key]/releases/page.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
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";
|
||||||
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
import { richTextToPlaintext } from "@/lib/projects/projectMeta";
|
||||||
|
|
||||||
|
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 canManage = await hasPermission(payload, user, "releases:update");
|
||||||
|
const canCreate = await hasPermission(payload, user, "releases:create");
|
||||||
|
|
||||||
|
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: richTextToPlaintext(r.description) || null,
|
||||||
|
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={canManage || canCreate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/app/(frontend)/projects/[key]/sprints/page.tsx
Normal file
107
src/app/(frontend)/projects/[key]/sprints/page.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
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";
|
||||||
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
|
||||||
|
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 canManage = await hasPermission(payload, user, "sprints:update");
|
||||||
|
const canCreate = await hasPermission(payload, user, "sprints:create");
|
||||||
|
|
||||||
|
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={canManage || canCreate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
307
src/app/(frontend)/projects/actions.ts
Normal file
307
src/app/(frontend)/projects/actions.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
"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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requirePermission(
|
||||||
|
payload: Awaited<ReturnType<typeof getPayload>>,
|
||||||
|
user: Parameters<typeof hasPermission>[1],
|
||||||
|
permission: Parameters<typeof hasPermission>[2],
|
||||||
|
) {
|
||||||
|
if (!(await hasPermission(payload, user, permission))) {
|
||||||
|
throw new Error("You don't have permission to manage project tracking.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProject(input: {
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
description?: string;
|
||||||
|
leadId?: number;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
await requirePermission(payload, user, "projects:create");
|
||||||
|
|
||||||
|
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();
|
||||||
|
await requirePermission(payload, user, "sprints:create");
|
||||||
|
|
||||||
|
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();
|
||||||
|
await requirePermission(payload, user, "sprints:update");
|
||||||
|
|
||||||
|
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();
|
||||||
|
await requirePermission(payload, user, "sprints:update");
|
||||||
|
|
||||||
|
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();
|
||||||
|
await requirePermission(payload, user, "releases:update");
|
||||||
|
|
||||||
|
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();
|
||||||
|
await requirePermission(payload, user, "releases:update");
|
||||||
|
|
||||||
|
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();
|
||||||
|
await requirePermission(payload, user, "releases:create");
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/app/(frontend)/projects/page.tsx
Normal file
55
src/app/(frontend)/projects/page.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
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";
|
||||||
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
import { richTextToPlaintext } from "@/lib/projects/projectMeta";
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
const canCreate = await hasPermission(payload, user, "projects:create");
|
||||||
|
|
||||||
|
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: richTextToPlaintext(p.description) || null,
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
updatedAt: p.updatedAt,
|
||||||
|
}))}
|
||||||
|
currentUserId={user.id as number}
|
||||||
|
isAdmin={canCreate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/app/(frontend)/projects/search/page.tsx
Normal file
73
src/app/(frontend)/projects/search/page.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
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,
|
||||||
|
pagination: false,
|
||||||
|
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",
|
||||||
|
category: t.category,
|
||||||
|
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,
|
||||||
|
href: `/helpdesk/${t.id}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -298,6 +298,23 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes xp-gain {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(0.35rem);
|
||||||
|
}
|
||||||
|
15% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-1.1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.locker-search-highlight {
|
.locker-search-highlight {
|
||||||
animation: locker-search-pulse 1.1s ease-in-out infinite;
|
animation: locker-search-pulse 1.1s ease-in-out infinite;
|
||||||
box-shadow: 0 0 0 3px rgb(251 191 36 / 0.9);
|
box-shadow: 0 0 0 3px rgb(251 191 36 / 0.9);
|
||||||
|
|
@ -331,4 +348,4 @@
|
||||||
|
|
||||||
*::-webkit-scrollbar-corner {
|
*::-webkit-scrollbar-corner {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
src/app/api/realtime/presence/route.ts
Normal file
38
src/app/api/realtime/presence/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { removePresence, touchPresence } from "@/lib/realtime/presence";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
async function authenticate(req: NextRequest) {
|
||||||
|
const payload = await getPayload({ config: await config });
|
||||||
|
return payload.auth({ headers: req.headers, canSetHeaders: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelFrom(req: NextRequest): string | null {
|
||||||
|
const channel = req.nextUrl.searchParams.get("channel")?.trim();
|
||||||
|
return channel && /^[a-z0-9:_-]{1,80}$/i.test(channel) ? channel : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const channel = channelFrom(req);
|
||||||
|
if (!channel) return NextResponse.json({ ok: false, error: "Invalid channel." }, { status: 400 });
|
||||||
|
const { user } = await authenticate(req);
|
||||||
|
if (!user) return NextResponse.json({ ok: false }, { status: 401 });
|
||||||
|
touchPresence(channel, {
|
||||||
|
id: user.id as number,
|
||||||
|
name: user.payloadDisplayName || user.username || `Pilot #${user.id}`,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const channel = channelFrom(req);
|
||||||
|
if (!channel) return NextResponse.json({ ok: false, error: "Invalid channel." }, { status: 400 });
|
||||||
|
const { user } = await authenticate(req);
|
||||||
|
if (!user) return NextResponse.json({ ok: false }, { status: 401 });
|
||||||
|
removePresence(channel, user.id as number);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
import { type SSEEventClient, subscribe, unsubscribe } from "@/lib/realtime/bus";
|
import { type SSEEventClient, subscribe, unsubscribe } from "@/lib/realtime/bus";
|
||||||
|
import { getPresence } from "@/lib/realtime/presence";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
const payload = await getPayload({ config: await config });
|
||||||
|
const { user } = await payload.auth({ headers: req.headers, canSetHeaders: false });
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
const channel = req.nextUrl.searchParams.get("channel");
|
||||||
|
|
||||||
let client: SSEEventClient | null = null;
|
let client: SSEEventClient | null = null;
|
||||||
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
@ -21,10 +27,15 @@ export async function GET(req: NextRequest) {
|
||||||
start(controller) {
|
start(controller) {
|
||||||
client = {
|
client = {
|
||||||
send: (data) => controller.enqueue(encoder.encode(data)),
|
send: (data) => controller.enqueue(encoder.encode(data)),
|
||||||
|
userId: user ? Number(user.id) : undefined,
|
||||||
|
events: channel ? new Set(["presence"]) : undefined,
|
||||||
};
|
};
|
||||||
subscribe(client);
|
subscribe(client);
|
||||||
|
|
||||||
controller.enqueue(encoder.encode(": connected\n\n"));
|
controller.enqueue(encoder.encode(": connected\n\n"));
|
||||||
|
if (channel) {
|
||||||
|
controller.enqueue(encoder.encode(`event: presence\ndata: ${JSON.stringify({ channel, action: "snapshot", participants: getPresence(channel) })}\n\n`));
|
||||||
|
}
|
||||||
|
|
||||||
heartbeat = setInterval(() => {
|
heartbeat = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
69
src/collections/projects/Projects.ts
Normal file
69
src/collections/projects/Projects.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
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: async ({ req }) => {
|
||||||
|
if (!req.user) return false;
|
||||||
|
return await hasPermission(req.payload, req.user, "projects:create");
|
||||||
|
},
|
||||||
|
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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { CollectionConfig } from "payload";
|
import { CollectionConfig } from "payload";
|
||||||
import { Award } from "@/payload-types";
|
import { Award } from "@/payload-types";
|
||||||
|
import { broadcastToUser } from "@/lib/realtime/bus";
|
||||||
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
|
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
|
||||||
export const Profiles: CollectionConfig = {
|
export const Profiles: CollectionConfig = {
|
||||||
|
|
@ -23,6 +24,16 @@ export const Profiles: CollectionConfig = {
|
||||||
return await hasPermission(req.payload, req.user, "profiles:delete");
|
return await hasPermission(req.payload, req.user, "profiles:delete");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
hooks: {
|
||||||
|
afterChange: [
|
||||||
|
async ({ doc }) => {
|
||||||
|
const userId = typeof doc.user === "object" ? doc.user?.id : doc.user;
|
||||||
|
if (typeof userId === "number") {
|
||||||
|
broadcastToUser("profile-update", userId, { profileId: doc.id });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: "user",
|
name: "user",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ import {
|
||||||
Backpack,
|
Backpack,
|
||||||
Bolt,
|
Bolt,
|
||||||
Crosshair,
|
Crosshair,
|
||||||
|
Kanban,
|
||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
|
Search,
|
||||||
Send,
|
Send,
|
||||||
Shield,
|
Shield,
|
||||||
UserCog,
|
UserCog,
|
||||||
|
|
@ -29,6 +31,7 @@ import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import XPDisplay from "@/components/frontend/blocks/XPDisplay";
|
import XPDisplay from "@/components/frontend/blocks/XPDisplay";
|
||||||
import type { ResolvedLevel } from "@/utils/xp/resolveLevel";
|
import type { ResolvedLevel } from "@/utils/xp/resolveLevel";
|
||||||
|
import { Rank } from "@/payload-types";
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
navMain: [
|
navMain: [
|
||||||
|
|
@ -47,6 +50,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: [
|
||||||
{
|
{
|
||||||
|
|
@ -139,6 +152,7 @@ export function AppSidebar({
|
||||||
user: {
|
user: {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
rank: string;
|
||||||
};
|
};
|
||||||
xpLevel: ResolvedLevel;
|
xpLevel: ResolvedLevel;
|
||||||
adminUrl?: string;
|
adminUrl?: string;
|
||||||
|
|
@ -216,7 +230,9 @@ export function AppSidebar({
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser
|
<NavUser
|
||||||
user={{
|
user={{
|
||||||
displayName: user?.displayName ?? "Not logged in",
|
displayName: user?.displayName
|
||||||
|
? [user?.rank ? `(${user?.rank}.)` : null, user?.displayName].join(" ")
|
||||||
|
: "Not logged in",
|
||||||
username: user?.username ?? "",
|
username: user?.username ?? "",
|
||||||
avatar: "",
|
avatar: "",
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,42 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { SparkleIcon } from "lucide-react";
|
import { SparkleIcon } from "lucide-react";
|
||||||
import type { ResolvedLevel } from "@/utils/xp/resolveLevel";
|
import type { ResolvedLevel } from "@/utils/xp/resolveLevel";
|
||||||
|
import { PROFILE_UPDATE_EVENT, type ProfileUpdateDetail } from "@/hooks/useGameTick";
|
||||||
|
|
||||||
const XPDisplay = ({ xpLevel }: { xpLevel: ResolvedLevel }) => {
|
const XPDisplay = ({ xpLevel }: { xpLevel: ResolvedLevel }) => {
|
||||||
const { levelName, progress, xp, nextLevelXP } = xpLevel;
|
const { levelName, progress, xp, nextLevelXP } = xpLevel;
|
||||||
|
const [xpGain, setXpGain] = useState<number | null>(null);
|
||||||
|
const [xpGainId, setXpGainId] = useState(0);
|
||||||
|
const lastGainRef = useRef<{ amount: number; at: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let clearTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const handleProfileUpdate = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<ProfileUpdateDetail>).detail;
|
||||||
|
if (!detail || typeof detail.xpGained !== "number" || detail.xpGained <= 0) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (lastGainRef.current?.amount === detail.xpGained && now - lastGainRef.current.at < 500) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastGainRef.current = { amount: detail.xpGained, at: now };
|
||||||
|
|
||||||
|
setXpGain(detail.xpGained);
|
||||||
|
setXpGainId((id) => id + 1);
|
||||||
|
if (clearTimer) clearTimeout(clearTimer);
|
||||||
|
clearTimer = setTimeout(() => setXpGain(null), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(PROFILE_UPDATE_EVENT, handleProfileUpdate);
|
||||||
|
return () => {
|
||||||
|
if (clearTimer) clearTimeout(clearTimer);
|
||||||
|
window.removeEventListener(PROFILE_UPDATE_EVENT, handleProfileUpdate);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Item>
|
<Item>
|
||||||
|
|
@ -15,7 +45,18 @@ const XPDisplay = ({ xpLevel }: { xpLevel: ResolvedLevel }) => {
|
||||||
<SparkleIcon />
|
<SparkleIcon />
|
||||||
<div className="flex flex-col items-end">
|
<div className="flex flex-col items-end">
|
||||||
<span className="uppercase">{levelName}</span>
|
<span className="uppercase">{levelName}</span>
|
||||||
<span className="text-2xl font-bold uppercase">{xp.toLocaleString()}</span>
|
<span className="relative text-2xl font-bold uppercase flex flex-row items-center">
|
||||||
|
{xp.toLocaleString()}
|
||||||
|
{xpGain !== null && (
|
||||||
|
<span
|
||||||
|
key={`${xpGain}-${xpGainId}`}
|
||||||
|
className="pointer-events-none absolute right-full top-0 mr-2 whitespace-nowrap text-sm font-bold normal-case text-emerald-400 animate-[xp-gain_1.5s_ease-out_forwards]"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
+{xpGain.toLocaleString()} XP
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</ItemTitle>
|
</ItemTitle>
|
||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,14 @@
|
||||||
import Link from "next/link";
|
|
||||||
import type { Campaign, Map } from "@/payload-types";
|
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { CalendarDaysIcon, ClockIcon, CrosshairIcon, MapPinIcon, UsersIcon } from "lucide-react";
|
import {
|
||||||
|
MissionCard,
|
||||||
|
type MissionCardProps,
|
||||||
|
} from "@/components/frontend/intelligence/MissionCard";
|
||||||
|
import { CrosshairIcon } from "lucide-react";
|
||||||
|
|
||||||
interface MissionBriefingProps {
|
interface MissionBriefingProps {
|
||||||
mission: {
|
readonly mission: MissionCardProps["mission"] | null;
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
codeName: string;
|
|
||||||
summary: string;
|
|
||||||
operationType: "main" | "side" | "external";
|
|
||||||
classification: {
|
|
||||||
missionType: string;
|
|
||||||
startDateTime: string;
|
|
||||||
estimatedDuration: number;
|
|
||||||
map: number | Map;
|
|
||||||
campaign: number | Campaign;
|
|
||||||
};
|
|
||||||
ownershipAndStatus: {
|
|
||||||
status: string;
|
|
||||||
};
|
|
||||||
missionRoles?: {
|
|
||||||
maxPlayers: number;
|
|
||||||
} | null;
|
|
||||||
} | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
|
||||||
Concept: "bg-muted text-muted-foreground",
|
|
||||||
Planning: "bg-muted text-muted-foreground",
|
|
||||||
Ready: "bg-blue-500/20 text-blue-400",
|
|
||||||
Scheduled: "bg-amber-500/20 text-amber-400",
|
|
||||||
Active: "bg-green-500/20 text-green-400",
|
|
||||||
Completed: "bg-emerald-500/20 text-emerald-400",
|
|
||||||
Cancelled: "bg-red-500/20 text-red-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function MissionBriefing({ mission }: MissionBriefingProps) {
|
export function MissionBriefing({ mission }: MissionBriefingProps) {
|
||||||
if (!mission) {
|
if (!mission) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -53,82 +26,5 @@ export function MissionBriefing({ mission }: MissionBriefingProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const map =
|
return <MissionCard mission={mission} showFullDetails={true} />;
|
||||||
typeof mission.classification.map === "object" ? (mission.classification.map as Map) : null;
|
|
||||||
const campaign =
|
|
||||||
typeof mission.classification.campaign === "object"
|
|
||||||
? (mission.classification.campaign as Campaign)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const startDate = new Date(mission.classification.startDateTime);
|
|
||||||
const dateStr = startDate.toLocaleDateString("en-US", {
|
|
||||||
weekday: "short",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
const timeStr = startDate.toLocaleTimeString("en-US", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
const durationHours = Math.floor(mission.classification.estimatedDuration / 60);
|
|
||||||
const durationMins = mission.classification.estimatedDuration % 60;
|
|
||||||
const durationStr = durationMins > 0 ? `${durationHours}h ${durationMins}m` : `${durationHours}h`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Item variant="outline">
|
|
||||||
<ItemHeader>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CrosshairIcon className="size-4 text-muted-foreground" />
|
|
||||||
<ItemTitle>Mission Briefing</ItemTitle>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={`/intelligence/missions/${mission.id}`}
|
|
||||||
className="text-xs text-muted-foreground hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
View details
|
|
||||||
</Link>
|
|
||||||
</ItemHeader>
|
|
||||||
<ItemContent className="flex flex-col gap-3">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-mono text-xs text-muted-foreground">{mission.codeName}</span>
|
|
||||||
<span
|
|
||||||
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[mission.ownershipAndStatus.status] ?? "bg-muted text-muted-foreground"}`}
|
|
||||||
>
|
|
||||||
{mission.ownershipAndStatus.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-base font-semibold">{mission.name}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">{mission.summary}</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<CalendarDaysIcon className="size-3" />
|
|
||||||
{dateStr} at {timeStr}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<ClockIcon className="size-3" />
|
|
||||||
{durationStr}
|
|
||||||
</span>
|
|
||||||
{map && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<MapPinIcon className="size-3" />
|
|
||||||
{map.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{mission.missionRoles?.maxPlayers && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<UsersIcon className="size-3" />
|
|
||||||
{mission.missionRoles.maxPlayers} slots
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{campaign && <span className="text-primary">{campaign.name}</span>}
|
|
||||||
</div>
|
|
||||||
</ItemContent>
|
|
||||||
</Item>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,13 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Campaign, Map } from "@/payload-types";
|
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { ChevronRightIcon, CrosshairIcon } from "lucide-react";
|
import {
|
||||||
|
MissionCard,
|
||||||
interface MissionEntry {
|
type MissionCardProps,
|
||||||
id: number;
|
} from "@/components/frontend/intelligence/MissionCard";
|
||||||
name: string;
|
import { CrosshairIcon } from "lucide-react";
|
||||||
codeName: string;
|
|
||||||
classification: {
|
|
||||||
startDateTime: string;
|
|
||||||
map: number | Map;
|
|
||||||
campaign: number | Campaign;
|
|
||||||
};
|
|
||||||
ownershipAndStatus: {
|
|
||||||
status: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UpcomingMissionsProps {
|
interface UpcomingMissionsProps {
|
||||||
missions: MissionEntry[];
|
readonly missions: MissionCardProps["mission"][];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UpcomingMissions({ missions }: UpcomingMissionsProps) {
|
export function UpcomingMissions({ missions }: UpcomingMissionsProps) {
|
||||||
|
|
@ -47,56 +36,16 @@ export function UpcomingMissions({ missions }: UpcomingMissionsProps) {
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/intelligence/missions"
|
href="/intelligence/missions"
|
||||||
className="text-xs text-muted-foreground hover:text-primary transition-colors"
|
className="text-xs text-muted-foreground transition-colors hover:text-primary"
|
||||||
>
|
>
|
||||||
View all
|
View all
|
||||||
</Link>
|
</Link>
|
||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{missions.map((mission) => {
|
{missions.map((mission) => (
|
||||||
const startDate = new Date(mission.classification.startDateTime);
|
<MissionCard key={mission.id} mission={mission} showFullDetails={false} />
|
||||||
const dateStr = startDate.toLocaleDateString("en-US", {
|
))}
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
const timeStr = startDate.toLocaleTimeString("en-US", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
});
|
|
||||||
const campaign =
|
|
||||||
typeof mission.classification.campaign === "object"
|
|
||||||
? (mission.classification.campaign as Campaign)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={mission.id}
|
|
||||||
href={`/intelligence/missions/${mission.id}`}
|
|
||||||
className="flex items-start gap-3 p-2 rounded-md hover:bg-muted/50 transition-colors group"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center text-center min-w-[2.5rem]">
|
|
||||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
||||||
{startDate.toLocaleDateString("en-US", { weekday: "short" })}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-bold">{dateStr.split(" ")[1]}</span>
|
|
||||||
<span className="text-[10px] text-muted-foreground">{timeStr}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
|
||||||
<span className="font-mono text-[10px] text-muted-foreground">
|
|
||||||
{mission.codeName}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium truncate group-hover:text-primary transition-colors">
|
|
||||||
{mission.name}
|
|
||||||
</span>
|
|
||||||
{campaign && (
|
|
||||||
<span className="text-[10px] text-muted-foreground">{campaign.name}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ChevronRightIcon className="size-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity mt-1 shrink-0" />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import type { FlappyStats } from "@/app/(frontend)/flappy/actions";
|
import type { FlappyStats } from "@/app/(frontend)/flappy/actions";
|
||||||
|
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||||
import { FlappyBird } from "./FlappyBird";
|
import { FlappyBird } from "./FlappyBird";
|
||||||
import { FlappyLeaderboard } from "./FlappyLeaderboard";
|
import { FlappyLeaderboard } from "./FlappyLeaderboard";
|
||||||
|
|
||||||
|
|
@ -14,10 +15,16 @@ export function FlappyArena({
|
||||||
}) {
|
}) {
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const activePilots = useRealtimePresence("flappy", playing, true);
|
||||||
|
const onPhaseChange = useCallback((phase: "ready" | "playing" | "over") => {
|
||||||
|
setPlaying(phase === "playing");
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6">
|
<div className="flex flex-col items-center gap-6">
|
||||||
<FlappyBird initialStats={initialStats} onGameRecorded={() => setRefreshKey((k) => k + 1)} />
|
<FlappyBird initialStats={initialStats} onGameRecorded={() => setRefreshKey((k) => k + 1)} onPhaseChange={onPhaseChange} />
|
||||||
<FlappyLeaderboard refreshKey={refreshKey} currentUserId={currentUserId} />
|
<FlappyLeaderboard refreshKey={refreshKey} currentUserId={currentUserId} activePilots={activePilots} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
import { type FlappyStats, submitFlappyScore } from "@/app/(frontend)/flappy/actions";
|
import { type FlappyStats, submitFlappyScore } from "@/app/(frontend)/flappy/actions";
|
||||||
|
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
||||||
import { playFlap, playHit, playXp } from "./sounds";
|
import { playFlap, playHit, playXp } from "./sounds";
|
||||||
|
|
||||||
const CANVAS_W = 420;
|
const CANVAS_W = 420;
|
||||||
|
|
@ -324,10 +326,13 @@ function birdHitsPipe(birdX: number, birdY: number, pipe: Pipe): boolean {
|
||||||
export function FlappyBird({
|
export function FlappyBird({
|
||||||
initialStats: serverStats,
|
initialStats: serverStats,
|
||||||
onGameRecorded,
|
onGameRecorded,
|
||||||
|
onPhaseChange,
|
||||||
}: {
|
}: {
|
||||||
initialStats: FlappyStats;
|
initialStats: FlappyStats;
|
||||||
onGameRecorded?: () => void;
|
onGameRecorded?: () => void;
|
||||||
|
onPhaseChange?: (phase: Phase) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
const phaseRef = useRef<Phase>("ready");
|
const phaseRef = useRef<Phase>("ready");
|
||||||
|
|
@ -358,7 +363,8 @@ export function FlappyBird({
|
||||||
const setPhaseSafe = useCallback((p: Phase) => {
|
const setPhaseSafe = useCallback((p: Phase) => {
|
||||||
phaseRef.current = p;
|
phaseRef.current = p;
|
||||||
setPhase(p);
|
setPhase(p);
|
||||||
}, []);
|
onPhaseChange?.(p);
|
||||||
|
}, [onPhaseChange]);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
const g = gameRef.current;
|
const g = gameRef.current;
|
||||||
|
|
@ -396,6 +402,12 @@ export function FlappyBird({
|
||||||
onGameRecorded?.();
|
onGameRecorded?.();
|
||||||
if (res.data.xpEarned > 0) {
|
if (res.data.xpEarned > 0) {
|
||||||
playXp();
|
playXp();
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(PROFILE_UPDATE_EVENT, {
|
||||||
|
detail: { reason: "xp", xpGained: res.data.xpEarned },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.setTimeout(() => router.refresh(), 1700);
|
||||||
}
|
}
|
||||||
setLastRun({
|
setLastRun({
|
||||||
score: finalScore,
|
score: finalScore,
|
||||||
|
|
@ -411,7 +423,7 @@ export function FlappyBird({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onGameRecorded],
|
[onGameRecorded, router],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleGameOverRef = useRef(handleGameOver);
|
const handleGameOverRef = useRef(handleGameOver);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Trophy } from "lucide-react";
|
import { Trophy } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
|
||||||
|
|
||||||
interface ApiUser {
|
interface ApiUser {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -35,11 +36,13 @@ interface LeaderboardRow {
|
||||||
bestScore: number;
|
bestScore: number;
|
||||||
xpEarned: number;
|
xpEarned: number;
|
||||||
isCurrentUser: boolean;
|
isCurrentUser: boolean;
|
||||||
|
isOutsideTopTen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FlappyLeaderboardProps {
|
interface FlappyLeaderboardProps {
|
||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
currentUserId?: number | string;
|
currentUserId?: number | string;
|
||||||
|
activePilots?: PresenceParticipant[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function rankClass(index: number) {
|
function rankClass(index: number) {
|
||||||
|
|
@ -49,7 +52,7 @@ function rankClass(index: number) {
|
||||||
return "text-muted-foreground";
|
return "text-muted-foreground";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeaderboardProps) {
|
export function FlappyLeaderboard({ refreshKey = 0, currentUserId, activePilots = [] }: FlappyLeaderboardProps) {
|
||||||
const [rows, setRows] = useState<LeaderboardRow[] | null>(null);
|
const [rows, setRows] = useState<LeaderboardRow[] | null>(null);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
|
@ -58,9 +61,10 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeade
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
"where[progression.minigames.flappyBird.bestScore][greater_than]": "0",
|
|
||||||
sort: "-progression.minigames.flappyBird.bestScore",
|
sort: "-progression.minigames.flappyBird.bestScore",
|
||||||
limit: "10",
|
// Fetch enough profiles to keep the current pilot visible even when
|
||||||
|
// they have not completed a scored run yet.
|
||||||
|
limit: "100",
|
||||||
depth: "1",
|
depth: "1",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -71,8 +75,7 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeade
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setRows(
|
const mapped: LeaderboardRow[] = (data.docs ?? []).map((doc) => {
|
||||||
(data.docs ?? []).map((doc) => {
|
|
||||||
const flappy = doc.progression?.minigames?.flappyBird ?? {};
|
const flappy = doc.progression?.minigames?.flappyBird ?? {};
|
||||||
const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null;
|
const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null;
|
||||||
const name =
|
const name =
|
||||||
|
|
@ -86,8 +89,14 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeade
|
||||||
xpEarned: flappy.xpEarned ?? 0,
|
xpEarned: flappy.xpEarned ?? 0,
|
||||||
isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
|
isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
);
|
const scored = mapped.filter((row) => row.bestScore > 0);
|
||||||
|
const visible = scored.slice(0, 10);
|
||||||
|
const current = mapped.find((row) => row.isCurrentUser);
|
||||||
|
if (current && !visible.some((row) => row.id === current.id)) {
|
||||||
|
visible.push({ ...current, isOutsideTopTen: true });
|
||||||
|
}
|
||||||
|
setRows(visible);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setFailed(true);
|
if (!cancelled) setFailed(true);
|
||||||
|
|
@ -105,6 +114,13 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeade
|
||||||
<h2 className="text-sm font-semibold">Flight Simulator Leaderboard</h2>
|
<h2 className="text-sm font-semibold">Flight Simulator Leaderboard</h2>
|
||||||
<span className="ml-auto text-xs text-muted-foreground">Top 10 · best run (bird-free)</span>
|
<span className="ml-auto text-xs text-muted-foreground">Top 10 · best run (bird-free)</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="border-b border-border px-4 py-2 text-xs text-muted-foreground" aria-live="polite">
|
||||||
|
<span className="font-medium text-emerald-400">{activePilots.length}</span>{" "}
|
||||||
|
{activePilots.length === 1 ? "pilot is" : "pilots are"} flying now
|
||||||
|
{activePilots.length > 0 && (
|
||||||
|
<span className="ml-1 text-foreground">({activePilots.map((pilot) => pilot.name).join(", ")})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{failed ? (
|
{failed ? (
|
||||||
<p className="px-4 py-4 text-sm text-muted-foreground">Could not load the leaderboard.</p>
|
<p className="px-4 py-4 text-sm text-muted-foreground">Could not load the leaderboard.</p>
|
||||||
|
|
@ -132,9 +148,16 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeade
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{rows.map((row, i) => (
|
{rows.map((row, i) => (
|
||||||
<tr key={row.id} className={cn(row.isCurrentUser && "bg-accent/40")}>
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
row.isCurrentUser && "bg-accent/40",
|
||||||
|
row.isOutsideTopTen && "text-muted-foreground opacity-70",
|
||||||
|
)}
|
||||||
|
title={row.isOutsideTopTen ? "Pilot status — not currently ranked in the top 10" : undefined}
|
||||||
|
>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<span className={rankClass(i)}>{i + 1}</span>
|
<span className={rankClass(i)}>{row.isOutsideTopTen ? "—" : i + 1}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2 font-medium">
|
<td className="px-2 py-2 font-medium">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
|
|
@ -142,6 +165,14 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId }: FlappyLeade
|
||||||
{row.isCurrentUser && (
|
{row.isCurrentUser && (
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">(you)</span>
|
<span className="shrink-0 text-xs text-muted-foreground">(you)</span>
|
||||||
)}
|
)}
|
||||||
|
{row.isOutsideTopTen && (
|
||||||
|
<span className="shrink-0 text-[10px] font-normal uppercase tracking-wide text-muted-foreground">
|
||||||
|
unranked
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{activePilots.some((pilot) => pilot.id === row.id) && (
|
||||||
|
<span className="size-2 rounded-full bg-emerald-400" title="Playing now" aria-label="Playing now" />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2 text-right tabular-nums text-muted-foreground">
|
<td className="px-2 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Campaign, Map } from "@/payload-types";
|
import type { Campaign, Map, Media } from "@/payload-types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
operationTypeColors,
|
||||||
|
operationTypeLabels,
|
||||||
|
statusColors,
|
||||||
|
} from "@/lib/intelligence/missionColors";
|
||||||
import {
|
import {
|
||||||
Item,
|
Item,
|
||||||
ItemContent,
|
ItemContent,
|
||||||
|
|
@ -13,62 +19,63 @@ import {
|
||||||
import {
|
import {
|
||||||
CalendarDaysIcon,
|
CalendarDaysIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
ClockIcon,
|
||||||
CrosshairIcon,
|
CrosshairIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
interface MissionCardProps {
|
export interface MissionCardProps {
|
||||||
mission: {
|
readonly mission: {
|
||||||
id: number;
|
readonly id: number;
|
||||||
name: string;
|
readonly name: string;
|
||||||
codeName: string;
|
readonly codeName: string;
|
||||||
summary: string;
|
readonly summary: string;
|
||||||
operationType: "main" | "side" | "external";
|
readonly operationType: "main" | "side" | "external";
|
||||||
classification: {
|
readonly classification: {
|
||||||
missionType: "Zeus" | "PvE" | "PvP" | "PvPvE" | "Training" | "Special";
|
readonly missionType: "Zeus" | "PvE" | "PvP" | "PvPvE" | "Training" | "Special";
|
||||||
startDateTime: string;
|
readonly startDateTime: string;
|
||||||
estimatedDuration: number;
|
readonly estimatedDuration: number;
|
||||||
map: number | Map;
|
readonly map: number | Map;
|
||||||
campaign: number | Campaign;
|
readonly campaign: number | Campaign;
|
||||||
};
|
};
|
||||||
ownershipAndStatus: {
|
readonly ownershipAndStatus: {
|
||||||
status: "Concept" | "Planning" | "Ready" | "Scheduled" | "Active" | "Completed" | "Cancelled";
|
readonly status:
|
||||||
|
| "Concept"
|
||||||
|
| "Planning"
|
||||||
|
| "Ready"
|
||||||
|
| "Scheduled"
|
||||||
|
| "Active"
|
||||||
|
| "Completed"
|
||||||
|
| "Cancelled";
|
||||||
};
|
};
|
||||||
missionRoles?: {
|
readonly missionRoles?: {
|
||||||
maxPlayers: number;
|
readonly maxPlayers: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
readonly coverImage?: number | Media | null;
|
||||||
};
|
};
|
||||||
|
readonly variant?: "default" | "image";
|
||||||
|
readonly showFullDetails?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
export function MissionCard({
|
||||||
Concept: "bg-muted text-muted-foreground",
|
mission,
|
||||||
Planning: "bg-muted text-muted-foreground",
|
variant,
|
||||||
Ready: "bg-blue-500/20 text-blue-400",
|
showFullDetails = false,
|
||||||
Scheduled: "bg-amber-500/20 text-amber-400",
|
}: MissionCardProps) {
|
||||||
Active: "bg-green-500/20 text-green-400",
|
const resolvedVariant = variant ?? (mission.coverImage ? "image" : "default");
|
||||||
Completed: "bg-emerald-500/20 text-emerald-400",
|
const coverImage =
|
||||||
Cancelled: "bg-red-500/20 text-red-400",
|
typeof mission.coverImage === "object" && mission.coverImage !== null
|
||||||
};
|
? mission.coverImage
|
||||||
|
: null;
|
||||||
const operationTypeColors: Record<string, string> = {
|
const coverImageUrl = coverImage?.url ?? null;
|
||||||
main: "bg-blue-500/20 text-blue-400",
|
|
||||||
side: "bg-muted text-muted-foreground",
|
|
||||||
external: "bg-amber-500/20 text-amber-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
const operationTypeLabels: Record<string, string> = {
|
|
||||||
main: "Main",
|
|
||||||
side: "Side",
|
|
||||||
external: "External",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function MissionCard({ mission }: MissionCardProps) {
|
|
||||||
const map =
|
const map =
|
||||||
typeof mission.classification.map === "object" ? (mission.classification.map as Map) : null;
|
typeof mission.classification.map === "object" && mission.classification.map !== null
|
||||||
|
? mission.classification.map
|
||||||
|
: null;
|
||||||
const campaign =
|
const campaign =
|
||||||
typeof mission.classification.campaign === "object"
|
typeof mission.classification.campaign === "object" && mission.classification.campaign !== null
|
||||||
? (mission.classification.campaign as Campaign)
|
? mission.classification.campaign
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const startDate = new Date(mission.classification.startDateTime);
|
const startDate = new Date(mission.classification.startDateTime);
|
||||||
|
|
@ -81,14 +88,36 @@ export function MissionCard({ mission }: MissionCardProps) {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
|
const durationHours = Math.floor(mission.classification.estimatedDuration / 60);
|
||||||
|
const durationMinutes = mission.classification.estimatedDuration % 60;
|
||||||
|
const durationLabel =
|
||||||
|
durationMinutes > 0 ? `${durationHours}h ${durationMinutes}m` : `${durationHours}h`;
|
||||||
|
const imageCard = resolvedVariant === "image" && coverImageUrl !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/intelligence/missions/${mission.id}`}>
|
<Link
|
||||||
|
href={`/intelligence/missions/${mission.id}`}
|
||||||
|
className="block rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||||
|
>
|
||||||
<Item
|
<Item
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="cursor-pointer group transition-all duration-200 hover:scale-[1.01] hover:[box-shadow:inset_0_2px_8px_rgba(255,255,255,0.33)]"
|
className={cn(
|
||||||
|
"group relative cursor-pointer overflow-hidden transition-all duration-200 hover:scale-[1.01] hover:[box-shadow:inset_0_2px_8px_rgba(255,255,255,0.33)]",
|
||||||
|
imageCard &&
|
||||||
|
"border border-transparent hover:border-white/75",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<ItemHeader>
|
{resolvedVariant === "image" && coverImageUrl !== null && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 overflow-hidden opacity-25 after:absolute after:inset-0 after:z-10 after:bg-linear-0 after:bg-linear-180 after:from-transparent after:to-black/42 after:transition-opacity group-hover:after:opacity-0">
|
||||||
|
<img
|
||||||
|
src={coverImageUrl}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-full w-full aspect-video object-cover object-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ItemHeader className="relative z-20">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ItemMedia variant="icon">
|
<ItemMedia variant="icon">
|
||||||
<CrosshairIcon />
|
<CrosshairIcon />
|
||||||
|
|
@ -111,32 +140,37 @@ export function MissionCard({ mission }: MissionCardProps) {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
<ItemContent>
|
<ItemContent className="relative z-20 w-full">
|
||||||
<div className="flex flex-col gap-3 w-full">
|
<ItemDescription className={showFullDetails ? "line-clamp-2" : "line-clamp-1"}>
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">{mission.summary}</p>
|
{mission.summary}
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
|
</ItemDescription>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CalendarDaysIcon className="size-3" />
|
||||||
|
{dateStr} at {timeStr}
|
||||||
|
</span>
|
||||||
|
{showFullDetails && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<CalendarDaysIcon className="size-3" />
|
<ClockIcon className="size-3" />
|
||||||
{dateStr} at {timeStr}
|
{durationLabel}
|
||||||
</span>
|
</span>
|
||||||
{map && (
|
)}
|
||||||
<span className="flex items-center gap-1">
|
{map && (
|
||||||
<MapPinIcon className="size-3" />
|
<span className="flex items-center gap-1">
|
||||||
{map.name}
|
<MapPinIcon className="size-3" />
|
||||||
</span>
|
{map.name}
|
||||||
)}
|
</span>
|
||||||
{mission.missionRoles?.maxPlayers && (
|
)}
|
||||||
<span className="flex items-center gap-1">
|
{mission.missionRoles?.maxPlayers && (
|
||||||
<UsersIcon className="size-3" />
|
<span className="flex items-center gap-1">
|
||||||
{mission.missionRoles.maxPlayers} slots
|
<UsersIcon className="size-3" />
|
||||||
</span>
|
{mission.missionRoles.maxPlayers} slots
|
||||||
)}
|
</span>
|
||||||
{campaign && <span className="text-primary">{campaign.name}</span>}
|
)}
|
||||||
</div>
|
{campaign && <span className="text-primary">{campaign.name}</span>}
|
||||||
</div>
|
</div>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
<ItemDescription>{mission.summary}</ItemDescription>
|
<span className="relative z-20 flex items-center gap-1 text-xs text-muted-foreground transition-colors group-hover:text-primary">
|
||||||
<span className="text-xs text-muted-foreground group-hover:text-primary transition-colors flex items-center gap-1">
|
|
||||||
View details <ChevronRightIcon className="size-3" />
|
View details <ChevronRightIcon className="size-3" />
|
||||||
</span>
|
</span>
|
||||||
</Item>
|
</Item>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { notificationLabel } from "@/lib/notifications";
|
import { notificationLabel } from "@/lib/notifications";
|
||||||
|
import { NOTIFICATION_EVENT } from "@/hooks/useGameTick";
|
||||||
import {
|
import {
|
||||||
markAllNotificationsRead,
|
markAllNotificationsRead,
|
||||||
markNotificationsRead,
|
markNotificationsRead,
|
||||||
|
|
@ -41,7 +42,7 @@ function timeAgo(iso: string): string {
|
||||||
return `${days}d ago`;
|
return `${days}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const POLL_MS = 30_000;
|
const POLL_MS = 60_000;
|
||||||
|
|
||||||
export function NotificationsBell() {
|
export function NotificationsBell() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -74,8 +75,11 @@ export function NotificationsBell() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchNotifications();
|
void fetchNotifications();
|
||||||
intervalRef.current = setInterval(() => void fetchNotifications(), POLL_MS);
|
intervalRef.current = setInterval(() => void fetchNotifications(), POLL_MS);
|
||||||
|
const onNotification = () => void fetchNotifications();
|
||||||
|
window.addEventListener(NOTIFICATION_EVENT, onNotification);
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||||
|
window.removeEventListener(NOTIFICATION_EVENT, onNotification);
|
||||||
};
|
};
|
||||||
}, [fetchNotifications]);
|
}, [fetchNotifications]);
|
||||||
|
|
||||||
|
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
280
src/components/frontend/projects/ProjectSearchView.tsx
Normal file
280
src/components/frontend/projects/ProjectSearchView.tsx
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
"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";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
export interface SearchTicket {
|
||||||
|
readonly id: number;
|
||||||
|
readonly title: string;
|
||||||
|
readonly status: string;
|
||||||
|
readonly priority: string;
|
||||||
|
readonly type: string;
|
||||||
|
readonly category?: 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;
|
||||||
|
readonly href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[]>([]);
|
||||||
|
const [status, setStatus] = useState("all");
|
||||||
|
const [type, setType] = useState("all");
|
||||||
|
|
||||||
|
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.id} ${ticket.title} ${ticket.status} ${ticket.priority} ${ticket.type} ${ticket.category ?? ""} ${ticket.project.name} ${ticket.project.key} ${ticket.labels.map((label) => label.name).join(" ")}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query);
|
||||||
|
|
||||||
|
const matchesLabels =
|
||||||
|
selectedLabelIds.length === 0
|
||||||
|
|| ticket.labels.some((label) => selectedLabelIds.includes(label.id));
|
||||||
|
const matchesStatus = status === "all" || ticket.status === status;
|
||||||
|
const matchesType = type === "all" || ticket.type === type;
|
||||||
|
|
||||||
|
return matchesSearch && matchesLabels && matchesStatus && matchesType;
|
||||||
|
});
|
||||||
|
}, [tickets, query, selectedLabelIds, status, type]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Select value={status} onValueChange={setStatus}>
|
||||||
|
<SelectTrigger className="w-[160px]" aria-label="Filter by status">
|
||||||
|
<SelectValue placeholder="All statuses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
|
<SelectItem value="open">Open</SelectItem>
|
||||||
|
<SelectItem value="planning">Planning</SelectItem>
|
||||||
|
<SelectItem value="in_progress">In progress</SelectItem>
|
||||||
|
<SelectItem value="in_review">In review</SelectItem>
|
||||||
|
<SelectItem value="waiting_on_reporter">Waiting on reporter</SelectItem>
|
||||||
|
<SelectItem value="in_testing">Testing</SelectItem>
|
||||||
|
<SelectItem value="resolved">Resolved</SelectItem>
|
||||||
|
<SelectItem value="canceled">Canceled</SelectItem>
|
||||||
|
<SelectItem value="closed">Closed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={type} onValueChange={setType}>
|
||||||
|
<SelectTrigger className="w-[150px]" aria-label="Filter by issue type">
|
||||||
|
<SelectValue placeholder="All types" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All types</SelectItem>
|
||||||
|
<SelectItem value="task">Task</SelectItem>
|
||||||
|
<SelectItem value="bug">Bug</SelectItem>
|
||||||
|
<SelectItem value="story">Story</SelectItem>
|
||||||
|
<SelectItem value="epic">Epic</SelectItem>
|
||||||
|
<SelectItem value="improvement">Improvement</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</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) => (
|
||||||
|
<Link href={ticket.href} key={ticket.id} className="block rounded-md">
|
||||||
|
<Item variant="outline" size="sm" role="listitem" className="transition-colors hover:bg-accent/50">
|
||||||
|
<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>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,14 @@
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { GAME_TICK_EVENT, type GameTickDetail } from "@/hooks/useGameTick";
|
import {
|
||||||
|
GAME_EVENT_EVENT,
|
||||||
|
GAME_TICK_EVENT,
|
||||||
|
NOTIFICATION_EVENT,
|
||||||
|
PROFILE_UPDATE_EVENT,
|
||||||
|
type GameTickDetail,
|
||||||
|
type ProfileUpdateDetail,
|
||||||
|
} from "@/hooks/useGameTick";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens a Server-Sent Events connection to the game tick stream.
|
* Opens a Server-Sent Events connection to the game tick stream.
|
||||||
|
|
@ -15,6 +22,14 @@ export function GameTickRealtime() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const source = new EventSource("/api/realtime");
|
const source = new EventSource("/api/realtime");
|
||||||
|
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const refresh = (delay = 250) => {
|
||||||
|
if (refreshTimer) return;
|
||||||
|
refreshTimer = setTimeout(() => {
|
||||||
|
refreshTimer = null;
|
||||||
|
router.refresh();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
source.addEventListener("game-tick", (event) => {
|
source.addEventListener("game-tick", (event) => {
|
||||||
let detail: GameTickDetail = {};
|
let detail: GameTickDetail = {};
|
||||||
try {
|
try {
|
||||||
|
|
@ -22,12 +37,32 @@ export function GameTickRealtime() {
|
||||||
} catch {
|
} catch {
|
||||||
// ignore malformed payload
|
// ignore malformed payload
|
||||||
}
|
}
|
||||||
router.refresh();
|
refresh();
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent(GAME_TICK_EVENT, { detail }),
|
new CustomEvent(GAME_TICK_EVENT, { detail }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return () => source.close();
|
source.addEventListener("profile-update", (event) => {
|
||||||
|
let detail: ProfileUpdateDetail = {};
|
||||||
|
try {
|
||||||
|
detail = JSON.parse(event.data ?? "{}");
|
||||||
|
} catch {
|
||||||
|
// ignore malformed payload
|
||||||
|
}
|
||||||
|
refresh(detail.xpGained ? 1700 : 250);
|
||||||
|
window.dispatchEvent(new CustomEvent(PROFILE_UPDATE_EVENT, { detail }));
|
||||||
|
});
|
||||||
|
source.addEventListener("game-event", () => {
|
||||||
|
refresh();
|
||||||
|
window.dispatchEvent(new CustomEvent(GAME_EVENT_EVENT));
|
||||||
|
});
|
||||||
|
source.addEventListener("notification", (event) => {
|
||||||
|
window.dispatchEvent(new CustomEvent(NOTIFICATION_EVENT, { detail: event.data }));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
if (refreshTimer) clearTimeout(refreshTimer);
|
||||||
|
source.close();
|
||||||
|
};
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,19 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
export const GAME_TICK_EVENT = "ptf:game-tick";
|
export const GAME_TICK_EVENT = "ptf:game-tick";
|
||||||
|
export const PROFILE_UPDATE_EVENT = "ptf:profile-update";
|
||||||
|
export const GAME_EVENT_EVENT = "ptf:game-event";
|
||||||
|
export const NOTIFICATION_EVENT = "ptf:notification";
|
||||||
|
|
||||||
export interface GameTickDetail {
|
export interface GameTickDetail {
|
||||||
processedAt?: string;
|
processedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProfileUpdateDetail {
|
||||||
|
reason?: string;
|
||||||
|
xpGained?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function useGameTick(onTick?: () => void): number {
|
export function useGameTick(onTick?: () => void): number {
|
||||||
const [count, setCount] = useState(0);
|
const [count, setCount] = useState(0);
|
||||||
const callbackRef = useRef(onTick);
|
const callbackRef = useRef(onTick);
|
||||||
|
|
|
||||||
69
src/hooks/useRealtimePresence.ts
Normal file
69
src/hooks/useRealtimePresence.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface PresenceParticipant {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
lastSeen: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresenceEvent {
|
||||||
|
channel?: string;
|
||||||
|
action?: "snapshot" | "join" | "update" | "leave";
|
||||||
|
participants?: PresenceParticipant[];
|
||||||
|
participant?: PresenceParticipant;
|
||||||
|
participantId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEARTBEAT_MS = 15_000;
|
||||||
|
|
||||||
|
export function useRealtimePresence(channel: string, active: boolean, observe = true): PresenceParticipant[] {
|
||||||
|
const [participants, setParticipants] = useState<PresenceParticipant[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!observe) {
|
||||||
|
setParticipants([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const endpoint = `/api/realtime/presence?channel=${encodeURIComponent(channel)}`;
|
||||||
|
const send = (method: "POST" | "DELETE") => {
|
||||||
|
void fetch(endpoint, { method, keepalive: method === "DELETE" }).catch(() => undefined);
|
||||||
|
};
|
||||||
|
const source = new EventSource(`/api/realtime?channel=${encodeURIComponent(channel)}`);
|
||||||
|
const onPresence = (event: MessageEvent<string>) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
let detail: PresenceEvent;
|
||||||
|
try {
|
||||||
|
detail = JSON.parse(event.data) as PresenceEvent;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (detail.action === "snapshot") setParticipants(detail.participants ?? []);
|
||||||
|
if ((detail.action === "join" || detail.action === "update") && detail.participant) {
|
||||||
|
setParticipants((current) => [
|
||||||
|
...current.filter((item) => item.id !== detail.participant!.id),
|
||||||
|
detail.participant!,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (detail.action === "leave" && detail.participantId !== undefined) {
|
||||||
|
setParticipants((current) => current.filter((item) => item.id !== detail.participantId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
source.addEventListener("presence", onPresence);
|
||||||
|
if (active) send("POST");
|
||||||
|
const heartbeat = active ? window.setInterval(() => send("POST"), HEARTBEAT_MS) : undefined;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (heartbeat) window.clearInterval(heartbeat);
|
||||||
|
source.removeEventListener("presence", onPresence);
|
||||||
|
source.close();
|
||||||
|
if (active) send("DELETE");
|
||||||
|
};
|
||||||
|
}, [channel, active, observe]);
|
||||||
|
|
||||||
|
return participants;
|
||||||
|
}
|
||||||
21
src/lib/intelligence/missionColors.ts
Normal file
21
src/lib/intelligence/missionColors.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
export const statusColors: Record<string, string> = {
|
||||||
|
Concept: "bg-muted text-muted-foreground",
|
||||||
|
Planning: "bg-muted text-muted-foreground",
|
||||||
|
Ready: "bg-blue-500/20 text-blue-400",
|
||||||
|
Scheduled: "bg-amber-500/20 text-amber-400",
|
||||||
|
Active: "bg-green-500/20 text-green-400",
|
||||||
|
Completed: "bg-emerald-500/20 text-emerald-400",
|
||||||
|
Cancelled: "bg-red-500/20 text-red-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const operationTypeColors: Record<string, string> = {
|
||||||
|
main: "bg-blue-500/20 text-blue-400",
|
||||||
|
side: "bg-muted text-muted-foreground",
|
||||||
|
external: "bg-amber-500/20 text-amber-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const operationTypeLabels: Record<string, string> = {
|
||||||
|
main: "Main",
|
||||||
|
side: "Side",
|
||||||
|
external: "External",
|
||||||
|
};
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { broadcastToUser } from "@/lib/realtime/bus";
|
||||||
|
|
||||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||||
|
|
||||||
export interface NotifyInput {
|
export interface NotifyInput {
|
||||||
|
|
@ -40,6 +42,7 @@ export async function notifyUser(payload: PayloadType, input: NotifyInput): Prom
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
});
|
});
|
||||||
|
broadcastToUser("notification", input.userId, { type: input.type ?? null });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Notifications] Failed to create notification:", err);
|
console.error("[Notifications] Failed to create notification:", err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
35
src/lib/projects/projectMeta.ts
Normal file
35
src/lib/projects/projectMeta.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Safely extracts readable text from Payload's Lexical rich-text JSON. */
|
||||||
|
export function richTextToPlaintext(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (!value || typeof value !== "object") return "";
|
||||||
|
|
||||||
|
const node = value as { text?: unknown; children?: unknown };
|
||||||
|
if (typeof node.text === "string") return node.text;
|
||||||
|
if (!Array.isArray(node.children)) return "";
|
||||||
|
return node.children
|
||||||
|
.map((child) => richTextToPlaintext(child))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
export interface SSEEventClient {
|
export interface SSEEventClient {
|
||||||
send: (data: string) => void;
|
send: (data: string) => void;
|
||||||
|
events?: Set<string>;
|
||||||
|
userId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clients = new Set<SSEEventClient>();
|
const clients = new Set<SSEEventClient>();
|
||||||
|
|
@ -20,6 +22,22 @@ export function clientCount(): number {
|
||||||
export function broadcast(event: string, data: unknown): number {
|
export function broadcast(event: string, data: unknown): number {
|
||||||
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
|
if (client.events && !client.events.has(event)) continue;
|
||||||
|
try {
|
||||||
|
client.send(payload);
|
||||||
|
} catch {
|
||||||
|
clients.delete(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clients.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastToUser(event: string, userId: number, data: unknown): number {
|
||||||
|
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
|
const targetUserId = Number(userId);
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.userId !== targetUserId) continue;
|
||||||
|
if (client.events && !client.events.has(event)) continue;
|
||||||
try {
|
try {
|
||||||
client.send(payload);
|
client.send(payload);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
56
src/lib/realtime/presence.ts
Normal file
56
src/lib/realtime/presence.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { broadcast } from "./bus";
|
||||||
|
|
||||||
|
export interface PresenceParticipant {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
lastSeen: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresenceRecord extends PresenceParticipant {
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRESENCE_TTL_MS = 45_000;
|
||||||
|
const channels = new Map<string, Map<number, PresenceRecord>>();
|
||||||
|
|
||||||
|
function prune(channel: string, now = Date.now()): void {
|
||||||
|
const entries = channels.get(channel);
|
||||||
|
if (!entries) return;
|
||||||
|
for (const [id, entry] of entries) {
|
||||||
|
if (entry.expiresAt <= now) {
|
||||||
|
entries.delete(id);
|
||||||
|
broadcast("presence", { channel, action: "leave", participantId: id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entries.size === 0) channels.delete(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPresence(channel: string): PresenceParticipant[] {
|
||||||
|
prune(channel);
|
||||||
|
return [...(channels.get(channel)?.values() ?? [])].map(({ id, name, lastSeen }) => ({ id, name, lastSeen }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function touchPresence(channel: string, participant: Omit<PresenceParticipant, "lastSeen">): void {
|
||||||
|
const now = Date.now();
|
||||||
|
prune(channel, now);
|
||||||
|
let entries = channels.get(channel);
|
||||||
|
if (!entries) {
|
||||||
|
entries = new Map();
|
||||||
|
channels.set(channel, entries);
|
||||||
|
}
|
||||||
|
const wasPresent = entries.has(participant.id);
|
||||||
|
const record = { ...participant, lastSeen: new Date(now).toISOString(), expiresAt: now + PRESENCE_TTL_MS };
|
||||||
|
entries.set(participant.id, record);
|
||||||
|
broadcast("presence", {
|
||||||
|
channel,
|
||||||
|
action: wasPresent ? "update" : "join",
|
||||||
|
participant: { id: record.id, name: record.name, lastSeen: record.lastSeen },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePresence(channel: string, id: number): void {
|
||||||
|
const entries = channels.get(channel);
|
||||||
|
if (!entries?.delete(id)) return;
|
||||||
|
if (entries.size === 0) channels.delete(channel);
|
||||||
|
broadcast("presence", { channel, action: "leave", participantId: id });
|
||||||
|
}
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
|
||||||
|
|
||||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
CREATE TYPE "public"."enum_plugin_ai_instructions_field_type" AS ENUM('text', 'textarea', 'upload', 'richText');
|
|
||||||
CREATE TYPE "public"."enum_plugin_ai_instructions_model_id" AS ENUM('unsloth-text');
|
|
||||||
CREATE TABLE "plugin_ai_instructions_images" (
|
|
||||||
"_order" integer NOT NULL,
|
|
||||||
"_parent_id" integer NOT NULL,
|
|
||||||
"id" varchar PRIMARY KEY NOT NULL,
|
|
||||||
"image_id" integer
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "plugin_ai_instructions" (
|
|
||||||
"id" serial PRIMARY KEY NOT NULL,
|
|
||||||
"schema_path" varchar,
|
|
||||||
"field_type" "enum_plugin_ai_instructions_field_type" DEFAULT 'text',
|
|
||||||
"relation_to" varchar,
|
|
||||||
"model_id" "enum_plugin_ai_instructions_model_id",
|
|
||||||
"disabled" boolean DEFAULT false,
|
|
||||||
"prompt" varchar,
|
|
||||||
"system" varchar DEFAULT 'INSTRUCTIONS:
|
|
||||||
You are a highly skilled and professional blog writer,
|
|
||||||
renowned for crafting engaging and well-organized articles.
|
|
||||||
When given a title, you meticulously create blogs that are not only
|
|
||||||
informative and accurate but also captivating and beautifully structured.',
|
|
||||||
"layout" varchar DEFAULT '[paragraph] - Write a concise introduction (2-3 sentences) that outlines the main topic.
|
|
||||||
[horizontalrule] - Insert a horizontal rule to separate the introduction from the main content.
|
|
||||||
[list] - Create a list with 3-5 items. Each list item should contain:
|
|
||||||
a. [heading] - A brief, descriptive heading (up to 5 words)
|
|
||||||
b. [paragraph] - A short explanation or elaboration (1-2 sentences)
|
|
||||||
[horizontalrule] - Insert another horizontal rule to separate the main content from the conclusion.
|
|
||||||
[paragraph] - Compose a brief conclusion (2-3 sentences) summarizing the key points.
|
|
||||||
[quote] - Include a relevant quote from a famous person, directly related to the topic. Format: "Quote text." - Author Name',
|
|
||||||
"unsloth_text_settings_model" varchar DEFAULT 'unsloth/gemma-4-E4B-it-qat-GGUF',
|
|
||||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
|
||||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "plugin_ai_instructions_id" integer;
|
|
||||||
ALTER TABLE "plugin_ai_instructions_images" ADD CONSTRAINT "plugin_ai_instructions_images_image_id_media_id_fk" FOREIGN KEY ("image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
|
||||||
ALTER TABLE "plugin_ai_instructions_images" ADD CONSTRAINT "plugin_ai_instructions_images_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."plugin_ai_instructions"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
CREATE INDEX "plugin_ai_instructions_images_order_idx" ON "plugin_ai_instructions_images" USING btree ("_order");
|
|
||||||
CREATE INDEX "plugin_ai_instructions_images_parent_id_idx" ON "plugin_ai_instructions_images" USING btree ("_parent_id");
|
|
||||||
CREATE INDEX "plugin_ai_instructions_images_image_idx" ON "plugin_ai_instructions_images" USING btree ("image_id");
|
|
||||||
CREATE UNIQUE INDEX "plugin_ai_instructions_schema_path_idx" ON "plugin_ai_instructions" USING btree ("schema_path");
|
|
||||||
CREATE INDEX "plugin_ai_instructions_updated_at_idx" ON "plugin_ai_instructions" USING btree ("updated_at");
|
|
||||||
CREATE INDEX "plugin_ai_instructions_created_at_idx" ON "plugin_ai_instructions" USING btree ("created_at");
|
|
||||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_plugin_ai_instructions_fk" FOREIGN KEY ("plugin_ai_instructions_id") REFERENCES "public"."plugin_ai_instructions"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
CREATE INDEX "payload_locked_documents_rels_plugin_ai_instructions_id_idx" ON "payload_locked_documents_rels" USING btree ("plugin_ai_instructions_id");`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
ALTER TABLE "plugin_ai_instructions_images" DISABLE ROW LEVEL SECURITY;
|
|
||||||
ALTER TABLE "plugin_ai_instructions" DISABLE ROW LEVEL SECURITY;
|
|
||||||
DROP TABLE "plugin_ai_instructions_images" CASCADE;
|
|
||||||
DROP TABLE "plugin_ai_instructions" CASCADE;
|
|
||||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_plugin_ai_instructions_fk";
|
|
||||||
|
|
||||||
DROP INDEX "payload_locked_documents_rels_plugin_ai_instructions_id_idx";
|
|
||||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "plugin_ai_instructions_id";
|
|
||||||
DROP TYPE "public"."enum_plugin_ai_instructions_field_type";
|
|
||||||
DROP TYPE "public"."enum_plugin_ai_instructions_model_id";`)
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +1,276 @@
|
||||||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
// Idempotent migration: safe to run on databases where push: true already applied
|
||||||
|
// the schema (dev), and on fresh production databases that need these objects.
|
||||||
|
// Each DDL block is wrapped in exception handlers to skip already-existing objects.
|
||||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
// --- Enums ---
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
CREATE TYPE "public"."enum_users_preferences_display_lead_or_actual" AS ENUM('lead', 'actual');
|
DO $$ BEGIN
|
||||||
CREATE TYPE "public"."enum_roles_permissions" AS ENUM('system:admin-access', 'users:create', 'users:read', 'users:update', 'users:delete', 'users:unlock', 'users:assign-roles', 'ranks:create', 'ranks:read', 'ranks:update', 'ranks:delete', 'profiles:create', 'profiles:read', 'profiles:update', 'profiles:delete', 'awards:create', 'awards:read', 'awards:update', 'awards:delete', 'qualifications:create', 'qualifications:read', 'qualifications:update', 'qualifications:delete', 'assignments:create', 'assignments:read', 'assignments:update', 'assignments:delete', 'experience:create', 'experience:read', 'experience:update', 'experience:delete', 'user-notifications:create', 'user-notifications:read', 'user-notifications:update', 'user-notifications:delete', 'missions:create', 'missions:read', 'missions:update', 'missions:delete', 'missions:read-sensitive', 'mission-attendances:create', 'mission-attendances:read', 'mission-attendances:update', 'mission-attendances:delete', 'campaigns:create', 'campaigns:read', 'campaigns:update', 'campaigns:delete', 'factions:create', 'factions:read', 'factions:update', 'factions:delete', 'technologies:create', 'technologies:read', 'technologies:update', 'technologies:delete', 'assets:create', 'assets:read', 'assets:update', 'assets:delete', 'resources:create', 'resources:read', 'resources:update', 'resources:delete', 'vehicles:create', 'vehicles:read', 'vehicles:update', 'vehicles:delete', 'structures:create', 'structures:read', 'structures:update', 'structures:delete', 'shipments:create', 'shipments:read', 'shipments:update', 'shipments:delete', 'bank-accounts:create', 'bank-accounts:read', 'bank-accounts:update', 'bank-accounts:delete', 'bank-transactions:create', 'bank-transactions:read', 'bank-transactions:update', 'bank-transactions:delete', 'ledger-entries:create', 'ledger-entries:read', 'ledger-entries:update', 'ledger-entries:delete', 'locker-storages:create', 'locker-storages:read', 'locker-storages:update', 'locker-storages:delete', 'loadouts:create', 'loadouts:read', 'loadouts:update', 'loadouts:delete', 'market-listings:create', 'market-listings:read', 'market-listings:update', 'market-listings:delete', 'market-negotiations:create', 'market-negotiations:read', 'market-negotiations:update', 'market-negotiations:delete', 'tickets:create', 'tickets:read', 'tickets:update', 'tickets:delete', 'game-structures:create', 'game-structures:read', 'game-structures:update', 'game-structures:delete', 'game-vehicles:create', 'game-vehicles:read', 'game-vehicles:update', 'game-vehicles:delete', 'game-npcs:create', 'game-npcs:read', 'game-npcs:update', 'game-npcs:delete', 'game-hard-resources:create', 'game-hard-resources:read', 'game-hard-resources:update', 'game-hard-resources:delete', 'game-event-logs:create', 'game-event-logs:read', 'game-event-logs:update', 'game-event-logs:delete', 'maps:create', 'maps:read', 'maps:update', 'maps:delete', 'narrative-events:create', 'narrative-events:read', 'narrative-events:update', 'narrative-events:delete', 'mission-files:create', 'mission-files:read', 'mission-files:update', 'mission-files:delete', 'mod-lists:create', 'mod-lists:read', 'mod-lists:update', 'mod-lists:delete', 'game-rules:read', 'game-rules:update', 'shims:read', 'shims:update', 'logistics:manage', 'intelligence:manage', 'profiles:intel_excerpt:update', 'banking:manage', 'tickets:staff', 'discord:staff', 'discord:announce');
|
CREATE TYPE "public"."enum_users_preferences_display_lead_or_actual" AS ENUM('lead', 'actual');
|
||||||
CREATE TABLE "users_rels" (
|
EXCEPTION WHEN duplicate_object THEN null;
|
||||||
"id" serial PRIMARY KEY NOT NULL,
|
END $$;
|
||||||
"order" integer,
|
`)
|
||||||
"parent_id" integer NOT NULL,
|
|
||||||
"path" varchar NOT NULL,
|
await db.execute(sql`
|
||||||
"roles_id" integer
|
DO $$ BEGIN
|
||||||
);
|
CREATE TYPE "public"."enum_roles_permissions" AS ENUM('system:admin-access', 'users:create', 'users:read', 'users:update', 'users:delete', 'users:unlock', 'users:assign-roles', 'ranks:create', 'ranks:read', 'ranks:update', 'ranks:delete', 'profiles:create', 'profiles:read', 'profiles:update', 'profiles:delete', 'awards:create', 'awards:read', 'awards:update', 'awards:delete', 'qualifications:create', 'qualifications:read', 'qualifications:update', 'qualifications:delete', 'assignments:create', 'assignments:read', 'assignments:update', 'assignments:delete', 'experience:create', 'experience:read', 'experience:update', 'experience:delete', 'user-notifications:create', 'user-notifications:read', 'user-notifications:update', 'user-notifications:delete', 'missions:create', 'missions:read', 'missions:update', 'missions:delete', 'missions:read-sensitive', 'mission-attendances:create', 'mission-attendances:read', 'mission-attendances:update', 'mission-attendances:delete', 'campaigns:create', 'campaigns:read', 'campaigns:update', 'campaigns:delete', 'factions:create', 'factions:read', 'factions:update', 'factions:delete', 'technologies:create', 'technologies:read', 'technologies:update', 'technologies:delete', 'assets:create', 'assets:read', 'assets:update', 'assets:delete', 'resources:create', 'resources:read', 'resources:update', 'resources:delete', 'vehicles:create', 'vehicles:read', 'vehicles:update', 'vehicles:delete', 'structures:create', 'structures:read', 'structures:update', 'structures:delete', 'shipments:create', 'shipments:read', 'shipments:update', 'shipments:delete', 'bank-accounts:create', 'bank-accounts:read', 'bank-accounts:update', 'bank-accounts:delete', 'bank-transactions:create', 'bank-transactions:read', 'bank-transactions:update', 'bank-transactions:delete', 'ledger-entries:create', 'ledger-entries:read', 'ledger-entries:update', 'ledger-entries:delete', 'locker-storages:create', 'locker-storages:read', 'locker-storages:update', 'locker-storages:delete', 'loadouts:create', 'loadouts:read', 'loadouts:update', 'loadouts:delete', 'market-listings:create', 'market-listings:read', 'market-listings:update', 'market-listings:delete', 'market-negotiations:create', 'market-negotiations:read', 'market-negotiations:update', 'market-negotiations:delete', 'tickets:create', 'tickets:read', 'tickets:update', 'tickets:delete', 'game-structures:create', 'game-structures:read', 'game-structures:update', 'game-structures:delete', 'game-vehicles:create', 'game-vehicles:read', 'game-vehicles:update', 'game-vehicles:delete', 'game-npcs:create', 'game-npcs:read', 'game-npcs:update', 'game-npcs:delete', 'game-hard-resources:create', 'game-hard-resources:read', 'game-hard-resources:update', 'game-hard-resources:delete', 'game-event-logs:create', 'game-event-logs:read', 'game-event-logs:update', 'game-event-logs:delete', 'maps:create', 'maps:read', 'maps:update', 'maps:delete', 'narrative-events:create', 'narrative-events:read', 'narrative-events:update', 'narrative-events:delete', 'mission-files:create', 'mission-files:read', 'mission-files:update', 'mission-files:delete', 'mod-lists:create', 'mod-lists:read', 'mod-lists:update', 'mod-lists:delete', 'game-rules:read', 'game-rules:update', 'shims:read', 'shims:update', 'logistics:manage', 'intelligence:manage', 'profiles:intel_excerpt:update', 'banking:manage', 'tickets:staff', 'discord:staff', 'discord:announce');
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null;
|
||||||
CREATE TABLE "roles_permissions" (
|
END $$;
|
||||||
"order" integer NOT NULL,
|
`)
|
||||||
"parent_id" integer NOT NULL,
|
|
||||||
"value" "enum_roles_permissions",
|
// --- Tables ---
|
||||||
"id" serial PRIMARY KEY NOT NULL
|
await db.execute(sql`
|
||||||
);
|
DO $$ BEGIN
|
||||||
|
CREATE TABLE "users_rels" (
|
||||||
CREATE TABLE "roles" (
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
"id" serial PRIMARY KEY NOT NULL,
|
"order" integer,
|
||||||
"name" varchar NOT NULL,
|
"parent_id" integer NOT NULL,
|
||||||
"slug" varchar NOT NULL,
|
"path" varchar NOT NULL,
|
||||||
"description" varchar,
|
"roles_id" integer
|
||||||
"is_system" boolean DEFAULT false,
|
);
|
||||||
"is_superuser" boolean DEFAULT false,
|
EXCEPTION WHEN duplicate_table THEN null;
|
||||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
END $$;
|
||||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
`)
|
||||||
);
|
|
||||||
|
await db.execute(sql`
|
||||||
CREATE TABLE "roles_rels" (
|
DO $$ BEGIN
|
||||||
"id" serial PRIMARY KEY NOT NULL,
|
CREATE TABLE "roles_permissions" (
|
||||||
"order" integer,
|
"order" integer NOT NULL,
|
||||||
"parent_id" integer NOT NULL,
|
"parent_id" integer NOT NULL,
|
||||||
"path" varchar NOT NULL,
|
"value" "enum_roles_permissions",
|
||||||
"roles_id" integer
|
"id" serial PRIMARY KEY NOT NULL
|
||||||
);
|
);
|
||||||
|
EXCEPTION WHEN duplicate_table THEN null;
|
||||||
ALTER TABLE "users" ADD COLUMN "preferences_display_lead_or_actual" "enum_users_preferences_display_lead_or_actual" DEFAULT 'lead';
|
END $$;
|
||||||
ALTER TABLE "users" ADD COLUMN "preferences_display_show_version_overlay" boolean DEFAULT true;
|
`)
|
||||||
ALTER TABLE "profiles" ADD COLUMN "dossier_enlistment_date" timestamp(3) with time zone NOT NULL;
|
|
||||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "roles_id" integer;
|
await db.execute(sql`
|
||||||
ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
|
CREATE TABLE "roles" (
|
||||||
ALTER TABLE "roles_permissions" ADD CONSTRAINT "roles_permissions_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
ALTER TABLE "roles_rels" ADD CONSTRAINT "roles_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
|
"name" varchar NOT NULL,
|
||||||
ALTER TABLE "roles_rels" ADD CONSTRAINT "roles_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
|
"slug" varchar NOT NULL,
|
||||||
CREATE INDEX "users_rels_order_idx" ON "users_rels" USING btree ("order");
|
"description" varchar,
|
||||||
CREATE INDEX "users_rels_parent_idx" ON "users_rels" USING btree ("parent_id");
|
"is_system" boolean DEFAULT false,
|
||||||
CREATE INDEX "users_rels_path_idx" ON "users_rels" USING btree ("path");
|
"is_superuser" boolean DEFAULT false,
|
||||||
CREATE INDEX "users_rels_roles_id_idx" ON "users_rels" USING btree ("roles_id");
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
CREATE INDEX "roles_permissions_order_idx" ON "roles_permissions" USING btree ("order");
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
CREATE INDEX "roles_permissions_parent_idx" ON "roles_permissions" USING btree ("parent_id");
|
);
|
||||||
CREATE UNIQUE INDEX "roles_name_idx" ON "roles" USING btree ("name");
|
EXCEPTION WHEN duplicate_table THEN null;
|
||||||
CREATE UNIQUE INDEX "roles_slug_idx" ON "roles" USING btree ("slug");
|
END $$;
|
||||||
CREATE INDEX "roles_updated_at_idx" ON "roles" USING btree ("updated_at");
|
`)
|
||||||
CREATE INDEX "roles_created_at_idx" ON "roles" USING btree ("created_at");
|
|
||||||
CREATE INDEX "roles_rels_order_idx" ON "roles_rels" USING btree ("order");
|
await db.execute(sql`
|
||||||
CREATE INDEX "roles_rels_parent_idx" ON "roles_rels" USING btree ("parent_id");
|
DO $$ BEGIN
|
||||||
CREATE INDEX "roles_rels_path_idx" ON "roles_rels" USING btree ("path");
|
CREATE TABLE "roles_rels" (
|
||||||
CREATE INDEX "roles_rels_roles_id_idx" ON "roles_rels" USING btree ("roles_id");
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
|
"order" integer,
|
||||||
CREATE INDEX "payload_locked_documents_rels_roles_id_idx" ON "payload_locked_documents_rels" USING btree ("roles_id");`)
|
"parent_id" integer NOT NULL,
|
||||||
|
"path" varchar NOT NULL,
|
||||||
|
"roles_id" integer
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN duplicate_table THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
// --- Columns (ADD COLUMN with DEFAULT for NOT NULL columns to handle existing rows) ---
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "users" ADD COLUMN "preferences_display_lead_or_actual" "enum_users_preferences_display_lead_or_actual" DEFAULT 'lead';
|
||||||
|
EXCEPTION WHEN duplicate_column THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "users" ADD COLUMN "preferences_display_show_version_overlay" boolean DEFAULT true;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "dossier_enlistment_date" timestamp(3) with time zone DEFAULT now() NOT NULL;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "roles_id" integer;
|
||||||
|
EXCEPTION WHEN duplicate_column THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
// --- Foreign keys ---
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "roles_permissions" ADD CONSTRAINT "roles_permissions_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "roles_rels" ADD CONSTRAINT "roles_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "roles_rels" ADD CONSTRAINT "roles_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
// --- Indexes (catch duplicate_table OR duplicate_object — PostgreSQL uses 42P07 for indexes) ---
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE INDEX "users_rels_order_idx" ON "users_rels" USING btree ("order");
|
||||||
|
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE INDEX "users_rels_parent_idx" ON "users_rels" USING btree ("parent_id");
|
||||||
|
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE INDEX "users_rels_path_idx" ON "users_rels" USING btree ("path");
|
||||||
|
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE INDEX "users_rels_roles_id_idx" ON "users_rels" USING btree ("roles_id");
|
||||||
|
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE INDEX "roles_permissions_order_idx" ON "roles_permissions" USING btree ("order");
|
||||||
|
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE INDEX "roles_permissions_parent_idx" ON "roles_permissions" USING btree ("parent_id");
|
||||||
|
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE UNIQUE INDEX "roles_name_idx" ON "roles" USING btree ("name");
|
||||||
|
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE UNIQUE INDEX "roles_slug_idx" ON "roles" USING btree ("slug");
|
||||||
|
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE INDEX "roles_updated_at_idx" ON "roles" USING btree ("updated_at");
|
||||||
|
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE INDEX "roles_created_at_idx" ON "roles" USING btree ("created_at");
|
||||||
|
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE INDEX "roles_rels_order_idx" ON "roles_rels" USING btree ("order");
|
||||||
|
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE INDEX "roles_rels_parent_idx" ON "roles_rels" USING btree ("parent_id");
|
||||||
|
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE INDEX "roles_rels_path_idx" ON "roles_rels" USING btree ("path");
|
||||||
|
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE INDEX "roles_rels_roles_id_idx" ON "roles_rels" USING btree ("roles_id");
|
||||||
|
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_roles_id_idx" ON "payload_locked_documents_rels" USING btree ("roles_id");
|
||||||
|
EXCEPTION WHEN duplicate_object OR duplicate_table THEN null;
|
||||||
|
END $$;
|
||||||
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
ALTER TABLE "users_rels" DISABLE ROW LEVEL SECURITY;
|
ALTER TABLE "users_rels" DISABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE "roles_permissions" DISABLE ROW LEVEL SECURITY;
|
ALTER TABLE "roles_permissions" DISABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE "roles" DISABLE ROW LEVEL SECURITY;
|
ALTER TABLE "roles" DISABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE "roles_rels" DISABLE ROW LEVEL SECURITY;
|
ALTER TABLE "roles_rels" DISABLE ROW LEVEL SECURITY;
|
||||||
DROP TABLE "users_rels" CASCADE;
|
DROP TABLE "users_rels" CASCADE;
|
||||||
DROP TABLE "roles_permissions" CASCADE;
|
DROP TABLE "roles_permissions" CASCADE;
|
||||||
DROP TABLE "roles" CASCADE;
|
DROP TABLE "roles" CASCADE;
|
||||||
DROP TABLE "roles_rels" CASCADE;
|
DROP TABLE "roles_rels" CASCADE;
|
||||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_roles_fk";
|
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_roles_fk";
|
||||||
|
|
||||||
DROP INDEX "payload_locked_documents_rels_roles_id_idx";
|
DROP INDEX "payload_locked_documents_rels_roles_id_idx";
|
||||||
ALTER TABLE "users" DROP COLUMN "preferences_display_lead_or_actual";
|
ALTER TABLE "users" DROP COLUMN "preferences_display_lead_or_actual";
|
||||||
ALTER TABLE "users" DROP COLUMN "preferences_display_show_version_overlay";
|
ALTER TABLE "users" DROP COLUMN "preferences_display_show_version_overlay";
|
||||||
ALTER TABLE "profiles" DROP COLUMN "dossier_enlistment_date";
|
ALTER TABLE "profiles" DROP COLUMN "dossier_enlistment_date";
|
||||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "roles_id";
|
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "roles_id";
|
||||||
DROP TYPE "public"."enum_users_preferences_display_lead_or_actual";
|
DROP TYPE "public"."enum_users_preferences_display_lead_or_actual";
|
||||||
DROP TYPE "public"."enum_roles_permissions";`)
|
DROP TYPE "public"."enum_roles_permissions";
|
||||||
|
`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17726
src/migrations/20260821_043007.json
Normal file
17726
src/migrations/20260821_043007.json
Normal file
File diff suppressed because it is too large
Load diff
196
src/migrations/20260821_043007.ts
Normal file
196
src/migrations/20260821_043007.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TYPE "public"."enum_tickets_type" AS ENUM('task', 'bug', 'story', 'epic', 'improvement');
|
||||||
|
CREATE TYPE "public"."enum_tickets_resolution" AS ENUM('fixed', 'wontfix', 'duplicate', 'invalid');
|
||||||
|
CREATE TYPE "public"."enum_projects_status" AS ENUM('active', 'archived');
|
||||||
|
CREATE TYPE "public"."enum_sprints_status" AS ENUM('planning', 'active', 'completed');
|
||||||
|
CREATE TYPE "public"."enum_releases_status" AS ENUM('draft', 'in_progress', 'released');
|
||||||
|
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'projects:create' BEFORE 'tickets:create';
|
||||||
|
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'projects:read' BEFORE 'tickets:create';
|
||||||
|
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'projects:update' BEFORE 'tickets:create';
|
||||||
|
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'projects:delete' BEFORE 'tickets:create';
|
||||||
|
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'labels:create' BEFORE 'tickets:create';
|
||||||
|
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'labels:read' BEFORE 'tickets:create';
|
||||||
|
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'labels:update' BEFORE 'tickets:create';
|
||||||
|
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'labels:delete' BEFORE 'tickets:create';
|
||||||
|
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'sprints:create' BEFORE 'tickets:create';
|
||||||
|
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'sprints:read' BEFORE 'tickets:create';
|
||||||
|
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'sprints:update' BEFORE 'tickets:create';
|
||||||
|
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'sprints:delete' BEFORE 'tickets:create';
|
||||||
|
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'releases:create' BEFORE 'tickets:create';
|
||||||
|
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'releases:read' BEFORE 'tickets:create';
|
||||||
|
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'releases:update' BEFORE 'tickets:create';
|
||||||
|
ALTER TYPE "public"."enum_roles_permissions" ADD VALUE 'releases:delete' BEFORE 'tickets:create';
|
||||||
|
CREATE TABLE "tickets_rels" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"order" integer,
|
||||||
|
"parent_id" integer NOT NULL,
|
||||||
|
"path" varchar NOT NULL,
|
||||||
|
"labels_id" integer
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "projects" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar NOT NULL,
|
||||||
|
"key" varchar NOT NULL,
|
||||||
|
"description" jsonb,
|
||||||
|
"lead_id" integer,
|
||||||
|
"status" "enum_projects_status" DEFAULT 'active',
|
||||||
|
"default_assignee_id" integer,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "sprints" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar NOT NULL,
|
||||||
|
"project_id" integer NOT NULL,
|
||||||
|
"goal" varchar,
|
||||||
|
"start_date" timestamp(3) with time zone,
|
||||||
|
"end_date" timestamp(3) with time zone,
|
||||||
|
"status" "enum_sprints_status" DEFAULT 'planning',
|
||||||
|
"capacity" numeric,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "releases" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar NOT NULL,
|
||||||
|
"project_id" integer NOT NULL,
|
||||||
|
"description" jsonb,
|
||||||
|
"status" "enum_releases_status" DEFAULT 'draft',
|
||||||
|
"release_date" timestamp(3) with time zone,
|
||||||
|
"target_version" varchar,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "labels" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar NOT NULL,
|
||||||
|
"color" varchar DEFAULT '#6366f1',
|
||||||
|
"project_id" integer,
|
||||||
|
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "tickets" ADD COLUMN "project_id" integer;
|
||||||
|
ALTER TABLE "tickets" ADD COLUMN "sprint_id" integer;
|
||||||
|
ALTER TABLE "tickets" ADD COLUMN "release_id" integer;
|
||||||
|
ALTER TABLE "tickets" ADD COLUMN "parent_ticket_id" integer;
|
||||||
|
ALTER TABLE "tickets" ADD COLUMN "type" "enum_tickets_type" DEFAULT 'task';
|
||||||
|
ALTER TABLE "tickets" ADD COLUMN "story_points" numeric;
|
||||||
|
ALTER TABLE "tickets" ADD COLUMN "estimate" numeric;
|
||||||
|
ALTER TABLE "tickets" ADD COLUMN "start_date" timestamp(3) with time zone;
|
||||||
|
ALTER TABLE "tickets" ADD COLUMN "due_date" timestamp(3) with time zone;
|
||||||
|
ALTER TABLE "tickets" ADD COLUMN "resolution" "enum_tickets_resolution";
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "projects_id" integer;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "sprints_id" integer;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "releases_id" integer;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "labels_id" integer;
|
||||||
|
ALTER TABLE "tickets_rels" ADD CONSTRAINT "tickets_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "tickets_rels" ADD CONSTRAINT "tickets_rels_labels_fk" FOREIGN KEY ("labels_id") REFERENCES "public"."labels"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "projects" ADD CONSTRAINT "projects_lead_id_users_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "projects" ADD CONSTRAINT "projects_default_assignee_id_users_id_fk" FOREIGN KEY ("default_assignee_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "sprints" ADD CONSTRAINT "sprints_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "releases" ADD CONSTRAINT "releases_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "labels" ADD CONSTRAINT "labels_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
CREATE INDEX "tickets_rels_order_idx" ON "tickets_rels" USING btree ("order");
|
||||||
|
CREATE INDEX "tickets_rels_parent_idx" ON "tickets_rels" USING btree ("parent_id");
|
||||||
|
CREATE INDEX "tickets_rels_path_idx" ON "tickets_rels" USING btree ("path");
|
||||||
|
CREATE INDEX "tickets_rels_labels_id_idx" ON "tickets_rels" USING btree ("labels_id");
|
||||||
|
CREATE UNIQUE INDEX "projects_key_idx" ON "projects" USING btree ("key");
|
||||||
|
CREATE INDEX "projects_lead_idx" ON "projects" USING btree ("lead_id");
|
||||||
|
CREATE INDEX "projects_default_assignee_idx" ON "projects" USING btree ("default_assignee_id");
|
||||||
|
CREATE INDEX "projects_updated_at_idx" ON "projects" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "projects_created_at_idx" ON "projects" USING btree ("created_at");
|
||||||
|
CREATE INDEX "sprints_project_idx" ON "sprints" USING btree ("project_id");
|
||||||
|
CREATE INDEX "sprints_updated_at_idx" ON "sprints" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "sprints_created_at_idx" ON "sprints" USING btree ("created_at");
|
||||||
|
CREATE INDEX "releases_project_idx" ON "releases" USING btree ("project_id");
|
||||||
|
CREATE INDEX "releases_updated_at_idx" ON "releases" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "releases_created_at_idx" ON "releases" USING btree ("created_at");
|
||||||
|
CREATE INDEX "labels_project_idx" ON "labels" USING btree ("project_id");
|
||||||
|
CREATE INDEX "labels_updated_at_idx" ON "labels" USING btree ("updated_at");
|
||||||
|
CREATE INDEX "labels_created_at_idx" ON "labels" USING btree ("created_at");
|
||||||
|
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_sprint_id_sprints_id_fk" FOREIGN KEY ("sprint_id") REFERENCES "public"."sprints"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_release_id_releases_id_fk" FOREIGN KEY ("release_id") REFERENCES "public"."releases"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_parent_ticket_id_tickets_id_fk" FOREIGN KEY ("parent_ticket_id") REFERENCES "public"."tickets"("id") ON DELETE set null ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_projects_fk" FOREIGN KEY ("projects_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_sprints_fk" FOREIGN KEY ("sprints_id") REFERENCES "public"."sprints"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_releases_fk" FOREIGN KEY ("releases_id") REFERENCES "public"."releases"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_labels_fk" FOREIGN KEY ("labels_id") REFERENCES "public"."labels"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
CREATE INDEX "tickets_project_idx" ON "tickets" USING btree ("project_id");
|
||||||
|
CREATE INDEX "tickets_sprint_idx" ON "tickets" USING btree ("sprint_id");
|
||||||
|
CREATE INDEX "tickets_release_idx" ON "tickets" USING btree ("release_id");
|
||||||
|
CREATE INDEX "tickets_parent_ticket_idx" ON "tickets" USING btree ("parent_ticket_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_projects_id_idx" ON "payload_locked_documents_rels" USING btree ("projects_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_sprints_id_idx" ON "payload_locked_documents_rels" USING btree ("sprints_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_releases_id_idx" ON "payload_locked_documents_rels" USING btree ("releases_id");
|
||||||
|
CREATE INDEX "payload_locked_documents_rels_labels_id_idx" ON "payload_locked_documents_rels" USING btree ("labels_id");`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "tickets_rels" DISABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "projects" DISABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "sprints" DISABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "releases" DISABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "labels" DISABLE ROW LEVEL SECURITY;
|
||||||
|
DROP TABLE "tickets_rels" CASCADE;
|
||||||
|
DROP TABLE "projects" CASCADE;
|
||||||
|
DROP TABLE "sprints" CASCADE;
|
||||||
|
DROP TABLE "releases" CASCADE;
|
||||||
|
DROP TABLE "labels" CASCADE;
|
||||||
|
ALTER TABLE "tickets" DROP CONSTRAINT "tickets_project_id_projects_id_fk";
|
||||||
|
|
||||||
|
ALTER TABLE "tickets" DROP CONSTRAINT "tickets_sprint_id_sprints_id_fk";
|
||||||
|
|
||||||
|
ALTER TABLE "tickets" DROP CONSTRAINT "tickets_release_id_releases_id_fk";
|
||||||
|
|
||||||
|
ALTER TABLE "tickets" DROP CONSTRAINT "tickets_parent_ticket_id_tickets_id_fk";
|
||||||
|
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_projects_fk";
|
||||||
|
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_sprints_fk";
|
||||||
|
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_releases_fk";
|
||||||
|
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_labels_fk";
|
||||||
|
|
||||||
|
ALTER TABLE "roles_permissions" ALTER COLUMN "value" SET DATA TYPE text;
|
||||||
|
DROP TYPE "public"."enum_roles_permissions";
|
||||||
|
CREATE TYPE "public"."enum_roles_permissions" AS ENUM('system:admin-access', 'users:create', 'users:read', 'users:update', 'users:delete', 'users:unlock', 'users:assign-roles', 'ranks:create', 'ranks:read', 'ranks:update', 'ranks:delete', 'profiles:create', 'profiles:read', 'profiles:update', 'profiles:delete', 'awards:create', 'awards:read', 'awards:update', 'awards:delete', 'qualifications:create', 'qualifications:read', 'qualifications:update', 'qualifications:delete', 'assignments:create', 'assignments:read', 'assignments:update', 'assignments:delete', 'experience:create', 'experience:read', 'experience:update', 'experience:delete', 'user-notifications:create', 'user-notifications:read', 'user-notifications:update', 'user-notifications:delete', 'missions:create', 'missions:read', 'missions:update', 'missions:delete', 'missions:read-sensitive', 'mission-attendances:create', 'mission-attendances:read', 'mission-attendances:update', 'mission-attendances:delete', 'campaigns:create', 'campaigns:read', 'campaigns:update', 'campaigns:delete', 'factions:create', 'factions:read', 'factions:update', 'factions:delete', 'technologies:create', 'technologies:read', 'technologies:update', 'technologies:delete', 'assets:create', 'assets:read', 'assets:update', 'assets:delete', 'resources:create', 'resources:read', 'resources:update', 'resources:delete', 'vehicles:create', 'vehicles:read', 'vehicles:update', 'vehicles:delete', 'structures:create', 'structures:read', 'structures:update', 'structures:delete', 'shipments:create', 'shipments:read', 'shipments:update', 'shipments:delete', 'bank-accounts:create', 'bank-accounts:read', 'bank-accounts:update', 'bank-accounts:delete', 'bank-transactions:create', 'bank-transactions:read', 'bank-transactions:update', 'bank-transactions:delete', 'ledger-entries:create', 'ledger-entries:read', 'ledger-entries:update', 'ledger-entries:delete', 'locker-storages:create', 'locker-storages:read', 'locker-storages:update', 'locker-storages:delete', 'loadouts:create', 'loadouts:read', 'loadouts:update', 'loadouts:delete', 'market-listings:create', 'market-listings:read', 'market-listings:update', 'market-listings:delete', 'market-negotiations:create', 'market-negotiations:read', 'market-negotiations:update', 'market-negotiations:delete', 'tickets:create', 'tickets:read', 'tickets:update', 'tickets:delete', 'game-structures:create', 'game-structures:read', 'game-structures:update', 'game-structures:delete', 'game-vehicles:create', 'game-vehicles:read', 'game-vehicles:update', 'game-vehicles:delete', 'game-npcs:create', 'game-npcs:read', 'game-npcs:update', 'game-npcs:delete', 'game-hard-resources:create', 'game-hard-resources:read', 'game-hard-resources:update', 'game-hard-resources:delete', 'game-event-logs:create', 'game-event-logs:read', 'game-event-logs:update', 'game-event-logs:delete', 'maps:create', 'maps:read', 'maps:update', 'maps:delete', 'narrative-events:create', 'narrative-events:read', 'narrative-events:update', 'narrative-events:delete', 'mission-files:create', 'mission-files:read', 'mission-files:update', 'mission-files:delete', 'mod-lists:create', 'mod-lists:read', 'mod-lists:update', 'mod-lists:delete', 'game-rules:read', 'game-rules:update', 'shims:read', 'shims:update', 'logistics:manage', 'intelligence:manage', 'profiles:intel_excerpt:update', 'banking:manage', 'tickets:staff', 'discord:staff', 'discord:announce');
|
||||||
|
ALTER TABLE "roles_permissions" ALTER COLUMN "value" SET DATA TYPE "public"."enum_roles_permissions" USING "value"::"public"."enum_roles_permissions";
|
||||||
|
DROP INDEX "tickets_project_idx";
|
||||||
|
DROP INDEX "tickets_sprint_idx";
|
||||||
|
DROP INDEX "tickets_release_idx";
|
||||||
|
DROP INDEX "tickets_parent_ticket_idx";
|
||||||
|
DROP INDEX "payload_locked_documents_rels_projects_id_idx";
|
||||||
|
DROP INDEX "payload_locked_documents_rels_sprints_id_idx";
|
||||||
|
DROP INDEX "payload_locked_documents_rels_releases_id_idx";
|
||||||
|
DROP INDEX "payload_locked_documents_rels_labels_id_idx";
|
||||||
|
ALTER TABLE "tickets" DROP COLUMN "project_id";
|
||||||
|
ALTER TABLE "tickets" DROP COLUMN "sprint_id";
|
||||||
|
ALTER TABLE "tickets" DROP COLUMN "release_id";
|
||||||
|
ALTER TABLE "tickets" DROP COLUMN "parent_ticket_id";
|
||||||
|
ALTER TABLE "tickets" DROP COLUMN "type";
|
||||||
|
ALTER TABLE "tickets" DROP COLUMN "story_points";
|
||||||
|
ALTER TABLE "tickets" DROP COLUMN "estimate";
|
||||||
|
ALTER TABLE "tickets" DROP COLUMN "start_date";
|
||||||
|
ALTER TABLE "tickets" DROP COLUMN "due_date";
|
||||||
|
ALTER TABLE "tickets" DROP COLUMN "resolution";
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "projects_id";
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "sprints_id";
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "releases_id";
|
||||||
|
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "labels_id";
|
||||||
|
DROP TYPE "public"."enum_tickets_type";
|
||||||
|
DROP TYPE "public"."enum_tickets_resolution";
|
||||||
|
DROP TYPE "public"."enum_projects_status";
|
||||||
|
DROP TYPE "public"."enum_sprints_status";
|
||||||
|
DROP TYPE "public"."enum_releases_status";`)
|
||||||
|
}
|
||||||
9
src/migrations/_archived/20260818_024510.ts
Normal file
9
src/migrations/_archived/20260818_024510.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
// No-op migration: the schema this originally created was already applied by the
|
||||||
|
// archived migration chain (20251206–20260817) that was tracked in payload_migrations
|
||||||
|
// before the consolidation. Payload needs to see this name registered so it marks
|
||||||
|
// the migration as "ran" — but no DDL should execute.
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {}
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
import * as migration_20260818_024510 from './20260818_024510';
|
import * as migration_20260818_024510 from './_archived/20260818_024510';
|
||||||
import * as migration_20260820_004157 from './20260820_004157';
|
import * as migration_20260820_004157 from './20260820_004157';
|
||||||
|
import * as migration_20260821_043007 from './20260821_043007';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
|
||||||
up: migration_20260818_024510.up,
|
|
||||||
down: migration_20260818_024510.down,
|
|
||||||
name: '20260818_024510',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
up: migration_20260820_004157.up,
|
up: migration_20260820_004157.up,
|
||||||
down: migration_20260820_004157.down,
|
down: migration_20260820_004157.down,
|
||||||
name: '20260820_004157'
|
name: '20260820_004157',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260821_043007.up,
|
||||||
|
down: migration_20260821_043007.down,
|
||||||
|
name: '20260821_043007'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
348
src/scripts/run-migrations.ts
Normal file
348
src/scripts/run-migrations.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Interactive migration runner — safely run Payload migrations against any
|
||||||
|
* environment database (dev / stg / prd) from your local machine.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun run migrate # interactive menu
|
||||||
|
* bun run migrate dev # skip menu, target dev
|
||||||
|
* bun run migrate stg # skip menu, target staging
|
||||||
|
* bun run migrate prd # skip menu, target production
|
||||||
|
* bun run migrate --status # show migration status only (no changes)
|
||||||
|
* bun run migrate prd --status # status for a specific env
|
||||||
|
* bun run migrate --fresh # drop + recreate (dev only!)
|
||||||
|
*
|
||||||
|
* Environment files:
|
||||||
|
* .env — dev (DATABASE_URI for local PostgreSQL)
|
||||||
|
* .env.stg — staging (DATABASE_URI for staging PostgreSQL)
|
||||||
|
* .env.prd — production (DATABASE_URI for production PostgreSQL)
|
||||||
|
*
|
||||||
|
* These files are gitignored. Copy the DATABASE_URI from Coolify and paste
|
||||||
|
* into the appropriate file. Template:
|
||||||
|
* DATABASE_URI=postgres://user:password@host:5432/dbname
|
||||||
|
*/
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
// ── Config ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type EnvName = "dev" | "stg" | "prd";
|
||||||
|
|
||||||
|
interface EnvConfig {
|
||||||
|
label: string;
|
||||||
|
file: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENVS: Record<EnvName, EnvConfig> = {
|
||||||
|
dev: {
|
||||||
|
label: "Development",
|
||||||
|
file: ".env",
|
||||||
|
color: "\x1b[36m",
|
||||||
|
},
|
||||||
|
stg: {
|
||||||
|
label: "Staging",
|
||||||
|
file: ".env.stg",
|
||||||
|
color: "\x1b[33m",
|
||||||
|
},
|
||||||
|
prd: {
|
||||||
|
label: "Production",
|
||||||
|
file: ".env.prd",
|
||||||
|
color: "\x1b[31m",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ENV_ORDER: EnvName[] = ["dev", "stg", "prd"];
|
||||||
|
|
||||||
|
// ── ANSI helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const RESET = "\x1b[0m";
|
||||||
|
const BOLD = "\x1b[1m";
|
||||||
|
const DIM = "\x1b[2m";
|
||||||
|
const RED = "\x1b[31m";
|
||||||
|
const GREEN = "\x1b[32m";
|
||||||
|
const YELLOW = "\x1b[33m";
|
||||||
|
|
||||||
|
function log(msg: string) {
|
||||||
|
console.log(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── stdin abstraction (handles both TTY and piped) ─────────────────────
|
||||||
|
|
||||||
|
const inputLines: string[] = [];
|
||||||
|
let inputReady: Promise<void>;
|
||||||
|
|
||||||
|
function initInput(): Promise<void> {
|
||||||
|
if (!process.stdin.isTTY) {
|
||||||
|
// Piped input: buffer everything up front so readline never closes early
|
||||||
|
inputReady = new Promise((resolve) => {
|
||||||
|
let buf = "";
|
||||||
|
process.stdin.setEncoding("utf-8");
|
||||||
|
process.stdin.on("data", (chunk: string) => {
|
||||||
|
buf += chunk;
|
||||||
|
});
|
||||||
|
process.stdin.on("end", () => {
|
||||||
|
for (const line of buf.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed) inputLines.push(trimmed);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return inputReady;
|
||||||
|
}
|
||||||
|
inputReady = Promise.resolve();
|
||||||
|
return inputReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ask(question: string): Promise<string> {
|
||||||
|
if (!process.stdin.isTTY) {
|
||||||
|
// Consume from buffer
|
||||||
|
if (inputLines.length === 0) return "";
|
||||||
|
const line = inputLines.shift()!;
|
||||||
|
process.stdout.write(`${question}${line}\n`);
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
// Interactive: use readline for proper prompt
|
||||||
|
const { createInterface } = await import("node:readline");
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const rl = createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
rl.question(question, (answer) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(answer.trim());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function redactUri(uri: string): string {
|
||||||
|
return uri.replace(/(\/\/[^:]+:)[^@]+(@)/, "$1****$2");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvFile(filePath: string): Record<string, string> {
|
||||||
|
const raw = readFileSync(filePath, "utf-8");
|
||||||
|
const vars: Record<string, string> = {};
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||||
|
const eqIdx = trimmed.indexOf("=");
|
||||||
|
if (eqIdx === -1) continue;
|
||||||
|
const key = trimmed.slice(0, eqIdx).trim();
|
||||||
|
const val = trimmed.slice(eqIdx + 1).trim();
|
||||||
|
vars[key] = val.replace(/^["']|["']$/g, "");
|
||||||
|
}
|
||||||
|
return vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDatabaseUri(envName: EnvName): string | null {
|
||||||
|
const config = ENVS[envName];
|
||||||
|
const filePath = path.resolve(process.cwd(), config.file);
|
||||||
|
if (!existsSync(filePath)) return null;
|
||||||
|
const vars = parseEnvFile(filePath);
|
||||||
|
return vars.DATABASE_URI ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(
|
||||||
|
cmd: string,
|
||||||
|
args: string[],
|
||||||
|
envOverrides: Record<string, string>,
|
||||||
|
stdinData?: string,
|
||||||
|
): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const child = spawn(cmd, args, {
|
||||||
|
env: { ...process.env, ...envOverrides },
|
||||||
|
stdio: [stdinData != null ? "pipe" : "ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
if (stdinData != null && child.stdin) {
|
||||||
|
child.stdin.write(stdinData);
|
||||||
|
child.stdin.end();
|
||||||
|
}
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
child.stdout?.on("data", (d: Buffer) => {
|
||||||
|
const s = d.toString();
|
||||||
|
stdout += s;
|
||||||
|
process.stdout.write(s);
|
||||||
|
});
|
||||||
|
child.stderr?.on("data", (d: Buffer) => {
|
||||||
|
const s = d.toString();
|
||||||
|
stderr += s;
|
||||||
|
process.stderr.write(s);
|
||||||
|
});
|
||||||
|
child.on("close", (code) =>
|
||||||
|
resolve({ code: code ?? 1, stdout, stderr }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Menu ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function pickEnv(forced?: string): Promise<{ env: EnvName; uri: string }> {
|
||||||
|
if (forced && forced in ENVS) {
|
||||||
|
const uri = loadDatabaseUri(forced as EnvName);
|
||||||
|
if (!uri) {
|
||||||
|
log(
|
||||||
|
`${RED}Cannot find DATABASE_URI in ${ENVS[forced as EnvName].file}${RESET}`,
|
||||||
|
);
|
||||||
|
log(`Create the file with: DATABASE_URI=postgres://user:pass@host:5432/db`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return { env: forced as EnvName, uri };
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`\n${BOLD}⚡ Polaris Task Force — Migration Runner${RESET}\n`);
|
||||||
|
log("Target environment:\n");
|
||||||
|
|
||||||
|
for (let i = 0; i < ENV_ORDER.length; i++) {
|
||||||
|
const key = ENV_ORDER[i];
|
||||||
|
const cfg = ENVS[key];
|
||||||
|
const exists = loadDatabaseUri(key) !== null;
|
||||||
|
const status = exists ? `${GREEN}●${RESET}` : `${DIM}○ missing${RESET}`;
|
||||||
|
log(
|
||||||
|
` ${BOLD}${i + 1}${RESET}) ${cfg.color}${cfg.label}${RESET} ${DIM}(${cfg.file})${RESET} ${status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log("");
|
||||||
|
const answer = (await ask(` Select (1-${ENV_ORDER.length}): `)).trim();
|
||||||
|
const idx = parseInt(answer, 10) - 1;
|
||||||
|
if (isNaN(idx) || idx < 0 || idx >= ENV_ORDER.length) {
|
||||||
|
log(`${RED}Invalid selection.${RESET}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = ENV_ORDER[idx];
|
||||||
|
const uri = loadDatabaseUri(env);
|
||||||
|
if (!uri) {
|
||||||
|
log(`\n${RED}DATABASE_URI not found in ${ENVS[env].file}${RESET}`);
|
||||||
|
log(
|
||||||
|
`Create the file with: ${BOLD}DATABASE_URI=postgres://user:pass@host:5432/db${RESET}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { env, uri };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await initInput();
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const statusOnly = args.includes("--status") || args.includes("-s");
|
||||||
|
const freshOnly = args.includes("--fresh");
|
||||||
|
const positionalArgs = args.filter((a) => !a.startsWith("-"));
|
||||||
|
|
||||||
|
const forced = positionalArgs[0];
|
||||||
|
const { env, uri } = await pickEnv(forced);
|
||||||
|
const cfg = ENVS[env];
|
||||||
|
const redacted = redactUri(uri);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
log(`\n${cfg.color}${BOLD}━━━ ${cfg.label.toUpperCase()} ━━━${RESET}`);
|
||||||
|
log(`${DIM} ${redacted}${RESET}\n`);
|
||||||
|
|
||||||
|
if (statusOnly) {
|
||||||
|
log(`${DIM}Migration status:${RESET}\n`);
|
||||||
|
await run("bun", ["run", "payload", "migrate:status"], {
|
||||||
|
DATABASE_URI: uri,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (freshOnly) {
|
||||||
|
if (env !== "dev") {
|
||||||
|
log(`${RED}${BOLD}--fresh is only allowed for dev.${RESET}`);
|
||||||
|
log(
|
||||||
|
`${DIM}For staging/production, use migrate (up only) or migrate:fresh manually.${RESET}`,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const confirm = await ask(
|
||||||
|
`${YELLOW}This will DROP and recreate all tables on ${cfg.label}. Continue? (y/N): ${RESET}`,
|
||||||
|
);
|
||||||
|
if (confirm.toLowerCase() !== "y") {
|
||||||
|
log("Aborted.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log("");
|
||||||
|
await run("bun", ["run", "payload", "migrate:fresh"], {
|
||||||
|
DATABASE_URI: uri,
|
||||||
|
});
|
||||||
|
log(`\n${GREEN}${BOLD}✓ Fresh migration complete.${RESET}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show current status
|
||||||
|
log(`${DIM}Migration status:${RESET}\n`);
|
||||||
|
const statusResult = await run(
|
||||||
|
"bun",
|
||||||
|
["run", "payload", "migrate:status"],
|
||||||
|
{ DATABASE_URI: uri },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for pending migrations
|
||||||
|
const hasPending = statusResult.stdout.includes("│ No │");
|
||||||
|
if (!hasPending && !statusResult.stderr) {
|
||||||
|
log(`\n${GREEN}All migrations applied. Nothing to do.${RESET}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm
|
||||||
|
log("");
|
||||||
|
if (env === "prd") {
|
||||||
|
log(
|
||||||
|
`${RED}${BOLD} ╔══════════════════════════════════════════╗${RESET}`,
|
||||||
|
);
|
||||||
|
log(
|
||||||
|
`${RED}${BOLD} ║ YOU ARE ABOUT TO MIGRATE PRODUCTION ║${RESET}`,
|
||||||
|
);
|
||||||
|
log(
|
||||||
|
`${RED}${BOLD} ╚══════════════════════════════════════════╝${RESET}\n`,
|
||||||
|
);
|
||||||
|
const confirm = await ask(
|
||||||
|
`${RED}Type "migrate" to confirm: ${RESET}`,
|
||||||
|
);
|
||||||
|
if (confirm.trim() !== "migrate") {
|
||||||
|
log("Aborted.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const confirm = await ask(
|
||||||
|
`${YELLOW}Run migrations on ${cfg.label}? (y/N): ${RESET}`,
|
||||||
|
);
|
||||||
|
if (confirm.toLowerCase() !== "y") {
|
||||||
|
log("Aborted.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migration — pipe "y\n" so Payload's dev-mode prompt (triggered by
|
||||||
|
// push:true's batch=-1 record) is auto-accepted instead of blocking on
|
||||||
|
// non-interactive stdin.
|
||||||
|
log(`\n${BOLD}Running migrations...${RESET}\n`);
|
||||||
|
const result = await run(
|
||||||
|
"bun",
|
||||||
|
["run", "payload", "migrate"],
|
||||||
|
{ DATABASE_URI: uri },
|
||||||
|
"y\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.code !== 0) {
|
||||||
|
log(`\n${RED}${BOLD}✗ Migration failed (exit ${result.code})${RESET}`);
|
||||||
|
process.exit(result.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`\n${GREEN}${BOLD}✓ Migrations applied successfully.${RESET}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`${RED}Fatal error:${RESET}`, err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { EventType } from "./eventTypes";
|
import type { EventType } from "./eventTypes";
|
||||||
|
import { broadcast } from "@/lib/realtime/bus";
|
||||||
|
|
||||||
interface GameEventInput {
|
interface GameEventInput {
|
||||||
type: EventType;
|
type: EventType;
|
||||||
|
|
@ -52,6 +53,12 @@ export async function emitGameEvent(
|
||||||
},
|
},
|
||||||
depth: 0,
|
depth: 0,
|
||||||
});
|
});
|
||||||
|
broadcast("game-event", {
|
||||||
|
type: event.type,
|
||||||
|
actor: event.actor ?? null,
|
||||||
|
targetCollection: event.targetCollection ?? null,
|
||||||
|
targetId: event.targetId ?? null,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[EventLog] Failed to emit event:", err);
|
console.error("[EventLog] Failed to emit event:", err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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