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.
This commit is contained in:
parent
b483cf910b
commit
42066e9d13
5 changed files with 1671 additions and 0 deletions
303
src/app/(frontend)/operations/reservations/page.tsx
Normal file
303
src/app/(frontend)/operations/reservations/page.tsx
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,535 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, ChevronDown, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { ReservationOptions } from "@/lib/operations/reservationOptions";
|
||||
import { createReservation } from "@/app/(frontend)/operations/reservations/actions";
|
||||
|
||||
interface CreateReservationDialogProps {
|
||||
options: ReservationOptions;
|
||||
canManageLogistics: boolean;
|
||||
currentUserId: number;
|
||||
/** Trigger only renders for users who hold operation-reservations:create. */
|
||||
canCreate: boolean;
|
||||
}
|
||||
|
||||
interface CargoLine {
|
||||
resourceId: string;
|
||||
amount: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable multi-value picker built on the same Command/Popover primitives as
|
||||
* the site Combobox, which is single-value only. Selections persist in a number
|
||||
* array; the popover stays as wide as the trigger.
|
||||
*/
|
||||
function MultiSelect({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = "None selected",
|
||||
}: {
|
||||
label: string;
|
||||
options: ComboboxOption[];
|
||||
selected: number[];
|
||||
onChange: (value: number[]) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const selectedLabels = useMemo(
|
||||
() => options.filter((o) => selected.includes(Number(o.value))).map((o) => o.label),
|
||||
[options, selected],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(next) => !disabled && setOpen(next)}>
|
||||
<PopoverTrigger
|
||||
disabled={disabled}
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span className="truncate text-left flex-1">
|
||||
{selectedLabels.length > 0 ? selectedLabels.join(", ") : placeholder}
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-50" aria-hidden />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
||||
<Command shouldFilter>
|
||||
<CommandInput placeholder="Search..." />
|
||||
<CommandList className="max-h-64">
|
||||
<CommandEmpty>Nothing found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const value = Number(option.value);
|
||||
const isSelected = selected.includes(value);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={() => {
|
||||
onChange(
|
||||
isSelected
|
||||
? selected.filter((v) => v !== value)
|
||||
: [...selected, value].sort((a, b) => a - b),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={isSelected} />
|
||||
<span className="ml-2 min-w-0 block truncate">{option.label}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateReservationDialog({
|
||||
options,
|
||||
canManageLogistics,
|
||||
currentUserId,
|
||||
canCreate,
|
||||
}: CreateReservationDialogProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reservationKey, setReservationKey] = useState("");
|
||||
const [operationId, setOperationId] = useState("");
|
||||
const [missionId, setMissionId] = useState("");
|
||||
const [personnel, setPersonnel] = useState<number[]>([]);
|
||||
const [vehicles, setVehicles] = useState<number[]>([]);
|
||||
const [originId, setOriginId] = useState("");
|
||||
const [destinationId, setDestinationId] = useState("");
|
||||
const [cargoLines, setCargoLines] = useState<CargoLine[]>([]);
|
||||
const [budgetAccountId, setBudgetAccountId] = useState("");
|
||||
const [budgetAmount, setBudgetAmount] = useState("");
|
||||
const [expiresAt, setExpiresAt] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const missionOptions: ComboboxOption[] = options.upcomingMissions.map((m) => ({
|
||||
value: String(m.id),
|
||||
label: m.name + (m.codeName ? ` (${m.codeName})` : ""),
|
||||
}));
|
||||
|
||||
const vehicleOptions: ComboboxOption[] = options.vehicles.map((v) => ({
|
||||
value: String(v.id),
|
||||
label: v.name,
|
||||
hint: `${v.fuelPct}% fuel`,
|
||||
}));
|
||||
|
||||
const rosterOptions: ComboboxOption[] = options.rosterUsers.map((u) => ({
|
||||
value: String(u.id),
|
||||
label: u.displayName,
|
||||
}));
|
||||
|
||||
const originOptions: ComboboxOption[] = options.originStructures.map((s) => ({
|
||||
value: String(s.id),
|
||||
label: s.name,
|
||||
}));
|
||||
|
||||
const destinationOptions: ComboboxOption[] = options.destinationStructures.map((s) => ({
|
||||
value: String(s.id),
|
||||
label: s.name,
|
||||
}));
|
||||
|
||||
const budgetOptions: ComboboxOption[] = options.budgetAccounts.map((a) => ({
|
||||
value: String(a.id),
|
||||
label: a.name,
|
||||
hint: a.type,
|
||||
}));
|
||||
|
||||
const originStock = originId ? options.stockByOrigin[originId] ?? [] : [];
|
||||
|
||||
function reset() {
|
||||
setReservationKey("");
|
||||
setOperationId("");
|
||||
setMissionId("");
|
||||
setPersonnel([]);
|
||||
setVehicles([]);
|
||||
setOriginId("");
|
||||
setDestinationId("");
|
||||
setCargoLines([]);
|
||||
setBudgetAccountId("");
|
||||
setBudgetAmount("");
|
||||
setExpiresAt("");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function updateCargo(index: number, patch: Partial<CargoLine>) {
|
||||
setCargoLines((lines) =>
|
||||
lines.map((line, i) => (i === index ? { ...line, ...patch } : line)),
|
||||
);
|
||||
}
|
||||
|
||||
function addCargoLine() {
|
||||
setCargoLines((lines) => [...lines, { resourceId: "", amount: "" }]);
|
||||
}
|
||||
|
||||
function removeCargoLine(index: number) {
|
||||
setCargoLines((lines) => lines.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side sanity checks only. The server action re-validates every limit
|
||||
* and is the source of truth; these give the form immediate feedback before
|
||||
* the request goes out.
|
||||
*/
|
||||
function clientErrors(): string[] {
|
||||
const errors: string[] = [];
|
||||
if (!reservationKey.trim()) errors.push("A reservation key is required.");
|
||||
if (!operationId.trim()) errors.push("An operation id is required.");
|
||||
for (const [i, line] of cargoLines.entries()) {
|
||||
const amount = Number(line.amount);
|
||||
if (line.resourceId && (!Number.isInteger(amount) || amount <= 0)) {
|
||||
errors.push(`Cargo amount on line ${i + 1} must be a positive integer.`);
|
||||
}
|
||||
}
|
||||
if (cargoLines.some((l) => l.resourceId) && !originId) {
|
||||
errors.push("An origin structure is required when cargo is reserved.");
|
||||
}
|
||||
if (budgetAmount && (!Number.isInteger(Number(budgetAmount)) || Number(budgetAmount) <= 0)) {
|
||||
errors.push("Budget amount must be a positive integer.");
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const problems = clientErrors();
|
||||
if (problems.length > 0) {
|
||||
setError(problems.join(" "));
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const result = await createReservation({
|
||||
reservationKey: reservationKey.trim(),
|
||||
operationId: operationId.trim(),
|
||||
actorId: currentUserId,
|
||||
...(missionId ? { missionId: Number(missionId) } : {}),
|
||||
...(personnel.length ? { personnel: personnel.map((id) => ({ userId: id })) } : {}),
|
||||
...(vehicles.length ? { vehicleIds: [...vehicles] } : {}),
|
||||
...(cargoLines
|
||||
.filter((l) => l.resourceId && Number(l.amount) > 0)
|
||||
.length
|
||||
? {
|
||||
cargo: cargoLines
|
||||
.filter((l) => l.resourceId && Number(l.amount) > 0)
|
||||
.map((l) => ({ resourceId: Number(l.resourceId), amount: Number(l.amount) })),
|
||||
}
|
||||
: {}),
|
||||
...(budgetAmount ? { budget: { accountId: Number(budgetAccountId), amount: Number(budgetAmount) } } : {}),
|
||||
...(originId ? { originId: Number(originId) } : {}),
|
||||
...(destinationId ? { destinationId: Number(destinationId) } : {}),
|
||||
...(expiresAt ? { expiresAt: new Date(expiresAt).toISOString() } : {}),
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setOpen(false);
|
||||
reset();
|
||||
router.refresh();
|
||||
} else {
|
||||
// Surface the engine's error string verbatim; the server action never
|
||||
// rewords the allocation-rejected message.
|
||||
setError(result.error ?? "Failed to create reservation.");
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => { setOpen(next); if (!next) reset(); }}>
|
||||
{canCreate ? (
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<PlusIcon className="size-4" />
|
||||
New Reservation
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
) : null}
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reserve Operation Assets</DialogTitle>
|
||||
<DialogDescription>
|
||||
earmark people, vehicles, cargo, and treasury budget against an operation. Capacity and
|
||||
funds are enforced server-side.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<Label htmlFor="reservationKey">Reservation key</Label>
|
||||
<Input
|
||||
id="reservationKey"
|
||||
value={reservationKey}
|
||||
onChange={(e) => setReservationKey(e.target.value)}
|
||||
placeholder="e.g. op-24-alpha"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<Label htmlFor="operationId">Operation id</Label>
|
||||
<Input
|
||||
id="operationId"
|
||||
value={operationId}
|
||||
onChange={(e) => setOperationId(e.target.value)}
|
||||
placeholder="e.g. operation-id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Mission (optional)</Label>
|
||||
<Combobox
|
||||
value={missionId}
|
||||
onValueChange={setMissionId}
|
||||
placeholder="Attach to a mission"
|
||||
searchPlaceholder="Search missions..."
|
||||
emptyMessage="No upcoming unit missions."
|
||||
options={missionOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Personnel (optional)</Label>
|
||||
<MultiSelect
|
||||
label="Personnel"
|
||||
options={rosterOptions}
|
||||
selected={personnel}
|
||||
onChange={setPersonnel}
|
||||
placeholder="Select unit members"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!canManageLogistics ? (
|
||||
<SectionGated hint="Logistics qualification required to reserve vehicles and cargo.">
|
||||
<Label>Vehicles</Label>
|
||||
<MultiSelect
|
||||
label="Vehicles"
|
||||
options={vehicleOptions}
|
||||
selected={vehicles}
|
||||
onChange={setVehicles}
|
||||
disabled
|
||||
placeholder="Request logistics access to reserve vehicles"
|
||||
/>
|
||||
</SectionGated>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Vehicles (optional)</Label>
|
||||
<MultiSelect
|
||||
label="Vehicles"
|
||||
options={vehicleOptions}
|
||||
selected={vehicles}
|
||||
onChange={setVehicles}
|
||||
placeholder="Select idle vehicles"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="origin">Origin structure (when reserving cargo)</Label>
|
||||
<Combobox
|
||||
id="origin"
|
||||
value={originId}
|
||||
onValueChange={setOriginId}
|
||||
placeholder="No origin"
|
||||
searchPlaceholder="Search structures..."
|
||||
emptyMessage="No operational structures."
|
||||
options={originOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canManageLogistics ? (
|
||||
<SectionGated>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Cargo lines</Label>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={addCargoLine}>
|
||||
<PlusIcon className="size-4" />
|
||||
Add line
|
||||
</Button>
|
||||
</div>
|
||||
{cargoLines.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No cargo reserved.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{cargoLines.map((line, index) => (
|
||||
<div key={index} className="flex items-end gap-2">
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<Label htmlFor={`resource-${index}`}>Resource</Label>
|
||||
{originStock.length > 0 ? (
|
||||
<Combobox
|
||||
value={line.resourceId}
|
||||
onValueChange={(v) => updateCargo(index, { resourceId: v })}
|
||||
searchPlaceholder="Search resources..."
|
||||
emptyMessage="Nothing stored at this origin."
|
||||
options={originStock.map((s) => ({
|
||||
value: String(s.resourceId),
|
||||
label: s.resourceName,
|
||||
hint: `${s.amount.toLocaleString()} available`,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select an origin to choose resources.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-24 flex-col gap-1.5">
|
||||
<Label htmlFor={`amount-${index}`}>Amount</Label>
|
||||
<Input
|
||||
id={`amount-${index}`}
|
||||
type="number"
|
||||
min={1}
|
||||
value={line.amount}
|
||||
onChange={(e) => updateCargo(index, { amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => removeCargoLine(index)}
|
||||
aria-label="Remove cargo line"
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionGated>
|
||||
) : (
|
||||
<SectionGated hint="Logistics qualification required to reserve cargo.">
|
||||
<Label>Cargo lines</Label>
|
||||
<p className="text-xs text-muted-foreground">Disabled until logistics access is granted.</p>
|
||||
</SectionGated>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="destination">Destination structure (optional)</Label>
|
||||
<Combobox
|
||||
id="destination"
|
||||
value={destinationId}
|
||||
onValueChange={setDestinationId}
|
||||
placeholder="No destination"
|
||||
searchPlaceholder="Search structures..."
|
||||
emptyMessage="No operational structures."
|
||||
options={destinationOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canManageLogistics ? (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<Label htmlFor="budgetAccount">Budget account (optional)</Label>
|
||||
<Combobox
|
||||
value={budgetAccountId}
|
||||
onValueChange={setBudgetAccountId}
|
||||
placeholder="No budget"
|
||||
searchPlaceholder="Search accounts..."
|
||||
emptyMessage="No accounts available."
|
||||
options={budgetOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-40 flex-col gap-1.5">
|
||||
<Label htmlFor="budgetAmount">Amount</Label>
|
||||
<Input
|
||||
id="budgetAmount"
|
||||
type="number"
|
||||
min={1}
|
||||
value={budgetAmount}
|
||||
onChange={(e) => setBudgetAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<SectionGated hint="Logistics qualification required to allocate budget.">
|
||||
<Label htmlFor="budgetAccount">Budget account (optional)</Label>
|
||||
<Combobox
|
||||
id="budgetAccount"
|
||||
value={budgetAccountId}
|
||||
onValueChange={setBudgetAccountId}
|
||||
placeholder="No budget"
|
||||
searchPlaceholder="Search accounts..."
|
||||
emptyMessage="No accounts available."
|
||||
options={budgetOptions}
|
||||
disabled
|
||||
/>
|
||||
</SectionGated>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="expiresAt">Expiry (optional)</Label>
|
||||
<Input
|
||||
id="expiresAt"
|
||||
type="datetime-local"
|
||||
value={expiresAt}
|
||||
onChange={(e) => setExpiresAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-400" role="alert">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Reserving..." : "Reserve assets"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper that disables a form section for users without logistics access and
|
||||
* shows why. UX-only gating; the server action is the source of truth.
|
||||
*/
|
||||
function SectionGated({
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-border/60 px-3 py-2 opacity-60">
|
||||
{hint ? <p className="mb-2 text-xs text-amber-300">{hint}</p> : null}
|
||||
<div className="pointer-events-none">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
311
src/lib/operations/reservationOptions.ts
Normal file
311
src/lib/operations/reservationOptions.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
/**
|
||||
* Read-only option pools for the reservation creation form.
|
||||
*
|
||||
* Server-only: no JSX, no client imports. The operations page fetches these
|
||||
* once via `getPayload()` and passes them as props to the client
|
||||
* `CreateReservationDialog`. Every query runs with `overrideAccess` because the
|
||||
* pools are authoring helpers, not user-scoped views, and the dialog itself is
|
||||
* only mounted for users who hold `operation-reservations:create`.
|
||||
*
|
||||
* Deliberately does NOT return account balances: the picker shows the account
|
||||
* name and type only, so a viewer can never read another party's funds.
|
||||
*/
|
||||
import type { Payload } from "payload";
|
||||
|
||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||
|
||||
const UPCOMING_MISSION_STATUSES = ["Ready", "Scheduled"] as const;
|
||||
const MAX_MISSIONS = 50;
|
||||
const MAX_VEHICLES = 50;
|
||||
const MAX_STRUCTURES = 100;
|
||||
const MAX_ROSTER = 200;
|
||||
const MAX_ACCOUNTS = 100;
|
||||
|
||||
export interface MissionOption {
|
||||
id: number;
|
||||
name: string;
|
||||
codeName: string | null;
|
||||
}
|
||||
|
||||
export interface VehicleOption {
|
||||
id: number;
|
||||
name: string;
|
||||
fuelPct: number;
|
||||
}
|
||||
|
||||
export interface StructureOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface BudgetOption {
|
||||
id: number;
|
||||
name: string;
|
||||
type: "treasury" | "faction" | "personal";
|
||||
}
|
||||
|
||||
export interface StockLine {
|
||||
resourceId: number;
|
||||
resourceName: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface RosterUser {
|
||||
id: number;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface ReservationOptions {
|
||||
upcomingMissions: MissionOption[];
|
||||
vehicles: VehicleOption[];
|
||||
originStructures: StructureOption[];
|
||||
destinationStructures: StructureOption[];
|
||||
budgetAccounts: BudgetOption[];
|
||||
/** origin structure id -> available stock lines for that origin. */
|
||||
stockByOrigin: Record<string, StockLine[]>;
|
||||
rosterUsers: RosterUser[];
|
||||
}
|
||||
|
||||
interface Depth0Structure {
|
||||
id: number;
|
||||
name: string;
|
||||
constructionStatus?: string;
|
||||
storedResources?: unknown[];
|
||||
voidStorage?: unknown[];
|
||||
}
|
||||
|
||||
interface Depth0Vehicle {
|
||||
id: number;
|
||||
name: string;
|
||||
status?: string;
|
||||
currentFuel?: number | null;
|
||||
type?: { fuel?: { fuelCapacity?: number | null } | null } | null;
|
||||
}
|
||||
|
||||
async function operationalStructures(
|
||||
payload: PayloadType,
|
||||
): Promise<Depth0Structure[]> {
|
||||
const res = await payload
|
||||
.find({
|
||||
collection: "game-structures",
|
||||
where: { constructionStatus: { equals: "complete" } },
|
||||
sort: "name",
|
||||
limit: MAX_STRUCTURES,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => ({ docs: [] as unknown[] }));
|
||||
return (res.docs as unknown as Depth0Structure[]).slice(0, MAX_STRUCTURES);
|
||||
}
|
||||
|
||||
async function idleUnreservedVehicles(
|
||||
payload: PayloadType,
|
||||
reservedIds: Set<number>,
|
||||
): Promise<VehicleOption[]> {
|
||||
const res = await payload
|
||||
.find({
|
||||
collection: "game-vehicles",
|
||||
where: { status: { equals: "idle" } },
|
||||
sort: "name",
|
||||
limit: MAX_VEHICLES,
|
||||
depth: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => ({ docs: [] as unknown[] }));
|
||||
const vehicles = (res.docs as unknown as Depth0Vehicle[]).filter(
|
||||
(v) => !reservedIds.has(v.id),
|
||||
);
|
||||
return vehicles.slice(0, MAX_VEHICLES).map((v) => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
fuelPct: fuelPercent(v),
|
||||
}));
|
||||
}
|
||||
|
||||
function fuelPercent(vehicle: Depth0Vehicle): number {
|
||||
const current = vehicle.currentFuel ?? 0;
|
||||
const capacity = vehicle.type?.fuel?.fuelCapacity;
|
||||
if (typeof capacity !== "number" || capacity <= 0) return 0;
|
||||
return Math.round((current / capacity) * 100);
|
||||
}
|
||||
|
||||
async function activeReservedVehicleIds(
|
||||
payload: PayloadType,
|
||||
): Promise<Set<number>> {
|
||||
const res = await payload
|
||||
.find({
|
||||
collection: "operation-reservations",
|
||||
where: { status: { equals: "reserved" } },
|
||||
limit: 1000,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => ({ docs: [] as unknown[] }));
|
||||
const ids = new Set<number>();
|
||||
for (const doc of res.docs) {
|
||||
const vehicles = (doc as { vehicles?: { vehicle: number | { id: number } }[] | null })
|
||||
.vehicles;
|
||||
if (!vehicles) continue;
|
||||
for (const entry of vehicles) {
|
||||
const ref = entry.vehicle;
|
||||
const id = typeof ref === "object" && ref ? ref.id : ref;
|
||||
if (typeof id === "number") ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function fetchBudgetAccounts(
|
||||
payload: PayloadType,
|
||||
userId: number | null,
|
||||
): Promise<BudgetOption[]> {
|
||||
const budgetAnd: Array<Record<string, unknown>> = [{ status: { equals: "open" } }];
|
||||
const accountOr: Array<Record<string, unknown>> = [{ accountType: { equals: "treasury" } }];
|
||||
if (userId != null) {
|
||||
accountOr.push({
|
||||
and: [
|
||||
{ accountType: { equals: "personal" } },
|
||||
{ ownerUser: { equals: userId } },
|
||||
],
|
||||
});
|
||||
}
|
||||
budgetAnd.push({ or: accountOr });
|
||||
|
||||
const res = await payload
|
||||
.find({
|
||||
collection: "bank-accounts",
|
||||
where: { and: budgetAnd } as never,
|
||||
sort: "name",
|
||||
limit: MAX_ACCOUNTS,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => ({ docs: [] as unknown[] }));
|
||||
return (res.docs as unknown as { id: number; name: string; accountType: string }[])
|
||||
.slice(0, MAX_ACCOUNTS)
|
||||
.map((a) => ({ id: a.id, name: a.name, type: a.accountType as BudgetOption["type"] }));
|
||||
}
|
||||
|
||||
async function fetchRosterUsers(payload: PayloadType): Promise<RosterUser[]> {
|
||||
const res = await payload
|
||||
.find({
|
||||
collection: "users",
|
||||
sort: "displayName",
|
||||
limit: MAX_ROSTER,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => ({ docs: [] as unknown[] }));
|
||||
return (res.docs as unknown as { id: number; displayName: string }[])
|
||||
.slice(0, MAX_ROSTER)
|
||||
.map((u) => ({ id: u.id, displayName: u.displayName || "Unknown user" }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Upcoming unit-visible missions: status Ready or Scheduled, visibility unit,
|
||||
* sorted by earliest start date.
|
||||
*/
|
||||
async function fetchUpcomingMissions(payload: PayloadType): Promise<MissionOption[]> {
|
||||
const res = await payload
|
||||
.find({
|
||||
collection: "missions",
|
||||
where: {
|
||||
and: [
|
||||
{ "ownershipAndStatus.status": { in: [...UPCOMING_MISSION_STATUSES] } },
|
||||
{ "ownershipAndStatus.visibility": { equals: "unit" } },
|
||||
],
|
||||
},
|
||||
sort: "classification.startDateTime",
|
||||
limit: MAX_MISSIONS,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
.catch(() => ({ docs: [] as unknown[] }));
|
||||
return (res.docs as unknown as { id: number; name: string; codeName: string | null }[])
|
||||
.slice(0, MAX_MISSIONS)
|
||||
.map((m) => ({ id: m.id, name: m.name, codeName: m.codeName ?? null }));
|
||||
}
|
||||
|
||||
/** Aggregate per-origin stock from storedResources + voidStorage, resolving names. */
|
||||
async function stockByOrigin(
|
||||
payload: PayloadType,
|
||||
origins: Depth0Structure[],
|
||||
): Promise<Record<string, StockLine[]>> {
|
||||
const nameCache = new Map<number, string>();
|
||||
const resolveName = async (resourceId: number): Promise<string> => {
|
||||
const cached = nameCache.get(resourceId);
|
||||
if (cached) return cached;
|
||||
const doc = await payload
|
||||
.findByID({ collection: "resources", id: resourceId, depth: 0, overrideAccess: true })
|
||||
.catch(() => null);
|
||||
const name = doc?.name ?? `Resource #${resourceId}`;
|
||||
nameCache.set(resourceId, name);
|
||||
return name;
|
||||
};
|
||||
|
||||
const out: Record<string, StockLine[]> = {};
|
||||
for (const origin of origins) {
|
||||
const entries = [
|
||||
...(origin.storedResources ?? []),
|
||||
...(origin.voidStorage ?? []),
|
||||
] as Array<{ resource: number | { id: number; name?: string }; amount: number }>;
|
||||
if (entries.length === 0) continue;
|
||||
|
||||
const totals = new Map<number, number>();
|
||||
for (const entry of entries) {
|
||||
const resourceId = typeof entry.resource === "object" ? entry.resource.id : entry.resource;
|
||||
if (typeof resourceId !== "number") continue;
|
||||
totals.set(resourceId, (totals.get(resourceId) ?? 0) + (entry.amount ?? 0));
|
||||
}
|
||||
|
||||
const lines: StockLine[] = [];
|
||||
for (const [resourceId, amount] of totals) {
|
||||
lines.push({ resourceId, resourceName: await resolveName(resourceId), amount });
|
||||
}
|
||||
lines.sort((a, b) => a.resourceName.localeCompare(b.resourceName));
|
||||
out[String(origin.id)] = lines;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* All option pools the reservation form needs, in one object.
|
||||
*
|
||||
* `userId` is used to scope budget accounts to the viewer's own personal
|
||||
* account (plus any open treasury accounts); it is never used to read balances.
|
||||
*/
|
||||
export async function listReservationOptions(
|
||||
payload: PayloadType,
|
||||
user: { id: number | string } | null,
|
||||
): Promise<ReservationOptions> {
|
||||
const userId = user ? Number(user.id) : null;
|
||||
const reservedVehicleIds = await activeReservedVehicleIds(payload);
|
||||
const origins = await operationalStructures(payload);
|
||||
|
||||
const [upcomingMissions, vehicles, budgetAccounts, rosterUsers] = await Promise.all([
|
||||
fetchUpcomingMissions(payload),
|
||||
idleUnreservedVehicles(payload, reservedVehicleIds),
|
||||
fetchBudgetAccounts(payload, userId),
|
||||
fetchRosterUsers(payload),
|
||||
]);
|
||||
|
||||
const stockByOriginData = await stockByOrigin(payload, origins);
|
||||
|
||||
const structureOptions = origins.map((s) => ({ id: s.id, name: s.name }));
|
||||
|
||||
return {
|
||||
upcomingMissions,
|
||||
vehicles,
|
||||
originStructures: structureOptions,
|
||||
destinationStructures: structureOptions,
|
||||
budgetAccounts,
|
||||
stockByOrigin: stockByOriginData,
|
||||
rosterUsers,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-exported so the dialog can type a stored structure/vehicle doc without
|
||||
* importing payload-types twice.
|
||||
*/
|
||||
export type { GameStructure, GameVehicle } from "@/payload-types";
|
||||
73
tests/e2e/operations-reservations.e2e.spec.ts
Normal file
73
tests/e2e/operations-reservations.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Create-reservation dialog on the reservations list page.
|
||||
*
|
||||
* NOTE: these tests exercise the real dev server and are run in the final
|
||||
* verification wave (F3) under the dev-server protocol, not on every change.
|
||||
* The dialog is server-gated: only users holding operation-reservations:create
|
||||
* render the "New Reservation" trigger; logistics qualification gates the
|
||||
* vehicle/cargo/budget sections inside.
|
||||
*/
|
||||
test.describe("Create reservation dialog", () => {
|
||||
test("guests never see the create-reservation dialog", async ({ page }) => {
|
||||
await page.goto("/operations/reservations");
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Polaris Task Force");
|
||||
});
|
||||
|
||||
test("a permitted, logistics-qualified user sees the create dialog with all sections", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill("dev");
|
||||
await page.getByLabel("Password").fill("Test123");
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
|
||||
await page.goto("/operations/reservations");
|
||||
await expect(page.getByText("Reservations")).toBeVisible();
|
||||
// The trigger renders for a user holding operation-reservations:create.
|
||||
await expect(page.getByRole("button", { name: "New Reservation" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "New Reservation" }).click();
|
||||
|
||||
// Core fields are present.
|
||||
await expect(page.getByLabel("Reservation key")).toBeVisible();
|
||||
await expect(page.getByLabel("Operation id")).toBeVisible();
|
||||
// Logistics-qualified users get an enabled Vehicles + Cargo + Budget section.
|
||||
await expect(page.getByText("Vehicles (optional)")).toBeVisible();
|
||||
await expect(page.getByText("Cargo lines")).toBeVisible();
|
||||
await expect(page.getByText("Budget account (optional)")).toBeVisible();
|
||||
});
|
||||
|
||||
test("submitting the dialog with the required fields missing shows the client error", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill("dev");
|
||||
await page.getByLabel("Password").fill("Test123");
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
|
||||
await page.goto("/operations/reservations");
|
||||
await page.getByRole("button", { name: "New Reservation" }).click();
|
||||
|
||||
// Empty submit -> client pre-validation surfaces the required-field errors.
|
||||
await page.getByRole("button", { name: "Reserve assets" }).click();
|
||||
await expect(
|
||||
page.getByRole("alert").filter({ hasText: "A reservation key is required." }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("the list renders and either a reserved row or the empty state is present", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill("dev");
|
||||
await page.getByLabel("Password").fill("Test123");
|
||||
await page.getByRole("button", { name: "Log in" }).click();
|
||||
|
||||
await page.goto("/operations/reservations");
|
||||
// Either a reserved row (with its Cancel control) or the empty state renders.
|
||||
await expect(
|
||||
page.getByText("No reservations match this view."),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
449
tests/int/operation-reservation-options.int.spec.ts
Normal file
449
tests/int/operation-reservation-options.int.spec.ts
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import config from "@/payload.config";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type {
|
||||
GameStructure,
|
||||
GameVehicle,
|
||||
OperationReservation,
|
||||
Resource,
|
||||
Structure,
|
||||
User,
|
||||
} from "@/payload-types";
|
||||
import { listReservationOptions } from "@/lib/operations/reservationOptions";
|
||||
import { createAccount } from "@/lib/banking";
|
||||
import { storedAmount } from "@/lib/base/storage";
|
||||
|
||||
let payload: Payload;
|
||||
|
||||
const RUN = `res-options-${Date.now().toString(36)}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixture tracking for cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
const reservationIds: number[] = [];
|
||||
const userIds: number[] = [];
|
||||
const roleIds: number[] = [];
|
||||
const accountIds: number[] = [];
|
||||
const gameStructureIds: number[] = [];
|
||||
const blueprintIds: number[] = [];
|
||||
const gameVehicleIds: number[] = [];
|
||||
const vehicleBlueprintIds: number[] = [];
|
||||
const resourceIds: number[] = [];
|
||||
const missionIds: number[] = [];
|
||||
const campaignIds: number[] = [];
|
||||
const mapIds: number[] = [];
|
||||
|
||||
let commandUser: User;
|
||||
let plainUser: User;
|
||||
let mapId: number;
|
||||
let campaignId: number;
|
||||
let resourceAId: number;
|
||||
let resourceBId: number;
|
||||
let normalBlueprintId: number;
|
||||
let vehicleBlueprintId: number;
|
||||
|
||||
const LEXICAL_EMPTY = {
|
||||
root: {
|
||||
children: [{ text: "" }],
|
||||
direction: null,
|
||||
format: "" as const,
|
||||
indent: 0,
|
||||
type: "text",
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
|
||||
function relId(value: unknown): number {
|
||||
return typeof value === "object" && value !== null
|
||||
? (value as { id: number }).id
|
||||
: (value as number);
|
||||
}
|
||||
|
||||
async function makeUser(label: string, roleDocIds?: number[]): Promise<User> {
|
||||
const user = (await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
username: `${RUN}-${label}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
discordUsername: `${RUN}-${label}-discord`,
|
||||
displayName: `${RUN} ${label}`,
|
||||
steamId: `${RUN}-steam-${label}`,
|
||||
password: "Test1234",
|
||||
roles: ["user"],
|
||||
...(roleDocIds && roleDocIds.length > 0 ? { roleDocs: roleDocIds } : {}),
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as User;
|
||||
userIds.push(user.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function makeMission(
|
||||
status: "Ready" | "Scheduled" | "Completed" | "Cancelled",
|
||||
visibility: "unit" | "leadership",
|
||||
): Promise<number> {
|
||||
const mission = await payload.create({
|
||||
collection: "missions",
|
||||
data: {
|
||||
name: `${RUN} ${status} ${visibility}`,
|
||||
codeName: `${RUN}-${status}-${visibility}`,
|
||||
summary: "Options test mission",
|
||||
operationType: "main",
|
||||
classification: {
|
||||
map: mapId,
|
||||
missionType: "PvE",
|
||||
campaign: campaignId,
|
||||
startDateTime: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
estimatedDuration: 60,
|
||||
},
|
||||
ownershipAndStatus: {
|
||||
authors: [commandUser.id],
|
||||
status,
|
||||
visibility,
|
||||
},
|
||||
missionRoles: { maxPlayers: 16 },
|
||||
gameDetails: { serverDetails: { serverIp: "127.0.0.1", serverPort: 2302 } },
|
||||
briefing: [],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
missionIds.push(mission.id);
|
||||
return mission.id;
|
||||
}
|
||||
|
||||
async function makeOrigin(stock: { resourceId: number; amount: number }[]): Promise<number> {
|
||||
const site = await payload.create({
|
||||
collection: "game-structures",
|
||||
data: {
|
||||
name: `${RUN} origin ${gameStructureIds.length}`,
|
||||
type: normalBlueprintId,
|
||||
map: mapId,
|
||||
coordinates: [100 + gameStructureIds.length, 100],
|
||||
constructionStatus: "complete",
|
||||
storedResources: stock.map((s, i) => ({
|
||||
resource: s.resourceId,
|
||||
amount: s.amount,
|
||||
gridX: 0,
|
||||
gridY: i,
|
||||
rotated: false,
|
||||
})),
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
gameStructureIds.push(site.id);
|
||||
return site.id;
|
||||
}
|
||||
|
||||
async function makeVehicle(status: "idle" | "assigned"): Promise<number> {
|
||||
const vehicle = await payload.create({
|
||||
collection: "game-vehicles",
|
||||
data: {
|
||||
name: `${RUN} vic ${gameVehicleIds.length}`,
|
||||
type: vehicleBlueprintId,
|
||||
deployedAt: gameStructureIds[0],
|
||||
status,
|
||||
currentFuel: 800,
|
||||
currentHealth: 100,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
gameVehicleIds.push(vehicle.id);
|
||||
return vehicle.id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup / teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
|
||||
const superRole = await payload.create({
|
||||
collection: "roles",
|
||||
data: { name: `${RUN} Command`, slug: `${RUN}-command`, isSuperuser: true },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
roleIds.push(superRole.id);
|
||||
|
||||
commandUser = await makeUser("command", [superRole.id]);
|
||||
plainUser = await makeUser("plain");
|
||||
|
||||
const map = await payload.create({
|
||||
collection: "maps",
|
||||
data: { name: `${RUN} Map`, worldSizeWidth: 8192, worldSizeHeight: 8192, basemapMode: "image" },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
mapId = map.id;
|
||||
mapIds.push(map.id);
|
||||
|
||||
const campaign = await payload.create({
|
||||
collection: "campaigns",
|
||||
data: {
|
||||
name: `${RUN} Campaign`,
|
||||
summary: "Options test campaign",
|
||||
status: "concept",
|
||||
campaignMode: "custom",
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
campaignId = campaign.id;
|
||||
campaignIds.push(campaign.id);
|
||||
|
||||
const resourceA = await payload.create({
|
||||
collection: "resources",
|
||||
data: {
|
||||
name: `${RUN} Alpha`,
|
||||
codeName: `${RUN}-alpha`,
|
||||
approvalStatus: "in_progress",
|
||||
type: "physical",
|
||||
baseValue: 1,
|
||||
rarity: "common",
|
||||
unitOfMeasure: "kg",
|
||||
massPerUnit: 1,
|
||||
gridWidth: 1,
|
||||
gridHeight: 1,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
resourceAId = resourceA.id;
|
||||
resourceIds.push(resourceA.id);
|
||||
|
||||
const resourceB = await payload.create({
|
||||
collection: "resources",
|
||||
data: {
|
||||
name: `${RUN} Beta`,
|
||||
codeName: `${RUN}-beta`,
|
||||
approvalStatus: "in_progress",
|
||||
type: "physical",
|
||||
baseValue: 1,
|
||||
rarity: "common",
|
||||
unitOfMeasure: "kg",
|
||||
massPerUnit: 1,
|
||||
gridWidth: 1,
|
||||
gridHeight: 1,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
resourceBId = resourceB.id;
|
||||
resourceIds.push(resourceB.id);
|
||||
|
||||
const normalBlueprint = await payload.create({
|
||||
collection: "structures",
|
||||
data: {
|
||||
name: `${RUN} Depot`,
|
||||
codeName: `${RUN}-depot`,
|
||||
approvalStatus: "in_progress",
|
||||
description: LEXICAL_EMPTY as unknown as Structure["description"],
|
||||
category: "logistics",
|
||||
materials: [{ resource: resourceAId, amount: 1 }],
|
||||
constructionDurationMinutes: 1,
|
||||
terrainType: "land",
|
||||
maxHealth: 100,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
normalBlueprintId = normalBlueprint.id;
|
||||
blueprintIds.push(normalBlueprint.id);
|
||||
|
||||
const vehicleBlueprint = await payload.create({
|
||||
collection: "vehicles",
|
||||
data: {
|
||||
name: `${RUN} Truck`,
|
||||
approvalStatus: "in_progress",
|
||||
transportMode: "ground",
|
||||
maxSpeedOnRoad: 60,
|
||||
fuel: { fuelType: resourceAId, fuelCapacity: 1000, fuelConsumptionRate: 0.01 },
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
vehicleBlueprintId = vehicleBlueprint.id;
|
||||
vehicleBlueprintIds.push(vehicleBlueprint.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!payload) return;
|
||||
for (const id of reservationIds) {
|
||||
await payload.delete({ collection: "operation-reservations", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of gameVehicleIds) {
|
||||
await payload.delete({ collection: "game-vehicles", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of gameStructureIds) {
|
||||
await payload.delete({ collection: "game-structures", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of vehicleBlueprintIds) {
|
||||
await payload.delete({ collection: "vehicles", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of blueprintIds) {
|
||||
await payload.delete({ collection: "structures", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of resourceIds) {
|
||||
await payload.delete({ collection: "resources", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of missionIds) {
|
||||
await payload.delete({ collection: "missions", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of campaignIds) {
|
||||
await payload.delete({ collection: "campaigns", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of mapIds) {
|
||||
await payload.delete({ collection: "maps", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
|
||||
for (const id of accountIds) {
|
||||
const txns = await payload.find({
|
||||
collection: "bank-transactions",
|
||||
where: { or: [{ fromAccount: { equals: id } }, { toAccount: { equals: id } }] },
|
||||
limit: 100,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
});
|
||||
for (const txn of txns.docs) {
|
||||
await payload.delete({ collection: "bank-transactions", id: txn.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
await payload.delete({ collection: "bank-accounts", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of userIds) {
|
||||
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of roleIds) {
|
||||
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("listReservationOptions", () => {
|
||||
it("returns only Ready/Scheduled unit-visible missions, sorted by start", async () => {
|
||||
const scheduledId = await makeMission("Scheduled", "unit");
|
||||
const readyId = await makeMission("Ready", "unit");
|
||||
const completedId = await makeMission("Completed", "unit");
|
||||
const leadershipId = await makeMission("Scheduled", "leadership");
|
||||
|
||||
const options = await listReservationOptions(payload, commandUser);
|
||||
const ids = options.upcomingMissions.map((m) => m.id);
|
||||
|
||||
expect(ids).toContain(scheduledId);
|
||||
expect(ids).toContain(readyId);
|
||||
expect(ids).not.toContain(completedId);
|
||||
expect(ids).not.toContain(leadershipId);
|
||||
});
|
||||
|
||||
it("excludes non-idle vehicles and vehicles already reserved", async () => {
|
||||
const idleId = await makeVehicle("idle");
|
||||
const assignedId = await makeVehicle("assigned");
|
||||
const reservedId = await makeVehicle("idle");
|
||||
|
||||
// Reserve the second idle vehicle via an active (reserved) reservation row.
|
||||
const row = await payload.create({
|
||||
collection: "operation-reservations",
|
||||
data: {
|
||||
reservationKey: `${RUN}-reserved-veh`,
|
||||
operationId: `${RUN}-op`,
|
||||
status: "reserved",
|
||||
vehicles: [{ vehicle: reservedId }],
|
||||
createdBy: commandUser.id,
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
reservationIds.push(row.id);
|
||||
|
||||
const options = await listReservationOptions(payload, commandUser);
|
||||
const vehicleIds = options.vehicles.map((v) => v.id);
|
||||
|
||||
expect(vehicleIds).toContain(idleId);
|
||||
expect(vehicleIds).not.toContain(assignedId);
|
||||
expect(vehicleIds).not.toContain(reservedId);
|
||||
});
|
||||
|
||||
it("reflects storedAmount per origin in stockByOrigin", async () => {
|
||||
const originId = await makeOrigin([
|
||||
{ resourceId: resourceAId, amount: 100 },
|
||||
{ resourceId: resourceBId, amount: 50 },
|
||||
]);
|
||||
|
||||
const options = await listReservationOptions(payload, commandUser);
|
||||
const stock = options.stockByOrigin[String(originId)];
|
||||
|
||||
expect(stock).toHaveLength(2);
|
||||
|
||||
const origin = (await payload
|
||||
.findByID({ collection: "game-structures", id: originId, depth: 0, overrideAccess: true })
|
||||
.catch(() => null)) as unknown as (GameStructure & {
|
||||
storedResources?: unknown[];
|
||||
voidStorage?: unknown[];
|
||||
}) | null;
|
||||
const entries = [
|
||||
...(origin?.storedResources ?? []),
|
||||
...(origin?.voidStorage ?? []),
|
||||
] as Array<{ resource: { id: number }; amount: number }>;
|
||||
|
||||
const amountA = storedAmount(entries as never, resourceAId);
|
||||
const amountB = storedAmount(entries as never, resourceBId);
|
||||
expect(amountA).toBe(100);
|
||||
expect(amountB).toBe(50);
|
||||
|
||||
const lineA = stock!.find((s) => s.resourceId === resourceAId);
|
||||
const lineB = stock!.find((s) => s.resourceId === resourceBId);
|
||||
expect(lineA?.amount).toBe(amountA);
|
||||
expect(lineB?.amount).toBe(amountB);
|
||||
expect(lineA?.resourceName).toBeTruthy();
|
||||
expect(lineB?.resourceName).toBeTruthy();
|
||||
});
|
||||
|
||||
it("caps the roster pool at 200 users", async () => {
|
||||
for (let i = 0; i < 205; i++) {
|
||||
await makeUser(`roster${i}`);
|
||||
}
|
||||
const options = await listReservationOptions(payload, commandUser);
|
||||
expect(options.rosterUsers.length).toBe(200);
|
||||
// Every entry carries a display name and id; nothing else leaks.
|
||||
for (const user of options.rosterUsers) {
|
||||
expect(typeof user.id).toBe("number");
|
||||
expect(user.displayName.length).toBeGreaterThan(0);
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
it("scopes budget accounts to treasury + this user's personal accounts, no balances", async () => {
|
||||
const treasury = await createAccount(payload, { name: `${RUN} Treasury`, accountType: "treasury" });
|
||||
accountIds.push(treasury.id);
|
||||
const commandPersonal = await createAccount(payload, {
|
||||
name: `${RUN} Command Personal`,
|
||||
accountType: "personal",
|
||||
ownerUserId: commandUser.id,
|
||||
});
|
||||
accountIds.push(commandPersonal.id);
|
||||
const plainPersonal = await createAccount(payload, {
|
||||
name: `${RUN} Plain Personal`,
|
||||
accountType: "personal",
|
||||
ownerUserId: plainUser.id,
|
||||
});
|
||||
accountIds.push(plainPersonal.id);
|
||||
|
||||
const options = await listReservationOptions(payload, commandUser);
|
||||
const ids = options.budgetAccounts.map((a) => a.id);
|
||||
|
||||
expect(ids).toContain(treasury.id);
|
||||
expect(ids).toContain(commandPersonal.id);
|
||||
// Another user's personal account is out of scope.
|
||||
expect(ids).not.toContain(plainPersonal.id);
|
||||
|
||||
// Only id, name, and type are exposed - balances never leak.
|
||||
for (const account of options.budgetAccounts) {
|
||||
const keys = Object.keys(account).sort();
|
||||
expect(keys).toEqual(["id", "name", "type"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue