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>
564 lines
20 KiB
TypeScript
564 lines
20 KiB
TypeScript
"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>
|
|
);
|
|
}
|