changes
This commit is contained in:
@@ -302,6 +302,133 @@ function toErrorMessage(value: unknown, fallback: string): string {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MachineActivityTimelineProps = {
|
||||||
|
machineId?: string;
|
||||||
|
locale: string;
|
||||||
|
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function MachineActivityTimeline({ machineId, locale, t }: MachineActivityTimelineProps) {
|
||||||
|
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
||||||
|
const [timelineLoading, setTimelineLoading] = useState(true);
|
||||||
|
const timelineHashRef = useRef("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!machineId) return;
|
||||||
|
let alive = true;
|
||||||
|
timelineHashRef.current = "";
|
||||||
|
setTimelineLoading(true);
|
||||||
|
|
||||||
|
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;
|
||||||
|
const nextTimeline = json as RecapTimelineResponse;
|
||||||
|
const nextHash = JSON.stringify({
|
||||||
|
hasData: nextTimeline.hasData,
|
||||||
|
segments: nextTimeline.segments.map((segment) => ({
|
||||||
|
type: segment.type,
|
||||||
|
startMs: segment.startMs,
|
||||||
|
endMs: segment.endMs,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
if (timelineHashRef.current === nextHash) return;
|
||||||
|
timelineHashRef.current = nextHash;
|
||||||
|
setTimeline(nextTimeline);
|
||||||
|
} finally {
|
||||||
|
if (alive) setTimelineLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadTimeline();
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
void loadTimeline();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [machineId]);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<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">1h</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-zinc-300">
|
||||||
|
{(["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>{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">
|
||||||
|
{!hasData ? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-400">
|
||||||
|
{t("machine.detail.activity.noData")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
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={`${segment.type}:${segment.startMs}:${segment.endMs}:${idx}`}
|
||||||
|
title={title}
|
||||||
|
className={`h-full ${TIMELINE_COLORS[segment.type]}`}
|
||||||
|
style={{ width: `${Math.max(0, widthPct)}%` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function MachineDetailClient() {
|
export default function MachineDetailClient() {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const { screenlessMode } = useScreenlessMode();
|
const { screenlessMode } = useScreenlessMode();
|
||||||
@@ -683,130 +810,6 @@ export default function MachineDetailClient() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MachineActivityTimeline({ machineId }: { machineId: string | undefined }) {
|
|
||||||
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
|
||||||
const [timelineLoading, setTimelineLoading] = useState(true);
|
|
||||||
const timelineHashRef = useRef("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!machineId) return;
|
|
||||||
let alive = true;
|
|
||||||
timelineHashRef.current = "";
|
|
||||||
setTimeline(null);
|
|
||||||
setTimelineLoading(true);
|
|
||||||
|
|
||||||
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;
|
|
||||||
const nextTimeline = json as RecapTimelineResponse;
|
|
||||||
const nextHash = JSON.stringify({
|
|
||||||
start: nextTimeline.range?.start ?? "",
|
|
||||||
end: nextTimeline.range?.end ?? "",
|
|
||||||
hasData: nextTimeline.hasData,
|
|
||||||
segments: nextTimeline.segments.map((segment) => ({
|
|
||||||
type: segment.type,
|
|
||||||
startMs: segment.startMs,
|
|
||||||
endMs: segment.endMs,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
if (timelineHashRef.current === nextHash) return;
|
|
||||||
timelineHashRef.current = nextHash;
|
|
||||||
setTimeline(nextTimeline);
|
|
||||||
} finally {
|
|
||||||
if (alive) setTimelineLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void loadTimeline();
|
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
void loadTimeline();
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
window.clearInterval(timer);
|
|
||||||
};
|
|
||||||
}, [machineId]);
|
|
||||||
|
|
||||||
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">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<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">1h</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-zinc-300">
|
|
||||||
{(["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>{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">
|
|
||||||
{!hasData ? (
|
|
||||||
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-400">
|
|
||||||
{t("machine.detail.activity.noData")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
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={`${segment.type}:${segment.startMs}:${segment.endMs}:${idx}`}
|
|
||||||
title={title}
|
|
||||||
className={`h-full ${TIMELINE_COLORS[segment.type]}`}
|
|
||||||
style={{ width: `${Math.max(0, widthPct)}%` }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Modal({
|
function Modal({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -1115,7 +1118,7 @@ export default function MachineDetailClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<MachineActivityTimeline machineId={machineId} />
|
<MachineActivityTimeline machineId={machineId} locale={locale} t={t} />
|
||||||
</div>
|
</div>
|
||||||
{!screenlessMode && (
|
{!screenlessMode && (
|
||||||
<div className="mt-6 rounded-2xl border border-white/10 bg-white/5 p-5">
|
<div className="mt-6 rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
|||||||
@@ -42,7 +42,12 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
|
|||||||
const [customStart, setCustomStart] = useState(toInputDate(initialData.range.start));
|
const [customStart, setCustomStart] = useState(toInputDate(initialData.range.start));
|
||||||
const [customEnd, setCustomEnd] = useState(toInputDate(initialData.range.end));
|
const [customEnd, setCustomEnd] = useState(toInputDate(initialData.range.end));
|
||||||
|
|
||||||
const selectedRange = (searchParams.get("range") as RecapRangeMode | null) ?? initialData.range.mode;
|
const requestedRange =
|
||||||
|
(searchParams.get("range") as RecapRangeMode | null) ?? initialData.range.requestedMode ?? initialData.range.mode;
|
||||||
|
const selectedRange = requestedRange;
|
||||||
|
const shiftAvailable = initialData.range.shiftAvailable ?? true;
|
||||||
|
const shiftFallbackReason = initialData.range.fallbackReason;
|
||||||
|
const shiftFallbackActive = selectedRange === "shift" && initialData.range.mode !== "shift";
|
||||||
|
|
||||||
function pushRange(nextRange: RecapRangeMode, start?: string, end?: string) {
|
function pushRange(nextRange: RecapRangeMode, start?: string, end?: string) {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
@@ -123,7 +128,9 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
|
|||||||
<button
|
<button
|
||||||
key={range}
|
key={range}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={range === "shift" && !shiftAvailable}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (range === "shift" && !shiftAvailable) return;
|
||||||
if (range === "custom") {
|
if (range === "custom") {
|
||||||
pushRange("custom", normalizeInputDate(customStart) ?? undefined, normalizeInputDate(customEnd) ?? undefined);
|
pushRange("custom", normalizeInputDate(customStart) ?? undefined, normalizeInputDate(customEnd) ?? undefined);
|
||||||
return;
|
return;
|
||||||
@@ -134,7 +141,7 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
|
|||||||
selectedRange === range
|
selectedRange === range
|
||||||
? "border-emerald-300/60 bg-emerald-500/20 text-emerald-100"
|
? "border-emerald-300/60 bg-emerald-500/20 text-emerald-100"
|
||||||
: "border-white/10 bg-black/40 text-zinc-200"
|
: "border-white/10 bg-black/40 text-zinc-200"
|
||||||
}`}
|
} ${range === "shift" && !shiftAvailable ? "cursor-not-allowed opacity-60" : ""}`}
|
||||||
>
|
>
|
||||||
{range === "24h" ? t("recap.range.24h") : null}
|
{range === "24h" ? t("recap.range.24h") : null}
|
||||||
{range === "shift" ? t("recap.range.shiftCurrent") : null}
|
{range === "shift" ? t("recap.range.shiftCurrent") : null}
|
||||||
@@ -145,6 +152,18 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!shiftAvailable ? (
|
||||||
|
<div className="mb-4 rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
|
||||||
|
{t("recap.range.shiftUnavailable")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{shiftFallbackActive ? (
|
||||||
|
<div className="mb-4 rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs text-amber-100">
|
||||||
|
{shiftFallbackReason === "shift-inactive" ? t("recap.range.shiftFallbackInactive") : t("recap.range.shiftFallbackUnavailable")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{selectedRange === "custom" ? (
|
{selectedRange === "custom" ? (
|
||||||
<div className="mb-4 flex flex-wrap gap-2 text-sm">
|
<div className="mb-4 flex flex-wrap gap-2 text-sm">
|
||||||
<input
|
<input
|
||||||
@@ -184,6 +203,7 @@ export default function RecapDetailClient({ machineId, initialData }: Props) {
|
|||||||
goodParts={machine.goodParts}
|
goodParts={machine.goodParts}
|
||||||
totalStops={Math.round(machine.stopMinutes)}
|
totalStops={Math.round(machine.stopMinutes)}
|
||||||
scrapParts={machine.scrap}
|
scrapParts={machine.scrap}
|
||||||
|
rangeMode={initialData.range.mode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
|||||||
@@ -19,16 +19,18 @@ export default function RecapBanners({ moldChangeStartMs, offlineForMin, ongoing
|
|||||||
const moldStartLabel = moldChangeStartMs
|
const moldStartLabel = moldChangeStartMs
|
||||||
? new Date(moldChangeStartMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })
|
? new Date(moldChangeStartMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })
|
||||||
: "--:--";
|
: "--:--";
|
||||||
|
const showOffline = offlineForMin != null && offlineForMin > 10;
|
||||||
|
const hideMoldBecauseOffline = showOffline && moldChangeStartMs != null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{moldChangeStartMs ? (
|
{moldChangeStartMs && !hideMoldBecauseOffline ? (
|
||||||
<div className="rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-sm text-amber-200">
|
<div className="rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-sm text-amber-200">
|
||||||
{t("recap.banner.moldChange", { time: moldStartLabel })}
|
{t("recap.banner.moldChange", { time: moldStartLabel })}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{offlineForMin != null && offlineForMin > 10 ? (
|
{showOffline ? (
|
||||||
<div className="rounded-xl border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
<div className="rounded-xl border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
{t("recap.banner.offline", { min: toInt(offlineForMin) })}
|
{t("recap.banner.offline", { min: toInt(offlineForMin) })}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
import type { RecapRangeMode } from "@/lib/recap/types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
oeeAvg: number | null;
|
oeeAvg: number | null;
|
||||||
goodParts: number;
|
goodParts: number;
|
||||||
totalStops: number;
|
totalStops: number;
|
||||||
scrapParts: number;
|
scrapParts: number;
|
||||||
|
rangeMode?: RecapRangeMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RecapKpiRow({ oeeAvg, goodParts, totalStops, scrapParts }: Props) {
|
export default function RecapKpiRow({ oeeAvg, goodParts, totalStops, scrapParts, rangeMode = "24h" }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const oeeLabel =
|
||||||
|
rangeMode === "shift"
|
||||||
|
? t("recap.kpi.oeeShift")
|
||||||
|
: rangeMode === "yesterday"
|
||||||
|
? t("recap.kpi.oeeYesterday")
|
||||||
|
: rangeMode === "custom"
|
||||||
|
? t("recap.kpi.oeeCustom")
|
||||||
|
: t("recap.kpi.oee24h");
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: t("recap.kpi.good"), value: String(goodParts), valueClass: "text-white" },
|
{ label: t("recap.kpi.good"), value: String(goodParts), valueClass: "text-white" },
|
||||||
@@ -24,7 +34,7 @@ export default function RecapKpiRow({ oeeAvg, goodParts, totalStops, scrapParts
|
|||||||
<div className={`text-2xl font-semibold ${oeeAvg == null || Number.isNaN(oeeAvg) ? "text-zinc-400" : "text-emerald-300"}`}>
|
<div className={`text-2xl font-semibold ${oeeAvg == null || Number.isNaN(oeeAvg) ? "text-zinc-400" : "text-emerald-300"}`}>
|
||||||
{oeeAvg == null || Number.isNaN(oeeAvg) ? "—" : `${oeeAvg.toFixed(1)}%`}
|
{oeeAvg == null || Number.isNaN(oeeAvg) ? "—" : `${oeeAvg.toFixed(1)}%`}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs uppercase tracking-wide text-zinc-400">{t("recap.kpi.oee")}</div>
|
<div className="mt-1 text-xs uppercase tracking-wide text-zinc-400">{oeeLabel}</div>
|
||||||
{oeeAvg == null || Number.isNaN(oeeAvg) ? (
|
{oeeAvg == null || Number.isNaN(oeeAvg) ? (
|
||||||
<div className="mt-1 text-xs text-zinc-500">{t("recap.kpi.noData")}</div>
|
<div className="mt-1 text-xs text-zinc-500">{t("recap.kpi.noData")}</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
42
fix2.md
Normal file
42
fix2.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
Traced. Here's the truth for each filter.
|
||||||
|
|
||||||
|
Range logic (lib/recap/redesign.ts line 458-490)
|
||||||
|
Filter Window computed What it shows
|
||||||
|
24h now - 24h → now Rolling last 24h. Mold change at right edge = started ~9:46pm today
|
||||||
|
Turno actual Current-shift window from orgShift table Needs configured shifts; else falls back to 24h
|
||||||
|
Ayer now-48h → now-24h (rolling!) NOT "yesterday 00:00-23:59" — it's "24-48h ago". Label is misleading
|
||||||
|
Personalizado User-picked Explicit
|
||||||
|
What's actually wrong
|
||||||
|
1. "Paros totales 3,102,444 min" and "144,409 min" are stale data. Those are huge because old duplicate ReasonEntry rows (from before the is_update/is_auto_ack filter was deployed) are still in DB. The filter stops NEW duplicates but doesn't delete old ones.
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
TRUNCATE TABLE "ReasonEntry";
|
||||||
|
Or targeted:
|
||||||
|
|
||||||
|
DELETE FROM "ReasonEntry" WHERE "capturedAt" < '<date-when-filter-deployed>';
|
||||||
|
After that, numbers will be realistic.
|
||||||
|
|
||||||
|
2. "Ayer" window is wrong. Current code is rolling 48-24h ago, not calendar yesterday. Fix in resolveDetailRange:
|
||||||
|
|
||||||
|
if (mode === "yesterday") {
|
||||||
|
// use local timezone calendar day, not rolling 24h
|
||||||
|
const tz = orgSettings.timezone ?? "America/Mexico_City";
|
||||||
|
const today0 = zonedToUtcDate({ ...localDayStart(now, tz), hours: 0, minutes: 0, timeZone: tz });
|
||||||
|
const yesterday0 = new Date(today0.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
return { mode, start: yesterday0, end: today0 };
|
||||||
|
}
|
||||||
|
3. "Sin señal hace 376 min" is real. Pi stopped sending heartbeats 6h ago. Simultaneously "Cambio de molde en curso" is stuck active because Pi went offline DURING the mold change — no resolved event ever arrived. Both facts are true. Banner logic is correct, UX could be improved:
|
||||||
|
|
||||||
|
If offlineMin > moldChangeAgeMin, show only the offline banner (more severe). Or combined: "Sin señal hace 376m — último estado: cambio de molde".
|
||||||
|
|
||||||
|
4. Different OEE across filters is expected (different windows, different math). Labels should make it obvious: OEE PROMEDIO 24h, OEE DEL TURNO, OEE AYER. Currently they all say "OEE PROMEDIO 24H" regardless of filter → confusing. Check RecapKpiRow.tsx — the label should come from the range mode, not be hardcoded.
|
||||||
|
|
||||||
|
5. Shift mode falls through to 24h if no shifts configured. That's why the numbers are slightly different — it actually ran with a real shift. Verify: SELECT * FROM "OrgShift" WHERE "orgId" = '<id>';. If empty, shifts aren't set; the filter is silently showing 24h and labeling it "Turno actual" → more confusion.
|
||||||
|
|
||||||
|
Priority order
|
||||||
|
Truncate ReasonEntry (kills 99% of the insanity).
|
||||||
|
Fix "Ayer" to be calendar-based.
|
||||||
|
Fix KPI row label to reflect selected range.
|
||||||
|
If no OrgShift rows exist, show a toast or disable "Turno actual" button instead of silently falling back.
|
||||||
|
Improve dual-banner priority (offline > mold-change).
|
||||||
@@ -129,10 +129,17 @@
|
|||||||
"recap.range.yesterday": "Yesterday",
|
"recap.range.yesterday": "Yesterday",
|
||||||
"recap.range.custom": "Custom",
|
"recap.range.custom": "Custom",
|
||||||
"recap.range.apply": "Apply",
|
"recap.range.apply": "Apply",
|
||||||
|
"recap.range.shiftUnavailable": "Current shift is unavailable because no shifts are configured.",
|
||||||
|
"recap.range.shiftFallbackUnavailable": "Current shift is unavailable. Showing the last 24h instead.",
|
||||||
|
"recap.range.shiftFallbackInactive": "No active shift right now. Showing the last 24h instead.",
|
||||||
"recap.shift.1": "Shift 1",
|
"recap.shift.1": "Shift 1",
|
||||||
"recap.shift.2": "Shift 2",
|
"recap.shift.2": "Shift 2",
|
||||||
"recap.shift.3": "Shift 3",
|
"recap.shift.3": "Shift 3",
|
||||||
"recap.kpi.oee": "OEE Avg 24h",
|
"recap.kpi.oee": "OEE Avg 24h",
|
||||||
|
"recap.kpi.oee24h": "OEE Avg 24h",
|
||||||
|
"recap.kpi.oeeShift": "OEE Shift",
|
||||||
|
"recap.kpi.oeeYesterday": "OEE Yesterday",
|
||||||
|
"recap.kpi.oeeCustom": "OEE Custom Range",
|
||||||
"recap.kpi.noData": "No KPI data",
|
"recap.kpi.noData": "No KPI data",
|
||||||
"recap.kpi.good": "Good parts",
|
"recap.kpi.good": "Good parts",
|
||||||
"recap.kpi.stops": "Total stops (min)",
|
"recap.kpi.stops": "Total stops (min)",
|
||||||
|
|||||||
@@ -129,10 +129,17 @@
|
|||||||
"recap.range.yesterday": "Ayer",
|
"recap.range.yesterday": "Ayer",
|
||||||
"recap.range.custom": "Personalizado",
|
"recap.range.custom": "Personalizado",
|
||||||
"recap.range.apply": "Aplicar",
|
"recap.range.apply": "Aplicar",
|
||||||
|
"recap.range.shiftUnavailable": "Turno actual no disponible porque no hay turnos configurados.",
|
||||||
|
"recap.range.shiftFallbackUnavailable": "Turno actual no disponible. Mostrando últimas 24h.",
|
||||||
|
"recap.range.shiftFallbackInactive": "No hay turno activo en este momento. Mostrando últimas 24h.",
|
||||||
"recap.shift.1": "Turno 1",
|
"recap.shift.1": "Turno 1",
|
||||||
"recap.shift.2": "Turno 2",
|
"recap.shift.2": "Turno 2",
|
||||||
"recap.shift.3": "Turno 3",
|
"recap.shift.3": "Turno 3",
|
||||||
"recap.kpi.oee": "OEE promedio 24h",
|
"recap.kpi.oee": "OEE promedio 24h",
|
||||||
|
"recap.kpi.oee24h": "OEE promedio 24h",
|
||||||
|
"recap.kpi.oeeShift": "OEE del turno",
|
||||||
|
"recap.kpi.oeeYesterday": "OEE ayer",
|
||||||
|
"recap.kpi.oeeCustom": "OEE rango personalizado",
|
||||||
"recap.kpi.noData": "Sin datos de KPI",
|
"recap.kpi.noData": "Sin datos de KPI",
|
||||||
"recap.kpi.good": "Buenas",
|
"recap.kpi.good": "Buenas",
|
||||||
"recap.kpi.stops": "Paros totales (min)",
|
"recap.kpi.stops": "Paros totales (min)",
|
||||||
|
|||||||
@@ -389,7 +389,12 @@ async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const enabledShifts = shifts.filter((shift) => shift.enabled !== false);
|
const enabledShifts = shifts.filter((shift) => shift.enabled !== false);
|
||||||
if (!enabledShifts.length) return null;
|
if (!enabledShifts.length) {
|
||||||
|
return {
|
||||||
|
hasEnabledShifts: false,
|
||||||
|
range: null,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
const timeZone = settings?.timezone || "UTC";
|
const timeZone = settings?.timezone || "UTC";
|
||||||
const local = getLocalParts(params.now, timeZone);
|
const local = getLocalParts(params.now, timeZone);
|
||||||
@@ -447,57 +452,125 @@ async function resolveCurrentShiftRange(params: { orgId: string; now: Date }) {
|
|||||||
if (end <= start) continue;
|
if (end <= start) continue;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
hasEnabledShifts: true,
|
||||||
|
range: {
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return {
|
||||||
|
hasEnabledShifts: true,
|
||||||
|
range: null,
|
||||||
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
|
async function resolveDetailRange(params: { orgId: string; input: DetailRangeInput }) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const mode = normalizedRangeMode(params.input.mode);
|
const requestedMode = normalizedRangeMode(params.input.mode);
|
||||||
|
const shiftEnabledCount = await prisma.orgShift.count({
|
||||||
|
where: {
|
||||||
|
orgId: params.orgId,
|
||||||
|
enabled: { not: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const shiftAvailable = shiftEnabledCount > 0;
|
||||||
|
|
||||||
if (mode === "custom") {
|
if (requestedMode === "custom") {
|
||||||
const start = parseDate(params.input.start);
|
const start = parseDate(params.input.start);
|
||||||
const end = parseDate(params.input.end);
|
const end = parseDate(params.input.end);
|
||||||
if (start && end && end > start) {
|
if (start && end && end > start) {
|
||||||
return { mode, start, end };
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "yesterday") {
|
if (requestedMode === "yesterday") {
|
||||||
const end = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
const settings = await prisma.orgSettings.findUnique({
|
||||||
const start = new Date(end.getTime() - 24 * 60 * 60 * 1000);
|
where: { orgId: params.orgId },
|
||||||
return { mode, start, end };
|
select: { timezone: true },
|
||||||
|
});
|
||||||
|
const timeZone = settings?.timezone || "America/Mexico_City";
|
||||||
|
const localNow = getLocalParts(now, timeZone);
|
||||||
|
const today = { year: localNow.year, month: localNow.month, day: localNow.day };
|
||||||
|
const yesterday = addDays(today, -1);
|
||||||
|
const start = zonedToUtcDate({
|
||||||
|
...yesterday,
|
||||||
|
hours: 0,
|
||||||
|
minutes: 0,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
const end = zonedToUtcDate({
|
||||||
|
...today,
|
||||||
|
hours: 0,
|
||||||
|
minutes: 0,
|
||||||
|
timeZone,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: requestedMode,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "shift") {
|
if (requestedMode === "shift") {
|
||||||
const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now });
|
const shiftRange = await resolveCurrentShiftRange({ orgId: params.orgId, now });
|
||||||
if (shiftRange) {
|
if (shiftRange.range) {
|
||||||
return {
|
return {
|
||||||
mode,
|
requestedMode,
|
||||||
start: shiftRange.start,
|
mode: requestedMode,
|
||||||
end: shiftRange.end,
|
start: shiftRange.range.start,
|
||||||
};
|
end: shiftRange.range.end,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
}
|
}
|
||||||
}
|
if (!shiftRange.hasEnabledShifts) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
requestedMode,
|
||||||
mode: "24h" as const,
|
mode: "24h" as const,
|
||||||
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
end: now,
|
end: now,
|
||||||
};
|
shiftAvailable,
|
||||||
|
fallbackReason: "shift-unavailable" as const,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
fallbackReason: "shift-inactive" as const,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestedMode,
|
||||||
|
mode: "24h" as const,
|
||||||
|
start: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
end: now,
|
||||||
|
shiftAvailable,
|
||||||
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function computeRecapMachineDetail(params: {
|
async function computeRecapMachineDetail(params: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
machineId: string;
|
machineId: string;
|
||||||
range: {
|
range: {
|
||||||
|
requestedMode: RecapRangeMode;
|
||||||
mode: RecapRangeMode;
|
mode: RecapRangeMode;
|
||||||
start: Date;
|
start: Date;
|
||||||
end: Date;
|
end: Date;
|
||||||
|
shiftAvailable: boolean;
|
||||||
|
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const { range } = params;
|
const { range } = params;
|
||||||
@@ -571,9 +644,12 @@ async function computeRecapMachineDetail(params: {
|
|||||||
const response: RecapDetailResponse = {
|
const response: RecapDetailResponse = {
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
range: {
|
range: {
|
||||||
|
requestedMode: range.requestedMode,
|
||||||
mode: range.mode,
|
mode: range.mode,
|
||||||
start: range.start.toISOString(),
|
start: range.start.toISOString(),
|
||||||
end: range.end.toISOString(),
|
end: range.end.toISOString(),
|
||||||
|
shiftAvailable: range.shiftAvailable,
|
||||||
|
fallbackReason: range.fallbackReason,
|
||||||
},
|
},
|
||||||
machine: machineDetail,
|
machine: machineDetail,
|
||||||
};
|
};
|
||||||
@@ -588,7 +664,10 @@ function summaryCacheKey(params: { orgId: string; hours: number }) {
|
|||||||
function detailCacheKey(params: {
|
function detailCacheKey(params: {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
machineId: string;
|
machineId: string;
|
||||||
|
requestedMode: RecapRangeMode;
|
||||||
mode: RecapRangeMode;
|
mode: RecapRangeMode;
|
||||||
|
shiftAvailable: boolean;
|
||||||
|
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||||
startMs: number;
|
startMs: number;
|
||||||
endMs: number;
|
endMs: number;
|
||||||
}) {
|
}) {
|
||||||
@@ -596,7 +675,10 @@ function detailCacheKey(params: {
|
|||||||
"recap-detail-v1",
|
"recap-detail-v1",
|
||||||
params.orgId,
|
params.orgId,
|
||||||
params.machineId,
|
params.machineId,
|
||||||
|
params.requestedMode,
|
||||||
params.mode,
|
params.mode,
|
||||||
|
params.shiftAvailable ? "shift-on" : "shift-off",
|
||||||
|
params.fallbackReason ?? "",
|
||||||
String(Math.trunc(params.startMs / 60000)),
|
String(Math.trunc(params.startMs / 60000)),
|
||||||
String(Math.trunc(params.endMs / 60000)),
|
String(Math.trunc(params.endMs / 60000)),
|
||||||
];
|
];
|
||||||
@@ -657,15 +739,21 @@ export async function getRecapMachineDetailCached(params: {
|
|||||||
orgId: params.orgId,
|
orgId: params.orgId,
|
||||||
machineId: params.machineId,
|
machineId: params.machineId,
|
||||||
range: {
|
range: {
|
||||||
|
requestedMode: resolved.requestedMode,
|
||||||
mode: resolved.mode,
|
mode: resolved.mode,
|
||||||
start: resolved.start,
|
start: resolved.start,
|
||||||
end: resolved.end,
|
end: resolved.end,
|
||||||
|
shiftAvailable: resolved.shiftAvailable,
|
||||||
|
fallbackReason: resolved.fallbackReason,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
detailCacheKey({
|
detailCacheKey({
|
||||||
orgId: params.orgId,
|
orgId: params.orgId,
|
||||||
machineId: params.machineId,
|
machineId: params.machineId,
|
||||||
|
requestedMode: resolved.requestedMode,
|
||||||
mode: resolved.mode,
|
mode: resolved.mode,
|
||||||
|
shiftAvailable: resolved.shiftAvailable,
|
||||||
|
fallbackReason: resolved.fallbackReason,
|
||||||
startMs: resolved.start.getTime(),
|
startMs: resolved.start.getTime(),
|
||||||
endMs: resolved.end.getTime(),
|
endMs: resolved.end.getTime(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -211,9 +211,12 @@ export type RecapMachineDetail = {
|
|||||||
export type RecapDetailResponse = {
|
export type RecapDetailResponse = {
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
range: {
|
range: {
|
||||||
|
requestedMode?: RecapRangeMode;
|
||||||
mode: RecapRangeMode;
|
mode: RecapRangeMode;
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string;
|
||||||
|
shiftAvailable?: boolean;
|
||||||
|
fallbackReason?: "shift-unavailable" | "shift-inactive";
|
||||||
};
|
};
|
||||||
machine: RecapMachineDetail;
|
machine: RecapMachineDetail;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user