Compare commits
No commits in common. "bcd6d97ff42fed26dc14aeae3621913d65a3199d" and "5430c02fbcc8328943cae7afda2e65d8a731e139" have entirely different histories.
bcd6d97ff4
...
5430c02fbc
80 changed files with 158 additions and 155100 deletions
|
|
@ -274,9 +274,8 @@ gameTick → POST `/api/game-tick/notify` (guarded by `x-game-tick-secret` heade
|
||||||
|
|
||||||
A Discord bot living in `src/bot/`, run as a standalone long-running process via `bun run bot` — it imports `@payload-config` directly (same pattern as `src/scripts/` and `src/tools/seed/`) and shares the Postgres DB with the web app. **Under active development and testing.** **Full product requirements live in `docs/bot/context.md`; the approved implementation design is in `docs/bot/design.md` — read both before touching bot code.**
|
A Discord bot living in `src/bot/`, run as a standalone long-running process via `bun run bot` — it imports `@payload-config` directly (same pattern as `src/scripts/` and `src/tools/seed/`) and shares the Postgres DB with the web app. **Under active development and testing.** **Full product requirements live in `docs/bot/context.md`; the approved implementation design is in `docs/bot/design.md` — read both before touching bot code.**
|
||||||
|
|
||||||
- **Env / run**: requires `DISCORD_TOKEN` and `DISCORD_GUILD_ID` (fail-fast on missing required vars in `src/bot/config.ts`); optional `DISCORD_OPS_CHANNEL_ID`, `DISCORD_ANNOUNCE_CHANNEL_ID`, `DISCORD_STAFF_ROLE_IDS`, `DISCORD_ATTENDANCE_POLL_MS` (default 60s), `DISCORD_NOTIFICATION_POLL_MS` (default 20s), `DISCORD_EVALUATION_POLL_MS` (default 60s), plus existing `APP_URL`. Logs through `payload.logger`.
|
- **Env / run**: requires `DISCORD_TOKEN` and `DISCORD_GUILD_ID` (fail-fast on missing required vars in `src/bot/config.ts`); optional `DISCORD_OPS_CHANNEL_ID`, `DISCORD_ANNOUNCE_CHANNEL_ID`, `DISCORD_STAFF_ROLE_IDS`, `DISCORD_ATTENDANCE_POLL_MS` (default 60s), `DISCORD_NOTIFICATION_POLL_MS` (default 20s), plus existing `APP_URL`. Logs through `payload.logger`.
|
||||||
- **Structure**: `commands/` — `ping`, `signup`, `link`, `announce`, `remindEvaluations`; `events/interactionCreate.ts` — routes `ptf-att:` RSVP buttons; `services/` — `missionEmbeds` (attendance embed lifecycle + reconcile loop), `notificationBridge` (poll → Discord DMs), and `evaluationReminders` (post-mission evaluation reminder DMs); `lib/` — `roles.ts` (`isStaff`), `resolve.ts` (discordId ↔ Payload user lookups). Command registration scope: `signup`/`link`/`ping` are **global** (DM-usable — guild-scoped commands never appear in DMs), `announce`/`remind-evaluations` are guild-only.
|
- **Structure**: `commands/` — `ping`, `signup`, `link`, `announce`; `events/interactionCreate.ts` — routes `ptf-att:` RSVP buttons; `services/` — `missionEmbeds` (attendance embed lifecycle + reconcile loop) and `notificationBridge` (poll → Discord DMs); `lib/` — `roles.ts` (`isStaff`), `resolve.ts` (discordId ↔ Payload user lookups). Command registration scope: `signup`/`link`/`ping` are **global** (DM-usable — guild-scoped commands never appear in DMs), `announce` is guild-only.
|
||||||
- **Evaluation reminders**: once a mission is evaluable (`Completed`, or `Scheduled`/`Active` past its start — same rule as `isMissionEvaluable`), the bot sends a one-shot DM (per user with a linked `discordId`) asking them to rate their leadership, plus a "rate your subordinates" section for leaders whose members RSVP'd yes. Links go to each ratee's profile page where the rating dialog lives. Recipient computation: `computeEvaluationReminderPlan` in `src/lib/evaluations/reminders.ts`; state marker: `evaluationRemindersSentAt` on Missions (one-shot, same pattern as `discordAttendanceSentAt`; withheld if the 40-DM/tick cap is hit — retries next tick). These DMs deliberately ignore `preferences.discord.enabled` (defaults false, not yet exposed in the web UI — honoring it would DM nobody). Staff can trigger manually via `/remind-evaluations` (optional `mission` option accepts an ID, name, or code name and re-sends even if the marker is set; omitted, it sweeps all pending missions and reports `{processed, sent}`).
|
|
||||||
- **Sign-up / linking (feature 1)**: `/signup` is **DM-only** (the temp password flows through the DM). Creates the Payload user with `username` = `discordUsername` = the caller's Discord username, plus `discordId`, `displayName`, `steamId`, and a random temp password. The ephemeral reply carries the password as the guaranteed delivery path; `interaction.user.send()` is a best-effort persistent copy, so a blocked DM never orphans the account. `/link` works in servers **and** DMs (credential-free, ephemeral reply only) — matches `discordUsername` → sets `discordId`.
|
- **Sign-up / linking (feature 1)**: `/signup` is **DM-only** (the temp password flows through the DM). Creates the Payload user with `username` = `discordUsername` = the caller's Discord username, plus `discordId`, `displayName`, `steamId`, and a random temp password. The ephemeral reply carries the password as the guaranteed delivery path; `interaction.user.send()` is a best-effort persistent copy, so a blocked DM never orphans the account. `/link` works in servers **and** DMs (credential-free, ephemeral reply only) — matches `discordUsername` → sets `discordId`.
|
||||||
- **DM gotcha**: a user with "Allow direct messages from server members" off in Discord privacy settings can neither receive the bot's DMs nor open a DM with the bot. The `/signup` rejection message explains how to enable it.
|
- **DM gotcha**: a user with "Allow direct messages from server members" off in Discord privacy settings can neither receive the bot's DMs nor open a DM with the bot. The `/signup` rejection message explains how to enable it.
|
||||||
- **Attendance (feature 2)**: `mission-attendances` collection + `src/lib/attendance/` (single write path, emits `mission:attendance-change`). The bot posts RSVP embeds (Yes/Tentative/No) for future, `Ready`/`Scheduled`, `visibility: "unit"` missions into the ops channel; stores `discordMessageId` + `discordAttendanceHash` on the mission; reconciles hash changes every poll tick (web ↔ Discord two-way sync, loop-safe). Web UI: `MissionAttendance` component on the mission detail page. `bun run payload generate-mission` clones the next weekly main mission.
|
- **Attendance (feature 2)**: `mission-attendances` collection + `src/lib/attendance/` (single write path, emits `mission:attendance-change`). The bot posts RSVP embeds (Yes/Tentative/No) for future, `Ready`/`Scheduled`, `visibility: "unit"` missions into the ops channel; stores `discordMessageId` + `discordAttendanceHash` on the mission; reconciles hash changes every poll tick (web ↔ Discord two-way sync, loop-safe). Web UI: `MissionAttendance` component on the mission detail page. `bun run payload generate-mission` clones the next weekly main mission.
|
||||||
|
|
|
||||||
2
TODO.md
2
TODO.md
|
|
@ -8,7 +8,7 @@
|
||||||
- [ ] **Scoped admin UI pages** - The admin UI is fantastic, but it needs to be scoped so that users can only see and interact with certain pages even if they have read permissions for any/all pages. For example, if a user has read permissions for the Users collection, they can't just see the admin page for it - they need something like "admin:users:manage" or something like that (based on whatever format fits our current system best.) So long as they have BOTH the read and manage permissions, they can see the admin page. Otherwise, it's just hidden from them entirely.
|
- [ ] **Scoped admin UI pages** - The admin UI is fantastic, but it needs to be scoped so that users can only see and interact with certain pages even if they have read permissions for any/all pages. For example, if a user has read permissions for the Users collection, they can't just see the admin page for it - they need something like "admin:users:manage" or something like that (based on whatever format fits our current system best.) So long as they have BOTH the read and manage permissions, they can see the admin page. Otherwise, it's just hidden from them entirely.
|
||||||
- [x] **Editable helpdesk tickets & user voting** - Having helpdesk tickets be editable by their authors after the fact would be very helpful. This would have to be auditable, of course. In addition, allow users to upvote specific tickets would help to prioritize desired features, pressing bugs, or other tickets wherein there is a shared desire for work from the userbase.
|
- [x] **Editable helpdesk tickets & user voting** - Having helpdesk tickets be editable by their authors after the fact would be very helpful. This would have to be auditable, of course. In addition, allow users to upvote specific tickets would help to prioritize desired features, pressing bugs, or other tickets wherein there is a shared desire for work from the userbase.
|
||||||
- [ ] **User-maintained wiki** - There really should be a wiki system (similar to MediaWiki or Fandom) where members of the unit can contribute information they have gathered about the campaign, its world and lore, characters, plot points, media, etc., with revision history, collaborative editing, template and tag support, wikilinks, and similar functionality. This is a BIG lift, but also an essential component, ESPECIALLY for our intelligence division members. While everyone should be allowed to contribute and make edits, intelligence division members should have moderator powers over this wiki, with the capability to lockdown pages, restore versions, etc.
|
- [ ] **User-maintained wiki** - There really should be a wiki system (similar to MediaWiki or Fandom) where members of the unit can contribute information they have gathered about the campaign, its world and lore, characters, plot points, media, etc., with revision history, collaborative editing, template and tag support, wikilinks, and similar functionality. This is a BIG lift, but also an essential component, ESPECIALLY for our intelligence division members. While everyone should be allowed to contribute and make edits, intelligence division members should have moderator powers over this wiki, with the capability to lockdown pages, restore versions, etc.
|
||||||
- [x] **"Personnel" navigation group** - A "Personnel" nav group in the sidebar with subpages like "Contacts (NPCs)", "Statistics", etc., would be really helpful for future updates wherein the players will have interactions with NPCs via the market, in general conversation, long-standing faction interactions, etc.
|
- [ ] **"Personnel" navigation group** - A "Personnel" nav group in the sidebar with subpages like "Contacts (NPCs)", "Statistics", etc., would be really helpful for future updates wherein the players will have interactions with NPCs via the market, in general conversation, long-standing faction interactions, etc.
|
||||||
- [ ] **Respawn tickets based on attendance** - I want to track respawn tickets as a "global variable" that is automatically incremented based on reported attendance for a mission compared against real attendance checked via the webapp<->Arma sync system. For every person who marks that they will attend and DOES attend, 1 respawn ticket is awarded. For every person who marks they MIGHT attend and DOES attend, 0.5 respawn tickets will be awarded. For every person who marks that they will NOT attend and does attend, 0.25 respawn tickets will be awarded. All respawn tickets are accumulated and applied to the next operation.
|
- [ ] **Respawn tickets based on attendance** - I want to track respawn tickets as a "global variable" that is automatically incremented based on reported attendance for a mission compared against real attendance checked via the webapp<->Arma sync system. For every person who marks that they will attend and DOES attend, 1 respawn ticket is awarded. For every person who marks they MIGHT attend and DOES attend, 0.5 respawn tickets will be awarded. For every person who marks that they will NOT attend and does attend, 0.25 respawn tickets will be awarded. All respawn tickets are accumulated and applied to the next operation.
|
||||||
|
|
||||||
## Desirable Additions, Medium Priority
|
## Desirable Additions, Medium Priority
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,6 @@ src/bot/
|
||||||
signup.ts # /signup (DM)
|
signup.ts # /signup (DM)
|
||||||
link.ts # /link (works in servers and DMs — credential-free, ephemeral reply only)
|
link.ts # /link (works in servers and DMs — credential-free, ephemeral reply only)
|
||||||
announce.ts # /announce (staff only)
|
announce.ts # /announce (staff only)
|
||||||
remindEvaluations.ts # /remind-evaluations (staff only, manual evaluation-reminder trigger)
|
|
||||||
events/
|
events/
|
||||||
interactionCreate.ts # routes buttons + commands; the RSVP button handler lives here
|
interactionCreate.ts # routes buttons + commands; the RSVP button handler lives here
|
||||||
ready.ts # startup: sync guild commands, kick off poll loops
|
ready.ts # startup: sync guild commands, kick off poll loops
|
||||||
|
|
@ -120,14 +119,6 @@ Dependency: `discord.js` ^14 (add to root `package.json`). Script: `"bot": "bun
|
||||||
- **Rate limits**: DM sends are ~5/5s per channel. Sequential sends with a small queue; cap ~5 DMs per tick. Backpressure by taking a longer poll interval if the queue stays full.
|
- **Rate limits**: DM sends are ~5/5s per channel. Sequential sends with a small queue; cap ~5 DMs per tick. Backpressure by taking a longer poll interval if the queue stays full.
|
||||||
- **`/announce`** (staff only, `isStaff(member)`): options `message` (+ optional `channel`, default `DISCORD_ANNOUNCE_CHANNEL_ID`). Posts a formatted announcement embed. App → Discord announcements are a later iteration, not v1.
|
- **`/announce`** (staff only, `isStaff(member)`): options `message` (+ optional `channel`, default `DISCORD_ANNOUNCE_CHANNEL_ID`). Posts a formatted announcement embed. App → Discord announcements are a later iteration, not v1.
|
||||||
|
|
||||||
### D. Evaluation reminders (post-mission)
|
|
||||||
|
|
||||||
- **Trigger**: a mission becomes evaluable (status `Completed`, or `Scheduled`/`Active` whose `classification.startDateTime` has fully passed — same rule as `isMissionEvaluable` in `src/lib/evaluations`). Poll loop every `DISCORD_EVALUATION_POLL_MS` (default 60s) queries evaluable missions with no `evaluationRemindersSentAt` marker (one-shot, same pattern as `discordAttendanceSentAt`).
|
|
||||||
- **Recipients** (`computeEvaluationReminderPlan` in `src/lib/evaluations/reminders.ts`): participants = users with a "yes" RSVP. Each participant is reminded to rate their direct leader(s); each leader of an assignment containing a participant is reminded to rate that participant (subordinate kind needs no RSVP). Self-pairs skipped; a user who is both gets one merged DM.
|
|
||||||
- **DM**: embed with "Rate your leadership" / "Rate your subordinates" sections, each line linking to the ratee's profile page (`APP_URL + /profile/<username>`) where the rating dialog lives. Best-effort: DM failures are logged and skipped, marker is set after the pass (cap 40 DMs/tick, 300ms between sends; overflow retries next tick before the marker is set).
|
|
||||||
- **Deliberate exception**: these DMs ignore `preferences.discord.enabled` (defaults false, not yet exposed in the web UI — honoring it would DM nobody). Dedicated unit-admin flow, not notification-stream forwarding.
|
|
||||||
- **Manual trigger**: `/remind-evaluations` (staff only, guild-only). Optional `mission` option (mission ID, name, or code name — ambiguous matches list candidates) sends reminders for that one mission even if the marker is already set (as long as it's evaluable); omitted, it runs the reconcile sweep over all pending missions immediately and reports `{processed, sent}`. Replies are ephemeral; the interaction is deferred because DM sending takes minutes at scale.
|
|
||||||
|
|
||||||
### Authz (`src/bot/lib/roles.ts`)
|
### Authz (`src/bot/lib/roles.ts`)
|
||||||
|
|
||||||
`isStaff(member)`: caller has any `DISCORD_STAFF_ROLE_IDS`, **or** their linked Payload user has roles `admin`/`developer`. RSVP/signup/link are open to all members. The bot performs Payload writes with `overrideAccess` and relies on these checks + its own validations.
|
`isStaff(member)`: caller has any `DISCORD_STAFF_ROLE_IDS`, **or** their linked Payload user has roles `admin`/`developer`. RSVP/signup/link are open to all members. The bot performs Payload writes with `overrideAccess` and relies on these checks + its own validations.
|
||||||
|
|
@ -143,7 +134,6 @@ Dependency: `discord.js` ^14 (add to root `package.json`). Script: `"bot": "bun
|
||||||
| `DISCORD_STAFF_ROLE_IDS` | no | comma-separated role IDs granting staff |
|
| `DISCORD_STAFF_ROLE_IDS` | no | comma-separated role IDs granting staff |
|
||||||
| `DISCORD_ATTENDANCE_POLL_MS` | no | default 60000 |
|
| `DISCORD_ATTENDANCE_POLL_MS` | no | default 60000 |
|
||||||
| `DISCORD_NOTIFICATION_POLL_MS` | no | default 20000 |
|
| `DISCORD_NOTIFICATION_POLL_MS` | no | default 20000 |
|
||||||
| `DISCORD_EVALUATION_POLL_MS` | no | default 60000 |
|
|
||||||
|
|
||||||
`APP_URL` already exists — used for login links in DMs.
|
`APP_URL` already exists — used for login links in DMs.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import type { BankAccount, LedgerEntry } from "@/payload-types";
|
||||||
import { AccountDetail } from "@/components/frontend/banking/AccountDetail";
|
import { AccountDetail } from "@/components/frontend/banking/AccountDetail";
|
||||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||||
import { getCurrencyConfig } from "@/lib/banking";
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Account — Polaris Task Force",
|
title: "Account — Polaris Task Force",
|
||||||
|
|
@ -36,12 +35,12 @@ export default async function AccountPage({ params }: AccountPageProps) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const typedAccount = account as unknown as BankAccount;
|
const typedAccount = account as unknown as BankAccount;
|
||||||
|
|
||||||
const isManager = user
|
const isManager = user
|
||||||
? (await hasPermission(payload, user, "banking:manage")) ||
|
? (await hasPermission(payload, user, "banking:manage")) ||
|
||||||
(await hasLogisticsQualification(payload, user).catch(() => false))
|
(await hasLogisticsQualification(payload, user).catch(() => false))
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const ownerId =
|
const ownerId =
|
||||||
typeof typedAccount.ownerUser === "object"
|
typeof typedAccount.ownerUser === "object"
|
||||||
|
|
@ -71,7 +70,6 @@ export default async function AccountPage({ params }: AccountPageProps) {
|
||||||
const otherAccounts = (allAccountsRes.docs as unknown as BankAccount[])
|
const otherAccounts = (allAccountsRes.docs as unknown as BankAccount[])
|
||||||
.filter((a) => a.id !== typedAccount.id)
|
.filter((a) => a.id !== typedAccount.id)
|
||||||
.map((a) => ({ id: a.id, name: a.name }));
|
.map((a) => ({ id: a.id, name: a.name }));
|
||||||
const currencyConfig = await getCurrencyConfig(payload);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 max-w-7xl">
|
<div className="p-5 max-w-7xl">
|
||||||
|
|
@ -80,7 +78,6 @@ export default async function AccountPage({ params }: AccountPageProps) {
|
||||||
ledger={ledger}
|
ledger={ledger}
|
||||||
otherAccounts={otherAccounts}
|
otherAccounts={otherAccounts}
|
||||||
canTransact={isManager || isOwner}
|
canTransact={isManager || isOwner}
|
||||||
currencyConfig={currencyConfig}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { LandmarkIcon } from "lucide-react";
|
||||||
import { BankingOverview } from "@/components/frontend/banking/BankingOverview";
|
import { BankingOverview } from "@/components/frontend/banking/BankingOverview";
|
||||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||||
import { getCurrencyConfig } from "@/lib/banking";
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Banking — Polaris Task Force",
|
title: "Banking — Polaris Task Force",
|
||||||
|
|
@ -37,20 +36,20 @@ export default async function BankingPage() {
|
||||||
});
|
});
|
||||||
const recentTransactions = transactionsRes.docs as unknown as BankTransaction[];
|
const recentTransactions = transactionsRes.docs as unknown as BankTransaction[];
|
||||||
|
|
||||||
const factionsRes = await payload.find({
|
const factionsRes = await payload.find({
|
||||||
collection: "factions",
|
collection: "factions",
|
||||||
limit: 100,
|
limit: 100,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
});
|
});
|
||||||
const factions = (factionsRes.docs as unknown as Faction[]).map((f) => ({
|
const factions = (factionsRes.docs as unknown as Faction[]).map((f) => ({
|
||||||
id: f.id,
|
id: f.id,
|
||||||
name: f.name,
|
name: f.name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const isManager = user
|
const isManager = user
|
||||||
? (await hasPermission(payload, user, "banking:manage")) ||
|
? (await hasPermission(payload, user, "banking:manage")) ||
|
||||||
(await hasLogisticsQualification(payload, user).catch(() => false))
|
(await hasLogisticsQualification(payload, user).catch(() => false))
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const currentUser = user
|
const currentUser = user
|
||||||
? {
|
? {
|
||||||
|
|
@ -66,7 +65,6 @@ export default async function BankingPage() {
|
||||||
accounts.length > 0 && typeof accounts[0].currency === "object"
|
accounts.length > 0 && typeof accounts[0].currency === "object"
|
||||||
? (accounts[0].currency as Resource)
|
? (accounts[0].currency as Resource)
|
||||||
: null;
|
: null;
|
||||||
const currencyConfig = await getCurrencyConfig(payload);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 flex flex-col gap-6">
|
<div className="p-5 flex flex-col gap-6">
|
||||||
|
|
@ -85,10 +83,9 @@ export default async function BankingPage() {
|
||||||
recentTransactions={recentTransactions}
|
recentTransactions={recentTransactions}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
currencyResource={currencyResource}
|
currencyResource={currencyResource}
|
||||||
currencyConfig={currencyConfig}
|
|
||||||
isManager={isManager}
|
isManager={isManager}
|
||||||
factions={factions}
|
factions={factions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
"use server";
|
|
||||||
|
|
||||||
import config from "@payload-config";
|
|
||||||
import { getPayload } from "payload";
|
|
||||||
import {
|
|
||||||
assertStaffHiringEnabled,
|
|
||||||
fireStaff as fireStaffAssignment,
|
|
||||||
hireStaff as hireStaffAssignment,
|
|
||||||
setLaborHeadcount as setLaborHeadcountAssignment,
|
|
||||||
upgradeStructure as upgradeStructureService,
|
|
||||||
} from "@/lib/base";
|
|
||||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
|
||||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
|
||||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
|
||||||
|
|
||||||
export type ActionResult<T> = { success: boolean; error?: string; data?: T };
|
|
||||||
|
|
||||||
async function authenticate() {
|
|
||||||
const payloadConfig = await config;
|
|
||||||
const payload = await getPayload({ config: payloadConfig });
|
|
||||||
const { headers } = await import("next/headers");
|
|
||||||
const requestHeaders = await headers();
|
|
||||||
const { user } = await payload.auth({ headers: requestHeaders, canSetHeaders: false });
|
|
||||||
if (!isPayloadUser(user)) throw new Error("Unauthorized");
|
|
||||||
return { payload, user };
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionError(error: unknown): ActionResult<never> {
|
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: message === "Unauthorized" ? "You must be logged in." : message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upgradeStructure(
|
|
||||||
gameStructureId: number,
|
|
||||||
): Promise<ActionResult<{ id: number }>> {
|
|
||||||
try {
|
|
||||||
const { payload, user } = await authenticate();
|
|
||||||
if (!(await hasPermission(payload, user, "structures:update"))) {
|
|
||||||
return { success: false, error: "Insufficient permissions." };
|
|
||||||
}
|
|
||||||
const structure = await upgradeStructureService(payload, gameStructureId);
|
|
||||||
return { success: true, data: { id: structure.id } };
|
|
||||||
} catch (error) {
|
|
||||||
return actionError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hireStaff(input: {
|
|
||||||
npcId: number;
|
|
||||||
structureId: number;
|
|
||||||
positionType: string;
|
|
||||||
positionTitle: string;
|
|
||||||
salary: number;
|
|
||||||
}): Promise<ActionResult<{ id: number }>> {
|
|
||||||
try {
|
|
||||||
const { payload, user } = await authenticate();
|
|
||||||
if (!(await hasLogisticsQualification(payload, user))) {
|
|
||||||
return { success: false, error: "Insufficient permissions." };
|
|
||||||
}
|
|
||||||
await assertStaffHiringEnabled(payload);
|
|
||||||
const staffing = await hireStaffAssignment(payload, input, user.id);
|
|
||||||
return { success: true, data: { id: staffing.id } };
|
|
||||||
} catch (error) {
|
|
||||||
return actionError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fireStaff(staffingId: number): Promise<ActionResult<null>> {
|
|
||||||
try {
|
|
||||||
const { payload, user } = await authenticate();
|
|
||||||
if (!(await hasLogisticsQualification(payload, user))) {
|
|
||||||
return { success: false, error: "Insufficient permissions." };
|
|
||||||
}
|
|
||||||
await fireStaffAssignment(payload, staffingId, user.id);
|
|
||||||
return { success: true, data: null };
|
|
||||||
} catch (error) {
|
|
||||||
return actionError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setLaborHeadcount(
|
|
||||||
structureId: number,
|
|
||||||
classificationId: number,
|
|
||||||
headcount: number,
|
|
||||||
unitSalary: number,
|
|
||||||
): Promise<ActionResult<{ id: number | null }>> {
|
|
||||||
try {
|
|
||||||
const { payload, user } = await authenticate();
|
|
||||||
if (!(await hasLogisticsQualification(payload, user))) {
|
|
||||||
return { success: false, error: "Insufficient permissions." };
|
|
||||||
}
|
|
||||||
await assertStaffHiringEnabled(payload);
|
|
||||||
const result = await setLaborHeadcountAssignment(payload, {
|
|
||||||
structureId,
|
|
||||||
classificationId,
|
|
||||||
headcount,
|
|
||||||
unitSalary,
|
|
||||||
});
|
|
||||||
return { success: true, data: result };
|
|
||||||
} catch (error) {
|
|
||||||
return actionError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { StoreIcon } from "lucide-react";
|
||||||
import { MarketView } from "@/components/frontend/market/MarketView";
|
import { MarketView } from "@/components/frontend/market/MarketView";
|
||||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||||
import { ensureLockerStorage } from "@/lib/locker";
|
import { ensureLockerStorage } from "@/lib/locker";
|
||||||
import { getCurrencyConfig } from "@/lib/banking";
|
import { getMainCurrencyName } from "@/lib/banking";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Market — Polaris Task Force",
|
title: "Market — Polaris Task Force",
|
||||||
|
|
@ -76,8 +76,8 @@ export default async function MarketPage() {
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isManager = user ? await hasPermission(payload, user, "logistics:manage") : false;
|
const isManager = user ? (await hasPermission(payload, user, "logistics:manage")) : false;
|
||||||
const currencyConfig = await getCurrencyConfig(payload);
|
const currencyLabel = await getMainCurrencyName(payload);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 flex flex-col gap-6">
|
<div className="p-5 flex flex-col gap-6">
|
||||||
|
|
@ -97,7 +97,7 @@ export default async function MarketPage() {
|
||||||
negotiations={negotiations}
|
negotiations={negotiations}
|
||||||
locker={lockerFull}
|
locker={lockerFull}
|
||||||
currentUserId={userId}
|
currentUserId={userId}
|
||||||
currencyConfig={currencyConfig}
|
currencyLabel={currencyLabel}
|
||||||
isManager={isManager}
|
isManager={isManager}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintex
|
||||||
import { CapacityBar } from "@/components/frontend/storage/CapacityBar";
|
import { CapacityBar } from "@/components/frontend/storage/CapacityBar";
|
||||||
import { StorageRules } from "@/components/frontend/storage/StorageRules";
|
import { StorageRules } from "@/components/frontend/storage/StorageRules";
|
||||||
import { StructureStorageView } from "@/components/frontend/storage/StructureStorageView";
|
import { StructureStorageView } from "@/components/frontend/storage/StructureStorageView";
|
||||||
import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster";
|
|
||||||
import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard";
|
|
||||||
import {
|
import {
|
||||||
calculateStoredMass,
|
calculateStoredMass,
|
||||||
calculateUtilization,
|
calculateUtilization,
|
||||||
|
|
@ -18,9 +16,6 @@ import {
|
||||||
getGridSlots,
|
getGridSlots,
|
||||||
} from "@/lib/logistics";
|
} from "@/lib/logistics";
|
||||||
import { pluralize } from "@/lib/utils";
|
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 {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
BoxIcon,
|
BoxIcon,
|
||||||
|
|
@ -83,162 +78,6 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
||||||
: null;
|
: null;
|
||||||
const imageUrl = image?.url ?? null;
|
const imageUrl = image?.url ?? null;
|
||||||
|
|
||||||
const upgradeTarget = structureType.upgradesInto;
|
|
||||||
const upgradeTargetId =
|
|
||||||
typeof upgradeTarget === "object" && upgradeTarget !== null ? upgradeTarget.id : upgradeTarget;
|
|
||||||
let upgrade: {
|
|
||||||
targetName: string;
|
|
||||||
materials: { name: string; needed: number; have: number }[];
|
|
||||||
} | null = null;
|
|
||||||
|
|
||||||
if (typeof upgradeTargetId === "number") {
|
|
||||||
const targetRes = await payload
|
|
||||||
.findByID({ collection: "structures", id: upgradeTargetId, depth: 1 })
|
|
||||||
.catch(() => null);
|
|
||||||
|
|
||||||
if (targetRes) {
|
|
||||||
const target = targetRes as unknown as Structure;
|
|
||||||
const resourceIds = target.materials
|
|
||||||
.map((material) =>
|
|
||||||
typeof material.resource === "object" && material.resource !== null
|
|
||||||
? material.resource.id
|
|
||||||
: material.resource,
|
|
||||||
)
|
|
||||||
.filter((resourceId): resourceId is number => typeof resourceId === "number");
|
|
||||||
const resourceRes =
|
|
||||||
resourceIds.length > 0
|
|
||||||
? await payload.find({
|
|
||||||
collection: "resources",
|
|
||||||
where: { id: { in: resourceIds } },
|
|
||||||
limit: resourceIds.length,
|
|
||||||
depth: 0,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const resourceNames = new Map(
|
|
||||||
(resourceRes?.docs ?? []).map((resource) => [resource.id, resource.name]),
|
|
||||||
);
|
|
||||||
const inventory = [...(structure.storedResources ?? []), ...(structure.voidStorage ?? [])];
|
|
||||||
|
|
||||||
upgrade = {
|
|
||||||
targetName: target.name,
|
|
||||||
materials: target.materials.map((material) => {
|
|
||||||
const resourceId =
|
|
||||||
typeof material.resource === "object" && material.resource !== null
|
|
||||||
? material.resource.id
|
|
||||||
: material.resource;
|
|
||||||
const resourceName =
|
|
||||||
typeof material.resource === "object" && material.resource !== null
|
|
||||||
? material.resource.name
|
|
||||||
: (resourceNames.get(resourceId) ?? `Resource #${resourceId}`);
|
|
||||||
const have = inventory.reduce((total, entry) => {
|
|
||||||
const entryResourceId =
|
|
||||||
typeof entry.resource === "object" && entry.resource !== null
|
|
||||||
? entry.resource.id
|
|
||||||
: entry.resource;
|
|
||||||
return entryResourceId === resourceId ? total + entry.amount : total;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: resourceName,
|
|
||||||
needed: Math.ceil((material.amount ?? 0) * (structureType.upgradeCostMultiplier ?? 1)),
|
|
||||||
have,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const staffRes = await payload.find({
|
|
||||||
collection: "npc-staffing",
|
|
||||||
where: {
|
|
||||||
and: [{ structure: { equals: structure.id } }, { status: { equals: "active" } }],
|
|
||||||
},
|
|
||||||
limit: 50,
|
|
||||||
depth: 1,
|
|
||||||
});
|
|
||||||
const staff = staffRes.docs.map((entry) => {
|
|
||||||
const npc = entry.npc;
|
|
||||||
const npcName =
|
|
||||||
typeof npc === "object" && npc !== null
|
|
||||||
? npc.name
|
|
||||||
: typeof npc === "number"
|
|
||||||
? `NPC #${npc}`
|
|
||||||
: null;
|
|
||||||
const laborClassificationId =
|
|
||||||
typeof entry.laborClassification === "object" && entry.laborClassification !== null
|
|
||||||
? entry.laborClassification.id
|
|
||||||
: typeof entry.laborClassification === "number"
|
|
||||||
? entry.laborClassification
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: entry.id,
|
|
||||||
npcName,
|
|
||||||
positionType: entry.positionType,
|
|
||||||
positionTitle: entry.positionTitle,
|
|
||||||
salary: entry.salary ?? 0,
|
|
||||||
headcount: entry.headcount ?? 1,
|
|
||||||
laborClassificationId,
|
|
||||||
hiredAt: new Date(entry.hiredAt ?? entry.createdAt).toISOString(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const canManage = await hasLogisticsQualification(payload, user);
|
|
||||||
|
|
||||||
const classificationsRes = await payload.find({
|
|
||||||
collection: "labor-classifications",
|
|
||||||
limit: 100,
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
name: doc.name,
|
|
||||||
key: doc.key,
|
|
||||||
defaultUnitSalary: doc.defaultUnitSalary ?? null,
|
|
||||||
availableHeadcount: doc.availableHeadcount ?? 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const rules = await payload.findGlobal({
|
|
||||||
slug: "game-rules",
|
|
||||||
depth: 1,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
const currencyConfig = currencyConfigFromGameRules(rules);
|
|
||||||
const structureStaffing: StructureStaffingOption = {
|
|
||||||
id: structure.id,
|
|
||||||
name: structure.name,
|
|
||||||
maintenanceCost: structure.maintenanceCost ?? 0,
|
|
||||||
laborRequirements: (structure.laborRequirements ?? []).map((requirement) => {
|
|
||||||
const classificationId =
|
|
||||||
typeof requirement.classification === "object"
|
|
||||||
? requirement.classification.id
|
|
||||||
: requirement.classification;
|
|
||||||
return {
|
|
||||||
classificationId,
|
|
||||||
requiredHeadcount: requirement.requiredHeadcount,
|
|
||||||
maxHeadcount: requirement.maxHeadcount ?? 0,
|
|
||||||
enforcedSalary: requirement.enforcedSalary ?? null,
|
|
||||||
currentHeadcount: staff
|
|
||||||
.filter((member) => member.laborClassificationId === classificationId)
|
|
||||||
.reduce((total, member) => total + member.headcount, 0),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
staffPositions: (structure.staffPositions ?? []).map((position) => ({
|
|
||||||
positionType: position.positionType,
|
|
||||||
max: position.max ?? null,
|
|
||||||
enforcedSalary: position.enforcedSalary ?? null,
|
|
||||||
currentHeadcount: staff.filter(
|
|
||||||
(member) => member.npcName !== null && member.positionType === position.positionType,
|
|
||||||
).length,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const npcsRes = await payload.find({
|
|
||||||
collection: "game-npcs",
|
|
||||||
limit: 200,
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
const npcs = npcsRes.docs.map((npc) => ({ id: npc.id, name: npc.name }));
|
|
||||||
|
|
||||||
const totalValue = storedResources.reduce((sum, entry) => {
|
const totalValue = storedResources.reduce((sum, entry) => {
|
||||||
const resource = entry.resource as Resource;
|
const resource = entry.resource as Resource;
|
||||||
if (!resource || typeof resource === "number") return sum;
|
if (!resource || typeof resource === "number") return sum;
|
||||||
|
|
@ -400,19 +239,6 @@ export default async function StructureDetailPage({ params }: StructureDetailPag
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UpgradeCard gameStructureId={structure.id} upgrade={upgrade} />
|
|
||||||
|
|
||||||
<StaffRoster
|
|
||||||
structure={structureStaffing}
|
|
||||||
staff={staff}
|
|
||||||
classifications={classifications}
|
|
||||||
npcs={npcs}
|
|
||||||
canManage={canManage}
|
|
||||||
hiringEnabled={Boolean(rules.staffHiringEnabled)}
|
|
||||||
hiringDisabledMessage={rules.staffHiringDisabledMessage ?? undefined}
|
|
||||||
currencyConfig={currencyConfig}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StructureStorageView structure={structure} />
|
<StructureStorageView structure={structure} />
|
||||||
|
|
||||||
<StorageRules
|
<StorageRules
|
||||||
|
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
import config from "@payload-config";
|
|
||||||
import { getPayload } from "payload";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { headers as nextHeaders } from "next/headers";
|
|
||||||
import type { GameNpc, MarketListing, NpcStaffing } from "@/payload-types";
|
|
||||||
import { NpcProfile } from "@/components/frontend/personnel/NpcProfile";
|
|
||||||
import { EmploymentCard } from "@/components/frontend/baseManagement/EmploymentCard";
|
|
||||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
|
||||||
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
|
||||||
import { currencyConfigFromGameRules } from "@/lib/banking";
|
|
||||||
|
|
||||||
interface NpcContactPageProps {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function NpcContactPage({ params }: NpcContactPageProps) {
|
|
||||||
const { id: idParam } = await params;
|
|
||||||
const id = Number(idParam);
|
|
||||||
|
|
||||||
if (!Number.isInteger(id) || id <= 0) notFound();
|
|
||||||
|
|
||||||
const payload = await getPayload({ config });
|
|
||||||
const headers = await nextHeaders();
|
|
||||||
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
|
||||||
|
|
||||||
let npc: GameNpc;
|
|
||||||
try {
|
|
||||||
npc = (await payload.findByID({
|
|
||||||
collection: "game-npcs",
|
|
||||||
id,
|
|
||||||
depth: 2,
|
|
||||||
})) as unknown as GameNpc;
|
|
||||||
} catch {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!npc) notFound();
|
|
||||||
|
|
||||||
const staffingRes = await payload.find({
|
|
||||||
collection: "npc-staffing",
|
|
||||||
where: {
|
|
||||||
and: [{ npc: { equals: id } }, { status: { equals: "active" } }],
|
|
||||||
},
|
|
||||||
limit: 1,
|
|
||||||
depth: 2,
|
|
||||||
});
|
|
||||||
const staffing = staffingRes.docs[0] as NpcStaffing | undefined;
|
|
||||||
const staffingStructure = staffing?.structure;
|
|
||||||
const structureName =
|
|
||||||
typeof staffingStructure === "object" && staffingStructure !== null
|
|
||||||
? staffingStructure.name
|
|
||||||
: typeof staffingStructure === "number"
|
|
||||||
? `Structure #${staffingStructure}`
|
|
||||||
: null;
|
|
||||||
const employment =
|
|
||||||
staffing && structureName
|
|
||||||
? {
|
|
||||||
positionTitle: staffing.positionTitle,
|
|
||||||
positionType: staffing.positionType,
|
|
||||||
structureName,
|
|
||||||
salary: staffing.salary ?? 0,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const structuresRes = await payload.find({
|
|
||||||
collection: "game-structures",
|
|
||||||
limit: 200,
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
const activeStaffRes = await payload.find({
|
|
||||||
collection: "npc-staffing",
|
|
||||||
where: { status: { equals: "active" } },
|
|
||||||
limit: 1000,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
const structures: StructureStaffingOption[] = structuresRes.docs.map((structure) => ({
|
|
||||||
id: structure.id,
|
|
||||||
name: structure.name,
|
|
||||||
maintenanceCost: structure.maintenanceCost ?? 0,
|
|
||||||
laborRequirements: (structure.laborRequirements ?? []).map((requirement) => {
|
|
||||||
const classificationId =
|
|
||||||
typeof requirement.classification === "object"
|
|
||||||
? requirement.classification.id
|
|
||||||
: requirement.classification;
|
|
||||||
return {
|
|
||||||
classificationId,
|
|
||||||
requiredHeadcount: requirement.requiredHeadcount,
|
|
||||||
maxHeadcount: requirement.maxHeadcount ?? 0,
|
|
||||||
enforcedSalary: requirement.enforcedSalary ?? null,
|
|
||||||
currentHeadcount: activeStaffRes.docs
|
|
||||||
.filter((entry) => {
|
|
||||||
const structureId =
|
|
||||||
typeof entry.structure === "object" ? entry.structure.id : entry.structure;
|
|
||||||
const laborClassificationId =
|
|
||||||
typeof entry.laborClassification === "object" && entry.laborClassification !== null
|
|
||||||
? entry.laborClassification.id
|
|
||||||
: entry.laborClassification;
|
|
||||||
return structureId === structure.id && laborClassificationId === classificationId;
|
|
||||||
})
|
|
||||||
.reduce((total, entry) => total + (entry.headcount ?? 1), 0),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
staffPositions: (structure.staffPositions ?? []).map((position) => ({
|
|
||||||
positionType: position.positionType,
|
|
||||||
max: position.max ?? null,
|
|
||||||
enforcedSalary: position.enforcedSalary ?? null,
|
|
||||||
currentHeadcount: activeStaffRes.docs.filter((entry) => {
|
|
||||||
const structureId =
|
|
||||||
typeof entry.structure === "object" ? entry.structure.id : entry.structure;
|
|
||||||
return (
|
|
||||||
structureId === structure.id &&
|
|
||||||
entry.npc !== null &&
|
|
||||||
entry.positionType === position.positionType
|
|
||||||
);
|
|
||||||
}).length,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const classificationsRes = await payload.find({
|
|
||||||
collection: "labor-classifications",
|
|
||||||
limit: 100,
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
const classifications: LaborClassificationOption[] = classificationsRes.docs.map((doc) => ({
|
|
||||||
id: doc.id,
|
|
||||||
name: doc.name,
|
|
||||||
key: doc.key,
|
|
||||||
defaultUnitSalary: doc.defaultUnitSalary ?? null,
|
|
||||||
availableHeadcount: doc.availableHeadcount ?? 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const rules = await payload.findGlobal({
|
|
||||||
slug: "game-rules",
|
|
||||||
depth: 1,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
const currencyConfig = currencyConfigFromGameRules(rules);
|
|
||||||
|
|
||||||
const canManage = await hasLogisticsQualification(payload, user);
|
|
||||||
|
|
||||||
const listingsRes = await payload.find({
|
|
||||||
collection: "market-listings",
|
|
||||||
where: {
|
|
||||||
and: [{ npc: { equals: id } }, { status: { equals: "active" } }],
|
|
||||||
},
|
|
||||||
limit: 100,
|
|
||||||
depth: 1,
|
|
||||||
});
|
|
||||||
const listings = listingsRes.docs as unknown as MarketListing[];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<NpcProfile npc={npc} listings={listings} />
|
|
||||||
</div>
|
|
||||||
<div className="px-5 pb-5 lg:col-start-2">
|
|
||||||
<EmploymentCard
|
|
||||||
employment={employment}
|
|
||||||
structures={structures}
|
|
||||||
canManage={canManage}
|
|
||||||
npcId={npc.id}
|
|
||||||
npcName={npc.name}
|
|
||||||
classifications={classifications}
|
|
||||||
hiringEnabled={Boolean(rules.staffHiringEnabled)}
|
|
||||||
hiringDisabledMessage={rules.staffHiringDisabledMessage ?? undefined}
|
|
||||||
currencyConfig={currencyConfig}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import config from "@payload-config";
|
|
||||||
import { getPayload } from "payload";
|
|
||||||
import type { GameNpc } from "@/payload-types";
|
|
||||||
import { UsersIcon } from "lucide-react";
|
|
||||||
import { NpcDirectory } from "@/components/frontend/personnel/NpcDirectory";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "Contacts — Polaris Task Force",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function ContactsPage() {
|
|
||||||
const payload = await getPayload({ config });
|
|
||||||
const npcsRes = await payload.find({
|
|
||||||
collection: "game-npcs",
|
|
||||||
limit: 200,
|
|
||||||
depth: 1,
|
|
||||||
});
|
|
||||||
const npcs = npcsRes.docs as unknown as GameNpc[];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6 p-5">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<UsersIcon className="size-5 text-muted-foreground" />
|
|
||||||
<h1 className="text-lg font-semibold">Contacts</h1>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{npcs.length} contact{npcs.length !== 1 ? "s" : ""} recorded in the theater of
|
|
||||||
operations.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NpcDirectory npcs={npcs} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
import config from "@payload-config";
|
|
||||||
import { getPayload } from "payload";
|
|
||||||
import type { GameNpc, MarketListing } from "@/payload-types";
|
|
||||||
import { TrendingUp } from "lucide-react";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "Statistics — Polaris Task Force",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface FactionStatistic {
|
|
||||||
name: string;
|
|
||||||
count: number;
|
|
||||||
percentage: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VendorStatistic {
|
|
||||||
name: string;
|
|
||||||
occupation: string | null;
|
|
||||||
listingCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function factionName(npc: GameNpc): string {
|
|
||||||
return typeof npc.faction === "object" && npc.faction !== null
|
|
||||||
? npc.faction.name
|
|
||||||
: "Unaffiliated";
|
|
||||||
}
|
|
||||||
|
|
||||||
function npcIdFromListing(listing: MarketListing): number | null {
|
|
||||||
if (typeof listing.npc === "number") return listing.npc;
|
|
||||||
if (typeof listing.npc === "object" && listing.npc !== null) return listing.npc.id;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function StatisticsPage() {
|
|
||||||
const payload = await getPayload({ config });
|
|
||||||
const [npcsRes, listingsRes] = await Promise.all([
|
|
||||||
payload.find({
|
|
||||||
collection: "game-npcs",
|
|
||||||
limit: 500,
|
|
||||||
depth: 1,
|
|
||||||
}),
|
|
||||||
payload.find({
|
|
||||||
collection: "market-listings",
|
|
||||||
where: {
|
|
||||||
and: [
|
|
||||||
{ isAutoGenerated: { equals: true } },
|
|
||||||
{ status: { equals: "active" } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
limit: 200,
|
|
||||||
depth: 0,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const npcs = npcsRes.docs as unknown as GameNpc[];
|
|
||||||
const activeVendorListings = listingsRes.docs as unknown as MarketListing[];
|
|
||||||
const totalNpcs = npcs.length;
|
|
||||||
const inGameCount = npcs.filter((npc) => npc.isInGame === true).length;
|
|
||||||
const generatedCount = npcs.filter((npc) => npc.isGenerated === true).length;
|
|
||||||
const vendorStallCount = npcs.filter((npc) => npc.vendor?.enabled === true).length;
|
|
||||||
|
|
||||||
const factionCounts = new Map<string, number>();
|
|
||||||
for (const npc of npcs) {
|
|
||||||
const name = factionName(npc);
|
|
||||||
factionCounts.set(name, (factionCounts.get(name) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const factionDistribution: FactionStatistic[] = [...factionCounts.entries()]
|
|
||||||
.map(([name, count]) => ({
|
|
||||||
name,
|
|
||||||
count,
|
|
||||||
percentage: totalNpcs > 0 ? Math.round((count / totalNpcs) * 100) : 0,
|
|
||||||
}))
|
|
||||||
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
|
|
||||||
|
|
||||||
const npcById = new Map(npcs.map((npc) => [npc.id, npc]));
|
|
||||||
const listingCounts = new Map<number, number>();
|
|
||||||
for (const listing of activeVendorListings) {
|
|
||||||
const npcId = npcIdFromListing(listing);
|
|
||||||
if (npcId === null || !npcById.has(npcId)) continue;
|
|
||||||
listingCounts.set(npcId, (listingCounts.get(npcId) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const topVendors: VendorStatistic[] = [...listingCounts.entries()]
|
|
||||||
.map(([npcId, listingCount]) => {
|
|
||||||
const npc = npcById.get(npcId);
|
|
||||||
return npc
|
|
||||||
? {
|
|
||||||
name: npc.name,
|
|
||||||
occupation: npc.occupation ?? null,
|
|
||||||
listingCount,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
})
|
|
||||||
.filter((vendor): vendor is VendorStatistic => vendor !== null)
|
|
||||||
.sort(
|
|
||||||
(left, right) =>
|
|
||||||
right.listingCount - left.listingCount || left.name.localeCompare(right.name),
|
|
||||||
)
|
|
||||||
.slice(0, 5);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6 p-5">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<TrendingUp className="size-5 text-muted-foreground" />
|
|
||||||
<h1 className="text-lg font-semibold">Statistics</h1>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
A theater-wide view of NPC presence, affiliations, and vendor activity.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-1">
|
|
||||||
<CardDescription>Total contacts</CardDescription>
|
|
||||||
<CardTitle className="text-2xl tabular-nums">{totalNpcs}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-1">
|
|
||||||
<CardDescription>In-game contacts</CardDescription>
|
|
||||||
<CardTitle className="text-2xl tabular-nums">{inGameCount}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-1">
|
|
||||||
<CardDescription>Generated placeholders</CardDescription>
|
|
||||||
<CardTitle className="text-2xl tabular-nums">{generatedCount}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-1">
|
|
||||||
<CardDescription>Vendor stalls</CardDescription>
|
|
||||||
<CardTitle className="text-2xl tabular-nums">{vendorStallCount}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-1">
|
|
||||||
<CardDescription>Active vendor stock</CardDescription>
|
|
||||||
<CardTitle className="text-2xl tabular-nums">{activeVendorListings.length}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<CardTitle>Faction distribution</CardTitle>
|
|
||||||
<CardDescription>NPC assignments across the current theater.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{factionDistribution.length === 0 ? (
|
|
||||||
<p className="text-sm italic text-muted-foreground">
|
|
||||||
No faction assignments recorded.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{factionDistribution.map((faction) => (
|
|
||||||
<div key={faction.name} className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between gap-3 text-sm">
|
|
||||||
<span className="truncate">{faction.name}</span>
|
|
||||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
|
||||||
{faction.count} ({faction.percentage}%)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-primary"
|
|
||||||
style={{ width: `${faction.percentage}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<CardTitle>Top vendors</CardTitle>
|
|
||||||
<CardDescription>Contacts with the most active auto-generated stock.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{topVendors.length === 0 ? (
|
|
||||||
<p className="text-sm italic text-muted-foreground">No active vendor stock.</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{topVendors.map((vendor) => (
|
|
||||||
<div
|
|
||||||
key={vendor.name}
|
|
||||||
className="flex items-center justify-between gap-4"
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-semibold">{vendor.name}</p>
|
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
|
||||||
{vendor.occupation ?? "Vendor"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 text-right">
|
|
||||||
<p className="text-sm font-semibold tabular-nums">{vendor.listingCount}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">listing(s)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8c
|
||||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||||
import { LexicalEditorFeatureClient as LexicalEditorFeatureClient_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
|
import { LexicalEditorFeatureClient as LexicalEditorFeatureClient_4490b89d4413c1ffaecdacfe72efaf73 } from '@ai-stack/payloadcms/client'
|
||||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||||
import { default as default_513af5ed43d616d133e530d8c94232f8 } from '@/components/admin/groups/CollapsibleGroupField'
|
|
||||||
import { default as default_d9c22b951ac6762f436db59b889da382 } from '@/components/admin/awards/CommendationColorField'
|
import { default as default_d9c22b951ac6762f436db59b889da382 } from '@/components/admin/awards/CommendationColorField'
|
||||||
import { default as default_c3ea68702b5e5f8f2f98055e01c43c8d } from '@/components/admin/awards/RibbonDesigner'
|
import { default as default_c3ea68702b5e5f8f2f98055e01c43c8d } from '@/components/admin/awards/RibbonDesigner'
|
||||||
import { default as default_2f2033acccb3be245f1a85f7b4271dac } from '@/components/admin/awards/MedalDesigner'
|
import { default as default_2f2033acccb3be245f1a85f7b4271dac } from '@/components/admin/awards/MedalDesigner'
|
||||||
|
|
@ -31,7 +30,6 @@ export const importMap = {
|
||||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||||
"@ai-stack/payloadcms/client#LexicalEditorFeatureClient": LexicalEditorFeatureClient_4490b89d4413c1ffaecdacfe72efaf73,
|
"@ai-stack/payloadcms/client#LexicalEditorFeatureClient": LexicalEditorFeatureClient_4490b89d4413c1ffaecdacfe72efaf73,
|
||||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||||
"@/components/admin/groups/CollapsibleGroupField#default": default_513af5ed43d616d133e530d8c94232f8,
|
|
||||||
"@/components/admin/awards/CommendationColorField#default": default_d9c22b951ac6762f436db59b889da382,
|
"@/components/admin/awards/CommendationColorField#default": default_d9c22b951ac6762f436db59b889da382,
|
||||||
"@/components/admin/awards/RibbonDesigner#default": default_c3ea68702b5e5f8f2f98055e01c43c8d,
|
"@/components/admin/awards/RibbonDesigner#default": default_c3ea68702b5e5f8f2f98055e01c43c8d,
|
||||||
"@/components/admin/awards/MedalDesigner#default": default_2f2033acccb3be245f1a85f7b4271dac,
|
"@/components/admin/awards/MedalDesigner#default": default_2f2033acccb3be245f1a85f7b4271dac,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ bot/
|
||||||
signup.ts # /signup (DM-only, creates Payload user with temp password)
|
signup.ts # /signup (DM-only, creates Payload user with temp password)
|
||||||
link.ts # /link (global, links discordId to existing user)
|
link.ts # /link (global, links discordId to existing user)
|
||||||
announce.ts # /announce (guild-only, staff only)
|
announce.ts # /announce (guild-only, staff only)
|
||||||
remindEvaluations.ts # /remind-evaluations (guild-only, staff only, manual evaluation-reminder trigger)
|
|
||||||
|
|
||||||
events/
|
events/
|
||||||
interactionCreate.ts # Routes ptf-att: RSVP button interactions
|
interactionCreate.ts # Routes ptf-att: RSVP button interactions
|
||||||
|
|
@ -38,7 +37,7 @@ bot/
|
||||||
## Command registration scope
|
## Command registration scope
|
||||||
|
|
||||||
- **Global** (DM-usable): `signup`, `link`, `ping`
|
- **Global** (DM-usable): `signup`, `link`, `ping`
|
||||||
- **Guild-only**: `announce`, `remind-evaluations` (guild-scoped commands never appear in DMs)
|
- **Guild-only**: `announce` (guild-scoped commands never appear in DMs)
|
||||||
|
|
||||||
## Feature flow: signup/link
|
## Feature flow: signup/link
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { pingCommand } from "./ping";
|
||||||
import { signupCommand } from "./signup";
|
import { signupCommand } from "./signup";
|
||||||
import { linkCommand } from "./link";
|
import { linkCommand } from "./link";
|
||||||
import { announceCommand } from "./announce";
|
import { announceCommand } from "./announce";
|
||||||
import { remindEvaluationsCommand } from "./remindEvaluations";
|
|
||||||
|
|
||||||
export interface BotCommand {
|
export interface BotCommand {
|
||||||
builder: SlashCommandBuilder;
|
builder: SlashCommandBuilder;
|
||||||
|
|
@ -17,10 +16,4 @@ export interface BotCommand {
|
||||||
scope?: "global" | "guild";
|
scope?: "global" | "guild";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const commands: BotCommand[] = [
|
export const commands: BotCommand[] = [pingCommand, signupCommand, linkCommand, announceCommand];
|
||||||
pingCommand,
|
|
||||||
signupCommand,
|
|
||||||
linkCommand,
|
|
||||||
announceCommand,
|
|
||||||
remindEvaluationsCommand,
|
|
||||||
];
|
|
||||||
|
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
import { MessageFlags, SlashCommandBuilder, type Client } from "discord.js";
|
|
||||||
import type { Payload } from "payload";
|
|
||||||
import type { BotCommand } from "./index";
|
|
||||||
import { isStaff } from "@/bot/lib/roles";
|
|
||||||
import { processMission, reconcile } from "@/bot/services/evaluationReminders";
|
|
||||||
import { isMissionEvaluable } from "@/lib/evaluations";
|
|
||||||
import type { Mission } from "@/payload-types";
|
|
||||||
|
|
||||||
const missionLabel = (mission: Mission): string =>
|
|
||||||
mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name;
|
|
||||||
|
|
||||||
const resolveTargetMissions = async (
|
|
||||||
payload: Payload,
|
|
||||||
input: string,
|
|
||||||
): Promise<{ mission?: Mission; error?: string }> => {
|
|
||||||
const trimmed = input.trim();
|
|
||||||
try {
|
|
||||||
const mission = await payload.findByID({
|
|
||||||
collection: "missions",
|
|
||||||
id: trimmed,
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
});
|
|
||||||
return { mission: mission as unknown as Mission };
|
|
||||||
} catch {
|
|
||||||
// Not an ID — fall through to a name / code-name search.
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await payload.find({
|
|
||||||
collection: "missions",
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
limit: 6,
|
|
||||||
where: {
|
|
||||||
or: [{ name: { like: trimmed } }, { codeName: { like: trimmed } }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const docs = res.docs as unknown as Mission[];
|
|
||||||
if (docs.length === 0) {
|
|
||||||
return { error: `No mission matches "${trimmed}" (searched by ID, name, and code name).` };
|
|
||||||
}
|
|
||||||
if (docs.length > 1) {
|
|
||||||
const list = docs.map((m) => `• ${missionLabel(m)} — #${m.id}`).join("\n");
|
|
||||||
return { error: `Multiple missions match "${trimmed}". Be more specific:\n${list}` };
|
|
||||||
}
|
|
||||||
return { mission: docs[0] };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const remindEvaluationsCommand: BotCommand = {
|
|
||||||
builder: new SlashCommandBuilder()
|
|
||||||
.setName("remind-evaluations")
|
|
||||||
.setDescription("Send leadership evaluation reminder DMs (staff only)")
|
|
||||||
.addStringOption((option) =>
|
|
||||||
option
|
|
||||||
.setName("mission")
|
|
||||||
.setDescription("Mission ID, name, or code name. Omit to sweep all pending missions."),
|
|
||||||
) as SlashCommandBuilder,
|
|
||||||
execute: async (interaction, payload) => {
|
|
||||||
if (!interaction.inGuild() || !interaction.guild) {
|
|
||||||
await interaction.reply({
|
|
||||||
content: "This command only works in a server.",
|
|
||||||
flags: MessageFlags.Ephemeral,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const member = await interaction.guild.members.fetch(interaction.user.id);
|
|
||||||
if (!(await isStaff(member, payload))) {
|
|
||||||
await interaction.reply({
|
|
||||||
content: "You don't have permission to trigger evaluation reminders.",
|
|
||||||
flags: MessageFlags.Ephemeral,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
||||||
|
|
||||||
const client = interaction.client as Client;
|
|
||||||
const missionInput = interaction.options.getString("mission")?.trim();
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (missionInput) {
|
|
||||||
const { mission, error } = await resolveTargetMissions(payload, missionInput);
|
|
||||||
if (error || !mission) {
|
|
||||||
await interaction.editReply(error ?? `No mission matches "${missionInput}".`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!isMissionEvaluable(
|
|
||||||
mission.ownershipAndStatus?.status ?? "",
|
|
||||||
mission.classification?.startDateTime ?? null,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
await interaction.editReply(
|
|
||||||
`**${missionLabel(mission)}** isn't evaluable yet — missions must be completed or past their scheduled start before reminders go out.`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sent = await processMission(client, payload, mission);
|
|
||||||
await interaction.editReply(
|
|
||||||
`Done — ${sent} evaluation reminder DM(s) sent for **${missionLabel(mission)}**.`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { processed, sent } = await reconcile(client, payload);
|
|
||||||
await interaction.editReply(
|
|
||||||
`Sweep complete — ${processed} pending mission(s) processed, ${sent} reminder DM(s) sent.`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
await interaction.editReply(
|
|
||||||
`Reminder trigger failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -7,7 +7,6 @@ export interface BotConfig {
|
||||||
staffRoleIds: string[];
|
staffRoleIds: string[];
|
||||||
attendancePollMs: number;
|
attendancePollMs: number;
|
||||||
notificationPollMs: number;
|
notificationPollMs: number;
|
||||||
evaluationPollMs: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const required = (name: string): string => {
|
const required = (name: string): string => {
|
||||||
|
|
@ -47,5 +46,4 @@ export const botConfig: BotConfig = {
|
||||||
.filter((id) => id.length > 0),
|
.filter((id) => id.length > 0),
|
||||||
attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000),
|
attendancePollMs: pollMs("DISCORD_ATTENDANCE_POLL_MS", 60_000),
|
||||||
notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000),
|
notificationPollMs: pollMs("DISCORD_NOTIFICATION_POLL_MS", 20_000),
|
||||||
evaluationPollMs: pollMs("DISCORD_EVALUATION_POLL_MS", 60_000),
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,215 +0,0 @@
|
||||||
import { type Client, EmbedBuilder } from "discord.js";
|
|
||||||
import type { Payload } from "payload";
|
|
||||||
import type { Mission } from "@/payload-types";
|
|
||||||
import { botConfig } from "@/bot/config";
|
|
||||||
import { computeEvaluationReminderPlan, type ReminderLink } from "@/lib/evaluations/reminders";
|
|
||||||
import { isMissionEvaluable } from "@/lib/evaluations";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluation reminder service: after a mission becomes evaluable (completed, or
|
|
||||||
* its scheduled start has passed), every participant with a linked Discord
|
|
||||||
* account gets a one-shot DM asking them to rate their leadership — and every
|
|
||||||
* leader additionally gets a section asking them to rate the subordinates who
|
|
||||||
* participated. Each DM links to the relevant profile pages, where the rating
|
|
||||||
* dialog lives.
|
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* unit-admin flow, not a forwarding of the generic notification stream.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const EMBED_COLOR = 0xf59e0b;
|
|
||||||
const MISSIONS_PER_TICK = 3;
|
|
||||||
const MAX_DMS_PER_TICK = 40;
|
|
||||||
const SEND_DELAY_MS = 300;
|
|
||||||
|
|
||||||
let reconcileRunning = false;
|
|
||||||
|
|
||||||
const errorMessage = (error: unknown): string =>
|
|
||||||
error instanceof Error ? error.message : "Unknown error";
|
|
||||||
|
|
||||||
const missionTitle = (mission: Mission): string =>
|
|
||||||
mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name;
|
|
||||||
|
|
||||||
const getReminderMissions = async (payload: Payload): Promise<Mission[]> => {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const res = await payload.find({
|
|
||||||
collection: "missions",
|
|
||||||
overrideAccess: true,
|
|
||||||
depth: 0,
|
|
||||||
sort: "-classification.startDateTime",
|
|
||||||
limit: MISSIONS_PER_TICK,
|
|
||||||
where: {
|
|
||||||
and: [
|
|
||||||
{ evaluationRemindersSentAt: { exists: false } },
|
|
||||||
{
|
|
||||||
or: [
|
|
||||||
{ "ownershipAndStatus.status": { equals: "Completed" } },
|
|
||||||
{
|
|
||||||
and: [
|
|
||||||
{ "ownershipAndStatus.status": { in: ["Scheduled", "Active"] } },
|
|
||||||
{ "classification.startDateTime": { less_than: now } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return res.docs as unknown as 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) || "—");
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderReminderEmbed = (
|
|
||||||
mission: Mission,
|
|
||||||
rateLeaders: ReminderLink[],
|
|
||||||
rateSubordinates: ReminderLink[],
|
|
||||||
appUrl: string,
|
|
||||||
): EmbedBuilder => {
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle(`Leadership feedback requested — ${missionTitle(mission)}`)
|
|
||||||
.setColor(EMBED_COLOR)
|
|
||||||
.setFooter({
|
|
||||||
text: "Ratings are private and anonymous in aggregate. A leader's results become visible once 3+ members have rated them.",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (rateLeaders.length > 0) {
|
|
||||||
embed.addFields({
|
|
||||||
name: "⭐ Rate your leadership",
|
|
||||||
value: formatLinks(rateLeaders, appUrl),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (rateSubordinates.length > 0) {
|
|
||||||
embed.addFields({
|
|
||||||
name: "🪖 Rate your subordinates' performance",
|
|
||||||
value: formatLinks(rateSubordinates, appUrl),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return embed;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendReminderDms = async (
|
|
||||||
client: Client,
|
|
||||||
payload: Payload,
|
|
||||||
mission: Mission,
|
|
||||||
): Promise<{ sent: number; capped: boolean }> => {
|
|
||||||
const plan = await computeEvaluationReminderPlan(payload, mission.id);
|
|
||||||
const targets = plan.recipients.filter((r) => r.discordId);
|
|
||||||
const skipped = plan.recipients.length - targets.length;
|
|
||||||
if (skipped > 0) {
|
|
||||||
payload.logger.warn(
|
|
||||||
`[Bot] Evaluation reminders for mission #${mission.id}: skipping ${skipped} user(s) without a linked Discord account.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let sent = 0;
|
|
||||||
for (const target of targets) {
|
|
||||||
if (sent >= MAX_DMS_PER_TICK) {
|
|
||||||
payload.logger.warn(
|
|
||||||
`[Bot] Evaluation reminder DM cap (${MAX_DMS_PER_TICK}) reached for mission #${mission.id}; remaining users will be sent next tick.`,
|
|
||||||
);
|
|
||||||
return { sent, capped: true };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const user = await client.users.fetch(target.discordId as string);
|
|
||||||
await user.send({
|
|
||||||
embeds: [
|
|
||||||
renderReminderEmbed(mission, target.rateLeaders, target.rateSubordinates, botConfig.appUrl),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
sent += 1;
|
|
||||||
} catch (error) {
|
|
||||||
payload.logger.warn(
|
|
||||||
`[Bot] Evaluation reminder DM failed for user #${target.userId} (mission #${mission.id}): ${errorMessage(error)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, SEND_DELAY_MS));
|
|
||||||
}
|
|
||||||
return { sent, capped: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends reminder DMs for one mission, then stamps the one-shot marker —
|
|
||||||
* unless the per-tick DM cap was hit, in which case the marker is left unset
|
|
||||||
* so the remaining users are sent on a later pass. Exported for the
|
|
||||||
* /remind-evaluations command's manual trigger.
|
|
||||||
*
|
|
||||||
* @returns the number of DMs actually sent.
|
|
||||||
*/
|
|
||||||
export const processMission = async (
|
|
||||||
client: Client,
|
|
||||||
payload: Payload,
|
|
||||||
mission: Mission,
|
|
||||||
): Promise<number> => {
|
|
||||||
const { sent, capped } = await sendReminderDms(client, payload, mission);
|
|
||||||
if (capped) {
|
|
||||||
payload.logger.warn(
|
|
||||||
`[Bot] Evaluation reminders for mission #${mission.id}: DM cap hit — marker not set, will retry next tick.`,
|
|
||||||
);
|
|
||||||
return sent;
|
|
||||||
}
|
|
||||||
await payload.update({
|
|
||||||
collection: "missions",
|
|
||||||
id: mission.id,
|
|
||||||
data: { evaluationRemindersSentAt: new Date().toISOString() },
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
payload.logger.info(
|
|
||||||
`[Bot] Sent ${sent} evaluation reminder DM(s) for mission #${mission.id} (${mission.name}).`,
|
|
||||||
);
|
|
||||||
return sent;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 missions = (await getReminderMissions(payload)).filter((mission) =>
|
|
||||||
isMissionEvaluable(
|
|
||||||
mission.ownershipAndStatus?.status ?? "",
|
|
||||||
mission.classification?.startDateTime ?? null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
for (const mission of missions) {
|
|
||||||
try {
|
|
||||||
sent += await processMission(client, payload, mission);
|
|
||||||
processed += 1;
|
|
||||||
} catch (error) {
|
|
||||||
payload.logger.error(
|
|
||||||
`[Bot] Evaluation reminder reconcile failed for mission #${mission.id}: ${errorMessage(error)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
payload.logger.error(`[Bot] Evaluation reminder reconcile failed: ${errorMessage(error)}`);
|
|
||||||
} finally {
|
|
||||||
reconcileRunning = false;
|
|
||||||
}
|
|
||||||
return { processed, sent };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const start = (client: Client, payload: Payload): void => {
|
|
||||||
payload.logger.info("[Bot] Evaluation reminder service started");
|
|
||||||
void reconcile(client, payload);
|
|
||||||
setInterval(() => {
|
|
||||||
void reconcile(client, payload);
|
|
||||||
}, botConfig.evaluationPollMs);
|
|
||||||
};
|
|
||||||
|
|
@ -3,7 +3,6 @@ import type { Payload } from "payload";
|
||||||
import { start as startMissionEmbeds } from "./missionEmbeds";
|
import { start as startMissionEmbeds } from "./missionEmbeds";
|
||||||
import { start as startNotificationBridge } from "./notificationBridge";
|
import { start as startNotificationBridge } from "./notificationBridge";
|
||||||
import { start as startTransferRequests } from "./transferRequests";
|
import { start as startTransferRequests } from "./transferRequests";
|
||||||
import { start as startEvaluationReminders } from "./evaluationReminders";
|
|
||||||
|
|
||||||
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
|
export type ServiceStarter = (client: Client, payload: Payload) => Promise<void> | void;
|
||||||
|
|
||||||
|
|
@ -11,5 +10,4 @@ export const services: ServiceStarter[] = [
|
||||||
startMissionEmbeds,
|
startMissionEmbeds,
|
||||||
startNotificationBridge,
|
startNotificationBridge,
|
||||||
startTransferRequests,
|
startTransferRequests,
|
||||||
startEvaluationReminders,
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ const TARGET_COLLECTIONS = [
|
||||||
{ label: "Game Structures", value: "game-structures" },
|
{ label: "Game Structures", value: "game-structures" },
|
||||||
{ label: "Game Vehicles", value: "game-vehicles" },
|
{ label: "Game Vehicles", value: "game-vehicles" },
|
||||||
{ label: "Game NPCs", value: "game-npcs" },
|
{ label: "Game NPCs", value: "game-npcs" },
|
||||||
{ label: "NPC Staffing", value: "npc-staffing" },
|
|
||||||
{ label: "Structures", value: "structures" },
|
{ label: "Structures", value: "structures" },
|
||||||
{ label: "Resources", value: "resources" },
|
{ label: "Resources", value: "resources" },
|
||||||
{ label: "Assets", value: "assets" },
|
{ label: "Assets", value: "assets" },
|
||||||
|
|
|
||||||
|
|
@ -18,54 +18,6 @@ export const GameRules: GlobalConfig = {
|
||||||
'The base currency ("gold standard") used by all factions to pay for goods and services.',
|
'The base currency ("gold standard") used by all factions to pay for goods and services.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Universal Currency",
|
|
||||||
type: "collapsible",
|
|
||||||
admin: {
|
|
||||||
initCollapsed: true,
|
|
||||||
description:
|
|
||||||
"Display names used throughout the app. These do not change the accounting resource selected above.",
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: "currencyName",
|
|
||||||
type: "text",
|
|
||||||
admin: {
|
|
||||||
description:
|
|
||||||
'General display name for the currency, e.g. "credit". Used when singular or plural names are not set.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "currencySymbol",
|
|
||||||
type: "text",
|
|
||||||
admin: {
|
|
||||||
description: 'Optional symbol prefixed to amounts, e.g. "¢" or "$".',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "currencySingular",
|
|
||||||
type: "text",
|
|
||||||
admin: {
|
|
||||||
description: 'Singular amount label, e.g. "credit".',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "currencyPlural",
|
|
||||||
type: "text",
|
|
||||||
admin: {
|
|
||||||
description: 'Plural amount label, e.g. "credits".',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "currencyDenomination",
|
|
||||||
type: "text",
|
|
||||||
admin: {
|
|
||||||
description:
|
|
||||||
'Optional subunit name for future fractional displays, e.g. "cent". This does not affect stored values.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "commandAssignments",
|
name: "commandAssignments",
|
||||||
type: "relationship",
|
type: "relationship",
|
||||||
|
|
@ -124,22 +76,5 @@ export const GameRules: GlobalConfig = {
|
||||||
"Height of each member's personal locker grid, in cells. Applies to newly provisioned lockers.",
|
"Height of each member's personal locker grid, in cells. Applies to newly provisioned lockers.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "staffHiringEnabled",
|
|
||||||
type: "checkbox",
|
|
||||||
defaultValue: false,
|
|
||||||
admin: {
|
|
||||||
description:
|
|
||||||
"When off, logistics officers cannot hire staff anywhere (both labor crews and named NPC staff).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "staffHiringDisabledMessage",
|
|
||||||
type: "text",
|
|
||||||
admin: {
|
|
||||||
description:
|
|
||||||
"Message shown to logistics officers when hiring is disabled, e.g. explaining the in-universe reason.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import type { CollectionConfig } from "payload";
|
import { CollectionConfig } from "payload";
|
||||||
import { requirePermission } from "@/utils/access-control/hasPermission";
|
import { requirePermission } from "@/utils/access-control/hasPermission";
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { staffingDefaultsFromBlueprint } from "@/lib/base/staffingDefaults";
|
|
||||||
import { createStaffingFields } from "./staffingFields";
|
|
||||||
|
|
||||||
export const GameStructures: CollectionConfig = {
|
export const GameStructures: CollectionConfig = {
|
||||||
slug: "game-structures",
|
slug: "game-structures",
|
||||||
|
|
@ -17,30 +15,6 @@ export const GameStructures: CollectionConfig = {
|
||||||
delete: requirePermission("game-structures:delete"),
|
delete: requirePermission("game-structures:delete"),
|
||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
beforeChange: [
|
|
||||||
async ({ data, operation, req }) => {
|
|
||||||
if (operation !== "create" || !data?.type) return data;
|
|
||||||
const blueprintId = typeof data.type === "object" ? data.type.id : data.type;
|
|
||||||
const blueprint = await req.payload.findByID({
|
|
||||||
collection: "structures",
|
|
||||||
id: blueprintId,
|
|
||||||
depth: 1,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
const defaults = staffingDefaultsFromBlueprint(blueprint);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
...(data.laborRequirements === undefined
|
|
||||||
? { laborRequirements: defaults.laborRequirements }
|
|
||||||
: {}),
|
|
||||||
...(data.staffPositions === undefined ? { staffPositions: defaults.staffPositions } : {}),
|
|
||||||
...(data.maintenanceCost === undefined
|
|
||||||
? { maintenanceCost: defaults.maintenanceCost }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
],
|
|
||||||
afterChange: [
|
afterChange: [
|
||||||
async ({ doc, previousDoc, req }) => {
|
async ({ doc, previousDoc, req }) => {
|
||||||
const d = doc as any;
|
const d = doc as any;
|
||||||
|
|
@ -99,7 +73,6 @@ export const GameStructures: CollectionConfig = {
|
||||||
description: "The faction that owns this structure.",
|
description: "The faction that owns this structure.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...createStaffingFields("instance"),
|
|
||||||
{
|
{
|
||||||
label: "Geolocation",
|
label: "Geolocation",
|
||||||
type: "group",
|
type: "group",
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import { CollectionConfig } from "payload";
|
|
||||||
import hasRoles from "@/utils/access-control/hasRoles";
|
|
||||||
|
|
||||||
export const LaborClassifications: CollectionConfig = {
|
|
||||||
slug: "labor-classifications",
|
|
||||||
admin: {
|
|
||||||
group: "Game",
|
|
||||||
useAsTitle: "name",
|
|
||||||
defaultColumns: ["name", "key", "defaultUnitSalary", "availableHeadcount"],
|
|
||||||
listSearchableFields: ["name", "key"],
|
|
||||||
},
|
|
||||||
access: {
|
|
||||||
read: ({ req }) => Boolean(req.user),
|
|
||||||
create: ({ req }) => hasRoles(["admin", "developer"], req.user),
|
|
||||||
update: ({ req }) => hasRoles(["admin", "developer"], req.user),
|
|
||||||
delete: ({ req }) => hasRoles(["admin", "developer"], req.user),
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: "name",
|
|
||||||
type: "text",
|
|
||||||
required: true,
|
|
||||||
unique: true,
|
|
||||||
index: true,
|
|
||||||
label: "Name",
|
|
||||||
admin: {
|
|
||||||
description: "Display label for this labor trade (e.g. Mechanic).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "key",
|
|
||||||
type: "text",
|
|
||||||
required: true,
|
|
||||||
unique: true,
|
|
||||||
index: true,
|
|
||||||
label: "Key",
|
|
||||||
admin: {
|
|
||||||
description: "Stable slug used to identify this classification (e.g. mechanic).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "defaultUnitSalary",
|
|
||||||
type: "number",
|
|
||||||
min: 0,
|
|
||||||
label: "Default unit salary",
|
|
||||||
admin: {
|
|
||||||
description: "Prefills the wage-per-worker input in the hire dialog.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "description",
|
|
||||||
type: "textarea",
|
|
||||||
label: "Description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "availableHeadcount",
|
|
||||||
type: "number",
|
|
||||||
min: 0,
|
|
||||||
defaultValue: 0,
|
|
||||||
label: "Available headcount",
|
|
||||||
admin: {
|
|
||||||
description:
|
|
||||||
"How many workers of this classification are currently available to hire across the unit. Hiring consumes from this pool; releasing staff returns them.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import type { CollectionConfig } from "payload";
|
|
||||||
import { requirePermission } from "@/utils/access-control/hasPermission";
|
|
||||||
|
|
||||||
export const NpcStaffing: CollectionConfig = {
|
|
||||||
slug: "npc-staffing",
|
|
||||||
admin: {
|
|
||||||
group: "Game",
|
|
||||||
useAsTitle: "positionTitle",
|
|
||||||
defaultColumns: ["npc", "structure", "positionTitle", "salary", "status"],
|
|
||||||
},
|
|
||||||
access: {
|
|
||||||
read: ({ req }) => Boolean(req.user),
|
|
||||||
create: requirePermission("structures:update"),
|
|
||||||
update: requirePermission("structures:update"),
|
|
||||||
delete: requirePermission("game-npcs:delete"),
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: "npc",
|
|
||||||
type: "relationship",
|
|
||||||
relationTo: "game-npcs",
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "structure",
|
|
||||||
type: "relationship",
|
|
||||||
relationTo: "game-structures",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "positionType",
|
|
||||||
type: "select",
|
|
||||||
options: ["labor", "administrative", "technical", "other"],
|
|
||||||
defaultValue: "labor",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "positionTitle",
|
|
||||||
type: "text",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "headcount",
|
|
||||||
type: "number",
|
|
||||||
min: 1,
|
|
||||||
defaultValue: 1,
|
|
||||||
label: "Headcount",
|
|
||||||
admin: {
|
|
||||||
description: "Workers in this block; always 1 for named staff",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "laborClassification",
|
|
||||||
type: "relationship",
|
|
||||||
relationTo: "labor-classifications",
|
|
||||||
hasMany: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "salary",
|
|
||||||
type: "number",
|
|
||||||
min: 0,
|
|
||||||
defaultValue: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "status",
|
|
||||||
type: "select",
|
|
||||||
options: ["active", "terminated"],
|
|
||||||
defaultValue: "active",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "hiredAt",
|
|
||||||
type: "date",
|
|
||||||
defaultValue: () => new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "terminatedAt",
|
|
||||||
type: "date",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "notes",
|
|
||||||
type: "textarea",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import type { Field } from "payload";
|
|
||||||
|
|
||||||
type StaffingFieldContext = "instance" | "defaults";
|
|
||||||
|
|
||||||
export function createStaffingFields(context: StaffingFieldContext): Field[] {
|
|
||||||
const defaults = context === "defaults";
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: "maintenanceCost",
|
|
||||||
type: "number",
|
|
||||||
min: 0,
|
|
||||||
defaultValue: 0,
|
|
||||||
admin: {
|
|
||||||
description: defaults
|
|
||||||
? "Default funds charged per base tick for structure upkeep. Copied into newly created game-structures of this type."
|
|
||||||
: "Funds charged per base tick for structure upkeep, separate from staff salaries.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "laborRequirements",
|
|
||||||
type: "array",
|
|
||||||
admin: {
|
|
||||||
description: defaults
|
|
||||||
? "Default labor classifications copied into newly created game-structures of this type. Empty = no labor crews by default."
|
|
||||||
: "Which labor classifications this structure needs. Empty = no labor crews can be assigned here.",
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: "classification",
|
|
||||||
type: "relationship",
|
|
||||||
relationTo: "labor-classifications",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "requiredHeadcount",
|
|
||||||
type: "number",
|
|
||||||
min: 0,
|
|
||||||
defaultValue: 0,
|
|
||||||
required: true,
|
|
||||||
admin: {
|
|
||||||
description: "Informational staffing target shown to logistics officers.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "maxHeadcount",
|
|
||||||
type: "number",
|
|
||||||
min: 0,
|
|
||||||
admin: {
|
|
||||||
description:
|
|
||||||
"Hard cap for this classification. Zero or empty means it cannot be hired here.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "enforcedSalary",
|
|
||||||
type: "number",
|
|
||||||
min: 0,
|
|
||||||
admin: {
|
|
||||||
description:
|
|
||||||
"When set, workers of this classification are always paid this wage; hirers cannot change it.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "staffPositions",
|
|
||||||
type: "array",
|
|
||||||
admin: {
|
|
||||||
description: defaults
|
|
||||||
? "Default named staff position types copied into newly created game-structures of this type. Empty = no named staff by default."
|
|
||||||
: "Which named staff position types this structure accepts. Empty = no named staff can be assigned here.",
|
|
||||||
},
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
name: "positionType",
|
|
||||||
type: "select",
|
|
||||||
required: true,
|
|
||||||
options: [
|
|
||||||
{ label: "Administrative", value: "administrative" },
|
|
||||||
{ label: "Technical", value: "technical" },
|
|
||||||
{ label: "Other", value: "other" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "max",
|
|
||||||
type: "number",
|
|
||||||
min: 0,
|
|
||||||
admin: {
|
|
||||||
description: "Maximum simultaneous named staff of this type. Empty means unlimited.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "enforcedSalary",
|
|
||||||
type: "number",
|
|
||||||
min: 0,
|
|
||||||
admin: {
|
|
||||||
description: "When set, named staff of this type are always paid this salary.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
@ -197,15 +197,6 @@ export const Missions: CollectionConfig = {
|
||||||
hidden: true,
|
hidden: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "evaluationRemindersSentAt",
|
|
||||||
type: "date",
|
|
||||||
admin: {
|
|
||||||
description:
|
|
||||||
"When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot).",
|
|
||||||
hidden: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "coverImage",
|
name: "coverImage",
|
||||||
type: "upload",
|
type: "upload",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { CollectionConfig } from "payload";
|
||||||
import { isDeveloper } from "@/utils/access-control/isRole";
|
import { isDeveloper } from "@/utils/access-control/isRole";
|
||||||
import type { Resource } from "@/payload-types";
|
import type { Resource } from "@/payload-types";
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
import { createStaffingFields } from "@/collections/game/staffingFields";
|
|
||||||
|
|
||||||
export const Structures: CollectionConfig = {
|
export const Structures: CollectionConfig = {
|
||||||
slug: "structures",
|
slug: "structures",
|
||||||
|
|
@ -272,7 +271,7 @@ export const Structures: CollectionConfig = {
|
||||||
{ label: "Revision Requested", value: "revision_requested" },
|
{ label: "Revision Requested", value: "revision_requested" },
|
||||||
],
|
],
|
||||||
defaultValue: "in_progress",
|
defaultValue: "in_progress",
|
||||||
filterOptions: ({ options, data: _data, req }) => {
|
filterOptions: ({ options, data, req }) => {
|
||||||
return !isDeveloper({ req })
|
return !isDeveloper({ req })
|
||||||
? options.filter((option) =>
|
? options.filter((option) =>
|
||||||
typeof option === "string"
|
typeof option === "string"
|
||||||
|
|
@ -499,12 +498,6 @@ export const Structures: CollectionConfig = {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
|
||||||
label: "Staffing Defaults",
|
|
||||||
type: "group",
|
|
||||||
fields: createStaffingFields("defaults"),
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
{
|
||||||
label: "Production & Function",
|
label: "Production & Function",
|
||||||
type: "group",
|
type: "group",
|
||||||
|
|
|
||||||
|
|
@ -274,14 +274,6 @@ export const Profiles: CollectionConfig = {
|
||||||
name: "minigames",
|
name: "minigames",
|
||||||
label: "Minigames",
|
label: "Minigames",
|
||||||
type: "group",
|
type: "group",
|
||||||
admin: {
|
|
||||||
components: {
|
|
||||||
Field: {
|
|
||||||
path: "@/components/admin/groups/CollapsibleGroupField",
|
|
||||||
clientProps: { initCollapsed: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: "flappyBird",
|
name: "flappyBird",
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { getTranslation } from "@payloadcms/translations";
|
|
||||||
import { Collapsible, GroupField, useTranslation } from "@payloadcms/ui";
|
|
||||||
import type { GroupFieldClientProps } from "payload";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
type CollapsibleGroupFieldProps = GroupFieldClientProps & {
|
|
||||||
/** Passed via `admin.components.Field` `clientProps` (groups don't support `admin.initCollapsed`). */
|
|
||||||
initCollapsed?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps Payload's stock group field in a Collapsible so named groups can be
|
|
||||||
* collapsed in the admin panel (group fields have no built-in collapse UI).
|
|
||||||
* Purely a presentation wrapper — the data shape is unchanged.
|
|
||||||
*
|
|
||||||
* Usage on a group field:
|
|
||||||
* admin: {
|
|
||||||
* components: {
|
|
||||||
* Field: {
|
|
||||||
* path: "@/components/admin/groups/CollapsibleGroupField",
|
|
||||||
* clientProps: { initCollapsed: true },
|
|
||||||
* },
|
|
||||||
* },
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export const CollapsibleGroupField = (props: CollapsibleGroupFieldProps) => {
|
|
||||||
const { field, initCollapsed = false } = props;
|
|
||||||
const { i18n } = useTranslation();
|
|
||||||
const title = getTranslation(field.label ?? ("name" in field ? field.name : "") ?? "", i18n);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Collapsible header={<h3 className="group-field__title">{title}</h3>} initCollapsed={initCollapsed}>
|
|
||||||
<GroupField {...props} field={{ ...field, label: undefined }} />
|
|
||||||
</Collapsible>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CollapsibleGroupField;
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
ItemTitle,
|
ItemTitle,
|
||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
import { LandmarkIcon, UsersIcon, WalletIcon } from "lucide-react";
|
import { LandmarkIcon, UsersIcon, WalletIcon } from "lucide-react";
|
||||||
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import { formatAmount } from "@/lib/banking/format";
|
||||||
import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui";
|
import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui";
|
||||||
|
|
||||||
const accountTypeMeta = {
|
const accountTypeMeta = {
|
||||||
|
|
@ -39,13 +39,7 @@ export function accountTypeInfo(accountType: string) {
|
||||||
return accountTypeMeta[accountType as keyof typeof accountTypeMeta] ?? accountTypeMeta.personal;
|
return accountTypeMeta[accountType as keyof typeof accountTypeMeta] ?? accountTypeMeta.personal;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountCard({
|
export function AccountCard({ account }: { account: BankAccount }) {
|
||||||
account,
|
|
||||||
currencyConfig,
|
|
||||||
}: {
|
|
||||||
account: BankAccount;
|
|
||||||
currencyConfig: CurrencyConfig;
|
|
||||||
}) {
|
|
||||||
const meta = accountTypeInfo(account.accountType);
|
const meta = accountTypeInfo(account.accountType);
|
||||||
const Icon = meta.icon;
|
const Icon = meta.icon;
|
||||||
const currency = typeof account.currency === "object" ? account.currency : null;
|
const currency = typeof account.currency === "object" ? account.currency : null;
|
||||||
|
|
@ -85,10 +79,7 @@ export function AccountCard({
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<span className="text-lg font-semibold font-mono">
|
<span className="text-lg font-semibold font-mono">
|
||||||
{formatAmount(
|
{formatAmount(account.balance ?? 0, currency)}
|
||||||
account.balance ?? 0,
|
|
||||||
currencyConfig.resourceName ? currencyConfig : currency,
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import Link from "next/link";
|
||||||
import { ArrowLeftIcon } from "lucide-react";
|
import { ArrowLeftIcon } from "lucide-react";
|
||||||
import type { BankAccount, LedgerEntry } from "@/payload-types";
|
import type { BankAccount, LedgerEntry } from "@/payload-types";
|
||||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||||
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import { formatAmount } from "@/lib/banking/format";
|
||||||
import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui";
|
import { type AccountStatus, accountStatusBase, accountStatusClass } from "@/lib/banking/ui";
|
||||||
import { accountTypeInfo } from "@/components/frontend/banking/AccountCard";
|
import { accountTypeInfo } from "@/components/frontend/banking/AccountCard";
|
||||||
import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog";
|
import { BankTransactionDialog } from "@/components/frontend/banking/BankTransactionDialog";
|
||||||
|
|
@ -15,16 +15,9 @@ interface AccountDetailProps {
|
||||||
ledger: LedgerEntry[];
|
ledger: LedgerEntry[];
|
||||||
otherAccounts: { id: number; name: string }[];
|
otherAccounts: { id: number; name: string }[];
|
||||||
canTransact: boolean;
|
canTransact: boolean;
|
||||||
currencyConfig: CurrencyConfig;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountDetail({
|
export function AccountDetail({ account, ledger, otherAccounts, canTransact }: AccountDetailProps) {
|
||||||
account,
|
|
||||||
ledger,
|
|
||||||
otherAccounts,
|
|
||||||
canTransact,
|
|
||||||
currencyConfig,
|
|
||||||
}: AccountDetailProps) {
|
|
||||||
const meta = accountTypeInfo(account.accountType);
|
const meta = accountTypeInfo(account.accountType);
|
||||||
const Icon = meta.icon;
|
const Icon = meta.icon;
|
||||||
const currency = typeof account.currency === "object" ? account.currency : null;
|
const currency = typeof account.currency === "object" ? account.currency : null;
|
||||||
|
|
@ -72,10 +65,7 @@ export function AccountDetail({
|
||||||
Balance
|
Balance
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-semibold font-mono">
|
<p className="text-2xl font-semibold font-mono">
|
||||||
{formatAmount(
|
{formatAmount(account.balance ?? 0, currency)}
|
||||||
account.balance ?? 0,
|
|
||||||
currencyConfig.resourceName ? currencyConfig : currency,
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { BankAccount, BankTransaction, User } from "@/payload-types";
|
import type { BankAccount, BankTransaction, Resource, User } from "@/payload-types";
|
||||||
import { ArrowDownLeftIcon, ArrowRightLeftIcon, ArrowUpRightIcon } from "lucide-react";
|
import { ArrowDownLeftIcon, ArrowRightLeftIcon, ArrowUpRightIcon } from "lucide-react";
|
||||||
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import { currencyLabel } from "@/lib/banking/format";
|
||||||
import {
|
import {
|
||||||
formatTxTimestamp,
|
formatTxTimestamp,
|
||||||
txAmountClass,
|
txAmountClass,
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
|
|
||||||
interface ActivityFeedProps {
|
interface ActivityFeedProps {
|
||||||
transactions: BankTransaction[];
|
transactions: BankTransaction[];
|
||||||
currencyConfig: CurrencyConfig;
|
currencyResource: Resource | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Icon per type — kept local because the icons are React components used only
|
// Icon per type — kept local because the icons are React components used only
|
||||||
|
|
@ -60,7 +60,9 @@ function resolveActorLabel(actor: (number | null) | User | undefined): string {
|
||||||
return `#${actor}`;
|
return `#${actor}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActivityFeed({ transactions, currencyConfig }: ActivityFeedProps) {
|
export function ActivityFeed({ transactions, currencyResource }: ActivityFeedProps) {
|
||||||
|
const cur = currencyLabel(currencyResource);
|
||||||
|
|
||||||
if (transactions.length === 0) {
|
if (transactions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/20 py-10 flex items-center justify-center">
|
<div className="rounded-lg border border-border/60 bg-muted/20 py-10 flex items-center justify-center">
|
||||||
|
|
@ -85,7 +87,7 @@ export function ActivityFeed({ transactions, currencyConfig }: ActivityFeedProps
|
||||||
const targetAccountId = to.id ?? from.id;
|
const targetAccountId = to.id ?? from.id;
|
||||||
const href = targetAccountId != null ? `/logistics/banking/${targetAccountId}` : null;
|
const href = targetAccountId != null ? `/logistics/banking/${targetAccountId}` : null;
|
||||||
const amount = tx.amount ?? 0;
|
const amount = tx.amount ?? 0;
|
||||||
const signed = `${txAmountPrefix(flow)}${formatAmount(amount, currencyConfig)}`;
|
const signed = `${txAmountPrefix(flow)}${amount.toLocaleString()}`;
|
||||||
|
|
||||||
const RowInner = (
|
const RowInner = (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
|
|
@ -113,6 +115,7 @@ export function ActivityFeed({ transactions, currencyConfig }: ActivityFeedProps
|
||||||
className={`font-mono text-sm tabular-nums shrink-0 ml-auto pr-1 ${txAmountClass(flow)}`}
|
className={`font-mono text-sm tabular-nums shrink-0 ml-auto pr-1 ${txAmountClass(flow)}`}
|
||||||
>
|
>
|
||||||
{signed}
|
{signed}
|
||||||
|
{cur && <span className="ml-1 text-[10px] text-muted-foreground/60">{cur}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -136,4 +139,4 @@ export function ActivityFeed({ transactions, currencyConfig }: ActivityFeedProps
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ import { AccountCard } from "@/components/frontend/banking/AccountCard";
|
||||||
import { CreateAccountDialog } from "@/components/frontend/banking/CreateAccountDialog";
|
import { CreateAccountDialog } from "@/components/frontend/banking/CreateAccountDialog";
|
||||||
import { WalletHero } from "@/components/frontend/banking/WalletHero";
|
import { WalletHero } from "@/components/frontend/banking/WalletHero";
|
||||||
import { ActivityFeed } from "@/components/frontend/banking/ActivityFeed";
|
import { ActivityFeed } from "@/components/frontend/banking/ActivityFeed";
|
||||||
import { currencyLabel, formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import { currencyLabel, formatAmount } from "@/lib/banking/format";
|
||||||
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
|
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
|
||||||
|
|
||||||
interface BankingOverviewProps {
|
interface BankingOverviewProps {
|
||||||
|
|
@ -16,7 +16,6 @@ interface BankingOverviewProps {
|
||||||
recentTransactions: BankTransaction[];
|
recentTransactions: BankTransaction[];
|
||||||
currentUser: { id: number; username: string; displayName?: string } | null;
|
currentUser: { id: number; username: string; displayName?: string } | null;
|
||||||
currencyResource: Resource | null;
|
currencyResource: Resource | null;
|
||||||
currencyConfig: CurrencyConfig;
|
|
||||||
isManager: boolean;
|
isManager: boolean;
|
||||||
factions: { id: number; name: string }[];
|
factions: { id: number; name: string }[];
|
||||||
}
|
}
|
||||||
|
|
@ -54,7 +53,6 @@ export function BankingOverview({
|
||||||
recentTransactions,
|
recentTransactions,
|
||||||
currentUser,
|
currentUser,
|
||||||
currencyResource,
|
currencyResource,
|
||||||
currencyConfig,
|
|
||||||
isManager,
|
isManager,
|
||||||
factions,
|
factions,
|
||||||
}: BankingOverviewProps) {
|
}: BankingOverviewProps) {
|
||||||
|
|
@ -103,7 +101,6 @@ export function BankingOverview({
|
||||||
account={myAccount ?? null}
|
account={myAccount ?? null}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
currencyResource={currencyResource}
|
currencyResource={currencyResource}
|
||||||
currencyConfig={currencyConfig}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue="activity" className="gap-4">
|
<Tabs defaultValue="activity" className="gap-4">
|
||||||
|
|
@ -126,13 +123,13 @@ export function BankingOverview({
|
||||||
{isManager && (
|
{isManager && (
|
||||||
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/70">
|
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/70">
|
||||||
UNIT FUNDS
|
UNIT FUNDS
|
||||||
{formatAmount(unitFundsTotal, currencyConfig)}
|
{formatAmount(unitFundsTotal, currencyResource)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="activity" className="m-0">
|
<TabsContent value="activity" className="m-0">
|
||||||
<ActivityFeed transactions={recentTransactions} currencyConfig={currencyConfig} />
|
<ActivityFeed transactions={recentTransactions} currencyResource={currencyResource} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="accounts" className="m-0 pt-4 flex flex-col gap-6">
|
<TabsContent value="accounts" className="m-0 pt-4 flex flex-col gap-6">
|
||||||
|
|
@ -164,11 +161,7 @@ export function BankingOverview({
|
||||||
{isManager && treasury.length > 0 && (
|
{isManager && treasury.length > 0 && (
|
||||||
<Section title="Treasury" count={treasury.length}>
|
<Section title="Treasury" count={treasury.length}>
|
||||||
{treasury.map((account) => (
|
{treasury.map((account) => (
|
||||||
<AccountCard
|
<AccountCard key={account.id} account={account} />
|
||||||
key={account.id}
|
|
||||||
account={account}
|
|
||||||
currencyConfig={currencyConfig}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
@ -176,11 +169,7 @@ export function BankingOverview({
|
||||||
{isManager && factionAccounts.length > 0 && (
|
{isManager && factionAccounts.length > 0 && (
|
||||||
<Section title="Factions" count={factionAccounts.length}>
|
<Section title="Factions" count={factionAccounts.length}>
|
||||||
{factionAccounts.map((account) => (
|
{factionAccounts.map((account) => (
|
||||||
<AccountCard
|
<AccountCard key={account.id} account={account} />
|
||||||
key={account.id}
|
|
||||||
account={account}
|
|
||||||
currencyConfig={currencyConfig}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
@ -191,11 +180,7 @@ export function BankingOverview({
|
||||||
count={visiblePersonal.length}
|
count={visiblePersonal.length}
|
||||||
>
|
>
|
||||||
{visiblePersonal.map((account) => (
|
{visiblePersonal.map((account) => (
|
||||||
<AccountCard
|
<AccountCard key={account.id} account={account} />
|
||||||
key={account.id}
|
|
||||||
account={account}
|
|
||||||
currencyConfig={currencyConfig}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
@ -211,8 +196,8 @@ export function BankingOverview({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/40">
|
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/40">
|
||||||
SYS.BANKING / currency={tickerCurrency(currencyLabel(currencyConfig))}
|
SYS.BANKING / currency={tickerCurrency(currencyLabel(currencyResource))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ import Link from "next/link";
|
||||||
import { ChevronRightIcon, WalletIcon } from "lucide-react";
|
import { ChevronRightIcon, WalletIcon } from "lucide-react";
|
||||||
import type { BankAccount, Resource } from "@/payload-types";
|
import type { BankAccount, Resource } from "@/payload-types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import { formatAmount } from "@/lib/banking/format";
|
||||||
import {
|
import {
|
||||||
type AccountStatus,
|
type AccountStatus,
|
||||||
accountStatusBase,
|
accountStatusBase,
|
||||||
|
|
@ -20,18 +20,12 @@ interface WalletHeroProps {
|
||||||
account: BankAccount | null;
|
account: BankAccount | null;
|
||||||
currentUser: { id: number; username: string; displayName?: string } | null;
|
currentUser: { id: number; username: string; displayName?: string } | null;
|
||||||
currencyResource: Resource | null;
|
currencyResource: Resource | null;
|
||||||
currencyConfig: CurrencyConfig;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared terminal-aesthetic class for small uppercase mono labels in the hero.
|
// Shared terminal-aesthetic class for small uppercase mono labels in the hero.
|
||||||
const labelCls = "font-mono text-[10px] uppercase tracking-wider text-muted-foreground";
|
const labelCls = "font-mono text-[10px] uppercase tracking-wider text-muted-foreground";
|
||||||
|
|
||||||
export function WalletHero({
|
export function WalletHero({ account, currentUser, currencyResource }: WalletHeroProps) {
|
||||||
account,
|
|
||||||
currentUser,
|
|
||||||
currencyResource,
|
|
||||||
currencyConfig,
|
|
||||||
}: WalletHeroProps) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [initializing, setInitializing] = useState(false);
|
const [initializing, setInitializing] = useState(false);
|
||||||
const [initError, setInitError] = useState<string | null>(null);
|
const [initError, setInitError] = useState<string | null>(null);
|
||||||
|
|
@ -120,10 +114,7 @@ export function WalletHero({
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<p className="text-4xl md:text-5xl font-bold font-mono tracking-tight">
|
<p className="text-4xl md:text-5xl font-bold font-mono tracking-tight">
|
||||||
{formatAmount(
|
{formatAmount(account.balance ?? 0, currency)}
|
||||||
account.balance ?? 0,
|
|
||||||
currencyConfig.resourceName ? currencyConfig : currency,
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/60">
|
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground/60">
|
||||||
LAST ACTIVITY {lastActivity}
|
LAST ACTIVITY {lastActivity}
|
||||||
|
|
@ -146,4 +137,4 @@ export function WalletHero({
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { BadgeCheckIcon } from "lucide-react";
|
|
||||||
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { HireStaffDialog } from "@/components/frontend/baseManagement/HireStaffDialog";
|
|
||||||
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
|
||||||
|
|
||||||
interface EmploymentCardProps {
|
|
||||||
readonly employment: {
|
|
||||||
readonly positionTitle: string;
|
|
||||||
readonly positionType: string;
|
|
||||||
readonly structureName: string;
|
|
||||||
readonly salary: number;
|
|
||||||
} | null;
|
|
||||||
readonly structures: readonly StructureStaffingOption[];
|
|
||||||
readonly canManage: boolean;
|
|
||||||
readonly npcId: number;
|
|
||||||
readonly npcName?: string;
|
|
||||||
readonly classifications?: readonly LaborClassificationOption[];
|
|
||||||
readonly hiringEnabled: boolean;
|
|
||||||
readonly hiringDisabledMessage?: string;
|
|
||||||
readonly currencyConfig: CurrencyConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmploymentCard({
|
|
||||||
employment,
|
|
||||||
structures,
|
|
||||||
canManage,
|
|
||||||
npcId,
|
|
||||||
npcName,
|
|
||||||
classifications,
|
|
||||||
hiringEnabled,
|
|
||||||
hiringDisabledMessage,
|
|
||||||
currencyConfig,
|
|
||||||
}: EmploymentCardProps) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<BadgeCheckIcon className="size-4 text-muted-foreground" />
|
|
||||||
Employment
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Current base assignment and compensation.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{employment ? (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<p className="font-medium">{employment.positionTitle}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{employment.positionType} · {employment.structureName}
|
|
||||||
</p>
|
|
||||||
<p className="font-mono text-sm tabular-nums">
|
|
||||||
{formatAmount(employment.salary, currencyConfig)}/tick
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : canManage && hiringEnabled ? (
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<p className="text-sm text-muted-foreground">Not currently employed.</p>
|
|
||||||
<HireStaffDialog
|
|
||||||
structures={structures}
|
|
||||||
npcs={[{ id: npcId, name: npcName ?? `NPC #${npcId}` }]}
|
|
||||||
fixedNpcId={npcId}
|
|
||||||
canManage={canManage}
|
|
||||||
classifications={classifications}
|
|
||||||
hiringEnabled={hiringEnabled}
|
|
||||||
hiringDisabledMessage={hiringDisabledMessage}
|
|
||||||
currencyConfig={currencyConfig}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : canManage ? (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<p className="text-sm text-muted-foreground">Not currently employed.</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{hiringDisabledMessage || "Staff hiring is currently disabled."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">Not currently employed.</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,564 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { UsersIcon } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { hireStaff, setLaborHeadcount } from "@/app/(frontend)/logistics/base/actions";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
|
||||||
import { currencyLabel, type CurrencyConfig } from "@/lib/banking/format";
|
|
||||||
|
|
||||||
interface HireStaffDialogProps {
|
|
||||||
readonly structures: readonly StructureStaffingOption[];
|
|
||||||
readonly npcs: readonly { id: number; name: string }[];
|
|
||||||
readonly fixedNpcId?: number | null;
|
|
||||||
readonly defaultStructureId?: number | null;
|
|
||||||
readonly canManage: boolean;
|
|
||||||
readonly triggerLabel?: string;
|
|
||||||
readonly classifications?: readonly LaborClassificationOption[];
|
|
||||||
readonly hiringEnabled: boolean;
|
|
||||||
readonly hiringDisabledMessage?: string;
|
|
||||||
readonly currencyConfig: CurrencyConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
const POSITION_TYPES = [
|
|
||||||
{ value: "administrative", label: "Administrative" },
|
|
||||||
{ value: "technical", label: "Technical" },
|
|
||||||
{ value: "other", label: "Other" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function HireStaffDialog({
|
|
||||||
structures,
|
|
||||||
npcs,
|
|
||||||
fixedNpcId,
|
|
||||||
defaultStructureId,
|
|
||||||
canManage,
|
|
||||||
triggerLabel = "Hire staff",
|
|
||||||
classifications = [],
|
|
||||||
hiringEnabled,
|
|
||||||
hiringDisabledMessage,
|
|
||||||
currencyConfig,
|
|
||||||
}: HireStaffDialogProps) {
|
|
||||||
const defaultStructure = structures.find((structure) => structure.id === defaultStructureId);
|
|
||||||
const initialRequirement = defaultStructure?.laborRequirements[0];
|
|
||||||
const initialClassification = classifications.find(
|
|
||||||
(classification) => classification.id === initialRequirement?.classificationId,
|
|
||||||
);
|
|
||||||
const initialPositionType =
|
|
||||||
fixedNpcId != null || !initialRequirement
|
|
||||||
? (defaultStructure?.staffPositions[0]?.positionType ?? "administrative")
|
|
||||||
: "labor";
|
|
||||||
const initialPositionRule = defaultStructure?.staffPositions.find(
|
|
||||||
(rule) => rule.positionType === initialPositionType,
|
|
||||||
);
|
|
||||||
const initialSalary =
|
|
||||||
initialPositionType === "labor"
|
|
||||||
? (initialRequirement?.enforcedSalary ?? initialClassification?.defaultUnitSalary ?? null)
|
|
||||||
: (initialPositionRule?.enforcedSalary ?? null);
|
|
||||||
const router = useRouter();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [npcId, setNpcId] = useState(fixedNpcId != null ? String(fixedNpcId) : "");
|
|
||||||
const [structureId, setStructureId] = useState(
|
|
||||||
defaultStructureId != null ? String(defaultStructureId) : "",
|
|
||||||
);
|
|
||||||
const [positionType, setPositionType] = useState(initialPositionType);
|
|
||||||
const [positionTitle, setPositionTitle] = useState("");
|
|
||||||
const [laborClassification, setLaborClassification] = useState(() =>
|
|
||||||
initialRequirement ? String(initialRequirement.classificationId) : "",
|
|
||||||
);
|
|
||||||
const [headcount, setHeadcount] = useState("1");
|
|
||||||
const [salary, setSalary] = useState(initialSalary !== null ? String(initialSalary) : "");
|
|
||||||
const [salaryDirty, setSalaryDirty] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
if (!canManage) return null;
|
|
||||||
|
|
||||||
if (!hiringEnabled) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled
|
|
||||||
title={hiringDisabledMessage || "Staff hiring is currently disabled."}
|
|
||||||
>
|
|
||||||
<UsersIcon data-icon="inline-start" />
|
|
||||||
{triggerLabel}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fixedNpc = fixedNpcId != null ? npcs.find((npc) => npc.id === fixedNpcId) : null;
|
|
||||||
const selectedStructure = structures.find((structure) => String(structure.id) === structureId);
|
|
||||||
const approvedClassifications = classifications.filter((classification) =>
|
|
||||||
selectedStructure?.laborRequirements.some(
|
|
||||||
(requirement) => requirement.classificationId === classification.id,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const approvedPositionTypes = POSITION_TYPES.filter((position) =>
|
|
||||||
selectedStructure?.staffPositions.some((rule) => rule.positionType === position.value),
|
|
||||||
);
|
|
||||||
const selectedClassification = classifications.find(
|
|
||||||
(classification) => String(classification.id) === laborClassification,
|
|
||||||
);
|
|
||||||
const selectedRequirement = selectedStructure?.laborRequirements.find(
|
|
||||||
(requirement) => String(requirement.classificationId) === laborClassification,
|
|
||||||
);
|
|
||||||
const selectedPositionRule = selectedStructure?.staffPositions.find(
|
|
||||||
(rule) => rule.positionType === positionType,
|
|
||||||
);
|
|
||||||
const salaryValue = Number(salary) || 0;
|
|
||||||
const marketRate = selectedClassification?.defaultUnitSalary ?? 0;
|
|
||||||
const effectiveAvailable = selectedClassification
|
|
||||||
? Math.floor(
|
|
||||||
selectedClassification.availableHeadcount *
|
|
||||||
(marketRate > 0 ? Math.min(1, salaryValue / marketRate) : 1),
|
|
||||||
)
|
|
||||||
: 0;
|
|
||||||
const laborHeadcountMax = selectedRequirement
|
|
||||||
? selectedRequirement.currentHeadcount +
|
|
||||||
Math.min(
|
|
||||||
Math.max(0, selectedRequirement.maxHeadcount - selectedRequirement.currentHeadcount),
|
|
||||||
effectiveAvailable,
|
|
||||||
)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
setNpcId(fixedNpcId != null ? String(fixedNpcId) : "");
|
|
||||||
setStructureId(defaultStructureId != null ? String(defaultStructureId) : "");
|
|
||||||
setPositionType(initialPositionType);
|
|
||||||
setPositionTitle("");
|
|
||||||
setLaborClassification(initialRequirement ? String(initialRequirement.classificationId) : "");
|
|
||||||
setHeadcount("1");
|
|
||||||
setSalary(initialSalary !== null ? String(initialSalary) : "");
|
|
||||||
setSalaryDirty(false);
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClassificationChange(value: string) {
|
|
||||||
setLaborClassification(value);
|
|
||||||
const requirement = selectedStructure?.laborRequirements.find(
|
|
||||||
(entry) => String(entry.classificationId) === value,
|
|
||||||
);
|
|
||||||
if (requirement?.enforcedSalary !== null && requirement?.enforcedSalary !== undefined) {
|
|
||||||
setSalary(String(requirement.enforcedSalary));
|
|
||||||
setSalaryDirty(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (salaryDirty) return;
|
|
||||||
const classification = classifications.find((c) => String(c.id) === value);
|
|
||||||
if (classification && classification.defaultUnitSalary != null) {
|
|
||||||
setSalary(String(classification.defaultUnitSalary));
|
|
||||||
} else {
|
|
||||||
setSalary("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleStructureChange(value: string) {
|
|
||||||
setStructureId(value);
|
|
||||||
const structure = structures.find((entry) => String(entry.id) === value);
|
|
||||||
const firstRequirement = structure?.laborRequirements[0];
|
|
||||||
const firstPosition = structure?.staffPositions[0];
|
|
||||||
if (fixedNpcId != null) {
|
|
||||||
setPositionType(firstPosition?.positionType ?? "administrative");
|
|
||||||
setSalary(
|
|
||||||
firstPosition?.enforcedSalary !== null && firstPosition?.enforcedSalary !== undefined
|
|
||||||
? String(firstPosition.enforcedSalary)
|
|
||||||
: "",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setPositionType(firstRequirement ? "labor" : (firstPosition?.positionType ?? "labor"));
|
|
||||||
setLaborClassification(firstRequirement ? String(firstRequirement.classificationId) : "");
|
|
||||||
const classification = classifications.find(
|
|
||||||
(entry) => entry.id === firstRequirement?.classificationId,
|
|
||||||
);
|
|
||||||
setSalary(
|
|
||||||
firstRequirement?.enforcedSalary !== null && firstRequirement?.enforcedSalary !== undefined
|
|
||||||
? String(firstRequirement.enforcedSalary)
|
|
||||||
: classification?.defaultUnitSalary != null
|
|
||||||
? String(classification.defaultUnitSalary)
|
|
||||||
: "",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setSalaryDirty(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePositionTypeChange(value: string) {
|
|
||||||
setPositionType(value);
|
|
||||||
if (value === "labor") {
|
|
||||||
handleClassificationChange(laborClassification);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rule = selectedStructure?.staffPositions.find((entry) => entry.positionType === value);
|
|
||||||
setSalary(
|
|
||||||
rule?.enforcedSalary !== null && rule?.enforcedSalary !== undefined
|
|
||||||
? String(rule.enforcedSalary)
|
|
||||||
: "",
|
|
||||||
);
|
|
||||||
setSalaryDirty(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
const selectedStructureId = Number(structureId);
|
|
||||||
|
|
||||||
if (positionType === "labor") {
|
|
||||||
if (!Number.isInteger(selectedStructureId) || selectedStructureId <= 0) {
|
|
||||||
setError("Select a structure.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!laborClassification) {
|
|
||||||
setError("Select a classification.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headcountValue = Number(headcount);
|
|
||||||
if (!Number.isInteger(headcountValue) || headcountValue < 1) {
|
|
||||||
setError("Headcount must be a whole number of at least 1.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(salaryValue) || salaryValue < 0) {
|
|
||||||
setError("Wage cannot be negative.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const result = await setLaborHeadcount(
|
|
||||||
selectedStructureId,
|
|
||||||
Number(laborClassification),
|
|
||||||
headcountValue,
|
|
||||||
salaryValue,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
const label =
|
|
||||||
classifications.find((c) => String(c.id) === laborClassification)?.name ?? "Labor";
|
|
||||||
toast.success(`${label} crew set to ${headcountValue}`);
|
|
||||||
setOpen(false);
|
|
||||||
reset();
|
|
||||||
router.refresh();
|
|
||||||
} else {
|
|
||||||
const message = result.error ?? "Labor update failed";
|
|
||||||
setError(message);
|
|
||||||
toast.error(message);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Labor update failed");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedNpcId = fixedNpcId ?? Number(npcId);
|
|
||||||
const title = positionTitle.trim();
|
|
||||||
|
|
||||||
if (!Number.isInteger(selectedNpcId) || selectedNpcId <= 0) {
|
|
||||||
setError("Select an NPC.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!Number.isInteger(selectedStructureId) || selectedStructureId <= 0) {
|
|
||||||
setError("Select a structure.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!title) {
|
|
||||||
setError("Enter a position title.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(salaryValue) || salaryValue < 0) {
|
|
||||||
setError("Salary cannot be negative.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const result = await hireStaff({
|
|
||||||
npcId: selectedNpcId,
|
|
||||||
structureId: selectedStructureId,
|
|
||||||
positionType,
|
|
||||||
positionTitle: title,
|
|
||||||
salary: salaryValue,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
const name = fixedNpc?.name ?? npcs.find((npc) => npc.id === selectedNpcId)?.name;
|
|
||||||
toast.success(`${name ?? `NPC #${selectedNpcId}`} hired as ${title}`);
|
|
||||||
setOpen(false);
|
|
||||||
reset();
|
|
||||||
router.refresh();
|
|
||||||
} else {
|
|
||||||
const message = result.error ?? "Hire failed";
|
|
||||||
setError(message);
|
|
||||||
toast.error(message);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Hire failed");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(nextOpen) => {
|
|
||||||
setOpen(nextOpen);
|
|
||||||
if (!nextOpen) reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<UsersIcon data-icon="inline-start" />
|
|
||||||
{triggerLabel}
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Hire staff</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{positionType === "labor"
|
|
||||||
? "Set a classification-based labor crew for a structure."
|
|
||||||
: "Assign an NPC to a structure and define their role."}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{positionType !== "labor" && (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>NPC</Label>
|
|
||||||
{fixedNpcId != null ? (
|
|
||||||
<p className="rounded-md border bg-muted px-3 py-2 text-sm">
|
|
||||||
{fixedNpc?.name ?? `NPC #${fixedNpcId}`}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<Select value={npcId} onValueChange={setNpcId}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select an NPC" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{npcs.map((npc) => (
|
|
||||||
<SelectItem key={npc.id} value={String(npc.id)}>
|
|
||||||
{npc.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>Structure</Label>
|
|
||||||
<Select value={structureId} onValueChange={handleStructureChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a structure" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{structures.map((structure) => (
|
|
||||||
<SelectItem key={structure.id} value={String(structure.id)}>
|
|
||||||
{structure.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>Position type</Label>
|
|
||||||
<Select value={positionType} onValueChange={handlePositionTypeChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{fixedNpcId == null && approvedClassifications.length > 0 && (
|
|
||||||
<SelectItem value="labor">Labor</SelectItem>
|
|
||||||
)}
|
|
||||||
{approvedPositionTypes.map((position) => (
|
|
||||||
<SelectItem key={position.value} value={position.value}>
|
|
||||||
{position.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{selectedStructure &&
|
|
||||||
approvedClassifications.length === 0 &&
|
|
||||||
approvedPositionTypes.length === 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
This structure has no approved staff positions.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{positionType === "labor" ? (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>Classification</Label>
|
|
||||||
<Select value={laborClassification} onValueChange={handleClassificationChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{approvedClassifications.map((classification) => (
|
|
||||||
<SelectItem key={classification.id} value={String(classification.id)}>
|
|
||||||
{classification.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{selectedStructure && approvedClassifications.length === 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
This structure has no approved labor positions.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="staff-headcount">Headcount</Label>
|
|
||||||
<Input
|
|
||||||
id="staff-headcount"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={laborHeadcountMax}
|
|
||||||
step={1}
|
|
||||||
inputMode="numeric"
|
|
||||||
value={headcount}
|
|
||||||
onChange={(event) => setHeadcount(event.target.value)}
|
|
||||||
placeholder="Number of workers"
|
|
||||||
disabled={!selectedRequirement || laborHeadcountMax < 1}
|
|
||||||
/>
|
|
||||||
{selectedRequirement && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{selectedRequirement.currentHeadcount} hired ·{" "}
|
|
||||||
{selectedRequirement.requiredHeadcount} required · max{" "}
|
|
||||||
{selectedRequirement.maxHeadcount}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="staff-unit-wage">
|
|
||||||
Wage per worker ({currencyLabel(currencyConfig)})
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="staff-unit-wage"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step="any"
|
|
||||||
value={salary}
|
|
||||||
onChange={(event) => {
|
|
||||||
setSalaryDirty(true);
|
|
||||||
setSalary(event.target.value);
|
|
||||||
}}
|
|
||||||
placeholder="Per worker per base tick"
|
|
||||||
disabled={selectedRequirement?.enforcedSalary != null}
|
|
||||||
/>
|
|
||||||
{selectedRequirement?.enforcedSalary != null && (
|
|
||||||
<p className="text-sm text-muted-foreground">Wage enforced by this structure.</p>
|
|
||||||
)}
|
|
||||||
{selectedClassification &&
|
|
||||||
effectiveAvailable < selectedClassification.availableHeadcount && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Only {effectiveAvailable} of {selectedClassification.availableHeadcount}{" "}
|
|
||||||
{selectedClassification.name} workers are willing to work at this wage.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="staff-position-title">Position title</Label>
|
|
||||||
<Input
|
|
||||||
id="staff-position-title"
|
|
||||||
value={positionTitle}
|
|
||||||
onChange={(event) => setPositionTitle(event.target.value)}
|
|
||||||
placeholder="e.g. Head Surgeon, Site Director"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="staff-salary">Salary</Label>
|
|
||||||
<Input
|
|
||||||
id="staff-salary"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step="any"
|
|
||||||
value={salary}
|
|
||||||
onChange={(event) => {
|
|
||||||
setSalaryDirty(true);
|
|
||||||
setSalary(event.target.value);
|
|
||||||
}}
|
|
||||||
placeholder="Per base tick"
|
|
||||||
disabled={selectedPositionRule?.enforcedSalary != null}
|
|
||||||
/>
|
|
||||||
{selectedPositionRule?.enforcedSalary != null && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Salary enforced by this structure.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{selectedPositionRule?.max != null && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{selectedPositionRule.currentHeadcount} of {selectedPositionRule.max} positions
|
|
||||||
filled.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={loading}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => void handleSubmit()}
|
|
||||||
disabled={
|
|
||||||
loading ||
|
|
||||||
!selectedStructure ||
|
|
||||||
(positionType === "labor"
|
|
||||||
? !selectedRequirement || laborHeadcountMax < 1
|
|
||||||
: !selectedPositionRule ||
|
|
||||||
(selectedPositionRule.max != null &&
|
|
||||||
selectedPositionRule.currentHeadcount >= selectedPositionRule.max))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{loading ? (positionType === "labor" ? "Saving..." : "Hiring...") : "Hire staff"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,437 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { UsersIcon } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { fireStaff, setLaborHeadcount } from "@/app/(frontend)/logistics/base/actions";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { currencyLabel, formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
|
||||||
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
|
|
||||||
import { HireStaffDialog } from "@/components/frontend/baseManagement/HireStaffDialog";
|
|
||||||
|
|
||||||
interface StaffMember {
|
|
||||||
readonly id: number;
|
|
||||||
readonly npcName: string | null;
|
|
||||||
readonly positionType: string;
|
|
||||||
readonly positionTitle: string;
|
|
||||||
readonly salary: number;
|
|
||||||
readonly headcount: number;
|
|
||||||
readonly laborClassificationId: number | null;
|
|
||||||
readonly hiredAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StaffRosterProps {
|
|
||||||
readonly structure: StructureStaffingOption;
|
|
||||||
readonly staff: readonly StaffMember[];
|
|
||||||
readonly classifications: readonly LaborClassificationOption[];
|
|
||||||
readonly npcs: readonly { id: number; name: string }[];
|
|
||||||
readonly canManage: boolean;
|
|
||||||
readonly hiringEnabled: boolean;
|
|
||||||
readonly hiringDisabledMessage?: string;
|
|
||||||
readonly currencyConfig: CurrencyConfig;
|
|
||||||
readonly onChanged?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LaborCrew {
|
|
||||||
readonly classificationId: number;
|
|
||||||
readonly classificationName: string;
|
|
||||||
headcount: number;
|
|
||||||
unitSalary: number;
|
|
||||||
readonly requiredHeadcount: number;
|
|
||||||
readonly maxHeadcount: number;
|
|
||||||
readonly enforcedSalary: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LaborEdit {
|
|
||||||
readonly headcount: string;
|
|
||||||
readonly unitSalary: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StaffRoster({
|
|
||||||
structure,
|
|
||||||
staff,
|
|
||||||
classifications,
|
|
||||||
npcs,
|
|
||||||
canManage,
|
|
||||||
hiringEnabled,
|
|
||||||
hiringDisabledMessage,
|
|
||||||
currencyConfig,
|
|
||||||
onChanged,
|
|
||||||
}: StaffRosterProps) {
|
|
||||||
const [staffMembers, setStaffMembers] = useState<readonly StaffMember[]>(staff);
|
|
||||||
const [fireTarget, setFireTarget] = useState<StaffMember | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [laborLoading, setLaborLoading] = useState<number | null>(null);
|
|
||||||
const [laborEdits, setLaborEdits] = useState<Record<number, LaborEdit>>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setStaffMembers(staff);
|
|
||||||
setLaborEdits({});
|
|
||||||
}, [staff]);
|
|
||||||
|
|
||||||
async function handleFire() {
|
|
||||||
if (!fireTarget) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await fireStaff(fireTarget.id);
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(`${fireTarget.npcName} fired`);
|
|
||||||
setStaffMembers((current) => current.filter((member) => member.id !== fireTarget.id));
|
|
||||||
setFireTarget(null);
|
|
||||||
onChanged?.();
|
|
||||||
} else {
|
|
||||||
toast.error(result.error ?? "Fire failed");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Fire failed");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLaborEdit(crew: LaborCrew): LaborEdit {
|
|
||||||
return (
|
|
||||||
laborEdits[crew.classificationId] ?? {
|
|
||||||
headcount: String(crew.headcount),
|
|
||||||
unitSalary: String(crew.unitSalary),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateLaborEdit(crew: LaborCrew, field: keyof LaborEdit, value: string) {
|
|
||||||
setLaborEdits((current) => {
|
|
||||||
const edit =
|
|
||||||
current[crew.classificationId] ??
|
|
||||||
({
|
|
||||||
headcount: String(crew.headcount),
|
|
||||||
unitSalary: String(crew.unitSalary),
|
|
||||||
} satisfies LaborEdit);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
[crew.classificationId]: {
|
|
||||||
...edit,
|
|
||||||
[field]: value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLaborSave(crew: LaborCrew) {
|
|
||||||
const edit = getLaborEdit(crew);
|
|
||||||
const headcount = Number(edit.headcount);
|
|
||||||
const unitSalary = Number(edit.unitSalary) || 0;
|
|
||||||
|
|
||||||
if (!Number.isInteger(headcount) || headcount < 0) {
|
|
||||||
toast.error("Headcount must be a whole number of 0 or more.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(unitSalary) || unitSalary < 0) {
|
|
||||||
toast.error("Wage cannot be negative.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLaborLoading(crew.classificationId);
|
|
||||||
try {
|
|
||||||
const result = await setLaborHeadcount(
|
|
||||||
structure.id,
|
|
||||||
crew.classificationId,
|
|
||||||
headcount,
|
|
||||||
unitSalary,
|
|
||||||
);
|
|
||||||
if (!result.success) {
|
|
||||||
toast.error(result.error ?? "Labor update failed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCrew = (member: StaffMember) =>
|
|
||||||
member.positionType === "labor" &&
|
|
||||||
member.npcName === null &&
|
|
||||||
member.laborClassificationId === crew.classificationId;
|
|
||||||
|
|
||||||
const requirement = structure.laborRequirements.find(
|
|
||||||
(entry) => entry.classificationId === crew.classificationId,
|
|
||||||
);
|
|
||||||
const effectiveUnitSalary = requirement?.enforcedSalary ?? unitSalary;
|
|
||||||
if (headcount === 0) {
|
|
||||||
setStaffMembers((current) => current.filter((member) => !isCrew(member)));
|
|
||||||
toast.success(`${crew.classificationName} crew released`);
|
|
||||||
} else {
|
|
||||||
setStaffMembers((current) => {
|
|
||||||
let keptCrew = false;
|
|
||||||
return current.flatMap((member) => {
|
|
||||||
if (!isCrew(member)) return [member];
|
|
||||||
if (keptCrew) return [];
|
|
||||||
keptCrew = true;
|
|
||||||
return [{ ...member, headcount, salary: effectiveUnitSalary }];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
toast.success(`${crew.classificationName} crew updated`);
|
|
||||||
}
|
|
||||||
setLaborEdits((current) => {
|
|
||||||
const next = { ...current };
|
|
||||||
delete next[crew.classificationId];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
onChanged?.();
|
|
||||||
} catch {
|
|
||||||
toast.error("Labor update failed");
|
|
||||||
} finally {
|
|
||||||
setLaborLoading(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const namedStaff = staffMembers.filter(
|
|
||||||
(member) => !(member.positionType === "labor" && member.npcName === null),
|
|
||||||
);
|
|
||||||
const laborMembers = staffMembers.filter(
|
|
||||||
(member) => member.positionType === "labor" && member.npcName === null,
|
|
||||||
);
|
|
||||||
const laborCrews = structure.laborRequirements.flatMap<LaborCrew>((requirement) => {
|
|
||||||
const classification = classifications.find(
|
|
||||||
(entry) => entry.id === requirement.classificationId,
|
|
||||||
);
|
|
||||||
if (!classification) return [];
|
|
||||||
const members = laborMembers.filter(
|
|
||||||
(member) => member.laborClassificationId === classification.id,
|
|
||||||
);
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
classificationId: classification.id,
|
|
||||||
classificationName: classification.name,
|
|
||||||
headcount: members.reduce((sum, member) => sum + member.headcount, 0),
|
|
||||||
unitSalary:
|
|
||||||
requirement.enforcedSalary ?? members[0]?.salary ?? classification.defaultUnitSalary ?? 0,
|
|
||||||
requiredHeadcount: requirement.requiredHeadcount,
|
|
||||||
maxHeadcount: requirement.maxHeadcount,
|
|
||||||
enforcedSalary: requirement.enforcedSalary,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
const showLaborSection =
|
|
||||||
laborMembers.length > 0 ||
|
|
||||||
structure.laborRequirements.length > 0 ||
|
|
||||||
(canManage && structure.staffPositions.length > 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<UsersIcon className="size-4 text-muted-foreground" />
|
|
||||||
Staff
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{staffMembers.length} active assignment{staffMembers.length === 1 ? "" : "s"}.
|
|
||||||
</CardDescription>
|
|
||||||
{structure.maintenanceCost > 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Maintenance: {formatAmount(structure.maintenanceCost, currencyConfig)}/tick
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{namedStaff.length === 0 && !showLaborSection ? (
|
|
||||||
<p className="text-sm text-muted-foreground">No staff assigned to this structure.</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{namedStaff.length > 0 && (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{namedStaff.map((member, index) => (
|
|
||||||
<div key={member.id}>
|
|
||||||
{index > 0 && <Separator />}
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 py-3 first:pt-0 last:pb-0">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-medium">{member.npcName}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{member.positionTitle} · {member.positionType}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="shrink-0 font-mono text-sm tabular-nums">
|
|
||||||
{formatAmount(member.salary, currencyConfig)}/tick
|
|
||||||
</span>
|
|
||||||
{canManage && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="border-destructive text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
||||||
onClick={() => setFireTarget(member)}
|
|
||||||
>
|
|
||||||
Fire
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showLaborSection && (
|
|
||||||
<>
|
|
||||||
{namedStaff.length > 0 && <Separator />}
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">Labor crew</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Classification-based workers assigned to this structure.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{canManage && hiringEnabled && (
|
|
||||||
<HireStaffDialog
|
|
||||||
structures={[structure]}
|
|
||||||
npcs={npcs}
|
|
||||||
defaultStructureId={structure.id}
|
|
||||||
canManage={canManage}
|
|
||||||
classifications={classifications}
|
|
||||||
hiringEnabled={hiringEnabled}
|
|
||||||
hiringDisabledMessage={hiringDisabledMessage}
|
|
||||||
currencyConfig={currencyConfig}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!hiringEnabled && canManage && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{hiringDisabledMessage || "Staff hiring is currently disabled."}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{laborCrews.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No labor crew — add workers via Hire Staff.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{laborCrews.map((crew, index) => {
|
|
||||||
const edit = getLaborEdit(crew);
|
|
||||||
const crewIsLoading = laborLoading === crew.classificationId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={crew.classificationId}>
|
|
||||||
{index > 0 && <Separator />}
|
|
||||||
<div className="flex flex-col gap-3 py-3 first:pt-0 last:pb-0">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="font-medium">
|
|
||||||
{crew.classificationName} — {crew.headcount} hired ·{" "}
|
|
||||||
{crew.requiredHeadcount} required · max {crew.maxHeadcount}
|
|
||||||
</p>
|
|
||||||
<p className="font-mono text-sm tabular-nums text-muted-foreground">
|
|
||||||
{formatAmount(crew.unitSalary, currencyConfig)}/tick each ·{" "}
|
|
||||||
{formatAmount(
|
|
||||||
crew.unitSalary * crew.headcount,
|
|
||||||
currencyConfig,
|
|
||||||
)}
|
|
||||||
/tick total
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canManage && (
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
|
||||||
<div className="flex flex-1 flex-col gap-1.5">
|
|
||||||
<Label htmlFor={`labor-headcount-${crew.classificationId}`}>
|
|
||||||
Headcount
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`labor-headcount-${crew.classificationId}`}
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={crew.maxHeadcount}
|
|
||||||
step={1}
|
|
||||||
inputMode="numeric"
|
|
||||||
value={edit.headcount}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateLaborEdit(crew, "headcount", event.target.value)
|
|
||||||
}
|
|
||||||
disabled={crewIsLoading || !hiringEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 flex-col gap-1.5">
|
|
||||||
<Label htmlFor={`labor-wage-${crew.classificationId}`}>
|
|
||||||
Wage per worker ({currencyLabel(currencyConfig)})
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id={`labor-wage-${crew.classificationId}`}
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step="any"
|
|
||||||
value={edit.unitSalary}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateLaborEdit(crew, "unitSalary", event.target.value)
|
|
||||||
}
|
|
||||||
disabled={
|
|
||||||
crewIsLoading ||
|
|
||||||
!hiringEnabled ||
|
|
||||||
crew.enforcedSalary !== null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void handleLaborSave(crew)}
|
|
||||||
disabled={crewIsLoading || !hiringEnabled}
|
|
||||||
>
|
|
||||||
{crewIsLoading
|
|
||||||
? "Saving..."
|
|
||||||
: edit.headcount === "0"
|
|
||||||
? "Release crew"
|
|
||||||
: "Apply"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={fireTarget !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open && !loading) setFireTarget(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Fire {fireTarget?.npcName}?</DialogTitle>
|
|
||||||
<DialogDescription>This ends their assignment.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setFireTarget(null)} disabled={loading}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant="destructive" onClick={() => void handleFire()} disabled={loading}>
|
|
||||||
{loading ? "Firing..." : "Fire staff"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { ArrowUpCircleIcon } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { upgradeStructure } from "@/app/(frontend)/logistics/base/actions";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface UpgradeCardProps {
|
|
||||||
readonly gameStructureId: number;
|
|
||||||
readonly upgrade: {
|
|
||||||
readonly targetName: string;
|
|
||||||
readonly materials: readonly {
|
|
||||||
readonly name: string;
|
|
||||||
readonly needed: number;
|
|
||||||
readonly have: number;
|
|
||||||
}[];
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UpgradeCard({ gameStructureId, upgrade }: UpgradeCardProps) {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
if (!upgrade) return null;
|
|
||||||
const upgradeData = upgrade;
|
|
||||||
|
|
||||||
async function handleUpgrade() {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await upgradeStructure(gameStructureId);
|
|
||||||
if (result.success) {
|
|
||||||
toast.success(`Upgraded to ${upgradeData.targetName}`);
|
|
||||||
} else {
|
|
||||||
toast.error(result.error ?? "Upgrade failed");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Upgrade failed");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<ArrowUpCircleIcon className="size-4 text-muted-foreground" />
|
|
||||||
Upgrade
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Advance this structure to {upgrade.targetName}.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="text-left text-xs text-muted-foreground">
|
|
||||||
<tr>
|
|
||||||
<th className="pb-2 font-medium">Material</th>
|
|
||||||
<th className="pb-2 text-right font-medium">Available / Needed</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{upgrade.materials.map((material) => (
|
|
||||||
<tr key={material.name} className="border-t border-border">
|
|
||||||
<td className="py-2.5">{material.name}</td>
|
|
||||||
<td
|
|
||||||
className={cn(
|
|
||||||
"py-2.5 text-right font-mono tabular-nums",
|
|
||||||
material.have < material.needed && "text-red-500",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{material.have.toLocaleString()} / {material.needed.toLocaleString()}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Materials are taken from this structure's own storage.
|
|
||||||
</p>
|
|
||||||
<Button size="sm" onClick={() => void handleUpgrade()} disabled={loading}>
|
|
||||||
{loading ? "Upgrading..." : `Upgrade to ${upgrade.targetName}`}
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
|
BookOpen,
|
||||||
Bolt,
|
Bolt,
|
||||||
Shield,
|
Shield,
|
||||||
Users,
|
Users,
|
||||||
|
|
@ -50,6 +51,11 @@ const data = {
|
||||||
url: "/transfers",
|
url: "/transfers",
|
||||||
icon: ArrowLeftRight,
|
icon: ArrowLeftRight,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Wiki",
|
||||||
|
url: "/wiki",
|
||||||
|
icon: BookOpen,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
...sidebarData,
|
...sidebarData,
|
||||||
};
|
};
|
||||||
|
|
@ -155,7 +161,6 @@ export function AppSidebar({
|
||||||
)}
|
)}
|
||||||
<NavCore label="Helpdesk" items={data.navHelpdesk} />
|
<NavCore label="Helpdesk" items={data.navHelpdesk} />
|
||||||
<NavCore label="Commerce" items={data.navCommerce} />
|
<NavCore label="Commerce" items={data.navCommerce} />
|
||||||
<NavCore label="Personnel" items={data.navPersonnel} />
|
|
||||||
<NavCore label="Personal" items={data.navPersonal} />
|
<NavCore label="Personal" items={data.navPersonal} />
|
||||||
{/*<NavProjects projects={data.projects} />*/}
|
{/*<NavProjects projects={data.projects} />*/}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import {
|
import {
|
||||||
Award,
|
Award,
|
||||||
Backpack,
|
Backpack,
|
||||||
Contact,
|
|
||||||
Crosshair,
|
Crosshair,
|
||||||
Kanban,
|
Kanban,
|
||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
|
|
@ -49,10 +48,6 @@ export const sidebarData = {
|
||||||
title: "Factions",
|
title: "Factions",
|
||||||
url: "/intelligence/factions",
|
url: "/intelligence/factions",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Wiki",
|
|
||||||
url: "/wiki",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -97,23 +92,6 @@ export const sidebarData = {
|
||||||
icon: ShoppingBag,
|
icon: ShoppingBag,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
navPersonnel: [
|
|
||||||
{
|
|
||||||
title: "Personnel",
|
|
||||||
url: "#",
|
|
||||||
icon: Contact,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "Contacts",
|
|
||||||
url: "/personnel/contacts",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Statistics",
|
|
||||||
url: "/personnel/statistics",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
navPersonal: [
|
navPersonal: [
|
||||||
{
|
{
|
||||||
title: "Awards",
|
title: "Awards",
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import { createMarketListing } from "@/app/(frontend)/logistics/market/actions";
|
||||||
|
|
||||||
interface CreateListingDialogProps {
|
interface CreateListingDialogProps {
|
||||||
locker: LockerStorage | null;
|
locker: LockerStorage | null;
|
||||||
currencyLabel: string;
|
currencyLabel: string | null;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -225,7 +225,7 @@ export function CreateListingDialog({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-1.5">
|
<div className="flex flex-1 flex-col gap-1.5">
|
||||||
<Label>Price per unit ({currencyLabel})</Label>
|
<Label>Price per unit ({currencyLabel ?? "units"})</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0.01}
|
min={0.01}
|
||||||
|
|
@ -254,7 +254,7 @@ export function CreateListingDialog({
|
||||||
</label>
|
</label>
|
||||||
{negotiable && (
|
{negotiable && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>Minimum price per unit ({currencyLabel})</Label>
|
<Label>Minimum price per unit ({currencyLabel ?? "units"})</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0.01}
|
min={0.01}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import {
|
||||||
} from "@/components/ui/item";
|
} from "@/components/ui/item";
|
||||||
import { getAssetTypeLabel } from "@/lib/locker";
|
import { getAssetTypeLabel } from "@/lib/locker";
|
||||||
import { getRarityLabel, isNegotiable } from "@/lib/market";
|
import { getRarityLabel, isNegotiable } from "@/lib/market";
|
||||||
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import { formatAmount } from "@/lib/banking/format";
|
||||||
import { PackageIcon, ShoppingCartIcon, XIcon } from "lucide-react";
|
import { PackageIcon, ShoppingCartIcon, XIcon } from "lucide-react";
|
||||||
import { buyMarketListing, cancelMarketListing } from "@/app/(frontend)/logistics/market/actions";
|
import { buyMarketListing, cancelMarketListing } from "@/app/(frontend)/logistics/market/actions";
|
||||||
import { Countdown } from "@/components/frontend/market/Countdown";
|
import { Countdown } from "@/components/frontend/market/Countdown";
|
||||||
|
|
@ -28,7 +28,7 @@ import { useNow } from "@/hooks/useNow";
|
||||||
interface ListingCardProps {
|
interface ListingCardProps {
|
||||||
listing: MarketListing;
|
listing: MarketListing;
|
||||||
currentUserId: number;
|
currentUserId: number;
|
||||||
currencyConfig: CurrencyConfig;
|
currencyLabel: string | null;
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,7 +54,7 @@ function sellerName(listing: MarketListing): string {
|
||||||
export function ListingCard({
|
export function ListingCard({
|
||||||
listing,
|
listing,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
currencyConfig,
|
currencyLabel,
|
||||||
canManage,
|
canManage,
|
||||||
}: ListingCardProps) {
|
}: ListingCardProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -141,7 +141,7 @@ export function ListingCard({
|
||||||
</ItemDescription>
|
</ItemDescription>
|
||||||
<div className="flex items-center justify-between gap-2 w-full">
|
<div className="flex items-center justify-between gap-2 w-full">
|
||||||
<span className="text-sm font-semibold font-mono">
|
<span className="text-sm font-semibold font-mono">
|
||||||
{formatAmount(listing.price ?? 0, currencyConfig)} / unit
|
{formatAmount(listing.price ?? 0, { name: currencyLabel ?? undefined })} / unit
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{stock > 1 && (
|
{stock > 1 && (
|
||||||
|
|
@ -149,7 +149,7 @@ export function ListingCard({
|
||||||
{selectedQty}/{stock} ·{" "}
|
{selectedQty}/{stock} ·{" "}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
total {formatAmount(selectedTotal, currencyConfig)}
|
total {formatAmount(selectedTotal, { name: currencyLabel ?? undefined })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||||
|
|
@ -188,7 +188,7 @@ export function ListingCard({
|
||||||
<MakeOfferDialog
|
<MakeOfferDialog
|
||||||
listing={listing}
|
listing={listing}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
currencyConfig={currencyConfig}
|
currencyLabel={currencyLabel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { currencyLabel, formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import { formatAmount } from "@/lib/banking/format";
|
||||||
import {
|
import {
|
||||||
acceptMarketOffer,
|
acceptMarketOffer,
|
||||||
cancelMarketOffer,
|
cancelMarketOffer,
|
||||||
|
|
@ -55,10 +55,10 @@ function outcomeOf(negotiation: MarketNegotiation | null): OfferOutcome | null {
|
||||||
interface MakeOfferDialogProps {
|
interface MakeOfferDialogProps {
|
||||||
listing: MarketListing;
|
listing: MarketListing;
|
||||||
currentUserId: number;
|
currentUserId: number;
|
||||||
currencyConfig: CurrencyConfig;
|
currencyLabel: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: MakeOfferDialogProps) {
|
export function MakeOfferDialog({ listing, currentUserId, currencyLabel }: MakeOfferDialogProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [amount, setAmount] = useState("");
|
const [amount, setAmount] = useState("");
|
||||||
|
|
@ -76,8 +76,7 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
|
||||||
const isVendor = listing.isAutoGenerated;
|
const isVendor = listing.isAutoGenerated;
|
||||||
const stock = listing.quantity ?? 1;
|
const stock = listing.quantity ?? 1;
|
||||||
const outcome = outcomeOf(negotiation);
|
const outcome = outcomeOf(negotiation);
|
||||||
const displayCurrency = currencyLabel(currencyConfig);
|
const fmt = (n: number) => formatAmount(n, { name: currencyLabel ?? undefined });
|
||||||
const fmt = (n: number) => formatAmount(n, currencyConfig);
|
|
||||||
const clampedQty = Math.max(1, Math.min(stock, qty));
|
const clampedQty = Math.max(1, Math.min(stock, qty));
|
||||||
|
|
||||||
const sinceAccept = acceptedAt != null ? nowTick - acceptedAt : 0;
|
const sinceAccept = acceptedAt != null ? nowTick - acceptedAt : 0;
|
||||||
|
|
@ -245,7 +244,7 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{isVendor && (
|
{isVendor && (
|
||||||
<NpcChat listing={listing} negotiation={negotiation} currencyLabel={displayCurrency} />
|
<NpcChat listing={listing} negotiation={negotiation} currencyLabel={currencyLabel} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loadingThread && !outcome && (
|
{loadingThread && !outcome && (
|
||||||
|
|
@ -255,7 +254,7 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
|
||||||
{!outcome && !loadingThread && (
|
{!outcome && !loadingThread && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>Your offer ({displayCurrency} per unit)</Label>
|
<Label>Your offer ({currencyLabel ?? "units"} per unit)</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0.01}
|
min={0.01}
|
||||||
|
|
@ -311,7 +310,7 @@ export function MakeOfferDialog({ listing, currentUserId, currencyConfig }: Make
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>Your counter ({displayCurrency} per unit)</Label>
|
<Label>Your counter ({currencyLabel ?? "units"} per unit)</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0.01}
|
min={0.01}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import { CreateListingDialog } from "@/components/frontend/market/CreateListingD
|
||||||
import { NegotiationPanel } from "@/components/frontend/market/NegotiationPanel";
|
import { NegotiationPanel } from "@/components/frontend/market/NegotiationPanel";
|
||||||
import { PlusIcon, SearchIcon } from "lucide-react";
|
import { PlusIcon, SearchIcon } from "lucide-react";
|
||||||
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
|
import { usePageCommands } from "@/components/command-palette/CommandPaletteContext";
|
||||||
import { currencyLabel, type CurrencyConfig } from "@/lib/banking/format";
|
|
||||||
|
|
||||||
const ASSET_TYPE_OPTIONS: { value: string; label: string }[] = [
|
const ASSET_TYPE_OPTIONS: { value: string; label: string }[] = [
|
||||||
{ value: "weapon", label: "Weapon" },
|
{ value: "weapon", label: "Weapon" },
|
||||||
|
|
@ -62,7 +61,7 @@ interface MarketViewProps {
|
||||||
negotiations: MarketNegotiation[];
|
negotiations: MarketNegotiation[];
|
||||||
locker: LockerStorage | null;
|
locker: LockerStorage | null;
|
||||||
currentUserId: number;
|
currentUserId: number;
|
||||||
currencyConfig: CurrencyConfig;
|
currencyLabel: string | null;
|
||||||
isManager: boolean;
|
isManager: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,14 +71,13 @@ export function MarketView({
|
||||||
negotiations,
|
negotiations,
|
||||||
locker,
|
locker,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
currencyConfig,
|
currencyLabel,
|
||||||
isManager,
|
isManager,
|
||||||
}: MarketViewProps) {
|
}: MarketViewProps) {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [rarity, setRarity] = useState("all");
|
const [rarity, setRarity] = useState("all");
|
||||||
const [assetType, setAssetType] = useState("all");
|
const [assetType, setAssetType] = useState("all");
|
||||||
const [createListingOpen, setCreateListingOpen] = useState(false);
|
const [createListingOpen, setCreateListingOpen] = useState(false);
|
||||||
const currencyName = currencyLabel(currencyConfig);
|
|
||||||
|
|
||||||
usePageCommands([
|
usePageCommands([
|
||||||
{
|
{
|
||||||
|
|
@ -155,7 +153,7 @@ export function MarketView({
|
||||||
</div>
|
</div>
|
||||||
<CreateListingDialog
|
<CreateListingDialog
|
||||||
locker={locker}
|
locker={locker}
|
||||||
currencyLabel={currencyName}
|
currencyLabel={currencyLabel}
|
||||||
open={createListingOpen}
|
open={createListingOpen}
|
||||||
onOpenChange={setCreateListingOpen}
|
onOpenChange={setCreateListingOpen}
|
||||||
/>
|
/>
|
||||||
|
|
@ -181,7 +179,7 @@ export function MarketView({
|
||||||
key={listing.id}
|
key={listing.id}
|
||||||
listing={listing}
|
listing={listing}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
currencyConfig={currencyConfig}
|
currencyLabel={currencyLabel}
|
||||||
canManage={isManager}
|
canManage={isManager}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -192,7 +190,7 @@ export function MarketView({
|
||||||
<NegotiationPanel
|
<NegotiationPanel
|
||||||
negotiations={negotiations}
|
negotiations={negotiations}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
currencyConfig={currencyConfig}
|
currencyLabel={currencyLabel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{myListings.length > 0 && (
|
{myListings.length > 0 && (
|
||||||
|
|
@ -209,7 +207,7 @@ export function MarketView({
|
||||||
key={listing.id}
|
key={listing.id}
|
||||||
listing={listing}
|
listing={listing}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
currencyConfig={currencyConfig}
|
currencyLabel={currencyLabel}
|
||||||
canManage={isManager}
|
canManage={isManager}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { currencyLabel, formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import { formatAmount } from "@/lib/banking/format";
|
||||||
import { buildChatBubbles } from "@/lib/market/chatBubbles";
|
import { buildChatBubbles } from "@/lib/market/chatBubbles";
|
||||||
import { ChatBubbleList } from "@/components/frontend/market/ChatBubbleList";
|
import { ChatBubbleList } from "@/components/frontend/market/ChatBubbleList";
|
||||||
import { ChevronDownIcon, HistoryIcon } from "lucide-react";
|
import { ChevronDownIcon, HistoryIcon } from "lucide-react";
|
||||||
|
|
@ -71,15 +71,15 @@ function counterpartyLabel(negotiation: MarketNegotiation, role: "buyer" | "sell
|
||||||
function HistoryRow({
|
function HistoryRow({
|
||||||
negotiation,
|
negotiation,
|
||||||
role,
|
role,
|
||||||
currencyConfig,
|
currencyLabel,
|
||||||
}: {
|
}: {
|
||||||
negotiation: MarketNegotiation;
|
negotiation: MarketNegotiation;
|
||||||
role: "buyer" | "seller";
|
role: "buyer" | "seller";
|
||||||
currencyConfig: CurrencyConfig;
|
currencyLabel: string | null;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const listing = listingOf(negotiation);
|
const listing = listingOf(negotiation);
|
||||||
const fmt = (n: number) => formatAmount(n, currencyConfig);
|
const fmt = (n: number) => formatAmount(n, { name: currencyLabel ?? undefined });
|
||||||
|
|
||||||
const buyerLabel = role === "buyer" ? "You" : counterpartyLabel(negotiation, "buyer");
|
const buyerLabel = role === "buyer" ? "You" : counterpartyLabel(negotiation, "buyer");
|
||||||
const sellerLabel = role === "seller" ? "You" : counterpartyLabel(negotiation, "seller");
|
const sellerLabel = role === "seller" ? "You" : counterpartyLabel(negotiation, "seller");
|
||||||
|
|
@ -112,7 +112,7 @@ function HistoryRow({
|
||||||
{listing && (
|
{listing && (
|
||||||
<CollapsibleContent className="border-t">
|
<CollapsibleContent className="border-t">
|
||||||
<ChatBubbleList
|
<ChatBubbleList
|
||||||
bubbles={buildChatBubbles(negotiation, listing, currencyLabel(currencyConfig))}
|
bubbles={buildChatBubbles(negotiation, listing, currencyLabel)}
|
||||||
buyerLabel={buyerLabel}
|
buyerLabel={buyerLabel}
|
||||||
sellerLabel={sellerLabel}
|
sellerLabel={sellerLabel}
|
||||||
maxHeight="max-h-64"
|
maxHeight="max-h-64"
|
||||||
|
|
@ -126,13 +126,13 @@ function HistoryRow({
|
||||||
interface NegotiationHistoryDialogProps {
|
interface NegotiationHistoryDialogProps {
|
||||||
negotiations: MarketNegotiation[];
|
negotiations: MarketNegotiation[];
|
||||||
currentUserId: number;
|
currentUserId: number;
|
||||||
currencyConfig: CurrencyConfig;
|
currencyLabel: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NegotiationHistoryDialog({
|
export function NegotiationHistoryDialog({
|
||||||
negotiations,
|
negotiations,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
currencyConfig,
|
currencyLabel,
|
||||||
}: NegotiationHistoryDialogProps) {
|
}: NegotiationHistoryDialogProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -170,7 +170,7 @@ export function NegotiationHistoryDialog({
|
||||||
key={negotiation.id}
|
key={negotiation.id}
|
||||||
negotiation={negotiation}
|
negotiation={negotiation}
|
||||||
role={role}
|
role={role}
|
||||||
currencyConfig={currencyConfig}
|
currencyLabel={currencyLabel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import type { Asset, MarketListing, MarketNegotiation } from "@/payload-types";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { formatAmount, type CurrencyConfig } from "@/lib/banking/format";
|
import { formatAmount } from "@/lib/banking/format";
|
||||||
import {
|
import {
|
||||||
acceptMarketOffer,
|
acceptMarketOffer,
|
||||||
cancelMarketOffer,
|
cancelMarketOffer,
|
||||||
|
|
@ -70,11 +70,11 @@ function counterpartyLabel(negotiation: MarketNegotiation, role: "buyer" | "sell
|
||||||
function NegotiationRow({
|
function NegotiationRow({
|
||||||
negotiation,
|
negotiation,
|
||||||
role,
|
role,
|
||||||
currencyConfig,
|
currencyLabel,
|
||||||
}: {
|
}: {
|
||||||
negotiation: MarketNegotiation;
|
negotiation: MarketNegotiation;
|
||||||
role: "buyer" | "seller";
|
role: "buyer" | "seller";
|
||||||
currencyConfig: CurrencyConfig;
|
currencyLabel: string | null;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
|
@ -87,7 +87,7 @@ function NegotiationRow({
|
||||||
const theirProposal =
|
const theirProposal =
|
||||||
(role === "buyer" && negotiation.proposedBy === "seller") ||
|
(role === "buyer" && negotiation.proposedBy === "seller") ||
|
||||||
(role === "seller" && negotiation.proposedBy === "buyer");
|
(role === "seller" && negotiation.proposedBy === "buyer");
|
||||||
const fmt = (n: number) => formatAmount(n, currencyConfig);
|
const fmt = (n: number) => formatAmount(n, { name: currencyLabel ?? undefined });
|
||||||
|
|
||||||
async function run(action: () => Promise<{ success: boolean; error?: string }>, key: string) {
|
async function run(action: () => Promise<{ success: boolean; error?: string }>, key: string) {
|
||||||
setBusy(key);
|
setBusy(key);
|
||||||
|
|
@ -116,6 +116,8 @@ function NegotiationRow({
|
||||||
void run(() => counterMarketOffer(negotiation.id, value), "counter");
|
void run(() => counterMarketOffer(negotiation.id, value), "counter");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showActions = isOpen && theirProposal;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 rounded-md border p-3">
|
<div className="flex flex-col gap-2 rounded-md border p-3">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
|
@ -219,13 +221,13 @@ function NegotiationRow({
|
||||||
interface NegotiationPanelProps {
|
interface NegotiationPanelProps {
|
||||||
negotiations: MarketNegotiation[];
|
negotiations: MarketNegotiation[];
|
||||||
currentUserId: number;
|
currentUserId: number;
|
||||||
currencyConfig: CurrencyConfig;
|
currencyLabel: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NegotiationPanel({
|
export function NegotiationPanel({
|
||||||
negotiations,
|
negotiations,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
currencyConfig,
|
currencyLabel,
|
||||||
}: NegotiationPanelProps) {
|
}: NegotiationPanelProps) {
|
||||||
const open = negotiations.filter((n) => n.status === "open");
|
const open = negotiations.filter((n) => n.status === "open");
|
||||||
const closed = negotiations.filter((n) => n.status !== "open");
|
const closed = negotiations.filter((n) => n.status !== "open");
|
||||||
|
|
@ -248,7 +250,7 @@ export function NegotiationPanel({
|
||||||
<NegotiationHistoryDialog
|
<NegotiationHistoryDialog
|
||||||
negotiations={negotiations}
|
negotiations={negotiations}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
currencyConfig={currencyConfig}
|
currencyLabel={currencyLabel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -267,7 +269,7 @@ export function NegotiationPanel({
|
||||||
key={negotiation.id}
|
key={negotiation.id}
|
||||||
negotiation={negotiation}
|
negotiation={negotiation}
|
||||||
role={role}
|
role={role}
|
||||||
currencyConfig={currencyConfig}
|
currencyLabel={currencyLabel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import type { GameNpc } from "@/payload-types";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
|
|
||||||
interface NpcCardProps {
|
|
||||||
readonly npc: GameNpc;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NpcCard({ npc }: NpcCardProps) {
|
|
||||||
const factionName =
|
|
||||||
typeof npc.faction === "object" && npc.faction !== null ? npc.faction.name : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link href={`/personnel/contacts/${npc.id}`} className="block h-full">
|
|
||||||
<Card className="h-full transition-colors hover:bg-accent/50">
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<CardTitle className="text-base">{npc.name}</CardTitle>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{npc.occupation ?? "Occupation not recorded"}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
{npc.isInGame === true && (
|
|
||||||
<Badge variant="secondary" className="border-emerald-500/30 text-emerald-500">
|
|
||||||
In Game
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{npc.isGenerated === true && (
|
|
||||||
<Badge variant="outline" className="border-amber-500/40 text-amber-500">
|
|
||||||
Generated
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{npc.vendor?.enabled === true && (
|
|
||||||
<Badge variant="outline" className="border-violet-500/40 text-violet-500">
|
|
||||||
Vendor
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col gap-2 text-sm">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-muted-foreground">Faction</span>
|
|
||||||
<span className="truncate text-right">{factionName ?? "Unaffiliated"}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { SearchIcon } from "lucide-react";
|
|
||||||
import type { GameNpc } from "@/payload-types";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { NpcCard } from "@/components/frontend/personnel/NpcCard";
|
|
||||||
|
|
||||||
interface NpcDirectoryProps {
|
|
||||||
readonly npcs: GameNpc[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NpcDirectory({ npcs }: NpcDirectoryProps) {
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [inGameOnly, setInGameOnly] = useState(false);
|
|
||||||
const [vendorsOnly, setVendorsOnly] = useState(false);
|
|
||||||
|
|
||||||
const filteredNpcs = useMemo(() => {
|
|
||||||
const query = search.trim().toLowerCase();
|
|
||||||
|
|
||||||
return [...npcs]
|
|
||||||
.filter((npc) => {
|
|
||||||
if (inGameOnly && npc.isInGame !== true) return false;
|
|
||||||
if (vendorsOnly && npc.vendor?.enabled !== true) return false;
|
|
||||||
if (
|
|
||||||
query &&
|
|
||||||
!`${npc.name} ${npc.occupation ?? ""}`.toLowerCase().includes(query)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.sort((left, right) => left.name.localeCompare(right.name));
|
|
||||||
}, [inGameOnly, npcs, search, vendorsOnly]);
|
|
||||||
|
|
||||||
const hasFilters = Boolean(search.trim()) || inGameOnly || vendorsOnly;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label htmlFor="npc-search">Search contacts</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<SearchIcon className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
id="npc-search"
|
|
||||||
aria-label="Search contacts by name or occupation"
|
|
||||||
className="w-full pl-8 sm:w-72"
|
|
||||||
placeholder="Search name or occupation..."
|
|
||||||
value={search}
|
|
||||||
onChange={(event) => setSearch(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<fieldset className="flex flex-wrap items-center gap-2">
|
|
||||||
<legend className="sr-only">Contact filters</legend>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 rounded-md border px-3 py-2",
|
|
||||||
inGameOnly && "border-primary/50 bg-primary/5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id="npc-in-game-only"
|
|
||||||
checked={inGameOnly}
|
|
||||||
onCheckedChange={(checked) => setInGameOnly(checked === true)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="npc-in-game-only" className="cursor-pointer text-sm font-normal">
|
|
||||||
In-game only
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 rounded-md border px-3 py-2",
|
|
||||||
vendorsOnly && "border-primary/50 bg-primary/5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id="npc-vendors-only"
|
|
||||||
checked={vendorsOnly}
|
|
||||||
onCheckedChange={(checked) => setVendorsOnly(checked === true)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="npc-vendors-only" className="cursor-pointer text-sm font-normal">
|
|
||||||
Vendors only
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Badge variant="outline" className="w-fit">
|
|
||||||
{filteredNpcs.length} of {npcs.length}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{filteredNpcs.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col gap-1 p-6">
|
|
||||||
<p className="font-semibold">
|
|
||||||
{npcs.length === 0
|
|
||||||
? "No contacts have been added."
|
|
||||||
: hasFilters
|
|
||||||
? "No contacts match those filters."
|
|
||||||
: "No contacts found."}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{npcs.length === 0
|
|
||||||
? "NPC records will appear here as the theater develops."
|
|
||||||
: "Try a different search or clear one of the filters."}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{filteredNpcs.map((npc) => (
|
|
||||||
<NpcCard key={npc.id} npc={npc} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,199 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Fragment } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { ArrowLeftIcon, MapPinIcon, ShieldIcon, StoreIcon } from "lucide-react";
|
|
||||||
import type { Asset, GameNpc, MarketListing } from "@/payload-types";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { formatAmount } from "@/lib/banking/format";
|
|
||||||
|
|
||||||
interface NpcProfileProps {
|
|
||||||
readonly npc: GameNpc;
|
|
||||||
readonly listings: MarketListing[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function assetName(asset: number | Asset): string {
|
|
||||||
return typeof asset === "object" && asset !== null ? asset.name : `Asset #${asset}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadges({ npc }: { npc: GameNpc }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
|
||||||
{npc.isInGame === true && (
|
|
||||||
<Badge variant="secondary" className="border-emerald-500/30 text-emerald-500">
|
|
||||||
In Game
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{npc.isGenerated === true && (
|
|
||||||
<Badge variant="outline" className="border-amber-500/40 text-amber-500">
|
|
||||||
Generated
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{npc.vendor?.enabled === true && (
|
|
||||||
<Badge variant="outline" className="border-violet-500/40 text-violet-500">
|
|
||||||
Vendor
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NpcProfile({ npc, listings }: NpcProfileProps) {
|
|
||||||
const faction = typeof npc.faction === "object" && npc.faction !== null ? npc.faction : null;
|
|
||||||
const location =
|
|
||||||
typeof npc.location === "object" && npc.location !== null ? npc.location : null;
|
|
||||||
const sellableAssets = (npc.vendor?.sells ?? []).filter(
|
|
||||||
(asset): asset is Asset => typeof asset === "object" && asset !== null,
|
|
||||||
);
|
|
||||||
const vendorEnabled = npc.vendor?.enabled === true;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6 p-5">
|
|
||||||
<Link
|
|
||||||
href="/personnel/contacts"
|
|
||||||
className="inline-flex w-fit items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="size-4" />
|
|
||||||
Back to Contacts
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<h1 className="text-lg font-semibold">{npc.name}</h1>
|
|
||||||
<StatusBadges npc={npc} />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{npc.occupation ?? "Occupation not recorded"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<CardTitle>Bio</CardTitle>
|
|
||||||
<CardDescription>Background information on this contact.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="whitespace-pre-wrap text-sm text-muted-foreground">
|
|
||||||
{npc.bio?.trim() || "No bio on file."}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<CardTitle>Assignment</CardTitle>
|
|
||||||
<CardDescription>Known affiliations and current station.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<MapPinIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
|
||||||
Stationed at
|
|
||||||
</span>
|
|
||||||
<span className="text-sm">{location?.name ?? "Location unknown"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<ShieldIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
|
||||||
Faction
|
|
||||||
</span>
|
|
||||||
<span className="text-sm">{faction?.name ?? "Unaffiliated"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
{vendorEnabled && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StoreIcon className="size-4 text-muted-foreground" />
|
|
||||||
<CardTitle>Vendor information</CardTitle>
|
|
||||||
</div>
|
|
||||||
<CardDescription>Market preferences for this contact.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center justify-between gap-3 text-sm">
|
|
||||||
<span className="text-muted-foreground">Price modifier</span>
|
|
||||||
<span className="font-mono tabular-nums">
|
|
||||||
×{(npc.vendor?.priceModifier ?? 1).toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-xs uppercase tracking-wider text-muted-foreground">
|
|
||||||
Goods they sell
|
|
||||||
</span>
|
|
||||||
{sellableAssets.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{sellableAssets.map((asset) => (
|
|
||||||
<Badge key={asset.id} variant="secondary" className="font-normal">
|
|
||||||
{asset.name}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">No goods assigned.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex-row items-start justify-between gap-3">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<CardTitle>Active Market Stock</CardTitle>
|
|
||||||
<CardDescription>{listings.length} active listing(s).</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button asChild variant="outline" size="sm">
|
|
||||||
<Link href="/logistics/market">View market</Link>
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{listings.length === 0 ? (
|
|
||||||
<p className="text-sm italic text-muted-foreground">No active stock.</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{listings.map((listing, index) => (
|
|
||||||
<Fragment key={listing.id}>
|
|
||||||
{index > 0 && <Separator />}
|
|
||||||
<div className="flex items-center justify-between gap-4 py-3 first:pt-0 last:pb-0">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-semibold">
|
|
||||||
{assetName(listing.asset)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{listing.quantity.toLocaleString()} available
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 text-sm font-semibold tabular-nums">
|
|
||||||
{formatAmount(listing.price, listing.currency)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +1,18 @@
|
||||||
import { pluralize } from "@/lib/utils";
|
interface CurrencyLike {
|
||||||
|
name?: string | null;
|
||||||
export interface CurrencyLike {
|
codeName?: string | null;
|
||||||
readonly name?: string | null;
|
|
||||||
readonly codeName?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CurrencyConfig {
|
export function currencyLabel(currency?: CurrencyLike | number | null): string {
|
||||||
readonly name?: string | null;
|
|
||||||
readonly symbol?: string | null;
|
|
||||||
readonly singular?: string | null;
|
|
||||||
readonly plural?: string | null;
|
|
||||||
readonly denomination?: string | null;
|
|
||||||
readonly resourceName?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type CurrencyDisplay = CurrencyLike | CurrencyConfig | number | null;
|
|
||||||
|
|
||||||
function isCurrencyConfig(currency: CurrencyLike | CurrencyConfig): currency is CurrencyConfig {
|
|
||||||
return (
|
|
||||||
"symbol" in currency ||
|
|
||||||
"singular" in currency ||
|
|
||||||
"plural" in currency ||
|
|
||||||
"denomination" in currency ||
|
|
||||||
"resourceName" in currency
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function currencyLabel(currency?: CurrencyDisplay, amount = 2): string {
|
|
||||||
if (typeof currency === "number") return "";
|
if (typeof currency === "number") return "";
|
||||||
if (!currency) return "units";
|
if (!currency) return "units";
|
||||||
if (!isCurrencyConfig(currency)) return currency.name || currency.codeName || "units";
|
return currency.name || currency.codeName || "units";
|
||||||
|
|
||||||
const configuredName = currency.name?.trim();
|
|
||||||
const singular =
|
|
||||||
currency.singular?.trim() || configuredName || currency.resourceName?.trim() || "unit";
|
|
||||||
if (amount === 1) return singular;
|
|
||||||
return (
|
|
||||||
currency.plural?.trim() ||
|
|
||||||
(currency.singular?.trim() || configuredName
|
|
||||||
? pluralize(amount, currency.singular?.trim() || configuredName || singular)
|
|
||||||
: currency.resourceName?.trim()) ||
|
|
||||||
"units"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAmount(amount: number, currency?: CurrencyDisplay): string {
|
export function formatAmount(amount: number, currency?: CurrencyLike | number | null): string {
|
||||||
const n = amount.toLocaleString();
|
const n = amount.toLocaleString();
|
||||||
const label = currencyLabel(currency, amount);
|
const label = currencyLabel(currency);
|
||||||
const symbol =
|
return label ? `${n} ${label}` : n;
|
||||||
currency && typeof currency === "object" && isCurrencyConfig(currency)
|
|
||||||
? currency.symbol?.trim()
|
|
||||||
: undefined;
|
|
||||||
const formatted = symbol ? `${symbol}${n}` : n;
|
|
||||||
return label ? `${formatted} ${label}` : formatted;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(date: string | Date): string {
|
export function formatDate(date: string | Date): string {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
import type { PayloadRequest } from "payload";
|
import type { PayloadRequest } from "payload";
|
||||||
import type { BankAccount, BankTransaction, GameRule } from "@/payload-types";
|
import type { BankAccount, BankTransaction } from "@/payload-types";
|
||||||
import { currencyLabel, type CurrencyConfig } from "./format";
|
|
||||||
|
|
||||||
export type BankAccountType = "treasury" | "faction" | "personal";
|
export type BankAccountType = "treasury" | "faction" | "personal";
|
||||||
|
|
||||||
export type BankTransactionType =
|
export type BankTransactionType =
|
||||||
"deposit" | "withdrawal" | "transfer" | "payment" | "fee" | "salary" | "adjustment";
|
| "deposit"
|
||||||
|
| "withdrawal"
|
||||||
|
| "transfer"
|
||||||
|
| "payment"
|
||||||
|
| "fee"
|
||||||
|
| "salary"
|
||||||
|
| "adjustment";
|
||||||
|
|
||||||
export interface ApplyTransactionInput {
|
export interface ApplyTransactionInput {
|
||||||
type: BankTransactionType;
|
type: BankTransactionType;
|
||||||
|
|
@ -42,46 +47,17 @@ export async function getMainCurrencyId(payload: PayloadType): Promise<number |
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMainCurrencyName(payload: PayloadType): Promise<string | null> {
|
export async function getMainCurrencyName(payload: PayloadType): Promise<string | null> {
|
||||||
|
const currencyId = await getMainCurrencyId(payload);
|
||||||
|
if (!currencyId) return null;
|
||||||
try {
|
try {
|
||||||
return currencyLabel(await getCurrencyConfig(payload));
|
const resource = (await payload.findByID({
|
||||||
} catch {
|
collection: "resources",
|
||||||
return null;
|
id: currencyId,
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function currencyConfigFromGameRules(rules: GameRule): CurrencyConfig {
|
|
||||||
const resource = typeof rules.mainCurrency === "object" ? rules.mainCurrency : null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: rules.currencyName?.trim() || null,
|
|
||||||
symbol: rules.currencySymbol?.trim() || null,
|
|
||||||
singular: rules.currencySingular?.trim() || null,
|
|
||||||
plural: rules.currencyPlural?.trim() || null,
|
|
||||||
denomination: rules.currencyDenomination?.trim() || null,
|
|
||||||
resourceName: resource?.name || resource?.codeName || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getCurrencyConfig(payload: PayloadType): Promise<CurrencyConfig> {
|
|
||||||
const rules = await payload.findGlobal({
|
|
||||||
slug: "game-rules",
|
|
||||||
depth: 1,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
return currencyConfigFromGameRules(rules);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve the id of the open unit treasury account, if any. */
|
|
||||||
export async function getTreasuryAccountId(payload: PayloadType): Promise<number | null> {
|
|
||||||
try {
|
|
||||||
const result = (await payload.find({
|
|
||||||
collection: "bank-accounts",
|
|
||||||
where: { and: [{ accountType: { equals: "treasury" } }, { status: { equals: "open" } }] },
|
|
||||||
limit: 1,
|
|
||||||
depth: 0,
|
depth: 0,
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
})) as unknown as { docs: { id: number }[] };
|
})) as unknown as { name?: string | null; codeName?: string | null } | null;
|
||||||
return result.docs[0]?.id ?? null;
|
if (!resource) return null;
|
||||||
|
return resource.name || resource.codeName || null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
export { upgradeStructure } from "./upgrade";
|
|
||||||
export { assertStaffHiringEnabled, fireStaff, hireStaff, setLaborHeadcount } from "./staffing";
|
|
||||||
export { processBaseTick } from "./tick";
|
|
||||||
export type {
|
|
||||||
BaseTickResult,
|
|
||||||
HireStaffInput,
|
|
||||||
LaborClassificationOption,
|
|
||||||
SetLaborHeadcountInput,
|
|
||||||
StaffingPositionType,
|
|
||||||
StructureStaffingOption,
|
|
||||||
} from "./types";
|
|
||||||
|
|
@ -1,337 +0,0 @@
|
||||||
import type { Payload } from "payload";
|
|
||||||
import type { NpcStaffing } from "@/payload-types";
|
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
|
||||||
import { relationshipId } from "./storage";
|
|
||||||
import type { HireStaffInput, SetLaborHeadcountInput } from "./types";
|
|
||||||
|
|
||||||
export async function assertStaffHiringEnabled(payload: Payload): Promise<void> {
|
|
||||||
const rules = await payload.findGlobal({
|
|
||||||
slug: "game-rules",
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
if (!rules.staffHiringEnabled) {
|
|
||||||
throw new Error(
|
|
||||||
rules.staffHiringDisabledMessage?.trim() || "Staff hiring is currently disabled.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function staffingPositionType(value: string): "administrative" | "technical" | "other" {
|
|
||||||
switch (value) {
|
|
||||||
case "administrative":
|
|
||||||
case "technical":
|
|
||||||
case "other":
|
|
||||||
return value;
|
|
||||||
default:
|
|
||||||
throw new Error("Invalid staff position type.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hireStaff(
|
|
||||||
payload: Payload,
|
|
||||||
input: HireStaffInput,
|
|
||||||
actorUserId?: number,
|
|
||||||
): Promise<NpcStaffing> {
|
|
||||||
await assertStaffHiringEnabled(payload);
|
|
||||||
if (input.positionType === "labor") {
|
|
||||||
throw new Error("Basic labor is tracked by headcount — use the labor count control instead.");
|
|
||||||
}
|
|
||||||
const positionType = staffingPositionType(input.positionType);
|
|
||||||
const npc = await payload
|
|
||||||
.findByID({ collection: "game-npcs", id: input.npcId, depth: 0, overrideAccess: true })
|
|
||||||
.catch(() => null);
|
|
||||||
if (!npc) throw new Error("NPC not found.");
|
|
||||||
const structure = await payload
|
|
||||||
.findByID({
|
|
||||||
collection: "game-structures",
|
|
||||||
id: input.structureId,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
})
|
|
||||||
.catch(() => null);
|
|
||||||
if (!structure) throw new Error("Structure not found.");
|
|
||||||
|
|
||||||
const positionRule = structure.staffPositions?.find(
|
|
||||||
(position) => position.positionType === positionType,
|
|
||||||
);
|
|
||||||
if (!positionRule) {
|
|
||||||
throw new Error(`This structure does not accept ${positionType} staff.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (positionRule.max !== null && positionRule.max !== undefined) {
|
|
||||||
const activeAtStructure = await payload.find({
|
|
||||||
collection: "npc-staffing",
|
|
||||||
where: {
|
|
||||||
and: [
|
|
||||||
{ structure: { equals: structure.id } },
|
|
||||||
{ positionType: { equals: positionType } },
|
|
||||||
{ npc: { exists: true } },
|
|
||||||
{ status: { equals: "active" } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
limit: 0,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
if (activeAtStructure.totalDocs >= positionRule.max) {
|
|
||||||
throw new Error(`This structure has reached its ${positionType} staff limit.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await payload.find({
|
|
||||||
collection: "npc-staffing",
|
|
||||||
where: { and: [{ npc: { equals: npc.id } }, { status: { equals: "active" } }] },
|
|
||||||
limit: 1,
|
|
||||||
depth: 1,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
if (existing.docs[0]) {
|
|
||||||
const assignment = existing.docs[0];
|
|
||||||
const assignedStructure = assignment.structure;
|
|
||||||
const structureName =
|
|
||||||
typeof assignedStructure === "object"
|
|
||||||
? assignedStructure.name
|
|
||||||
: `structure #${assignedStructure}`;
|
|
||||||
throw new Error(
|
|
||||||
`${npc.name} is already employed as ${assignment.positionTitle} at ${structureName}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const staffing = await payload.create({
|
|
||||||
collection: "npc-staffing",
|
|
||||||
data: {
|
|
||||||
npc: npc.id,
|
|
||||||
structure: structure.id,
|
|
||||||
positionType,
|
|
||||||
positionTitle: input.positionTitle,
|
|
||||||
salary: positionRule.enforcedSalary ?? input.salary,
|
|
||||||
headcount: 1,
|
|
||||||
status: "active",
|
|
||||||
hiredAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
await payload.update({
|
|
||||||
collection: "game-npcs",
|
|
||||||
id: npc.id,
|
|
||||||
data: { location: structure.id, isInGame: true },
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
await emitGameEvent(payload, {
|
|
||||||
type: EventTypes.StaffHired,
|
|
||||||
message: `${npc.name} hired as ${staffing.positionTitle} at ${structure.name}`,
|
|
||||||
actor: actorUserId,
|
|
||||||
structure: structure.id,
|
|
||||||
targetCollection: "npc-staffing",
|
|
||||||
targetId: staffing.id,
|
|
||||||
data: { salary: staffing.salary ?? 0, npcId: npc.id },
|
|
||||||
});
|
|
||||||
return staffing;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setLaborHeadcount(
|
|
||||||
payload: Payload,
|
|
||||||
input: SetLaborHeadcountInput,
|
|
||||||
): Promise<{ readonly id: number | null }> {
|
|
||||||
await assertStaffHiringEnabled(payload);
|
|
||||||
if (!Number.isInteger(input.headcount) || input.headcount < 0) {
|
|
||||||
throw new Error("Headcount must be a whole number of 0 or more.");
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(input.unitSalary) || input.unitSalary < 0) {
|
|
||||||
throw new Error("Wage cannot be negative.");
|
|
||||||
}
|
|
||||||
const classification = await payload
|
|
||||||
.findByID({
|
|
||||||
collection: "labor-classifications",
|
|
||||||
id: input.classificationId,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
})
|
|
||||||
.catch(() => null);
|
|
||||||
if (!classification) throw new Error("Unknown labor classification.");
|
|
||||||
const structure = await payload
|
|
||||||
.findByID({
|
|
||||||
collection: "game-structures",
|
|
||||||
id: input.structureId,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
})
|
|
||||||
.catch(() => null);
|
|
||||||
if (!structure) throw new Error("Structure not found.");
|
|
||||||
|
|
||||||
const requirement = structure.laborRequirements?.find(
|
|
||||||
(entry) => relationshipId(entry.classification) === classification.id,
|
|
||||||
);
|
|
||||||
if (!requirement) {
|
|
||||||
throw new Error(`This structure does not accept ${classification.name} crews.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await payload.find({
|
|
||||||
collection: "npc-staffing",
|
|
||||||
where: {
|
|
||||||
and: [
|
|
||||||
{ structure: { equals: structure.id } },
|
|
||||||
{ positionType: { equals: "labor" } },
|
|
||||||
{ laborClassification: { equals: classification.id } },
|
|
||||||
{ status: { equals: "active" } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
limit: 1,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
const staffing = existing.docs[0];
|
|
||||||
const classificationLabel = classification.name;
|
|
||||||
const currentHeadcount = staffing?.headcount ?? 0;
|
|
||||||
const maxHeadcount = requirement.maxHeadcount ?? 0;
|
|
||||||
const unitSalary = requirement.enforcedSalary ?? input.unitSalary;
|
|
||||||
|
|
||||||
if (input.headcount > maxHeadcount) {
|
|
||||||
throw new Error(
|
|
||||||
`${classificationLabel} crews are capped at ${maxHeadcount} for this structure.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const headcountDelta = input.headcount - currentHeadcount;
|
|
||||||
const availableHeadcount = classification.availableHeadcount ?? 0;
|
|
||||||
const marketRate = classification.defaultUnitSalary ?? 0;
|
|
||||||
const wageFactor = marketRate > 0 ? Math.min(1, unitSalary / marketRate) : 1;
|
|
||||||
const effectiveAvailable = Math.floor(availableHeadcount * wageFactor);
|
|
||||||
if (headcountDelta > effectiveAvailable) {
|
|
||||||
throw new Error(
|
|
||||||
`Only ${effectiveAvailable} ${classificationLabel} workers are available at this wage.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.headcount <= 0) {
|
|
||||||
if (staffing) {
|
|
||||||
await payload.update({
|
|
||||||
collection: "npc-staffing",
|
|
||||||
id: staffing.id,
|
|
||||||
data: { status: "terminated", terminatedAt: new Date().toISOString() },
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
await payload.update({
|
|
||||||
collection: "labor-classifications",
|
|
||||||
id: classification.id,
|
|
||||||
data: { availableHeadcount: availableHeadcount + currentHeadcount },
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
await emitGameEvent(payload, {
|
|
||||||
type: EventTypes.StaffTerminated,
|
|
||||||
message: `${classificationLabel} crew released at ${structure.name}`,
|
|
||||||
structure: structure.id,
|
|
||||||
targetCollection: "npc-staffing",
|
|
||||||
targetId: staffing.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { id: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
npc: null,
|
|
||||||
structure: structure.id,
|
|
||||||
positionType: "labor" as const,
|
|
||||||
laborClassification: classification.id,
|
|
||||||
positionTitle: `${classificationLabel} Labor`,
|
|
||||||
salary: unitSalary,
|
|
||||||
headcount: input.headcount,
|
|
||||||
status: "active" as const,
|
|
||||||
};
|
|
||||||
const updated = staffing
|
|
||||||
? await payload.update({
|
|
||||||
collection: "npc-staffing",
|
|
||||||
id: staffing.id,
|
|
||||||
data,
|
|
||||||
overrideAccess: true,
|
|
||||||
})
|
|
||||||
: await payload.create({
|
|
||||||
collection: "npc-staffing",
|
|
||||||
data: { ...data, hiredAt: new Date().toISOString() },
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
if (headcountDelta !== 0) {
|
|
||||||
await payload.update({
|
|
||||||
collection: "labor-classifications",
|
|
||||||
id: classification.id,
|
|
||||||
data: { availableHeadcount: availableHeadcount - headcountDelta },
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await emitGameEvent(payload, {
|
|
||||||
type: EventTypes.StaffHeadcountSet,
|
|
||||||
message: `${classificationLabel} crew set to ${input.headcount} at ${structure.name} ($${unitSalary}/worker per tick)`,
|
|
||||||
structure: structure.id,
|
|
||||||
targetCollection: "npc-staffing",
|
|
||||||
targetId: updated.id,
|
|
||||||
data: {
|
|
||||||
classification: classification.key,
|
|
||||||
headcount: input.headcount,
|
|
||||||
unitSalary,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return { id: updated.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fireStaff(
|
|
||||||
payload: Payload,
|
|
||||||
staffingId: number,
|
|
||||||
actorUserId?: number,
|
|
||||||
): Promise<NpcStaffing> {
|
|
||||||
const staffing = await payload
|
|
||||||
.findByID({ collection: "npc-staffing", id: staffingId, depth: 1, overrideAccess: true })
|
|
||||||
.catch(() => null);
|
|
||||||
if (!staffing) throw new Error("Staff assignment not found.");
|
|
||||||
if (staffing.status !== "active") {
|
|
||||||
throw new Error("This staff assignment is already terminated.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const npcId = relationshipId(staffing.npc);
|
|
||||||
const structureId = relationshipId(staffing.structure);
|
|
||||||
const laborClassificationId = relationshipId(staffing.laborClassification);
|
|
||||||
const npcName =
|
|
||||||
typeof staffing.npc === "object" && staffing.npc !== null
|
|
||||||
? staffing.npc.name
|
|
||||||
: staffing.positionTitle;
|
|
||||||
const updated = await payload.update({
|
|
||||||
collection: "npc-staffing",
|
|
||||||
id: staffing.id,
|
|
||||||
data: { status: "terminated", terminatedAt: new Date().toISOString() },
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
if (npcId !== null) {
|
|
||||||
await payload.update({
|
|
||||||
collection: "game-npcs",
|
|
||||||
id: npcId,
|
|
||||||
data: { location: null, isInGame: false },
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (npcId === null && laborClassificationId !== null) {
|
|
||||||
const classification = await payload.findByID({
|
|
||||||
collection: "labor-classifications",
|
|
||||||
id: laborClassificationId,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
await payload.update({
|
|
||||||
collection: "labor-classifications",
|
|
||||||
id: classification.id,
|
|
||||||
data: {
|
|
||||||
availableHeadcount: (classification.availableHeadcount ?? 0) + (staffing.headcount ?? 1),
|
|
||||||
},
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await emitGameEvent(payload, {
|
|
||||||
type: EventTypes.StaffTerminated,
|
|
||||||
message: `${npcName}'s assignment as ${staffing.positionTitle} was terminated`,
|
|
||||||
actor: actorUserId,
|
|
||||||
structure: structureId ?? undefined,
|
|
||||||
targetCollection: "npc-staffing",
|
|
||||||
targetId: staffing.id,
|
|
||||||
});
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import type { Structure } from "@/payload-types";
|
|
||||||
|
|
||||||
export type StaffingDefaults = {
|
|
||||||
readonly maintenanceCost: number;
|
|
||||||
readonly laborRequirements: NonNullable<Structure["laborRequirements"]>;
|
|
||||||
readonly staffPositions: NonNullable<Structure["staffPositions"]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function staffingDefaultsFromBlueprint(blueprint: Structure): StaffingDefaults {
|
|
||||||
return {
|
|
||||||
maintenanceCost: blueprint.maintenanceCost ?? 0,
|
|
||||||
laborRequirements: (blueprint.laborRequirements ?? []).map((requirement) => ({
|
|
||||||
classification:
|
|
||||||
typeof requirement.classification === "object"
|
|
||||||
? requirement.classification.id
|
|
||||||
: requirement.classification,
|
|
||||||
requiredHeadcount: requirement.requiredHeadcount,
|
|
||||||
maxHeadcount: requirement.maxHeadcount ?? null,
|
|
||||||
enforcedSalary: requirement.enforcedSalary ?? null,
|
|
||||||
})),
|
|
||||||
staffPositions: (blueprint.staffPositions ?? []).map((position) => ({
|
|
||||||
positionType: position.positionType,
|
|
||||||
max: position.max ?? null,
|
|
||||||
enforcedSalary: position.enforcedSalary ?? null,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import type { Payload } from "payload";
|
|
||||||
import type { GameStructure, Resource } from "@/payload-types";
|
|
||||||
|
|
||||||
type StorageEntry = NonNullable<GameStructure["storedResources"]>[number];
|
|
||||||
|
|
||||||
export function relationshipId(
|
|
||||||
value: number | { id: number } | null | undefined,
|
|
||||||
): number | null {
|
|
||||||
if (typeof value === "number") return value;
|
|
||||||
return value?.id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function storedAmount(entries: readonly StorageEntry[], resourceId: number): number {
|
|
||||||
return entries.reduce((total, entry) => {
|
|
||||||
return total + (relationshipId(entry.resource) === resourceId ? entry.amount : 0);
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function consumeStorage(
|
|
||||||
entries: readonly StorageEntry[],
|
|
||||||
resourceId: number,
|
|
||||||
requested: number,
|
|
||||||
): { readonly entries: StorageEntry[]; readonly remaining: number } {
|
|
||||||
let remaining = requested;
|
|
||||||
const updated = entries
|
|
||||||
.map((entry) => {
|
|
||||||
if (remaining <= 0 || relationshipId(entry.resource) !== resourceId) return entry;
|
|
||||||
const consumed = Math.min(entry.amount, remaining);
|
|
||||||
remaining -= consumed;
|
|
||||||
return { ...entry, amount: entry.amount - consumed };
|
|
||||||
})
|
|
||||||
.filter((entry) => entry.amount > 0);
|
|
||||||
return { entries: updated, remaining };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resourceName(payload: Payload, resource: number | Resource): Promise<string> {
|
|
||||||
if (typeof resource === "object") return resource.name;
|
|
||||||
const document = await payload
|
|
||||||
.findByID({ collection: "resources", id: resource, depth: 0, overrideAccess: true })
|
|
||||||
.catch(() => null);
|
|
||||||
return document?.name ?? `Resource #${resource}`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
import type { Payload } from "payload";
|
|
||||||
import { applyTransaction, getTreasuryAccountId } from "@/lib/banking";
|
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
|
||||||
import { consumeStorage, relationshipId, resourceName, storedAmount } from "./storage";
|
|
||||||
import type { BaseTickResult } from "./types";
|
|
||||||
|
|
||||||
async function processSalaries(
|
|
||||||
payload: Payload,
|
|
||||||
): Promise<Pick<BaseTickResult, "salariesPaid" | "salariesUnpaid">> {
|
|
||||||
const treasuryId = await getTreasuryAccountId(payload);
|
|
||||||
if (treasuryId === null) {
|
|
||||||
payload.logger.warn("[Base Tick] No open treasury account; skipping staff salaries.");
|
|
||||||
return { salariesPaid: 0, salariesUnpaid: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeStaff = await payload.find({
|
|
||||||
collection: "npc-staffing",
|
|
||||||
where: { and: [{ status: { equals: "active" } }, { salary: { greater_than: 0 } }] },
|
|
||||||
limit: 500,
|
|
||||||
depth: 1,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
let salariesPaid = 0;
|
|
||||||
let salariesUnpaid = 0;
|
|
||||||
for (const staffing of activeStaff.docs) {
|
|
||||||
const unitSalary = staffing.salary ?? 0;
|
|
||||||
const headcount = staffing.headcount ?? 1;
|
|
||||||
const salary = unitSalary * headcount;
|
|
||||||
const isLabor = staffing.positionType === "labor";
|
|
||||||
const classification =
|
|
||||||
typeof staffing.laborClassification === "object" && staffing.laborClassification !== null
|
|
||||||
? staffing.laborClassification.name
|
|
||||||
: null;
|
|
||||||
const classificationLabel = classification ?? "Unclassified";
|
|
||||||
const npcName =
|
|
||||||
typeof staffing.npc === "object" && staffing.npc !== null
|
|
||||||
? staffing.npc.name
|
|
||||||
: staffing.positionTitle;
|
|
||||||
const memo = isLabor
|
|
||||||
? `Labor: ${headcount} ${classificationLabel}(s) @ ${unitSalary}`
|
|
||||||
: `Salary: ${staffing.positionTitle} — ${npcName}`;
|
|
||||||
try {
|
|
||||||
await applyTransaction(payload, {
|
|
||||||
type: "salary",
|
|
||||||
fromAccountId: treasuryId,
|
|
||||||
amount: salary,
|
|
||||||
memo,
|
|
||||||
});
|
|
||||||
await emitGameEvent(payload, {
|
|
||||||
type: EventTypes.SalaryPaid,
|
|
||||||
message: `Paid ${salary} salary to ${npcName}`,
|
|
||||||
structure: relationshipId(staffing.structure) ?? undefined,
|
|
||||||
targetCollection: "npc-staffing",
|
|
||||||
targetId: staffing.id,
|
|
||||||
data: { amount: salary, headcount, unitSalary },
|
|
||||||
});
|
|
||||||
salariesPaid += 1;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
await emitGameEvent(payload, {
|
|
||||||
type: EventTypes.SalaryUnpaid,
|
|
||||||
message: `Salary ${salary} unpaid for ${npcName}: ${message}`,
|
|
||||||
structure: relationshipId(staffing.structure) ?? undefined,
|
|
||||||
targetCollection: "npc-staffing",
|
|
||||||
targetId: staffing.id,
|
|
||||||
data: { amount: salary, headcount, unitSalary, error: message },
|
|
||||||
});
|
|
||||||
salariesUnpaid += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { salariesPaid, salariesUnpaid };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processUpkeep(payload: Payload): Promise<number> {
|
|
||||||
const structures = await payload.find({
|
|
||||||
collection: "game-structures",
|
|
||||||
limit: 500,
|
|
||||||
depth: 1,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
let upkeepShortages = 0;
|
|
||||||
|
|
||||||
for (const structure of structures.docs) {
|
|
||||||
if (typeof structure.type !== "object" || !structure.type.upkeep?.length) continue;
|
|
||||||
let storedResources = [...(structure.storedResources ?? [])];
|
|
||||||
let voidStorage = [...(structure.voidStorage ?? [])];
|
|
||||||
let consumedAny = false;
|
|
||||||
|
|
||||||
for (const upkeep of structure.type.upkeep) {
|
|
||||||
const resourceId = relationshipId(upkeep.resource);
|
|
||||||
const needed = upkeep.amount ?? 0;
|
|
||||||
if (resourceId === null || needed <= 0) continue;
|
|
||||||
const available =
|
|
||||||
storedAmount(storedResources, resourceId) + storedAmount(voidStorage, resourceId);
|
|
||||||
if (available < needed) {
|
|
||||||
const name = await resourceName(payload, upkeep.resource ?? resourceId);
|
|
||||||
await emitGameEvent(payload, {
|
|
||||||
type: EventTypes.UpkeepShort,
|
|
||||||
message: `Structure "${structure.name}" short on upkeep: ${name} (have ${available}, need ${needed})`,
|
|
||||||
structure: structure.id,
|
|
||||||
targetCollection: "game-structures",
|
|
||||||
targetId: structure.id,
|
|
||||||
});
|
|
||||||
upkeepShortages += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const amountToConsume = Math.min(available, needed);
|
|
||||||
const storedResult = consumeStorage(storedResources, resourceId, amountToConsume);
|
|
||||||
storedResources = storedResult.entries;
|
|
||||||
voidStorage = consumeStorage(voidStorage, resourceId, storedResult.remaining).entries;
|
|
||||||
consumedAny ||= amountToConsume > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (consumedAny) {
|
|
||||||
await payload.update({
|
|
||||||
collection: "game-structures",
|
|
||||||
id: structure.id,
|
|
||||||
data: { storedResources, voidStorage },
|
|
||||||
context: { skipGridRepack: true },
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return upkeepShortages;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processMaintenance(
|
|
||||||
payload: Payload,
|
|
||||||
): Promise<Pick<BaseTickResult, "maintenancePaid" | "maintenanceUnpaid">> {
|
|
||||||
const treasuryId = await getTreasuryAccountId(payload);
|
|
||||||
if (treasuryId === null) {
|
|
||||||
payload.logger.warn("[Base Tick] No open treasury account; skipping structure maintenance.");
|
|
||||||
return { maintenancePaid: 0, maintenanceUnpaid: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const structures = await payload.find({
|
|
||||||
collection: "game-structures",
|
|
||||||
where: { maintenanceCost: { greater_than: 0 } },
|
|
||||||
limit: 500,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
let maintenancePaid = 0;
|
|
||||||
let maintenanceUnpaid = 0;
|
|
||||||
|
|
||||||
for (const structure of structures.docs) {
|
|
||||||
const amount = structure.maintenanceCost ?? 0;
|
|
||||||
if (amount <= 0) continue;
|
|
||||||
try {
|
|
||||||
await applyTransaction(payload, {
|
|
||||||
type: "fee",
|
|
||||||
fromAccountId: treasuryId,
|
|
||||||
amount,
|
|
||||||
memo: `Structure maintenance: ${structure.name}`,
|
|
||||||
});
|
|
||||||
await emitGameEvent(payload, {
|
|
||||||
type: EventTypes.MaintenancePaid,
|
|
||||||
message: `Paid ${amount} maintenance for ${structure.name}`,
|
|
||||||
structure: structure.id,
|
|
||||||
targetCollection: "game-structures",
|
|
||||||
targetId: structure.id,
|
|
||||||
data: { amount },
|
|
||||||
});
|
|
||||||
maintenancePaid += 1;
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Unknown error";
|
|
||||||
await emitGameEvent(payload, {
|
|
||||||
type: EventTypes.MaintenanceUnpaid,
|
|
||||||
message: `Maintenance ${amount} unpaid for ${structure.name}: ${message}`,
|
|
||||||
structure: structure.id,
|
|
||||||
targetCollection: "game-structures",
|
|
||||||
targetId: structure.id,
|
|
||||||
data: { amount, error: message },
|
|
||||||
});
|
|
||||||
maintenanceUnpaid += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { maintenancePaid, maintenanceUnpaid };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function processBaseTick(payload: Payload): Promise<BaseTickResult> {
|
|
||||||
const salaries = await processSalaries(payload);
|
|
||||||
const maintenance = await processMaintenance(payload);
|
|
||||||
const upkeepShortages = await processUpkeep(payload);
|
|
||||||
return { ...salaries, ...maintenance, upkeepShortages };
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
export type HireStaffInput = {
|
|
||||||
readonly npcId: number;
|
|
||||||
readonly structureId: number;
|
|
||||||
readonly positionType: string;
|
|
||||||
readonly positionTitle: string;
|
|
||||||
readonly salary: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type LaborClassificationOption = {
|
|
||||||
readonly id: number;
|
|
||||||
readonly name: string;
|
|
||||||
readonly key: string;
|
|
||||||
readonly defaultUnitSalary: number | null;
|
|
||||||
readonly availableHeadcount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type StaffingPositionType = "administrative" | "technical" | "other";
|
|
||||||
|
|
||||||
export type StructureStaffingOption = {
|
|
||||||
readonly id: number;
|
|
||||||
readonly name: string;
|
|
||||||
readonly maintenanceCost: number;
|
|
||||||
readonly laborRequirements: readonly {
|
|
||||||
readonly classificationId: number;
|
|
||||||
readonly requiredHeadcount: number;
|
|
||||||
readonly maxHeadcount: number;
|
|
||||||
readonly enforcedSalary: number | null;
|
|
||||||
readonly currentHeadcount: number;
|
|
||||||
}[];
|
|
||||||
readonly staffPositions: readonly {
|
|
||||||
readonly positionType: StaffingPositionType;
|
|
||||||
readonly max: number | null;
|
|
||||||
readonly enforcedSalary: number | null;
|
|
||||||
readonly currentHeadcount: number;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SetLaborHeadcountInput = {
|
|
||||||
readonly structureId: number;
|
|
||||||
readonly classificationId: number;
|
|
||||||
readonly headcount: number;
|
|
||||||
readonly unitSalary: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BaseTickResult = {
|
|
||||||
readonly salariesPaid: number;
|
|
||||||
readonly salariesUnpaid: number;
|
|
||||||
readonly maintenancePaid: number;
|
|
||||||
readonly maintenanceUnpaid: number;
|
|
||||||
readonly upkeepShortages: number;
|
|
||||||
};
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import type { Payload } from "payload";
|
|
||||||
import type { GameStructure, Structure } from "@/payload-types";
|
|
||||||
import { emitGameEvent } from "@/utils/event-log/emit";
|
|
||||||
import { EventTypes } from "@/utils/event-log/eventTypes";
|
|
||||||
import { staffingDefaultsFromBlueprint } from "./staffingDefaults";
|
|
||||||
import { consumeStorage, relationshipId, resourceName, storedAmount } from "./storage";
|
|
||||||
|
|
||||||
async function structureBlueprint(payload: Payload, source: GameStructure): Promise<Structure> {
|
|
||||||
if (typeof source.type === "object") return source.type;
|
|
||||||
return payload.findByID({
|
|
||||||
collection: "structures",
|
|
||||||
id: source.type,
|
|
||||||
depth: 2,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upgradeStructure(
|
|
||||||
payload: Payload,
|
|
||||||
gameStructureId: number,
|
|
||||||
): Promise<GameStructure> {
|
|
||||||
const gameStructure = await payload.findByID({
|
|
||||||
collection: "game-structures",
|
|
||||||
id: gameStructureId,
|
|
||||||
depth: 2,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
const source = await structureBlueprint(payload, gameStructure);
|
|
||||||
const targetReference = source.upgradesInto;
|
|
||||||
if (!targetReference) throw new Error("This structure has no upgrade path.");
|
|
||||||
|
|
||||||
const target =
|
|
||||||
typeof targetReference === "object"
|
|
||||||
? targetReference
|
|
||||||
: await payload.findByID({
|
|
||||||
collection: "structures",
|
|
||||||
id: targetReference,
|
|
||||||
depth: 2,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
const multiplier = source.upgradeCostMultiplier ?? 1;
|
|
||||||
const requirements = new Map<number, { needed: number; name: string }>();
|
|
||||||
|
|
||||||
for (const material of target.materials) {
|
|
||||||
const resourceId = relationshipId(material.resource);
|
|
||||||
if (resourceId === null) continue;
|
|
||||||
const current = requirements.get(resourceId);
|
|
||||||
requirements.set(resourceId, {
|
|
||||||
needed: (current?.needed ?? 0) + Math.ceil((material.amount ?? 0) * multiplier),
|
|
||||||
name: current?.name ?? (await resourceName(payload, material.resource)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let storedResources = [...(gameStructure.storedResources ?? [])];
|
|
||||||
let voidStorage = [...(gameStructure.voidStorage ?? [])];
|
|
||||||
const shortages: string[] = [];
|
|
||||||
for (const [resourceId, requirement] of requirements) {
|
|
||||||
const available =
|
|
||||||
storedAmount(storedResources, resourceId) + storedAmount(voidStorage, resourceId);
|
|
||||||
if (available < requirement.needed) {
|
|
||||||
shortages.push(`${requirement.name} (have ${available}, need ${requirement.needed})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (shortages.length > 0) throw new Error(`Missing materials: ${shortages.join(", ")}`);
|
|
||||||
|
|
||||||
for (const [resourceId, requirement] of requirements) {
|
|
||||||
const storedResult = consumeStorage(storedResources, resourceId, requirement.needed);
|
|
||||||
storedResources = storedResult.entries;
|
|
||||||
voidStorage = consumeStorage(voidStorage, resourceId, storedResult.remaining).entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await payload.update({
|
|
||||||
collection: "game-structures",
|
|
||||||
id: gameStructure.id,
|
|
||||||
data: {
|
|
||||||
type: target.id,
|
|
||||||
storedResources,
|
|
||||||
voidStorage,
|
|
||||||
...staffingDefaultsFromBlueprint(target),
|
|
||||||
},
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
await emitGameEvent(payload, {
|
|
||||||
type: EventTypes.StructureUpgraded,
|
|
||||||
message: `Structure "${gameStructure.name}" upgraded to ${target.name}`,
|
|
||||||
structure: gameStructure.id,
|
|
||||||
targetCollection: "game-structures",
|
|
||||||
targetId: gameStructure.id,
|
|
||||||
});
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
import type { Payload } from "payload";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recipient computation for the post-mission evaluation reminder DMs (Discord bot).
|
|
||||||
*
|
|
||||||
* Rules (aligned with `evaluateEligibility` in ./index.ts):
|
|
||||||
* - Participants = users with a "yes" RSVP for the mission (the documented
|
|
||||||
* participation proxy — same as the profile-page rating dialog).
|
|
||||||
* - Every participant is reminded to rate their direct leader(s) — eligible as
|
|
||||||
* `leader-direct` (and, for any other leader, `leader-indirect`).
|
|
||||||
* - Every leader of an assignment containing a participant is reminded to rate
|
|
||||||
* that participant — eligible as `subordinate` (no RSVP required for leaders).
|
|
||||||
* - Self-pairs are skipped; a user who is both a participant and a leader gets a
|
|
||||||
* single merged DM.
|
|
||||||
*
|
|
||||||
* This module is payload-only (no Discord imports) so the recipient plan is
|
|
||||||
* testable independently of the bot service.
|
|
||||||
*/
|
|
||||||
|
|
||||||
type PayloadType = Payload;
|
|
||||||
|
|
||||||
export interface ReminderLink {
|
|
||||||
userId: number;
|
|
||||||
username: string;
|
|
||||||
displayName: string;
|
|
||||||
/** App-relative link to the ratee's profile page where the rating dialog lives. */
|
|
||||||
profilePath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReminderRecipient {
|
|
||||||
userId: number;
|
|
||||||
discordId: string | null;
|
|
||||||
/** Leaders this participant should rate (may be empty). */
|
|
||||||
rateLeaders: ReminderLink[];
|
|
||||||
/** Subordinates this leader should rate (may be empty). */
|
|
||||||
rateSubordinates: ReminderLink[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EvaluationReminderPlan {
|
|
||||||
missionId: number;
|
|
||||||
/** Only users with at least one actionable rating are included. */
|
|
||||||
recipients: ReminderRecipient[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractIds(value: unknown): number[] {
|
|
||||||
if (!Array.isArray(value)) return [];
|
|
||||||
const ids: number[] = [];
|
|
||||||
for (const v of value) {
|
|
||||||
if (typeof v === "number") ids.push(v);
|
|
||||||
else if (v && typeof v === "object" && typeof (v as { id?: unknown }).id === "number") {
|
|
||||||
ids.push((v as { id: number }).id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractId(value: unknown): number | null {
|
|
||||||
if (typeof value === "number") return value;
|
|
||||||
if (value && typeof value === "object" && typeof (value as { id?: unknown }).id === "number") {
|
|
||||||
return (value as { id: number }).id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toLink(user: { id: number; username?: string | null; displayName?: string | null }): ReminderLink {
|
|
||||||
const username = user.username ?? `user-${user.id}`;
|
|
||||||
return {
|
|
||||||
userId: user.id,
|
|
||||||
username,
|
|
||||||
displayName: user.displayName || username,
|
|
||||||
profilePath: `/profile/${username}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes who should be reminded to submit evaluations for a mission.
|
|
||||||
* `missionId` must already be evaluable (see `isMissionEvaluable`) — this
|
|
||||||
* function does not re-check eligibility.
|
|
||||||
*/
|
|
||||||
export async function computeEvaluationReminderPlan(
|
|
||||||
payload: PayloadType,
|
|
||||||
missionId: number,
|
|
||||||
): Promise<EvaluationReminderPlan> {
|
|
||||||
// 1. Participants: yes-RSVP users for this mission.
|
|
||||||
const attendance = await payload.find({
|
|
||||||
collection: "mission-attendances",
|
|
||||||
where: {
|
|
||||||
and: [{ mission: { equals: missionId } }, { response: { equals: "yes" } }],
|
|
||||||
},
|
|
||||||
limit: 1000,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
const participantIds = [
|
|
||||||
...new Set(attendance.docs.map((doc) => extractId(doc.user)).filter((id): id is number => id !== null)),
|
|
||||||
];
|
|
||||||
if (participantIds.length === 0) {
|
|
||||||
return { missionId, recipients: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Command graph from assignments: leader -> members, member -> leaders.
|
|
||||||
const assignments = await payload.find({
|
|
||||||
collection: "assignments",
|
|
||||||
where: { leader: { not_equals: null } },
|
|
||||||
limit: 1000,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
const leadersOf = new Map<number, Set<number>>();
|
|
||||||
const membersLedBy = new Map<number, Set<number>>();
|
|
||||||
for (const a of assignments.docs) {
|
|
||||||
const leaderId = extractId(a.leader);
|
|
||||||
if (leaderId === null) continue;
|
|
||||||
for (const memberId of extractIds(a.members)) {
|
|
||||||
if (memberId === leaderId) continue; // a leader is not their own leader
|
|
||||||
if (!leadersOf.has(memberId)) leadersOf.set(memberId, new Set());
|
|
||||||
leadersOf.get(memberId)!.add(leaderId);
|
|
||||||
if (!membersLedBy.has(leaderId)) membersLedBy.set(leaderId, new Set());
|
|
||||||
membersLedBy.get(leaderId)!.add(memberId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Merge both roles per user: participant -> rate leaders; leader of a
|
|
||||||
// participant -> rate that participant.
|
|
||||||
const plan = new Map<number, { rateLeaders: Map<number, number>; rateSubordinates: Map<number, number> }>();
|
|
||||||
const ensure = (userId: number) => {
|
|
||||||
let entry = plan.get(userId);
|
|
||||||
if (!entry) {
|
|
||||||
entry = { rateLeaders: new Map(), rateSubordinates: new Map() };
|
|
||||||
plan.set(userId, entry);
|
|
||||||
}
|
|
||||||
return entry;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const participantId of participantIds) {
|
|
||||||
ensure(participantId); // even with no direct leader they may still be rated by theirs
|
|
||||||
for (const leaderId of leadersOf.get(participantId) ?? []) {
|
|
||||||
ensure(participantId).rateLeaders.set(leaderId, participantId);
|
|
||||||
ensure(leaderId).rateSubordinates.set(participantId, leaderId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Resolve user docs (discordId / username / displayName) for everyone in the plan.
|
|
||||||
const userIds = [...plan.keys()];
|
|
||||||
const users = await payload.find({
|
|
||||||
collection: "users",
|
|
||||||
where: { id: { in: userIds } },
|
|
||||||
limit: 1000,
|
|
||||||
depth: 0,
|
|
||||||
overrideAccess: true,
|
|
||||||
});
|
|
||||||
const userById = new Map(users.docs.map((u) => [u.id, u]));
|
|
||||||
|
|
||||||
const recipients: ReminderRecipient[] = [];
|
|
||||||
for (const [userId, entry] of plan) {
|
|
||||||
const rateLeaders = [...entry.rateLeaders.keys()]
|
|
||||||
.map((id) => userById.get(id))
|
|
||||||
.filter((u): u is NonNullable<typeof u> => Boolean(u))
|
|
||||||
.map(toLink);
|
|
||||||
const rateSubordinates = [...entry.rateSubordinates.keys()]
|
|
||||||
.map((id) => userById.get(id))
|
|
||||||
.filter((u): u is NonNullable<typeof u> => Boolean(u))
|
|
||||||
.map(toLink);
|
|
||||||
if (rateLeaders.length === 0 && rateSubordinates.length === 0) continue;
|
|
||||||
const user = userById.get(userId);
|
|
||||||
recipients.push({
|
|
||||||
userId,
|
|
||||||
discordId: user?.discordId ?? null,
|
|
||||||
rateLeaders,
|
|
||||||
rateSubordinates,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { missionId, recipients };
|
|
||||||
}
|
|
||||||
|
|
@ -9,14 +9,7 @@ import {
|
||||||
resolveAssetId,
|
resolveAssetId,
|
||||||
toLockerGridItems,
|
toLockerGridItems,
|
||||||
} from "@/lib/locker";
|
} from "@/lib/locker";
|
||||||
import {
|
import { applyTransaction, ensurePersonalAccount, getMainCurrencyId } from "@/lib/banking";
|
||||||
applyTransaction,
|
|
||||||
ensurePersonalAccount,
|
|
||||||
getMainCurrencyId,
|
|
||||||
getTreasuryAccountId,
|
|
||||||
} from "@/lib/banking";
|
|
||||||
|
|
||||||
export { getTreasuryAccountId } from "@/lib/banking";
|
|
||||||
|
|
||||||
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||||
|
|
||||||
|
|
@ -225,6 +218,22 @@ export async function creditLockerQuantity(
|
||||||
})) as unknown as LockerStorage;
|
})) as unknown as LockerStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve the id of the open unit treasury account, if any. */
|
||||||
|
export async function getTreasuryAccountId(payload: PayloadType): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const result = (await payload.find({
|
||||||
|
collection: "bank-accounts",
|
||||||
|
where: { and: [{ accountType: { equals: "treasury" } }, { status: { equals: "open" } }] },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as unknown as { docs: { id: number }[] };
|
||||||
|
return result.docs[0]?.id ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface BuyResult {
|
export interface BuyResult {
|
||||||
transactionId: number;
|
transactionId: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
export type NpcDialogueKind =
|
export type NpcDialogueKind =
|
||||||
"firm" | "lowball" | "backward" | "stall" | "concede" | "good" | "overpay" | "closed" | "accept";
|
| "firm"
|
||||||
|
| "lowball"
|
||||||
|
| "backward"
|
||||||
|
| "stall"
|
||||||
|
| "concede"
|
||||||
|
| "good"
|
||||||
|
| "overpay"
|
||||||
|
| "closed"
|
||||||
|
| "accept";
|
||||||
|
|
||||||
type DialogueLine = (amount: string, asking: string) => string;
|
type DialogueLine = (amount: string, asking: string) => string;
|
||||||
|
|
||||||
|
|
@ -10,8 +18,8 @@ const LINES: Record<NpcDialogueKind, DialogueLine[]> = {
|
||||||
(amount) => `I'm holding at ${amount}. My prices are already fair.`,
|
(amount) => `I'm holding at ${amount}. My prices are already fair.`,
|
||||||
],
|
],
|
||||||
lowball: [
|
lowball: [
|
||||||
(amount) => `That's an insult! I'll come down to ${amount} — not a fraction less.`,
|
(amount) => `That's an insult! I'll come down to ${amount} — not a unit less.`,
|
||||||
(amount) => `You're joking, right? ${amount} and not a fraction less.`,
|
(amount) => `You're joking, right? ${amount} and not a unit less.`,
|
||||||
(amount) => `I paid more than that for lunch! ${amount} — final offer.`,
|
(amount) => `I paid more than that for lunch! ${amount} — final offer.`,
|
||||||
],
|
],
|
||||||
backward: [
|
backward: [
|
||||||
|
|
@ -20,8 +28,8 @@ const LINES: Record<NpcDialogueKind, DialogueLine[]> = {
|
||||||
],
|
],
|
||||||
stall: [
|
stall: [
|
||||||
() => "Really? That's hardly different. Negotiate like you mean it.",
|
() => "Really? That's hardly different. Negotiate like you mean it.",
|
||||||
() => "That tiny increase isn't negotiating. Come back with a real number.",
|
() => "A whole dollar more? That's not negotiating, that's teasing. Come back with a real number.",
|
||||||
() => "You're just inching up now. Bring something meaningful.",
|
() => "You're just wearing me down with pennies now. Bring something meaningful.",
|
||||||
],
|
],
|
||||||
concede: [
|
concede: [
|
||||||
(amount) => `Hmm, not sure about that... how about ${amount}?`,
|
(amount) => `Hmm, not sure about that... how about ${amount}?`,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,48 +0,0 @@
|
||||||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
|
||||||
|
|
||||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
CREATE TYPE "public"."enum_npc_staffing_position_type" AS ENUM('labor', 'administrative', 'technical', 'other');
|
|
||||||
CREATE TYPE "public"."enum_npc_staffing_status" AS ENUM('active', 'terminated');
|
|
||||||
ALTER TYPE "public"."enum_game_event_logs_target_collection" ADD VALUE 'npc-staffing' BEFORE 'structures';
|
|
||||||
CREATE TABLE "npc_staffing" (
|
|
||||||
"id" serial PRIMARY KEY NOT NULL,
|
|
||||||
"npc_id" integer NOT NULL,
|
|
||||||
"structure_id" integer NOT NULL,
|
|
||||||
"position_type" "enum_npc_staffing_position_type" DEFAULT 'labor' NOT NULL,
|
|
||||||
"position_title" varchar NOT NULL,
|
|
||||||
"salary" numeric DEFAULT 0,
|
|
||||||
"status" "enum_npc_staffing_status" DEFAULT 'active',
|
|
||||||
"hired_at" timestamp(3) with time zone,
|
|
||||||
"terminated_at" timestamp(3) with time zone,
|
|
||||||
"notes" varchar,
|
|
||||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
|
||||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "npc_staffing_id" integer;
|
|
||||||
ALTER TABLE "npc_staffing" ADD CONSTRAINT "npc_staffing_npc_id_game_npcs_id_fk" FOREIGN KEY ("npc_id") REFERENCES "public"."game_npcs"("id") ON DELETE set null ON UPDATE no action;
|
|
||||||
ALTER TABLE "npc_staffing" ADD CONSTRAINT "npc_staffing_structure_id_game_structures_id_fk" FOREIGN KEY ("structure_id") REFERENCES "public"."game_structures"("id") ON DELETE set null ON UPDATE no action;
|
|
||||||
CREATE INDEX "npc_staffing_npc_idx" ON "npc_staffing" USING btree ("npc_id");
|
|
||||||
CREATE INDEX "npc_staffing_structure_idx" ON "npc_staffing" USING btree ("structure_id");
|
|
||||||
CREATE INDEX "npc_staffing_updated_at_idx" ON "npc_staffing" USING btree ("updated_at");
|
|
||||||
CREATE INDEX "npc_staffing_created_at_idx" ON "npc_staffing" USING btree ("created_at");
|
|
||||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_npc_staffing_fk" FOREIGN KEY ("npc_staffing_id") REFERENCES "public"."npc_staffing"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
CREATE INDEX "payload_locked_documents_rels_npc_staffing_id_idx" ON "payload_locked_documents_rels" USING btree ("npc_staffing_id");`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
ALTER TABLE "npc_staffing" DISABLE ROW LEVEL SECURITY;
|
|
||||||
DROP TABLE "npc_staffing" CASCADE;
|
|
||||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_npc_staffing_fk";
|
|
||||||
|
|
||||||
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE text;
|
|
||||||
DROP TYPE "public"."enum_game_event_logs_target_collection";
|
|
||||||
CREATE TYPE "public"."enum_game_event_logs_target_collection" AS ENUM('game-structures', 'game-vehicles', 'game-npcs', 'structures', 'resources', 'assets', 'vehicles', 'factions', 'missions', 'campaigns', 'users', 'technologies', 'maps', 'shipments', 'bank-accounts', 'bank-transactions', 'ledger-entries', 'locker-storages', 'loadouts', 'market-listings', 'market-negotiations', 'evaluations', 'tickets', 'wiki-pages', 'wiki-revisions', 'wiki-templates', 'game-servers', 'assignment-transfers');
|
|
||||||
ALTER TABLE "game_event_logs" ALTER COLUMN "target_collection" SET DATA TYPE "public"."enum_game_event_logs_target_collection" USING "target_collection"::"public"."enum_game_event_logs_target_collection";
|
|
||||||
DROP INDEX "payload_locked_documents_rels_npc_staffing_id_idx";
|
|
||||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "npc_staffing_id";
|
|
||||||
DROP TYPE "public"."enum_npc_staffing_position_type";
|
|
||||||
DROP TYPE "public"."enum_npc_staffing_status";`)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +0,0 @@
|
||||||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
|
||||||
|
|
||||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
CREATE TYPE "public"."enum_npc_staffing_labor_classification" AS ENUM('general', 'doctor', 'mechanic', 'engineer', 'security', 'other');
|
|
||||||
ALTER TABLE "npc_staffing" ALTER COLUMN "npc_id" DROP NOT NULL;
|
|
||||||
ALTER TABLE "npc_staffing" ADD COLUMN "headcount" numeric DEFAULT 1;
|
|
||||||
ALTER TABLE "npc_staffing" ADD COLUMN "labor_classification" "enum_npc_staffing_labor_classification";`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
ALTER TABLE "npc_staffing" ALTER COLUMN "npc_id" SET NOT NULL;
|
|
||||||
ALTER TABLE "npc_staffing" DROP COLUMN "headcount";
|
|
||||||
ALTER TABLE "npc_staffing" DROP COLUMN "labor_classification";
|
|
||||||
DROP TYPE "public"."enum_npc_staffing_labor_classification";`)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,51 +0,0 @@
|
||||||
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
|
|
||||||
|
|
||||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
CREATE TABLE "labor_classifications" (
|
|
||||||
"id" serial PRIMARY KEY NOT NULL,
|
|
||||||
"name" varchar NOT NULL,
|
|
||||||
"key" varchar NOT NULL,
|
|
||||||
"default_unit_salary" numeric,
|
|
||||||
"description" varchar,
|
|
||||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
|
||||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE "npc_staffing" ADD COLUMN "labor_classification_id" integer;
|
|
||||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "labor_classifications_id" integer;
|
|
||||||
CREATE UNIQUE INDEX "labor_classifications_name_idx" ON "labor_classifications" USING btree ("name");
|
|
||||||
CREATE UNIQUE INDEX "labor_classifications_key_idx" ON "labor_classifications" USING btree ("key");
|
|
||||||
CREATE INDEX "labor_classifications_updated_at_idx" ON "labor_classifications" USING btree ("updated_at");
|
|
||||||
CREATE INDEX "labor_classifications_created_at_idx" ON "labor_classifications" USING btree ("created_at");
|
|
||||||
ALTER TABLE "npc_staffing" ADD CONSTRAINT "npc_staffing_labor_classification_id_labor_classifications_id_fk" FOREIGN KEY ("labor_classification_id") REFERENCES "public"."labor_classifications"("id") ON DELETE set null ON UPDATE no action;
|
|
||||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_labor_classifications_fk" FOREIGN KEY ("labor_classifications_id") REFERENCES "public"."labor_classifications"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
CREATE INDEX "npc_staffing_labor_classification_idx" ON "npc_staffing" USING btree ("labor_classification_id");
|
|
||||||
CREATE INDEX "payload_locked_documents_rels_labor_classifications_id_idx" ON "payload_locked_documents_rels" USING btree ("labor_classifications_id");
|
|
||||||
ALTER TABLE "npc_staffing" DROP COLUMN "labor_classification";
|
|
||||||
DROP TYPE "public"."enum_npc_staffing_labor_classification";
|
|
||||||
INSERT INTO "labor_classifications" ("name", "key", "default_unit_salary", "description") VALUES
|
|
||||||
('General', 'general', NULL, NULL),
|
|
||||||
('Doctor', 'doctor', NULL, NULL),
|
|
||||||
('Mechanic', 'mechanic', NULL, NULL),
|
|
||||||
('Engineer', 'engineer', NULL, NULL),
|
|
||||||
('Security', 'security', NULL, NULL),
|
|
||||||
('Other', 'other', NULL, NULL)
|
|
||||||
ON CONFLICT ("key") DO NOTHING;`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
CREATE TYPE "public"."enum_npc_staffing_labor_classification" AS ENUM('general', 'doctor', 'mechanic', 'engineer', 'security', 'other');
|
|
||||||
ALTER TABLE "labor_classifications" DISABLE ROW LEVEL SECURITY;
|
|
||||||
DROP TABLE "labor_classifications" CASCADE;
|
|
||||||
ALTER TABLE "npc_staffing" DROP CONSTRAINT "npc_staffing_labor_classification_id_labor_classifications_id_fk";
|
|
||||||
|
|
||||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_labor_classifications_fk";
|
|
||||||
|
|
||||||
DROP INDEX "npc_staffing_labor_classification_idx";
|
|
||||||
DROP INDEX "payload_locked_documents_rels_labor_classifications_id_idx";
|
|
||||||
ALTER TABLE "npc_staffing" ADD COLUMN "labor_classification" "enum_npc_staffing_labor_classification";
|
|
||||||
ALTER TABLE "npc_staffing" DROP COLUMN "labor_classification_id";
|
|
||||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "labor_classifications_id";`);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,48 +0,0 @@
|
||||||
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
|
|
||||||
|
|
||||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
CREATE TYPE "public"."enum_game_structures_staff_positions_position_type" AS ENUM('administrative', 'technical', 'other');
|
|
||||||
CREATE TABLE "game_structures_labor_requirements" (
|
|
||||||
"_order" integer NOT NULL,
|
|
||||||
"_parent_id" integer NOT NULL,
|
|
||||||
"id" varchar PRIMARY KEY NOT NULL,
|
|
||||||
"classification_id" integer NOT NULL,
|
|
||||||
"required_headcount" numeric DEFAULT 0 NOT NULL,
|
|
||||||
"max_headcount" numeric,
|
|
||||||
"enforced_salary" numeric
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "game_structures_staff_positions" (
|
|
||||||
"_order" integer NOT NULL,
|
|
||||||
"_parent_id" integer NOT NULL,
|
|
||||||
"id" varchar PRIMARY KEY NOT NULL,
|
|
||||||
"position_type" "enum_game_structures_staff_positions_position_type" NOT NULL,
|
|
||||||
"max" numeric,
|
|
||||||
"enforced_salary" numeric
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE "game_structures" ADD COLUMN "maintenance_cost" numeric DEFAULT 0;
|
|
||||||
ALTER TABLE "labor_classifications" ADD COLUMN "available_headcount" numeric DEFAULT 0;
|
|
||||||
ALTER TABLE "game_rules" ADD COLUMN "staff_hiring_enabled" boolean DEFAULT false;
|
|
||||||
ALTER TABLE "game_rules" ADD COLUMN "staff_hiring_disabled_message" varchar;
|
|
||||||
ALTER TABLE "game_structures_labor_requirements" ADD CONSTRAINT "game_structures_labor_requirements_classification_id_labor_classifications_id_fk" FOREIGN KEY ("classification_id") REFERENCES "public"."labor_classifications"("id") ON DELETE set null ON UPDATE no action;
|
|
||||||
ALTER TABLE "game_structures_labor_requirements" ADD CONSTRAINT "game_structures_labor_requirements_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."game_structures"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
ALTER TABLE "game_structures_staff_positions" ADD CONSTRAINT "game_structures_staff_positions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."game_structures"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
CREATE INDEX "game_structures_labor_requirements_order_idx" ON "game_structures_labor_requirements" USING btree ("_order");
|
|
||||||
CREATE INDEX "game_structures_labor_requirements_parent_id_idx" ON "game_structures_labor_requirements" USING btree ("_parent_id");
|
|
||||||
CREATE INDEX "game_structures_labor_requirements_classification_idx" ON "game_structures_labor_requirements" USING btree ("classification_id");
|
|
||||||
CREATE INDEX "game_structures_staff_positions_order_idx" ON "game_structures_staff_positions" USING btree ("_order");
|
|
||||||
CREATE INDEX "game_structures_staff_positions_parent_id_idx" ON "game_structures_staff_positions" USING btree ("_parent_id");`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
DROP TABLE "game_structures_labor_requirements" CASCADE;
|
|
||||||
DROP TABLE "game_structures_staff_positions" CASCADE;
|
|
||||||
ALTER TABLE "game_structures" DROP COLUMN "maintenance_cost";
|
|
||||||
ALTER TABLE "labor_classifications" DROP COLUMN "available_headcount";
|
|
||||||
ALTER TABLE "game_rules" DROP COLUMN "staff_hiring_enabled";
|
|
||||||
ALTER TABLE "game_rules" DROP COLUMN "staff_hiring_disabled_message";
|
|
||||||
DROP TYPE "public"."enum_game_structures_staff_positions_position_type";`);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,42 +0,0 @@
|
||||||
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
|
|
||||||
|
|
||||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
CREATE TYPE "public"."enum_structures_staff_positions_position_type" AS ENUM('administrative', 'technical', 'other');
|
|
||||||
CREATE TABLE "structures_labor_requirements" (
|
|
||||||
"_order" integer NOT NULL,
|
|
||||||
"_parent_id" integer NOT NULL,
|
|
||||||
"id" varchar PRIMARY KEY NOT NULL,
|
|
||||||
"classification_id" integer NOT NULL,
|
|
||||||
"required_headcount" numeric DEFAULT 0 NOT NULL,
|
|
||||||
"max_headcount" numeric,
|
|
||||||
"enforced_salary" numeric
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE "structures_staff_positions" (
|
|
||||||
"_order" integer NOT NULL,
|
|
||||||
"_parent_id" integer NOT NULL,
|
|
||||||
"id" varchar PRIMARY KEY NOT NULL,
|
|
||||||
"position_type" "enum_structures_staff_positions_position_type" NOT NULL,
|
|
||||||
"max" numeric,
|
|
||||||
"enforced_salary" numeric
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE "structures" ADD COLUMN "maintenance_cost" numeric DEFAULT 0;
|
|
||||||
ALTER TABLE "structures_labor_requirements" ADD CONSTRAINT "structures_labor_requirements_classification_id_labor_classifications_id_fk" FOREIGN KEY ("classification_id") REFERENCES "public"."labor_classifications"("id") ON DELETE set null ON UPDATE no action;
|
|
||||||
ALTER TABLE "structures_labor_requirements" ADD CONSTRAINT "structures_labor_requirements_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."structures"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
ALTER TABLE "structures_staff_positions" ADD CONSTRAINT "structures_staff_positions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."structures"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
CREATE INDEX "structures_labor_requirements_order_idx" ON "structures_labor_requirements" USING btree ("_order");
|
|
||||||
CREATE INDEX "structures_labor_requirements_parent_id_idx" ON "structures_labor_requirements" USING btree ("_parent_id");
|
|
||||||
CREATE INDEX "structures_labor_requirements_classification_idx" ON "structures_labor_requirements" USING btree ("classification_id");
|
|
||||||
CREATE INDEX "structures_staff_positions_order_idx" ON "structures_staff_positions" USING btree ("_order");
|
|
||||||
CREATE INDEX "structures_staff_positions_parent_id_idx" ON "structures_staff_positions" USING btree ("_parent_id");`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
DROP TABLE "structures_labor_requirements" CASCADE;
|
|
||||||
DROP TABLE "structures_staff_positions" CASCADE;
|
|
||||||
ALTER TABLE "structures" DROP COLUMN "maintenance_cost";
|
|
||||||
DROP TYPE "public"."enum_structures_staff_positions_position_type";`);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,19 +0,0 @@
|
||||||
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
|
|
||||||
|
|
||||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
ALTER TABLE "game_rules" ADD COLUMN "currency_name" varchar;
|
|
||||||
ALTER TABLE "game_rules" ADD COLUMN "currency_symbol" varchar;
|
|
||||||
ALTER TABLE "game_rules" ADD COLUMN "currency_singular" varchar;
|
|
||||||
ALTER TABLE "game_rules" ADD COLUMN "currency_plural" varchar;
|
|
||||||
ALTER TABLE "game_rules" ADD COLUMN "currency_denomination" varchar;`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
|
||||||
await db.execute(sql`
|
|
||||||
ALTER TABLE "game_rules" DROP COLUMN "currency_name";
|
|
||||||
ALTER TABLE "game_rules" DROP COLUMN "currency_symbol";
|
|
||||||
ALTER TABLE "game_rules" DROP COLUMN "currency_singular";
|
|
||||||
ALTER TABLE "game_rules" DROP COLUMN "currency_plural";
|
|
||||||
ALTER TABLE "game_rules" DROP COLUMN "currency_denomination";`);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,11 +0,0 @@
|
||||||
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 "evaluation_reminders_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 "evaluation_reminders_sent_at";`)
|
|
||||||
}
|
|
||||||
|
|
@ -22,13 +22,6 @@ import * as migration_20260903_060000_add_radio_mode_stats from './20260903_0600
|
||||||
import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts';
|
import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts';
|
||||||
import * as migration_20260903_062000_add_aim_trainer_stats from './20260903_062000_add_aim_trainer_stats';
|
import * as migration_20260903_062000_add_aim_trainer_stats from './20260903_062000_add_aim_trainer_stats';
|
||||||
import * as migration_20260904_230838_add_wiki_collections from './20260904_230838_add_wiki_collections';
|
import * as migration_20260904_230838_add_wiki_collections from './20260904_230838_add_wiki_collections';
|
||||||
import * as migration_20260905_183940_base_management from './20260905_183940_base_management';
|
|
||||||
import * as migration_20260905_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';
|
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -149,41 +142,6 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260904_230838_add_wiki_collections.up,
|
up: migration_20260904_230838_add_wiki_collections.up,
|
||||||
down: migration_20260904_230838_add_wiki_collections.down,
|
down: migration_20260904_230838_add_wiki_collections.down,
|
||||||
name: '20260904_230838_add_wiki_collections',
|
name: '20260904_230838_add_wiki_collections'
|
||||||
},
|
|
||||||
{
|
|
||||||
up: migration_20260905_183940_base_management.up,
|
|
||||||
down: migration_20260905_183940_base_management.down,
|
|
||||||
name: '20260905_183940_base_management',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
up: migration_20260905_195456_labor_headcount.up,
|
|
||||||
down: migration_20260905_195456_labor_headcount.down,
|
|
||||||
name: '20260905_195456_labor_headcount',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
up: migration_20260905_210310_labor_classifications.up,
|
|
||||||
down: migration_20260905_210310_labor_classifications.down,
|
|
||||||
name: '20260905_210310_labor_classifications',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
up: migration_20260905_213824.up,
|
|
||||||
down: migration_20260905_213824.down,
|
|
||||||
name: '20260905_213824',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
up: migration_20260905_215658.up,
|
|
||||||
down: migration_20260905_215658.down,
|
|
||||||
name: '20260905_215658',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
up: migration_20260906_045502.up,
|
|
||||||
down: migration_20260906_045502.down,
|
|
||||||
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'
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,6 @@ export interface Config {
|
||||||
'game-structures': GameStructure;
|
'game-structures': GameStructure;
|
||||||
'game-vehicles': GameVehicle;
|
'game-vehicles': GameVehicle;
|
||||||
'game-npcs': GameNpc;
|
'game-npcs': GameNpc;
|
||||||
'npc-staffing': NpcStaffing;
|
|
||||||
'labor-classifications': LaborClassification;
|
|
||||||
'game-event-logs': GameEventLog;
|
'game-event-logs': GameEventLog;
|
||||||
users: User;
|
users: User;
|
||||||
roles: Role;
|
roles: Role;
|
||||||
|
|
@ -141,8 +139,6 @@ export interface Config {
|
||||||
'game-structures': GameStructuresSelect<false> | GameStructuresSelect<true>;
|
'game-structures': GameStructuresSelect<false> | GameStructuresSelect<true>;
|
||||||
'game-vehicles': GameVehiclesSelect<false> | GameVehiclesSelect<true>;
|
'game-vehicles': GameVehiclesSelect<false> | GameVehiclesSelect<true>;
|
||||||
'game-npcs': GameNpcsSelect<false> | GameNpcsSelect<true>;
|
'game-npcs': GameNpcsSelect<false> | GameNpcsSelect<true>;
|
||||||
'npc-staffing': NpcStaffingSelect<false> | NpcStaffingSelect<true>;
|
|
||||||
'labor-classifications': LaborClassificationsSelect<false> | LaborClassificationsSelect<true>;
|
|
||||||
'game-event-logs': GameEventLogsSelect<false> | GameEventLogsSelect<true>;
|
'game-event-logs': GameEventLogsSelect<false> | GameEventLogsSelect<true>;
|
||||||
users: UsersSelect<false> | UsersSelect<true>;
|
users: UsersSelect<false> | UsersSelect<true>;
|
||||||
roles: RolesSelect<false> | RolesSelect<true>;
|
roles: RolesSelect<false> | RolesSelect<true>;
|
||||||
|
|
@ -316,48 +312,6 @@ export interface GameStructure {
|
||||||
* The faction that owns this structure.
|
* The faction that owns this structure.
|
||||||
*/
|
*/
|
||||||
faction?: (number | null) | Faction;
|
faction?: (number | null) | Faction;
|
||||||
/**
|
|
||||||
* Funds charged per base tick for structure upkeep, separate from staff salaries.
|
|
||||||
*/
|
|
||||||
maintenanceCost?: number | null;
|
|
||||||
/**
|
|
||||||
* Which labor classifications this structure needs. Empty = no labor crews can be assigned here.
|
|
||||||
*/
|
|
||||||
laborRequirements?:
|
|
||||||
| {
|
|
||||||
classification: number | LaborClassification;
|
|
||||||
/**
|
|
||||||
* Informational staffing target shown to logistics officers.
|
|
||||||
*/
|
|
||||||
requiredHeadcount: number;
|
|
||||||
/**
|
|
||||||
* Hard cap for this classification. Zero or empty means it cannot be hired here.
|
|
||||||
*/
|
|
||||||
maxHeadcount?: number | null;
|
|
||||||
/**
|
|
||||||
* When set, workers of this classification are always paid this wage; hirers cannot change it.
|
|
||||||
*/
|
|
||||||
enforcedSalary?: number | null;
|
|
||||||
id?: string | null;
|
|
||||||
}[]
|
|
||||||
| null;
|
|
||||||
/**
|
|
||||||
* Which named staff position types this structure accepts. Empty = no named staff can be assigned here.
|
|
||||||
*/
|
|
||||||
staffPositions?:
|
|
||||||
| {
|
|
||||||
positionType: 'administrative' | 'technical' | 'other';
|
|
||||||
/**
|
|
||||||
* Maximum simultaneous named staff of this type. Empty means unlimited.
|
|
||||||
*/
|
|
||||||
max?: number | null;
|
|
||||||
/**
|
|
||||||
* When set, named staff of this type are always paid this salary.
|
|
||||||
*/
|
|
||||||
enforcedSalary?: number | null;
|
|
||||||
id?: string | null;
|
|
||||||
}[]
|
|
||||||
| null;
|
|
||||||
/**
|
/**
|
||||||
* @minItems 2
|
* @minItems 2
|
||||||
* @maxItems 2
|
* @maxItems 2
|
||||||
|
|
@ -499,48 +453,6 @@ export interface Structure {
|
||||||
* A base price that this structure could sell for when fully operational. Leaving this empty will make this structure unsellable.
|
* A base price that this structure could sell for when fully operational. Leaving this empty will make this structure unsellable.
|
||||||
*/
|
*/
|
||||||
sellPrice?: number | null;
|
sellPrice?: number | null;
|
||||||
/**
|
|
||||||
* Default funds charged per base tick for structure upkeep. Copied into newly created game-structures of this type.
|
|
||||||
*/
|
|
||||||
maintenanceCost?: number | null;
|
|
||||||
/**
|
|
||||||
* Default labor classifications copied into newly created game-structures of this type. Empty = no labor crews by default.
|
|
||||||
*/
|
|
||||||
laborRequirements?:
|
|
||||||
| {
|
|
||||||
classification: number | LaborClassification;
|
|
||||||
/**
|
|
||||||
* Informational staffing target shown to logistics officers.
|
|
||||||
*/
|
|
||||||
requiredHeadcount: number;
|
|
||||||
/**
|
|
||||||
* Hard cap for this classification. Zero or empty means it cannot be hired here.
|
|
||||||
*/
|
|
||||||
maxHeadcount?: number | null;
|
|
||||||
/**
|
|
||||||
* When set, workers of this classification are always paid this wage; hirers cannot change it.
|
|
||||||
*/
|
|
||||||
enforcedSalary?: number | null;
|
|
||||||
id?: string | null;
|
|
||||||
}[]
|
|
||||||
| null;
|
|
||||||
/**
|
|
||||||
* Default named staff position types copied into newly created game-structures of this type. Empty = no named staff by default.
|
|
||||||
*/
|
|
||||||
staffPositions?:
|
|
||||||
| {
|
|
||||||
positionType: 'administrative' | 'technical' | 'other';
|
|
||||||
/**
|
|
||||||
* Maximum simultaneous named staff of this type. Empty means unlimited.
|
|
||||||
*/
|
|
||||||
max?: number | null;
|
|
||||||
/**
|
|
||||||
* When set, named staff of this type are always paid this salary.
|
|
||||||
*/
|
|
||||||
enforcedSalary?: number | null;
|
|
||||||
id?: string | null;
|
|
||||||
}[]
|
|
||||||
| null;
|
|
||||||
/**
|
/**
|
||||||
* The app will use this type to determine what processes should run for this structure type.
|
* The app will use this type to determine what processes should run for this structure type.
|
||||||
*/
|
*/
|
||||||
|
|
@ -802,32 +714,6 @@ export interface Technology {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
|
||||||
* via the `definition` "labor-classifications".
|
|
||||||
*/
|
|
||||||
export interface LaborClassification {
|
|
||||||
id: number;
|
|
||||||
/**
|
|
||||||
* Display label for this labor trade (e.g. Mechanic).
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
/**
|
|
||||||
* Stable slug used to identify this classification (e.g. mechanic).
|
|
||||||
*/
|
|
||||||
key: string;
|
|
||||||
/**
|
|
||||||
* Prefills the wage-per-worker input in the hire dialog.
|
|
||||||
*/
|
|
||||||
defaultUnitSalary?: number | null;
|
|
||||||
description?: string | null;
|
|
||||||
/**
|
|
||||||
* How many workers of this classification are currently available to hire across the unit. Hiring consumes from this pool; releasing staff returns them.
|
|
||||||
*/
|
|
||||||
availableHeadcount?: number | null;
|
|
||||||
updatedAt: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "assets".
|
* via the `definition` "assets".
|
||||||
|
|
@ -1754,29 +1640,6 @@ export interface GameNpc {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
|
||||||
* via the `definition` "npc-staffing".
|
|
||||||
*/
|
|
||||||
export interface NpcStaffing {
|
|
||||||
id: number;
|
|
||||||
npc?: (number | null) | GameNpc;
|
|
||||||
structure: number | GameStructure;
|
|
||||||
positionType: 'labor' | 'administrative' | 'technical' | 'other';
|
|
||||||
positionTitle: string;
|
|
||||||
/**
|
|
||||||
* Workers in this block; always 1 for named staff
|
|
||||||
*/
|
|
||||||
headcount?: number | null;
|
|
||||||
laborClassification?: (number | null) | LaborClassification;
|
|
||||||
salary?: number | null;
|
|
||||||
status?: ('active' | 'terminated') | null;
|
|
||||||
hiredAt?: string | null;
|
|
||||||
terminatedAt?: string | null;
|
|
||||||
notes?: string | null;
|
|
||||||
updatedAt: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "game-event-logs".
|
* via the `definition` "game-event-logs".
|
||||||
|
|
@ -1800,7 +1663,6 @@ export interface GameEventLog {
|
||||||
| 'game-structures'
|
| 'game-structures'
|
||||||
| 'game-vehicles'
|
| 'game-vehicles'
|
||||||
| 'game-npcs'
|
| 'game-npcs'
|
||||||
| 'npc-staffing'
|
|
||||||
| 'structures'
|
| 'structures'
|
||||||
| 'resources'
|
| 'resources'
|
||||||
| 'assets'
|
| 'assets'
|
||||||
|
|
@ -2299,10 +2161,6 @@ export interface Mission {
|
||||||
* When the attendance roll-call was first posted to Discord (managed by the bot).
|
* When the attendance roll-call was first posted to Discord (managed by the bot).
|
||||||
*/
|
*/
|
||||||
discordAttendanceSentAt?: string | null;
|
discordAttendanceSentAt?: string | null;
|
||||||
/**
|
|
||||||
* When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot).
|
|
||||||
*/
|
|
||||||
evaluationRemindersSentAt?: string | null;
|
|
||||||
/**
|
/**
|
||||||
* Background image for the mission page hero header. Renders translucent with a top-to-bottom fade to black. Recommended aspect ratio: 16:9 or wider.
|
* Background image for the mission page hero header. Renders translucent with a top-to-bottom fade to black. Recommended aspect ratio: 16:9 or wider.
|
||||||
*/
|
*/
|
||||||
|
|
@ -3519,14 +3377,6 @@ export interface PayloadLockedDocument {
|
||||||
relationTo: 'game-npcs';
|
relationTo: 'game-npcs';
|
||||||
value: number | GameNpc;
|
value: number | GameNpc;
|
||||||
} | null)
|
} | null)
|
||||||
| ({
|
|
||||||
relationTo: 'npc-staffing';
|
|
||||||
value: number | NpcStaffing;
|
|
||||||
} | null)
|
|
||||||
| ({
|
|
||||||
relationTo: 'labor-classifications';
|
|
||||||
value: number | LaborClassification;
|
|
||||||
} | null)
|
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'game-event-logs';
|
relationTo: 'game-event-logs';
|
||||||
value: number | GameEventLog;
|
value: number | GameEventLog;
|
||||||
|
|
@ -3804,24 +3654,6 @@ export interface GameStructuresSelect<T extends boolean = true> {
|
||||||
name?: T;
|
name?: T;
|
||||||
type?: T;
|
type?: T;
|
||||||
faction?: T;
|
faction?: T;
|
||||||
maintenanceCost?: T;
|
|
||||||
laborRequirements?:
|
|
||||||
| T
|
|
||||||
| {
|
|
||||||
classification?: T;
|
|
||||||
requiredHeadcount?: T;
|
|
||||||
maxHeadcount?: T;
|
|
||||||
enforcedSalary?: T;
|
|
||||||
id?: T;
|
|
||||||
};
|
|
||||||
staffPositions?:
|
|
||||||
| T
|
|
||||||
| {
|
|
||||||
positionType?: T;
|
|
||||||
max?: T;
|
|
||||||
enforcedSalary?: T;
|
|
||||||
id?: T;
|
|
||||||
};
|
|
||||||
coordinates?: T;
|
coordinates?: T;
|
||||||
storedResources?:
|
storedResources?:
|
||||||
| T
|
| T
|
||||||
|
|
@ -3903,38 +3735,6 @@ export interface GameNpcsSelect<T extends boolean = true> {
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
|
||||||
* via the `definition` "npc-staffing_select".
|
|
||||||
*/
|
|
||||||
export interface NpcStaffingSelect<T extends boolean = true> {
|
|
||||||
npc?: T;
|
|
||||||
structure?: T;
|
|
||||||
positionType?: T;
|
|
||||||
positionTitle?: T;
|
|
||||||
headcount?: T;
|
|
||||||
laborClassification?: T;
|
|
||||||
salary?: T;
|
|
||||||
status?: T;
|
|
||||||
hiredAt?: T;
|
|
||||||
terminatedAt?: T;
|
|
||||||
notes?: T;
|
|
||||||
updatedAt?: T;
|
|
||||||
createdAt?: T;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
|
||||||
* via the `definition` "labor-classifications_select".
|
|
||||||
*/
|
|
||||||
export interface LaborClassificationsSelect<T extends boolean = true> {
|
|
||||||
name?: T;
|
|
||||||
key?: T;
|
|
||||||
defaultUnitSalary?: T;
|
|
||||||
description?: T;
|
|
||||||
availableHeadcount?: T;
|
|
||||||
updatedAt?: T;
|
|
||||||
createdAt?: T;
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* This interface was referenced by `Config`'s JSON-Schema
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "game-event-logs_select".
|
* via the `definition` "game-event-logs_select".
|
||||||
|
|
@ -4272,7 +4072,6 @@ export interface MissionsSelect<T extends boolean = true> {
|
||||||
discordMessageId?: T;
|
discordMessageId?: T;
|
||||||
discordAttendanceHash?: T;
|
discordAttendanceHash?: T;
|
||||||
discordAttendanceSentAt?: T;
|
discordAttendanceSentAt?: T;
|
||||||
evaluationRemindersSentAt?: T;
|
|
||||||
coverImage?: T;
|
coverImage?: T;
|
||||||
classification?:
|
classification?:
|
||||||
| T
|
| T
|
||||||
|
|
@ -4728,24 +4527,6 @@ export interface StructuresSelect<T extends boolean = true> {
|
||||||
id?: T;
|
id?: T;
|
||||||
};
|
};
|
||||||
sellPrice?: T;
|
sellPrice?: T;
|
||||||
maintenanceCost?: T;
|
|
||||||
laborRequirements?:
|
|
||||||
| T
|
|
||||||
| {
|
|
||||||
classification?: T;
|
|
||||||
requiredHeadcount?: T;
|
|
||||||
maxHeadcount?: T;
|
|
||||||
enforcedSalary?: T;
|
|
||||||
id?: T;
|
|
||||||
};
|
|
||||||
staffPositions?:
|
|
||||||
| T
|
|
||||||
| {
|
|
||||||
positionType?: T;
|
|
||||||
max?: T;
|
|
||||||
enforcedSalary?: T;
|
|
||||||
id?: T;
|
|
||||||
};
|
|
||||||
functionType?: T;
|
functionType?: T;
|
||||||
canProduce?: T;
|
canProduce?: T;
|
||||||
autoGeneratedResources?:
|
autoGeneratedResources?:
|
||||||
|
|
@ -5320,26 +5101,6 @@ export interface GameRule {
|
||||||
* The base currency ("gold standard") used by all factions to pay for goods and services.
|
* The base currency ("gold standard") used by all factions to pay for goods and services.
|
||||||
*/
|
*/
|
||||||
mainCurrency: number | Resource;
|
mainCurrency: number | Resource;
|
||||||
/**
|
|
||||||
* General display name for the currency, e.g. "credit". Used when singular or plural names are not set.
|
|
||||||
*/
|
|
||||||
currencyName?: string | null;
|
|
||||||
/**
|
|
||||||
* Optional symbol prefixed to amounts, e.g. "¢" or "$".
|
|
||||||
*/
|
|
||||||
currencySymbol?: string | null;
|
|
||||||
/**
|
|
||||||
* Singular amount label, e.g. "credit".
|
|
||||||
*/
|
|
||||||
currencySingular?: string | null;
|
|
||||||
/**
|
|
||||||
* Plural amount label, e.g. "credits".
|
|
||||||
*/
|
|
||||||
currencyPlural?: string | null;
|
|
||||||
/**
|
|
||||||
* Optional subunit name for future fractional displays, e.g. "cent". This does not affect stored values.
|
|
||||||
*/
|
|
||||||
currencyDenomination?: string | null;
|
|
||||||
/**
|
/**
|
||||||
* Assignment(s) considered unit command. Members and leaders of these assignments get elevated access to draft missions and other restricted content.
|
* Assignment(s) considered unit command. Members and leaders of these assignments get elevated access to draft missions and other restricted content.
|
||||||
*/
|
*/
|
||||||
|
|
@ -5364,14 +5125,6 @@ export interface GameRule {
|
||||||
* Height of each member's personal locker grid, in cells. Applies to newly provisioned lockers.
|
* Height of each member's personal locker grid, in cells. Applies to newly provisioned lockers.
|
||||||
*/
|
*/
|
||||||
lockerGridHeight?: number | null;
|
lockerGridHeight?: number | null;
|
||||||
/**
|
|
||||||
* When off, logistics officers cannot hire staff anywhere (both labor crews and named NPC staff).
|
|
||||||
*/
|
|
||||||
staffHiringEnabled?: boolean | null;
|
|
||||||
/**
|
|
||||||
* Message shown to logistics officers when hiring is disabled, e.g. explaining the in-universe reason.
|
|
||||||
*/
|
|
||||||
staffHiringDisabledMessage?: string | null;
|
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -5440,19 +5193,12 @@ export interface Shim {
|
||||||
*/
|
*/
|
||||||
export interface GameRulesSelect<T extends boolean = true> {
|
export interface GameRulesSelect<T extends boolean = true> {
|
||||||
mainCurrency?: T;
|
mainCurrency?: T;
|
||||||
currencyName?: T;
|
|
||||||
currencySymbol?: T;
|
|
||||||
currencySingular?: T;
|
|
||||||
currencyPlural?: T;
|
|
||||||
currencyDenomination?: T;
|
|
||||||
commandAssignments?: T;
|
commandAssignments?: T;
|
||||||
intelAssignments?: T;
|
intelAssignments?: T;
|
||||||
proximityThreshold?: T;
|
proximityThreshold?: T;
|
||||||
gameTickIntervalMinutes?: T;
|
gameTickIntervalMinutes?: T;
|
||||||
lockerGridWidth?: T;
|
lockerGridWidth?: T;
|
||||||
lockerGridHeight?: T;
|
lockerGridHeight?: T;
|
||||||
staffHiringEnabled?: T;
|
|
||||||
staffHiringDisabledMessage?: T;
|
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: T;
|
createdAt?: T;
|
||||||
globalType?: T;
|
globalType?: T;
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,6 @@ import { GameHardResources } from "@/collections/game/GameHardResources";
|
||||||
import { GameEventLogs } from "@/collections/game/GameEventLogs";
|
import { GameEventLogs } from "@/collections/game/GameEventLogs";
|
||||||
import { GameVehicles } from "@/collections/game/GameVehicles";
|
import { GameVehicles } from "@/collections/game/GameVehicles";
|
||||||
import { GameNpcs } from "@/collections/game/GameNpcs";
|
import { GameNpcs } from "@/collections/game/GameNpcs";
|
||||||
import { NpcStaffing } from "@/collections/game/NpcStaffing";
|
|
||||||
import { LaborClassifications } from "@/collections/game/LaborClassifications";
|
|
||||||
import { Experience } from "@/collections/users/Experience";
|
import { Experience } from "@/collections/users/Experience";
|
||||||
import { BankAccounts } from "@/collections/banking/BankAccounts";
|
import { BankAccounts } from "@/collections/banking/BankAccounts";
|
||||||
import { BankTransactions } from "@/collections/banking/BankTransactions";
|
import { BankTransactions } from "@/collections/banking/BankTransactions";
|
||||||
|
|
@ -153,8 +151,6 @@ const collections = [
|
||||||
GameStructures,
|
GameStructures,
|
||||||
GameVehicles,
|
GameVehicles,
|
||||||
GameNpcs,
|
GameNpcs,
|
||||||
NpcStaffing,
|
|
||||||
LaborClassifications,
|
|
||||||
GameEventLogs,
|
GameEventLogs,
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
|
|
@ -318,10 +314,6 @@ export default buildConfig({
|
||||||
scriptPath: path.resolve(dirname, "scripts/serverTick.ts"),
|
scriptPath: path.resolve(dirname, "scripts/serverTick.ts"),
|
||||||
key: "server-tick",
|
key: "server-tick",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
scriptPath: path.resolve(dirname, "scripts/baseTick.ts"),
|
|
||||||
key: "base-tick",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
scriptPath: path.resolve(dirname, "scripts/generateNextMission.ts"),
|
scriptPath: path.resolve(dirname, "scripts/generateNextMission.ts"),
|
||||||
key: "generate-mission",
|
key: "generate-mission",
|
||||||
|
|
@ -337,8 +329,6 @@ export default buildConfig({
|
||||||
[GameStructures.slug]: true,
|
[GameStructures.slug]: true,
|
||||||
[GameVehicles.slug]: true,
|
[GameVehicles.slug]: true,
|
||||||
[GameNpcs.slug]: true,
|
[GameNpcs.slug]: true,
|
||||||
[NpcStaffing.slug]: true,
|
|
||||||
[LaborClassifications.slug]: true,
|
|
||||||
[GameEventLogs.slug]: true,
|
[GameEventLogs.slug]: true,
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import type { SanitizedConfig } from "payload";
|
|
||||||
import { getPayload } from "payload";
|
|
||||||
import { processBaseTick } from "@/lib/base";
|
|
||||||
import { createBinLogger, errorMessage } from "./lib/binFileLogger";
|
|
||||||
|
|
||||||
export const script = async (config: SanitizedConfig) => {
|
|
||||||
const payload = await getPayload({ config });
|
|
||||||
const log = createBinLogger("base-tick", payload.logger);
|
|
||||||
|
|
||||||
try {
|
|
||||||
log.info("[Base Tick] Starting base tick...");
|
|
||||||
const processedAt = new Date().toISOString();
|
|
||||||
const result = await processBaseTick(payload);
|
|
||||||
log.info(
|
|
||||||
`[Base Tick] Complete. Salaries paid ${result.salariesPaid}, unpaid ${result.salariesUnpaid}; maintenance paid ${result.maintenancePaid}, unpaid ${result.maintenanceUnpaid}; upkeep shortages ${result.upkeepShortages}.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const notifyUrl = (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
|
|
||||||
const notifySecret = process.env.GAME_TICK_NOTIFY_SECRET;
|
|
||||||
if (notifySecret) {
|
|
||||||
await fetch(`${notifyUrl}/api/game-tick/notify`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"content-type": "application/json",
|
|
||||||
"x-game-tick-secret": notifySecret,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ processedAt, source: "base-tick" }),
|
|
||||||
}).catch((error: unknown) => {
|
|
||||||
log.error(`[Base Tick] Failed to notify app clients: ${errorMessage(error)}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
log.error(
|
|
||||||
`[Base Tick] Fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`,
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -20,7 +20,6 @@ interface GameEventInput {
|
||||||
| "game-structures"
|
| "game-structures"
|
||||||
| "game-vehicles"
|
| "game-vehicles"
|
||||||
| "game-npcs"
|
| "game-npcs"
|
||||||
| "npc-staffing"
|
|
||||||
| "shipments"
|
| "shipments"
|
||||||
| "bank-accounts"
|
| "bank-accounts"
|
||||||
| "bank-transactions"
|
| "bank-transactions"
|
||||||
|
|
|
||||||
|
|
@ -16,17 +16,6 @@ export const EventTypes = {
|
||||||
|
|
||||||
// Structure
|
// Structure
|
||||||
StructureResize: "structure:resize",
|
StructureResize: "structure:resize",
|
||||||
StructureUpgraded: "structure:upgraded",
|
|
||||||
UpkeepShort: "structure:upkeep-short",
|
|
||||||
|
|
||||||
// Staffing
|
|
||||||
StaffHired: "staff:hired",
|
|
||||||
StaffTerminated: "staff:terminated",
|
|
||||||
StaffHeadcountSet: "staff:headcount-set",
|
|
||||||
SalaryPaid: "staff:salary-paid",
|
|
||||||
SalaryUnpaid: "staff:salary-unpaid",
|
|
||||||
MaintenancePaid: "structure:maintenance-paid",
|
|
||||||
MaintenanceUnpaid: "structure:maintenance-unpaid",
|
|
||||||
|
|
||||||
// Finance
|
// Finance
|
||||||
FinanceDeposit: "finance:deposit",
|
FinanceDeposit: "finance:deposit",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue