Satisfies the Next 16 rule that page files only export page fields: getSupplyBoxDetail/getSupplyBoxList and getReservationList/getReservationDetail move to reservations/surface.ts and pages import from it.
281 lines
9.7 KiB
TypeScript
281 lines
9.7 KiB
TypeScript
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.
|
|
*/
|
|
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>
|
|
);
|
|
}
|