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
* render a not-found notice.
*/
export async function getSupplyBoxDetail(
async function getSupplyBoxDetail(
payload: PayloadType,
user: SurfaceUser,
id: string,
@ -114,17 +114,15 @@ function Section({ title, children }: { title: string; children: React.ReactNode
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="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 }>;
}) {
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({
@ -257,7 +255,9 @@ export default async function SupplyBoxDetailPage({
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="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)]}`}
@ -278,4 +278,4 @@ export default async function SupplyBoxDetailPage({
</PageShell>
</div>
);
}
}

View file

@ -92,7 +92,7 @@ export interface SupplyBoxListData {
* mints a fresh sessionId, so rows from older generations are stale snapshots;
* only the newest sessionId per server is kept.
*/
export async function getSupplyBoxList(
async function getSupplyBoxList(
payload: PayloadType,
user: SurfaceUser,
): 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">
<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.
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>
@ -209,4 +209,4 @@ export default async function SupplyBoxesPage() {
)}
</div>
);
}
}

View file

@ -8,6 +8,7 @@ import { LedgerRow, NoticeState, PageShell } from "@/components/frontend/operati
import type { SurfaceUser } from "@/lib/operations/surface";
import { CancelButton } from "@/components/frontend/operations/reservations/ReservationCancelButton";
import type { OperationReservation } from "@/payload-types";
import { getReservationDetail } from "../surface";
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
@ -21,14 +22,18 @@ function relationId(value: unknown): number | null {
}
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;
if (typeof codeName === "string" && codeName) return codeName;
}
return String(relationId(value) ?? "n/a");
}
interface ReservationDetail {
export interface ReservationDetail {
reservation: OperationReservation | null;
canCancel: boolean;
}
@ -40,31 +45,6 @@ interface ReservationDetail {
* canCancel is true only when the row is still reserved AND the viewer holds
* 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 }) {
return (
@ -78,7 +58,9 @@ function Section({ title, children }: { title: string; children: React.ReactNode
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="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>
);
@ -156,7 +138,10 @@ export default async function ReservationDetailPage({
) : (
<ul className="flex flex-col gap-1">
{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">
{relationLabel(member.user)} · id {relationId(member.user)}
</span>
@ -201,7 +186,9 @@ export default async function ReservationDetailPage({
<span className="min-w-0 truncate font-mono text-xs text-white/80">
{relationLabel(line.resource)} · id {relationId(line.resource)}
</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>
))}
</ul>
@ -211,7 +198,12 @@ export default async function ReservationDetailPage({
<Section title="Budget">
{budget?.amount != null || budget?.account != null ? (
<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")}
</div>
) : (
@ -221,14 +213,23 @@ export default async function ReservationDetailPage({
<Section title="Routing">
<div className="flex flex-col gap-1">
{kvRow("Origin", reservation.origin != null ? relationLabel(reservation.origin) : "n/a")}
{kvRow("Destination", reservation.destination != null ? relationLabel(reservation.destination) : "n/a")}
{kvRow(
"Origin",
reservation.origin != null ? relationLabel(reservation.origin) : "n/a",
)}
{kvRow(
"Destination",
reservation.destination != null ? relationLabel(reservation.destination) : "n/a",
)}
</div>
</Section>
<Section title="Metadata">
<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("Expires", reservation.expiresAt ?? "n/a")}
</div>

View file

@ -18,8 +18,9 @@ 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";
import { getReservationList, type ReservationListData } from "./surface";
const RESERVATION_PAGE_SIZE = 20;
export const RESERVATION_PAGE_SIZE = 20;
export interface ReservationRow {
id: number;
@ -34,16 +35,7 @@ export interface ReservationFilters {
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<ReturnType<typeof import("payload").getPayload>>;
export type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
/**
* 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
* 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 {
const params = new URLSearchParams();
@ -140,7 +87,12 @@ export default async function ReservationsPage({
status: params.status || 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
// operation-reservations:update may render it, and only on reserved rows.
@ -154,9 +106,7 @@ export default async function ReservationsPage({
const canCreate = user
? await hasPermission(payload, user, "operation-reservations:create")
: false;
const canManageLogistics = user
? await hasLogisticsQualification(payload, user)
: false;
const canManageLogistics = user ? await hasLogisticsQualification(payload, user) : false;
const options = canCreate && user ? await listReservationOptions(payload, user) : null;
return (
@ -168,9 +118,16 @@ export default async function ReservationsPage({
row captures what was consumed or returned.
</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">
<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
</label>
<select
@ -187,7 +144,10 @@ export default async function ReservationsPage({
</select>
</div>
<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
</label>
<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 { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest";
import type { OperationReservation, User } from "@/payload-types";
import { getReservationList } from "@/app/(frontend)/operations/reservations/page";
import { getReservationDetail } from "@/app/(frontend)/operations/reservations/[id]/page";
import { createReservation, cancelReservation } from "@/app/(frontend)/operations/reservations/actions";
import { getReservationList } from "@/app/(frontend)/operations/reservations/surface";
import { getReservationDetail } from "@/app/(frontend)/operations/reservations/surface";
import {
createReservation,
cancelReservation,
} from "@/app/(frontend)/operations/reservations/actions";
// Mock next/headers so the action's authenticate() can run outside a request.
vi.mock("next/headers", () => ({
@ -85,7 +88,9 @@ beforeAll(async () => {
afterAll(async () => {
if (!payload) return;
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) {
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`);
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`);
expect(reservedRows).toHaveLength(1);
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`);
expect(cancelledRows).toHaveLength(1);
expect(cancelledRows[0].status).toBe("cancelled");
@ -136,7 +151,12 @@ describe("reservation surfaces: getReservationList", () => {
it("narrows rows by operationId", async () => {
authSpy.mockResolvedValue({ user: superUser });
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[0].operationId).toBe(`${RUN}-op-specific`);
});