feat(logistics): supply box management surface
This commit is contained in:
parent
f895f75317
commit
679a0f7879
7 changed files with 1086 additions and 0 deletions
281
src/app/(frontend)/logistics/supply-boxes/[id]/page.tsx
Normal file
281
src/app/(frontend)/logistics/supply-boxes/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||
import {
|
||||
LedgerRow,
|
||||
NoticeState,
|
||||
PageShell,
|
||||
ProvenanceChip,
|
||||
STATUS_TONE_CLASSES,
|
||||
type ReadoutCell,
|
||||
type StatusTone,
|
||||
} from "@/components/frontend/operations";
|
||||
import { SupplyBoxActions } from "@/components/frontend/logistics/SupplyBoxActions";
|
||||
import type { ArmaCommand, SupplyBox } from "@/payload-types";
|
||||
import type { SurfaceUser } from "@/lib/operations/surface";
|
||||
|
||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||
|
||||
export const metadata = {
|
||||
title: "Supply Box Detail: Polaris Task Force",
|
||||
};
|
||||
|
||||
function relationId(value: unknown): number | null {
|
||||
if (typeof value === "number") return value;
|
||||
if (value !== null && typeof value === "object" && "id" in (value as { id: unknown })) {
|
||||
const id = (value as { id: unknown }).id;
|
||||
return typeof id === "number" ? id : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function locationFromJson(value: unknown): { x: number; y: number; z: number } | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
const { x, y, z } = value as { x?: unknown; y?: unknown; z?: unknown };
|
||||
if (typeof x !== "number" || typeof y !== "number" || typeof z !== "number") return null;
|
||||
return { x, y, z };
|
||||
}
|
||||
|
||||
function locationLabel(value: unknown): string {
|
||||
const loc = locationFromJson(value);
|
||||
return loc === null ? "n/a" : `${loc.x}, ${loc.y}, ${loc.z}`;
|
||||
}
|
||||
|
||||
function commandTone(status: string): StatusTone {
|
||||
if (status === "succeeded") return "success";
|
||||
if (status === "failed") return "danger";
|
||||
if (status === "delivered") return "info";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
export interface SupplyBoxDetailData {
|
||||
box: SupplyBox | null;
|
||||
capability: boolean;
|
||||
commands: ArmaCommand[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Full detail for a single supply box plus its recent queued commands, fetched
|
||||
* through collection access control (default access + the explicit session
|
||||
* user, never overrideAccess). Returns null for an unknown id so the page can
|
||||
* render a not-found notice.
|
||||
*/
|
||||
export async function getSupplyBoxDetail(
|
||||
payload: PayloadType,
|
||||
user: SurfaceUser,
|
||||
id: string,
|
||||
): Promise<SupplyBoxDetailData> {
|
||||
const qualified = await hasLogisticsQualification(payload, user);
|
||||
if (!qualified) return { box: null, capability: false, commands: [] };
|
||||
|
||||
let box: SupplyBox | null = null;
|
||||
try {
|
||||
box = await payload.findByID({
|
||||
collection: "supply-boxes",
|
||||
id: Number(id),
|
||||
depth: 2,
|
||||
user: user ?? undefined,
|
||||
});
|
||||
} catch {
|
||||
box = null;
|
||||
}
|
||||
if (box === null) return { box: null, capability: true, commands: [] };
|
||||
|
||||
const serverId = relationId(box.server);
|
||||
const commands =
|
||||
serverId === null
|
||||
? []
|
||||
: (
|
||||
await payload.find({
|
||||
collection: "arma-commands",
|
||||
where: { server: { equals: serverId } },
|
||||
sort: "-createdAt",
|
||||
limit: 50,
|
||||
depth: 0,
|
||||
user: user ?? undefined,
|
||||
})
|
||||
).docs.filter((cmd) => Array.isArray(cmd.payload) && cmd.payload[0] === box.boxId);
|
||||
|
||||
return { box, capability: true, commands };
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section data-slot="supply-box-section" className="flex flex-col gap-2">
|
||||
<h2 className="font-mono text-[9px] uppercase tracking-widest text-white/60">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function kvRow(label: string, value: React.ReactNode) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 border border-white/10 px-2 py-1.5">
|
||||
<span className="shrink-0 font-mono text-[9px] uppercase tracking-widest text-white/50">{label}</span>
|
||||
<span className="min-w-0 truncate text-right font-mono text-xs text-white/80">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function SupplyBoxDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
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 { box, capability, commands } = await getSupplyBoxDetail(payload, surfaceUser, id);
|
||||
|
||||
if (capability === false) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||
<PageShell title="Supply Box" meta="LOGISTICS // CRATE SYNC">
|
||||
<NoticeState
|
||||
variant="error"
|
||||
message="You do not have the Logistics qualification to manage supply boxes."
|
||||
hint="Request the Logistics qualification from unit command."
|
||||
/>
|
||||
</PageShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (box === null) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||
<PageShell title="Supply Box" meta="LOGISTICS // CRATE SYNC">
|
||||
<NoticeState
|
||||
variant="error"
|
||||
message="Supply box not found."
|
||||
hint="The supply box id is unknown or you lack permission to read it."
|
||||
action={
|
||||
<Link
|
||||
href="/logistics/supply-boxes"
|
||||
className="mt-2 border border-white/15 px-3 py-1 font-mono text-[10px] uppercase tracking-widest text-white/80 hover:border-white/30 hover:text-white"
|
||||
>
|
||||
Back to supply boxes
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</PageShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const contents = Array.isArray(box.contents) ? box.contents : [];
|
||||
const itemCount = contents.reduce<number>((sum, line) => {
|
||||
if (typeof line !== "object" || line === null) return sum;
|
||||
const count = (line as { count?: unknown }).count;
|
||||
return sum + (typeof count === "number" ? count : 0);
|
||||
}, 0);
|
||||
const sourceEventId = relationId(box.sourceEvent);
|
||||
|
||||
const readoutCells: ReadoutCell[] = [
|
||||
{ label: "Class", value: box.boxClass ?? "n/a" },
|
||||
{ label: "Status", value: box.status },
|
||||
{ label: "Items", value: String(itemCount) },
|
||||
{ label: "Last Sync", value: box.lastSyncedAt },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||
<PageShell title={box.boxId} meta="SUPPLY BOX // DETAIL">
|
||||
<Link
|
||||
href="/logistics/supply-boxes"
|
||||
className="font-mono text-[10px] uppercase tracking-widest text-white/60 underline-offset-4 hover:underline"
|
||||
>
|
||||
Back to supply boxes
|
||||
</Link>
|
||||
|
||||
<LedgerRow cells={readoutCells} />
|
||||
|
||||
{sourceEventId !== null ? (
|
||||
<ProvenanceChip
|
||||
label={`RAW EVENT #${sourceEventId}`}
|
||||
href={`/admin/collections/arma-sync-events/${sourceEventId}`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Section title="Contents">
|
||||
{contents.length === 0 ? (
|
||||
<NoticeState variant="empty" message="This crate is empty." />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{contents.map((line, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 truncate font-mono text-xs text-white/80">
|
||||
{(line as { class?: unknown }).class as string}
|
||||
</span>
|
||||
<span className="font-mono text-xs tabular-nums text-white/70">
|
||||
{(line as { count?: unknown }).count as number}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Location">
|
||||
<div className="flex flex-col gap-1">
|
||||
{kvRow("Position", locationLabel(box.location))}
|
||||
{kvRow("Session", box.sessionId)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Command Queue">
|
||||
<SupplyBoxActions
|
||||
boxId={box.boxId}
|
||||
boxClass={box.boxClass ?? null}
|
||||
location={locationFromJson(box.location)}
|
||||
contents={contents.map((line) => ({
|
||||
class: (line as { class?: unknown }).class as string,
|
||||
count: (line as { count?: unknown }).count as number,
|
||||
}))}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Recent Commands">
|
||||
{commands.length === 0 ? (
|
||||
<NoticeState variant="empty" message="No commands have been queued for this crate." />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{commands.map((cmd) => (
|
||||
<li
|
||||
key={cmd.id}
|
||||
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 truncate font-mono text-xs text-white/80">{cmd.type}</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<span
|
||||
className={`border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[commandTone(cmd.status)]}`}
|
||||
>
|
||||
{cmd.status}
|
||||
</span>
|
||||
{cmd.error ? (
|
||||
<span className="max-w-40 truncate font-mono text-[10px] text-rose-300">
|
||||
{cmd.error}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
</PageShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/app/(frontend)/logistics/supply-boxes/actions.ts
Normal file
124
src/app/(frontend)/logistics/supply-boxes/actions.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"use server";
|
||||
|
||||
import { randomBytes } from "crypto";
|
||||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||
|
||||
export interface ActionResult<T> {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
export type CrateCommand = "spawn" | "apply" | "despawn";
|
||||
|
||||
async function authenticate() {
|
||||
const { headers } = await import("next/headers");
|
||||
const payload = await getPayload({ config });
|
||||
const { user: authUser } = await payload.auth({
|
||||
headers: await headers(),
|
||||
canSetHeaders: false,
|
||||
});
|
||||
return { payload, user: isPayloadUser(authUser) ? authUser : null };
|
||||
}
|
||||
|
||||
function relId(value: unknown): number | null {
|
||||
if (typeof value === "number") return value;
|
||||
if (value !== null && typeof value === "object" && "id" in (value as { id: unknown })) {
|
||||
const id = (value as { id: unknown }).id;
|
||||
return typeof id === "number" ? id : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the crate command payload against the crate-sync contract
|
||||
* (docs/arma-bridge/crate-sync.md). The command envelope is built web-side by
|
||||
* the pull route; the action only stores the row, so the payload must already
|
||||
* match the shape the Arma bridge expects.
|
||||
*/
|
||||
function validateCratePayload(command: CrateCommand, payloadArgs: unknown[]): boolean {
|
||||
if (command === "spawn") {
|
||||
if (payloadArgs.length !== 4) return false;
|
||||
const [boxClass, x, y, z] = payloadArgs;
|
||||
return (
|
||||
typeof boxClass === "string" &&
|
||||
typeof x === "number" &&
|
||||
typeof y === "number" &&
|
||||
typeof z === "number"
|
||||
);
|
||||
}
|
||||
if (command === "apply") {
|
||||
if (payloadArgs.length !== 1) return false;
|
||||
const contents = payloadArgs[0];
|
||||
if (!Array.isArray(contents)) return false;
|
||||
return contents.every(
|
||||
(line) =>
|
||||
Array.isArray(line) &&
|
||||
line.length === 2 &&
|
||||
typeof line[0] === "string" &&
|
||||
typeof line[1] === "number",
|
||||
);
|
||||
}
|
||||
// despawn
|
||||
return payloadArgs.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues an outbound crate command for a supply box. The command row is
|
||||
* created with overrideAccess because arma-commands denies direct writes; the
|
||||
* pull route picks it up and builds the [commandId, commandType, payload]
|
||||
* envelope for the game server.
|
||||
*/
|
||||
export async function queueCrateCommand(
|
||||
boxId: string,
|
||||
command: CrateCommand,
|
||||
payloadArgs: unknown[],
|
||||
): Promise<ActionResult<{ commandId: string; status: string }>> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!user) return { success: false, error: "Authentication required." };
|
||||
|
||||
const qualified = await hasLogisticsQualification(payload, user);
|
||||
if (!qualified) return { success: false, error: "forbidden" };
|
||||
|
||||
if (!validateCratePayload(command, payloadArgs)) {
|
||||
return { success: false, error: `Invalid payload for crate.${command}.` };
|
||||
}
|
||||
|
||||
const found = await payload.find({
|
||||
collection: "supply-boxes",
|
||||
where: { boxId: { equals: boxId } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
user: user ?? undefined,
|
||||
});
|
||||
const box = found.docs[0];
|
||||
if (!box) return { success: false, error: "Supply box not found." };
|
||||
|
||||
const serverId = relId(box.server);
|
||||
if (serverId === null) {
|
||||
return { success: false, error: "Supply box has no owning server." };
|
||||
}
|
||||
|
||||
const commandId = `crate-${randomBytes(12).toString("hex")}`;
|
||||
const created = await payload.create({
|
||||
collection: "arma-commands",
|
||||
data: {
|
||||
commandId,
|
||||
server: serverId,
|
||||
type: `crate.${command}`,
|
||||
payload: [boxId, ...payloadArgs],
|
||||
status: "queued",
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
return { success: true, data: { commandId: created.commandId, status: created.status } };
|
||||
} catch (e) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
212
src/app/(frontend)/logistics/supply-boxes/page.tsx
Normal file
212
src/app/(frontend)/logistics/supply-boxes/page.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
import { headers as nextHeaders } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||
import {
|
||||
LedgerRow,
|
||||
NoticeState,
|
||||
OperationsRealtimeRefresher,
|
||||
PageShell,
|
||||
STATUS_TONE_CLASSES,
|
||||
type ReadoutCell,
|
||||
type StatusTone,
|
||||
} from "@/components/frontend/operations";
|
||||
import type { SurfaceUser } from "@/lib/operations/surface";
|
||||
|
||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||
|
||||
export const metadata = {
|
||||
title: "Supply Boxes: Polaris Task Force",
|
||||
};
|
||||
|
||||
const SUPPLY_BOX_PAGE_SIZE = 50;
|
||||
|
||||
function relationId(value: unknown): number | null {
|
||||
if (typeof value === "number") return value;
|
||||
if (value !== null && typeof value === "object" && "id" in (value as { id: unknown })) {
|
||||
const id = (value as { id: unknown }).id;
|
||||
return typeof id === "number" ? id : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function boxStatusTone(status: string): StatusTone {
|
||||
if (status === "active") return "success";
|
||||
if (status === "deleted") return "danger";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function syncTone(iso: string): StatusTone {
|
||||
const ageMs = Date.now() - new Date(iso).getTime();
|
||||
if (ageMs < 2 * 60_000) return "success";
|
||||
if (ageMs < 10 * 60_000) return "warning";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
function syncAgeLabel(iso: string): string {
|
||||
const ageMs = Date.now() - new Date(iso).getTime();
|
||||
const minutes = Math.floor(ageMs / 60_000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
function boxItemCount(contents: unknown): number {
|
||||
if (!Array.isArray(contents)) return 0;
|
||||
return contents.reduce((sum, line) => {
|
||||
if (typeof line !== "object" || line === null) return sum;
|
||||
const count = (line as { count?: unknown }).count;
|
||||
return sum + (typeof count === "number" ? count : 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function boxLocationLabel(location: unknown): string {
|
||||
if (typeof location !== "object" || location === null) return "n/a";
|
||||
const { x, y, z } = location as { x?: unknown; y?: unknown; z?: unknown };
|
||||
if (typeof x !== "number" || typeof y !== "number" || typeof z !== "number") return "n/a";
|
||||
return `${x}, ${y}, ${z}`;
|
||||
}
|
||||
|
||||
export interface SupplyBoxRow {
|
||||
id: number;
|
||||
boxId: string;
|
||||
boxClass: string | null;
|
||||
itemCount: number;
|
||||
locationLabel: string;
|
||||
lastSyncedAt: string;
|
||||
status: string;
|
||||
serverName: string | null;
|
||||
}
|
||||
|
||||
export interface SupplyBoxListData {
|
||||
rows: SupplyBoxRow[];
|
||||
capability: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* List of supply boxes, fetched through collection access control (default
|
||||
* access + the explicit session user, never overrideAccess). A server restart
|
||||
* mints a fresh sessionId, so rows from older generations are stale snapshots;
|
||||
* only the newest sessionId per server is kept.
|
||||
*/
|
||||
export async function getSupplyBoxList(
|
||||
payload: PayloadType,
|
||||
user: SurfaceUser,
|
||||
): Promise<SupplyBoxListData> {
|
||||
const qualified = await hasLogisticsQualification(payload, user);
|
||||
if (!qualified) return { rows: [], capability: false };
|
||||
|
||||
const found = await payload.find({
|
||||
collection: "supply-boxes",
|
||||
sort: "-lastSyncedAt",
|
||||
limit: SUPPLY_BOX_PAGE_SIZE,
|
||||
depth: 1,
|
||||
user: user ?? undefined,
|
||||
});
|
||||
|
||||
const newestSessionByServer = new Map<number, string>();
|
||||
for (const doc of found.docs) {
|
||||
const serverId = relationId(doc.server);
|
||||
if (serverId === null) continue;
|
||||
const current = newestSessionByServer.get(serverId);
|
||||
if (current === undefined || doc.sessionId > current) {
|
||||
newestSessionByServer.set(serverId, doc.sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: SupplyBoxRow[] = found.docs
|
||||
.filter((doc) => {
|
||||
const serverId = relationId(doc.server);
|
||||
return serverId !== null && newestSessionByServer.get(serverId) === doc.sessionId;
|
||||
})
|
||||
.map((doc) => ({
|
||||
id: doc.id,
|
||||
boxId: doc.boxId,
|
||||
boxClass: doc.boxClass ?? null,
|
||||
itemCount: boxItemCount(doc.contents),
|
||||
locationLabel: boxLocationLabel(doc.location),
|
||||
lastSyncedAt: doc.lastSyncedAt,
|
||||
status: doc.status,
|
||||
serverName: typeof doc.server === "object" && doc.server !== null ? doc.server.name : null,
|
||||
}));
|
||||
|
||||
return { rows, capability: true };
|
||||
}
|
||||
|
||||
export default async function SupplyBoxesPage() {
|
||||
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 data = await getSupplyBoxList(payload, surfaceUser);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||
<PageShell title="Supply Boxes" meta="LOGISTICS // CRATE SYNC">
|
||||
<p className="max-w-2xl text-sm text-white/70">
|
||||
Crate inventory synced from the game server through the Arma bridge.
|
||||
Rows show the newest generation per server; older sessions are filtered out.
|
||||
</p>
|
||||
</PageShell>
|
||||
|
||||
<OperationsRealtimeRefresher />
|
||||
|
||||
{data.capability === false ? (
|
||||
<NoticeState
|
||||
variant="error"
|
||||
message="You do not have the Logistics qualification to manage supply boxes."
|
||||
hint="Request the Logistics qualification from unit command."
|
||||
/>
|
||||
) : data.rows.length === 0 ? (
|
||||
<NoticeState
|
||||
variant="empty"
|
||||
message="No supply boxes have been registered yet."
|
||||
hint="Boxes appear here once the game server registers them through the Arma bridge."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4" data-slot="supply-boxes-list">
|
||||
{data.rows.map((row) => {
|
||||
const cells: ReadoutCell[] = [
|
||||
{ label: "Class", value: row.boxClass ?? "n/a" },
|
||||
{ label: "Items", value: String(row.itemCount) },
|
||||
{ label: "Location", value: row.locationLabel },
|
||||
{ label: "Sync", value: syncAgeLabel(row.lastSyncedAt) },
|
||||
{ label: "Server", value: row.serverName ?? "n/a" },
|
||||
];
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
data-slot="supply-box-row"
|
||||
className="flex flex-col gap-2 rounded border border-white/10 bg-card/20"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 px-2 py-1.5">
|
||||
<Link
|
||||
href={`/logistics/supply-boxes/${row.id}`}
|
||||
data-slot="supply-box-id"
|
||||
className="min-w-0 truncate font-mono text-xs text-white/85 underline-offset-4 hover:underline"
|
||||
>
|
||||
{row.boxId}
|
||||
</Link>
|
||||
<span
|
||||
data-slot="supply-box-status"
|
||||
className={`shrink-0 border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[boxStatusTone(row.status)]}`}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
</div>
|
||||
<LedgerRow cells={cells} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -123,6 +123,11 @@ export const sidebarData = {
|
|||
url: "/logistics/game-vehicles",
|
||||
icon: Car,
|
||||
},
|
||||
{
|
||||
title: "Supply Boxes",
|
||||
url: "/logistics/supply-boxes",
|
||||
icon: Boxes,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
207
src/components/frontend/logistics/SupplyBoxActions.tsx
Normal file
207
src/components/frontend/logistics/SupplyBoxActions.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
queueCrateCommand,
|
||||
type ActionResult,
|
||||
type CrateCommand,
|
||||
} from "@/app/(frontend)/logistics/supply-boxes/actions";
|
||||
|
||||
export interface SupplyBoxContentLine {
|
||||
class: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface SupplyBoxActionsProps {
|
||||
boxId: string;
|
||||
boxClass: string | null;
|
||||
location: { x: number; y: number; z: number } | null;
|
||||
contents: SupplyBoxContentLine[];
|
||||
}
|
||||
|
||||
type PendingAction = CrateCommand | null;
|
||||
|
||||
export function SupplyBoxActions({ boxId, boxClass, location, contents }: SupplyBoxActionsProps) {
|
||||
const [lines, setLines] = useState<SupplyBoxContentLine[]>(contents);
|
||||
const [confirm, setConfirm] = useState<PendingAction>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
function updateLine(index: number, field: "class" | "count", value: string) {
|
||||
setLines((prev) =>
|
||||
prev.map((line, i) =>
|
||||
i === index
|
||||
? field === "class"
|
||||
? { ...line, class: value }
|
||||
: { ...line, count: Number.parseInt(value, 10) || 0 }
|
||||
: line,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
setLines((prev) => [...prev, { class: "", count: 1 }]);
|
||||
}
|
||||
|
||||
function removeLine(index: number) {
|
||||
setLines((prev) => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function openConfirm(action: Exclude<PendingAction, null>) {
|
||||
setError(null);
|
||||
setConfirm(action);
|
||||
}
|
||||
|
||||
async function dispatch() {
|
||||
if (confirm === null) return;
|
||||
setError(null);
|
||||
let result: ActionResult<{ commandId: string; status: string }>;
|
||||
if (confirm === "apply") {
|
||||
const clean = lines.filter((line) => line.class.trim() !== "" && line.count > 0);
|
||||
result = await queueCrateCommand(
|
||||
boxId,
|
||||
"apply",
|
||||
[clean.map((line) => [line.class.trim(), line.count])],
|
||||
);
|
||||
} else if (confirm === "despawn") {
|
||||
result = await queueCrateCommand(boxId, "despawn", []);
|
||||
} else {
|
||||
if (boxClass === null || location === null) return;
|
||||
result = await queueCrateCommand(boxId, "spawn", [boxClass, location.x, location.y, location.z]);
|
||||
}
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Failed to queue command.");
|
||||
return;
|
||||
}
|
||||
setConfirm(null);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const applyLineCount = lines.filter((line) => line.class.trim() !== "" && line.count > 0).length;
|
||||
|
||||
return (
|
||||
<div data-slot="supply-box-actions" className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="font-mono text-[9px] uppercase tracking-widest text-white/50">
|
||||
Contents to apply
|
||||
</label>
|
||||
<div className="flex flex-col gap-1">
|
||||
{lines.map((line, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
value={line.class}
|
||||
onChange={(e) => updateLine(index, "class", e.target.value)}
|
||||
placeholder="itemClass"
|
||||
className="min-w-0 flex-1 border border-white/15 bg-black/40 px-2 py-1 font-mono text-xs text-white/80 placeholder:text-white/30"
|
||||
/>
|
||||
<input
|
||||
value={String(line.count)}
|
||||
onChange={(e) => updateLine(index, "count", e.target.value)}
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-20 border border-white/15 bg-black/40 px-2 py-1 font-mono text-xs text-white/80"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeLine(index)}
|
||||
className="border border-white/15 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-white/60 hover:border-rose-500/40 hover:text-rose-300"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addLine}
|
||||
className="self-start border border-white/15 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-white/70 hover:border-white/30 hover:text-white"
|
||||
>
|
||||
Add line
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-slot="supply-box-apply"
|
||||
onClick={() => openConfirm("apply")}
|
||||
disabled={applyLineCount === 0}
|
||||
className="border border-emerald-500/30 bg-emerald-500/5 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-emerald-300 transition-colors hover:border-emerald-500/50 hover:text-emerald-200 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Queue apply
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="supply-box-despawn"
|
||||
onClick={() => openConfirm("despawn")}
|
||||
className="border border-rose-500/30 bg-rose-500/5 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-rose-300 transition-colors hover:border-rose-500/50 hover:text-rose-200"
|
||||
>
|
||||
Despawn
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="supply-box-respawn"
|
||||
onClick={() => openConfirm("spawn")}
|
||||
disabled={boxClass === null || location === null}
|
||||
className="border border-blue-500/30 bg-blue-500/5 px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-blue-300 transition-colors hover:border-blue-500/50 hover:text-blue-200 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Respawn
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p data-slot="supply-box-actions-error" className="min-w-0 truncate text-xs text-rose-300">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Dialog
|
||||
open={confirm !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirm(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm crate command</DialogTitle>
|
||||
<DialogDescription>
|
||||
{confirm === "apply"
|
||||
? `Queue crate.apply with ${applyLineCount} item line${applyLineCount === 1 ? "" : "s"} to the game server?`
|
||||
: confirm === "despawn"
|
||||
? "Remove this crate from the game server? The stored row stays as a deleted snapshot."
|
||||
: "Spawn this crate at its last known location?"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirm(null)}
|
||||
className="border border-white/15 px-3 py-1 font-mono text-[10px] uppercase tracking-widest text-white/70 hover:border-white/30 hover:text-white"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="supply-box-confirm"
|
||||
onClick={() => startTransition(dispatch)}
|
||||
disabled={isPending}
|
||||
className="border border-emerald-500/30 bg-emerald-500/5 px-3 py-1 font-mono text-[10px] uppercase tracking-widest text-emerald-300 hover:border-emerald-500/50 hover:text-emerald-200 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Queuing..." : "Confirm"}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
tests/e2e/supply-boxes.e2e.spec.ts
Normal file
31
tests/e2e/supply-boxes.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Supply box management surface.
|
||||
*
|
||||
* 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 list is server-gated: guests get the landing page; only logged-in users
|
||||
* see the supply box list (logistics-qualified users additionally get the
|
||||
* crate command controls on the detail page).
|
||||
*/
|
||||
test.describe("Supply box management surface", () => {
|
||||
test("guests never see the supply box list", async ({ page }) => {
|
||||
await page.goto("/logistics/supply-boxes");
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Polaris Task Force");
|
||||
});
|
||||
|
||||
test("an authed user sees the supply box list page", 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("/logistics/supply-boxes");
|
||||
await expect(page.getByText("Supply Boxes")).toBeVisible();
|
||||
// Either a registered box row or the empty state renders.
|
||||
await expect(
|
||||
page.getByText("No supply boxes have been registered yet."),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
226
tests/int/supply-box-actions.int.spec.ts
Normal file
226
tests/int/supply-box-actions.int.spec.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest";
|
||||
import type { ArmaCommand, SupplyBox, User } from "@/payload-types";
|
||||
import { queueCrateCommand } from "@/app/(frontend)/logistics/supply-boxes/actions";
|
||||
|
||||
// Mock next/headers so the action's authenticate() can run outside a request.
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: async () => new Headers(),
|
||||
}));
|
||||
|
||||
let payload: Payload;
|
||||
let authSpy: MockInstance;
|
||||
|
||||
const RUN = `sbx-${Date.now().toString(36)}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture tracking for cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
const userIds: number[] = [];
|
||||
const roleIds: number[] = [];
|
||||
const syncEventIds: number[] = [];
|
||||
const boxIds: number[] = [];
|
||||
const commandIds: number[] = [];
|
||||
let serverId: number;
|
||||
|
||||
let commandUser: User;
|
||||
let plainUser: User;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
async function makeUser(label: string, roleDocIds?: number[]): Promise<User> {
|
||||
const user = (await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
username: `${RUN}-${label}`,
|
||||
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;
|
||||
}
|
||||
|
||||
// Creates a supply box directly (the crate-sync handler is covered by
|
||||
// supply-box-sync.int.spec.ts) with a real arma-sync-events row as the
|
||||
// required sourceEvent provenance anchor.
|
||||
async function makeBox(boxId: string): Promise<SupplyBox> {
|
||||
const raw = await payload.create({
|
||||
collection: "arma-sync-events",
|
||||
data: {
|
||||
eventId: `${RUN}-evt-${boxId}`,
|
||||
server: serverId,
|
||||
type: "fixture",
|
||||
occurredAt: new Date().toISOString(),
|
||||
payload: [],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
syncEventIds.push(raw.id);
|
||||
const box = (await payload.create({
|
||||
collection: "supply-boxes",
|
||||
data: {
|
||||
boxId,
|
||||
sessionId: RUN,
|
||||
server: serverId,
|
||||
boxClass: "Box_NATO_Wps_F",
|
||||
displayName: "Ammo Crate",
|
||||
location: { x: 100, y: 200, z: 0 },
|
||||
contents: [{ class: "Item_1", count: 10 }],
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
status: "active",
|
||||
sourceEvent: raw.id,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as SupplyBox;
|
||||
boxIds.push(box.id);
|
||||
return box;
|
||||
}
|
||||
|
||||
async function findCommand(commandId: string): Promise<ArmaCommand | null> {
|
||||
const found = await payload.find({
|
||||
collection: "arma-commands",
|
||||
where: { commandId: { equals: commandId } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
return (found.docs[0] as ArmaCommand | undefined) ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup / teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
authSpy = vi.spyOn(payload, "auth");
|
||||
|
||||
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 server = await payload.create({
|
||||
collection: "game-servers",
|
||||
data: { serverId: `${RUN}-srv`, name: `${RUN} Server`, status: "online" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
serverId = server.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!payload) return;
|
||||
for (const id of commandIds) {
|
||||
await payload.delete({ collection: "arma-commands", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of boxIds) {
|
||||
await payload.delete({ collection: "supply-boxes", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of syncEventIds) {
|
||||
await payload.delete({ collection: "arma-sync-events", 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(() => {});
|
||||
}
|
||||
if (serverId) {
|
||||
await payload.delete({ collection: "game-servers", id: serverId, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("supply box crate command actions", () => {
|
||||
describe("queueCrateCommand", () => {
|
||||
it("queues a crate.apply command with the contract payload for a qualified user", async () => {
|
||||
authSpy.mockResolvedValue({ user: commandUser });
|
||||
const box = await makeBox(`box:${RUN}:apply`);
|
||||
|
||||
const result = await queueCrateCommand(box.boxId, "apply", [
|
||||
[
|
||||
["Item_1", 10],
|
||||
["Item_2", 5],
|
||||
],
|
||||
]);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.status).toBe("queued");
|
||||
expect(result.data?.commandId).toMatch(/^crate-[0-9a-f]{24}$/);
|
||||
|
||||
const cmd = await findCommand(result.data!.commandId);
|
||||
expect(cmd).not.toBeNull();
|
||||
expect(cmd?.type).toBe("crate.apply");
|
||||
expect(cmd?.server).toBe(serverId);
|
||||
expect(cmd?.status).toBe("queued");
|
||||
expect(cmd?.payload).toEqual([
|
||||
box.boxId,
|
||||
[
|
||||
["Item_1", 10],
|
||||
["Item_2", 5],
|
||||
],
|
||||
]);
|
||||
commandIds.push(cmd!.id);
|
||||
});
|
||||
|
||||
it("rejects a user without the logistics qualification with a forbidden error", async () => {
|
||||
authSpy.mockResolvedValue({ user: plainUser });
|
||||
const box = await makeBox(`box:${RUN}:forbidden`);
|
||||
|
||||
const result = await queueCrateCommand(box.boxId, "despawn", []);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("forbidden");
|
||||
});
|
||||
|
||||
it("maps an unknown box id to a not-found error", async () => {
|
||||
authSpy.mockResolvedValue({ user: commandUser });
|
||||
|
||||
const result = await queueCrateCommand(`box:${RUN}:missing`, "despawn", []);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("not found");
|
||||
});
|
||||
|
||||
it("mints a unique command id per queue", async () => {
|
||||
authSpy.mockResolvedValue({ user: commandUser });
|
||||
const box = await makeBox(`box:${RUN}:unique`);
|
||||
|
||||
const first = await queueCrateCommand(box.boxId, "despawn", []);
|
||||
const second = await queueCrateCommand(box.boxId, "despawn", []);
|
||||
|
||||
expect(first.success).toBe(true);
|
||||
expect(second.success).toBe(true);
|
||||
expect(first.data?.commandId).not.toBe(second.data?.commandId);
|
||||
|
||||
const firstCmd = await findCommand(first.data!.commandId);
|
||||
const secondCmd = await findCommand(second.data!.commandId);
|
||||
expect(firstCmd).not.toBeNull();
|
||||
expect(secondCmd).not.toBeNull();
|
||||
expect(firstCmd?.id).not.toBe(secondCmd?.id);
|
||||
expect(firstCmd?.payload).toEqual([box.boxId]);
|
||||
expect(secondCmd?.payload).toEqual([box.boxId]);
|
||||
commandIds.push(firstCmd!.id, secondCmd!.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue