Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
156 lines
5.5 KiB
TypeScript
156 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { ConstructionEta } from "@/components/frontend/baseManagement/ConstructionEta";
|
|
|
|
interface ConstructionMaterial {
|
|
name: string;
|
|
required: number;
|
|
delivered: number;
|
|
}
|
|
|
|
interface ConstructionPanelProps {
|
|
status: "awaiting_materials" | "building" | "complete" | null;
|
|
startedAt: string | null;
|
|
completesAt: string | null;
|
|
durationMinutes: number | null;
|
|
materials: ConstructionMaterial[];
|
|
crew?: { required: number; effective: number } | null;
|
|
}
|
|
|
|
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
|
|
awaiting_materials: {
|
|
label: "Awaiting Materials",
|
|
className: "border-amber-500/40 bg-amber-500/10 text-amber-300",
|
|
},
|
|
building: {
|
|
label: "Building",
|
|
className: "border-blue-500/40 bg-blue-500/10 text-blue-300",
|
|
},
|
|
complete: {
|
|
label: "Operational",
|
|
className: "border-emerald-500/40 bg-emerald-500/10 text-emerald-300",
|
|
},
|
|
};
|
|
|
|
function progressPct(startedAt: string | null, completesAt: string | null, nowMs: number): number {
|
|
if (!startedAt || !completesAt) return 0;
|
|
const start = new Date(startedAt).getTime();
|
|
const end = new Date(completesAt).getTime();
|
|
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return 0;
|
|
const ratio = (nowMs - start) / (end - start);
|
|
if (!Number.isFinite(ratio)) return 0;
|
|
return Math.min(100, Math.max(0, Math.round(ratio * 100)));
|
|
}
|
|
|
|
export function ConstructionPanel({
|
|
status,
|
|
startedAt,
|
|
completesAt,
|
|
durationMinutes,
|
|
materials,
|
|
crew,
|
|
}: ConstructionPanelProps) {
|
|
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
|
|
useEffect(() => {
|
|
const timer = setInterval(() => setNowMs(Date.now()), 1000);
|
|
return () => clearInterval(timer);
|
|
}, []);
|
|
|
|
if (!status || status === "complete") return null;
|
|
|
|
const statusInfo = STATUS_LABELS[status] ?? STATUS_LABELS.awaiting_materials;
|
|
const allDelivered = materials.every((material) => material.delivered >= material.required);
|
|
const pct = status === "building" ? progressPct(startedAt, completesAt, nowMs) : 0;
|
|
|
|
const crewRequired = crew?.required ?? 0;
|
|
const crewEffective = crew?.effective ?? 0;
|
|
const crewFactor = crewRequired > 0 ? Math.min(1, crewEffective / crewRequired) : 1;
|
|
const crewLine =
|
|
crewRequired > 0
|
|
? crewEffective <= 0
|
|
? {
|
|
text:
|
|
status === "building"
|
|
? "No crew assigned: construction is crawling at minimum speed."
|
|
: "No crew assigned: construction cannot start once materials arrive.",
|
|
className: "text-amber-300",
|
|
}
|
|
: crewFactor < 1
|
|
? {
|
|
text: `Crew ${crewEffective.toLocaleString()} / ${crewRequired.toLocaleString()}: understaffed, building at ${Math.round(crewFactor * 100)}% speed.`,
|
|
className: "text-amber-300",
|
|
}
|
|
: {
|
|
text: `Crew ${crewEffective.toLocaleString()} / ${crewRequired.toLocaleString()}: full build speed.`,
|
|
className: "text-emerald-400",
|
|
}
|
|
: null;
|
|
|
|
return (
|
|
<div className="rounded-lg border border-border p-5 flex flex-col gap-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h2 className="text-sm font-semibold">Construction</h2>
|
|
<span
|
|
className={`rounded-sm border px-2 py-0.5 text-xs uppercase tracking-wide ${statusInfo.className}`}
|
|
>
|
|
{statusInfo.label}
|
|
</span>
|
|
</div>
|
|
|
|
{status === "building" && (
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex justify-between text-xs text-muted-foreground">
|
|
<span>
|
|
Build time {durationMinutes != null ? `${durationMinutes.toLocaleString()} min` : ""}
|
|
</span>
|
|
<span>{pct}%</span>
|
|
</div>
|
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
|
<div className="h-full rounded-full bg-blue-400" style={{ width: `${pct}%` }} />
|
|
</div>
|
|
<div className="flex justify-end text-xs tabular-nums text-muted-foreground">
|
|
<ConstructionEta completesAt={completesAt} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{status === "awaiting_materials" && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Ship the required materials to this site. Construction starts automatically once
|
|
everything has arrived.
|
|
</p>
|
|
)}
|
|
|
|
{crewLine && <p className={`text-xs ${crewLine.className}`}>{crewLine.text}</p>}
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
{materials.map((material) => {
|
|
const met = material.delivered >= material.required;
|
|
return (
|
|
<div
|
|
key={material.name}
|
|
className="flex items-center justify-between gap-2 rounded-md bg-muted/40 px-3 py-1.5 text-sm"
|
|
>
|
|
<span>{material.name}</span>
|
|
<span className={`tabular-nums ${met ? "text-emerald-400" : "text-amber-300"}`}>
|
|
{material.delivered.toLocaleString()} / {material.required.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{materials.length === 0 && (
|
|
<p className="text-xs text-muted-foreground">No materials required by this blueprint.</p>
|
|
)}
|
|
</div>
|
|
|
|
{status === "awaiting_materials" && !allDelivered && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{materials.filter((material) => material.delivered < material.required).length} material
|
|
type(s) still missing.
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|