1
0
Fork 0
polaris-task-force/src/app/(frontend)/operations/reservations/page.tsx
Z8MB1E 42066e9d13 feat(operations): reservation creation flow
Add the create-reservation dialog and its option sources for todo 3 of
ops-reservations-and-crate-sync.

- src/lib/operations/reservationOptions.ts: server-only option pool
  (listReservationOptions) returning upcoming unit-visible missions, idle
  unreserved vehicles, operational structures, budget accounts (no
  balances), per-origin stock, and a capped roster.
- src/components/frontend/operations/reservations/CreateReservationDialog.tsx:
  client dialog with core fields, personnel/vehicle multi-picks, cargo
  line repeater, budget + expiry; client pre-validation mirrors the
  server validator; engine errors render verbatim.
- Mount the dialog on the reservations list page, server-gated by
  operation-reservations:create + hasLogisticsQualification.
- Integration tests for the option scopes; e2e spec written (run in F3).

tsc clean; 5/5 integration tests pass.
2026-09-21 04:39:09 -04:00

303 lines
11 KiB
TypeScript

import config from "@payload-config";
import { getPayload } from "payload";
import type { Where } from "payload";
import { headers as nextHeaders } from "next/headers";
import Link from "next/link";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { hasPermission } from "@/utils/access-control/hasPermission";
import {
LedgerRow,
NoticeState,
PageShell,
STATUS_TONE_CLASSES,
statusToneFor,
type ReadoutCell,
} from "@/components/frontend/operations";
import type { SurfaceUser } from "@/lib/operations/surface";
import { listReservationOptions } from "@/lib/operations/reservationOptions";
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
import { CancelButton } from "@/components/frontend/operations/reservations/ReservationCancelButton";
import { CreateReservationDialog } from "@/components/frontend/operations/reservations/CreateReservationDialog";
const RESERVATION_PAGE_SIZE = 20;
export interface ReservationRow {
id: number;
reservationKey: string;
operationId: string;
status: string;
expiresAt: string | null;
}
export interface ReservationFilters {
status?: string;
operationId?: string;
}
export interface ReservationListData {
rows: ReservationRow[];
/** True when the viewer holds operation-reservations:read. False -> empty rows. */
capability: boolean;
page: number;
totalDocs: number;
totalPages: number;
}
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
/**
* List of allocation reservations with status/operationId filters and
* pagination. Runs through collection access control (default access + the
* explicit session user, never overrideAccess). Gated behind
* operation-reservations:read: a viewer lacking that permission gets an empty
* result (capability false) so the page can render the permission notice
* instead of a list that looks like "no data".
*/
export async function getReservationList(
payload: PayloadType,
user: SurfaceUser,
filters: ReservationFilters,
requestedPage = 1,
): Promise<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();
if (filters.status) params.set("status", filters.status);
if (filters.operationId) params.set("operationId", filters.operationId);
if (page > 1) params.set("page", String(page));
const qs = params.toString();
return qs ? `/operations/reservations?${qs}` : "/operations/reservations";
}
const STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: "", label: "All" },
{ value: "reserved", label: "Reserved" },
{ value: "settled", label: "Settled" },
{ value: "cancelled", label: "Cancelled" },
{ value: "expired", label: "Expired" },
{ value: "no-show", label: "No-Show" },
];
export const metadata = {
title: "Reservations: Polaris Task Force",
};
export default async function ReservationsPage({
searchParams,
}: {
searchParams: Promise<ReservationFilters & { page?: string }>;
}) {
const params = await searchParams;
const payload = await getPayload({ config });
const { user: authUser } = await payload.auth({
headers: await nextHeaders(),
canSetHeaders: false,
});
const user = isPayloadUser(authUser) ? authUser : null;
const surfaceUser: SurfaceUser = user ? { id: user.id } : null;
const page = Number.parseInt(params.page ?? "1", 10);
const filters: ReservationFilters = {
status: params.status || undefined,
operationId: params.operationId || undefined,
};
const data = await getReservationList(payload, surfaceUser, filters, Number.isNaN(page) ? 1 : page);
// The cancel control is server-gated: only viewers holding
// operation-reservations:update may render it, and only on reserved rows.
const canCancel =
data.capability && user !== null
? await hasPermission(payload, user, "operation-reservations:update")
: false;
// Server-gated create dialog: only operation-reservations:create viewers open it;
// logistics qualification gates the vehicle/cargo/budget sections inside.
const canCreate = user
? await hasPermission(payload, user, "operation-reservations:create")
: false;
const canManageLogistics = user
? await hasLogisticsQualification(payload, user)
: false;
const options = canCreate && user ? await listReservationOptions(payload, user) : null;
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
<PageShell title="Reservations" meta="ALLOCATION RESERVATIONS // COMMAND VIEW">
<p className="max-w-2xl text-sm text-white/70">
Every allocation of people, vehicles, cargo, and treasury budget against an operation,
with its settlement outcome. Reservations are earmarks only; the settlement record on a
row captures what was consumed or returned.
</p>
<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">
Status
</label>
<select
id="reservation-status"
name="status"
defaultValue={filters.status || ""}
className="border border-white/15 bg-black/40 px-2 py-1 font-mono text-xs text-white/80"
>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value} className="bg-black">
{option.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="font-mono text-[9px] uppercase tracking-widest text-white/50" htmlFor="reservation-operation">
Operation
</label>
<input
id="reservation-operation"
name="operationId"
defaultValue={filters.operationId || ""}
placeholder="operationId"
className="w-64 border border-white/15 bg-black/40 px-2 py-1 font-mono text-xs text-white/80 placeholder:text-white/30"
/>
</div>
<button
type="submit"
className="border border-white/15 bg-black/40 px-3 py-1 font-mono text-[10px] uppercase tracking-widest text-white/80 hover:border-white/30 hover:text-white"
>
Apply
</button>
<Link
href="/operations/reservations"
className="font-mono text-[10px] uppercase tracking-widest text-white/60 underline-offset-4 hover:underline"
>
Clear
</Link>
</form>
</PageShell>
{canCreate && options ? (
<CreateReservationDialog
options={options}
canManageLogistics={canManageLogistics}
canCreate={canCreate}
currentUserId={user!.id as number}
/>
) : null}
{data.capability === false ? (
<NoticeState
variant="error"
message="You do not have permission to view reservations."
hint="Request the operation-reservations:read capability from a command staffer."
/>
) : data.rows.length === 0 ? (
<NoticeState
variant="empty"
message="No reservations match this view."
hint="Reservations appear here once staff allocate assets to an operation."
/>
) : (
<div className="flex flex-col gap-4" data-slot="reservations-list">
{data.rows.map((row) => {
const cells: ReadoutCell[] = [
{ label: "Operation", value: row.operationId },
{ label: "Status", value: row.status },
{ label: "Expires", value: row.expiresAt ?? "n/a" },
];
return (
<div
key={row.id}
data-slot="reservation-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={`/operations/reservations/${row.id}`}
data-slot="reservation-key"
className="min-w-0 truncate font-mono text-xs text-white/85 underline-offset-4 hover:underline"
>
{row.reservationKey}
</Link>
<span
data-slot="reservation-status"
className={`shrink-0 border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[statusToneFor(row.status)]}`}
>
{row.status}
</span>
{canCancel && row.status === "reserved" ? (
<div className="shrink-0">
<CancelButton reservationId={row.id} />
</div>
) : null}
</div>
<LedgerRow cells={cells} />
</div>
);
})}
<div className="flex items-center justify-between">
<span className="font-mono text-[10px] uppercase tracking-widest text-white/50">
Page {data.page} of {Math.max(1, data.totalPages)} ({data.totalDocs} reservations)
</span>
<span className="flex gap-2">
{data.page > 1 && (
<Link
href={reservationHref(filters, data.page - 1)}
className="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"
>
Prev
</Link>
)}
{data.page < data.totalPages && (
<Link
href={reservationHref(filters, data.page + 1)}
className="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"
>
Next
</Link>
)}
</span>
</div>
</div>
)}
</div>
);
}