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