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.
303 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|