Compare commits
21 commits
3af17928e8
...
dcc308c2ae
| Author | SHA1 | Date | |
|---|---|---|---|
| dcc308c2ae | |||
| 949459647d | |||
| 9f80361d14 | |||
| eb3dc0231c | |||
| 5f5072399b | |||
| d485a82fa6 | |||
| 12b19b3d6a | |||
| a8d1a8870e | |||
| 8ee02faa59 | |||
| a399f3feab | |||
| da9ea23271 | |||
| a4ea3ea387 | |||
| 47d338c868 | |||
| cdfd5c4278 | |||
| ccfdc3b46e | |||
| dcb4893b10 | |||
| 91dc48ad2c | |||
| 29f7e2b57a | |||
| 227885daf7 | |||
| d757c58c5d | |||
| ffb2c2c5d9 |
53 changed files with 24934 additions and 473 deletions
|
|
@ -341,6 +341,7 @@ In development, `postgresAdapter` uses `push: false` — run `bun run payload mi
|
||||||
- **Prettier**: double quotes, trailing commas (all), 100 char print width, semicolons.
|
- **Prettier**: double quotes, trailing commas (all), 100 char print width, semicolons.
|
||||||
- **ESLint**: `next/core-web-vitals` + `next/typescript`. `@typescript-eslint/no-unused-vars` warns (prefix unused with `_`).
|
- **ESLint**: `next/core-web-vitals` + `next/typescript`. `@typescript-eslint/no-unused-vars` warns (prefix unused with `_`).
|
||||||
- **Tailwind v4**: no `tailwind.config` — configured via `@tailwindcss/postcss` in `postcss.config.mjs` and CSS imports. Use `cn()` from `@/lib/utils` for class merging.
|
- **Tailwind v4**: no `tailwind.config` — configured via `@tailwindcss/postcss` in `postcss.config.mjs` and CSS imports. Use `cn()` from `@/lib/utils` for class merging.
|
||||||
|
- **No em-dashes in final outputs**: never emit em-dashes (`—`, `–`) in anything you produce as a final deliverable: chat replies, commit messages, PR descriptions, docs, comments, UI copy. Replace them with a comma, semicolon, colon, or parenthetical, or split into two sentences. (Earlier bullets in this section predate the rule; new writing must comply.)
|
||||||
|
|
||||||
## UI components
|
## UI components
|
||||||
|
|
||||||
|
|
@ -359,6 +360,8 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
|
||||||
|
|
||||||
`bun run deploy` bumps the patch version (via `bun pm version patch`) and runs the production build. Push the resulting version commit to deploy through Coolify; the legacy `build/deploy.sh` path is no longer used.
|
`bun run deploy` bumps the patch version (via `bun pm version patch`) and runs the production build. Push the resulting version commit to deploy through Coolify; the legacy `build/deploy.sh` path is no longer used.
|
||||||
|
|
||||||
|
**Pre-deploy dash gate**: before any production build, release version bump, or deploy, run the `dash-sanitize` skill (`/home/jason/.agents/skills/dash-sanitize/SKILL.md`) and show its verification output (re-scan classification + filtered tsc) before proceeding. A dirty user-facing dash scan pauses the release; a clean re-scan on an already-swept tree takes seconds and should still be run rather than assumed.
|
||||||
|
|
||||||
**Version bump after git commands**: whenever a `/git` or `/git-master` workflow creates commits, also bump the patch version with `bun pm version patch --message "v%s - <descriptor>"` and push it together with the work. `<descriptor>` is a short comma-separated summary of what the release ships (derived from the commits just created), e.g. `v0.2.4 - add voicelines, update git rules` — never a bare version number. The command itself creates the `vX.Y.Z` version commit **and** a local git tag. `push.followTags` is set in this repo, so a plain `git push` automatically carries the annotated release tag with the commits — but never use a bare `git push --tags`, which sends only tags and not the branch. The deployed site's version overlay reads `package.json`'s `version` (Coolify builds have no git metadata, so the displayed version is `<semver>+<commit sha>`), and an unbumped semver means every deploy shows the same `0.2.0`-style number even though the commit-hash suffix changes. Skipping the bump — or pushing the commit without its tag — makes releases indistinguishable and breaks version history.
|
**Version bump after git commands**: whenever a `/git` or `/git-master` workflow creates commits, also bump the patch version with `bun pm version patch --message "v%s - <descriptor>"` and push it together with the work. `<descriptor>` is a short comma-separated summary of what the release ships (derived from the commits just created), e.g. `v0.2.4 - add voicelines, update git rules` — never a bare version number. The command itself creates the `vX.Y.Z` version commit **and** a local git tag. `push.followTags` is set in this repo, so a plain `git push` automatically carries the annotated release tag with the commits — but never use a bare `git push --tags`, which sends only tags and not the branch. The deployed site's version overlay reads `package.json`'s `version` (Coolify builds have no git metadata, so the displayed version is `<semver>+<commit sha>`), and an unbumped semver means every deploy shows the same `0.2.0`-style number even though the commit-hash suffix changes. Skipping the bump — or pushing the commit without its tag — makes releases indistinguishable and breaks version history.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "polaris-task-force",
|
"name": "polaris-task-force",
|
||||||
"version": "0.2.6",
|
"version": "0.2.8",
|
||||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
22
src/app/(frontend)/events/page.tsx
Normal file
22
src/app/(frontend)/events/page.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import EventLogTable from "@/components/frontend/events/EventLogTable";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Event Log (Polaris Task Force)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EventLogPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Event Log</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
The full game event ledger: system actions and GM narrative events. Filter by type,
|
||||||
|
target, source, or date range.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<EventLogTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -166,7 +166,7 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{campaign.startDate && campaign.endDate && <span>—</span>}
|
{campaign.startDate && campaign.endDate && <span>-</span>}
|
||||||
{campaign.endDate ? (
|
{campaign.endDate ? (
|
||||||
<span>
|
<span>
|
||||||
<ClientDate
|
<ClientDate
|
||||||
|
|
@ -267,14 +267,14 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
|
||||||
)}
|
)}
|
||||||
{campaign.endDate ? (
|
{campaign.endDate ? (
|
||||||
<>
|
<>
|
||||||
{" — "}
|
{" - "}
|
||||||
<ClientDate
|
<ClientDate
|
||||||
date={campaign.endDate}
|
date={campaign.endDate}
|
||||||
options={{ year: "numeric", month: "short", day: "numeric" }}
|
options={{ year: "numeric", month: "short", day: "numeric" }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
" — Present"
|
" - Present"
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { getPayload } from "payload";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { headers as nextHeaders } from "next/headers";
|
import { headers as nextHeaders } from "next/headers";
|
||||||
import type { Campaign, Map, Media, Mission } from "@/payload-types";
|
import type { Campaign, Map, Media, Mission, User } from "@/payload-types";
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
CalendarDaysIcon,
|
CalendarDaysIcon,
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
FlagIcon,
|
FlagIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
|
UserIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { ClientDate } from "@/components/frontend/ClientDate";
|
import { ClientDate } from "@/components/frontend/ClientDate";
|
||||||
|
|
@ -94,6 +95,13 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
||||||
? (missionWithCoverImage.coverImage as Media)
|
? (missionWithCoverImage.coverImage as Media)
|
||||||
: null;
|
: null;
|
||||||
const coverImageUrl = coverImage?.url ?? null;
|
const coverImageUrl = coverImage?.url ?? null;
|
||||||
|
const host =
|
||||||
|
mission.operationType === "side" &&
|
||||||
|
typeof mission.createdBy === "object" &&
|
||||||
|
mission.createdBy !== null
|
||||||
|
? (mission.createdBy as User)
|
||||||
|
: null;
|
||||||
|
const hostLabel = host ? (host.displayName || host.username) : null;
|
||||||
|
|
||||||
const durationHours = Math.floor(mission.classification.estimatedDuration / 60);
|
const durationHours = Math.floor(mission.classification.estimatedDuration / 60);
|
||||||
const durationMins = mission.classification.estimatedDuration % 60;
|
const durationMins = mission.classification.estimatedDuration % 60;
|
||||||
|
|
@ -328,6 +336,17 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{hostLabel && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<UserIcon className="size-4 text-white/60" />
|
||||||
|
<span className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/50">
|
||||||
|
Host
|
||||||
|
</span>
|
||||||
|
<span className="max-w-56 truncate text-sm text-white">{hostLabel}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{campaign && (
|
{campaign && (
|
||||||
<Link
|
<Link
|
||||||
href={`/intelligence/campaigns/${campaign.id}`}
|
href={`/intelligence/campaigns/${campaign.id}`}
|
||||||
|
|
@ -367,7 +386,7 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Max Players</span>
|
<span className="text-muted-foreground">Max Players</span>
|
||||||
<p className="font-medium">{mission.missionRoles?.maxPlayers ?? "—"}</p>
|
<p className="font-medium">{mission.missionRoles?.maxPlayers ?? "-"}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
|
||||||
|
|
@ -738,6 +738,70 @@ export async function cancelFridaySideOp(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function markSideOpReady(
|
||||||
|
missionId: number,
|
||||||
|
): Promise<ActionResult<{ missionId: number }>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
const userId = user.id as number;
|
||||||
|
|
||||||
|
const mission = await payload.findByID({
|
||||||
|
collection: "missions",
|
||||||
|
id: missionId,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const canManage = await hasPermission(payload, user, "missions:update");
|
||||||
|
if (!canManage && !isSideOpOwner(mission, userId)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: "Only the player who scheduled this side operation can mark it Ready.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = mission.ownershipAndStatus.status;
|
||||||
|
if (status === "Ready") {
|
||||||
|
return { success: true, data: { missionId: mission.id } };
|
||||||
|
}
|
||||||
|
if (!["Concept", "Planning", "Scheduled"].includes(status)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
status === "Active"
|
||||||
|
? "This mission is already underway and cannot be marked Ready."
|
||||||
|
: `This mission is ${status.toLowerCase()} and can no longer be marked Ready.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await payload.update({
|
||||||
|
collection: "missions",
|
||||||
|
id: missionId,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
data: {
|
||||||
|
ownershipAndStatus: {
|
||||||
|
status: "Ready",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await emitGameEvent(payload, {
|
||||||
|
type: EventTypes.MissionReady,
|
||||||
|
message: `${user.displayName || user.username} marked side operation "${updated.name}" (${updated.codeName}) as Ready. Details are locked in and the roll-call can be posted.`,
|
||||||
|
actor: userId,
|
||||||
|
targetCollection: "missions",
|
||||||
|
targetId: updated.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: { missionId: updated.id } };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
return message === "Unauthorized"
|
||||||
|
? { success: false, error: "You must be logged in." }
|
||||||
|
: { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function moveFridaySideOpToCampaign(
|
export async function moveFridaySideOpToCampaign(
|
||||||
missionId: number,
|
missionId: number,
|
||||||
campaignId: number,
|
campaignId: number,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { pluralize } from "@/lib/utils";
|
||||||
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
||||||
import { currencyConfigFromGameRules } from "@/lib/banking";
|
import { currencyConfigFromGameRules } from "@/lib/banking";
|
||||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||||
|
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
BoxIcon,
|
BoxIcon,
|
||||||
|
|
@ -183,6 +184,8 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const canManage = await hasLogisticsQualification(payload, user);
|
const canManage = await hasLogisticsQualification(payload, user);
|
||||||
|
const canManageStorage = await isSuperuser(payload, user);
|
||||||
|
const canTransferStorage = canManageStorage || canManage;
|
||||||
|
|
||||||
const classificationsRes = await payload.find({
|
const classificationsRes = await payload.find({
|
||||||
collection: "labor-classifications",
|
collection: "labor-classifications",
|
||||||
|
|
@ -307,7 +310,13 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
||||||
{structure.faction.name}
|
{structure.faction.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{user && <ManageStorageButton structureId={structure.id} />}
|
{user && (
|
||||||
|
<ManageStorageButton
|
||||||
|
structureId={structure.id}
|
||||||
|
canManage={canManageStorage}
|
||||||
|
canTransfer={canTransferStorage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -413,7 +422,11 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
||||||
currencyConfig={currencyConfig}
|
currencyConfig={currencyConfig}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StructureStorageView structure={structure} />
|
<StructureStorageView
|
||||||
|
structure={structure}
|
||||||
|
canMutate={canManageStorage}
|
||||||
|
canArrange={canTransferStorage}
|
||||||
|
/>
|
||||||
|
|
||||||
<StorageRules
|
<StorageRules
|
||||||
allowedStorage={structureType.allowedStorage}
|
allowedStorage={structureType.allowedStorage}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
import config from "@payload-config";
|
import config from "@payload-config";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import { GameStructure, Resource, Structure } from "@/payload-types";
|
import { GameStructure, Resource, Structure } from "@/payload-types";
|
||||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||||
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||||
|
|
@ -14,6 +15,19 @@ interface ActionResult {
|
||||||
data?: GameStructure;
|
data?: GameStructure;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PayloadClient = Awaited<ReturnType<typeof getPayload>>;
|
||||||
|
type AuthedUser = Awaited<ReturnType<typeof authenticate>>["user"];
|
||||||
|
|
||||||
|
// SECURITY: creates/destroys stored items, overriding economy/production — superusers only
|
||||||
|
async function canManageStorage(payload: PayloadClient, user: AuthedUser): Promise<boolean> {
|
||||||
|
return isSuperuser(payload, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY: conserves quantities (moves items around) — logistics personnel + superusers
|
||||||
|
async function canHandleStorage(payload: PayloadClient, user: AuthedUser): Promise<boolean> {
|
||||||
|
return (await isSuperuser(payload, user)) || (await hasLogisticsQualification(payload, user));
|
||||||
|
}
|
||||||
|
|
||||||
async function authenticate() {
|
async function authenticate() {
|
||||||
const payloadConfig = await config;
|
const payloadConfig = await config;
|
||||||
const payload = await getPayload({ config: payloadConfig });
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
|
@ -74,13 +88,30 @@ export async function addResource(
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
const { payload, user } = await authenticate();
|
const { payload, user } = await authenticate();
|
||||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
if (!(await canManageStorage(payload, user))) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
error: "Only superusers may deposit resources into structures.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
return await addResourceInternal(payload, user, structureId, resourceId, amount);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
if (message === "Unauthorized") {
|
||||||
|
return { success: false, error: "You must be logged in." };
|
||||||
|
}
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addResourceInternal(
|
||||||
|
payload: PayloadClient,
|
||||||
|
user: AuthedUser,
|
||||||
|
structureId: number,
|
||||||
|
resourceId: number,
|
||||||
|
amount: number,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
if (amount <= 0) {
|
if (amount <= 0) {
|
||||||
return { success: false, error: "Amount must be greater than zero." };
|
return { success: false, error: "Amount must be greater than zero." };
|
||||||
}
|
}
|
||||||
|
|
@ -264,13 +295,30 @@ export async function removeResource(
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
const { payload, user } = await authenticate();
|
const { payload, user } = await authenticate();
|
||||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
if (!(await canManageStorage(payload, user))) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Insufficient permissions. Admin or Developer role required for withdrawal.",
|
error: "Only superusers may withdraw resources from structures.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
return await removeResourceInternal(payload, user, structureId, resourceId, amount);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
if (message === "Unauthorized") {
|
||||||
|
return { success: false, error: "You must be logged in." };
|
||||||
|
}
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeResourceInternal(
|
||||||
|
payload: PayloadClient,
|
||||||
|
user: AuthedUser,
|
||||||
|
structureId: number,
|
||||||
|
resourceId: number,
|
||||||
|
amount: number,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
if (amount <= 0) {
|
if (amount <= 0) {
|
||||||
return { success: false, error: "Amount must be greater than zero." };
|
return { success: false, error: "Amount must be greater than zero." };
|
||||||
}
|
}
|
||||||
|
|
@ -373,10 +421,10 @@ export async function transferResource(
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
try {
|
try {
|
||||||
const { payload, user } = await authenticate();
|
const { payload, user } = await authenticate();
|
||||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
if (!(await canHandleStorage(payload, user))) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Insufficient permissions. User, Admin, or Developer role required for transfer.",
|
error: "Logistics personnel or superuser role required for transfers.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -464,17 +512,23 @@ export async function transferResource(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from source
|
// Remove from source (unguarded internal — gate already applied above)
|
||||||
const removeResult = await removeResource(fromStructureId, resourceId, amount);
|
const removeResult = await removeResourceInternal(
|
||||||
|
payload,
|
||||||
|
user,
|
||||||
|
fromStructureId,
|
||||||
|
resourceId,
|
||||||
|
amount,
|
||||||
|
);
|
||||||
if (!removeResult.success) {
|
if (!removeResult.success) {
|
||||||
return removeResult;
|
return removeResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to destination
|
// Add to destination
|
||||||
const addResult = await addResource(toStructureId, resourceId, amount);
|
const addResult = await addResourceInternal(payload, user, toStructureId, resourceId, amount);
|
||||||
if (!addResult.success) {
|
if (!addResult.success) {
|
||||||
// Rollback: re-add to source
|
// Rollback: re-add to source
|
||||||
await addResource(fromStructureId, resourceId, amount);
|
await addResourceInternal(payload, user, fromStructureId, resourceId, amount);
|
||||||
return { success: false, error: `Transfer failed at destination: ${addResult.error}` };
|
return { success: false, error: `Transfer failed at destination: ${addResult.error}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -524,10 +578,10 @@ export async function placeResourceOnGrid(
|
||||||
): Promise<GridActionResult> {
|
): Promise<GridActionResult> {
|
||||||
try {
|
try {
|
||||||
const { payload, user } = await authenticate();
|
const { payload, user } = await authenticate();
|
||||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
if (!(await canManageStorage(payload, user))) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Insufficient permissions. Admin or Developer role required for placement.",
|
error: "Only superusers may place resources on the storage grid.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -641,10 +695,10 @@ export async function moveGridItem(
|
||||||
): Promise<GridActionResult> {
|
): Promise<GridActionResult> {
|
||||||
try {
|
try {
|
||||||
const { payload, user } = await authenticate();
|
const { payload, user } = await authenticate();
|
||||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
if (!(await canHandleStorage(payload, user))) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
error: "Logistics personnel or superuser role required.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const structure = await getStructure(payload, structureId);
|
const structure = await getStructure(payload, structureId);
|
||||||
|
|
@ -730,10 +784,10 @@ export async function mergeGridStacks(
|
||||||
): Promise<GridActionResult> {
|
): Promise<GridActionResult> {
|
||||||
try {
|
try {
|
||||||
const { payload, user } = await authenticate();
|
const { payload, user } = await authenticate();
|
||||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
if (!(await canHandleStorage(payload, user))) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
error: "Logistics personnel or superuser role required.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const structure = await getStructure(payload, structureId);
|
const structure = await getStructure(payload, structureId);
|
||||||
|
|
@ -812,10 +866,10 @@ export async function rotateGridItem(
|
||||||
): Promise<GridActionResult> {
|
): Promise<GridActionResult> {
|
||||||
try {
|
try {
|
||||||
const { payload, user } = await authenticate();
|
const { payload, user } = await authenticate();
|
||||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
if (!(await canHandleStorage(payload, user))) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
error: "Logistics personnel or superuser role required.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const structure = await getStructure(payload, structureId);
|
const structure = await getStructure(payload, structureId);
|
||||||
|
|
@ -899,10 +953,10 @@ export async function removeGridItem(
|
||||||
): Promise<GridActionResult> {
|
): Promise<GridActionResult> {
|
||||||
try {
|
try {
|
||||||
const { payload, user } = await authenticate();
|
const { payload, user } = await authenticate();
|
||||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
if (!(await canManageStorage(payload, user))) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Insufficient permissions. Admin or Developer role required for removal.",
|
error: "Only superusers may remove items from the storage grid.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const structure = await getStructure(payload, structureId);
|
const structure = await getStructure(payload, structureId);
|
||||||
|
|
@ -953,10 +1007,10 @@ export async function splitGridStack(
|
||||||
): Promise<GridActionResult> {
|
): Promise<GridActionResult> {
|
||||||
try {
|
try {
|
||||||
const { payload, user } = await authenticate();
|
const { payload, user } = await authenticate();
|
||||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
if (!(await canHandleStorage(payload, user))) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
error: "Logistics personnel or superuser role required.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1061,8 +1115,8 @@ export async function splitGridStack(
|
||||||
export async function retrieveFromVoid(structureId: number): Promise<GridActionResult> {
|
export async function retrieveFromVoid(structureId: number): Promise<GridActionResult> {
|
||||||
try {
|
try {
|
||||||
const { payload, user } = await authenticate();
|
const { payload, user } = await authenticate();
|
||||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
if (!(await canHandleStorage(payload, user))) {
|
||||||
return { success: false, error: "Insufficient permissions." };
|
return { success: false, error: "Logistics personnel or superuser role required." };
|
||||||
}
|
}
|
||||||
const structure = await getStructure(payload, structureId);
|
const structure = await getStructure(payload, structureId);
|
||||||
const gridW = structure.type.xSize ?? 0;
|
const gridW = structure.type.xSize ?? 0;
|
||||||
|
|
@ -1159,10 +1213,10 @@ export async function retrieveFromVoid(structureId: number): Promise<GridActionR
|
||||||
export async function autoArrangeGrid(structureId: number): Promise<GridActionResult> {
|
export async function autoArrangeGrid(structureId: number): Promise<GridActionResult> {
|
||||||
try {
|
try {
|
||||||
const { payload, user } = await authenticate();
|
const { payload, user } = await authenticate();
|
||||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
if (!(await canHandleStorage(payload, user))) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
error: "Logistics personnel or superuser role required.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const structure = await getStructure(payload, structureId);
|
const structure = await getStructure(payload, structureId);
|
||||||
|
|
|
||||||
|
|
@ -263,7 +263,7 @@ export default async function VehicleDetailPage({ params }: VehicleDetailPagePro
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle>Grid Dimensions</ItemTitle>
|
<ItemTitle>Grid Dimensions</ItemTitle>
|
||||||
<ItemDescription>
|
<ItemDescription>
|
||||||
{vehicle.width ?? "—"}×{vehicle.height ?? "—"}
|
{vehicle.width ?? "-"}×{vehicle.height ?? "-"}
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</Item>
|
</Item>
|
||||||
|
|
|
||||||
|
|
@ -307,7 +307,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
const airKills = scoreboard.airKills ?? 0;
|
const airKills = scoreboard.airKills ?? 0;
|
||||||
const deaths = scoreboard.deaths ?? 0;
|
const deaths = scoreboard.deaths ?? 0;
|
||||||
const totalKills = infantryKills + vehicleKills + armorKills + airKills;
|
const totalKills = infantryKills + vehicleKills + armorKills + airKills;
|
||||||
const kdRatio = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills > 0 ? "∞" : "—";
|
const kdRatio = deaths > 0 ? (totalKills / deaths).toFixed(2) : totalKills > 0 ? "∞" : "-";
|
||||||
|
|
||||||
const enlistDate = new Date(profile.dossier.enlistmentDate);
|
const enlistDate = new Date(profile.dossier.enlistmentDate);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -562,7 +562,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
||||||
Deaths
|
Deaths
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-right font-mono">{deaths.toLocaleString()}</td>
|
<td className="px-4 py-2 text-right font-mono">{deaths.toLocaleString()}</td>
|
||||||
<td className="px-4 py-2 text-right font-mono text-muted-foreground">—</td>
|
<td className="px-4 py-2 text-right font-mono text-muted-foreground">-</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr className="bg-muted/30">
|
<tr className="bg-muted/30">
|
||||||
<td className="px-4 py-2.5 flex items-center gap-2 font-semibold">
|
<td className="px-4 py-2.5 flex items-center gap-2 font-semibold">
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export interface BotConfig {
|
||||||
attendancePollMs: number;
|
attendancePollMs: number;
|
||||||
notificationPollMs: number;
|
notificationPollMs: number;
|
||||||
evaluationPollMs: number;
|
evaluationPollMs: number;
|
||||||
|
readyReminderPollMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const required = (name: string): string => {
|
const required = (name: string): string => {
|
||||||
|
|
@ -48,4 +49,5 @@ export const botConfig: BotConfig = {
|
||||||
attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000),
|
attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000),
|
||||||
notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000),
|
notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000),
|
||||||
evaluationPollMs: pollMs("DISCORD_EVALUATION_POLL_MS", 60_000),
|
evaluationPollMs: pollMs("DISCORD_EVALUATION_POLL_MS", 60_000),
|
||||||
|
readyReminderPollMs: pollMs("DISCORD_READY_REMINDER_POLL_MS", 60_000),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ const getReminderMissions = async (payload: Payload): Promise<Mission[]> => {
|
||||||
const formatLinks = (links: ReminderLink[], appUrl: string): string => {
|
const formatLinks = (links: ReminderLink[], appUrl: string): string => {
|
||||||
const lines = links.map((link) => `• [${link.displayName}](${appUrl}${link.profilePath})`);
|
const lines = links.map((link) => `• [${link.displayName}](${appUrl}${link.profilePath})`);
|
||||||
// Discord embed field values cap at 1024 chars.
|
// Discord embed field values cap at 1024 chars.
|
||||||
return lines.join("\n").slice(0, 1024) || "—";
|
return lines.join("\n").slice(0, 1024) || "-";
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderReminderEmbed = (
|
const renderReminderEmbed = (
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { start as startMissionEmbeds } from "./missionEmbeds";
|
||||||
import { start as startNotificationBridge } from "./notificationBridge";
|
import { start as startNotificationBridge } from "./notificationBridge";
|
||||||
import { start as startTransferRequests } from "./transferRequests";
|
import { start as startTransferRequests } from "./transferRequests";
|
||||||
import { start as startEvaluationReminders } from "./evaluationReminders";
|
import { start as startEvaluationReminders } from "./evaluationReminders";
|
||||||
|
import { start as startReadyReminders } from "./readyReminders";
|
||||||
|
|
||||||
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
|
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
|
||||||
|
|
||||||
|
|
@ -12,4 +13,5 @@ export const services: ServiceStarter[] = [
|
||||||
startNotificationBridge,
|
startNotificationBridge,
|
||||||
startTransferRequests,
|
startTransferRequests,
|
||||||
startEvaluationReminders,
|
startEvaluationReminders,
|
||||||
|
startReadyReminders,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ export const renderAttendanceEmbed = (
|
||||||
summary: MissionAttendanceSummary,
|
summary: MissionAttendanceSummary,
|
||||||
): EmbedBuilder => {
|
): EmbedBuilder => {
|
||||||
const startUnix = Math.floor(new Date(mission.classification.startDateTime).getTime() / 1000);
|
const startUnix = Math.floor(new Date(mission.classification.startDateTime).getTime() / 1000);
|
||||||
const names = (users: User[]): string => users.map(memberName).join("\n").slice(0, 1024) || "—";
|
const names = (users: User[]): string => users.map(memberName).join("\n").slice(0, 1024) || "-";
|
||||||
return new EmbedBuilder()
|
return new EmbedBuilder()
|
||||||
.setTitle(missionTitle(mission))
|
.setTitle(missionTitle(mission))
|
||||||
.setDescription(
|
.setDescription(
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,19 @@ import type { Payload } from "payload";
|
||||||
import type { User, UserNotification } from "@/payload-types";
|
import type { User, UserNotification } from "@/payload-types";
|
||||||
import { botConfig } from "@/bot/config";
|
import { botConfig } from "@/bot/config";
|
||||||
import { TRANSFER_NOTIFICATION_TYPES } from "@/lib/transfers/notificationTypes";
|
import { TRANSFER_NOTIFICATION_TYPES } from "@/lib/transfers/notificationTypes";
|
||||||
|
import { READY_REMINDER_NOTIFICATION_TYPE } from "@/lib/intelligence/readyReminders";
|
||||||
|
|
||||||
const MAX_SENDS_PER_TICK = 5;
|
const MAX_SENDS_PER_TICK = 5;
|
||||||
const SEND_DELAY_MS = 300;
|
const SEND_DELAY_MS = 300;
|
||||||
const EMBED_COLOR = 0x3b82f6;
|
const EMBED_COLOR = 0x3b82f6;
|
||||||
|
|
||||||
// Transfer notifications are delivered by the dedicated transfer bot service
|
// Transfer notifications are delivered by the dedicated transfer bot service
|
||||||
// (Phase 2), not this generic bridge. Exclude exactly these types.
|
// (Phase 2), not this generic bridge. Ready reminders are delivered by the
|
||||||
const NON_BRIDGED_TYPES = new Set<string>(Object.values(TRANSFER_NOTIFICATION_TYPES));
|
// dedicated readyReminders DM service. Exclude exactly these types.
|
||||||
|
const NON_BRIDGED_TYPES = new Set<string>([
|
||||||
|
...Object.values(TRANSFER_NOTIFICATION_TYPES),
|
||||||
|
READY_REMINDER_NOTIFICATION_TYPE,
|
||||||
|
]);
|
||||||
|
|
||||||
let cursor: number | null = null;
|
let cursor: number | null = null;
|
||||||
|
|
||||||
|
|
|
||||||
130
src/bot/services/readyReminders.ts
Normal file
130
src/bot/services/readyReminders.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { type Client, EmbedBuilder } from "discord.js";
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
import type { Mission, User } from "@/payload-types";
|
||||||
|
import { botConfig } from "@/bot/config";
|
||||||
|
import {
|
||||||
|
READY_REMINDER_LEAD_DAYS,
|
||||||
|
daysUntil,
|
||||||
|
getReadyReminderMissions,
|
||||||
|
isReminderDue,
|
||||||
|
missionTitle,
|
||||||
|
} from "@/lib/intelligence/readyReminders";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ready-reminder service: starting READY_REMINDER_LEAD_DAYS days before a
|
||||||
|
* player-scheduled side op, the op's creator gets a once-per-day DM nudging
|
||||||
|
* them to finish the op and mark it "Ready" — the status that posts the
|
||||||
|
* roll-call, locks in details, and delivers the mission hooks.
|
||||||
|
*
|
||||||
|
* The DM deliberately ignores `preferences.discord.enabled` (the notification
|
||||||
|
* bridge opt-in): that toggle defaults to false and is not yet exposed in the
|
||||||
|
* web UI, so honoring it here would silently DM nobody. This is a dedicated
|
||||||
|
* creator-prompt flow, not a forwarding of the generic notification stream
|
||||||
|
* (the web half of each reminder is excluded from the bridge via
|
||||||
|
* NON_BRIDGED_TYPES). A per-day marker on the mission (readyReminderDmSentAt)
|
||||||
|
* keeps the cadence to one DM per day.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const EMBED_COLOR = 0xf59e0b;
|
||||||
|
const MISSIONS_PER_TICK = 10;
|
||||||
|
const SEND_DELAY_MS = 300;
|
||||||
|
|
||||||
|
let reconcileRunning = false;
|
||||||
|
|
||||||
|
const errorMessage = (error: unknown): string =>
|
||||||
|
error instanceof Error ? error.message : "Unknown error";
|
||||||
|
|
||||||
|
const creatorIdOf = (mission: Mission): number | null =>
|
||||||
|
typeof mission.createdBy === "object" ? (mission.createdBy?.id ?? null) : (mission.createdBy ?? null);
|
||||||
|
|
||||||
|
const renderReminderEmbed = (mission: Mission, now: Date): EmbedBuilder => {
|
||||||
|
const days = daysUntil(mission.classification.startDateTime, now);
|
||||||
|
const when = days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`;
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle(`⏰ Ready check: ${missionTitle(mission)}`)
|
||||||
|
.setDescription(
|
||||||
|
`Your op starts **${when}** and it isn't marked **Ready** yet.\n\n` +
|
||||||
|
"Finish up the operation and set the mission status to **Ready** so the roll-call can be posted, the details can be locked in, and all the proper mission hooks can be delivered.",
|
||||||
|
)
|
||||||
|
.setColor(EMBED_COLOR)
|
||||||
|
.addFields({
|
||||||
|
name: "Open the op",
|
||||||
|
value: `${botConfig.appUrl}/intelligence/missions/${mission.id}`,
|
||||||
|
})
|
||||||
|
.setFooter({ text: "You'll get this reminder once a day until the op is Ready." });
|
||||||
|
};
|
||||||
|
|
||||||
|
const processMission = async (
|
||||||
|
client: Client,
|
||||||
|
payload: Payload,
|
||||||
|
mission: Mission,
|
||||||
|
now: Date,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const creatorId = creatorIdOf(mission);
|
||||||
|
if (!creatorId) return false;
|
||||||
|
|
||||||
|
const creator = (await payload.findByID({
|
||||||
|
collection: "users",
|
||||||
|
id: creatorId,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as unknown as User;
|
||||||
|
if (!creator.discordId) return false;
|
||||||
|
|
||||||
|
const target = await client.users.fetch(creator.discordId);
|
||||||
|
await target.send({ embeds: [renderReminderEmbed(mission, now)] });
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: "missions",
|
||||||
|
id: mission.id,
|
||||||
|
data: { readyReminderDmSentAt: now.toISOString() },
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
payload.logger.info(
|
||||||
|
`[Bot] Sent ready reminder DM for mission #${mission.id} (${mission.name}) to user #${creatorId}.`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ReconcileSummary {
|
||||||
|
processed: number;
|
||||||
|
sent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reconcile = async (client: Client, payload: Payload): Promise<ReconcileSummary> => {
|
||||||
|
if (reconcileRunning) return { processed: 0, sent: 0 };
|
||||||
|
reconcileRunning = true;
|
||||||
|
let processed = 0;
|
||||||
|
let sent = 0;
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const missions = (await getReadyReminderMissions(payload, now)).slice(0, MISSIONS_PER_TICK);
|
||||||
|
for (const mission of missions) {
|
||||||
|
if (!isReminderDue(mission.readyReminderDmSentAt, now)) continue;
|
||||||
|
processed += 1;
|
||||||
|
try {
|
||||||
|
if (await processMission(client, payload, mission, now)) {
|
||||||
|
sent += 1;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, SEND_DELAY_MS));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
payload.logger.error(
|
||||||
|
`[Bot] Ready reminder failed for mission #${mission.id}: ${errorMessage(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
payload.logger.error(`[Bot] Ready reminder reconcile failed: ${errorMessage(error)}`);
|
||||||
|
} finally {
|
||||||
|
reconcileRunning = false;
|
||||||
|
}
|
||||||
|
return { processed, sent };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const start = (client: Client, payload: Payload): void => {
|
||||||
|
payload.logger.info("[Bot] Ready reminder service started");
|
||||||
|
void reconcile(client, payload);
|
||||||
|
setInterval(() => {
|
||||||
|
void reconcile(client, payload);
|
||||||
|
}, botConfig.readyReminderPollMs);
|
||||||
|
};
|
||||||
|
|
@ -87,7 +87,7 @@ export const Shims: GlobalConfig = {
|
||||||
admin: {
|
admin: {
|
||||||
condition: (_data, siblingData) => siblingData?.audience === "percentage",
|
condition: (_data, siblingData) => siblingData?.audience === "percentage",
|
||||||
description:
|
description:
|
||||||
"Percentage of users (0–100) who receive this shim. Assignment is deterministic per user.",
|
"Percentage of users (0-100) who receive this shim. Assignment is deterministic per user.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,24 @@ export const Missions: CollectionConfig = {
|
||||||
hidden: true,
|
hidden: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "readyReminderWebSentAt",
|
||||||
|
type: "date",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Server-local day the last web 'not Ready' reminder notification was sent to the op creator (managed by the mission tick, once per day).",
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "readyReminderDmSentAt",
|
||||||
|
type: "date",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"Server-local day the last 'not Ready' reminder DM was sent to the op creator (managed by the Discord bot, once per day).",
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "createdBy",
|
name: "createdBy",
|
||||||
type: "relationship",
|
type: "relationship",
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ export const MarketNegotiations: CollectionConfig = {
|
||||||
max: 100,
|
max: 100,
|
||||||
admin: {
|
admin: {
|
||||||
description:
|
description:
|
||||||
"NPC vendor patience (0–100). Rises each bargaining round and sharply for lowball offers; at 100 the negotiation closes and the buyer can only buy at the asking price.",
|
"NPC vendor patience (0-100). Rises each bargaining round and sharply for lowball offers; at 100 the negotiation closes and the buyer can only buy at the asking price.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ function resolveAccountLabel(account: (number | null) | BankAccount | undefined)
|
||||||
label: string;
|
label: string;
|
||||||
id: number | null;
|
id: number | null;
|
||||||
} {
|
} {
|
||||||
if (!account) return { label: "—", id: null };
|
if (!account) return { label: "-", id: null };
|
||||||
if (typeof account === "object") {
|
if (typeof account === "object") {
|
||||||
return { label: account.name ?? `#${account.id}`, id: account.id };
|
return { label: account.name ?? `#${account.id}`, id: account.id };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
Users,
|
Users,
|
||||||
Maximize,
|
Maximize,
|
||||||
Minimize,
|
Minimize,
|
||||||
|
ScrollText,
|
||||||
SparkleIcon,
|
SparkleIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
|
@ -56,6 +57,11 @@ const data = {
|
||||||
url: "/intelligence/missions/calendar",
|
url: "/intelligence/missions/calendar",
|
||||||
icon: CalendarDays,
|
icon: CalendarDays,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Event Log",
|
||||||
|
url: "/events",
|
||||||
|
icon: ScrollText,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
...sidebarData,
|
...sidebarData,
|
||||||
};
|
};
|
||||||
|
|
@ -151,7 +157,6 @@ export function AppSidebar({
|
||||||
)}
|
)}
|
||||||
<NavCore label="Helpdesk" items={data.navHelpdesk} />
|
<NavCore label="Helpdesk" items={data.navHelpdesk} />
|
||||||
<NavCore label="Commerce" items={data.navCommerce} />
|
<NavCore label="Commerce" items={data.navCommerce} />
|
||||||
<NavCore label="Personnel" items={data.navPersonnel} />
|
|
||||||
<NavCore label="Personal" items={data.navPersonal} />
|
<NavCore label="Personal" items={data.navPersonal} />
|
||||||
{/*<NavProjects projects={data.projects} />*/}
|
{/*<NavProjects projects={data.projects} />*/}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Truck,
|
Truck,
|
||||||
UserCog,
|
UserCog,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export const sidebarData = {
|
export const sidebarData = {
|
||||||
|
|
@ -63,29 +64,60 @@ export const sidebarData = {
|
||||||
],
|
],
|
||||||
navLogistics: [
|
navLogistics: [
|
||||||
{
|
{
|
||||||
title: "Structures",
|
title: "Assets",
|
||||||
url: "/logistics",
|
url: "",
|
||||||
icon: Building2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Resources",
|
|
||||||
url: "/logistics/resources",
|
|
||||||
icon: Boxes,
|
icon: Boxes,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Structures",
|
||||||
|
url: "/logistics",
|
||||||
|
icon: Building2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Resources",
|
||||||
|
url: "/logistics/resources",
|
||||||
|
icon: Boxes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Vehicles",
|
||||||
|
url: "/logistics/vehicles",
|
||||||
|
icon: Truck,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Vehicles",
|
title: "Operations",
|
||||||
url: "/logistics/vehicles",
|
url: "",
|
||||||
icon: Truck,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Shipments",
|
|
||||||
url: "/logistics/shipments",
|
|
||||||
icon: Package,
|
icon: Package,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Shipments",
|
||||||
|
url: "/logistics/shipments",
|
||||||
|
icon: Package,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Deployed Vehicles",
|
||||||
|
url: "/logistics/game-vehicles",
|
||||||
|
icon: Car,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Deployed Vehicles",
|
title: "Personnel",
|
||||||
url: "/logistics/game-vehicles",
|
url: "",
|
||||||
icon: Car,
|
icon: Users,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Contacts",
|
||||||
|
url: "/personnel/contacts",
|
||||||
|
icon: Contact,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Statistics",
|
||||||
|
url: "/personnel/statistics",
|
||||||
|
icon: BarChart3,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
navCommerce: [
|
navCommerce: [
|
||||||
|
|
@ -100,18 +132,6 @@ export const sidebarData = {
|
||||||
icon: ShoppingBag,
|
icon: ShoppingBag,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
navPersonnel: [
|
|
||||||
{
|
|
||||||
title: "Contacts",
|
|
||||||
url: "/personnel/contacts",
|
|
||||||
icon: Contact,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Statistics",
|
|
||||||
url: "/personnel/statistics",
|
|
||||||
icon: BarChart3,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
navPersonal: [
|
navPersonal: [
|
||||||
{
|
{
|
||||||
title: "Awards",
|
title: "Awards",
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,7 @@ export function EodLeaderboard({
|
||||||
>
|
>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<span className={rankClass(index)}>
|
<span className={rankClass(index)}>
|
||||||
{row.isOutsideTopTen ? "—" : index + 1}
|
{row.isOutsideTopTen ? "-" : index + 1}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2 font-medium">
|
<td className="px-2 py-2 font-medium">
|
||||||
|
|
|
||||||
485
src/components/frontend/events/EventLogTable.tsx
Normal file
485
src/components/frontend/events/EventLogTable.tsx
Normal file
|
|
@ -0,0 +1,485 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { ChevronLeft, ChevronRight, RotateCcw, Search } from "lucide-react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
import { formatType } from "@/utils/event-log/formatType";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// Local label map mirroring GameEventLogs TARGET_COLLECTIONS (the collection
|
||||||
|
// file imports Payload server-side, so it cannot be imported from a client
|
||||||
|
// component).
|
||||||
|
const TARGET_COLLECTION_LABELS: Record<string, string> = {
|
||||||
|
"game-structures": "Game Structures",
|
||||||
|
"game-vehicles": "Game Vehicles",
|
||||||
|
"game-npcs": "Game NPCs",
|
||||||
|
"npc-staffing": "NPC Staffing",
|
||||||
|
structures: "Structures",
|
||||||
|
resources: "Resources",
|
||||||
|
assets: "Assets",
|
||||||
|
vehicles: "Vehicles",
|
||||||
|
factions: "Factions",
|
||||||
|
missions: "Missions",
|
||||||
|
campaigns: "Campaigns",
|
||||||
|
users: "Users",
|
||||||
|
technologies: "Technologies",
|
||||||
|
maps: "Maps",
|
||||||
|
shipments: "Shipments",
|
||||||
|
"bank-accounts": "Bank Accounts",
|
||||||
|
"bank-transactions": "Bank Transactions",
|
||||||
|
"ledger-entries": "Ledger Entries",
|
||||||
|
"locker-storages": "Locker Storages",
|
||||||
|
loadouts: "Loadouts",
|
||||||
|
"market-listings": "Market Listings",
|
||||||
|
"market-negotiations": "Market Negotiations",
|
||||||
|
"market-state": "Market State",
|
||||||
|
evaluations: "Evaluations",
|
||||||
|
tickets: "Tickets",
|
||||||
|
"wiki-pages": "Wiki Pages",
|
||||||
|
"wiki-revisions": "Wiki Revisions",
|
||||||
|
"wiki-templates": "Wiki Templates",
|
||||||
|
"game-servers": "Game Servers",
|
||||||
|
"assignment-transfers": "Assignment Transfers",
|
||||||
|
"voice-scripts": "Voice Scripts",
|
||||||
|
"voice-submissions": "Voice Submissions",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TARGET_COLLECTION_OPTIONS = Object.entries(TARGET_COLLECTION_LABELS).sort((a, b) =>
|
||||||
|
a[1].localeCompare(b[1]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const PAGE_SIZES = [25, 50, 100];
|
||||||
|
|
||||||
|
type SourceFilter = "all" | "system" | "narrative";
|
||||||
|
type SortDir = "desc" | "asc";
|
||||||
|
|
||||||
|
interface EventRow {
|
||||||
|
id: number;
|
||||||
|
system?: boolean | null;
|
||||||
|
timestamp: string;
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
actor?: number | null | { displayName?: string | null; username?: string };
|
||||||
|
structure?: number | null | { name?: string | null };
|
||||||
|
targetCollection?: string | null;
|
||||||
|
targetId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventLogResponse {
|
||||||
|
docs: EventRow[];
|
||||||
|
totalDocs: number;
|
||||||
|
page?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group event type values by their prefix (e.g. "market" from "market:sale"). */
|
||||||
|
function eventTypeGroups(): [string, string[]][] {
|
||||||
|
const groups = new Map<string, string[]>();
|
||||||
|
for (const value of Object.values(EventTypes)) {
|
||||||
|
const prefix = value.includes(":") ? value.split(":")[0] : "other";
|
||||||
|
const list = groups.get(prefix);
|
||||||
|
if (list) {
|
||||||
|
list.push(value);
|
||||||
|
} else {
|
||||||
|
groups.set(prefix, [value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_GROUPS = eventTypeGroups();
|
||||||
|
|
||||||
|
function actorLabel(actor: EventRow["actor"]): string {
|
||||||
|
if (actor == null || typeof actor === "number") return "System";
|
||||||
|
return actor.displayName || actor.username || "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function structureLabel(structure: EventRow["structure"]): string {
|
||||||
|
if (structure == null) return "-";
|
||||||
|
if (typeof structure === "number") return `#${structure}`;
|
||||||
|
return structure.name || "Unknown structure";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(ts: string): string {
|
||||||
|
const date = new Date(ts);
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventLogTable() {
|
||||||
|
const [searchInput, setSearchInput] = React.useState("");
|
||||||
|
const [search, setSearch] = React.useState("");
|
||||||
|
const [selectedTypes, setSelectedTypes] = React.useState<string[]>([]);
|
||||||
|
const [targetCollection, setTargetCollection] = React.useState<string>("all");
|
||||||
|
const [source, setSource] = React.useState<SourceFilter>("all");
|
||||||
|
const [dateFrom, setDateFrom] = React.useState("");
|
||||||
|
const [dateTo, setDateTo] = React.useState("");
|
||||||
|
const [sortDir, setSortDir] = React.useState<SortDir>("desc");
|
||||||
|
const [page, setPage] = React.useState(1);
|
||||||
|
const [pageSize, setPageSize] = React.useState(50);
|
||||||
|
|
||||||
|
const [rows, setRows] = React.useState<EventRow[]>([]);
|
||||||
|
const [totalDocs, setTotalDocs] = React.useState(0);
|
||||||
|
const [totalPages, setTotalPages] = React.useState(1);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
// Debounce the search box.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const t = setTimeout(() => setSearch(searchInput.trim()), 300);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [searchInput]);
|
||||||
|
|
||||||
|
// Any filter change resets to page 1.
|
||||||
|
React.useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [search, selectedTypes, targetCollection, source, dateFrom, dateTo, sortDir, pageSize]);
|
||||||
|
|
||||||
|
const hasActiveFilters =
|
||||||
|
search !== "" ||
|
||||||
|
selectedTypes.length > 0 ||
|
||||||
|
targetCollection !== "all" ||
|
||||||
|
source !== "all" ||
|
||||||
|
dateFrom !== "" ||
|
||||||
|
dateTo !== "";
|
||||||
|
|
||||||
|
const resetFilters = React.useCallback(() => {
|
||||||
|
setSearchInput("");
|
||||||
|
setSelectedTypes([]);
|
||||||
|
setTargetCollection("all");
|
||||||
|
setSource("all");
|
||||||
|
setDateFrom("");
|
||||||
|
setDateTo("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const conditions: Record<string, unknown>[] = [];
|
||||||
|
if (search) {
|
||||||
|
conditions.push({ or: [{ message: { like: search } }, { type: { like: search } }] });
|
||||||
|
}
|
||||||
|
if (selectedTypes.length > 0) {
|
||||||
|
conditions.push({ type: { in: selectedTypes } });
|
||||||
|
}
|
||||||
|
if (targetCollection !== "all") {
|
||||||
|
conditions.push({ targetCollection: { equals: targetCollection } });
|
||||||
|
}
|
||||||
|
if (source !== "all") {
|
||||||
|
conditions.push({ system: { equals: source === "system" } });
|
||||||
|
}
|
||||||
|
if (dateFrom) {
|
||||||
|
conditions.push({ timestamp: { greater_than_equal: `${dateFrom}T00:00:00.000Z` } });
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
conditions.push({ timestamp: { less_than_equal: `${dateTo}T23:59:59.999Z` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
depth: "1",
|
||||||
|
sort: `${sortDir === "desc" ? "-" : ""}timestamp`,
|
||||||
|
limit: String(pageSize),
|
||||||
|
page: String(page),
|
||||||
|
});
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
const where = conditions.length === 1 ? conditions[0] : { and: conditions };
|
||||||
|
params.set("where", JSON.stringify(where));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/game-event-logs?${params.toString()}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Request failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as EventLogResponse;
|
||||||
|
setRows(data.docs ?? []);
|
||||||
|
setTotalDocs(data.totalDocs ?? 0);
|
||||||
|
setTotalPages(Math.max(1, data.totalPages ?? 1));
|
||||||
|
} catch (err) {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load events");
|
||||||
|
setRows([]);
|
||||||
|
setTotalDocs(0);
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [search, selectedTypes, targetCollection, source, dateFrom, dateTo, sortDir, page, pageSize]);
|
||||||
|
|
||||||
|
const toggleType = (value: string) => {
|
||||||
|
setSelectedTypes((prev) =>
|
||||||
|
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const from = totalDocs === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||||
|
const to = Math.min(page * pageSize, totalDocs);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="relative w-full max-w-xs">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-2 size-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
placeholder="Search message or type…"
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Type
|
||||||
|
{selectedTypes.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-1 px-1.5 py-0 text-[10px]">
|
||||||
|
{selectedTypes.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="max-h-96 w-64 overflow-y-auto" align="start">
|
||||||
|
{TYPE_GROUPS.map(([prefix, types], groupIdx) => (
|
||||||
|
<React.Fragment key={prefix}>
|
||||||
|
{groupIdx > 0 && <DropdownMenuSeparator />}
|
||||||
|
<DropdownMenuLabel className="text-xs uppercase text-muted-foreground">
|
||||||
|
{prefix}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{types.map((value) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={value}
|
||||||
|
checked={selectedTypes.includes(value)}
|
||||||
|
onCheckedChange={() => toggleType(value)}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{formatType(value)}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Select value={targetCollection} onValueChange={setTargetCollection}>
|
||||||
|
<SelectTrigger className="h-8 w-44">
|
||||||
|
<SelectValue placeholder="Target" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
<SelectItem value="all">All targets</SelectItem>
|
||||||
|
{TARGET_COLLECTION_OPTIONS.map(([value, label]) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={source} onValueChange={(v) => setSource(v as SourceFilter)}>
|
||||||
|
<SelectTrigger className="h-8 w-36">
|
||||||
|
<SelectValue placeholder="Source" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All sources</SelectItem>
|
||||||
|
<SelectItem value="system">System</SelectItem>
|
||||||
|
<SelectItem value="narrative">Narrative</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => setDateFrom(e.target.value)}
|
||||||
|
className="w-36"
|
||||||
|
aria-label="From date"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs">-</span>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => setDateTo(e.target.value)}
|
||||||
|
className="w-36"
|
||||||
|
aria-label="To date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select value={sortDir} onValueChange={(v) => setSortDir(v as SortDir)}>
|
||||||
|
<SelectTrigger className="h-8 w-28">
|
||||||
|
<SelectValue placeholder="Sort" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="desc">Newest first</SelectItem>
|
||||||
|
<SelectItem value="asc">Oldest first</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={resetFilters}>
|
||||||
|
<RotateCcw className="size-4" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="text-muted-foreground ml-auto text-xs">
|
||||||
|
{totalDocs.toLocaleString()} event{totalDocs === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-44">Time</TableHead>
|
||||||
|
<TableHead className="w-44">Type</TableHead>
|
||||||
|
<TableHead>Message</TableHead>
|
||||||
|
<TableHead className="w-32">Actor</TableHead>
|
||||||
|
<TableHead className="w-44">Structure</TableHead>
|
||||||
|
<TableHead className="w-40">Target</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading && rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-muted-foreground py-8 text-center">
|
||||||
|
Loading events…
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : error ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-destructive py-8 text-center">
|
||||||
|
{error}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-muted-foreground py-8 text-center">
|
||||||
|
No events match the current filters.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => {
|
||||||
|
const narrative = row.system === false;
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={cn(narrative && "border-l-2 border-l-amber-500/70")}
|
||||||
|
>
|
||||||
|
<TableCell className="text-muted-foreground whitespace-nowrap text-xs">
|
||||||
|
{formatTimestamp(row.timestamp)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="max-w-40 truncate font-normal">
|
||||||
|
{formatType(row.type)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-md whitespace-normal">{row.message}</TableCell>
|
||||||
|
<TableCell className="text-xs">{actorLabel(row.actor)}</TableCell>
|
||||||
|
<TableCell className="text-xs">{structureLabel(row.structure)}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-xs">
|
||||||
|
{row.targetCollection
|
||||||
|
? `${TARGET_COLLECTION_LABELS[row.targetCollection] ?? row.targetCollection}${
|
||||||
|
row.targetId != null ? ` #${row.targetId}` : ""
|
||||||
|
}`
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Showing {from.toLocaleString()}-{to.toLocaleString()} of {totalDocs.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={String(pageSize)}
|
||||||
|
onValueChange={(v) => setPageSize(Number(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-28">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PAGE_SIZES.map((size) => (
|
||||||
|
<SelectItem key={size} value={String(size)}>
|
||||||
|
{size} / page
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page <= 1 || loading}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Prev
|
||||||
|
</Button>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Page {page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={page >= totalPages || loading}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -159,7 +159,7 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId, activePilots
|
||||||
title={row.isOutsideTopTen ? "Pilot status: not currently ranked in the top 10" : undefined}
|
title={row.isOutsideTopTen ? "Pilot status: not currently ranked in the top 10" : undefined}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<span className={rankClass(i)}>{row.isOutsideTopTen ? "—" : i + 1}</span>
|
<span className={rankClass(i)}>{row.isOutsideTopTen ? "-" : i + 1}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2 font-medium">
|
<td className="px-2 py-2 font-medium">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ function formatDate(iso: string | null | undefined): string {
|
||||||
? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(
|
? new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(
|
||||||
new Date(iso),
|
new Date(iso),
|
||||||
)
|
)
|
||||||
: "—";
|
: "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTicketStatus(value: string): value is TicketStatus {
|
function isTicketStatus(value: string): value is TicketStatus {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Campaign, Map, Media } from "@/payload-types";
|
import type { Campaign, Map, Media, User } from "@/payload-types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
operationTypeColors,
|
operationTypeColors,
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
ClockIcon,
|
ClockIcon,
|
||||||
CrosshairIcon,
|
CrosshairIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
|
UserIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
|
@ -52,6 +53,7 @@ export interface MissionCardProps {
|
||||||
readonly missionRoles?: {
|
readonly missionRoles?: {
|
||||||
readonly maxPlayers: number;
|
readonly maxPlayers: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
readonly createdBy?: number | User | null;
|
||||||
readonly coverImage?: number | Media | null;
|
readonly coverImage?: number | Media | null;
|
||||||
};
|
};
|
||||||
readonly variant?: "default" | "image";
|
readonly variant?: "default" | "image";
|
||||||
|
|
@ -77,6 +79,12 @@ export function MissionCard({
|
||||||
typeof mission.classification.campaign === "object" && mission.classification.campaign !== null
|
typeof mission.classification.campaign === "object" && mission.classification.campaign !== null
|
||||||
? mission.classification.campaign
|
? mission.classification.campaign
|
||||||
: null;
|
: null;
|
||||||
|
const createdByUser =
|
||||||
|
typeof mission.createdBy === "object" && mission.createdBy !== null
|
||||||
|
? mission.createdBy
|
||||||
|
: null;
|
||||||
|
const hostLabel = createdByUser ? (createdByUser.displayName || createdByUser.username) : null;
|
||||||
|
const showHost = mission.operationType === "side" && hostLabel !== null;
|
||||||
|
|
||||||
const startDate = new Date(mission.classification.startDateTime);
|
const startDate = new Date(mission.classification.startDateTime);
|
||||||
const dateStr = startDate.toLocaleDateString("en-US", {
|
const dateStr = startDate.toLocaleDateString("en-US", {
|
||||||
|
|
@ -122,7 +130,7 @@ export function MissionCard({
|
||||||
<ItemMedia variant="icon">
|
<ItemMedia variant="icon">
|
||||||
<CrosshairIcon />
|
<CrosshairIcon />
|
||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
<ItemTitle className="flex flex-col">
|
<ItemTitle className="flex flex-col items-start text-start">
|
||||||
<span className="font-mono text-xs text-muted-foreground">{mission.codeName}</span>
|
<span className="font-mono text-xs text-muted-foreground">{mission.codeName}</span>
|
||||||
<span>{mission.name}</span>
|
<span>{mission.name}</span>
|
||||||
</ItemTitle>
|
</ItemTitle>
|
||||||
|
|
@ -131,7 +139,7 @@ export function MissionCard({
|
||||||
<span
|
<span
|
||||||
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${operationTypeColors[mission.operationType] ?? "bg-muted text-muted-foreground"}`}
|
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${operationTypeColors[mission.operationType] ?? "bg-muted text-muted-foreground"}`}
|
||||||
>
|
>
|
||||||
{operationTypeLabels[mission.operationType] ?? mission.operationType}
|
{operationTypeLabels[mission.operationType] ?? mission.operationType} Operation
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[mission.ownershipAndStatus.status] ?? "bg-muted text-muted-foreground"}`}
|
className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded ${statusColors[mission.ownershipAndStatus.status] ?? "bg-muted text-muted-foreground"}`}
|
||||||
|
|
@ -167,6 +175,12 @@ export function MissionCard({
|
||||||
{mission.missionRoles.maxPlayers} slots
|
{mission.missionRoles.maxPlayers} slots
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{showHost && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<UserIcon className="size-3" />
|
||||||
|
Hosted by {hostLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{campaign && <span className="text-primary">{campaign.name}</span>}
|
{campaign && <span className="text-primary">{campaign.name}</span>}
|
||||||
</div>
|
</div>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ export function MissionComms({ commsAndDocs }: MissionCommsProps) {
|
||||||
{frequencies!.map((freq, index) => (
|
{frequencies!.map((freq, index) => (
|
||||||
<tr key={index} className="border-b border-border/50">
|
<tr key={index} className="border-b border-border/50">
|
||||||
<td className="px-4 py-2 font-mono font-medium">{freq.frequency}</td>
|
<td className="px-4 py-2 font-mono font-medium">{freq.frequency}</td>
|
||||||
<td className="px-4 py-2 text-muted-foreground">{freq.channel ?? "—"}</td>
|
<td className="px-4 py-2 text-muted-foreground">{freq.channel ?? "-"}</td>
|
||||||
<td className="px-4 py-2 font-medium">{freq.netName}</td>
|
<td className="px-4 py-2 font-medium">{freq.netName}</td>
|
||||||
<td className="px-4 py-2 text-muted-foreground">{freq.freqUsage}</td>
|
<td className="px-4 py-2 text-muted-foreground">{freq.freqUsage}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ export function MissionLogistics({ logisticsAndSupport }: MissionLogisticsProps)
|
||||||
<tr key={index} className="border-b border-border/50">
|
<tr key={index} className="border-b border-border/50">
|
||||||
<td className="px-4 py-2 font-medium">{asset.assetType}</td>
|
<td className="px-4 py-2 font-medium">{asset.assetType}</td>
|
||||||
<td className="px-4 py-2 text-muted-foreground">
|
<td className="px-4 py-2 text-muted-foreground">
|
||||||
{asset.assetCount ?? "—"}
|
{asset.assetCount ?? "-"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-center">
|
<td className="px-4 py-2 text-center">
|
||||||
{asset.doesRespawn ? (
|
{asset.doesRespawn ? (
|
||||||
|
|
@ -122,7 +122,7 @@ export function MissionLogistics({ logisticsAndSupport }: MissionLogisticsProps)
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-muted-foreground font-mono text-xs">
|
<td className="px-4 py-2 text-muted-foreground font-mono text-xs">
|
||||||
{asset.assetCallsign ?? "—"}
|
{asset.assetCallsign ?? "-"}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -41,10 +41,10 @@ function ObjectiveText({
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
if (!redacted) {
|
if (!redacted) {
|
||||||
return <span className={className}>{text ?? "—"}</span>;
|
return <span className={className}>{text ?? "-"}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const display = redactText(text) ?? "—";
|
const display = redactText(text) ?? "-";
|
||||||
|
|
||||||
if (!canReveal) {
|
if (!canReveal) {
|
||||||
return <span className={`${className ?? ""} select-none break-all`}>{display}</span>;
|
return <span className={`${className ?? ""} select-none break-all`}>{display}</span>;
|
||||||
|
|
@ -57,7 +57,7 @@ function ObjectiveText({
|
||||||
<span className={`${className ?? ""} cursor-help select-none break-all`}>{display}</span>
|
<span className={`${className ?? ""} cursor-help select-none break-all`}>{display}</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-sm whitespace-pre-wrap break-words">
|
<TooltipContent className="max-w-sm whitespace-pre-wrap break-words">
|
||||||
{text ?? "—"}
|
{text ?? "-"}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
|
||||||
|
|
@ -84,14 +84,14 @@ export function MissionRoles({ roles, notes, maxPlayers }: MissionRolesProps) {
|
||||||
<td className="px-4 py-2 text-muted-foreground">
|
<td className="px-4 py-2 text-muted-foreground">
|
||||||
{role.requiredMos && role.requiredMos.length > 0
|
{role.requiredMos && role.requiredMos.length > 0
|
||||||
? resolveNames(role.requiredMos)
|
? resolveNames(role.requiredMos)
|
||||||
: "—"}
|
: "-"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-muted-foreground">
|
<td className="px-4 py-2 text-muted-foreground">
|
||||||
{typeof role.minimumRank === "object" && role.minimumRank !== null
|
{typeof role.minimumRank === "object" && role.minimumRank !== null
|
||||||
? role.minimumRank.name
|
? role.minimumRank.name
|
||||||
: typeof role.minimumRank === "number"
|
: typeof role.minimumRank === "number"
|
||||||
? `#${role.minimumRank}`
|
? `#${role.minimumRank}`
|
||||||
: "—"}
|
: "-"}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,13 @@
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { PencilIcon, XCircleIcon } from "lucide-react";
|
import { CheckCircleIcon, PencilIcon, XCircleIcon } from "lucide-react";
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cancelFridaySideOp } from "@/app/(frontend)/intelligence/missions/actions";
|
import {
|
||||||
|
cancelFridaySideOp,
|
||||||
|
markSideOpReady,
|
||||||
|
} from "@/app/(frontend)/intelligence/missions/actions";
|
||||||
import {
|
import {
|
||||||
CreateSideOpDialog,
|
CreateSideOpDialog,
|
||||||
type SideOpDialogInitial,
|
type SideOpDialogInitial,
|
||||||
|
|
@ -34,6 +37,19 @@ export function SideOpControls({
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const isTerminal = status === "Completed" || status === "Cancelled";
|
const isTerminal = status === "Completed" || status === "Cancelled";
|
||||||
|
const canMarkReady = ["Concept", "Planning", "Scheduled"].includes(status);
|
||||||
|
|
||||||
|
function handleMarkReady() {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await markSideOpReady(initial.missionId);
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error ?? "Failed to mark the side operation as Ready.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success("Side operation marked as Ready. Roll-call can now be posted.");
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
if (!confirmingCancel) {
|
if (!confirmingCancel) {
|
||||||
|
|
@ -64,6 +80,12 @@ export function SideOpControls({
|
||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{canMarkReady && (
|
||||||
|
<Button size="sm" variant="default" onClick={handleMarkReady} disabled={isPending}>
|
||||||
|
<CheckCircleIcon className="size-3.5" />
|
||||||
|
Mark as Ready
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button size="sm" variant="outline" onClick={() => setEditOpen(true)} disabled={isPending}>
|
<Button size="sm" variant="outline" onClick={() => setEditOpen(true)} disabled={isPending}>
|
||||||
<PencilIcon className="size-3.5" />
|
<PencilIcon className="size-3.5" />
|
||||||
Edit
|
Edit
|
||||||
|
|
@ -79,8 +101,9 @@ export function SideOpControls({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Editing covers details, campaign, and narrative. Cancelling frees your scheduling slot
|
Marking the operation Ready locks in the details so the Discord roll-call can be posted
|
||||||
and updates the Discord embed.
|
and the mission hooks delivered. Editing covers details, campaign, and narrative.
|
||||||
|
Cancelling frees your scheduling slot and updates the Discord embed.
|
||||||
</p>
|
</p>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ export function LockerGridToolbar({
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-muted-foreground/70">FOOTPRINT</dt>
|
<dt className="text-muted-foreground/70">FOOTPRINT</dt>
|
||||||
<dd className="font-mono tabular-nums text-foreground/90">
|
<dd className="font-mono tabular-nums text-foreground/90">
|
||||||
{fp ? `${fp.width}×${fp.height}` : "—"}
|
{fp ? `${fp.width}×${fp.height}` : "-"}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ export function SprintsView({ project, sprints, isAdmin }: SprintsViewProps) {
|
||||||
<ItemFooter className="w-full flex-wrap justify-start gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
<ItemFooter className="w-full flex-wrap justify-start gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<CalendarIcon className="size-3" />
|
<CalendarIcon className="size-3" />
|
||||||
{dateFor(sprint.startDate)} — {dateFor(sprint.endDate)}
|
{dateFor(sprint.startDate)} - {dateFor(sprint.endDate)}
|
||||||
</span>
|
</span>
|
||||||
</ItemFooter>
|
</ItemFooter>
|
||||||
</Item>
|
</Item>
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ export function AimTrainerLeaderboard({
|
||||||
>
|
>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<span className={rankClass(index)}>
|
<span className={rankClass(index)}>
|
||||||
{row.isOutsideTopTen ? "—" : index + 1}
|
{row.isOutsideTopTen ? "-" : index + 1}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2 font-medium">
|
<td className="px-2 py-2 font-medium">
|
||||||
|
|
|
||||||
|
|
@ -288,7 +288,7 @@ export function RadioLeaderboard({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<span className={rankClass(index)}>{row.isOutsideTopTen ? "—" : index + 1}</span>
|
<span className={rankClass(index)}>{row.isOutsideTopTen ? "-" : index + 1}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-2 font-medium">
|
<td className="px-2 py-2 font-medium">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
|
|
@ -318,7 +318,7 @@ export function RadioLeaderboard({
|
||||||
</td>
|
</td>
|
||||||
{mode === "sprint" && (
|
{mode === "sprint" && (
|
||||||
<td className="px-2 py-2 text-right tabular-nums">
|
<td className="px-2 py-2 text-right tabular-nums">
|
||||||
{row.bestSprintTimeMs > 0 ? `${(row.bestSprintTimeMs / 1000).toFixed(1)}s` : "—"}
|
{row.bestSprintTimeMs > 0 ? `${(row.bestSprintTimeMs / 1000).toFixed(1)}s` : "-"}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
|
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -620,7 +620,7 @@ export function RadioTraffic({
|
||||||
<div className="flex flex-col gap-2 sm:flex-row"><Input ref={inputRef} id="radio-traffic-input" value={input} onKeyDown={handleKeystroke} onChange={(event) => { setInput(event.target.value.toUpperCase()); playKeystroke(); }} onPaste={(event) => { event.preventDefault(); rejectInsertion(); }} onDrop={(event) => { event.preventDefault(); rejectInsertion(); }} onDragOver={(event) => event.preventDefault()} autoCapitalize="characters" autoComplete="off" autoCorrect="off" autoFocus spellCheck={false} aria-describedby="radio-traffic-hint" className="font-mono sm:flex-1" /><Button type="submit" className="sm:min-w-24">Relay</Button></div>
|
<div className="flex flex-col gap-2 sm:flex-row"><Input ref={inputRef} id="radio-traffic-input" value={input} onKeyDown={handleKeystroke} onChange={(event) => { setInput(event.target.value.toUpperCase()); playKeystroke(); }} onPaste={(event) => { event.preventDefault(); rejectInsertion(); }} onDrop={(event) => { event.preventDefault(); rejectInsertion(); }} onDragOver={(event) => event.preventDefault()} autoCapitalize="characters" autoComplete="off" autoCorrect="off" autoFocus spellCheck={false} aria-describedby="radio-traffic-hint" className="font-mono sm:flex-1" /><Button type="submit" className="sm:min-w-24">Relay</Button></div>
|
||||||
<p id="radio-traffic-hint" className="text-xs text-muted-foreground">Input is auto-capitalized, so no Shift needed. Match callsigns, grid words, punctuation, and spacing.</p>
|
<p id="radio-traffic-hint" className="text-xs text-muted-foreground">Input is auto-capitalized, so no Shift needed. Match callsigns, grid words, punctuation, and spacing.</p>
|
||||||
</form>
|
</form>
|
||||||
<div className="flex flex-wrap justify-between gap-3 border-t border-border pt-3 text-xs text-muted-foreground"><span>Current accuracy <strong className="ml-1 text-foreground">{liveKeyTotal === 0 ? "—" : formatAccuracy(currentAccuracy)}</strong></span><span>Round score <strong className="ml-1 font-mono text-foreground">{provisionalScore}</strong></span><span>Completed <strong className="ml-1 font-mono text-foreground">{results.length}</strong></span></div>
|
<div className="flex flex-wrap justify-between gap-3 border-t border-border pt-3 text-xs text-muted-foreground"><span>Current accuracy <strong className="ml-1 text-foreground">{liveKeyTotal === 0 ? "-" : formatAccuracy(currentAccuracy)}</strong></span><span>Round score <strong className="ml-1 font-mono text-foreground">{provisionalScore}</strong></span><span>Completed <strong className="ml-1 font-mono text-foreground">{results.length}</strong></span></div>
|
||||||
{feedback && <p role="status" className={feedback.correct ? "text-sm text-emerald-400" : "text-sm text-destructive"}>{feedback.message}</p>}
|
{feedback && <p role="status" className={feedback.correct ? "text-sm text-emerald-400" : "text-sm text-destructive"}>{feedback.message}</p>}
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -541,7 +541,7 @@ export function RosterView({
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="alpha" className="gap-1.5">
|
<TabsTrigger value="alpha" className="gap-1.5">
|
||||||
<ListIcon className="size-3" />
|
<ListIcon className="size-3" />
|
||||||
A–Z
|
A-Z
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="seniority" className="gap-1.5">
|
<TabsTrigger value="seniority" className="gap-1.5">
|
||||||
<CalendarDaysIcon className="size-3" />
|
<CalendarDaysIcon className="size-3" />
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ interface GridToolbarProps {
|
||||||
onRotate: (entryId: string) => Promise<void>;
|
onRotate: (entryId: string) => Promise<void>;
|
||||||
onRemove: (entryId: string) => Promise<void>;
|
onRemove: (entryId: string) => Promise<void>;
|
||||||
onSplit: (entryId: string, amount: number) => Promise<void>;
|
onSplit: (entryId: string, amount: number) => Promise<void>;
|
||||||
|
/** Rearranging/rotating/splitting conserves quantities — logistics personnel or superuser. */
|
||||||
|
canArrange?: boolean;
|
||||||
|
/** Removing grid items destroys stored stock — superuser only. */
|
||||||
|
canMutate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GridToolbar({
|
export function GridToolbar({
|
||||||
|
|
@ -37,6 +41,8 @@ export function GridToolbar({
|
||||||
onRotate,
|
onRotate,
|
||||||
onRemove,
|
onRemove,
|
||||||
onSplit,
|
onSplit,
|
||||||
|
canArrange = false,
|
||||||
|
canMutate = false,
|
||||||
}: GridToolbarProps) {
|
}: GridToolbarProps) {
|
||||||
const occupancy = getGridOccupancy(storedResources, gridWidth, gridHeight);
|
const occupancy = getGridOccupancy(storedResources, gridWidth, gridHeight);
|
||||||
const res = selectedItem ? (selectedItem.resource as Resource) : null;
|
const res = selectedItem ? (selectedItem.resource as Resource) : null;
|
||||||
|
|
@ -50,10 +56,12 @@ export function GridToolbar({
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={onAutoArrange}>
|
{canArrange && (
|
||||||
<ShuffleIcon className="size-3.5 mr-1" />
|
<Button variant="outline" size="sm" onClick={onAutoArrange}>
|
||||||
Auto-Arrange
|
<ShuffleIcon className="size-3.5 mr-1" />
|
||||||
</Button>
|
Auto-Arrange
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|
@ -86,12 +94,12 @@ export function GridToolbar({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{res.rotatable !== false && (
|
{canArrange && res.rotatable !== false && (
|
||||||
<Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}>
|
<Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}>
|
||||||
<RotateCwIcon className="size-3.5" />
|
<RotateCwIcon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{res.stackable && selectedItem.amount > 1 && (
|
{canArrange && res.stackable && selectedItem.amount > 1 && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -101,9 +109,11 @@ export function GridToolbar({
|
||||||
<ScissorsIcon className="size-3.5" />
|
<ScissorsIcon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}>
|
{canMutate && (
|
||||||
<TrashIcon className="size-3.5" />
|
<Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}>
|
||||||
</Button>
|
<TrashIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,32 @@ import { ManageStorageDialog } from "@/components/frontend/storage/ManageStorage
|
||||||
|
|
||||||
interface ManageStorageButtonProps {
|
interface ManageStorageButtonProps {
|
||||||
structureId: number;
|
structureId: number;
|
||||||
|
canManage: boolean;
|
||||||
|
canTransfer: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ManageStorageButton({ structureId }: ManageStorageButtonProps) {
|
export function ManageStorageButton({
|
||||||
|
structureId,
|
||||||
|
canManage,
|
||||||
|
canTransfer,
|
||||||
|
}: ManageStorageButtonProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!canManage && !canTransfer) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||||
<SettingsIcon className="size-3.5 mr-1.5" />
|
<SettingsIcon className="size-3.5 mr-1.5" />
|
||||||
Manage
|
Manage
|
||||||
</Button>
|
</Button>
|
||||||
<ManageStorageDialog structureId={structureId} open={open} onOpenChange={setOpen} />
|
<ManageStorageDialog
|
||||||
|
structureId={structureId}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
canManage={canManage}
|
||||||
|
canTransfer={canTransfer}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,17 @@ interface ManageStorageDialogProps {
|
||||||
structureId: number;
|
structureId: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
canManage: boolean;
|
||||||
|
canTransfer: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ManageStorageDialog({ structureId, open, onOpenChange }: ManageStorageDialogProps) {
|
export function ManageStorageDialog({
|
||||||
|
structureId,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
canManage,
|
||||||
|
canTransfer,
|
||||||
|
}: ManageStorageDialogProps) {
|
||||||
const [structure, setStructure] = useState<(GameStructure & { type: Structure }) | null>(null);
|
const [structure, setStructure] = useState<(GameStructure & { type: Structure }) | null>(null);
|
||||||
const [allStructures, setAllStructures] = useState<(GameStructure & { type: Structure })[]>([]);
|
const [allStructures, setAllStructures] = useState<(GameStructure & { type: Structure })[]>([]);
|
||||||
const [allResources, setAllResources] = useState<Resource[]>([]);
|
const [allResources, setAllResources] = useState<Resource[]>([]);
|
||||||
|
|
@ -641,265 +649,288 @@ export function ManageStorageDialog({ structureId, open, onOpenChange }: ManageS
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Tabs defaultValue="deposit" className="w-full">
|
<Tabs defaultValue={canManage ? "deposit" : "transfer"} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<TabsList
|
||||||
<TabsTrigger value="deposit" className="flex items-center gap-1.5">
|
className={cn(
|
||||||
<ArrowDownToLineIcon className="size-3.5" />
|
"grid w-full",
|
||||||
Deposit
|
canManage && canTransfer
|
||||||
</TabsTrigger>
|
? "grid-cols-3"
|
||||||
<TabsTrigger value="withdraw" className="flex items-center gap-1.5">
|
: canManage
|
||||||
<ArrowUpFromLineIcon className="size-3.5" />
|
? "grid-cols-2"
|
||||||
Withdraw
|
: "grid-cols-1",
|
||||||
</TabsTrigger>
|
)}
|
||||||
<TabsTrigger value="transfer" className="flex items-center gap-1.5">
|
>
|
||||||
<ArrowRightLeftIcon className="size-3.5" />
|
{canManage && (
|
||||||
Transfer
|
<TabsTrigger value="deposit" className="flex items-center gap-1.5">
|
||||||
</TabsTrigger>
|
<ArrowDownToLineIcon className="size-3.5" />
|
||||||
|
Deposit
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
{canManage && (
|
||||||
|
<TabsTrigger value="withdraw" className="flex items-center gap-1.5">
|
||||||
|
<ArrowUpFromLineIcon className="size-3.5" />
|
||||||
|
Withdraw
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
{canTransfer && (
|
||||||
|
<TabsTrigger value="transfer" className="flex items-center gap-1.5">
|
||||||
|
<ArrowRightLeftIcon className="size-3.5" />
|
||||||
|
Transfer
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
|
{canManage && (
|
||||||
<div className="flex flex-col gap-2">
|
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
|
||||||
<Label>Resource</Label>
|
<div className="flex flex-col gap-2">
|
||||||
<Select
|
<Label>Resource</Label>
|
||||||
value={depositResourceId}
|
<Select
|
||||||
onValueChange={(val) => {
|
value={depositResourceId}
|
||||||
setDepositResourceId(val);
|
onValueChange={(val) => {
|
||||||
setDepositAmount(
|
setDepositResourceId(val);
|
||||||
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
|
setDepositAmount(
|
||||||
);
|
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
|
||||||
}}
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a resource..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{allResources
|
||||||
|
.filter((r) => isResourceAllowed(r.id))
|
||||||
|
.map((resource) => {
|
||||||
|
const cap = getAllowedDepositCap(resource.id);
|
||||||
|
return (
|
||||||
|
<SelectItem key={resource.id} value={String(resource.id)}>
|
||||||
|
{resource.name}
|
||||||
|
{cap !== undefined && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
({cap.toLocaleString()} max)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Amount</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max={getDepositMax()}
|
||||||
|
value={depositAmount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDepositAmount(clampAmount(e.target.value, getDepositMax()))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{getDepositLimitInfoFor(depositResourceId) !== undefined && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Max: {getDepositLimitInfoFor(depositResourceId)!.max.toLocaleString()}{" "}
|
||||||
|
(limited by {getDepositLimitInfoFor(depositResourceId)!.reason})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{renderCapacityPreview("deposit")}
|
||||||
|
<Button
|
||||||
|
onClick={handleDeposit}
|
||||||
|
disabled={isPending || !depositResourceId}
|
||||||
|
className="w-full"
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
{isPending ? (
|
||||||
<SelectValue placeholder="Select a resource..." />
|
<Loader2Icon className="size-4 animate-spin mr-2" />
|
||||||
</SelectTrigger>
|
) : (
|
||||||
<SelectContent>
|
<ArrowDownToLineIcon className="size-4 mr-2" />
|
||||||
{allResources
|
)}
|
||||||
.filter((r) => isResourceAllowed(r.id))
|
Deposit
|
||||||
.map((resource) => {
|
</Button>
|
||||||
const cap = getAllowedDepositCap(resource.id);
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canManage && (
|
||||||
|
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Resource</Label>
|
||||||
|
<Select
|
||||||
|
value={withdrawResourceId}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
setWithdrawResourceId(val);
|
||||||
|
const stored = findStoredResource(val);
|
||||||
|
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a resource..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{storedResources.map((entry) => {
|
||||||
|
const resource = entry.resource as Resource;
|
||||||
|
if (!resource || typeof resource === "number") return null;
|
||||||
return (
|
return (
|
||||||
<SelectItem key={resource.id} value={String(resource.id)}>
|
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
||||||
{resource.name}
|
{resource.name} ({entry.amount.toLocaleString()}{" "}
|
||||||
{cap !== undefined && (
|
{pluralize(entry.amount, resource.unitOfMeasure)})
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{" "}
|
|
||||||
({cap.toLocaleString()} max)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Amount</Label>
|
<Label>Amount</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max={getDepositMax()}
|
max={getWithdrawMax()}
|
||||||
value={depositAmount}
|
value={withdrawAmount}
|
||||||
onChange={(e) => setDepositAmount(clampAmount(e.target.value, getDepositMax()))}
|
onChange={(e) =>
|
||||||
/>
|
setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax()))
|
||||||
{getDepositLimitInfoFor(depositResourceId) !== undefined && (
|
}
|
||||||
<span className="text-xs text-muted-foreground">
|
/>
|
||||||
Max: {getDepositLimitInfoFor(depositResourceId)!.max.toLocaleString()}{" "}
|
{getWithdrawMax() !== undefined && (
|
||||||
(limited by {getDepositLimitInfoFor(depositResourceId)!.reason})
|
<span className="text-xs text-muted-foreground">
|
||||||
</span>
|
Max: {getWithdrawMax()!.toLocaleString()} stored
|
||||||
)}
|
</span>
|
||||||
</div>
|
)}
|
||||||
{renderCapacityPreview("deposit")}
|
</div>
|
||||||
<Button
|
{renderCapacityPreview("withdraw")}
|
||||||
onClick={handleDeposit}
|
<Button
|
||||||
disabled={isPending || !depositResourceId}
|
onClick={handleWithdraw}
|
||||||
className="w-full"
|
disabled={isPending || !withdrawResourceId}
|
||||||
>
|
className="w-full"
|
||||||
{isPending ? (
|
variant="destructive"
|
||||||
<Loader2Icon className="size-4 animate-spin mr-2" />
|
>
|
||||||
) : (
|
{isPending ? (
|
||||||
<ArrowDownToLineIcon className="size-4 mr-2" />
|
<Loader2Icon className="size-4 animate-spin mr-2" />
|
||||||
)}
|
) : (
|
||||||
Deposit
|
<ArrowUpFromLineIcon className="size-4 mr-2" />
|
||||||
</Button>
|
)}
|
||||||
</TabsContent>
|
Withdraw
|
||||||
|
</Button>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4">
|
{canTransfer && (
|
||||||
<div className="flex flex-col gap-2">
|
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
|
||||||
<Label>Resource</Label>
|
<div className="flex flex-col gap-2">
|
||||||
<Select
|
<Label>Destination</Label>
|
||||||
value={withdrawResourceId}
|
<Select
|
||||||
onValueChange={(val) => {
|
value={transferToId}
|
||||||
setWithdrawResourceId(val);
|
onValueChange={(val) => {
|
||||||
const stored = findStoredResource(val);
|
setTransferToId(val);
|
||||||
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount));
|
setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val)));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a resource..." />
|
<SelectValue placeholder="Select a structure..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{storedResources.map((entry) => {
|
{allStructures.map((s) => (
|
||||||
const resource = entry.resource as Resource;
|
<SelectItem key={s.id} value={String(s.id)}>
|
||||||
if (!resource || typeof resource === "number") return null;
|
{s.name}
|
||||||
return (
|
|
||||||
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
|
||||||
{resource.name} ({entry.amount.toLocaleString()}{" "}
|
|
||||||
{pluralize(entry.amount, resource.unitOfMeasure)})
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
))}
|
||||||
})}
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</Select>
|
</div>
|
||||||
</div>
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-col gap-2">
|
<Label>Resource</Label>
|
||||||
<Label>Amount</Label>
|
<Select
|
||||||
<Input
|
value={transferResourceId}
|
||||||
type="number"
|
onValueChange={(val) => {
|
||||||
min="1"
|
setTransferResourceId(val);
|
||||||
max={getWithdrawMax()}
|
const stored = findStoredResource(val);
|
||||||
value={withdrawAmount}
|
setTransferAmount(clampAmount(transferAmount, stored?.amount));
|
||||||
onChange={(e) =>
|
}}
|
||||||
setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax()))
|
>
|
||||||
}
|
<SelectTrigger>
|
||||||
/>
|
<SelectValue placeholder="Select a resource..." />
|
||||||
{getWithdrawMax() !== undefined && (
|
</SelectTrigger>
|
||||||
<span className="text-xs text-muted-foreground">
|
<SelectContent>
|
||||||
Max: {getWithdrawMax()!.toLocaleString()} stored
|
{storedResources.map((entry) => {
|
||||||
</span>
|
const resource = entry.resource as Resource;
|
||||||
)}
|
if (!resource || typeof resource === "number") return null;
|
||||||
</div>
|
return (
|
||||||
{renderCapacityPreview("withdraw")}
|
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
||||||
<Button
|
{resource.name} ({entry.amount.toLocaleString()}{" "}
|
||||||
onClick={handleWithdraw}
|
{pluralize(entry.amount, resource.unitOfMeasure)})
|
||||||
disabled={isPending || !withdrawResourceId}
|
</SelectItem>
|
||||||
className="w-full"
|
);
|
||||||
variant="destructive"
|
})}
|
||||||
>
|
</SelectContent>
|
||||||
{isPending ? (
|
</Select>
|
||||||
<Loader2Icon className="size-4 animate-spin mr-2" />
|
</div>
|
||||||
) : (
|
<div className="flex flex-col gap-2">
|
||||||
<ArrowUpFromLineIcon className="size-4 mr-2" />
|
<Label>Amount</Label>
|
||||||
)}
|
<Input
|
||||||
Withdraw
|
type="number"
|
||||||
</Button>
|
min="1"
|
||||||
</TabsContent>
|
max={getTransferMax()}
|
||||||
|
value={transferAmount}
|
||||||
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
|
onChange={(e) =>
|
||||||
<div className="flex flex-col gap-2">
|
setTransferAmount(clampAmount(e.target.value, getTransferMax()))
|
||||||
<Label>Destination</Label>
|
}
|
||||||
<Select
|
/>
|
||||||
value={transferToId}
|
{getTransferMax() !== undefined && (
|
||||||
onValueChange={(val) => {
|
<span className="text-xs text-muted-foreground">
|
||||||
setTransferToId(val);
|
Max: {getTransferMax()!.toLocaleString()} stored
|
||||||
setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val)));
|
</span>
|
||||||
}}
|
)}
|
||||||
>
|
{transferToId &&
|
||||||
<SelectTrigger>
|
transferResourceId &&
|
||||||
<SelectValue placeholder="Select a structure..." />
|
(() => {
|
||||||
</SelectTrigger>
|
const dest = allStructures.find((s) => s.id === Number(transferToId));
|
||||||
<SelectContent>
|
const destType = dest?.type;
|
||||||
{allStructures.map((s) => (
|
if (!dest || !destType) return null;
|
||||||
<SelectItem key={s.id} value={String(s.id)}>
|
const stored = findStoredResource(transferResourceId);
|
||||||
{s.name}
|
if (!stored) return null;
|
||||||
</SelectItem>
|
const resId = stored.resource.id;
|
||||||
))}
|
if (isResourceProhibited(destType, resId)) {
|
||||||
</SelectContent>
|
return (
|
||||||
</Select>
|
<span className="text-xs text-red-400">
|
||||||
</div>
|
Destination prohibits this resource.
|
||||||
<div className="flex flex-col gap-2">
|
</span>
|
||||||
<Label>Resource</Label>
|
);
|
||||||
<Select
|
}
|
||||||
value={transferResourceId}
|
if (!isResourceWhitelisted(destType, resId)) {
|
||||||
onValueChange={(val) => {
|
return (
|
||||||
setTransferResourceId(val);
|
<span className="text-xs text-amber-400">
|
||||||
const stored = findStoredResource(val);
|
Destination only accepts items on its allowed storage list. This
|
||||||
setTransferAmount(clampAmount(transferAmount, stored?.amount));
|
resource is not permitted.
|
||||||
}}
|
</span>
|
||||||
>
|
);
|
||||||
<SelectTrigger>
|
}
|
||||||
<SelectValue placeholder="Select a resource..." />
|
const cap = getResourceStorageCap(destType, resId);
|
||||||
</SelectTrigger>
|
const destStored = getStoredAmountOf(dest, resId);
|
||||||
<SelectContent>
|
|
||||||
{storedResources.map((entry) => {
|
|
||||||
const resource = entry.resource as Resource;
|
|
||||||
if (!resource || typeof resource === "number") return null;
|
|
||||||
return (
|
return (
|
||||||
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
<span className="text-xs text-muted-foreground">
|
||||||
{resource.name} ({entry.amount.toLocaleString()}{" "}
|
{cap != null
|
||||||
{pluralize(entry.amount, resource.unitOfMeasure)})
|
? `Destination allowance: ${Math.max(0, cap - destStored).toLocaleString()} / ${cap.toLocaleString()}`
|
||||||
</SelectItem>
|
: "Destination has no per-item storage limit"}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label>Amount</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max={getTransferMax()}
|
|
||||||
value={transferAmount}
|
|
||||||
onChange={(e) =>
|
|
||||||
setTransferAmount(clampAmount(e.target.value, getTransferMax()))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{getTransferMax() !== undefined && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Max: {getTransferMax()!.toLocaleString()} stored
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{transferToId &&
|
|
||||||
transferResourceId &&
|
|
||||||
(() => {
|
|
||||||
const dest = allStructures.find((s) => s.id === Number(transferToId));
|
|
||||||
const destType = dest?.type;
|
|
||||||
if (!dest || !destType) return null;
|
|
||||||
const stored = findStoredResource(transferResourceId);
|
|
||||||
if (!stored) return null;
|
|
||||||
const resId = stored.resource.id;
|
|
||||||
if (isResourceProhibited(destType, resId)) {
|
|
||||||
return (
|
|
||||||
<span className="text-xs text-red-400">
|
|
||||||
Destination prohibits this resource.
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
})()}
|
||||||
if (!isResourceWhitelisted(destType, resId)) {
|
</div>
|
||||||
return (
|
{renderCapacityPreview("transfer")}
|
||||||
<span className="text-xs text-amber-400">
|
<Button
|
||||||
Destination only accepts items on its allowed storage list. This
|
onClick={handleTransfer}
|
||||||
resource is not permitted.
|
disabled={
|
||||||
</span>
|
isPending || !transferToId || !transferResourceId || getTransferMax() === 0
|
||||||
);
|
}
|
||||||
}
|
className="w-full"
|
||||||
const cap = getResourceStorageCap(destType, resId);
|
>
|
||||||
const destStored = getStoredAmountOf(dest, resId);
|
{isPending ? (
|
||||||
return (
|
<Loader2Icon className="size-4 animate-spin mr-2" />
|
||||||
<span className="text-xs text-muted-foreground">
|
) : (
|
||||||
{cap != null
|
<ArrowRightLeftIcon className="size-4 mr-2" />
|
||||||
? `Destination allowance: ${Math.max(0, cap - destStored).toLocaleString()} / ${cap.toLocaleString()}`
|
)}
|
||||||
: "Destination has no per-item storage limit"}
|
Transfer
|
||||||
</span>
|
</Button>
|
||||||
);
|
</TabsContent>
|
||||||
})()}
|
)}
|
||||||
</div>
|
|
||||||
{renderCapacityPreview("transfer")}
|
|
||||||
<Button
|
|
||||||
onClick={handleTransfer}
|
|
||||||
disabled={
|
|
||||||
isPending || !transferToId || !transferResourceId || getTransferMax() === 0
|
|
||||||
}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<Loader2Icon className="size-4 animate-spin mr-2" />
|
|
||||||
) : (
|
|
||||||
<ArrowRightLeftIcon className="size-4 mr-2" />
|
|
||||||
)}
|
|
||||||
Transfer
|
|
||||||
</Button>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -45,9 +45,17 @@ function getResourceIcon(type: Resource["type"]) {
|
||||||
|
|
||||||
interface StructureStorageViewProps {
|
interface StructureStorageViewProps {
|
||||||
structure: GameStructure & { type: Structure };
|
structure: GameStructure & { type: Structure };
|
||||||
|
/** Rearranging/rotating/splitting conserves quantities — logistics personnel or superuser. */
|
||||||
|
canArrange?: boolean;
|
||||||
|
/** Removing grid items / withdrawing destroys stored stock — superuser only. */
|
||||||
|
canMutate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
export function StructureStorageView({
|
||||||
|
structure,
|
||||||
|
canArrange = false,
|
||||||
|
canMutate = false,
|
||||||
|
}: StructureStorageViewProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||||
const [selectedItem, setSelectedItem] = useState<GridItem | null>(null);
|
const [selectedItem, setSelectedItem] = useState<GridItem | null>(null);
|
||||||
|
|
@ -80,64 +88,72 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
||||||
|
|
||||||
const handleMoveItem = useCallback(
|
const handleMoveItem = useCallback(
|
||||||
async (entryId: string, gridX: number, gridY: number, rotated?: boolean) => {
|
async (entryId: string, gridX: number, gridY: number, rotated?: boolean) => {
|
||||||
|
if (!canArrange) return;
|
||||||
const result = await moveGridItem(structure.id, entryId, gridX, gridY, rotated);
|
const result = await moveGridItem(structure.id, entryId, gridX, gridY, rotated);
|
||||||
if (result.success) refresh();
|
if (result.success) refresh();
|
||||||
},
|
},
|
||||||
[structure.id, refresh],
|
[structure.id, refresh, canArrange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMergeStacks = useCallback(
|
const handleMergeStacks = useCallback(
|
||||||
async (sourceId: string, targetId: string) => {
|
async (sourceId: string, targetId: string) => {
|
||||||
|
if (!canArrange) return;
|
||||||
const result = await mergeGridStacks(structure.id, sourceId, targetId);
|
const result = await mergeGridStacks(structure.id, sourceId, targetId);
|
||||||
if (result.success) refresh();
|
if (result.success) refresh();
|
||||||
},
|
},
|
||||||
[structure.id, refresh],
|
[structure.id, refresh, canArrange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRotateItem = useCallback(
|
const handleRotateItem = useCallback(
|
||||||
async (entryId: string) => {
|
async (entryId: string) => {
|
||||||
|
if (!canArrange) return;
|
||||||
const result = await rotateGridItem(structure.id, entryId);
|
const result = await rotateGridItem(structure.id, entryId);
|
||||||
if (result.success) refresh();
|
if (result.success) refresh();
|
||||||
},
|
},
|
||||||
[structure.id, refresh],
|
[structure.id, refresh, canArrange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRemoveItem = useCallback(
|
const handleRemoveItem = useCallback(
|
||||||
async (entryId: string) => {
|
async (entryId: string) => {
|
||||||
|
if (!canMutate) return;
|
||||||
const result = await removeGridItem(structure.id, entryId);
|
const result = await removeGridItem(structure.id, entryId);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setSelectedItem(null);
|
setSelectedItem(null);
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[structure.id, refresh],
|
[structure.id, refresh, canMutate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSplitStack = useCallback(
|
const handleSplitStack = useCallback(
|
||||||
async (entryId: string, amount: number) => {
|
async (entryId: string, amount: number) => {
|
||||||
|
if (!canArrange) return;
|
||||||
const result = await splitGridStack(structure.id, entryId, amount);
|
const result = await splitGridStack(structure.id, entryId, amount);
|
||||||
if (result.success) refresh();
|
if (result.success) refresh();
|
||||||
},
|
},
|
||||||
[structure.id, refresh],
|
[structure.id, refresh, canArrange],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAutoArrange = useCallback(async () => {
|
const handleAutoArrange = useCallback(async () => {
|
||||||
|
if (!canArrange) return;
|
||||||
const result = await autoArrangeGrid(structure.id);
|
const result = await autoArrangeGrid(structure.id);
|
||||||
if (result.success) refresh();
|
if (result.success) refresh();
|
||||||
}, [structure.id, refresh]);
|
}, [structure.id, refresh, canArrange]);
|
||||||
|
|
||||||
const handleWithdrawFromVoid = useCallback(
|
const handleWithdrawFromVoid = useCallback(
|
||||||
async (resourceId: number, amount: number) => {
|
async (resourceId: number, amount: number) => {
|
||||||
|
if (!canMutate) return;
|
||||||
const result = await removeResource(structure.id, resourceId, amount);
|
const result = await removeResource(structure.id, resourceId, amount);
|
||||||
if (result.success) refresh();
|
if (result.success) refresh();
|
||||||
},
|
},
|
||||||
[structure.id, refresh],
|
[structure.id, refresh, canMutate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRetrieveFromVoid = useCallback(async () => {
|
const handleRetrieveFromVoid = useCallback(async () => {
|
||||||
|
if (!canArrange) return;
|
||||||
const result = await retrieveFromVoid(structure.id);
|
const result = await retrieveFromVoid(structure.id);
|
||||||
if (result.success) refresh();
|
if (result.success) refresh();
|
||||||
}, [structure.id, refresh]);
|
}, [structure.id, refresh, canArrange]);
|
||||||
|
|
||||||
if (!hasGrid) {
|
if (!hasGrid) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -179,6 +195,8 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
||||||
onRotate={handleRotateItem}
|
onRotate={handleRotateItem}
|
||||||
onRemove={handleRemoveItem}
|
onRemove={handleRemoveItem}
|
||||||
onSplit={handleSplitStack}
|
onSplit={handleSplitStack}
|
||||||
|
canArrange={canArrange}
|
||||||
|
canMutate={canMutate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{viewMode === "grid" ? (
|
{viewMode === "grid" ? (
|
||||||
|
|
@ -213,13 +231,15 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
||||||
{voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""}: these no
|
{voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""}: these no
|
||||||
longer fit in the grid.
|
longer fit in the grid.
|
||||||
</span>
|
</span>
|
||||||
<button
|
{canArrange && (
|
||||||
type="button"
|
<button
|
||||||
onClick={handleRetrieveFromVoid}
|
type="button"
|
||||||
className="text-xs bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 px-2 py-0.5 rounded transition-colors"
|
onClick={handleRetrieveFromVoid}
|
||||||
>
|
className="text-xs bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 px-2 py-0.5 rounded transition-colors"
|
||||||
Retrieve All to Grid
|
>
|
||||||
</button>
|
Retrieve All to Grid
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
|
|
@ -262,14 +282,16 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
||||||
{mass > 0 && `${mass.toLocaleString()} kg`}
|
{mass > 0 && `${mass.toLocaleString()} kg`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{canMutate && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => handleWithdrawFromVoid(entry.resourceId, entry.amount)}
|
type="button"
|
||||||
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded transition-colors shrink-0"
|
onClick={() => handleWithdrawFromVoid(entry.resourceId, entry.amount)}
|
||||||
title="Withdraw from structure"
|
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded transition-colors shrink-0"
|
||||||
>
|
title="Withdraw from structure"
|
||||||
Withdraw
|
>
|
||||||
</button>
|
Withdraw
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{index < voidStorageItems.length - 1 && <ItemSeparator />}
|
{index < voidStorageItems.length - 1 && <ItemSeparator />}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
67
src/components/ui/table.tsx
Normal file
67
src/components/ui/table.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return <thead className={cn("[&_tr]:border-b", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
className={cn("p-2 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
|
||||||
|
return <caption className={cn("text-muted-foreground mt-4 text-sm", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||||
|
|
@ -77,7 +77,7 @@ export function txAmountPrefix(flow: TxFlow): string {
|
||||||
*/
|
*/
|
||||||
export function formatTxTimestamp(ts: string): string {
|
export function formatTxTimestamp(ts: string): string {
|
||||||
const date = new Date(ts);
|
const date = new Date(ts);
|
||||||
if (Number.isNaN(date.getTime())) return "—";
|
if (Number.isNaN(date.getTime())) return "-";
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const isToday = date.toDateString() === now.toDateString();
|
const isToday = date.toDateString() === now.toDateString();
|
||||||
if (isToday) {
|
if (isToday) {
|
||||||
|
|
|
||||||
138
src/lib/intelligence/readyReminders.ts
Normal file
138
src/lib/intelligence/readyReminders.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
import type { Mission } from "@/payload-types";
|
||||||
|
import { notifyUser } from "@/lib/notifications";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ready-reminder system for player-scheduled side ops.
|
||||||
|
*
|
||||||
|
* Starting READY_REMINDER_LEAD_DAYS days before an op's scheduled start, the
|
||||||
|
* creator gets a once-per-day reminder (web notification + Discord DM) to
|
||||||
|
* finish the op and mark it "Ready" — the status that triggers the roll-call
|
||||||
|
* embed, locks in details, and delivers the mission hooks. Reminders stop the
|
||||||
|
* moment the op is Ready (or cancelled/completed/active), and a marker field
|
||||||
|
* records the last server-local day each channel was sent so the cadence is
|
||||||
|
* exactly one reminder per day per channel.
|
||||||
|
*
|
||||||
|
* Scope: only ops with a `createdBy` (player-scheduled via the Friday Ops
|
||||||
|
* calendar — always `operationType: "side"`). Staff/admin-created missions are
|
||||||
|
* never reminded.
|
||||||
|
*
|
||||||
|
* Shared by the mission-tick bin (web notifications) and the Discord bot's
|
||||||
|
* readyReminders service (DMs) — keep this module free of discord.js imports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** How many days before the op the daily reminders begin. */
|
||||||
|
export const READY_REMINDER_LEAD_DAYS = 5;
|
||||||
|
|
||||||
|
/** Notification type used for the web-app reminder (labels + bridge exclusion). */
|
||||||
|
export const READY_REMINDER_NOTIFICATION_TYPE = "mission:ready-reminder";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuses that still need the creator's attention. "Ready" is the committed
|
||||||
|
* state the reminders push toward; Active/Completed/Cancelled are terminal or
|
||||||
|
* already underway.
|
||||||
|
*/
|
||||||
|
export const READY_REMINDER_ELIGIBLE_STATUSES = ["Concept", "Planning", "Scheduled"] as const;
|
||||||
|
|
||||||
|
export const missionTitle = (mission: Mission): string =>
|
||||||
|
mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name;
|
||||||
|
|
||||||
|
/** Server-local YYYY-MM-DD key used for the once-per-day cadence. */
|
||||||
|
export const dayKey = (date: Date): string =>
|
||||||
|
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(
|
||||||
|
date.getDate(),
|
||||||
|
).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A channel is due when it has never sent, or last sent on a previous
|
||||||
|
* server-local day. Day-based (not 24h-based) so retries within the same day
|
||||||
|
* stay deduped, and a status flapping Ready → Scheduled resumes reminders the
|
||||||
|
* next day instead of being suppressed forever.
|
||||||
|
*/
|
||||||
|
export const isReminderDue = (marker: string | null | undefined, now: Date): boolean => {
|
||||||
|
if (!marker) return true;
|
||||||
|
const markerDate = new Date(marker);
|
||||||
|
if (Number.isNaN(markerDate.getTime())) return true;
|
||||||
|
return dayKey(markerDate) !== dayKey(now);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const daysUntil = (startIso: string, now: Date): number =>
|
||||||
|
Math.max(0, Math.ceil((new Date(startIso).getTime() - now.getTime()) / 86_400_000));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ops due for reminders: player-created (createdBy set), still not Ready /
|
||||||
|
* terminal, and starting within the lead window (future start, at most
|
||||||
|
* READY_REMINDER_LEAD_DAYS days out). Marker filtering is done per-channel by
|
||||||
|
* the caller via isReminderDue — the "not sent today" condition can't be
|
||||||
|
* expressed in a single where clause.
|
||||||
|
*/
|
||||||
|
export const getReadyReminderMissions = async (payload: Payload, now = new Date()): Promise<Mission[]> => {
|
||||||
|
const nowIso = now.toISOString();
|
||||||
|
const windowEndIso = new Date(
|
||||||
|
now.getTime() + READY_REMINDER_LEAD_DAYS * 86_400_000,
|
||||||
|
).toISOString();
|
||||||
|
const res = await payload.find({
|
||||||
|
collection: "missions",
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
sort: "classification.startDateTime",
|
||||||
|
limit: 50,
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{ createdBy: { exists: true } },
|
||||||
|
{ "ownershipAndStatus.status": { in: [...READY_REMINDER_ELIGIBLE_STATUSES] } },
|
||||||
|
{ "classification.startDateTime": { greater_than: nowIso } },
|
||||||
|
{ "classification.startDateTime": { less_than_equal: windowEndIso } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.docs as unknown as Mission[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ReadyReminderSummary {
|
||||||
|
missions: number;
|
||||||
|
notified: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web sweep: one in-app notification per due mission per server-local day.
|
||||||
|
* Called from the mission-tick bin. Respects each user's notification mutes
|
||||||
|
* (notifyUser handles suppression) and stamps the per-day marker.
|
||||||
|
*/
|
||||||
|
export const sendWebReadyReminders = async (
|
||||||
|
payload: Payload,
|
||||||
|
now = new Date(),
|
||||||
|
): Promise<ReadyReminderSummary> => {
|
||||||
|
const missions = await getReadyReminderMissions(payload, now);
|
||||||
|
let notified = 0;
|
||||||
|
for (const mission of missions) {
|
||||||
|
if (!isReminderDue(mission.readyReminderWebSentAt, now)) continue;
|
||||||
|
if (mission.createdBy == null) continue;
|
||||||
|
const creatorId =
|
||||||
|
typeof mission.createdBy === "object" ? mission.createdBy.id : mission.createdBy;
|
||||||
|
if (!creatorId) continue;
|
||||||
|
|
||||||
|
const days = daysUntil(mission.classification.startDateTime, now);
|
||||||
|
const when =
|
||||||
|
days === 0
|
||||||
|
? "today"
|
||||||
|
: days === 1
|
||||||
|
? "tomorrow"
|
||||||
|
: `in ${days} days`;
|
||||||
|
await notifyUser(payload, {
|
||||||
|
userId: creatorId,
|
||||||
|
type: READY_REMINDER_NOTIFICATION_TYPE,
|
||||||
|
title: `Ready check: ${missionTitle(mission)}`,
|
||||||
|
message: `Your op starts ${when}. Finish up the details and set the mission status to Ready so the roll-call can be posted and the mission hooks delivered.`,
|
||||||
|
link: `/intelligence/missions/${mission.id}`,
|
||||||
|
});
|
||||||
|
await payload.update({
|
||||||
|
collection: "missions",
|
||||||
|
id: mission.id,
|
||||||
|
data: { readyReminderWebSentAt: now.toISOString() },
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
notified += 1;
|
||||||
|
}
|
||||||
|
return { missions: missions.length, notified };
|
||||||
|
};
|
||||||
|
|
@ -91,6 +91,8 @@ export function notificationLabel(type?: string | null): string {
|
||||||
return "New reply";
|
return "New reply";
|
||||||
case "ticket:status":
|
case "ticket:status":
|
||||||
return "Ticket updated";
|
return "Ticket updated";
|
||||||
|
case "mission:ready-reminder":
|
||||||
|
return "Ready reminder";
|
||||||
case "assignment-transfer:request":
|
case "assignment-transfer:request":
|
||||||
return "Transfer request";
|
return "Transfer request";
|
||||||
case "assignment-transfer:approved":
|
case "assignment-transfer:approved":
|
||||||
|
|
|
||||||
23260
src/migrations/20260907_090245_add_mission_ready_reminder_fields.json
Normal file
23260
src/migrations/20260907_090245_add_mission_ready_reminder_fields.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "missions" ADD COLUMN "ready_reminder_web_sent_at" timestamp(3) with time zone;
|
||||||
|
ALTER TABLE "missions" ADD COLUMN "ready_reminder_dm_sent_at" timestamp(3) with time zone;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "missions" DROP COLUMN "ready_reminder_web_sent_at";
|
||||||
|
ALTER TABLE "missions" DROP COLUMN "ready_reminder_dm_sent_at";`)
|
||||||
|
}
|
||||||
|
|
@ -1,261 +1,267 @@
|
||||||
import * as migration_20260820_004157 from "./20260820_004157";
|
import * as migration_20260820_004157 from './20260820_004157';
|
||||||
import * as migration_20260825_220000 from "./20260825_220000";
|
import * as migration_20260825_220000 from './20260825_220000';
|
||||||
import * as migration_20260826_050000_repair_shims_auxiliary_tables from "./20260826_050000_repair_shims_auxiliary_tables";
|
import * as migration_20260826_050000_repair_shims_auxiliary_tables from './20260826_050000_repair_shims_auxiliary_tables';
|
||||||
import * as migration_20260826_060000_add_game_servers_event_target from "./20260826_060000_add_game_servers_event_target";
|
import * as migration_20260826_060000_add_game_servers_event_target from './20260826_060000_add_game_servers_event_target';
|
||||||
import * as migration_20260827_005757 from "./20260827_005757";
|
import * as migration_20260827_005757 from './20260827_005757';
|
||||||
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from "./20260827_022334_add_eod_minesweeper_best_flags";
|
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from './20260827_022334_add_eod_minesweeper_best_flags';
|
||||||
import * as migration_20260827_213857_add_evaluations_collection from "./20260827_213857_add_evaluations_collection";
|
import * as migration_20260827_213857_add_evaluations_collection from './20260827_213857_add_evaluations_collection';
|
||||||
import * as migration_20260827_233224_add_mission_completed_at from "./20260827_233224_add_mission_completed_at";
|
import * as migration_20260827_233224_add_mission_completed_at from './20260827_233224_add_mission_completed_at';
|
||||||
import * as migration_20260828_011500_add_background_texture_preference from "./20260828_011500_add_background_texture_preference";
|
import * as migration_20260828_011500_add_background_texture_preference from './20260828_011500_add_background_texture_preference';
|
||||||
import * as migration_20260828_041615_add_campaign_mode from "./20260828_041615_add_campaign_mode";
|
import * as migration_20260828_041615_add_campaign_mode from './20260828_041615_add_campaign_mode';
|
||||||
import * as migration_20260828_044506 from "./20260828_044506";
|
import * as migration_20260828_044506 from './20260828_044506';
|
||||||
import * as migration_20260831_142503 from "./20260831_142503";
|
import * as migration_20260831_142503 from './20260831_142503';
|
||||||
import * as migration_20260831_180233_assignment_transfers from "./20260831_180233_assignment_transfers";
|
import * as migration_20260831_180233_assignment_transfers from './20260831_180233_assignment_transfers';
|
||||||
import * as migration_20260901_014006_add_assignment_transfer_event_target from "./20260901_014006_add_assignment_transfer_event_target";
|
import * as migration_20260901_014006_add_assignment_transfer_event_target from './20260901_014006_add_assignment_transfer_event_target';
|
||||||
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from "./20260901_213108_add_ticket_votes_and_edit_history";
|
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from './20260901_213108_add_ticket_votes_and_edit_history';
|
||||||
import * as migration_20260901_222200_add_admin_page_permissions from "./20260901_222200_add_admin_page_permissions";
|
import * as migration_20260901_222200_add_admin_page_permissions from './20260901_222200_add_admin_page_permissions';
|
||||||
import * as migration_20260902_195336_add_medal_backgrounds from "./20260902_195336_add_medal_backgrounds";
|
import * as migration_20260902_195336_add_medal_backgrounds from './20260902_195336_add_medal_backgrounds';
|
||||||
import * as migration_20260903_035544 from "./20260903_035544";
|
import * as migration_20260903_035544 from './20260903_035544';
|
||||||
import * as migration_20260903_044047 from "./20260903_044047";
|
import * as migration_20260903_044047 from './20260903_044047';
|
||||||
import * as migration_20260903_053419 from "./20260903_053419";
|
import * as migration_20260903_053419 from './20260903_053419';
|
||||||
import * as migration_20260903_060000_add_radio_mode_stats from "./20260903_060000_add_radio_mode_stats";
|
import * as migration_20260903_060000_add_radio_mode_stats from './20260903_060000_add_radio_mode_stats';
|
||||||
import * as migration_20260903_061000_add_radio_incorrect_counts from "./20260903_061000_add_radio_incorrect_counts";
|
import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts';
|
||||||
import * as migration_20260903_062000_add_aim_trainer_stats from "./20260903_062000_add_aim_trainer_stats";
|
import * as migration_20260903_062000_add_aim_trainer_stats from './20260903_062000_add_aim_trainer_stats';
|
||||||
import * as migration_20260904_230838_add_wiki_collections from "./20260904_230838_add_wiki_collections";
|
import * as migration_20260904_230838_add_wiki_collections from './20260904_230838_add_wiki_collections';
|
||||||
import * as migration_20260905_183940_base_management from "./20260905_183940_base_management";
|
import * as migration_20260905_183940_base_management from './20260905_183940_base_management';
|
||||||
import * as migration_20260905_195456_labor_headcount from "./20260905_195456_labor_headcount";
|
import * as migration_20260905_195456_labor_headcount from './20260905_195456_labor_headcount';
|
||||||
import * as migration_20260905_210310_labor_classifications from "./20260905_210310_labor_classifications";
|
import * as migration_20260905_210310_labor_classifications from './20260905_210310_labor_classifications';
|
||||||
import * as migration_20260905_213824 from "./20260905_213824";
|
import * as migration_20260905_213824 from './20260905_213824';
|
||||||
import * as migration_20260905_215658 from "./20260905_215658";
|
import * as migration_20260905_215658 from './20260905_215658';
|
||||||
import * as migration_20260906_045502 from "./20260906_045502";
|
import * as migration_20260906_045502 from './20260906_045502';
|
||||||
import * as migration_20260906_050900_add_evaluation_reminders_field from "./20260906_050900_add_evaluation_reminders_field";
|
import * as migration_20260906_050900_add_evaluation_reminders_field from './20260906_050900_add_evaluation_reminders_field';
|
||||||
import * as migration_20260906_061451_add_wiki_template_css from "./20260906_061451_add_wiki_template_css";
|
import * as migration_20260906_061451_add_wiki_template_css from './20260906_061451_add_wiki_template_css';
|
||||||
import * as migration_20260906_063753_add_personal_excerpt_richtext from "./20260906_063753_add_personal_excerpt_richtext";
|
import * as migration_20260906_063753_add_personal_excerpt_richtext from './20260906_063753_add_personal_excerpt_richtext';
|
||||||
import * as migration_20260906_065405_add_wiki_template_html from "./20260906_065405_add_wiki_template_html";
|
import * as migration_20260906_065405_add_wiki_template_html from './20260906_065405_add_wiki_template_html';
|
||||||
import * as migration_20260906_100908_add_market_economy_state from "./20260906_100908_add_market_economy_state";
|
import * as migration_20260906_100908_add_market_economy_state from './20260906_100908_add_market_economy_state';
|
||||||
import * as migration_20260906_165837_add_market_state_event_target from "./20260906_165837_add_market_state_event_target";
|
import * as migration_20260906_165837_add_market_state_event_target from './20260906_165837_add_market_state_event_target';
|
||||||
import * as migration_20260906_202925_add_voice_submissions from "./20260906_202925_add_voice_submissions";
|
import * as migration_20260906_202925_add_voice_submissions from './20260906_202925_add_voice_submissions';
|
||||||
import * as migration_20260907_012326_add_missions_created_by from "./20260907_012326_add_missions_created_by";
|
import * as migration_20260907_012326_add_missions_created_by from './20260907_012326_add_missions_created_by';
|
||||||
import * as migration_20260907_013724_make_missions_campaign_optional from "./20260907_013724_make_missions_campaign_optional";
|
import * as migration_20260907_013724_make_missions_campaign_optional from './20260907_013724_make_missions_campaign_optional';
|
||||||
import * as migration_20260907_041051_add_voice_script_spoiler from "./20260907_041051_add_voice_script_spoiler";
|
import * as migration_20260907_041051_add_voice_script_spoiler from './20260907_041051_add_voice_script_spoiler';
|
||||||
import * as migration_20260907_043339_add_voice_script_description from "./20260907_043339_add_voice_script_description";
|
import * as migration_20260907_043339_add_voice_script_description from './20260907_043339_add_voice_script_description';
|
||||||
import * as migration_20260907_044934_add_missions_discord_modlist_hash from "./20260907_044934_add_missions_discord_modlist_hash";
|
import * as migration_20260907_044934_add_missions_discord_modlist_hash from './20260907_044934_add_missions_discord_modlist_hash';
|
||||||
import * as migration_20260907_083010_add_voice_submission_description from "./20260907_083010_add_voice_submission_description";
|
import * as migration_20260907_083010_add_voice_submission_description from './20260907_083010_add_voice_submission_description';
|
||||||
|
import * as migration_20260907_090245_add_mission_ready_reminder_fields from './20260907_090245_add_mission_ready_reminder_fields';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260820_004157.up,
|
up: migration_20260820_004157.up,
|
||||||
down: migration_20260820_004157.down,
|
down: migration_20260820_004157.down,
|
||||||
name: "20260820_004157",
|
name: '20260820_004157',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260825_220000.up,
|
up: migration_20260825_220000.up,
|
||||||
down: migration_20260825_220000.down,
|
down: migration_20260825_220000.down,
|
||||||
name: "20260825_220000",
|
name: '20260825_220000',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260826_050000_repair_shims_auxiliary_tables.up,
|
up: migration_20260826_050000_repair_shims_auxiliary_tables.up,
|
||||||
down: migration_20260826_050000_repair_shims_auxiliary_tables.down,
|
down: migration_20260826_050000_repair_shims_auxiliary_tables.down,
|
||||||
name: "20260826_050000_repair_shims_auxiliary_tables",
|
name: '20260826_050000_repair_shims_auxiliary_tables',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260826_060000_add_game_servers_event_target.up,
|
up: migration_20260826_060000_add_game_servers_event_target.up,
|
||||||
down: migration_20260826_060000_add_game_servers_event_target.down,
|
down: migration_20260826_060000_add_game_servers_event_target.down,
|
||||||
name: "20260826_060000_add_game_servers_event_target",
|
name: '20260826_060000_add_game_servers_event_target',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260827_005757.up,
|
up: migration_20260827_005757.up,
|
||||||
down: migration_20260827_005757.down,
|
down: migration_20260827_005757.down,
|
||||||
name: "20260827_005757",
|
name: '20260827_005757',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260827_022334_add_eod_minesweeper_best_flags.up,
|
up: migration_20260827_022334_add_eod_minesweeper_best_flags.up,
|
||||||
down: migration_20260827_022334_add_eod_minesweeper_best_flags.down,
|
down: migration_20260827_022334_add_eod_minesweeper_best_flags.down,
|
||||||
name: "20260827_022334_add_eod_minesweeper_best_flags",
|
name: '20260827_022334_add_eod_minesweeper_best_flags',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260827_213857_add_evaluations_collection.up,
|
up: migration_20260827_213857_add_evaluations_collection.up,
|
||||||
down: migration_20260827_213857_add_evaluations_collection.down,
|
down: migration_20260827_213857_add_evaluations_collection.down,
|
||||||
name: "20260827_213857_add_evaluations_collection",
|
name: '20260827_213857_add_evaluations_collection',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260827_233224_add_mission_completed_at.up,
|
up: migration_20260827_233224_add_mission_completed_at.up,
|
||||||
down: migration_20260827_233224_add_mission_completed_at.down,
|
down: migration_20260827_233224_add_mission_completed_at.down,
|
||||||
name: "20260827_233224_add_mission_completed_at",
|
name: '20260827_233224_add_mission_completed_at',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260828_011500_add_background_texture_preference.up,
|
up: migration_20260828_011500_add_background_texture_preference.up,
|
||||||
down: migration_20260828_011500_add_background_texture_preference.down,
|
down: migration_20260828_011500_add_background_texture_preference.down,
|
||||||
name: "20260828_011500_add_background_texture_preference",
|
name: '20260828_011500_add_background_texture_preference',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260828_041615_add_campaign_mode.up,
|
up: migration_20260828_041615_add_campaign_mode.up,
|
||||||
down: migration_20260828_041615_add_campaign_mode.down,
|
down: migration_20260828_041615_add_campaign_mode.down,
|
||||||
name: "20260828_041615_add_campaign_mode",
|
name: '20260828_041615_add_campaign_mode',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260828_044506.up,
|
up: migration_20260828_044506.up,
|
||||||
down: migration_20260828_044506.down,
|
down: migration_20260828_044506.down,
|
||||||
name: "20260828_044506",
|
name: '20260828_044506',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260831_142503.up,
|
up: migration_20260831_142503.up,
|
||||||
down: migration_20260831_142503.down,
|
down: migration_20260831_142503.down,
|
||||||
name: "20260831_142503",
|
name: '20260831_142503',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260831_180233_assignment_transfers.up,
|
up: migration_20260831_180233_assignment_transfers.up,
|
||||||
down: migration_20260831_180233_assignment_transfers.down,
|
down: migration_20260831_180233_assignment_transfers.down,
|
||||||
name: "20260831_180233_assignment_transfers",
|
name: '20260831_180233_assignment_transfers',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260901_014006_add_assignment_transfer_event_target.up,
|
up: migration_20260901_014006_add_assignment_transfer_event_target.up,
|
||||||
down: migration_20260901_014006_add_assignment_transfer_event_target.down,
|
down: migration_20260901_014006_add_assignment_transfer_event_target.down,
|
||||||
name: "20260901_014006_add_assignment_transfer_event_target",
|
name: '20260901_014006_add_assignment_transfer_event_target',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260901_213108_add_ticket_votes_and_edit_history.up,
|
up: migration_20260901_213108_add_ticket_votes_and_edit_history.up,
|
||||||
down: migration_20260901_213108_add_ticket_votes_and_edit_history.down,
|
down: migration_20260901_213108_add_ticket_votes_and_edit_history.down,
|
||||||
name: "20260901_213108_add_ticket_votes_and_edit_history",
|
name: '20260901_213108_add_ticket_votes_and_edit_history',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260901_222200_add_admin_page_permissions.up,
|
up: migration_20260901_222200_add_admin_page_permissions.up,
|
||||||
down: migration_20260901_222200_add_admin_page_permissions.down,
|
down: migration_20260901_222200_add_admin_page_permissions.down,
|
||||||
name: "20260901_222200_add_admin_page_permissions",
|
name: '20260901_222200_add_admin_page_permissions',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260902_195336_add_medal_backgrounds.up,
|
up: migration_20260902_195336_add_medal_backgrounds.up,
|
||||||
down: migration_20260902_195336_add_medal_backgrounds.down,
|
down: migration_20260902_195336_add_medal_backgrounds.down,
|
||||||
name: "20260902_195336_add_medal_backgrounds",
|
name: '20260902_195336_add_medal_backgrounds',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260903_035544.up,
|
up: migration_20260903_035544.up,
|
||||||
down: migration_20260903_035544.down,
|
down: migration_20260903_035544.down,
|
||||||
name: "20260903_035544",
|
name: '20260903_035544',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260903_044047.up,
|
up: migration_20260903_044047.up,
|
||||||
down: migration_20260903_044047.down,
|
down: migration_20260903_044047.down,
|
||||||
name: "20260903_044047",
|
name: '20260903_044047',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260903_053419.up,
|
up: migration_20260903_053419.up,
|
||||||
down: migration_20260903_053419.down,
|
down: migration_20260903_053419.down,
|
||||||
name: "20260903_053419",
|
name: '20260903_053419',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260903_060000_add_radio_mode_stats.up,
|
up: migration_20260903_060000_add_radio_mode_stats.up,
|
||||||
down: migration_20260903_060000_add_radio_mode_stats.down,
|
down: migration_20260903_060000_add_radio_mode_stats.down,
|
||||||
name: "20260903_060000_add_radio_mode_stats",
|
name: '20260903_060000_add_radio_mode_stats',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260903_061000_add_radio_incorrect_counts.up,
|
up: migration_20260903_061000_add_radio_incorrect_counts.up,
|
||||||
down: migration_20260903_061000_add_radio_incorrect_counts.down,
|
down: migration_20260903_061000_add_radio_incorrect_counts.down,
|
||||||
name: "20260903_061000_add_radio_incorrect_counts",
|
name: '20260903_061000_add_radio_incorrect_counts',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260903_062000_add_aim_trainer_stats.up,
|
up: migration_20260903_062000_add_aim_trainer_stats.up,
|
||||||
down: migration_20260903_062000_add_aim_trainer_stats.down,
|
down: migration_20260903_062000_add_aim_trainer_stats.down,
|
||||||
name: "20260903_062000_add_aim_trainer_stats",
|
name: '20260903_062000_add_aim_trainer_stats',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260904_230838_add_wiki_collections.up,
|
up: migration_20260904_230838_add_wiki_collections.up,
|
||||||
down: migration_20260904_230838_add_wiki_collections.down,
|
down: migration_20260904_230838_add_wiki_collections.down,
|
||||||
name: "20260904_230838_add_wiki_collections",
|
name: '20260904_230838_add_wiki_collections',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260905_183940_base_management.up,
|
up: migration_20260905_183940_base_management.up,
|
||||||
down: migration_20260905_183940_base_management.down,
|
down: migration_20260905_183940_base_management.down,
|
||||||
name: "20260905_183940_base_management",
|
name: '20260905_183940_base_management',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260905_195456_labor_headcount.up,
|
up: migration_20260905_195456_labor_headcount.up,
|
||||||
down: migration_20260905_195456_labor_headcount.down,
|
down: migration_20260905_195456_labor_headcount.down,
|
||||||
name: "20260905_195456_labor_headcount",
|
name: '20260905_195456_labor_headcount',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260905_210310_labor_classifications.up,
|
up: migration_20260905_210310_labor_classifications.up,
|
||||||
down: migration_20260905_210310_labor_classifications.down,
|
down: migration_20260905_210310_labor_classifications.down,
|
||||||
name: "20260905_210310_labor_classifications",
|
name: '20260905_210310_labor_classifications',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260905_213824.up,
|
up: migration_20260905_213824.up,
|
||||||
down: migration_20260905_213824.down,
|
down: migration_20260905_213824.down,
|
||||||
name: "20260905_213824",
|
name: '20260905_213824',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260905_215658.up,
|
up: migration_20260905_215658.up,
|
||||||
down: migration_20260905_215658.down,
|
down: migration_20260905_215658.down,
|
||||||
name: "20260905_215658",
|
name: '20260905_215658',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260906_045502.up,
|
up: migration_20260906_045502.up,
|
||||||
down: migration_20260906_045502.down,
|
down: migration_20260906_045502.down,
|
||||||
name: "20260906_045502",
|
name: '20260906_045502',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260906_050900_add_evaluation_reminders_field.up,
|
up: migration_20260906_050900_add_evaluation_reminders_field.up,
|
||||||
down: migration_20260906_050900_add_evaluation_reminders_field.down,
|
down: migration_20260906_050900_add_evaluation_reminders_field.down,
|
||||||
name: "20260906_050900_add_evaluation_reminders_field",
|
name: '20260906_050900_add_evaluation_reminders_field',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260906_061451_add_wiki_template_css.up,
|
up: migration_20260906_061451_add_wiki_template_css.up,
|
||||||
down: migration_20260906_061451_add_wiki_template_css.down,
|
down: migration_20260906_061451_add_wiki_template_css.down,
|
||||||
name: "20260906_061451_add_wiki_template_css",
|
name: '20260906_061451_add_wiki_template_css',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260906_063753_add_personal_excerpt_richtext.up,
|
up: migration_20260906_063753_add_personal_excerpt_richtext.up,
|
||||||
down: migration_20260906_063753_add_personal_excerpt_richtext.down,
|
down: migration_20260906_063753_add_personal_excerpt_richtext.down,
|
||||||
name: "20260906_063753_add_personal_excerpt_richtext",
|
name: '20260906_063753_add_personal_excerpt_richtext',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260906_065405_add_wiki_template_html.up,
|
up: migration_20260906_065405_add_wiki_template_html.up,
|
||||||
down: migration_20260906_065405_add_wiki_template_html.down,
|
down: migration_20260906_065405_add_wiki_template_html.down,
|
||||||
name: "20260906_065405_add_wiki_template_html",
|
name: '20260906_065405_add_wiki_template_html',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260906_100908_add_market_economy_state.up,
|
up: migration_20260906_100908_add_market_economy_state.up,
|
||||||
down: migration_20260906_100908_add_market_economy_state.down,
|
down: migration_20260906_100908_add_market_economy_state.down,
|
||||||
name: "20260906_100908_add_market_economy_state",
|
name: '20260906_100908_add_market_economy_state',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260906_165837_add_market_state_event_target.up,
|
up: migration_20260906_165837_add_market_state_event_target.up,
|
||||||
down: migration_20260906_165837_add_market_state_event_target.down,
|
down: migration_20260906_165837_add_market_state_event_target.down,
|
||||||
name: "20260906_165837_add_market_state_event_target",
|
name: '20260906_165837_add_market_state_event_target',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260906_202925_add_voice_submissions.up,
|
up: migration_20260906_202925_add_voice_submissions.up,
|
||||||
down: migration_20260906_202925_add_voice_submissions.down,
|
down: migration_20260906_202925_add_voice_submissions.down,
|
||||||
name: "20260906_202925_add_voice_submissions",
|
name: '20260906_202925_add_voice_submissions',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260907_012326_add_missions_created_by.up,
|
up: migration_20260907_012326_add_missions_created_by.up,
|
||||||
down: migration_20260907_012326_add_missions_created_by.down,
|
down: migration_20260907_012326_add_missions_created_by.down,
|
||||||
name: "20260907_012326_add_missions_created_by",
|
name: '20260907_012326_add_missions_created_by',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260907_013724_make_missions_campaign_optional.up,
|
up: migration_20260907_013724_make_missions_campaign_optional.up,
|
||||||
down: migration_20260907_013724_make_missions_campaign_optional.down,
|
down: migration_20260907_013724_make_missions_campaign_optional.down,
|
||||||
name: "20260907_013724_make_missions_campaign_optional",
|
name: '20260907_013724_make_missions_campaign_optional',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260907_041051_add_voice_script_spoiler.up,
|
up: migration_20260907_041051_add_voice_script_spoiler.up,
|
||||||
down: migration_20260907_041051_add_voice_script_spoiler.down,
|
down: migration_20260907_041051_add_voice_script_spoiler.down,
|
||||||
name: "20260907_041051_add_voice_script_spoiler",
|
name: '20260907_041051_add_voice_script_spoiler',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260907_043339_add_voice_script_description.up,
|
up: migration_20260907_043339_add_voice_script_description.up,
|
||||||
down: migration_20260907_043339_add_voice_script_description.down,
|
down: migration_20260907_043339_add_voice_script_description.down,
|
||||||
name: "20260907_043339_add_voice_script_description",
|
name: '20260907_043339_add_voice_script_description',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260907_044934_add_missions_discord_modlist_hash.up,
|
up: migration_20260907_044934_add_missions_discord_modlist_hash.up,
|
||||||
down: migration_20260907_044934_add_missions_discord_modlist_hash.down,
|
down: migration_20260907_044934_add_missions_discord_modlist_hash.down,
|
||||||
name: "20260907_044934_add_missions_discord_modlist_hash",
|
name: '20260907_044934_add_missions_discord_modlist_hash',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
up: migration_20260907_083010_add_voice_submission_description.up,
|
up: migration_20260907_083010_add_voice_submission_description.up,
|
||||||
down: migration_20260907_083010_add_voice_submission_description.down,
|
down: migration_20260907_083010_add_voice_submission_description.down,
|
||||||
name: "20260907_083010_add_voice_submission_description",
|
name: '20260907_083010_add_voice_submission_description',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260907_090245_add_mission_ready_reminder_fields.up,
|
||||||
|
down: migration_20260907_090245_add_mission_ready_reminder_fields.down,
|
||||||
|
name: '20260907_090245_add_mission_ready_reminder_fields'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -2380,6 +2380,14 @@ export interface Mission {
|
||||||
* When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot).
|
* When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot).
|
||||||
*/
|
*/
|
||||||
evaluationRemindersSentAt?: string | null;
|
evaluationRemindersSentAt?: string | null;
|
||||||
|
/**
|
||||||
|
* Server-local day the last web 'not Ready' reminder notification was sent to the op creator (managed by the mission tick, once per day).
|
||||||
|
*/
|
||||||
|
readyReminderWebSentAt?: string | null;
|
||||||
|
/**
|
||||||
|
* Server-local day the last 'not Ready' reminder DM was sent to the op creator (managed by the Discord bot, once per day).
|
||||||
|
*/
|
||||||
|
readyReminderDmSentAt?: string | null;
|
||||||
/**
|
/**
|
||||||
* Player who scheduled this mission via the Friday Ops calendar (ownership + scheduling-cap tracking). Set automatically; leave empty for staff-created missions.
|
* Player who scheduled this mission via the Friday Ops calendar (ownership + scheduling-cap tracking). Set automatically; leave empty for staff-created missions.
|
||||||
*/
|
*/
|
||||||
|
|
@ -3014,7 +3022,7 @@ export interface MarketNegotiation {
|
||||||
buyer: number | User;
|
buyer: number | User;
|
||||||
status: 'open' | 'accepted' | 'rejected' | 'closed' | 'cancelled' | 'expired';
|
status: 'open' | 'accepted' | 'rejected' | 'closed' | 'cancelled' | 'expired';
|
||||||
/**
|
/**
|
||||||
* NPC vendor patience (0–100). Rises each bargaining round and sharply for lowball offers; at 100 the negotiation closes and the buyer can only buy at the asking price.
|
* NPC vendor patience (0-100). Rises each bargaining round and sharply for lowball offers; at 100 the negotiation closes and the buyer can only buy at the asking price.
|
||||||
*/
|
*/
|
||||||
patience?: number | null;
|
patience?: number | null;
|
||||||
/**
|
/**
|
||||||
|
|
@ -4489,6 +4497,8 @@ export interface MissionsSelect<T extends boolean = true> {
|
||||||
discordAttendanceSentAt?: T;
|
discordAttendanceSentAt?: T;
|
||||||
discordModlistHash?: T;
|
discordModlistHash?: T;
|
||||||
evaluationRemindersSentAt?: T;
|
evaluationRemindersSentAt?: T;
|
||||||
|
readyReminderWebSentAt?: T;
|
||||||
|
readyReminderDmSentAt?: T;
|
||||||
createdBy?: T;
|
createdBy?: T;
|
||||||
coverImage?: T;
|
coverImage?: T;
|
||||||
classification?:
|
classification?:
|
||||||
|
|
@ -5727,7 +5737,7 @@ export interface Shim {
|
||||||
targeting: {
|
targeting: {
|
||||||
audience: 'all' | 'percentage' | 'users' | 'roles';
|
audience: 'all' | 'percentage' | 'users' | 'roles';
|
||||||
/**
|
/**
|
||||||
* Percentage of users (0–100) who receive this shim. Assignment is deterministic per user.
|
* Percentage of users (0-100) who receive this shim. Assignment is deterministic per user.
|
||||||
*/
|
*/
|
||||||
percentage?: number | null;
|
percentage?: number | null;
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { SanitizedConfig } from "payload";
|
import type { SanitizedConfig } from "payload";
|
||||||
import { getPayload } from "payload";
|
import { getPayload } from "payload";
|
||||||
import { autoCompleteMissions } from "@/lib/intelligence/missionLifecycle";
|
import { autoCompleteMissions } from "@/lib/intelligence/missionLifecycle";
|
||||||
|
import { sendWebReadyReminders } from "@/lib/intelligence/readyReminders";
|
||||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
||||||
|
|
||||||
export const script = async (config: SanitizedConfig) => {
|
export const script = async (config: SanitizedConfig) => {
|
||||||
|
|
@ -13,6 +14,13 @@ export const script = async (config: SanitizedConfig) => {
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
const result = await autoCompleteMissions(payload);
|
const result = await autoCompleteMissions(payload);
|
||||||
|
|
||||||
|
const reminders = await sendWebReadyReminders(payload);
|
||||||
|
if (reminders.notified > 0) {
|
||||||
|
log.info(
|
||||||
|
`[Mission Tick] Sent ${reminders.notified} ready reminder notification(s) across ${reminders.missions} due op(s).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
||||||
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
|
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
|
||||||
if (notifySecret) {
|
if (notifySecret) {
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@ const USER_PERMISSIONS: Permission[] = [
|
||||||
"shims:read",
|
"shims:read",
|
||||||
"shipments:create",
|
"shipments:create",
|
||||||
"shipments:update",
|
"shipments:update",
|
||||||
"structures:update",
|
|
||||||
"structures:delete",
|
"structures:delete",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -87,6 +86,7 @@ const ADMIN_PERMISSIONS: Permission[] = [
|
||||||
"discord:staff",
|
"discord:staff",
|
||||||
"discord:announce",
|
"discord:announce",
|
||||||
"structures:create",
|
"structures:create",
|
||||||
|
"structures:update",
|
||||||
...ALL_COLLECTION_READS,
|
...ALL_COLLECTION_READS,
|
||||||
...ALL_ADMIN_PAGE_MANAGE,
|
...ALL_ADMIN_PAGE_MANAGE,
|
||||||
]),
|
]),
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,7 @@ export const EventTypes = {
|
||||||
MissionAutoComplete: "mission:auto-complete",
|
MissionAutoComplete: "mission:auto-complete",
|
||||||
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
|
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
|
||||||
MissionSchedule: "mission:schedule",
|
MissionSchedule: "mission:schedule",
|
||||||
|
MissionReady: "mission:ready",
|
||||||
MissionUpdate: "mission:update",
|
MissionUpdate: "mission:update",
|
||||||
MissionCancel: "mission:cancel",
|
MissionCancel: "mission:cancel",
|
||||||
MissionMoveCampaign: "mission:move-campaign",
|
MissionMoveCampaign: "mission:move-campaign",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue