Finalish MVP
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
type Heartbeat = {
|
||||
ts: string;
|
||||
@@ -61,12 +62,13 @@ const EVENT_WINDOW_SEC = 1800;
|
||||
const MAX_EVENT_MACHINES = 6;
|
||||
const TOL = 0.10;
|
||||
|
||||
function secondsAgo(ts?: string) {
|
||||
if (!ts) return "never";
|
||||
function secondsAgo(ts: string | undefined, locale: string, fallback: string) {
|
||||
if (!ts) return fallback;
|
||||
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
return `${Math.floor(diff / 3600)}h ago`;
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
if (diff < 60) return rtf.format(-diff, "second");
|
||||
if (diff < 3600) return rtf.format(-Math.floor(diff / 60), "minute");
|
||||
return rtf.format(-Math.floor(diff / 3600), "hour");
|
||||
}
|
||||
|
||||
function isOffline(ts?: string) {
|
||||
@@ -135,6 +137,7 @@ function classifyDerivedEvent(c: CycleRow) {
|
||||
}
|
||||
|
||||
export default function OverviewPage() {
|
||||
const { t, locale } = useI18n();
|
||||
const [machines, setMachines] = useState<MachineRow[]>([]);
|
||||
const [events, setEvents] = useState<EventRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -345,72 +348,93 @@ export default function OverviewPage() {
|
||||
return list;
|
||||
}, [machines]);
|
||||
|
||||
const formatEventType = (eventType?: string) => {
|
||||
if (!eventType) return "";
|
||||
const key = `overview.event.${eventType}`;
|
||||
const label = t(key);
|
||||
return label === key ? eventType : label;
|
||||
};
|
||||
|
||||
const formatSource = (source?: string) => {
|
||||
if (!source) return "";
|
||||
const key = `overview.source.${source}`;
|
||||
const label = t(key);
|
||||
return label === key ? source : label;
|
||||
};
|
||||
|
||||
const formatSeverity = (severity?: string) => {
|
||||
if (!severity) return "";
|
||||
const key = `overview.severity.${severity}`;
|
||||
const label = t(key);
|
||||
return label === key ? severity.toUpperCase() : label;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">Overview</h1>
|
||||
<p className="text-sm text-zinc-400">Fleet pulse, alerts, and top attention items.</p>
|
||||
<h1 className="text-2xl font-semibold text-white">{t("overview.title")}</h1>
|
||||
<p className="text-sm text-zinc-400">{t("overview.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/machines"
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
|
||||
>
|
||||
View Machines
|
||||
{t("overview.viewMachines")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading && <div className="mb-4 text-sm text-zinc-400">Loading overview...</div>}
|
||||
{loading && <div className="mb-4 text-sm text-zinc-400">{t("overview.loading")}</div>}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">Fleet Health</div>
|
||||
<div className="text-xs text-zinc-400">{t("overview.fleetHealth")}</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-white">{stats.total}</div>
|
||||
<div className="mt-2 text-xs text-zinc-400">Machines total</div>
|
||||
<div className="mt-2 text-xs text-zinc-400">{t("overview.machinesTotal")}</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs">
|
||||
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-emerald-300">
|
||||
Online {stats.online}
|
||||
{t("overview.online")} {stats.online}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-zinc-300">
|
||||
Offline {stats.offline}
|
||||
{t("overview.offline")} {stats.offline}
|
||||
</span>
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-emerald-200">
|
||||
Run {stats.running}
|
||||
{t("overview.run")} {stats.running}
|
||||
</span>
|
||||
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
|
||||
Idle {stats.idle}
|
||||
{t("overview.idle")} {stats.idle}
|
||||
</span>
|
||||
<span className="rounded-full bg-red-500/15 px-2 py-0.5 text-red-300">
|
||||
Stop {stats.stopped}
|
||||
{t("overview.stop")} {stats.stopped}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">Production Totals</div>
|
||||
<div className="text-xs text-zinc-400">{t("overview.productionTotals")}</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-3">
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-[11px] text-zinc-400">Good</div>
|
||||
<div className="text-[11px] text-zinc-400">{t("overview.good")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(stats.goodSum)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-[11px] text-zinc-400">Scrap</div>
|
||||
<div className="text-[11px] text-zinc-400">{t("overview.scrap")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(stats.scrapSum)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-[11px] text-zinc-400">Target</div>
|
||||
<div className="text-[11px] text-zinc-400">{t("overview.target")}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(stats.targetSum)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-zinc-400">Sum of latest KPIs across machines.</div>
|
||||
<div className="mt-3 text-xs text-zinc-400">{t("overview.kpiSumNote")}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">Activity Feed</div>
|
||||
<div className="text-xs text-zinc-400">{t("overview.activityFeed")}</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-white">{events.length}</div>
|
||||
<div className="mt-2 text-xs text-zinc-400">
|
||||
{eventsLoading ? "Refreshing recent events..." : "Last 30 merged events"}
|
||||
{eventsLoading ? t("overview.eventsRefreshing") : t("overview.eventsLast30")}
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
{events.slice(0, 3).map((e) => (
|
||||
@@ -419,11 +443,13 @@ export default function OverviewPage() {
|
||||
{e.machineName ? `${e.machineName}: ` : ""}
|
||||
{e.title}
|
||||
</div>
|
||||
<div className="shrink-0 text-zinc-500">{secondsAgo(e.ts)}</div>
|
||||
<div className="shrink-0 text-zinc-500">
|
||||
{secondsAgo(e.ts, locale, t("common.never"))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{events.length === 0 && !eventsLoading ? (
|
||||
<div className="text-xs text-zinc-500">No recent events.</div>
|
||||
<div className="text-xs text-zinc-500">{t("overview.eventsNone")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -431,19 +457,19 @@ export default function OverviewPage() {
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">OEE (avg)</div>
|
||||
<div className="text-xs text-zinc-400">{t("overview.oeeAvg")}</div>
|
||||
<div className="mt-2 text-3xl font-semibold text-emerald-300">{fmtPct(stats.oee)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">Availability (avg)</div>
|
||||
<div className="text-xs text-zinc-400">{t("overview.availabilityAvg")}</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(stats.availability)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">Performance (avg)</div>
|
||||
<div className="text-xs text-zinc-400">{t("overview.performanceAvg")}</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(stats.performance)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-xs text-zinc-400">Quality (avg)</div>
|
||||
<div className="text-xs text-zinc-400">{t("overview.qualityAvg")}</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(stats.quality)}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -451,11 +477,13 @@ export default function OverviewPage() {
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-1">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-sm font-semibold text-white">Attention List</div>
|
||||
<div className="text-xs text-zinc-400">{attention.length} shown</div>
|
||||
<div className="text-sm font-semibold text-white">{t("overview.attentionList")}</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
{attention.length} {t("overview.shown")}
|
||||
</div>
|
||||
</div>
|
||||
{attention.length === 0 ? (
|
||||
<div className="text-sm text-zinc-400">No urgent issues detected.</div>
|
||||
<div className="text-sm text-zinc-400">{t("overview.noUrgent")}</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{attention.map(({ machine, offline, oee }) => (
|
||||
@@ -467,7 +495,9 @@ export default function OverviewPage() {
|
||||
{machine.code ?? ""} {machine.location ? `- ${machine.location}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">{secondsAgo(machine.latestHeartbeat?.ts)}</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
{secondsAgo(machine.latestHeartbeat?.ts, locale, t("common.never"))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
@@ -475,7 +505,7 @@ export default function OverviewPage() {
|
||||
offline ? "bg-white/10 text-zinc-300" : "bg-emerald-500/15 text-emerald-300"
|
||||
}`}
|
||||
>
|
||||
{offline ? "OFFLINE" : "ONLINE"}
|
||||
{offline ? t("overview.status.offline") : t("overview.status.online")}
|
||||
</span>
|
||||
{oee != null && (
|
||||
<span className="rounded-full bg-yellow-500/15 px-2 py-0.5 text-yellow-300">
|
||||
@@ -491,12 +521,14 @@ export default function OverviewPage() {
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-sm font-semibold text-white">Unified Timeline</div>
|
||||
<div className="text-xs text-zinc-400">{events.length} items</div>
|
||||
<div className="text-sm font-semibold text-white">{t("overview.timeline")}</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
{events.length} {t("overview.items")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{events.length === 0 && !eventsLoading ? (
|
||||
<div className="text-sm text-zinc-400">No events yet.</div>
|
||||
<div className="text-sm text-zinc-400">{t("overview.noEvents")}</div>
|
||||
) : (
|
||||
<div className="h-[360px] space-y-3 overflow-y-auto no-scrollbar">
|
||||
{events.map((e) => (
|
||||
@@ -505,16 +537,18 @@ export default function OverviewPage() {
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs ${severityClass(e.severity)}`}>
|
||||
{e.severity.toUpperCase()}
|
||||
{formatSeverity(e.severity)}
|
||||
</span>
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-zinc-200">
|
||||
{e.eventType}
|
||||
{formatEventType(e.eventType)}
|
||||
</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs ${sourceClass(e.source)}`}>
|
||||
{e.source}
|
||||
{formatSource(e.source)}
|
||||
</span>
|
||||
{e.requiresAck ? (
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">ACK</span>
|
||||
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">
|
||||
{t("overview.ack")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -526,7 +560,9 @@ export default function OverviewPage() {
|
||||
<div className="mt-1 text-sm text-zinc-300">{e.description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shrink-0 text-xs text-zinc-400">{secondsAgo(e.ts)}</div>
|
||||
<div className="shrink-0 text-xs text-zinc-400">
|
||||
{secondsAgo(e.ts, locale, t("common.never"))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user