fix(operations): move page data helpers to surface modules
Satisfies the Next 16 rule that page files only export page fields: getSupplyBoxDetail/getSupplyBoxList and getReservationList/getReservationDetail move to reservations/surface.ts and pages import from it.
This commit is contained in:
parent
8c10719124
commit
5fb619a5cf
6 changed files with 207 additions and 117 deletions
|
|
@ -63,7 +63,7 @@ export interface SupplyBoxDetailData {
|
||||||
* user, never overrideAccess). Returns null for an unknown id so the page can
|
* user, never overrideAccess). Returns null for an unknown id so the page can
|
||||||
* render a not-found notice.
|
* render a not-found notice.
|
||||||
*/
|
*/
|
||||||
export async function getSupplyBoxDetail(
|
async function getSupplyBoxDetail(
|
||||||
payload: PayloadType,
|
payload: PayloadType,
|
||||||
user: SurfaceUser,
|
user: SurfaceUser,
|
||||||
id: string,
|
id: string,
|
||||||
|
|
@ -114,17 +114,15 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
||||||
function kvRow(label: string, value: React.ReactNode) {
|
function kvRow(label: string, value: React.ReactNode) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-4 border border-white/10 px-2 py-1.5">
|
<div className="flex items-start justify-between gap-4 border border-white/10 px-2 py-1.5">
|
||||||
<span className="shrink-0 font-mono text-[9px] uppercase tracking-widest text-white/50">{label}</span>
|
<span className="shrink-0 font-mono text-[9px] uppercase tracking-widest text-white/50">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
<span className="min-w-0 truncate text-right font-mono text-xs text-white/80">{value}</span>
|
<span className="min-w-0 truncate text-right font-mono text-xs text-white/80">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function SupplyBoxDetailPage({
|
export default async function SupplyBoxDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
params,
|
|
||||||
}: {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}) {
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const payload = await getPayload({ config });
|
const payload = await getPayload({ config });
|
||||||
const { user: authUser } = await payload.auth({
|
const { user: authUser } = await payload.auth({
|
||||||
|
|
@ -257,7 +255,9 @@ export default async function SupplyBoxDetailPage({
|
||||||
key={cmd.id}
|
key={cmd.id}
|
||||||
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
|
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 truncate font-mono text-xs text-white/80">{cmd.type}</span>
|
<span className="min-w-0 truncate font-mono text-xs text-white/80">
|
||||||
|
{cmd.type}
|
||||||
|
</span>
|
||||||
<span className="flex shrink-0 items-center gap-2">
|
<span className="flex shrink-0 items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[commandTone(cmd.status)]}`}
|
className={`border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-widest ${STATUS_TONE_CLASSES[commandTone(cmd.status)]}`}
|
||||||
|
|
@ -278,4 +278,4 @@ export default async function SupplyBoxDetailPage({
|
||||||
</PageShell>
|
</PageShell>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ export interface SupplyBoxListData {
|
||||||
* mints a fresh sessionId, so rows from older generations are stale snapshots;
|
* mints a fresh sessionId, so rows from older generations are stale snapshots;
|
||||||
* only the newest sessionId per server is kept.
|
* only the newest sessionId per server is kept.
|
||||||
*/
|
*/
|
||||||
export async function getSupplyBoxList(
|
async function getSupplyBoxList(
|
||||||
payload: PayloadType,
|
payload: PayloadType,
|
||||||
user: SurfaceUser,
|
user: SurfaceUser,
|
||||||
): Promise<SupplyBoxListData> {
|
): Promise<SupplyBoxListData> {
|
||||||
|
|
@ -151,8 +151,8 @@ export default async function SupplyBoxesPage() {
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6">
|
||||||
<PageShell title="Supply Boxes" meta="LOGISTICS // CRATE SYNC">
|
<PageShell title="Supply Boxes" meta="LOGISTICS // CRATE SYNC">
|
||||||
<p className="max-w-2xl text-sm text-white/70">
|
<p className="max-w-2xl text-sm text-white/70">
|
||||||
Crate inventory synced from the game server through the Arma bridge.
|
Crate inventory synced from the game server through the Arma bridge. Rows show the newest
|
||||||
Rows show the newest generation per server; older sessions are filtered out.
|
generation per server; older sessions are filtered out.
|
||||||
</p>
|
</p>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|
||||||
|
|
@ -209,4 +209,4 @@ export default async function SupplyBoxesPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { LedgerRow, NoticeState, PageShell } from "@/components/frontend/operati
|
||||||
import type { SurfaceUser } from "@/lib/operations/surface";
|
import type { SurfaceUser } from "@/lib/operations/surface";
|
||||||
import { CancelButton } from "@/components/frontend/operations/reservations/ReservationCancelButton";
|
import { CancelButton } from "@/components/frontend/operations/reservations/ReservationCancelButton";
|
||||||
import type { OperationReservation } from "@/payload-types";
|
import type { OperationReservation } from "@/payload-types";
|
||||||
|
import { getReservationDetail } from "../surface";
|
||||||
|
|
||||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||||
|
|
||||||
|
|
@ -21,14 +22,18 @@ function relationId(value: unknown): number | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
function relationLabel(value: unknown): string {
|
function relationLabel(value: unknown): string {
|
||||||
if (typeof value === "object" && value !== null && "codeName" in (value as { codeName?: unknown })) {
|
if (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"codeName" in (value as { codeName?: unknown })
|
||||||
|
) {
|
||||||
const codeName = (value as { codeName?: unknown }).codeName;
|
const codeName = (value as { codeName?: unknown }).codeName;
|
||||||
if (typeof codeName === "string" && codeName) return codeName;
|
if (typeof codeName === "string" && codeName) return codeName;
|
||||||
}
|
}
|
||||||
return String(relationId(value) ?? "n/a");
|
return String(relationId(value) ?? "n/a");
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReservationDetail {
|
export interface ReservationDetail {
|
||||||
reservation: OperationReservation | null;
|
reservation: OperationReservation | null;
|
||||||
canCancel: boolean;
|
canCancel: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -40,31 +45,6 @@ interface ReservationDetail {
|
||||||
* canCancel is true only when the row is still reserved AND the viewer holds
|
* canCancel is true only when the row is still reserved AND the viewer holds
|
||||||
* operation-reservations:update.
|
* operation-reservations:update.
|
||||||
*/
|
*/
|
||||||
export async function getReservationDetail(
|
|
||||||
payload: PayloadType,
|
|
||||||
user: SurfaceUser,
|
|
||||||
id: string,
|
|
||||||
): Promise<ReservationDetail> {
|
|
||||||
let reservation: OperationReservation | null = null;
|
|
||||||
try {
|
|
||||||
reservation = await payload.findByID({
|
|
||||||
collection: "operation-reservations",
|
|
||||||
id: Number(id),
|
|
||||||
depth: 1,
|
|
||||||
user: user ?? undefined,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
reservation = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canCancel =
|
|
||||||
reservation !== null &&
|
|
||||||
reservation.status === "reserved" &&
|
|
||||||
user !== null &&
|
|
||||||
(await hasPermission(payload, user, "operation-reservations:update"));
|
|
||||||
|
|
||||||
return { reservation, canCancel };
|
|
||||||
}
|
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -78,7 +58,9 @@ function Section({ title, children }: { title: string; children: React.ReactNode
|
||||||
function kvRow(label: string, value: React.ReactNode) {
|
function kvRow(label: string, value: React.ReactNode) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-4 border border-white/10 px-2 py-1.5">
|
<div className="flex items-start justify-between gap-4 border border-white/10 px-2 py-1.5">
|
||||||
<span className="shrink-0 font-mono text-[9px] uppercase tracking-widest text-white/50">{label}</span>
|
<span className="shrink-0 font-mono text-[9px] uppercase tracking-widest text-white/50">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
<span className="min-w-0 truncate text-right font-mono text-xs text-white/80">{value}</span>
|
<span className="min-w-0 truncate text-right font-mono text-xs text-white/80">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -156,7 +138,10 @@ export default async function ReservationDetailPage({
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
{personnel.map((member) => (
|
{personnel.map((member) => (
|
||||||
<li key={member.id} className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5">
|
<li
|
||||||
|
key={member.id}
|
||||||
|
className="flex items-center justify-between gap-2 border border-white/10 px-2 py-1.5"
|
||||||
|
>
|
||||||
<span className="min-w-0 truncate font-mono text-xs text-white/80">
|
<span className="min-w-0 truncate font-mono text-xs text-white/80">
|
||||||
{relationLabel(member.user)} · id {relationId(member.user)}
|
{relationLabel(member.user)} · id {relationId(member.user)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -201,7 +186,9 @@ export default async function ReservationDetailPage({
|
||||||
<span className="min-w-0 truncate font-mono text-xs text-white/80">
|
<span className="min-w-0 truncate font-mono text-xs text-white/80">
|
||||||
{relationLabel(line.resource)} · id {relationId(line.resource)}
|
{relationLabel(line.resource)} · id {relationId(line.resource)}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-xs tabular-nums text-white/70">{line.amount}</span>
|
<span className="font-mono text-xs tabular-nums text-white/70">
|
||||||
|
{line.amount}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -211,7 +198,12 @@ export default async function ReservationDetailPage({
|
||||||
<Section title="Budget">
|
<Section title="Budget">
|
||||||
{budget?.amount != null || budget?.account != null ? (
|
{budget?.amount != null || budget?.account != null ? (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{kvRow("Account", budget.account != null ? `${relationLabel(budget.account)} · id ${relationId(budget.account)}` : "n/a")}
|
{kvRow(
|
||||||
|
"Account",
|
||||||
|
budget.account != null
|
||||||
|
? `${relationLabel(budget.account)} · id ${relationId(budget.account)}`
|
||||||
|
: "n/a",
|
||||||
|
)}
|
||||||
{kvRow("Amount", budget.amount ?? "n/a")}
|
{kvRow("Amount", budget.amount ?? "n/a")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -221,14 +213,23 @@ export default async function ReservationDetailPage({
|
||||||
|
|
||||||
<Section title="Routing">
|
<Section title="Routing">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{kvRow("Origin", reservation.origin != null ? relationLabel(reservation.origin) : "n/a")}
|
{kvRow(
|
||||||
{kvRow("Destination", reservation.destination != null ? relationLabel(reservation.destination) : "n/a")}
|
"Origin",
|
||||||
|
reservation.origin != null ? relationLabel(reservation.origin) : "n/a",
|
||||||
|
)}
|
||||||
|
{kvRow(
|
||||||
|
"Destination",
|
||||||
|
reservation.destination != null ? relationLabel(reservation.destination) : "n/a",
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Metadata">
|
<Section title="Metadata">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{kvRow("Created by", reservation.createdBy != null ? relationLabel(reservation.createdBy) : "n/a")}
|
{kvRow(
|
||||||
|
"Created by",
|
||||||
|
reservation.createdBy != null ? relationLabel(reservation.createdBy) : "n/a",
|
||||||
|
)}
|
||||||
{kvRow("Created at", reservation.createdAt)}
|
{kvRow("Created at", reservation.createdAt)}
|
||||||
{kvRow("Expires", reservation.expiresAt ?? "n/a")}
|
{kvRow("Expires", reservation.expiresAt ?? "n/a")}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,9 @@ import { listReservationOptions } from "@/lib/operations/reservationOptions";
|
||||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||||
import { CancelButton } from "@/components/frontend/operations/reservations/ReservationCancelButton";
|
import { CancelButton } from "@/components/frontend/operations/reservations/ReservationCancelButton";
|
||||||
import { CreateReservationDialog } from "@/components/frontend/operations/reservations/CreateReservationDialog";
|
import { CreateReservationDialog } from "@/components/frontend/operations/reservations/CreateReservationDialog";
|
||||||
|
import { getReservationList, type ReservationListData } from "./surface";
|
||||||
|
|
||||||
const RESERVATION_PAGE_SIZE = 20;
|
export const RESERVATION_PAGE_SIZE = 20;
|
||||||
|
|
||||||
export interface ReservationRow {
|
export interface ReservationRow {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -34,16 +35,7 @@ export interface ReservationFilters {
|
||||||
operationId?: string;
|
operationId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReservationListData {
|
export type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||||
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
|
* List of allocation reservations with status/operationId filters and
|
||||||
|
|
@ -53,51 +45,6 @@ type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||||
* result (capability false) so the page can render the permission notice
|
* result (capability false) so the page can render the permission notice
|
||||||
* instead of a list that looks like "no data".
|
* 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 {
|
function reservationHref(filters: ReservationFilters, page: number): string {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
@ -140,7 +87,12 @@ export default async function ReservationsPage({
|
||||||
status: params.status || undefined,
|
status: params.status || undefined,
|
||||||
operationId: params.operationId || undefined,
|
operationId: params.operationId || undefined,
|
||||||
};
|
};
|
||||||
const data = await getReservationList(payload, surfaceUser, filters, Number.isNaN(page) ? 1 : page);
|
const data = await getReservationList(
|
||||||
|
payload,
|
||||||
|
surfaceUser,
|
||||||
|
filters,
|
||||||
|
Number.isNaN(page) ? 1 : page,
|
||||||
|
);
|
||||||
|
|
||||||
// The cancel control is server-gated: only viewers holding
|
// The cancel control is server-gated: only viewers holding
|
||||||
// operation-reservations:update may render it, and only on reserved rows.
|
// operation-reservations:update may render it, and only on reserved rows.
|
||||||
|
|
@ -154,9 +106,7 @@ export default async function ReservationsPage({
|
||||||
const canCreate = user
|
const canCreate = user
|
||||||
? await hasPermission(payload, user, "operation-reservations:create")
|
? await hasPermission(payload, user, "operation-reservations:create")
|
||||||
: false;
|
: false;
|
||||||
const canManageLogistics = user
|
const canManageLogistics = user ? await hasLogisticsQualification(payload, user) : false;
|
||||||
? await hasLogisticsQualification(payload, user)
|
|
||||||
: false;
|
|
||||||
const options = canCreate && user ? await listReservationOptions(payload, user) : null;
|
const options = canCreate && user ? await listReservationOptions(payload, user) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -168,9 +118,16 @@ export default async function ReservationsPage({
|
||||||
row captures what was consumed or returned.
|
row captures what was consumed or returned.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method="get" className="flex flex-wrap items-end gap-3" data-slot="reservations-filters">
|
<form
|
||||||
|
method="get"
|
||||||
|
className="flex flex-wrap items-end gap-3"
|
||||||
|
data-slot="reservations-filters"
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="font-mono text-[9px] uppercase tracking-widest text-white/50" htmlFor="reservation-status">
|
<label
|
||||||
|
className="font-mono text-[9px] uppercase tracking-widest text-white/50"
|
||||||
|
htmlFor="reservation-status"
|
||||||
|
>
|
||||||
Status
|
Status
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
|
|
@ -187,7 +144,10 @@ export default async function ReservationsPage({
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="font-mono text-[9px] uppercase tracking-widest text-white/50" htmlFor="reservation-operation">
|
<label
|
||||||
|
className="font-mono text-[9px] uppercase tracking-widest text-white/50"
|
||||||
|
htmlFor="reservation-operation"
|
||||||
|
>
|
||||||
Operation
|
Operation
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
109
src/app/(frontend)/operations/reservations/surface.ts
Normal file
109
src/app/(frontend)/operations/reservations/surface.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import {
|
||||||
|
RESERVATION_PAGE_SIZE,
|
||||||
|
type PayloadType,
|
||||||
|
type ReservationFilters,
|
||||||
|
type ReservationRow,
|
||||||
|
} from "./page";
|
||||||
|
import type { OperationReservation } from "@/payload-types";
|
||||||
|
import type { ReservationDetail } from "./[id]/page";
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import type { Where } from "payload";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||||
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
|
import {
|
||||||
|
LedgerRow,
|
||||||
|
NoticeState,
|
||||||
|
PageShell,
|
||||||
|
STATUS_TONE_CLASSES,
|
||||||
|
statusToneFor,
|
||||||
|
type ReadoutCell,
|
||||||
|
} from "@/components/frontend/operations";
|
||||||
|
import type { SurfaceUser } from "@/lib/operations/surface";
|
||||||
|
import { listReservationOptions } from "@/lib/operations/reservationOptions";
|
||||||
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||||
|
import { CancelButton } from "@/components/frontend/operations/reservations/ReservationCancelButton";
|
||||||
|
import { CreateReservationDialog } from "@/components/frontend/operations/reservations/CreateReservationDialog";
|
||||||
|
|
||||||
|
export interface ReservationListData {
|
||||||
|
rows: ReservationRow[];
|
||||||
|
/** True when the viewer holds operation-reservations:read. False -> empty rows. */
|
||||||
|
capability: boolean;
|
||||||
|
page: number;
|
||||||
|
totalDocs: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReservationList(
|
||||||
|
payload: PayloadType,
|
||||||
|
user: SurfaceUser,
|
||||||
|
filters: ReservationFilters,
|
||||||
|
requestedPage = 1,
|
||||||
|
): Promise<ReservationListData> {
|
||||||
|
const canSee = await hasPermission(payload, user, "operation-reservations:read");
|
||||||
|
if (!canSee) {
|
||||||
|
return { rows: [], capability: false, page: 1, totalDocs: 0, totalPages: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditions: Where[] = [];
|
||||||
|
if (filters.status) conditions.push({ status: { equals: filters.status } });
|
||||||
|
if (filters.operationId) conditions.push({ operationId: { equals: filters.operationId } });
|
||||||
|
const where: Where | undefined = conditions.length > 0 ? { and: conditions } : undefined;
|
||||||
|
|
||||||
|
const found = await payload.find({
|
||||||
|
collection: "operation-reservations",
|
||||||
|
where,
|
||||||
|
sort: "-createdAt",
|
||||||
|
page: Math.max(1, requestedPage),
|
||||||
|
limit: RESERVATION_PAGE_SIZE,
|
||||||
|
depth: 0,
|
||||||
|
user: user ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows: ReservationRow[] = found.docs.map((doc) => {
|
||||||
|
const r = doc as unknown as ReservationRow;
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
reservationKey: r.reservationKey,
|
||||||
|
operationId: r.operationId,
|
||||||
|
status: r.status,
|
||||||
|
expiresAt: r.expiresAt ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
capability: true,
|
||||||
|
page: found.page ?? Math.max(1, requestedPage),
|
||||||
|
totalDocs: found.totalDocs ?? found.docs.length,
|
||||||
|
totalPages: found.totalPages ?? 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReservationDetail(
|
||||||
|
payload: PayloadType,
|
||||||
|
user: SurfaceUser,
|
||||||
|
id: string,
|
||||||
|
): Promise<ReservationDetail> {
|
||||||
|
let reservation: OperationReservation | null = null;
|
||||||
|
try {
|
||||||
|
reservation = await payload.findByID({
|
||||||
|
collection: "operation-reservations",
|
||||||
|
id: Number(id),
|
||||||
|
depth: 1,
|
||||||
|
user: user ?? undefined,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
reservation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canCancel =
|
||||||
|
reservation !== null &&
|
||||||
|
reservation.status === "reserved" &&
|
||||||
|
user !== null &&
|
||||||
|
(await hasPermission(payload, user, "operation-reservations:update"));
|
||||||
|
|
||||||
|
return { reservation, canCancel };
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,12 @@ import { getPayload, Payload } from "payload";
|
||||||
import config from "@/payload.config";
|
import config from "@/payload.config";
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi, type MockInstance } from "vitest";
|
||||||
import type { OperationReservation, User } from "@/payload-types";
|
import type { OperationReservation, User } from "@/payload-types";
|
||||||
import { getReservationList } from "@/app/(frontend)/operations/reservations/page";
|
import { getReservationList } from "@/app/(frontend)/operations/reservations/surface";
|
||||||
import { getReservationDetail } from "@/app/(frontend)/operations/reservations/[id]/page";
|
import { getReservationDetail } from "@/app/(frontend)/operations/reservations/surface";
|
||||||
import { createReservation, cancelReservation } from "@/app/(frontend)/operations/reservations/actions";
|
import {
|
||||||
|
createReservation,
|
||||||
|
cancelReservation,
|
||||||
|
} from "@/app/(frontend)/operations/reservations/actions";
|
||||||
|
|
||||||
// Mock next/headers so the action's authenticate() can run outside a request.
|
// Mock next/headers so the action's authenticate() can run outside a request.
|
||||||
vi.mock("next/headers", () => ({
|
vi.mock("next/headers", () => ({
|
||||||
|
|
@ -85,7 +88,9 @@ beforeAll(async () => {
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
for (const id of reservationIds) {
|
for (const id of reservationIds) {
|
||||||
await payload.delete({ collection: "operation-reservations", id, overrideAccess: true }).catch(() => {});
|
await payload
|
||||||
|
.delete({ collection: "operation-reservations", id, overrideAccess: true })
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
for (const id of userIds) {
|
for (const id of userIds) {
|
||||||
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
||||||
|
|
@ -122,12 +127,22 @@ describe("reservation surfaces: getReservationList", () => {
|
||||||
const cancelledId = await createReservedReservation(`${RUN}-filter-c`, `${RUN}-op-filter-c`);
|
const cancelledId = await createReservedReservation(`${RUN}-filter-c`, `${RUN}-op-filter-c`);
|
||||||
await cancelReservation({ reservationId: cancelledId });
|
await cancelReservation({ reservationId: cancelledId });
|
||||||
|
|
||||||
const reserved = await getReservationList(payload, { id: superUser.id }, { status: "reserved" }, 1);
|
const reserved = await getReservationList(
|
||||||
|
payload,
|
||||||
|
{ id: superUser.id },
|
||||||
|
{ status: "reserved" },
|
||||||
|
1,
|
||||||
|
);
|
||||||
const reservedRows = reserved.rows.filter((r) => r.reservationKey === `${RUN}-filter-r`);
|
const reservedRows = reserved.rows.filter((r) => r.reservationKey === `${RUN}-filter-r`);
|
||||||
expect(reservedRows).toHaveLength(1);
|
expect(reservedRows).toHaveLength(1);
|
||||||
expect(reservedRows[0].status).toBe("reserved");
|
expect(reservedRows[0].status).toBe("reserved");
|
||||||
|
|
||||||
const cancelled = await getReservationList(payload, { id: superUser.id }, { status: "cancelled" }, 1);
|
const cancelled = await getReservationList(
|
||||||
|
payload,
|
||||||
|
{ id: superUser.id },
|
||||||
|
{ status: "cancelled" },
|
||||||
|
1,
|
||||||
|
);
|
||||||
const cancelledRows = cancelled.rows.filter((r) => r.reservationKey === `${RUN}-filter-c`);
|
const cancelledRows = cancelled.rows.filter((r) => r.reservationKey === `${RUN}-filter-c`);
|
||||||
expect(cancelledRows).toHaveLength(1);
|
expect(cancelledRows).toHaveLength(1);
|
||||||
expect(cancelledRows[0].status).toBe("cancelled");
|
expect(cancelledRows[0].status).toBe("cancelled");
|
||||||
|
|
@ -136,7 +151,12 @@ describe("reservation surfaces: getReservationList", () => {
|
||||||
it("narrows rows by operationId", async () => {
|
it("narrows rows by operationId", async () => {
|
||||||
authSpy.mockResolvedValue({ user: superUser });
|
authSpy.mockResolvedValue({ user: superUser });
|
||||||
await createReservedReservation(`${RUN}-opid-1`, `${RUN}-op-specific`);
|
await createReservedReservation(`${RUN}-opid-1`, `${RUN}-op-specific`);
|
||||||
const data = await getReservationList(payload, { id: superUser.id }, { operationId: `${RUN}-op-specific` }, 1);
|
const data = await getReservationList(
|
||||||
|
payload,
|
||||||
|
{ id: superUser.id },
|
||||||
|
{ operationId: `${RUN}-op-specific` },
|
||||||
|
1,
|
||||||
|
);
|
||||||
expect(data.rows).toHaveLength(1);
|
expect(data.rows).toHaveLength(1);
|
||||||
expect(data.rows[0].operationId).toBe(`${RUN}-op-specific`);
|
expect(data.rows[0].operationId).toBe(`${RUN}-op-specific`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue