1
0
Fork 0

fix(operations): move page data helpers to surface modules

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.
This commit is contained in:
Jason Fraley 2026-09-21 20:47:57 -04:00
parent 8c10719124
commit 5fb619a5cf
6 changed files with 207 additions and 117 deletions

View file

@ -63,7 +63,7 @@ export interface SupplyBoxDetailData {
* user, never overrideAccess). Returns null for an unknown id so the page can * user, never overrideAccess). Returns null for an unknown id so the page can
* render a not-found notice. * render a not-found notice.
*/ */
export async function getSupplyBoxDetail( async function getSupplyBoxDetail(
payload: PayloadType, payload: PayloadType,
user: SurfaceUser, user: SurfaceUser,
id: string, id: string,
@ -114,17 +114,15 @@ function Section({ title, children }: { title: string; children: React.ReactNode
function kvRow(label: string, value: React.ReactNode) { function kvRow(label: string, value: React.ReactNode) {
return ( return (
<div className="flex items-start justify-between gap-4 border border-white/10 px-2 py-1.5"> <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="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> <span className="min-w-0 truncate text-right font-mono text-xs text-white/80">{value}</span>
</div> </div>
); );
} }
export default async function SupplyBoxDetailPage({ export default async function SupplyBoxDetailPage({ params }: { params: Promise<{ id: string }> }) {
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params; const { id } = await params;
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const { user: authUser } = await payload.auth({ const { user: authUser } = await payload.auth({
@ -257,7 +255,9 @@ export default async function SupplyBoxDetailPage({
key={cmd.id} key={cmd.id}
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5" 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="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="flex shrink-0 items-center gap-2">
<span <span
className={`border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[commandTone(cmd.status)]}`} className={`border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[commandTone(cmd.status)]}`}
@ -278,4 +278,4 @@ export default async function SupplyBoxDetailPage({
</PageShell> </PageShell>
</div> </div>
); );
} }

View file

@ -92,7 +92,7 @@ export interface SupplyBoxListData {
* mints a fresh sessionId, so rows from older generations are stale snapshots; * mints a fresh sessionId, so rows from older generations are stale snapshots;
* only the newest sessionId per server is kept. * only the newest sessionId per server is kept.
*/ */
export async function getSupplyBoxList( async function getSupplyBoxList(
payload: PayloadType, payload: PayloadType,
user: SurfaceUser, user: SurfaceUser,
): Promise<SupplyBoxListData> { ): Promise<SupplyBoxListData> {
@ -151,8 +151,8 @@ export default async function SupplyBoxesPage() {
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6"> <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"> <PageShell title="Supply Boxes" meta="LOGISTICS // CRATE SYNC">
<p className="max-w-2xl text-sm text-white/70"> <p className="max-w-2xl text-sm text-white/70">
Crate inventory synced from the game server through the Arma bridge. Crate inventory synced from the game server through the Arma bridge. Rows show the newest
Rows show the newest generation per server; older sessions are filtered out. generation per server; older sessions are filtered out.
</p> </p>
</PageShell> </PageShell>
@ -209,4 +209,4 @@ export default async function SupplyBoxesPage() {
)} )}
</div> </div>
); );
} }

View file

@ -8,6 +8,7 @@ import { LedgerRow, NoticeState, PageShell } from "@/components/frontend/operati
import type { SurfaceUser } from "@/lib/operations/surface"; import type { SurfaceUser } from "@/lib/operations/surface";
import { CancelButton } from "@/components/frontend/operations/reservations/ReservationCancelButton"; import { CancelButton } from "@/components/frontend/operations/reservations/ReservationCancelButton";
import type { OperationReservation } from "@/payload-types"; import type { OperationReservation } from "@/payload-types";
import { getReservationDetail } from "../surface";
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>; type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
@ -21,14 +22,18 @@ function relationId(value: unknown): number | null {
} }
function relationLabel(value: unknown): string { function relationLabel(value: unknown): string {
if (typeof value === "object" && value !== null && "codeName" in (value as { codeName?: unknown })) { if (
typeof value === "object" &&
value !== null &&
"codeName" in (value as { codeName?: unknown })
) {
const codeName = (value as { codeName?: unknown }).codeName; const codeName = (value as { codeName?: unknown }).codeName;
if (typeof codeName === "string" && codeName) return codeName; if (typeof codeName === "string" && codeName) return codeName;
} }
return String(relationId(value) ?? "n/a"); return String(relationId(value) ?? "n/a");
} }
interface ReservationDetail { export interface ReservationDetail {
reservation: OperationReservation | null; reservation: OperationReservation | null;
canCancel: boolean; canCancel: boolean;
} }
@ -40,31 +45,6 @@ interface ReservationDetail {
* canCancel is true only when the row is still reserved AND the viewer holds * canCancel is true only when the row is still reserved AND the viewer holds
* operation-reservations:update. * operation-reservations:update.
*/ */
export async function getReservationDetail(
payload: PayloadType,
user: SurfaceUser,
id: string,
): Promise<ReservationDetail> {
let reservation: OperationReservation | null = null;
try {
reservation = await payload.findByID({
collection: "operation-reservations",
id: Number(id),
depth: 1,
user: user ?? undefined,
});
} catch {
reservation = null;
}
const canCancel =
reservation !== null &&
reservation.status === "reserved" &&
user !== null &&
(await hasPermission(payload, user, "operation-reservations:update"));
return { reservation, canCancel };
}
function Section({ title, children }: { title: string; children: React.ReactNode }) { function Section({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
@ -78,7 +58,9 @@ function Section({ title, children }: { title: string; children: React.ReactNode
function kvRow(label: string, value: React.ReactNode) { function kvRow(label: string, value: React.ReactNode) {
return ( return (
<div className="flex items-start justify-between gap-4 border border-white/10 px-2 py-1.5"> <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="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> <span className="min-w-0 truncate text-right font-mono text-xs text-white/80">{value}</span>
</div> </div>
); );
@ -156,7 +138,10 @@ export default async function ReservationDetailPage({
) : ( ) : (
<ul className="flex flex-col gap-1"> <ul className="flex flex-col gap-1">
{personnel.map((member) => ( {personnel.map((member) => (
<li key={member.id} className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"> <li
key={member.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"> <span className="min-w-0 truncate font-mono text-xs text-white/80">
{relationLabel(member.user)} · id {relationId(member.user)} {relationLabel(member.user)} · id {relationId(member.user)}
</span> </span>
@ -201,7 +186,9 @@ export default async function ReservationDetailPage({
<span className="min-w-0 truncate font-mono text-xs text-white/80"> <span className="min-w-0 truncate font-mono text-xs text-white/80">
{relationLabel(line.resource)} · id {relationId(line.resource)} {relationLabel(line.resource)} · id {relationId(line.resource)}
</span> </span>
<span className="font-mono text-xs tabular-nums text-white/70">{line.amount}</span> <span className="font-mono text-xs tabular-nums text-white/70">
{line.amount}
</span>
</li> </li>
))} ))}
</ul> </ul>
@ -211,7 +198,12 @@ export default async function ReservationDetailPage({
<Section title="Budget"> <Section title="Budget">
{budget?.amount != null || budget?.account != null ? ( {budget?.amount != null || budget?.account != null ? (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{kvRow("Account", budget.account != null ? `${relationLabel(budget.account)} · id ${relationId(budget.account)}` : "n/a")} {kvRow(
"Account",
budget.account != null
? `${relationLabel(budget.account)} · id ${relationId(budget.account)}`
: "n/a",
)}
{kvRow("Amount", budget.amount ?? "n/a")} {kvRow("Amount", budget.amount ?? "n/a")}
</div> </div>
) : ( ) : (
@ -221,14 +213,23 @@ export default async function ReservationDetailPage({
<Section title="Routing"> <Section title="Routing">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{kvRow("Origin", reservation.origin != null ? relationLabel(reservation.origin) : "n/a")} {kvRow(
{kvRow("Destination", reservation.destination != null ? relationLabel(reservation.destination) : "n/a")} "Origin",
reservation.origin != null ? relationLabel(reservation.origin) : "n/a",
)}
{kvRow(
"Destination",
reservation.destination != null ? relationLabel(reservation.destination) : "n/a",
)}
</div> </div>
</Section> </Section>
<Section title="Metadata"> <Section title="Metadata">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{kvRow("Created by", reservation.createdBy != null ? relationLabel(reservation.createdBy) : "n/a")} {kvRow(
"Created by",
reservation.createdBy != null ? relationLabel(reservation.createdBy) : "n/a",
)}
{kvRow("Created at", reservation.createdAt)} {kvRow("Created at", reservation.createdAt)}
{kvRow("Expires", reservation.expiresAt ?? "n/a")} {kvRow("Expires", reservation.expiresAt ?? "n/a")}
</div> </div>

View file

@ -18,8 +18,9 @@ import { listReservationOptions } from "@/lib/operations/reservationOptions";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification"; import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { CancelButton } from "@/components/frontend/operations/reservations/ReservationCancelButton"; import { CancelButton } from "@/components/frontend/operations/reservations/ReservationCancelButton";
import { CreateReservationDialog } from "@/components/frontend/operations/reservations/CreateReservationDialog"; import { CreateReservationDialog } from "@/components/frontend/operations/reservations/CreateReservationDialog";
import { getReservationList, type ReservationListData } from "./surface";
const RESERVATION_PAGE_SIZE = 20; export const RESERVATION_PAGE_SIZE = 20;
export interface ReservationRow { export interface ReservationRow {
id: number; id: number;
@ -34,16 +35,7 @@ export interface ReservationFilters {
operationId?: string; operationId?: string;
} }
export interface ReservationListData { export type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
rows: ReservationRow[];
/** True when the viewer holds operation-reservations:read. False -> empty rows. */
capability: boolean;
page: number;
totalDocs: number;
totalPages: number;
}
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
/** /**
* List of allocation reservations with status/operationId filters and * List of allocation reservations with status/operationId filters and
@ -53,51 +45,6 @@ type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
* result (capability false) so the page can render the permission notice * result (capability false) so the page can render the permission notice
* instead of a list that looks like "no data". * instead of a list that looks like "no data".
*/ */
export async function getReservationList(
payload: PayloadType,
user: SurfaceUser,
filters: ReservationFilters,
requestedPage = 1,
): Promise<ReservationListData> {
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 { function reservationHref(filters: ReservationFilters, page: number): string {
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -140,7 +87,12 @@ export default async function ReservationsPage({
status: params.status || undefined, status: params.status || undefined,
operationId: params.operationId || undefined, operationId: params.operationId || undefined,
}; };
const data = await getReservationList(payload, surfaceUser, filters, Number.isNaN(page) ? 1 : page); const data = await getReservationList(
payload,
surfaceUser,
filters,
Number.isNaN(page) ? 1 : page,
);
// The cancel control is server-gated: only viewers holding // The cancel control is server-gated: only viewers holding
// operation-reservations:update may render it, and only on reserved rows. // operation-reservations:update may render it, and only on reserved rows.
@ -154,9 +106,7 @@ export default async function ReservationsPage({
const canCreate = user const canCreate = user
? await hasPermission(payload, user, "operation-reservations:create") ? await hasPermission(payload, user, "operation-reservations:create")
: false; : false;
const canManageLogistics = user const canManageLogistics = user ? await hasLogisticsQualification(payload, user) : false;
? await hasLogisticsQualification(payload, user)
: false;
const options = canCreate && user ? await listReservationOptions(payload, user) : null; const options = canCreate && user ? await listReservationOptions(payload, user) : null;
return ( return (
@ -168,9 +118,16 @@ export default async function ReservationsPage({
row captures what was consumed or returned. row captures what was consumed or returned.
</p> </p>
<form method="get" className="flex flex-wrap items-end gap-3" data-slot="reservations-filters"> <form
method="get"
className="flex flex-wrap items-end gap-3"
data-slot="reservations-filters"
>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="font-mono text-[9px] uppercase tracking-widest text-white/50" htmlFor="reservation-status"> <label
className="font-mono text-[9px] uppercase tracking-widest text-white/50"
htmlFor="reservation-status"
>
Status Status
</label> </label>
<select <select
@ -187,7 +144,10 @@ export default async function ReservationsPage({
</select> </select>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="font-mono text-[9px] uppercase tracking-widest text-white/50" htmlFor="reservation-operation"> <label
className="font-mono text-[9px] uppercase tracking-widest text-white/50"
htmlFor="reservation-operation"
>
Operation Operation
</label> </label>
<input <input

View file

@ -0,0 +1,109 @@
import {
RESERVATION_PAGE_SIZE,
type PayloadType,
type ReservationFilters,
type ReservationRow,
} from "./page";
import type { OperationReservation } from "@/payload-types";
import type { ReservationDetail } from "./[id]/page";
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";
export interface ReservationListData {
rows: ReservationRow[];
/** True when the viewer holds operation-reservations:read. False -> empty rows. */
capability: boolean;
page: number;
totalDocs: number;
totalPages: number;
}
export async function getReservationList(
payload: PayloadType,
user: SurfaceUser,
filters: ReservationFilters,
requestedPage = 1,
): Promise<ReservationListData> {
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,
};
}
export async function getReservationDetail(
payload: PayloadType,
user: SurfaceUser,
id: string,
): Promise<ReservationDetail> {
let reservation: OperationReservation | null = null;
try {
reservation = await payload.findByID({
collection: "operation-reservations",
id: Number(id),
depth: 1,
user: user ?? undefined,
});
} catch {
reservation = null;
}
const canCancel =
reservation !== null &&
reservation.status === "reserved" &&
user !== null &&
(await hasPermission(payload, user, "operation-reservations:update"));
return { reservation, canCancel };
}

View file

@ -2,9 +2,12 @@ import { getPayload, Payload } from "payload";
import config from "@/payload.config"; import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest"; import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest";
import type { OperationReservation, User } from "@/payload-types"; import type { OperationReservation, User } from "@/payload-types";
import { getReservationList } from "@/app/(frontend)/operations/reservations/page"; import { getReservationList } from "@/app/(frontend)/operations/reservations/surface";
import { getReservationDetail } from "@/app/(frontend)/operations/reservations/[id]/page"; import { getReservationDetail } from "@/app/(frontend)/operations/reservations/surface";
import { createReservation, cancelReservation } from "@/app/(frontend)/operations/reservations/actions"; import {
createReservation,
cancelReservation,
} from "@/app/(frontend)/operations/reservations/actions";
// Mock next/headers so the action's authenticate() can run outside a request. // Mock next/headers so the action's authenticate() can run outside a request.
vi.mock("next/headers", () => ({ vi.mock("next/headers", () => ({
@ -85,7 +88,9 @@ beforeAll(async () => {
afterAll(async () => { afterAll(async () => {
if (!payload) return; if (!payload) return;
for (const id of reservationIds) { for (const id of reservationIds) {
await payload.delete({ collection: "operation-reservations", id, overrideAccess: true }).catch(() => {}); await payload
.delete({ collection: "operation-reservations", id, overrideAccess: true })
.catch(() => {});
} }
for (const id of userIds) { for (const id of userIds) {
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {}); await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
@ -122,12 +127,22 @@ describe("reservation surfaces: getReservationList", () => {
const cancelledId = await createReservedReservation(`${RUN}-filter-c`, `${RUN}-op-filter-c`); const cancelledId = await createReservedReservation(`${RUN}-filter-c`, `${RUN}-op-filter-c`);
await cancelReservation({ reservationId: cancelledId }); await cancelReservation({ reservationId: cancelledId });
const reserved = await getReservationList(payload, { id: superUser.id }, { status: "reserved" }, 1); const reserved = await getReservationList(
payload,
{ id: superUser.id },
{ status: "reserved" },
1,
);
const reservedRows = reserved.rows.filter((r) => r.reservationKey === `${RUN}-filter-r`); const reservedRows = reserved.rows.filter((r) => r.reservationKey === `${RUN}-filter-r`);
expect(reservedRows).toHaveLength(1); expect(reservedRows).toHaveLength(1);
expect(reservedRows[0].status).toBe("reserved"); expect(reservedRows[0].status).toBe("reserved");
const cancelled = await getReservationList(payload, { id: superUser.id }, { status: "cancelled" }, 1); const cancelled = await getReservationList(
payload,
{ id: superUser.id },
{ status: "cancelled" },
1,
);
const cancelledRows = cancelled.rows.filter((r) => r.reservationKey === `${RUN}-filter-c`); const cancelledRows = cancelled.rows.filter((r) => r.reservationKey === `${RUN}-filter-c`);
expect(cancelledRows).toHaveLength(1); expect(cancelledRows).toHaveLength(1);
expect(cancelledRows[0].status).toBe("cancelled"); expect(cancelledRows[0].status).toBe("cancelled");
@ -136,7 +151,12 @@ describe("reservation surfaces: getReservationList", () => {
it("narrows rows by operationId", async () => { it("narrows rows by operationId", async () => {
authSpy.mockResolvedValue({ user: superUser }); authSpy.mockResolvedValue({ user: superUser });
await createReservedReservation(`${RUN}-opid-1`, `${RUN}-op-specific`); await createReservedReservation(`${RUN}-opid-1`, `${RUN}-op-specific`);
const data = await getReservationList(payload, { id: superUser.id }, { operationId: `${RUN}-op-specific` }, 1); const data = await getReservationList(
payload,
{ id: superUser.id },
{ operationId: `${RUN}-op-specific` },
1,
);
expect(data.rows).toHaveLength(1); expect(data.rows).toHaveLength(1);
expect(data.rows[0].operationId).toBe(`${RUN}-op-specific`); expect(data.rows[0].operationId).toBe(`${RUN}-op-specific`);
}); });