reliability semi-fix
This commit is contained in:
@@ -20,6 +20,14 @@ import {
|
||||
} from "recharts";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||
import type { RecapTimelineResponse, RecapTimelineSegment } from "@/lib/recap/types";
|
||||
import {
|
||||
computeWidths,
|
||||
formatDuration,
|
||||
formatTime,
|
||||
normalizeTimelineSegments,
|
||||
TIMELINE_COLORS,
|
||||
} from "@/components/recap/timelineRender";
|
||||
|
||||
type Heartbeat = {
|
||||
ts: string;
|
||||
@@ -87,20 +95,6 @@ type Thresholds = {
|
||||
|
||||
type TimelineState = "normal" | "slow" | "microstop" | "macrostop";
|
||||
|
||||
type TimelineSeg = {
|
||||
start: number;
|
||||
end: number;
|
||||
durationSec: number;
|
||||
state: TimelineState;
|
||||
};
|
||||
|
||||
type ActiveStoppage = {
|
||||
state: "microstop" | "macrostop";
|
||||
startedAt: string;
|
||||
durationSec: number;
|
||||
theoreticalCycleTime: number;
|
||||
};
|
||||
|
||||
type UploadState = {
|
||||
status: "idle" | "parsing" | "uploading" | "success" | "error";
|
||||
message?: string;
|
||||
@@ -324,7 +318,6 @@ export default function MachineDetailClient() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cycles, setCycles] = useState<CycleRow[]>([]);
|
||||
const [thresholds, setThresholds] = useState<Thresholds | null>(null);
|
||||
const [activeStoppage, setActiveStoppage] = useState<ActiveStoppage | null>(null);
|
||||
const [open, setOpen] = useState<null | "events" | "deviation" | "impact">(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploadState, setUploadState] = useState<UploadState>({ status: "idle" });
|
||||
@@ -372,7 +365,6 @@ export default function MachineDetailClient() {
|
||||
setEventsCountAll(typeof json.eventsCountAll === "number" ? json.eventsCountAll : null);
|
||||
setCycles(json.cycles ?? []);
|
||||
setThresholds(json.thresholds ?? null);
|
||||
setActiveStoppage(json.activeStoppage ?? null);
|
||||
setError(null);
|
||||
if (initial) setLoading(false);
|
||||
} catch {
|
||||
@@ -691,84 +683,45 @@ export default function MachineDetailClient() {
|
||||
);
|
||||
}
|
||||
|
||||
function MachineActivityTimeline({
|
||||
cycles,
|
||||
cycleTarget,
|
||||
thresholds,
|
||||
activeStoppage,
|
||||
}: {
|
||||
cycles: CycleRow[];
|
||||
cycleTarget: number | null;
|
||||
thresholds: Thresholds | null;
|
||||
activeStoppage: ActiveStoppage | null;
|
||||
}) {
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
function MachineActivityTimeline({ machineId }: { machineId: string | undefined }) {
|
||||
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
||||
const [timelineLoading, setTimelineLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNowMs(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
if (!machineId) return;
|
||||
let alive = true;
|
||||
|
||||
const timeline = useMemo(() => {
|
||||
const rows = cycles ?? [];
|
||||
const windowSec = rows.length < 1 ? 10800 : 3600;
|
||||
const end = nowMs;
|
||||
const start = end - windowSec * 1000;
|
||||
|
||||
if (rows.length < 1) {
|
||||
return {
|
||||
windowSec,
|
||||
segments: [] as TimelineSeg[],
|
||||
start,
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
const segs: TimelineSeg[] = [];
|
||||
|
||||
for (const cycle of rows) {
|
||||
const ideal = (cycle.ideal ?? cycleTarget ?? 0) as number;
|
||||
const actual = cycle.actual ?? 0;
|
||||
if (!ideal || ideal <= 0 || !actual || actual <= 0) continue;
|
||||
|
||||
const cycleEnd = cycle.t;
|
||||
const cycleStart = cycleEnd - actual * 1000;
|
||||
if (cycleEnd <= start || cycleStart >= end) continue;
|
||||
|
||||
const segStart = Math.max(cycleStart, start);
|
||||
const segEnd = Math.min(cycleEnd, end);
|
||||
if (segEnd <= segStart) continue;
|
||||
|
||||
const state = classifyCycleDuration(actual, ideal, thresholds);
|
||||
|
||||
segs.push({
|
||||
start: segStart,
|
||||
end: segEnd,
|
||||
durationSec: (segEnd - segStart) / 1000,
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
if (activeStoppage?.startedAt) {
|
||||
const stoppageStart = new Date(activeStoppage.startedAt).getTime();
|
||||
const segStart = Math.max(stoppageStart, start);
|
||||
const segEnd = Math.min(end, nowMs);
|
||||
if (segEnd > segStart) {
|
||||
segs.push({
|
||||
start: segStart,
|
||||
end: segEnd,
|
||||
durationSec: (segEnd - segStart) / 1000,
|
||||
state: activeStoppage.state,
|
||||
});
|
||||
async function loadTimeline() {
|
||||
try {
|
||||
const res = await fetch(`/api/recap/${machineId}/timeline?range=1h`, { cache: "no-store" });
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!alive || !res.ok || !json) return;
|
||||
setTimeline(json as RecapTimelineResponse);
|
||||
} finally {
|
||||
if (alive) setTimelineLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
segs.sort((a, b) => a.start - b.start);
|
||||
void loadTimeline();
|
||||
const timer = window.setInterval(() => {
|
||||
void loadTimeline();
|
||||
}, 30000);
|
||||
|
||||
return { windowSec, segments: segs, start, end };
|
||||
}, [activeStoppage, cycles, cycleTarget, nowMs, thresholds]);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [machineId]);
|
||||
|
||||
const { segments, windowSec } = timeline;
|
||||
const hasData = timeline?.hasData ?? false;
|
||||
const startMs = timeline ? new Date(timeline.range.start).getTime() : Date.now() - 60 * 60 * 1000;
|
||||
const endMs = timeline ? new Date(timeline.range.end).getTime() : Date.now();
|
||||
const totalMs = Math.max(1, endMs - startMs);
|
||||
const normalized = useMemo(() => {
|
||||
if (!timeline || !hasData) return [] as RecapTimelineSegment[];
|
||||
return normalizeTimelineSegments(timeline.segments, startMs, endMs);
|
||||
}, [timeline, hasData, startMs, endMs]);
|
||||
const widths = useMemo(() => computeWidths(normalized, totalMs, 1.5), [normalized, totalMs]);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
@@ -777,49 +730,56 @@ export default function MachineDetailClient() {
|
||||
<div className="text-sm font-semibold text-white">{t("machine.detail.activity.title")}</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">{t("machine.detail.activity.subtitle")}</div>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400">{windowSec}s</div>
|
||||
<div className="text-xs text-zinc-400">1h</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-zinc-300">
|
||||
{(["normal", "slow", "microstop", "macrostop"] as const).map((key) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: BUCKET[key].dot }} />
|
||||
<span>{t(BUCKET[key].labelKey)}</span>
|
||||
{(["production", "mold-change", "macrostop", "microstop", "idle"] as const).map((type) => (
|
||||
<div key={type} className="flex items-center gap-2">
|
||||
<span className={`h-2.5 w-2.5 rounded-full ${TIMELINE_COLORS[type]}`} />
|
||||
<span>
|
||||
{type === "production" ? t("recap.timeline.type.production") : null}
|
||||
{type === "mold-change" ? t("recap.timeline.type.moldChange") : null}
|
||||
{type === "macrostop" ? t("recap.timeline.type.macrostop") : null}
|
||||
{type === "microstop" ? t("recap.timeline.type.microstop") : null}
|
||||
{type === "idle" ? t("recap.timeline.type.idle") : null}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/25 p-4">
|
||||
<div className="mb-2 flex justify-between text-[11px] text-zinc-500">
|
||||
<span>0s</span>
|
||||
<span>1h</span>
|
||||
<span>{timelineLoading ? t("common.loading") : formatTime(startMs, locale)}</span>
|
||||
<span>{formatTime(endMs, locale)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex h-14 w-full overflow-hidden rounded-2xl">
|
||||
{segments.length === 0 ? (
|
||||
{!hasData ? (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-400">
|
||||
{t("machine.detail.activity.noData")}
|
||||
</div>
|
||||
) : (
|
||||
segments.map((seg, idx) => {
|
||||
const wPct = Math.max(0, (seg.durationSec / windowSec) * 100);
|
||||
const meta = BUCKET[seg.state];
|
||||
const glow =
|
||||
seg.state === "microstop" || seg.state === "macrostop"
|
||||
? `0 0 22px ${meta.glow}`
|
||||
: `0 0 12px ${meta.glow}`;
|
||||
normalized.map((segment, idx) => {
|
||||
const widthPct = widths[idx] ?? 0;
|
||||
const typeLabel =
|
||||
segment.type === "production"
|
||||
? t("recap.timeline.type.production")
|
||||
: segment.type === "mold-change"
|
||||
? t("recap.timeline.type.moldChange")
|
||||
: segment.type === "macrostop"
|
||||
? t("recap.timeline.type.macrostop")
|
||||
: segment.type === "microstop" || segment.type === "slow-cycle"
|
||||
? t("recap.timeline.type.microstop")
|
||||
: t("recap.timeline.type.idle");
|
||||
const title = `${typeLabel} · ${formatDuration(segment.startMs, segment.endMs)}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${seg.start}-${seg.end}-${idx}`}
|
||||
title={`${t(meta.labelKey)}: ${seg.durationSec.toFixed(1)}s`}
|
||||
className="h-full"
|
||||
style={{
|
||||
width: `${wPct}%`,
|
||||
background: meta.dot,
|
||||
boxShadow: glow,
|
||||
opacity: 0.95,
|
||||
}}
|
||||
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${idx}`}
|
||||
title={title}
|
||||
className={`h-full ${TIMELINE_COLORS[segment.type]}`}
|
||||
style={{ width: `${Math.max(0, widthPct)}%` }}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -1106,12 +1066,19 @@ export default function MachineDetailClient() {
|
||||
<div className="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</div>
|
||||
<div className="mt-2 text-3xl font-bold text-emerald-300">{fmtPct(kpi?.oee)}</div>
|
||||
{kpi?.oee == null || Number.isNaN(kpi.oee) ? (
|
||||
<div className="mt-2 text-3xl font-bold text-zinc-400">—</div>
|
||||
) : (
|
||||
<div className="mt-2 text-3xl font-bold text-emerald-300">{fmtPct(kpi?.oee)}</div>
|
||||
)}
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{t("machine.detail.kpi.updated", {
|
||||
time: kpi?.ts ? timeAgo(kpi.ts) : t("common.never"),
|
||||
})}
|
||||
</div>
|
||||
{kpi?.oee == null || Number.isNaN(kpi.oee) ? (
|
||||
<div className="mt-1 text-xs text-zinc-500">{t("recap.kpi.noData")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
@@ -1131,12 +1098,7 @@ export default function MachineDetailClient() {
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<MachineActivityTimeline
|
||||
cycles={cycles}
|
||||
cycleTarget={cycleTarget}
|
||||
thresholds={thresholds}
|
||||
activeStoppage={activeStoppage}
|
||||
/>
|
||||
<MachineActivityTimeline machineId={machineId} />
|
||||
</div>
|
||||
{!screenlessMode && (
|
||||
<div className="mt-6 rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import type { RecapMachine, RecapResponse, RecapTimelineResponse } from "@/lib/recap/types";
|
||||
import RecapKpiRow from "@/components/recap/RecapKpiRow";
|
||||
import RecapProductionBySku from "@/components/recap/RecapProductionBySku";
|
||||
import RecapDowntimeTop from "@/components/recap/RecapDowntimeTop";
|
||||
import RecapWorkOrderStatus from "@/components/recap/RecapWorkOrderStatus";
|
||||
import RecapMachineStatus from "@/components/recap/RecapMachineStatus";
|
||||
import RecapTimeline from "@/components/recap/RecapTimeline";
|
||||
|
||||
type Props = {
|
||||
initialData: RecapResponse;
|
||||
initialFilters: {
|
||||
machineId: string;
|
||||
shift: string;
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
};
|
||||
|
||||
type RangeMode = "24h" | "shift" | "custom";
|
||||
|
||||
function toInputDate(value: string) {
|
||||
if (!value) return "";
|
||||
const d = new Date(value);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function toMinutesLabel(minutes: number | null) {
|
||||
if (minutes == null || minutes <= 0) return "0";
|
||||
return String(Math.round(minutes));
|
||||
}
|
||||
|
||||
export default function RecapClient({ initialData, initialFilters }: Props) {
|
||||
const { t, locale } = useI18n();
|
||||
const [data, setData] = useState<RecapResponse>(initialData);
|
||||
const [machineId, setMachineId] = useState(initialFilters.machineId || "");
|
||||
const [shift, setShift] = useState(initialFilters.shift || "shift1");
|
||||
const [customStart, setCustomStart] = useState(toInputDate(initialFilters.start));
|
||||
const [customEnd, setCustomEnd] = useState(toInputDate(initialFilters.end));
|
||||
const [mode, setMode] = useState<RangeMode>(() => {
|
||||
if (initialFilters.shift) return "shift";
|
||||
if (initialFilters.start || initialFilters.end) return "custom";
|
||||
return "24h";
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
||||
|
||||
const shiftOptions = useMemo(
|
||||
() =>
|
||||
data.availableShifts?.length
|
||||
? data.availableShifts
|
||||
: [
|
||||
{ id: "shift1", name: t("recap.shift.1") },
|
||||
{ id: "shift2", name: t("recap.shift.2") },
|
||||
{ id: "shift3", name: t("recap.shift.3") },
|
||||
],
|
||||
[data.availableShifts, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "shift") return;
|
||||
if (shiftOptions.some((option) => option.id === shift)) return;
|
||||
setShift(shiftOptions[0]?.id ?? "shift1");
|
||||
}, [mode, shift, shiftOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
const qs = new URLSearchParams();
|
||||
if (machineId) qs.set("machineId", machineId);
|
||||
if (mode === "shift") qs.set("shift", shift || "shift1");
|
||||
if (mode === "custom") {
|
||||
if (customStart) qs.set("start", new Date(customStart).toISOString());
|
||||
if (customEnd) qs.set("end", new Date(customEnd).toISOString());
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/recap?${qs.toString()}`, { cache: "no-cache" });
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!alive || !json) return;
|
||||
setData(json as RecapResponse);
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = setTimeout(load, 200);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [machineId, mode, shift, customStart, customEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
async function refresh() {
|
||||
const qs = new URLSearchParams();
|
||||
if (machineId) qs.set("machineId", machineId);
|
||||
if (mode === "shift") qs.set("shift", shift || "shift1");
|
||||
if (mode === "custom") {
|
||||
if (customStart) qs.set("start", new Date(customStart).toISOString());
|
||||
if (customEnd) qs.set("end", new Date(customEnd).toISOString());
|
||||
}
|
||||
const res = await fetch(`/api/recap?${qs.toString()}`, { cache: "no-cache" });
|
||||
const json = await res.json().catch(() => null);
|
||||
if (json) setData(json as RecapResponse);
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const interval = window.setInterval(onFocus, 60000);
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
};
|
||||
}, [machineId, mode, shift, customStart, customEnd]);
|
||||
|
||||
const selectedMachine = useMemo(() => {
|
||||
if (!data.machines.length) return null;
|
||||
return data.machines.find((m) => m.machineId === machineId) ?? data.machines[0];
|
||||
}, [data.machines, machineId]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
async function loadTimeline() {
|
||||
if (mode !== "24h") {
|
||||
if (alive) setTimeline(null);
|
||||
return;
|
||||
}
|
||||
if (!selectedMachine?.machineId) {
|
||||
if (alive) setTimeline(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const qs = new URLSearchParams({
|
||||
machineId: selectedMachine.machineId,
|
||||
hours: "24",
|
||||
start: data.range.start,
|
||||
end: data.range.end,
|
||||
});
|
||||
const res = await fetch(`/api/recap/timeline?${qs.toString()}`, { cache: "no-cache" });
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!alive) return;
|
||||
if (res.ok && json && json.segments) {
|
||||
setTimeline(json as RecapTimelineResponse);
|
||||
} else {
|
||||
setTimeline(null);
|
||||
}
|
||||
}
|
||||
|
||||
void loadTimeline();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [mode, selectedMachine?.machineId, data.range.start, data.range.end]);
|
||||
|
||||
const fleet = useMemo(() => {
|
||||
let good = 0;
|
||||
let scrap = 0;
|
||||
let stops = 0;
|
||||
let oeeSum = 0;
|
||||
let oeeCount = 0;
|
||||
for (const m of data.machines) {
|
||||
good += m.production.goodParts;
|
||||
scrap += m.production.scrapParts;
|
||||
stops += m.downtime.stopsCount;
|
||||
if (m.oee.avg != null) {
|
||||
oeeSum += m.oee.avg;
|
||||
oeeCount += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
oeeAvg: oeeCount ? oeeSum / oeeCount : null,
|
||||
good,
|
||||
scrap,
|
||||
stops,
|
||||
};
|
||||
}, [data.machines]);
|
||||
|
||||
const bannerMold = selectedMachine?.workOrders.moldChangeInProgress;
|
||||
const moldStartMs = selectedMachine?.workOrders.moldChangeStartMs ?? null;
|
||||
const moldStartLabel = moldStartMs
|
||||
? new Date(moldStartMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })
|
||||
: "--:--";
|
||||
const moldElapsedMin = moldStartMs ? Math.max(0, Math.floor((Date.now() - moldStartMs) / 60000)) : null;
|
||||
const bannerStop = (selectedMachine?.downtime.ongoingStopMin ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">{t("recap.title")}</h1>
|
||||
<p className="text-sm text-zinc-400">
|
||||
{t("recap.subtitle")} · {new Date(data.range.start).toLocaleString(locale)} - {new Date(data.range.end).toLocaleString(locale)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
<select
|
||||
value={machineId}
|
||||
onChange={(event) => setMachineId(event.target.value)}
|
||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||
>
|
||||
<option value="">{t("recap.allMachines")}</option>
|
||||
{data.machines.map((m) => (
|
||||
<option key={m.machineId} value={m.machineId}>
|
||||
{m.machineName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(event) => setMode(event.target.value as RangeMode)}
|
||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||
>
|
||||
<option value="24h">24h</option>
|
||||
<option value="shift">{t("recap.range.shift")}</option>
|
||||
<option value="custom">{t("recap.range.custom")}</option>
|
||||
</select>
|
||||
|
||||
{mode === "shift" ? (
|
||||
<select
|
||||
value={shift}
|
||||
onChange={(event) => setShift(event.target.value)}
|
||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||
>
|
||||
{shiftOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
|
||||
{mode === "custom" ? (
|
||||
<>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={customStart}
|
||||
onChange={(event) => setCustomStart(event.target.value)}
|
||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||
/>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={customEnd}
|
||||
onChange={(event) => setCustomEnd(event.target.value)}
|
||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bannerMold ? (
|
||||
<div className="mb-3 rounded-2xl border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-300">
|
||||
{t("recap.banner.mold")} {moldStartLabel}
|
||||
{moldElapsedMin != null ? ` · ${moldElapsedMin} min` : ""}
|
||||
</div>
|
||||
) : null}
|
||||
{bannerStop ? (
|
||||
<div className="mb-3 rounded-2xl border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
|
||||
{t("recap.banner.stopped", { minutes: toMinutesLabel(selectedMachine?.downtime.ongoingStopMin ?? null) })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? <div className="mb-3 text-sm text-zinc-400">{t("common.loading")}</div> : null}
|
||||
|
||||
{timeline ? (
|
||||
<RecapTimeline
|
||||
rangeStart={timeline.range.start}
|
||||
rangeEnd={timeline.range.end}
|
||||
segments={timeline.segments}
|
||||
locale={locale}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<RecapKpiRow oeeAvg={fleet.oeeAvg} goodParts={fleet.good} totalStops={fleet.stops} scrapParts={fleet.scrap} />
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<RecapProductionBySku rows={selectedMachine?.production.bySku ?? []} />
|
||||
<RecapDowntimeTop rows={selectedMachine?.downtime.topReasons ?? []} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<RecapWorkOrderStatus
|
||||
workOrders={
|
||||
selectedMachine?.workOrders ?? {
|
||||
completed: [],
|
||||
active: null,
|
||||
moldChangeInProgress: false,
|
||||
moldChangeStartMs: null,
|
||||
}
|
||||
}
|
||||
/>
|
||||
<RecapMachineStatus machine={selectedMachine as RecapMachine | null} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
app/(app)/recap/RecapGridClient.tsx
Normal file
153
app/(app)/recap/RecapGridClient.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import type { RecapMachineStatus, RecapSummaryResponse } from "@/lib/recap/types";
|
||||
import RecapMachineCard from "@/components/recap/RecapMachineCard";
|
||||
|
||||
type Props = {
|
||||
initialData: RecapSummaryResponse;
|
||||
};
|
||||
|
||||
function statusLabel(status: RecapMachineStatus, 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");
|
||||
}
|
||||
|
||||
export default function RecapGridClient({ initialData }: Props) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const [data, setData] = useState<RecapSummaryResponse>(initialData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [locationFilter, setLocationFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | RecapMachineStatus>("all");
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/recap/summary?hours=${data.range.hours}`, { cache: "no-store" });
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!alive || !json || !res.ok) return;
|
||||
setData(json as RecapSummaryResponse);
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const interval = window.setInterval(onFocus, 60000);
|
||||
window.addEventListener("focus", onFocus);
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
};
|
||||
}, [data.range.hours]);
|
||||
|
||||
const locationOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const machine of data.machines) {
|
||||
if (machine.location) set.add(machine.location);
|
||||
}
|
||||
return [...set].sort((a, b) => a.localeCompare(b));
|
||||
}, [data.machines]);
|
||||
|
||||
const filteredMachines = useMemo(() => {
|
||||
return data.machines.filter((machine) => {
|
||||
if (locationFilter !== "all" && machine.location !== locationFilter) return false;
|
||||
if (statusFilter !== "all" && machine.status !== statusFilter) return false;
|
||||
return true;
|
||||
});
|
||||
}, [data.machines, locationFilter, statusFilter]);
|
||||
|
||||
const generatedAtMs = new Date(data.generatedAt).getTime();
|
||||
const freshAgeSec = Number.isFinite(generatedAtMs) ? Math.max(0, Math.floor((nowMs - generatedAtMs) / 1000)) : null;
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">{t("recap.grid.title")}</h1>
|
||||
<p className="text-sm text-zinc-400">{t("recap.grid.subtitle")}</p>
|
||||
{freshAgeSec != null ? (
|
||||
<p className="mt-1 text-xs text-zinc-500">{t("recap.grid.updatedAgo", { sec: freshAgeSec })}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
<select
|
||||
value={locationFilter}
|
||||
onChange={(event) => setLocationFilter(event.target.value)}
|
||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||
>
|
||||
<option value="all">{t("recap.filter.allLocations")}</option>
|
||||
{locationOptions.map((location) => (
|
||||
<option key={location} value={location}>
|
||||
{location}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as "all" | RecapMachineStatus)}
|
||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||
>
|
||||
<option value="all">{t("recap.filter.allStatuses")}</option>
|
||||
{(["running", "mold-change", "stopped", "offline"] as const).map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{statusLabel(status, t)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && data.machines.length === 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, idx) => (
|
||||
<div key={idx} className="h-[220px] animate-pulse rounded-2xl border border-white/10 bg-white/5" />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading && data.machines.length > 0 ? (
|
||||
<div className="mb-3 text-xs text-zinc-500">{t("common.loading")}</div>
|
||||
) : null}
|
||||
|
||||
{filteredMachines.length === 0 ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 p-4 text-sm text-zinc-400">
|
||||
{t("recap.grid.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredMachines.map((machine) => (
|
||||
<RecapMachineCard
|
||||
key={machine.machineId}
|
||||
machine={machine}
|
||||
rangeStart={data.range.start}
|
||||
rangeEnd={data.range.end}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
app/(app)/recap/[machineId]/RecapDetailClient.tsx
Normal file
213
app/(app)/recap/[machineId]/RecapDetailClient.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import type { RecapDetailResponse, RecapRangeMode, RecapTimelineResponse } from "@/lib/recap/types";
|
||||
import RecapBanners from "@/components/recap/RecapBanners";
|
||||
import RecapKpiRow from "@/components/recap/RecapKpiRow";
|
||||
import RecapProductionBySku from "@/components/recap/RecapProductionBySku";
|
||||
import RecapDowntimeTop from "@/components/recap/RecapDowntimeTop";
|
||||
import RecapWorkOrders from "@/components/recap/RecapWorkOrders";
|
||||
import RecapMachineStatus from "@/components/recap/RecapMachineStatus";
|
||||
import RecapFullTimeline from "@/components/recap/RecapFullTimeline";
|
||||
|
||||
type Props = {
|
||||
machineId: string;
|
||||
initialData: RecapDetailResponse;
|
||||
};
|
||||
|
||||
function toInputDate(value: string) {
|
||||
const d = new Date(value);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function normalizeInputDate(value: string) {
|
||||
const d = new Date(value);
|
||||
if (!Number.isFinite(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export default function RecapDetailClient({ machineId, initialData }: Props) {
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
const [customStart, setCustomStart] = useState(toInputDate(initialData.range.start));
|
||||
const [customEnd, setCustomEnd] = useState(toInputDate(initialData.range.end));
|
||||
|
||||
const selectedRange = (searchParams.get("range") as RecapRangeMode | null) ?? initialData.range.mode;
|
||||
|
||||
function pushRange(nextRange: RecapRangeMode, start?: string, end?: string) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("range", nextRange);
|
||||
|
||||
if (nextRange === "custom" && start && end) {
|
||||
params.set("start", start);
|
||||
params.set("end", end);
|
||||
} else {
|
||||
params.delete("start");
|
||||
params.delete("end");
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`);
|
||||
});
|
||||
}
|
||||
|
||||
function applyCustomRange() {
|
||||
const start = normalizeInputDate(customStart);
|
||||
const end = normalizeInputDate(customEnd);
|
||||
if (!start || !end || end <= start) return;
|
||||
pushRange("custom", start, end);
|
||||
}
|
||||
|
||||
const machine = initialData.machine;
|
||||
const generatedAtMs = new Date(initialData.generatedAt).getTime();
|
||||
const freshAgeSec = Number.isFinite(generatedAtMs) ? Math.max(0, Math.floor((nowMs - generatedAtMs) / 1000)) : null;
|
||||
const timelineStart = timeline?.range.start ?? initialData.range.start;
|
||||
const timelineEnd = timeline?.range.end ?? initialData.range.end;
|
||||
const timelineSegments = timeline?.segments ?? machine.timeline;
|
||||
const timelineHasData = timeline?.hasData ?? true;
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
async function loadTimeline() {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
start: initialData.range.start,
|
||||
end: initialData.range.end,
|
||||
});
|
||||
const res = await fetch(`/api/recap/${machineId}/timeline?${params.toString()}`, { cache: "no-store" });
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!alive || !res.ok || !json) return;
|
||||
setTimeline(json as RecapTimelineResponse);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
void loadTimeline();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [initialData.range.end, initialData.range.start, machineId]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNowMs(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<Link href="/recap" className="text-sm text-zinc-400 hover:text-zinc-200">
|
||||
{`← ${t("recap.detail.back")}`}
|
||||
</Link>
|
||||
<h1 className="mt-1 text-2xl font-semibold text-white">{machine.name || machineId}</h1>
|
||||
<div className="text-sm text-zinc-400">{machine.location || t("common.na")}</div>
|
||||
{freshAgeSec != null ? (
|
||||
<div className="mt-1 text-xs text-zinc-500">{t("recap.grid.updatedAgo", { sec: freshAgeSec })}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-sm">
|
||||
{(["24h", "shift", "yesterday", "custom"] as const).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (range === "custom") {
|
||||
pushRange("custom", normalizeInputDate(customStart) ?? undefined, normalizeInputDate(customEnd) ?? undefined);
|
||||
return;
|
||||
}
|
||||
pushRange(range);
|
||||
}}
|
||||
className={`rounded-xl border px-3 py-2 ${
|
||||
selectedRange === range
|
||||
? "border-emerald-300/60 bg-emerald-500/20 text-emerald-100"
|
||||
: "border-white/10 bg-black/40 text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
{range === "24h" ? t("recap.range.24h") : null}
|
||||
{range === "shift" ? t("recap.range.shiftCurrent") : null}
|
||||
{range === "yesterday" ? t("recap.range.yesterday") : null}
|
||||
{range === "custom" ? t("recap.range.custom") : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedRange === "custom" ? (
|
||||
<div className="mb-4 flex flex-wrap gap-2 text-sm">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={customStart}
|
||||
onChange={(event) => setCustomStart(event.target.value)}
|
||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||
/>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={customEnd}
|
||||
onChange={(event) => setCustomEnd(event.target.value)}
|
||||
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-zinc-200"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyCustomRange}
|
||||
className="rounded-xl border border-emerald-300/50 bg-emerald-500/20 px-3 py-2 text-emerald-100"
|
||||
>
|
||||
{t("recap.range.apply")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPending ? <div className="mb-3 text-xs text-zinc-500">{t("common.loading")}</div> : null}
|
||||
|
||||
<div className="mb-4">
|
||||
<RecapBanners
|
||||
moldChangeStartMs={machine.moldChange?.active ? machine.moldChange.startMs : null}
|
||||
offlineForMin={machine.offlineForMin}
|
||||
ongoingStopMin={machine.ongoingStopMin}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RecapKpiRow
|
||||
oeeAvg={machine.oee}
|
||||
goodParts={machine.goodParts}
|
||||
totalStops={Math.round(machine.stopMinutes)}
|
||||
scrapParts={machine.scrap}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<RecapFullTimeline
|
||||
rangeStart={timelineStart}
|
||||
rangeEnd={timelineEnd}
|
||||
segments={timelineSegments}
|
||||
hasData={timelineHasData}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<RecapProductionBySku rows={machine.productionBySku} />
|
||||
<RecapDowntimeTop rows={machine.downtimeTop} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<RecapWorkOrders workOrders={machine.workOrders} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<RecapMachineStatus heartbeat={machine.heartbeat} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
app/(app)/recap/[machineId]/loading.tsx
Normal file
13
app/(app)/recap/[machineId]/loading.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export default function LoadingRecapDetail() {
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="h-16 animate-pulse rounded-2xl border border-white/10 bg-black/40" />
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="h-24 animate-pulse rounded-2xl border border-white/10 bg-black/30" />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 h-48 animate-pulse rounded-2xl border border-white/10 bg-black/30" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
app/(app)/recap/[machineId]/page.tsx
Normal file
35
app/(app)/recap/[machineId]/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { getRecapMachineDetailCached, parseRecapDetailRangeInput } from "@/lib/recap/redesign";
|
||||
import RecapDetailClient from "./RecapDetailClient";
|
||||
|
||||
export default async function RecapMachineDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ machineId: string }>;
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
const { machineId } = await params;
|
||||
if (!session) redirect(`/login?next=/recap/${machineId}`);
|
||||
|
||||
const rawSearchParams = (await searchParams) ?? {};
|
||||
const input = parseRecapDetailRangeInput(rawSearchParams);
|
||||
|
||||
const initialData = await getRecapMachineDetailCached({
|
||||
orgId: session.orgId,
|
||||
machineId,
|
||||
input,
|
||||
});
|
||||
|
||||
if (!initialData) notFound();
|
||||
|
||||
return (
|
||||
<RecapDetailClient
|
||||
key={`${machineId}:${initialData.range.mode}:${initialData.range.start}:${initialData.range.end}`}
|
||||
machineId={machineId}
|
||||
initialData={initialData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
12
app/(app)/recap/loading.tsx
Normal file
12
app/(app)/recap/loading.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export default function LoadingRecapGrid() {
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mb-4 h-24 animate-pulse rounded-2xl border border-white/10 bg-black/40" />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="h-[220px] animate-pulse rounded-2xl border border-white/10 bg-white/5" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,16 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
import { getRecapDataCached, parseRecapQuery } from "@/lib/recap/getRecapData";
|
||||
import RecapClient from "./RecapClient";
|
||||
import { getRecapSummaryCached } from "@/lib/recap/redesign";
|
||||
import RecapGridClient from "./RecapGridClient";
|
||||
|
||||
export default async function RecapPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
export default async function RecapPage() {
|
||||
const session = await requireSession();
|
||||
if (!session) redirect("/login?next=/recap");
|
||||
|
||||
const params = (await searchParams) ?? {};
|
||||
const getParam = (key: string) => {
|
||||
const value = params[key];
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
};
|
||||
|
||||
const parsed = parseRecapQuery({
|
||||
machineId: getParam("machineId"),
|
||||
start: getParam("start"),
|
||||
end: getParam("end"),
|
||||
shift: getParam("shift"),
|
||||
});
|
||||
|
||||
const initialData = await getRecapDataCached({
|
||||
const initialData = await getRecapSummaryCached({
|
||||
orgId: session.orgId,
|
||||
machineId: parsed.machineId,
|
||||
start: parsed.start ?? undefined,
|
||||
end: parsed.end ?? undefined,
|
||||
shift: parsed.shift ?? undefined,
|
||||
hours: 24,
|
||||
});
|
||||
|
||||
return (
|
||||
<RecapClient
|
||||
initialData={initialData}
|
||||
initialFilters={{
|
||||
machineId: parsed.machineId ?? "",
|
||||
shift: parsed.shift ?? "",
|
||||
start: parsed.start?.toISOString() ?? "",
|
||||
end: parsed.end?.toISOString() ?? "",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <RecapGridClient initialData={initialData} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user