1
0
Fork 0
polaris-task-force/src/components/frontend/baseManagement/StaffRoster.tsx
Z8MB1E 9e44b69714 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>
2026-09-06 01:24:14 -04:00

437 lines
17 KiB
TypeScript

"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>
</>
);
}