From 9e44b6971447dfe13378cf62a89e9f35495aae06 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Sun, 6 Sep 2026 01:24:14 -0400 Subject: [PATCH] feat(base): add structure staffing and upgrade management UI Adds hire/terminate staff actions and an upgradeStructure server action plus baseManagement components (employment card, hire dialog, staff roster, upgrade card) wired into the structure detail page. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/app/(frontend)/logistics/base/actions.ts | 106 ++++ .../logistics/structures/[id]/page.tsx | 174 ++++++ .../baseManagement/EmploymentCard.tsx | 84 +++ .../baseManagement/HireStaffDialog.tsx | 564 ++++++++++++++++++ .../frontend/baseManagement/StaffRoster.tsx | 437 ++++++++++++++ .../frontend/baseManagement/UpgradeCard.tsx | 98 +++ 6 files changed, 1463 insertions(+) create mode 100644 src/app/(frontend)/logistics/base/actions.ts create mode 100644 src/components/frontend/baseManagement/EmploymentCard.tsx create mode 100644 src/components/frontend/baseManagement/HireStaffDialog.tsx create mode 100644 src/components/frontend/baseManagement/StaffRoster.tsx create mode 100644 src/components/frontend/baseManagement/UpgradeCard.tsx diff --git a/src/app/(frontend)/logistics/base/actions.ts b/src/app/(frontend)/logistics/base/actions.ts new file mode 100644 index 0000000..b2ddda6 --- /dev/null +++ b/src/app/(frontend)/logistics/base/actions.ts @@ -0,0 +1,106 @@ +"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 = { 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 { + 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> { + 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> { + 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> { + 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> { + 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); + } +} diff --git a/src/app/(frontend)/logistics/structures/[id]/page.tsx b/src/app/(frontend)/logistics/structures/[id]/page.tsx index f4faee2..a38ff23 100644 --- a/src/app/(frontend)/logistics/structures/[id]/page.tsx +++ b/src/app/(frontend)/logistics/structures/[id]/page.tsx @@ -8,6 +8,8 @@ import { convertLexicalToPlaintext } from "@payloadcms/richtext-lexical/plaintex import { CapacityBar } from "@/components/frontend/storage/CapacityBar"; import { StorageRules } from "@/components/frontend/storage/StorageRules"; import { StructureStorageView } from "@/components/frontend/storage/StructureStorageView"; +import { StaffRoster } from "@/components/frontend/baseManagement/StaffRoster"; +import { UpgradeCard } from "@/components/frontend/baseManagement/UpgradeCard"; import { calculateStoredMass, calculateUtilization, @@ -16,6 +18,9 @@ import { getGridSlots, } from "@/lib/logistics"; 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 { ArrowLeftIcon, BoxIcon, @@ -78,6 +83,162 @@ export default async function StructureDetailPage({ params }: StructureDetailPag : 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 resource = entry.resource as Resource; if (!resource || typeof resource === "number") return sum; @@ -239,6 +400,19 @@ export default async function StructureDetailPage({ params }: StructureDetailPag )} + + + + + + + + Employment + + Current base assignment and compensation. + + + {employment ? ( +
+

{employment.positionTitle}

+

+ {employment.positionType} · {employment.structureName} +

+

+ {formatAmount(employment.salary, currencyConfig)}/tick +

+
+ ) : canManage && hiringEnabled ? ( +
+

Not currently employed.

+ +
+ ) : canManage ? ( +
+

Not currently employed.

+

+ {hiringDisabledMessage || "Staff hiring is currently disabled."} +

+
+ ) : ( +

Not currently employed.

+ )} +
+ + ); +} diff --git a/src/components/frontend/baseManagement/HireStaffDialog.tsx b/src/components/frontend/baseManagement/HireStaffDialog.tsx new file mode 100644 index 0000000..a6862a1 --- /dev/null +++ b/src/components/frontend/baseManagement/HireStaffDialog.tsx @@ -0,0 +1,564 @@ +"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(null); + + if (!canManage) return null; + + if (!hiringEnabled) { + return ( + + ); + } + + 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 ( + { + setOpen(nextOpen); + if (!nextOpen) reset(); + }} + > + + + + + + Hire staff + + {positionType === "labor" + ? "Set a classification-based labor crew for a structure." + : "Assign an NPC to a structure and define their role."} + + + +
+ {positionType !== "labor" && ( +
+ + {fixedNpcId != null ? ( +

+ {fixedNpc?.name ?? `NPC #${fixedNpcId}`} +

+ ) : ( + + )} +
+ )} + +
+ + +
+ +
+ + + {selectedStructure && + approvedClassifications.length === 0 && + approvedPositionTypes.length === 0 && ( +

+ This structure has no approved staff positions. +

+ )} +
+ + {positionType === "labor" ? ( + <> +
+ + + {selectedStructure && approvedClassifications.length === 0 && ( +

+ This structure has no approved labor positions. +

+ )} +
+ +
+ + setHeadcount(event.target.value)} + placeholder="Number of workers" + disabled={!selectedRequirement || laborHeadcountMax < 1} + /> + {selectedRequirement && ( +

+ {selectedRequirement.currentHeadcount} hired ·{" "} + {selectedRequirement.requiredHeadcount} required · max{" "} + {selectedRequirement.maxHeadcount} +

+ )} +
+ +
+ + { + setSalaryDirty(true); + setSalary(event.target.value); + }} + placeholder="Per worker per base tick" + disabled={selectedRequirement?.enforcedSalary != null} + /> + {selectedRequirement?.enforcedSalary != null && ( +

Wage enforced by this structure.

+ )} + {selectedClassification && + effectiveAvailable < selectedClassification.availableHeadcount && ( +

+ Only {effectiveAvailable} of {selectedClassification.availableHeadcount}{" "} + {selectedClassification.name} workers are willing to work at this wage. +

+ )} +
+ + ) : ( + <> +
+ + setPositionTitle(event.target.value)} + placeholder="e.g. Head Surgeon, Site Director" + /> +
+ +
+ + { + setSalaryDirty(true); + setSalary(event.target.value); + }} + placeholder="Per base tick" + disabled={selectedPositionRule?.enforcedSalary != null} + /> + {selectedPositionRule?.enforcedSalary != null && ( +

+ Salary enforced by this structure. +

+ )} + {selectedPositionRule?.max != null && ( +

+ {selectedPositionRule.currentHeadcount} of {selectedPositionRule.max} positions + filled. +

+ )} +
+ + )} + + {error &&

{error}

} +
+ + + + + +
+
+ ); +} diff --git a/src/components/frontend/baseManagement/StaffRoster.tsx b/src/components/frontend/baseManagement/StaffRoster.tsx new file mode 100644 index 0000000..67a9efa --- /dev/null +++ b/src/components/frontend/baseManagement/StaffRoster.tsx @@ -0,0 +1,437 @@ +"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(staff); + const [fireTarget, setFireTarget] = useState(null); + const [loading, setLoading] = useState(false); + const [laborLoading, setLaborLoading] = useState(null); + const [laborEdits, setLaborEdits] = useState>({}); + + 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((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 ( + <> + + + + + Staff + + + {staffMembers.length} active assignment{staffMembers.length === 1 ? "" : "s"}. + + {structure.maintenanceCost > 0 && ( +

+ Maintenance: {formatAmount(structure.maintenanceCost, currencyConfig)}/tick +

+ )} +
+ + {namedStaff.length === 0 && !showLaborSection ? ( +

No staff assigned to this structure.

+ ) : ( +
+ {namedStaff.length > 0 && ( +
+ {namedStaff.map((member, index) => ( +
+ {index > 0 && } +
+
+

{member.npcName}

+

+ {member.positionTitle} · {member.positionType} +

+
+
+ + {formatAmount(member.salary, currencyConfig)}/tick + + {canManage && ( + + )} +
+
+
+ ))} +
+ )} + + {showLaborSection && ( + <> + {namedStaff.length > 0 && } +
+
+
+

Labor crew

+

+ Classification-based workers assigned to this structure. +

+
+ {canManage && hiringEnabled && ( + + )} +
+ + {!hiringEnabled && canManage && ( +

+ {hiringDisabledMessage || "Staff hiring is currently disabled."} +

+ )} + + {laborCrews.length === 0 ? ( +

+ No labor crew — add workers via Hire Staff. +

+ ) : ( +
+ {laborCrews.map((crew, index) => { + const edit = getLaborEdit(crew); + const crewIsLoading = laborLoading === crew.classificationId; + + return ( +
+ {index > 0 && } +
+
+
+

+ {crew.classificationName} — {crew.headcount} hired ·{" "} + {crew.requiredHeadcount} required · max {crew.maxHeadcount} +

+

+ {formatAmount(crew.unitSalary, currencyConfig)}/tick each ·{" "} + {formatAmount( + crew.unitSalary * crew.headcount, + currencyConfig, + )} + /tick total +

+
+
+ + {canManage && ( +
+
+ + + updateLaborEdit(crew, "headcount", event.target.value) + } + disabled={crewIsLoading || !hiringEnabled} + /> +
+
+ + + updateLaborEdit(crew, "unitSalary", event.target.value) + } + disabled={ + crewIsLoading || + !hiringEnabled || + crew.enforcedSalary !== null + } + /> +
+ +
+ )} +
+
+ ); + })} +
+ )} +
+ + )} +
+ )} +
+
+ + { + if (!open && !loading) setFireTarget(null); + }} + > + + + Fire {fireTarget?.npcName}? + This ends their assignment. + + + + + + + + + ); +} diff --git a/src/components/frontend/baseManagement/UpgradeCard.tsx b/src/components/frontend/baseManagement/UpgradeCard.tsx new file mode 100644 index 0000000..b14dad6 --- /dev/null +++ b/src/components/frontend/baseManagement/UpgradeCard.tsx @@ -0,0 +1,98 @@ +"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 ( + + + + + Upgrade + + Advance this structure to {upgrade.targetName}. + + +
+ + + + + + + + + {upgrade.materials.map((material) => ( + + + + + ))} + +
MaterialAvailable / Needed
{material.name} + {material.have.toLocaleString()} / {material.needed.toLocaleString()} +
+
+
+ +

+ Materials are taken from this structure's own storage. +

+ +
+
+ ); +}