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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
**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.
|
||||
|
||||
## Gotchas
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"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.",
|
||||
"license": "MIT",
|
||||
"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>
|
||||
)}
|
||||
{campaign.startDate && campaign.endDate && <span>—</span>}
|
||||
{campaign.startDate && campaign.endDate && <span>-</span>}
|
||||
{campaign.endDate ? (
|
||||
<span>
|
||||
<ClientDate
|
||||
|
|
@ -267,14 +267,14 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
|
|||
)}
|
||||
{campaign.endDate ? (
|
||||
<>
|
||||
{" — "}
|
||||
{" - "}
|
||||
<ClientDate
|
||||
date={campaign.endDate}
|
||||
options={{ year: "numeric", month: "short", day: "numeric" }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
" — Present"
|
||||
" - Present"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { getPayload } from "payload";
|
|||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
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 {
|
||||
ArrowLeftIcon,
|
||||
CalendarDaysIcon,
|
||||
|
|
@ -13,6 +13,7 @@ import {
|
|||
FlagIcon,
|
||||
MapPinIcon,
|
||||
ServerIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { ClientDate } from "@/components/frontend/ClientDate";
|
||||
|
|
@ -94,6 +95,13 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
|||
? (missionWithCoverImage.coverImage as Media)
|
||||
: 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 durationMins = mission.classification.estimatedDuration % 60;
|
||||
|
|
@ -328,6 +336,17 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
|||
</span>
|
||||
</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 && (
|
||||
<Link
|
||||
href={`/intelligence/campaigns/${campaign.id}`}
|
||||
|
|
@ -367,7 +386,7 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
|||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</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(
|
||||
missionId: number,
|
||||
campaignId: number,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { pluralize } from "@/lib/utils";
|
|||
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
||||
import { currencyConfigFromGameRules } from "@/lib/banking";
|
||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||
import { isSuperuser } from "@/utils/access-control/hasPermission";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BoxIcon,
|
||||
|
|
@ -183,6 +184,8 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
};
|
||||
});
|
||||
const canManage = await hasLogisticsQualification(payload, user);
|
||||
const canManageStorage = await isSuperuser(payload, user);
|
||||
const canTransferStorage = canManageStorage || canManage;
|
||||
|
||||
const classificationsRes = await payload.find({
|
||||
collection: "labor-classifications",
|
||||
|
|
@ -307,7 +310,13 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
{structure.faction.name}
|
||||
</span>
|
||||
)}
|
||||
{user && <ManageStorageButton structureId={structure.id} />}
|
||||
{user && (
|
||||
<ManageStorageButton
|
||||
structureId={structure.id}
|
||||
canManage={canManageStorage}
|
||||
canTransfer={canTransferStorage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -413,7 +422,11 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
|||
currencyConfig={currencyConfig}
|
||||
/>
|
||||
|
||||
<StructureStorageView structure={structure} />
|
||||
<StructureStorageView
|
||||
structure={structure}
|
||||
canMutate={canManageStorage}
|
||||
canArrange={canTransferStorage}
|
||||
/>
|
||||
|
||||
<StorageRules
|
||||
allowedStorage={structureType.allowedStorage}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
import config from "@payload-config";
|
||||
import { getPayload } from "payload";
|
||||
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 { EventTypes } from "@/utils/event-log/eventTypes";
|
||||
import { checkStorageDeposit, storageViolationMessage } from "@/lib/storageRules";
|
||||
|
|
@ -14,6 +15,19 @@ interface ActionResult {
|
|||
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() {
|
||||
const payloadConfig = await config;
|
||||
const payload = await getPayload({ config: payloadConfig });
|
||||
|
|
@ -74,13 +88,30 @@ export async function addResource(
|
|||
): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canManageStorage(payload, user))) {
|
||||
return {
|
||||
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) {
|
||||
return { success: false, error: "Amount must be greater than zero." };
|
||||
}
|
||||
|
|
@ -264,13 +295,30 @@ export async function removeResource(
|
|||
): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canManageStorage(payload, user))) {
|
||||
return {
|
||||
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) {
|
||||
return { success: false, error: "Amount must be greater than zero." };
|
||||
}
|
||||
|
|
@ -373,10 +421,10 @@ export async function transferResource(
|
|||
): Promise<ActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canHandleStorage(payload, user))) {
|
||||
return {
|
||||
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
|
||||
const removeResult = await removeResource(fromStructureId, resourceId, amount);
|
||||
// Remove from source (unguarded internal — gate already applied above)
|
||||
const removeResult = await removeResourceInternal(
|
||||
payload,
|
||||
user,
|
||||
fromStructureId,
|
||||
resourceId,
|
||||
amount,
|
||||
);
|
||||
if (!removeResult.success) {
|
||||
return removeResult;
|
||||
}
|
||||
|
||||
// Add to destination
|
||||
const addResult = await addResource(toStructureId, resourceId, amount);
|
||||
const addResult = await addResourceInternal(payload, user, toStructureId, resourceId, amount);
|
||||
if (!addResult.success) {
|
||||
// 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}` };
|
||||
}
|
||||
|
||||
|
|
@ -524,10 +578,10 @@ export async function placeResourceOnGrid(
|
|||
): Promise<GridActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canManageStorage(payload, user))) {
|
||||
return {
|
||||
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> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canHandleStorage(payload, user))) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
||||
error: "Logistics personnel or superuser role required.",
|
||||
};
|
||||
}
|
||||
const structure = await getStructure(payload, structureId);
|
||||
|
|
@ -730,10 +784,10 @@ export async function mergeGridStacks(
|
|||
): Promise<GridActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canHandleStorage(payload, user))) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
||||
error: "Logistics personnel or superuser role required.",
|
||||
};
|
||||
}
|
||||
const structure = await getStructure(payload, structureId);
|
||||
|
|
@ -812,10 +866,10 @@ export async function rotateGridItem(
|
|||
): Promise<GridActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canHandleStorage(payload, user))) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
||||
error: "Logistics personnel or superuser role required.",
|
||||
};
|
||||
}
|
||||
const structure = await getStructure(payload, structureId);
|
||||
|
|
@ -899,10 +953,10 @@ export async function removeGridItem(
|
|||
): Promise<GridActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canManageStorage(payload, user))) {
|
||||
return {
|
||||
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);
|
||||
|
|
@ -953,10 +1007,10 @@ export async function splitGridStack(
|
|||
): Promise<GridActionResult> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canHandleStorage(payload, user))) {
|
||||
return {
|
||||
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> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
return { success: false, error: "Insufficient permissions." };
|
||||
if (!(await canHandleStorage(payload, user))) {
|
||||
return { success: false, error: "Logistics personnel or superuser role required." };
|
||||
}
|
||||
const structure = await getStructure(payload, structureId);
|
||||
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> {
|
||||
try {
|
||||
const { payload, user } = await authenticate();
|
||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
||||
if (!(await canHandleStorage(payload, user))) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Insufficient permissions. User, Admin, or Developer role required.",
|
||||
error: "Logistics personnel or superuser role required.",
|
||||
};
|
||||
}
|
||||
const structure = await getStructure(payload, structureId);
|
||||
|
|
|
|||
|
|
@ -263,7 +263,7 @@ export default async function VehicleDetailPage({ params }: VehicleDetailPagePro
|
|||
<ItemContent>
|
||||
<ItemTitle>Grid Dimensions</ItemTitle>
|
||||
<ItemDescription>
|
||||
{vehicle.width ?? "—"}×{vehicle.height ?? "—"}
|
||||
{vehicle.width ?? "-"}×{vehicle.height ?? "-"}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||
const airKills = scoreboard.airKills ?? 0;
|
||||
const deaths = scoreboard.deaths ?? 0;
|
||||
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 now = new Date();
|
||||
|
|
@ -562,7 +562,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||
Deaths
|
||||
</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 className="bg-muted/30">
|
||||
<td className="px-4 py-2.5 flex items-center gap-2 font-semibold">
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export interface BotConfig {
|
|||
attendancePollMs: number;
|
||||
notificationPollMs: number;
|
||||
evaluationPollMs: number;
|
||||
readyReminderPollMs: number;
|
||||
}
|
||||
|
||||
const required = (name: string): string => {
|
||||
|
|
@ -48,4 +49,5 @@ export const botConfig: BotConfig = {
|
|||
attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000),
|
||||
notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_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 lines = links.map((link) => `• [${link.displayName}](${appUrl}${link.profilePath})`);
|
||||
// Discord embed field values cap at 1024 chars.
|
||||
return lines.join("\n").slice(0, 1024) || "—";
|
||||
return lines.join("\n").slice(0, 1024) || "-";
|
||||
};
|
||||
|
||||
const renderReminderEmbed = (
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { start as startMissionEmbeds } from "./missionEmbeds";
|
|||
import { start as startNotificationBridge } from "./notificationBridge";
|
||||
import { start as startTransferRequests } from "./transferRequests";
|
||||
import { start as startEvaluationReminders } from "./evaluationReminders";
|
||||
import { start as startReadyReminders } from "./readyReminders";
|
||||
|
||||
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
|
||||
|
||||
|
|
@ -12,4 +13,5 @@ export const services: ServiceStarter[] = [
|
|||
startNotificationBridge,
|
||||
startTransferRequests,
|
||||
startEvaluationReminders,
|
||||
startReadyReminders,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ export const renderAttendanceEmbed = (
|
|||
summary: MissionAttendanceSummary,
|
||||
): EmbedBuilder => {
|
||||
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()
|
||||
.setTitle(missionTitle(mission))
|
||||
.setDescription(
|
||||
|
|
|
|||
|
|
@ -3,14 +3,19 @@ import type { Payload } from "payload";
|
|||
import type { User, UserNotification } from "@/payload-types";
|
||||
import { botConfig } from "@/bot/config";
|
||||
import { TRANSFER_NOTIFICATION_TYPES } from "@/lib/transfers/notificationTypes";
|
||||
import { READY_REMINDER_NOTIFICATION_TYPE } from "@/lib/intelligence/readyReminders";
|
||||
|
||||
const MAX_SENDS_PER_TICK = 5;
|
||||
const SEND_DELAY_MS = 300;
|
||||
const EMBED_COLOR = 0x3b82f6;
|
||||
|
||||
// Transfer notifications are delivered by the dedicated transfer bot service
|
||||
// (Phase 2), not this generic bridge. Exclude exactly these types.
|
||||
const NON_BRIDGED_TYPES = new Set<string>(Object.values(TRANSFER_NOTIFICATION_TYPES));
|
||||
// (Phase 2), not this generic bridge. Ready reminders are delivered by the
|
||||
// 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;
|
||||
|
||||
|
|
|
|||
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: {
|
||||
condition: (_data, siblingData) => siblingData?.audience === "percentage",
|
||||
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,
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
type: "relationship",
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export const MarketNegotiations: CollectionConfig = {
|
|||
max: 100,
|
||||
admin: {
|
||||
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;
|
||||
id: number | null;
|
||||
} {
|
||||
if (!account) return { label: "—", id: null };
|
||||
if (!account) return { label: "-", id: null };
|
||||
if (typeof account === "object") {
|
||||
return { label: account.name ?? `#${account.id}`, id: account.id };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Users,
|
||||
Maximize,
|
||||
Minimize,
|
||||
ScrollText,
|
||||
SparkleIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
|
|
@ -56,6 +57,11 @@ const data = {
|
|||
url: "/intelligence/missions/calendar",
|
||||
icon: CalendarDays,
|
||||
},
|
||||
{
|
||||
title: "Event Log",
|
||||
url: "/events",
|
||||
icon: ScrollText,
|
||||
},
|
||||
],
|
||||
...sidebarData,
|
||||
};
|
||||
|
|
@ -151,7 +157,6 @@ export function AppSidebar({
|
|||
)}
|
||||
<NavCore label="Helpdesk" items={data.navHelpdesk} />
|
||||
<NavCore label="Commerce" items={data.navCommerce} />
|
||||
<NavCore label="Personnel" items={data.navPersonnel} />
|
||||
<NavCore label="Personal" items={data.navPersonal} />
|
||||
{/*<NavProjects projects={data.projects} />*/}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
TrendingUp,
|
||||
Truck,
|
||||
UserCog,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
|
||||
export const sidebarData = {
|
||||
|
|
@ -63,29 +64,60 @@ export const sidebarData = {
|
|||
],
|
||||
navLogistics: [
|
||||
{
|
||||
title: "Structures",
|
||||
url: "/logistics",
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
title: "Resources",
|
||||
url: "/logistics/resources",
|
||||
title: "Assets",
|
||||
url: "",
|
||||
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",
|
||||
url: "/logistics/vehicles",
|
||||
icon: Truck,
|
||||
},
|
||||
{
|
||||
title: "Shipments",
|
||||
url: "/logistics/shipments",
|
||||
title: "Operations",
|
||||
url: "",
|
||||
icon: Package,
|
||||
items: [
|
||||
{
|
||||
title: "Shipments",
|
||||
url: "/logistics/shipments",
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
title: "Deployed Vehicles",
|
||||
url: "/logistics/game-vehicles",
|
||||
icon: Car,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Deployed Vehicles",
|
||||
url: "/logistics/game-vehicles",
|
||||
icon: Car,
|
||||
title: "Personnel",
|
||||
url: "",
|
||||
icon: Users,
|
||||
items: [
|
||||
{
|
||||
title: "Contacts",
|
||||
url: "/personnel/contacts",
|
||||
icon: Contact,
|
||||
},
|
||||
{
|
||||
title: "Statistics",
|
||||
url: "/personnel/statistics",
|
||||
icon: BarChart3,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
navCommerce: [
|
||||
|
|
@ -100,18 +132,6 @@ export const sidebarData = {
|
|||
icon: ShoppingBag,
|
||||
},
|
||||
],
|
||||
navPersonnel: [
|
||||
{
|
||||
title: "Contacts",
|
||||
url: "/personnel/contacts",
|
||||
icon: Contact,
|
||||
},
|
||||
{
|
||||
title: "Statistics",
|
||||
url: "/personnel/statistics",
|
||||
icon: BarChart3,
|
||||
},
|
||||
],
|
||||
navPersonal: [
|
||||
{
|
||||
title: "Awards",
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ export function EodLeaderboard({
|
|||
>
|
||||
<td className="px-4 py-2">
|
||||
<span className={rankClass(index)}>
|
||||
{row.isOutsideTopTen ? "—" : index + 1}
|
||||
{row.isOutsideTopTen ? "-" : index + 1}
|
||||
</span>
|
||||
</td>
|
||||
<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}
|
||||
>
|
||||
<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 className="px-2 py-2 font-medium">
|
||||
<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 Date(iso),
|
||||
)
|
||||
: "—";
|
||||
: "-";
|
||||
}
|
||||
|
||||
function isTicketStatus(value: string): value is TicketStatus {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
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 {
|
||||
operationTypeColors,
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
ClockIcon,
|
||||
CrosshairIcon,
|
||||
MapPinIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
|
|
@ -52,6 +53,7 @@ export interface MissionCardProps {
|
|||
readonly missionRoles?: {
|
||||
readonly maxPlayers: number;
|
||||
} | null;
|
||||
readonly createdBy?: number | User | null;
|
||||
readonly coverImage?: number | Media | null;
|
||||
};
|
||||
readonly variant?: "default" | "image";
|
||||
|
|
@ -77,6 +79,12 @@ export function MissionCard({
|
|||
typeof mission.classification.campaign === "object" && mission.classification.campaign !== null
|
||||
? mission.classification.campaign
|
||||
: 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 dateStr = startDate.toLocaleDateString("en-US", {
|
||||
|
|
@ -122,7 +130,7 @@ export function MissionCard({
|
|||
<ItemMedia variant="icon">
|
||||
<CrosshairIcon />
|
||||
</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>{mission.name}</span>
|
||||
</ItemTitle>
|
||||
|
|
@ -131,7 +139,7 @@ export function MissionCard({
|
|||
<span
|
||||
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
|
||||
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
|
||||
</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>}
|
||||
</div>
|
||||
</ItemContent>
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export function MissionComms({ commsAndDocs }: MissionCommsProps) {
|
|||
{frequencies!.map((freq, index) => (
|
||||
<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 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 text-muted-foreground">{freq.freqUsage}</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export function MissionLogistics({ logisticsAndSupport }: MissionLogisticsProps)
|
|||
<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 text-muted-foreground">
|
||||
{asset.assetCount ?? "—"}
|
||||
{asset.assetCount ?? "-"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{asset.doesRespawn ? (
|
||||
|
|
@ -122,7 +122,7 @@ export function MissionLogistics({ logisticsAndSupport }: MissionLogisticsProps)
|
|||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground font-mono text-xs">
|
||||
{asset.assetCallsign ?? "—"}
|
||||
{asset.assetCallsign ?? "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -41,10 +41,10 @@ function ObjectiveText({
|
|||
className?: string;
|
||||
}) {
|
||||
if (!redacted) {
|
||||
return <span className={className}>{text ?? "—"}</span>;
|
||||
return <span className={className}>{text ?? "-"}</span>;
|
||||
}
|
||||
|
||||
const display = redactText(text) ?? "—";
|
||||
const display = redactText(text) ?? "-";
|
||||
|
||||
if (!canReveal) {
|
||||
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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm whitespace-pre-wrap break-words">
|
||||
{text ?? "—"}
|
||||
{text ?? "-"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
|
|
|||
|
|
@ -84,14 +84,14 @@ export function MissionRoles({ roles, notes, maxPlayers }: MissionRolesProps) {
|
|||
<td className="px-4 py-2 text-muted-foreground">
|
||||
{role.requiredMos && role.requiredMos.length > 0
|
||||
? resolveNames(role.requiredMos)
|
||||
: "—"}
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">
|
||||
{typeof role.minimumRank === "object" && role.minimumRank !== null
|
||||
? role.minimumRank.name
|
||||
: typeof role.minimumRank === "number"
|
||||
? `#${role.minimumRank}`
|
||||
: "—"}
|
||||
: "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@
|
|||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { cancelFridaySideOp } from "@/app/(frontend)/intelligence/missions/actions";
|
||||
import {
|
||||
cancelFridaySideOp,
|
||||
markSideOpReady,
|
||||
} from "@/app/(frontend)/intelligence/missions/actions";
|
||||
import {
|
||||
CreateSideOpDialog,
|
||||
type SideOpDialogInitial,
|
||||
|
|
@ -34,6 +37,19 @@ export function SideOpControls({
|
|||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
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() {
|
||||
if (!confirmingCancel) {
|
||||
|
|
@ -64,6 +80,12 @@ export function SideOpControls({
|
|||
</ItemHeader>
|
||||
<ItemContent>
|
||||
<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}>
|
||||
<PencilIcon className="size-3.5" />
|
||||
Edit
|
||||
|
|
@ -79,8 +101,9 @@ export function SideOpControls({
|
|||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Editing covers details, campaign, and narrative. Cancelling frees your scheduling slot
|
||||
and updates the Discord embed.
|
||||
Marking the operation Ready locks in the details so the Discord roll-call can be posted
|
||||
and the mission hooks delivered. Editing covers details, campaign, and narrative.
|
||||
Cancelling frees your scheduling slot and updates the Discord embed.
|
||||
</p>
|
||||
</ItemContent>
|
||||
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export function LockerGridToolbar({
|
|||
<div>
|
||||
<dt className="text-muted-foreground/70">FOOTPRINT</dt>
|
||||
<dd className="font-mono tabular-nums text-foreground/90">
|
||||
{fp ? `${fp.width}×${fp.height}` : "—"}
|
||||
{fp ? `${fp.width}×${fp.height}` : "-"}
|
||||
</dd>
|
||||
</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">
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarIcon className="size-3" />
|
||||
{dateFor(sprint.startDate)} — {dateFor(sprint.endDate)}
|
||||
{dateFor(sprint.startDate)} - {dateFor(sprint.endDate)}
|
||||
</span>
|
||||
</ItemFooter>
|
||||
</Item>
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ export function AimTrainerLeaderboard({
|
|||
>
|
||||
<td className="px-4 py-2">
|
||||
<span className={rankClass(index)}>
|
||||
{row.isOutsideTopTen ? "—" : index + 1}
|
||||
{row.isOutsideTopTen ? "-" : index + 1}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2 font-medium">
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@ export function RadioLeaderboard({
|
|||
}
|
||||
>
|
||||
<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 className="px-2 py-2 font-medium">
|
||||
<span className="flex items-center gap-2">
|
||||
|
|
@ -318,7 +318,7 @@ export function RadioLeaderboard({
|
|||
</td>
|
||||
{mode === "sprint" && (
|
||||
<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 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>
|
||||
<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>
|
||||
<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>}
|
||||
</div>}
|
||||
|
||||
|
|
|
|||
|
|
@ -541,7 +541,7 @@ export function RosterView({
|
|||
</TabsTrigger>
|
||||
<TabsTrigger value="alpha" className="gap-1.5">
|
||||
<ListIcon className="size-3" />
|
||||
A–Z
|
||||
A-Z
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="seniority" className="gap-1.5">
|
||||
<CalendarDaysIcon className="size-3" />
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ interface GridToolbarProps {
|
|||
onRotate: (entryId: string) => Promise<void>;
|
||||
onRemove: (entryId: string) => 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({
|
||||
|
|
@ -37,6 +41,8 @@ export function GridToolbar({
|
|||
onRotate,
|
||||
onRemove,
|
||||
onSplit,
|
||||
canArrange = false,
|
||||
canMutate = false,
|
||||
}: GridToolbarProps) {
|
||||
const occupancy = getGridOccupancy(storedResources, gridWidth, gridHeight);
|
||||
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 items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onAutoArrange}>
|
||||
<ShuffleIcon className="size-3.5 mr-1" />
|
||||
Auto-Arrange
|
||||
</Button>
|
||||
{canArrange && (
|
||||
<Button variant="outline" size="sm" onClick={onAutoArrange}>
|
||||
<ShuffleIcon className="size-3.5 mr-1" />
|
||||
Auto-Arrange
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
|
|
@ -86,12 +94,12 @@ export function GridToolbar({
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{res.rotatable !== false && (
|
||||
{canArrange && res.rotatable !== false && (
|
||||
<Button variant="outline" size="sm" onClick={() => onRotate(selectedItem.id)}>
|
||||
<RotateCwIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{res.stackable && selectedItem.amount > 1 && (
|
||||
{canArrange && res.stackable && selectedItem.amount > 1 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -101,9 +109,11 @@ export function GridToolbar({
|
|||
<ScissorsIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</Button>
|
||||
{canMutate && (
|
||||
<Button variant="outline" size="sm" onClick={() => onRemove(selectedItem.id)}>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,18 +7,32 @@ import { ManageStorageDialog } from "@/components/frontend/storage/ManageStorage
|
|||
|
||||
interface ManageStorageButtonProps {
|
||||
structureId: number;
|
||||
canManage: boolean;
|
||||
canTransfer: boolean;
|
||||
}
|
||||
|
||||
export function ManageStorageButton({ structureId }: ManageStorageButtonProps) {
|
||||
export function ManageStorageButton({
|
||||
structureId,
|
||||
canManage,
|
||||
canTransfer,
|
||||
}: ManageStorageButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!canManage && !canTransfer) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<SettingsIcon className="size-3.5 mr-1.5" />
|
||||
Manage
|
||||
</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;
|
||||
open: boolean;
|
||||
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 [allStructures, setAllStructures] = useState<(GameStructure & { type: Structure })[]>([]);
|
||||
const [allResources, setAllResources] = useState<Resource[]>([]);
|
||||
|
|
@ -641,265 +649,288 @@ export function ManageStorageDialog({ structureId, open, onOpenChange }: ManageS
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Tabs defaultValue="deposit" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="deposit" className="flex items-center gap-1.5">
|
||||
<ArrowDownToLineIcon className="size-3.5" />
|
||||
Deposit
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="withdraw" className="flex items-center gap-1.5">
|
||||
<ArrowUpFromLineIcon className="size-3.5" />
|
||||
Withdraw
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="transfer" className="flex items-center gap-1.5">
|
||||
<ArrowRightLeftIcon className="size-3.5" />
|
||||
Transfer
|
||||
</TabsTrigger>
|
||||
<Tabs defaultValue={canManage ? "deposit" : "transfer"} className="w-full">
|
||||
<TabsList
|
||||
className={cn(
|
||||
"grid w-full",
|
||||
canManage && canTransfer
|
||||
? "grid-cols-3"
|
||||
: canManage
|
||||
? "grid-cols-2"
|
||||
: "grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{canManage && (
|
||||
<TabsTrigger value="deposit" className="flex items-center gap-1.5">
|
||||
<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>
|
||||
|
||||
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Resource</Label>
|
||||
<Select
|
||||
value={depositResourceId}
|
||||
onValueChange={(val) => {
|
||||
setDepositResourceId(val);
|
||||
setDepositAmount(
|
||||
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
|
||||
);
|
||||
}}
|
||||
{canManage && (
|
||||
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Resource</Label>
|
||||
<Select
|
||||
value={depositResourceId}
|
||||
onValueChange={(val) => {
|
||||
setDepositResourceId(val);
|
||||
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>
|
||||
<SelectValue placeholder="Select a resource..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allResources
|
||||
.filter((r) => isResourceAllowed(r.id))
|
||||
.map((resource) => {
|
||||
const cap = getAllowedDepositCap(resource.id);
|
||||
{isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<ArrowDownToLineIcon className="size-4 mr-2" />
|
||||
)}
|
||||
Deposit
|
||||
</Button>
|
||||
</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 (
|
||||
<SelectItem key={resource.id} value={String(resource.id)}>
|
||||
{resource.name}
|
||||
{cap !== undefined && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
({cap.toLocaleString()} max)
|
||||
</span>
|
||||
)}
|
||||
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
||||
{resource.name} ({entry.amount.toLocaleString()}{" "}
|
||||
{pluralize(entry.amount, resource.unitOfMeasure)})
|
||||
</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"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<ArrowDownToLineIcon className="size-4 mr-2" />
|
||||
)}
|
||||
Deposit
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={getWithdrawMax()}
|
||||
value={withdrawAmount}
|
||||
onChange={(e) =>
|
||||
setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax()))
|
||||
}
|
||||
/>
|
||||
{getWithdrawMax() !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {getWithdrawMax()!.toLocaleString()} stored
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{renderCapacityPreview("withdraw")}
|
||||
<Button
|
||||
onClick={handleWithdraw}
|
||||
disabled={isPending || !withdrawResourceId}
|
||||
className="w-full"
|
||||
variant="destructive"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<ArrowUpFromLineIcon className="size-4 mr-2" />
|
||||
)}
|
||||
Withdraw
|
||||
</Button>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<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 (
|
||||
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
||||
{resource.name} ({entry.amount.toLocaleString()}{" "}
|
||||
{pluralize(entry.amount, resource.unitOfMeasure)})
|
||||
{canTransfer && (
|
||||
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Destination</Label>
|
||||
<Select
|
||||
value={transferToId}
|
||||
onValueChange={(val) => {
|
||||
setTransferToId(val);
|
||||
setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val)));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a structure..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allStructures.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Amount</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={getWithdrawMax()}
|
||||
value={withdrawAmount}
|
||||
onChange={(e) =>
|
||||
setWithdrawAmount(clampAmount(e.target.value, getWithdrawMax()))
|
||||
}
|
||||
/>
|
||||
{getWithdrawMax() !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Max: {getWithdrawMax()!.toLocaleString()} stored
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{renderCapacityPreview("withdraw")}
|
||||
<Button
|
||||
onClick={handleWithdraw}
|
||||
disabled={isPending || !withdrawResourceId}
|
||||
className="w-full"
|
||||
variant="destructive"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<ArrowUpFromLineIcon className="size-4 mr-2" />
|
||||
)}
|
||||
Withdraw
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Destination</Label>
|
||||
<Select
|
||||
value={transferToId}
|
||||
onValueChange={(val) => {
|
||||
setTransferToId(val);
|
||||
setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val)));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a structure..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allStructures.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Resource</Label>
|
||||
<Select
|
||||
value={transferResourceId}
|
||||
onValueChange={(val) => {
|
||||
setTransferResourceId(val);
|
||||
const stored = findStoredResource(val);
|
||||
setTransferAmount(clampAmount(transferAmount, 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;
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Resource</Label>
|
||||
<Select
|
||||
value={transferResourceId}
|
||||
onValueChange={(val) => {
|
||||
setTransferResourceId(val);
|
||||
const stored = findStoredResource(val);
|
||||
setTransferAmount(clampAmount(transferAmount, 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 (
|
||||
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
||||
{resource.name} ({entry.amount.toLocaleString()}{" "}
|
||||
{pluralize(entry.amount, resource.unitOfMeasure)})
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
if (!isResourceWhitelisted(destType, resId)) {
|
||||
return (
|
||||
<span className="text-xs text-amber-400">
|
||||
Destination only accepts items on its allowed storage list. This
|
||||
resource is not permitted.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const cap = getResourceStorageCap(destType, resId);
|
||||
const destStored = getStoredAmountOf(dest, resId);
|
||||
return (
|
||||
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
|
||||
{resource.name} ({entry.amount.toLocaleString()}{" "}
|
||||
{pluralize(entry.amount, resource.unitOfMeasure)})
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</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 className="text-xs text-muted-foreground">
|
||||
{cap != null
|
||||
? `Destination allowance: ${Math.max(0, cap - destStored).toLocaleString()} / ${cap.toLocaleString()}`
|
||||
: "Destination has no per-item storage limit"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!isResourceWhitelisted(destType, resId)) {
|
||||
return (
|
||||
<span className="text-xs text-amber-400">
|
||||
Destination only accepts items on its allowed storage list. This
|
||||
resource is not permitted.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const cap = getResourceStorageCap(destType, resId);
|
||||
const destStored = getStoredAmountOf(dest, resId);
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{cap != null
|
||||
? `Destination allowance: ${Math.max(0, cap - destStored).toLocaleString()} / ${cap.toLocaleString()}`
|
||||
: "Destination has no per-item storage limit"}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</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>
|
||||
})()}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -45,9 +45,17 @@ function getResourceIcon(type: Resource["type"]) {
|
|||
|
||||
interface StructureStorageViewProps {
|
||||
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 [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
const [selectedItem, setSelectedItem] = useState<GridItem | null>(null);
|
||||
|
|
@ -80,64 +88,72 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
|||
|
||||
const handleMoveItem = useCallback(
|
||||
async (entryId: string, gridX: number, gridY: number, rotated?: boolean) => {
|
||||
if (!canArrange) return;
|
||||
const result = await moveGridItem(structure.id, entryId, gridX, gridY, rotated);
|
||||
if (result.success) refresh();
|
||||
},
|
||||
[structure.id, refresh],
|
||||
[structure.id, refresh, canArrange],
|
||||
);
|
||||
|
||||
const handleMergeStacks = useCallback(
|
||||
async (sourceId: string, targetId: string) => {
|
||||
if (!canArrange) return;
|
||||
const result = await mergeGridStacks(structure.id, sourceId, targetId);
|
||||
if (result.success) refresh();
|
||||
},
|
||||
[structure.id, refresh],
|
||||
[structure.id, refresh, canArrange],
|
||||
);
|
||||
|
||||
const handleRotateItem = useCallback(
|
||||
async (entryId: string) => {
|
||||
if (!canArrange) return;
|
||||
const result = await rotateGridItem(structure.id, entryId);
|
||||
if (result.success) refresh();
|
||||
},
|
||||
[structure.id, refresh],
|
||||
[structure.id, refresh, canArrange],
|
||||
);
|
||||
|
||||
const handleRemoveItem = useCallback(
|
||||
async (entryId: string) => {
|
||||
if (!canMutate) return;
|
||||
const result = await removeGridItem(structure.id, entryId);
|
||||
if (result.success) {
|
||||
setSelectedItem(null);
|
||||
refresh();
|
||||
}
|
||||
},
|
||||
[structure.id, refresh],
|
||||
[structure.id, refresh, canMutate],
|
||||
);
|
||||
|
||||
const handleSplitStack = useCallback(
|
||||
async (entryId: string, amount: number) => {
|
||||
if (!canArrange) return;
|
||||
const result = await splitGridStack(structure.id, entryId, amount);
|
||||
if (result.success) refresh();
|
||||
},
|
||||
[structure.id, refresh],
|
||||
[structure.id, refresh, canArrange],
|
||||
);
|
||||
|
||||
const handleAutoArrange = useCallback(async () => {
|
||||
if (!canArrange) return;
|
||||
const result = await autoArrangeGrid(structure.id);
|
||||
if (result.success) refresh();
|
||||
}, [structure.id, refresh]);
|
||||
}, [structure.id, refresh, canArrange]);
|
||||
|
||||
const handleWithdrawFromVoid = useCallback(
|
||||
async (resourceId: number, amount: number) => {
|
||||
if (!canMutate) return;
|
||||
const result = await removeResource(structure.id, resourceId, amount);
|
||||
if (result.success) refresh();
|
||||
},
|
||||
[structure.id, refresh],
|
||||
[structure.id, refresh, canMutate],
|
||||
);
|
||||
|
||||
const handleRetrieveFromVoid = useCallback(async () => {
|
||||
if (!canArrange) return;
|
||||
const result = await retrieveFromVoid(structure.id);
|
||||
if (result.success) refresh();
|
||||
}, [structure.id, refresh]);
|
||||
}, [structure.id, refresh, canArrange]);
|
||||
|
||||
if (!hasGrid) {
|
||||
return (
|
||||
|
|
@ -179,6 +195,8 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
|||
onRotate={handleRotateItem}
|
||||
onRemove={handleRemoveItem}
|
||||
onSplit={handleSplitStack}
|
||||
canArrange={canArrange}
|
||||
canMutate={canMutate}
|
||||
/>
|
||||
|
||||
{viewMode === "grid" ? (
|
||||
|
|
@ -213,13 +231,15 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
|||
{voidStorageItems.length} item{voidStorageItems.length !== 1 ? "s" : ""}: these no
|
||||
longer fit in the grid.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
{canArrange && (
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
)}
|
||||
</ItemDescription>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
|
|
@ -262,14 +282,16 @@ export function StructureStorageView({ structure }: StructureStorageViewProps) {
|
|||
{mass > 0 && `${mass.toLocaleString()} kg`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleWithdrawFromVoid(entry.resourceId, entry.amount)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded transition-colors shrink-0"
|
||||
title="Withdraw from structure"
|
||||
>
|
||||
Withdraw
|
||||
</button>
|
||||
{canMutate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleWithdrawFromVoid(entry.resourceId, entry.amount)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1 rounded transition-colors shrink-0"
|
||||
title="Withdraw from structure"
|
||||
>
|
||||
Withdraw
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{index < voidStorageItems.length - 1 && <ItemSeparator />}
|
||||
</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 {
|
||||
const date = new Date(ts);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
if (Number.isNaN(date.getTime())) return "-";
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
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";
|
||||
case "ticket:status":
|
||||
return "Ticket updated";
|
||||
case "mission:ready-reminder":
|
||||
return "Ready reminder";
|
||||
case "assignment-transfer:request":
|
||||
return "Transfer request";
|
||||
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_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_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_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_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_041615_add_campaign_mode from "./20260828_041615_add_campaign_mode";
|
||||
import * as migration_20260828_044506 from "./20260828_044506";
|
||||
import * as migration_20260831_142503 from "./20260831_142503";
|
||||
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_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_20260902_195336_add_medal_backgrounds from "./20260902_195336_add_medal_backgrounds";
|
||||
import * as migration_20260903_035544 from "./20260903_035544";
|
||||
import * as migration_20260903_044047 from "./20260903_044047";
|
||||
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_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_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_195456_labor_headcount from "./20260905_195456_labor_headcount";
|
||||
import * as migration_20260905_210310_labor_classifications from "./20260905_210310_labor_classifications";
|
||||
import * as migration_20260905_213824 from "./20260905_213824";
|
||||
import * as migration_20260905_215658 from "./20260905_215658";
|
||||
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_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_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_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_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_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_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_20260820_004157 from './20260820_004157';
|
||||
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_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_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_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_041615_add_campaign_mode from './20260828_041615_add_campaign_mode';
|
||||
import * as migration_20260828_044506 from './20260828_044506';
|
||||
import * as migration_20260831_142503 from './20260831_142503';
|
||||
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_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_20260902_195336_add_medal_backgrounds from './20260902_195336_add_medal_backgrounds';
|
||||
import * as migration_20260903_035544 from './20260903_035544';
|
||||
import * as migration_20260903_044047 from './20260903_044047';
|
||||
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_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_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_195456_labor_headcount from './20260905_195456_labor_headcount';
|
||||
import * as migration_20260905_210310_labor_classifications from './20260905_210310_labor_classifications';
|
||||
import * as migration_20260905_213824 from './20260905_213824';
|
||||
import * as migration_20260905_215658 from './20260905_215658';
|
||||
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_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_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_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_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_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_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_090245_add_mission_ready_reminder_fields from './20260907_090245_add_mission_ready_reminder_fields';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
up: migration_20260820_004157.up,
|
||||
down: migration_20260820_004157.down,
|
||||
name: "20260820_004157",
|
||||
name: '20260820_004157',
|
||||
},
|
||||
{
|
||||
up: migration_20260825_220000.up,
|
||||
down: migration_20260825_220000.down,
|
||||
name: "20260825_220000",
|
||||
name: '20260825_220000',
|
||||
},
|
||||
{
|
||||
up: migration_20260826_050000_repair_shims_auxiliary_tables.up,
|
||||
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,
|
||||
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,
|
||||
down: migration_20260827_005757.down,
|
||||
name: "20260827_005757",
|
||||
name: '20260827_005757',
|
||||
},
|
||||
{
|
||||
up: migration_20260827_022334_add_eod_minesweeper_best_flags.up,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
down: migration_20260828_044506.down,
|
||||
name: "20260828_044506",
|
||||
name: '20260828_044506',
|
||||
},
|
||||
{
|
||||
up: migration_20260831_142503.up,
|
||||
down: migration_20260831_142503.down,
|
||||
name: "20260831_142503",
|
||||
name: '20260831_142503',
|
||||
},
|
||||
{
|
||||
up: migration_20260831_180233_assignment_transfers.up,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
down: migration_20260903_035544.down,
|
||||
name: "20260903_035544",
|
||||
name: '20260903_035544',
|
||||
},
|
||||
{
|
||||
up: migration_20260903_044047.up,
|
||||
down: migration_20260903_044047.down,
|
||||
name: "20260903_044047",
|
||||
name: '20260903_044047',
|
||||
},
|
||||
{
|
||||
up: migration_20260903_053419.up,
|
||||
down: migration_20260903_053419.down,
|
||||
name: "20260903_053419",
|
||||
name: '20260903_053419',
|
||||
},
|
||||
{
|
||||
up: migration_20260903_060000_add_radio_mode_stats.up,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
down: migration_20260905_183940_base_management.down,
|
||||
name: "20260905_183940_base_management",
|
||||
name: '20260905_183940_base_management',
|
||||
},
|
||||
{
|
||||
up: migration_20260905_195456_labor_headcount.up,
|
||||
down: migration_20260905_195456_labor_headcount.down,
|
||||
name: "20260905_195456_labor_headcount",
|
||||
name: '20260905_195456_labor_headcount',
|
||||
},
|
||||
{
|
||||
up: migration_20260905_210310_labor_classifications.up,
|
||||
down: migration_20260905_210310_labor_classifications.down,
|
||||
name: "20260905_210310_labor_classifications",
|
||||
name: '20260905_210310_labor_classifications',
|
||||
},
|
||||
{
|
||||
up: migration_20260905_213824.up,
|
||||
down: migration_20260905_213824.down,
|
||||
name: "20260905_213824",
|
||||
name: '20260905_213824',
|
||||
},
|
||||
{
|
||||
up: migration_20260905_215658.up,
|
||||
down: migration_20260905_215658.down,
|
||||
name: "20260905_215658",
|
||||
name: '20260905_215658',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_045502.up,
|
||||
down: migration_20260906_045502.down,
|
||||
name: "20260906_045502",
|
||||
name: '20260906_045502',
|
||||
},
|
||||
{
|
||||
up: migration_20260906_050900_add_evaluation_reminders_field.up,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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).
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
|
|
@ -3014,7 +3022,7 @@ export interface MarketNegotiation {
|
|||
buyer: number | User;
|
||||
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;
|
||||
/**
|
||||
|
|
@ -4489,6 +4497,8 @@ export interface MissionsSelect<T extends boolean = true> {
|
|||
discordAttendanceSentAt?: T;
|
||||
discordModlistHash?: T;
|
||||
evaluationRemindersSentAt?: T;
|
||||
readyReminderWebSentAt?: T;
|
||||
readyReminderDmSentAt?: T;
|
||||
createdBy?: T;
|
||||
coverImage?: T;
|
||||
classification?:
|
||||
|
|
@ -5727,7 +5737,7 @@ export interface Shim {
|
|||
targeting: {
|
||||
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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { SanitizedConfig } from "payload";
|
||||
import { getPayload } from "payload";
|
||||
import { autoCompleteMissions } from "@/lib/intelligence/missionLifecycle";
|
||||
import { sendWebReadyReminders } from "@/lib/intelligence/readyReminders";
|
||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
||||
|
||||
export const script = async (config: SanitizedConfig) => {
|
||||
|
|
@ -13,6 +14,13 @@ export const script = async (config: SanitizedConfig) => {
|
|||
const nowIso = new Date().toISOString();
|
||||
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 notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
|
||||
if (notifySecret) {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ const USER_PERMISSIONS: Permission[] = [
|
|||
"shims:read",
|
||||
"shipments:create",
|
||||
"shipments:update",
|
||||
"structures:update",
|
||||
"structures:delete",
|
||||
];
|
||||
|
||||
|
|
@ -87,6 +86,7 @@ const ADMIN_PERMISSIONS: Permission[] = [
|
|||
"discord:staff",
|
||||
"discord:announce",
|
||||
"structures:create",
|
||||
"structures:update",
|
||||
...ALL_COLLECTION_READS,
|
||||
...ALL_ADMIN_PAGE_MANAGE,
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ export const EventTypes = {
|
|||
MissionAutoComplete: "mission:auto-complete",
|
||||
MissionObjectiveRedactedToggle: "mission:objective-redacted-toggle",
|
||||
MissionSchedule: "mission:schedule",
|
||||
MissionReady: "mission:ready",
|
||||
MissionUpdate: "mission:update",
|
||||
MissionCancel: "mission:cancel",
|
||||
MissionMoveCampaign: "mission:move-campaign",
|
||||
|
|
|
|||
Loading…
Reference in a new issue