diff --git a/src/app/(frontend)/operations/reservations/page.tsx b/src/app/(frontend)/operations/reservations/page.tsx new file mode 100644 index 0000000..0b80490 --- /dev/null +++ b/src/app/(frontend)/operations/reservations/page.tsx @@ -0,0 +1,303 @@ +import config from "@payload-config"; +import { getPayload } from "payload"; +import type { Where } from "payload"; +import { headers as nextHeaders } from "next/headers"; +import Link from "next/link"; +import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; +import { hasPermission } from "@/utils/access-control/hasPermission"; +import { + LedgerRow, + NoticeState, + PageShell, + STATUS_TONE_CLASSES, + statusToneFor, + type ReadoutCell, +} from "@/components/frontend/operations"; +import type { SurfaceUser } from "@/lib/operations/surface"; +import { listReservationOptions } from "@/lib/operations/reservationOptions"; +import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; +import { CancelButton } from "@/components/frontend/operations/reservations/ReservationCancelButton"; +import { CreateReservationDialog } from "@/components/frontend/operations/reservations/CreateReservationDialog"; + +const RESERVATION_PAGE_SIZE = 20; + +export interface ReservationRow { + id: number; + reservationKey: string; + operationId: string; + status: string; + expiresAt: string | null; +} + +export interface ReservationFilters { + status?: string; + operationId?: string; +} + +export interface ReservationListData { + rows: ReservationRow[]; + /** True when the viewer holds operation-reservations:read. False -> empty rows. */ + capability: boolean; + page: number; + totalDocs: number; + totalPages: number; +} + +type PayloadType = Awaited>; + +/** + * List of allocation reservations with status/operationId filters and + * pagination. Runs through collection access control (default access + the + * explicit session user, never overrideAccess). Gated behind + * operation-reservations:read: a viewer lacking that permission gets an empty + * result (capability false) so the page can render the permission notice + * instead of a list that looks like "no data". + */ +export async function getReservationList( + payload: PayloadType, + user: SurfaceUser, + filters: ReservationFilters, + requestedPage = 1, +): Promise { + const canSee = await hasPermission(payload, user, "operation-reservations:read"); + if (!canSee) { + return { rows: [], capability: false, page: 1, totalDocs: 0, totalPages: 0 }; + } + + const conditions: Where[] = []; + if (filters.status) conditions.push({ status: { equals: filters.status } }); + if (filters.operationId) conditions.push({ operationId: { equals: filters.operationId } }); + const where: Where | undefined = conditions.length > 0 ? { and: conditions } : undefined; + + const found = await payload.find({ + collection: "operation-reservations", + where, + sort: "-createdAt", + page: Math.max(1, requestedPage), + limit: RESERVATION_PAGE_SIZE, + depth: 0, + user: user ?? undefined, + }); + + const rows: ReservationRow[] = found.docs.map((doc) => { + const r = doc as unknown as ReservationRow; + return { + id: r.id, + reservationKey: r.reservationKey, + operationId: r.operationId, + status: r.status, + expiresAt: r.expiresAt ?? null, + }; + }); + + return { + rows, + capability: true, + page: found.page ?? Math.max(1, requestedPage), + totalDocs: found.totalDocs ?? found.docs.length, + totalPages: found.totalPages ?? 1, + }; +} + +function reservationHref(filters: ReservationFilters, page: number): string { + const params = new URLSearchParams(); + if (filters.status) params.set("status", filters.status); + if (filters.operationId) params.set("operationId", filters.operationId); + if (page > 1) params.set("page", String(page)); + const qs = params.toString(); + return qs ? `/operations/reservations?${qs}` : "/operations/reservations"; +} + +const STATUS_OPTIONS: { value: string; label: string }[] = [ + { value: "", label: "All" }, + { value: "reserved", label: "Reserved" }, + { value: "settled", label: "Settled" }, + { value: "cancelled", label: "Cancelled" }, + { value: "expired", label: "Expired" }, + { value: "no-show", label: "No-Show" }, +]; + +export const metadata = { + title: "Reservations: Polaris Task Force", +}; + +export default async function ReservationsPage({ + searchParams, +}: { + searchParams: Promise; +}) { + const params = await searchParams; + const payload = await getPayload({ config }); + const { user: authUser } = await payload.auth({ + headers: await nextHeaders(), + canSetHeaders: false, + }); + const user = isPayloadUser(authUser) ? authUser : null; + const surfaceUser: SurfaceUser = user ? { id: user.id } : null; + + const page = Number.parseInt(params.page ?? "1", 10); + const filters: ReservationFilters = { + status: params.status || undefined, + operationId: params.operationId || undefined, + }; + const data = await getReservationList(payload, surfaceUser, filters, Number.isNaN(page) ? 1 : page); + + // The cancel control is server-gated: only viewers holding + // operation-reservations:update may render it, and only on reserved rows. + const canCancel = + data.capability && user !== null + ? await hasPermission(payload, user, "operation-reservations:update") + : false; + + // Server-gated create dialog: only operation-reservations:create viewers open it; + // logistics qualification gates the vehicle/cargo/budget sections inside. + const canCreate = user + ? await hasPermission(payload, user, "operation-reservations:create") + : false; + const canManageLogistics = user + ? await hasLogisticsQualification(payload, user) + : false; + const options = canCreate && user ? await listReservationOptions(payload, user) : null; + + return ( +
+ +

+ Every allocation of people, vehicles, cargo, and treasury budget against an operation, + with its settlement outcome. Reservations are earmarks only; the settlement record on a + row captures what was consumed or returned. +

+ +
+
+ + +
+
+ + +
+ + + Clear + +
+
+ + {canCreate && options ? ( + + ) : null} + + {data.capability === false ? ( + + ) : data.rows.length === 0 ? ( + + ) : ( +
+ {data.rows.map((row) => { + const cells: ReadoutCell[] = [ + { label: "Operation", value: row.operationId }, + { label: "Status", value: row.status }, + { label: "Expires", value: row.expiresAt ?? "n/a" }, + ]; + return ( +
+
+ + {row.reservationKey} + + + {row.status} + + {canCancel && row.status === "reserved" ? ( +
+ +
+ ) : null} +
+ +
+ ); + })} + +
+ + Page {data.page} of {Math.max(1, data.totalPages)} ({data.totalDocs} reservations) + + + {data.page > 1 && ( + + Prev + + )} + {data.page < data.totalPages && ( + + Next + + )} + +
+
+ )} +
+ ); +} diff --git a/src/components/frontend/operations/reservations/CreateReservationDialog.tsx b/src/components/frontend/operations/reservations/CreateReservationDialog.tsx new file mode 100644 index 0000000..90efc76 --- /dev/null +++ b/src/components/frontend/operations/reservations/CreateReservationDialog.tsx @@ -0,0 +1,535 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Check, ChevronDown, PlusIcon, TrashIcon } from "lucide-react"; +import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import type { ReservationOptions } from "@/lib/operations/reservationOptions"; +import { createReservation } from "@/app/(frontend)/operations/reservations/actions"; + +interface CreateReservationDialogProps { + options: ReservationOptions; + canManageLogistics: boolean; + currentUserId: number; + /** Trigger only renders for users who hold operation-reservations:create. */ + canCreate: boolean; +} + +interface CargoLine { + resourceId: string; + amount: string; +} + +/** + * Searchable multi-value picker built on the same Command/Popover primitives as + * the site Combobox, which is single-value only. Selections persist in a number + * array; the popover stays as wide as the trigger. + */ +function MultiSelect({ + label, + options, + selected, + onChange, + disabled, + placeholder = "None selected", +}: { + label: string; + options: ComboboxOption[]; + selected: number[]; + onChange: (value: number[]) => void; + disabled?: boolean; + placeholder?: string; +}) { + const [open, setOpen] = useState(false); + const selectedLabels = useMemo( + () => options.filter((o) => selected.includes(Number(o.value))).map((o) => o.label), + [options, selected], + ); + + return ( + !disabled && setOpen(next)}> + + + {selectedLabels.length > 0 ? selectedLabels.join(", ") : placeholder} + + + + + + + + Nothing found. + + {options.map((option) => { + const value = Number(option.value); + const isSelected = selected.includes(value); + return ( + { + onChange( + isSelected + ? selected.filter((v) => v !== value) + : [...selected, value].sort((a, b) => a - b), + ); + }} + > + + {option.label} + + ); + })} + + + + + + ); +} + +export function CreateReservationDialog({ + options, + canManageLogistics, + currentUserId, + canCreate, +}: CreateReservationDialogProps) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [reservationKey, setReservationKey] = useState(""); + const [operationId, setOperationId] = useState(""); + const [missionId, setMissionId] = useState(""); + const [personnel, setPersonnel] = useState([]); + const [vehicles, setVehicles] = useState([]); + const [originId, setOriginId] = useState(""); + const [destinationId, setDestinationId] = useState(""); + const [cargoLines, setCargoLines] = useState([]); + const [budgetAccountId, setBudgetAccountId] = useState(""); + const [budgetAmount, setBudgetAmount] = useState(""); + const [expiresAt, setExpiresAt] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const missionOptions: ComboboxOption[] = options.upcomingMissions.map((m) => ({ + value: String(m.id), + label: m.name + (m.codeName ? ` (${m.codeName})` : ""), + })); + + const vehicleOptions: ComboboxOption[] = options.vehicles.map((v) => ({ + value: String(v.id), + label: v.name, + hint: `${v.fuelPct}% fuel`, + })); + + const rosterOptions: ComboboxOption[] = options.rosterUsers.map((u) => ({ + value: String(u.id), + label: u.displayName, + })); + + const originOptions: ComboboxOption[] = options.originStructures.map((s) => ({ + value: String(s.id), + label: s.name, + })); + + const destinationOptions: ComboboxOption[] = options.destinationStructures.map((s) => ({ + value: String(s.id), + label: s.name, + })); + + const budgetOptions: ComboboxOption[] = options.budgetAccounts.map((a) => ({ + value: String(a.id), + label: a.name, + hint: a.type, + })); + + const originStock = originId ? options.stockByOrigin[originId] ?? [] : []; + + function reset() { + setReservationKey(""); + setOperationId(""); + setMissionId(""); + setPersonnel([]); + setVehicles([]); + setOriginId(""); + setDestinationId(""); + setCargoLines([]); + setBudgetAccountId(""); + setBudgetAmount(""); + setExpiresAt(""); + setError(null); + } + + function updateCargo(index: number, patch: Partial) { + setCargoLines((lines) => + lines.map((line, i) => (i === index ? { ...line, ...patch } : line)), + ); + } + + function addCargoLine() { + setCargoLines((lines) => [...lines, { resourceId: "", amount: "" }]); + } + + function removeCargoLine(index: number) { + setCargoLines((lines) => lines.filter((_, i) => i !== index)); + } + + /** + * Client-side sanity checks only. The server action re-validates every limit + * and is the source of truth; these give the form immediate feedback before + * the request goes out. + */ + function clientErrors(): string[] { + const errors: string[] = []; + if (!reservationKey.trim()) errors.push("A reservation key is required."); + if (!operationId.trim()) errors.push("An operation id is required."); + for (const [i, line] of cargoLines.entries()) { + const amount = Number(line.amount); + if (line.resourceId && (!Number.isInteger(amount) || amount <= 0)) { + errors.push(`Cargo amount on line ${i + 1} must be a positive integer.`); + } + } + if (cargoLines.some((l) => l.resourceId) && !originId) { + errors.push("An origin structure is required when cargo is reserved."); + } + if (budgetAmount && (!Number.isInteger(Number(budgetAmount)) || Number(budgetAmount) <= 0)) { + errors.push("Budget amount must be a positive integer."); + } + return errors; + } + + async function handleSubmit() { + const problems = clientErrors(); + if (problems.length > 0) { + setError(problems.join(" ")); + return; + } + + setSubmitting(true); + setError(null); + const result = await createReservation({ + reservationKey: reservationKey.trim(), + operationId: operationId.trim(), + actorId: currentUserId, + ...(missionId ? { missionId: Number(missionId) } : {}), + ...(personnel.length ? { personnel: personnel.map((id) => ({ userId: id })) } : {}), + ...(vehicles.length ? { vehicleIds: [...vehicles] } : {}), + ...(cargoLines + .filter((l) => l.resourceId && Number(l.amount) > 0) + .length + ? { + cargo: cargoLines + .filter((l) => l.resourceId && Number(l.amount) > 0) + .map((l) => ({ resourceId: Number(l.resourceId), amount: Number(l.amount) })), + } + : {}), + ...(budgetAmount ? { budget: { accountId: Number(budgetAccountId), amount: Number(budgetAmount) } } : {}), + ...(originId ? { originId: Number(originId) } : {}), + ...(destinationId ? { destinationId: Number(destinationId) } : {}), + ...(expiresAt ? { expiresAt: new Date(expiresAt).toISOString() } : {}), + }); + + if (result.success) { + setOpen(false); + reset(); + router.refresh(); + } else { + // Surface the engine's error string verbatim; the server action never + // rewords the allocation-rejected message. + setError(result.error ?? "Failed to create reservation."); + setSubmitting(false); + } + } + + return ( + { setOpen(next); if (!next) reset(); }}> + {canCreate ? ( + + + + ) : null} + + + Reserve Operation Assets + + earmark people, vehicles, cargo, and treasury budget against an operation. Capacity and + funds are enforced server-side. + + + +
+
+
+ + setReservationKey(e.target.value)} + placeholder="e.g. op-24-alpha" + /> +
+
+ + setOperationId(e.target.value)} + placeholder="e.g. operation-id" + /> +
+
+ +
+ + +
+ +
+ + +
+ + {!canManageLogistics ? ( + + + + + ) : ( +
+ + +
+ )} + +
+ + +
+ + {canManageLogistics ? ( + +
+ + +
+ {cargoLines.length === 0 ? ( +

No cargo reserved.

+ ) : ( +
+ {cargoLines.map((line, index) => ( +
+
+ + {originStock.length > 0 ? ( + updateCargo(index, { resourceId: v })} + searchPlaceholder="Search resources..." + emptyMessage="Nothing stored at this origin." + options={originStock.map((s) => ({ + value: String(s.resourceId), + label: s.resourceName, + hint: `${s.amount.toLocaleString()} available`, + }))} + /> + ) : ( +

+ Select an origin to choose resources. +

+ )} +
+
+ + updateCargo(index, { amount: e.target.value })} + /> +
+ +
+ ))} +
+ )} +
+ ) : ( + + +

Disabled until logistics access is granted.

+
+ )} + +
+ + +
+ + {canManageLogistics ? ( +
+
+ + +
+
+ + setBudgetAmount(e.target.value)} + /> +
+
+ ) : ( + + + + + )} + +
+ + setExpiresAt(e.target.value)} + /> +
+ + {error &&

{error}

} +
+ + + + + +
+
+ ); +} + +/** + * Wrapper that disables a form section for users without logistics access and + * shows why. UX-only gating; the server action is the source of truth. + */ +function SectionGated({ + hint, + children, +}: { + hint?: string; + children: React.ReactNode; +}) { + return ( +
+ {hint ?

{hint}

: null} +
{children}
+
+ ); +} diff --git a/src/lib/operations/reservationOptions.ts b/src/lib/operations/reservationOptions.ts new file mode 100644 index 0000000..62631cd --- /dev/null +++ b/src/lib/operations/reservationOptions.ts @@ -0,0 +1,311 @@ +/** + * Read-only option pools for the reservation creation form. + * + * Server-only: no JSX, no client imports. The operations page fetches these + * once via `getPayload()` and passes them as props to the client + * `CreateReservationDialog`. Every query runs with `overrideAccess` because the + * pools are authoring helpers, not user-scoped views, and the dialog itself is + * only mounted for users who hold `operation-reservations:create`. + * + * Deliberately does NOT return account balances: the picker shows the account + * name and type only, so a viewer can never read another party's funds. + */ +import type { Payload } from "payload"; + +type PayloadType = Awaited>; + +const UPCOMING_MISSION_STATUSES = ["Ready", "Scheduled"] as const; +const MAX_MISSIONS = 50; +const MAX_VEHICLES = 50; +const MAX_STRUCTURES = 100; +const MAX_ROSTER = 200; +const MAX_ACCOUNTS = 100; + +export interface MissionOption { + id: number; + name: string; + codeName: string | null; +} + +export interface VehicleOption { + id: number; + name: string; + fuelPct: number; +} + +export interface StructureOption { + id: number; + name: string; +} + +export interface BudgetOption { + id: number; + name: string; + type: "treasury" | "faction" | "personal"; +} + +export interface StockLine { + resourceId: number; + resourceName: string; + amount: number; +} + +export interface RosterUser { + id: number; + displayName: string; +} + +export interface ReservationOptions { + upcomingMissions: MissionOption[]; + vehicles: VehicleOption[]; + originStructures: StructureOption[]; + destinationStructures: StructureOption[]; + budgetAccounts: BudgetOption[]; + /** origin structure id -> available stock lines for that origin. */ + stockByOrigin: Record; + rosterUsers: RosterUser[]; +} + +interface Depth0Structure { + id: number; + name: string; + constructionStatus?: string; + storedResources?: unknown[]; + voidStorage?: unknown[]; +} + +interface Depth0Vehicle { + id: number; + name: string; + status?: string; + currentFuel?: number | null; + type?: { fuel?: { fuelCapacity?: number | null } | null } | null; +} + +async function operationalStructures( + payload: PayloadType, +): Promise { + const res = await payload + .find({ + collection: "game-structures", + where: { constructionStatus: { equals: "complete" } }, + sort: "name", + limit: MAX_STRUCTURES, + depth: 0, + overrideAccess: true, + }) + .catch(() => ({ docs: [] as unknown[] })); + return (res.docs as unknown as Depth0Structure[]).slice(0, MAX_STRUCTURES); +} + +async function idleUnreservedVehicles( + payload: PayloadType, + reservedIds: Set, +): Promise { + const res = await payload + .find({ + collection: "game-vehicles", + where: { status: { equals: "idle" } }, + sort: "name", + limit: MAX_VEHICLES, + depth: 1, + overrideAccess: true, + }) + .catch(() => ({ docs: [] as unknown[] })); + const vehicles = (res.docs as unknown as Depth0Vehicle[]).filter( + (v) => !reservedIds.has(v.id), + ); + return vehicles.slice(0, MAX_VEHICLES).map((v) => ({ + id: v.id, + name: v.name, + fuelPct: fuelPercent(v), + })); +} + +function fuelPercent(vehicle: Depth0Vehicle): number { + const current = vehicle.currentFuel ?? 0; + const capacity = vehicle.type?.fuel?.fuelCapacity; + if (typeof capacity !== "number" || capacity <= 0) return 0; + return Math.round((current / capacity) * 100); +} + +async function activeReservedVehicleIds( + payload: PayloadType, +): Promise> { + const res = await payload + .find({ + collection: "operation-reservations", + where: { status: { equals: "reserved" } }, + limit: 1000, + depth: 0, + overrideAccess: true, + }) + .catch(() => ({ docs: [] as unknown[] })); + const ids = new Set(); + for (const doc of res.docs) { + const vehicles = (doc as { vehicles?: { vehicle: number | { id: number } }[] | null }) + .vehicles; + if (!vehicles) continue; + for (const entry of vehicles) { + const ref = entry.vehicle; + const id = typeof ref === "object" && ref ? ref.id : ref; + if (typeof id === "number") ids.add(id); + } + } + return ids; +} + +async function fetchBudgetAccounts( + payload: PayloadType, + userId: number | null, +): Promise { + const budgetAnd: Array> = [{ status: { equals: "open" } }]; + const accountOr: Array> = [{ accountType: { equals: "treasury" } }]; + if (userId != null) { + accountOr.push({ + and: [ + { accountType: { equals: "personal" } }, + { ownerUser: { equals: userId } }, + ], + }); + } + budgetAnd.push({ or: accountOr }); + + const res = await payload + .find({ + collection: "bank-accounts", + where: { and: budgetAnd } as never, + sort: "name", + limit: MAX_ACCOUNTS, + depth: 0, + overrideAccess: true, + }) + .catch(() => ({ docs: [] as unknown[] })); + return (res.docs as unknown as { id: number; name: string; accountType: string }[]) + .slice(0, MAX_ACCOUNTS) + .map((a) => ({ id: a.id, name: a.name, type: a.accountType as BudgetOption["type"] })); +} + +async function fetchRosterUsers(payload: PayloadType): Promise { + const res = await payload + .find({ + collection: "users", + sort: "displayName", + limit: MAX_ROSTER, + depth: 0, + overrideAccess: true, + }) + .catch(() => ({ docs: [] as unknown[] })); + return (res.docs as unknown as { id: number; displayName: string }[]) + .slice(0, MAX_ROSTER) + .map((u) => ({ id: u.id, displayName: u.displayName || "Unknown user" })); +} + +/** + * Upcoming unit-visible missions: status Ready or Scheduled, visibility unit, + * sorted by earliest start date. + */ +async function fetchUpcomingMissions(payload: PayloadType): Promise { + const res = await payload + .find({ + collection: "missions", + where: { + and: [ + { "ownershipAndStatus.status": { in: [...UPCOMING_MISSION_STATUSES] } }, + { "ownershipAndStatus.visibility": { equals: "unit" } }, + ], + }, + sort: "classification.startDateTime", + limit: MAX_MISSIONS, + depth: 0, + overrideAccess: true, + }) + .catch(() => ({ docs: [] as unknown[] })); + return (res.docs as unknown as { id: number; name: string; codeName: string | null }[]) + .slice(0, MAX_MISSIONS) + .map((m) => ({ id: m.id, name: m.name, codeName: m.codeName ?? null })); +} + +/** Aggregate per-origin stock from storedResources + voidStorage, resolving names. */ +async function stockByOrigin( + payload: PayloadType, + origins: Depth0Structure[], +): Promise> { + const nameCache = new Map(); + const resolveName = async (resourceId: number): Promise => { + const cached = nameCache.get(resourceId); + if (cached) return cached; + const doc = await payload + .findByID({ collection: "resources", id: resourceId, depth: 0, overrideAccess: true }) + .catch(() => null); + const name = doc?.name ?? `Resource #${resourceId}`; + nameCache.set(resourceId, name); + return name; + }; + + const out: Record = {}; + for (const origin of origins) { + const entries = [ + ...(origin.storedResources ?? []), + ...(origin.voidStorage ?? []), + ] as Array<{ resource: number | { id: number; name?: string }; amount: number }>; + if (entries.length === 0) continue; + + const totals = new Map(); + for (const entry of entries) { + const resourceId = typeof entry.resource === "object" ? entry.resource.id : entry.resource; + if (typeof resourceId !== "number") continue; + totals.set(resourceId, (totals.get(resourceId) ?? 0) + (entry.amount ?? 0)); + } + + const lines: StockLine[] = []; + for (const [resourceId, amount] of totals) { + lines.push({ resourceId, resourceName: await resolveName(resourceId), amount }); + } + lines.sort((a, b) => a.resourceName.localeCompare(b.resourceName)); + out[String(origin.id)] = lines; + } + return out; +} + +/** + * All option pools the reservation form needs, in one object. + * + * `userId` is used to scope budget accounts to the viewer's own personal + * account (plus any open treasury accounts); it is never used to read balances. + */ +export async function listReservationOptions( + payload: PayloadType, + user: { id: number | string } | null, +): Promise { + const userId = user ? Number(user.id) : null; + const reservedVehicleIds = await activeReservedVehicleIds(payload); + const origins = await operationalStructures(payload); + + const [upcomingMissions, vehicles, budgetAccounts, rosterUsers] = await Promise.all([ + fetchUpcomingMissions(payload), + idleUnreservedVehicles(payload, reservedVehicleIds), + fetchBudgetAccounts(payload, userId), + fetchRosterUsers(payload), + ]); + + const stockByOriginData = await stockByOrigin(payload, origins); + + const structureOptions = origins.map((s) => ({ id: s.id, name: s.name })); + + return { + upcomingMissions, + vehicles, + originStructures: structureOptions, + destinationStructures: structureOptions, + budgetAccounts, + stockByOrigin: stockByOriginData, + rosterUsers, + }; +} + +/** + * Re-exported so the dialog can type a stored structure/vehicle doc without + * importing payload-types twice. + */ +export type { GameStructure, GameVehicle } from "@/payload-types"; diff --git a/tests/e2e/operations-reservations.e2e.spec.ts b/tests/e2e/operations-reservations.e2e.spec.ts new file mode 100644 index 0000000..41fc136 --- /dev/null +++ b/tests/e2e/operations-reservations.e2e.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from "@playwright/test"; + +/** + * Create-reservation dialog on the reservations list page. + * + * NOTE: these tests exercise the real dev server and are run in the final + * verification wave (F3) under the dev-server protocol, not on every change. + * The dialog is server-gated: only users holding operation-reservations:create + * render the "New Reservation" trigger; logistics qualification gates the + * vehicle/cargo/budget sections inside. + */ +test.describe("Create reservation dialog", () => { + test("guests never see the create-reservation dialog", async ({ page }) => { + await page.goto("/operations/reservations"); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Polaris Task Force"); + }); + + test("a permitted, logistics-qualified user sees the create dialog with all sections", async ({ + page, + }) => { + await page.goto("/login"); + await page.getByLabel("Username").fill("dev"); + await page.getByLabel("Password").fill("Test123"); + await page.getByRole("button", { name: "Log in" }).click(); + + await page.goto("/operations/reservations"); + await expect(page.getByText("Reservations")).toBeVisible(); + // The trigger renders for a user holding operation-reservations:create. + await expect(page.getByRole("button", { name: "New Reservation" })).toBeVisible(); + await page.getByRole("button", { name: "New Reservation" }).click(); + + // Core fields are present. + await expect(page.getByLabel("Reservation key")).toBeVisible(); + await expect(page.getByLabel("Operation id")).toBeVisible(); + // Logistics-qualified users get an enabled Vehicles + Cargo + Budget section. + await expect(page.getByText("Vehicles (optional)")).toBeVisible(); + await expect(page.getByText("Cargo lines")).toBeVisible(); + await expect(page.getByText("Budget account (optional)")).toBeVisible(); + }); + + test("submitting the dialog with the required fields missing shows the client error", async ({ + page, + }) => { + await page.goto("/login"); + await page.getByLabel("Username").fill("dev"); + await page.getByLabel("Password").fill("Test123"); + await page.getByRole("button", { name: "Log in" }).click(); + + await page.goto("/operations/reservations"); + await page.getByRole("button", { name: "New Reservation" }).click(); + + // Empty submit -> client pre-validation surfaces the required-field errors. + await page.getByRole("button", { name: "Reserve assets" }).click(); + await expect( + page.getByRole("alert").filter({ hasText: "A reservation key is required." }), + ).toBeVisible(); + }); + + test("the list renders and either a reserved row or the empty state is present", async ({ + page, + }) => { + await page.goto("/login"); + await page.getByLabel("Username").fill("dev"); + await page.getByLabel("Password").fill("Test123"); + await page.getByRole("button", { name: "Log in" }).click(); + + await page.goto("/operations/reservations"); + // Either a reserved row (with its Cancel control) or the empty state renders. + await expect( + page.getByText("No reservations match this view."), + ).toBeVisible(); + }); +}); diff --git a/tests/int/operation-reservation-options.int.spec.ts b/tests/int/operation-reservation-options.int.spec.ts new file mode 100644 index 0000000..7573869 --- /dev/null +++ b/tests/int/operation-reservation-options.int.spec.ts @@ -0,0 +1,449 @@ +import { getPayload, Payload } from "payload"; +import config from "@/payload.config"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { + GameStructure, + GameVehicle, + OperationReservation, + Resource, + Structure, + User, +} from "@/payload-types"; +import { listReservationOptions } from "@/lib/operations/reservationOptions"; +import { createAccount } from "@/lib/banking"; +import { storedAmount } from "@/lib/base/storage"; + +let payload: Payload; + +const RUN = `res-options-${Date.now().toString(36)}`; + +// --------------------------------------------------------------------------- +// Fixture tracking for cleanup +// --------------------------------------------------------------------------- +const reservationIds: number[] = []; +const userIds: number[] = []; +const roleIds: number[] = []; +const accountIds: number[] = []; +const gameStructureIds: number[] = []; +const blueprintIds: number[] = []; +const gameVehicleIds: number[] = []; +const vehicleBlueprintIds: number[] = []; +const resourceIds: number[] = []; +const missionIds: number[] = []; +const campaignIds: number[] = []; +const mapIds: number[] = []; + +let commandUser: User; +let plainUser: User; +let mapId: number; +let campaignId: number; +let resourceAId: number; +let resourceBId: number; +let normalBlueprintId: number; +let vehicleBlueprintId: number; + +const LEXICAL_EMPTY = { + root: { + children: [{ text: "" }], + direction: null, + format: "" as const, + indent: 0, + type: "text", + version: 1, + }, +}; + +function relId(value: unknown): number { + return typeof value === "object" && value !== null + ? (value as { id: number }).id + : (value as number); +} + +async function makeUser(label: string, roleDocIds?: number[]): Promise { + const user = (await payload.create({ + collection: "users", + data: { + username: `${RUN}-${label}-${Math.random().toString(36).slice(2, 8)}`, + discordUsername: `${RUN}-${label}-discord`, + displayName: `${RUN} ${label}`, + steamId: `${RUN}-steam-${label}`, + password: "Test1234", + roles: ["user"], + ...(roleDocIds && roleDocIds.length > 0 ? { roleDocs: roleDocIds } : {}), + }, + overrideAccess: true, + depth: 0, + })) as unknown as User; + userIds.push(user.id); + return user; +} + +async function makeMission( + status: "Ready" | "Scheduled" | "Completed" | "Cancelled", + visibility: "unit" | "leadership", +): Promise { + const mission = await payload.create({ + collection: "missions", + data: { + name: `${RUN} ${status} ${visibility}`, + codeName: `${RUN}-${status}-${visibility}`, + summary: "Options test mission", + operationType: "main", + classification: { + map: mapId, + missionType: "PvE", + campaign: campaignId, + startDateTime: new Date(Date.now() + 86_400_000).toISOString(), + estimatedDuration: 60, + }, + ownershipAndStatus: { + authors: [commandUser.id], + status, + visibility, + }, + missionRoles: { maxPlayers: 16 }, + gameDetails: { serverDetails: { serverIp: "127.0.0.1", serverPort: 2302 } }, + briefing: [], + }, + overrideAccess: true, + depth: 0, + }); + missionIds.push(mission.id); + return mission.id; +} + +async function makeOrigin(stock: { resourceId: number; amount: number }[]): Promise { + const site = await payload.create({ + collection: "game-structures", + data: { + name: `${RUN} origin ${gameStructureIds.length}`, + type: normalBlueprintId, + map: mapId, + coordinates: [100 + gameStructureIds.length, 100], + constructionStatus: "complete", + storedResources: stock.map((s, i) => ({ + resource: s.resourceId, + amount: s.amount, + gridX: 0, + gridY: i, + rotated: false, + })), + }, + overrideAccess: true, + depth: 0, + }); + gameStructureIds.push(site.id); + return site.id; +} + +async function makeVehicle(status: "idle" | "assigned"): Promise { + const vehicle = await payload.create({ + collection: "game-vehicles", + data: { + name: `${RUN} vic ${gameVehicleIds.length}`, + type: vehicleBlueprintId, + deployedAt: gameStructureIds[0], + status, + currentFuel: 800, + currentHealth: 100, + }, + overrideAccess: true, + depth: 0, + }); + gameVehicleIds.push(vehicle.id); + return vehicle.id; +} + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- +beforeAll(async () => { + const payloadConfig = await config; + payload = await getPayload({ config: payloadConfig }); + + const superRole = await payload.create({ + collection: "roles", + data: { name: `${RUN} Command`, slug: `${RUN}-command`, isSuperuser: true }, + overrideAccess: true, + depth: 0, + }); + roleIds.push(superRole.id); + + commandUser = await makeUser("command", [superRole.id]); + plainUser = await makeUser("plain"); + + const map = await payload.create({ + collection: "maps", + data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" }, + overrideAccess: true, + depth: 0, + }); + mapId = map.id; + mapIds.push(map.id); + + const campaign = await payload.create({ + collection: "campaigns", + data: { + name: `${RUN} Campaign`, + summary: "Options test campaign", + status: "concept", + campaignMode: "custom", + }, + overrideAccess: true, + depth: 0, + }); + campaignId = campaign.id; + campaignIds.push(campaign.id); + + const resourceA = await payload.create({ + collection: "resources", + data: { + name: `${RUN} Alpha`, + codeName: `${RUN}-alpha`, + approvalStatus: "in_progress", + type: "physical", + baseValue: 1, + rarity: "common", + unitOfMeasure: "kg", + massPerUnit: 1, + gridWidth: 1, + gridHeight: 1, + }, + overrideAccess: true, + depth: 0, + }); + resourceAId = resourceA.id; + resourceIds.push(resourceA.id); + + const resourceB = await payload.create({ + collection: "resources", + data: { + name: `${RUN} Beta`, + codeName: `${RUN}-beta`, + approvalStatus: "in_progress", + type: "physical", + baseValue: 1, + rarity: "common", + unitOfMeasure: "kg", + massPerUnit: 1, + gridWidth: 1, + gridHeight: 1, + }, + overrideAccess: true, + depth: 0, + }); + resourceBId = resourceB.id; + resourceIds.push(resourceB.id); + + const normalBlueprint = await payload.create({ + collection: "structures", + data: { + name: `${RUN} Depot`, + codeName: `${RUN}-depot`, + approvalStatus: "in_progress", + description: LEXICAL_EMPTY as unknown as Structure["description"], + category: "logistics", + materials: [{ resource: resourceAId, amount: 1 }], + constructionDurationMinutes: 1, + terrainType: "land", + maxHealth: 100, + }, + overrideAccess: true, + depth: 0, + }); + normalBlueprintId = normalBlueprint.id; + blueprintIds.push(normalBlueprint.id); + + const vehicleBlueprint = await payload.create({ + collection: "vehicles", + data: { + name: `${RUN} Truck`, + approvalStatus: "in_progress", + transportMode: "ground", + maxSpeedOnRoad: 60, + fuel: { fuelType: resourceAId, fuelCapacity: 1000, fuelConsumptionRate: 0.01 }, + }, + overrideAccess: true, + depth: 0, + }); + vehicleBlueprintId = vehicleBlueprint.id; + vehicleBlueprintIds.push(vehicleBlueprint.id); +}); + +afterAll(async () => { + if (!payload) return; + for (const id of reservationIds) { + await payload.delete({ collection: "operation-reservations", id, overrideAccess: true }).catch(() => {}); + } + for (const id of gameVehicleIds) { + await payload.delete({ collection: "game-vehicles", id, overrideAccess: true }).catch(() => {}); + } + for (const id of gameStructureIds) { + await payload.delete({ collection: "game-structures", id, overrideAccess: true }).catch(() => {}); + } + for (const id of vehicleBlueprintIds) { + await payload.delete({ collection: "vehicles", id, overrideAccess: true }).catch(() => {}); + } + for (const id of blueprintIds) { + await payload.delete({ collection: "structures", id, overrideAccess: true }).catch(() => {}); + } + for (const id of resourceIds) { + await payload.delete({ collection: "resources", id, overrideAccess: true }).catch(() => {}); + } + for (const id of missionIds) { + await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {}); + } + for (const id of campaignIds) { + await payload.delete({ collection: "campaigns", id, overrideAccess: true }).catch(() => {}); + } + for (const id of mapIds) { + await payload.delete({ collection: "maps", id, overrideAccess: true }).catch(() => {}); + } + + for (const id of accountIds) { + const txns = await payload.find({ + collection: "bank-transactions", + where: { or: [{ fromAccount: { equals: id } }, { toAccount: { equals: id } }] }, + limit: 100, + depth: 0, + overrideAccess: true, + }); + for (const txn of txns.docs) { + await payload.delete({ collection: "bank-transactions", id: txn.id, overrideAccess: true }).catch(() => {}); + } + await payload.delete({ collection: "bank-accounts", id, overrideAccess: true }).catch(() => {}); + } + for (const id of userIds) { + await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {}); + } + for (const id of roleIds) { + await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {}); + } +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe("listReservationOptions", () => { + it("returns only Ready/Scheduled unit-visible missions, sorted by start", async () => { + const scheduledId = await makeMission("Scheduled", "unit"); + const readyId = await makeMission("Ready", "unit"); + const completedId = await makeMission("Completed", "unit"); + const leadershipId = await makeMission("Scheduled", "leadership"); + + const options = await listReservationOptions(payload, commandUser); + const ids = options.upcomingMissions.map((m) => m.id); + + expect(ids).toContain(scheduledId); + expect(ids).toContain(readyId); + expect(ids).not.toContain(completedId); + expect(ids).not.toContain(leadershipId); + }); + + it("excludes non-idle vehicles and vehicles already reserved", async () => { + const idleId = await makeVehicle("idle"); + const assignedId = await makeVehicle("assigned"); + const reservedId = await makeVehicle("idle"); + + // Reserve the second idle vehicle via an active (reserved) reservation row. + const row = await payload.create({ + collection: "operation-reservations", + data: { + reservationKey: `${RUN}-reserved-veh`, + operationId: `${RUN}-op`, + status: "reserved", + vehicles: [{ vehicle: reservedId }], + createdBy: commandUser.id, + }, + overrideAccess: true, + depth: 0, + }); + reservationIds.push(row.id); + + const options = await listReservationOptions(payload, commandUser); + const vehicleIds = options.vehicles.map((v) => v.id); + + expect(vehicleIds).toContain(idleId); + expect(vehicleIds).not.toContain(assignedId); + expect(vehicleIds).not.toContain(reservedId); + }); + + it("reflects storedAmount per origin in stockByOrigin", async () => { + const originId = await makeOrigin([ + { resourceId: resourceAId, amount: 100 }, + { resourceId: resourceBId, amount: 50 }, + ]); + + const options = await listReservationOptions(payload, commandUser); + const stock = options.stockByOrigin[String(originId)]; + + expect(stock).toHaveLength(2); + + const origin = (await payload + .findByID({ collection: "game-structures", id: originId, depth: 0, overrideAccess: true }) + .catch(() => null)) as unknown as (GameStructure & { + storedResources?: unknown[]; + voidStorage?: unknown[]; + }) | null; + const entries = [ + ...(origin?.storedResources ?? []), + ...(origin?.voidStorage ?? []), + ] as Array<{ resource: { id: number }; amount: number }>; + + const amountA = storedAmount(entries as never, resourceAId); + const amountB = storedAmount(entries as never, resourceBId); + expect(amountA).toBe(100); + expect(amountB).toBe(50); + + const lineA = stock!.find((s) => s.resourceId === resourceAId); + const lineB = stock!.find((s) => s.resourceId === resourceBId); + expect(lineA?.amount).toBe(amountA); + expect(lineB?.amount).toBe(amountB); + expect(lineA?.resourceName).toBeTruthy(); + expect(lineB?.resourceName).toBeTruthy(); + }); + + it("caps the roster pool at 200 users", async () => { + for (let i = 0; i < 205; i++) { + await makeUser(`roster${i}`); + } + const options = await listReservationOptions(payload, commandUser); + expect(options.rosterUsers.length).toBe(200); + // Every entry carries a display name and id; nothing else leaks. + for (const user of options.rosterUsers) { + expect(typeof user.id).toBe("number"); + expect(user.displayName.length).toBeGreaterThan(0); + } + }, 120000); + + it("scopes budget accounts to treasury + this user's personal accounts, no balances", async () => { + const treasury = await createAccount(payload, { name: `${RUN} Treasury`, accountType: "treasury" }); + accountIds.push(treasury.id); + const commandPersonal = await createAccount(payload, { + name: `${RUN} Command Personal`, + accountType: "personal", + ownerUserId: commandUser.id, + }); + accountIds.push(commandPersonal.id); + const plainPersonal = await createAccount(payload, { + name: `${RUN} Plain Personal`, + accountType: "personal", + ownerUserId: plainUser.id, + }); + accountIds.push(plainPersonal.id); + + const options = await listReservationOptions(payload, commandUser); + const ids = options.budgetAccounts.map((a) => a.id); + + expect(ids).toContain(treasury.id); + expect(ids).toContain(commandPersonal.id); + // Another user's personal account is out of scope. + expect(ids).not.toContain(plainPersonal.id); + + // Only id, name, and type are exposed - balances never leak. + for (const account of options.budgetAccounts) { + const keys = Object.keys(account).sort(); + expect(keys).toEqual(["id", "name", "type"]); + } + }); +});