Files
MIS-Contro-Tower/components/recap/RecapMachineCard.tsx
2026-04-26 16:31:04 +00:00

157 lines
5.9 KiB
TypeScript

"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useI18n } from "@/lib/i18n/useI18n";
import { RECAP_HEARTBEAT_STALE_MS } from "@/lib/recap/recapUiConstants";
import type { RecapSummaryMachine, RecapTimelineResponse } from "@/lib/recap/types";
import RecapMiniTimeline from "@/components/recap/RecapMiniTimeline";
type Props = {
machine: RecapSummaryMachine;
rangeStart: string;
rangeEnd: string;
};
const STATUS_DOT: Record<RecapSummaryMachine["status"], string> = {
running: "bg-emerald-400",
"mold-change": "bg-amber-400",
stopped: "bg-red-500",
offline: "bg-zinc-500",
};
function statusLabel(status: RecapSummaryMachine["status"], t: (key: string) => string) {
if (status === "running") return t("recap.status.running");
if (status === "mold-change") return t("recap.status.moldChange");
if (status === "stopped") return t("recap.status.stopped");
return t("recap.status.offline");
}
function toInt(value: number | null | undefined) {
if (value == null || Number.isNaN(value)) return 0;
return Math.max(0, Math.round(value));
}
export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Props) {
const { t, locale } = useI18n();
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
const [nowMs, setNowMs] = useState(() => Date.now());
const zeroActivity = machine.goodParts === 0 && machine.scrap === 0 && machine.stopsCount === 0;
const primaryMetric = machine.oee == null ? "—" : `${machine.oee.toFixed(1)}%`;
const timelineSegments = timeline?.segments ?? machine.miniTimeline;
const timelineStart = timeline?.range.start ?? rangeStart;
const timelineEnd = timeline?.range.end ?? rangeEnd;
const hasTimelineData = timeline?.hasData ?? timelineSegments.length > 0;
const staleHeartbeat =
machine.lastSeenMs == null ? true : nowMs - machine.lastSeenMs > RECAP_HEARTBEAT_STALE_MS;
const lastSeenLabel =
machine.lastActivityMin == null
? t("common.never")
: t("recap.card.lastActivity", { min: toInt(machine.lastActivityMin) });
const footerText = machine.activeWorkOrderId
? t("recap.card.activeWorkOrder", { id: machine.activeWorkOrderId })
: lastSeenLabel;
const moldMinutes = machine.moldChange?.active ? machine.moldChange.elapsedMin : null;
useEffect(() => {
const timer = window.setInterval(() => setNowMs(Date.now()), 60000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
let alive = true;
async function loadTimeline() {
try {
const res = await fetch(
`/api/recap/${machine.machineId}/timeline?range=24h&compact=1&maxSegments=30`,
{ cache: "no-store" }
);
const json = await res.json().catch(() => null);
if (!alive || !res.ok || !json) return;
setTimeline(json as RecapTimelineResponse);
} catch {
}
}
void loadTimeline();
const timer = window.setInterval(() => {
void loadTimeline();
}, 60000);
return () => {
alive = false;
window.clearInterval(timer);
};
}, [machine.machineId]);
return (
<Link
href={`/recap/${machine.machineId}`}
className="rounded-2xl border border-white/10 bg-white/5 p-4 transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/80"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-lg font-semibold text-white">{machine.name}</div>
<div className="mt-1 truncate text-xs text-zinc-400">{machine.location || t("common.na")}</div>
</div>
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 px-2 py-1 text-xs text-zinc-200">
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${STATUS_DOT[machine.status]}`}
aria-label={statusLabel(machine.status, t)}
/>
{statusLabel(machine.status, t)}
</span>
</div>
<div className="mt-4 flex items-baseline gap-2">
<div className={`text-3xl font-semibold ${machine.oee == null ? "text-zinc-400" : "text-white"}`}>{primaryMetric}</div>
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.card.oee")}</div>
</div>
{machine.oee == null ? <div className="mt-1 text-xs text-zinc-500">{t("recap.kpi.noData")}</div> : null}
{zeroActivity ? <div className="mt-1 text-xs text-zinc-500">{t("recap.card.noProduction")}</div> : null}
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-300">
<span>{t("recap.card.good")}: {machine.goodParts}</span>
<span>{t("recap.card.scrap")}: {machine.scrap}</span>
<span>{t("recap.card.stops")}: {machine.stopsCount}</span>
</div>
<div className="mt-3">
<RecapMiniTimeline
rangeStart={timelineStart}
rangeEnd={timelineEnd}
segments={timelineSegments}
locale={locale}
hasData={hasTimelineData}
muted={zeroActivity}
/>
</div>
{machine.moldChange?.active ? (
<div className="mt-3 rounded-lg border border-amber-400/40 bg-amber-400/10 px-2 py-1.5 text-xs text-amber-200">
{t("recap.card.moldChangeActive", { min: toInt(moldMinutes) })}
</div>
) : null}
{machine.offlineForMin != null && machine.offlineForMin > 10 ? (
<div className="mt-2 rounded-lg border border-red-500/40 bg-red-500/10 px-2 py-1.5 text-xs text-red-200">
{t("recap.banner.offline", { min: toInt(machine.offlineForMin) })}
</div>
) : null}
{staleHeartbeat ? (
<div className="mt-2 rounded-lg border border-amber-400/40 bg-amber-400/10 px-2 py-1.5 text-xs text-amber-200">
{t("recap.card.desynced")}
</div>
) : null}
<div className="mt-3 text-xs text-zinc-400">{footerText}</div>
</Link>
);
}