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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
06dbf5b796
commit
9e44b69714
6 changed files with 1463 additions and 0 deletions
106
src/app/(frontend)/logistics/base/actions.ts
Normal file
106
src/app/(frontend)/logistics/base/actions.ts
Normal file
|
|
@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,8 @@ 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,
|
||||||
|
|
@ -16,6 +18,9 @@ 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,
|
||||||
|
|
@ -78,6 +83,162 @@ 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;
|
||||||
|
|
@ -239,6 +400,19 @@ 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
|
||||||
|
|
|
||||||
84
src/components/frontend/baseManagement/EmploymentCard.tsx
Normal file
84
src/components/frontend/baseManagement/EmploymentCard.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
564
src/components/frontend/baseManagement/HireStaffDialog.tsx
Normal file
564
src/components/frontend/baseManagement/HireStaffDialog.tsx
Normal file
|
|
@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
437
src/components/frontend/baseManagement/StaffRoster.tsx
Normal file
437
src/components/frontend/baseManagement/StaffRoster.tsx
Normal file
|
|
@ -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<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/components/frontend/baseManagement/UpgradeCard.tsx
Normal file
98
src/components/frontend/baseManagement/UpgradeCard.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue