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>
437 lines
17 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|