pre-bemis

This commit is contained in:
Marcelo
2026-04-22 05:04:19 +00:00
parent ac1a7900c8
commit 80d27f83b6
91 changed files with 11769 additions and 820 deletions

View File

@@ -46,6 +46,19 @@ function readBool(value: unknown) {
return value === true;
}
function normalizeStatus(value?: string | null) {
if (!value) return null;
const raw = value.trim().toLowerCase();
if (!raw) return null;
if (raw === "in_progress" || raw === "in-progress" || raw === "open" || raw === "activa" || raw === "activo") {
return "active";
}
if (raw === "resuelta" || raw === "resuelto" || raw === "closed" || raw === "ended" || raw === "done") {
return "resolved";
}
return raw;
}
function extractDurationSec(raw: unknown): number | null {
const payload = asRecord(raw);
if (!payload) return null;
@@ -302,10 +315,11 @@ export async function evaluateAlertsForEvent(eventId: string) {
const alertId = readString(payload?.alert_id ?? inner?.alert_id);
const isUpdate = readBool(payload?.is_update ?? inner?.is_update);
const isAutoAck = readBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
const status = normalizeStatus(readString(payload?.status ?? inner?.status));
const lastCycleTs = readNumber(payload?.last_cycle_timestamp ?? inner?.last_cycle_timestamp);
const theoreticalSec = readNumber(payload?.theoretical_cycle_time ?? inner?.theoretical_cycle_time);
if (isAutoAck) return;
if (isUpdate && !(rule.repeatMinutes && rule.repeatMinutes > 0)) return;
if (isUpdate && status !== "resolved") return;
if ((eventType === "microstop" || eventType === "macrostop") && theoreticalSec && lastCycleTs == null) {
return;
}
@@ -345,9 +359,11 @@ export async function evaluateAlertsForEvent(eventId: string) {
const key = `${channel}:${recipient.userId ?? recipient.contactId ?? recipient.email ?? recipient.phone ?? ""}`;
if (delivered.has(key)) continue;
const statusKey = status === "resolved" ? "resolved" : "active";
const ruleKey = `${rule.id}:${statusKey}`;
const allowed = await shouldSendNotification({
eventIds: notificationEventIds,
ruleId: rule.id,
ruleId: ruleKey,
role: roleName,
channel,
contactId: recipient.contactId,
@@ -376,7 +392,7 @@ export async function evaluateAlertsForEvent(eventId: string) {
machineId: event.machineId,
eventId: event.id,
eventType,
ruleId: rule.id,
ruleId: ruleKey,
role: roleName,
channel,
contactId: recipient.contactId,
@@ -391,7 +407,7 @@ export async function evaluateAlertsForEvent(eventId: string) {
machineId: event.machineId,
eventId: event.id,
eventType,
ruleId: rule.id,
ruleId: ruleKey,
role: roleName,
channel,
contactId: recipient.contactId,

View File

@@ -1,3 +1,4 @@
import { normalizeShiftOverrides } from "@/lib/settings";
import { prisma } from "@/lib/prisma";
const RANGE_MS: Record<string, number> = {
@@ -21,6 +22,26 @@ type AlertsInboxParams = {
limit?: number;
};
type AlertsInboxEvent = {
id: string;
ts: Date;
eventType: string;
severity: string;
title: string;
description?: string | null;
machineId: string;
machineName?: string | null;
location?: string | null;
workOrderId?: string | null;
sku?: string | null;
durationSec?: number | null;
status?: string | null;
shift?: string | null;
alertId?: string | null;
isUpdate?: boolean;
isAutoAck?: boolean;
};
function pickRange(range: string, start?: Date | null, end?: Date | null) {
const now = new Date();
if (range === "custom") {
@@ -50,6 +71,19 @@ function safeBool(value: unknown) {
return value === true;
}
function normalizeStatus(value?: string | null) {
if (!value) return null;
const raw = value.trim().toLowerCase();
if (!raw) return null;
if (raw === "in_progress" || raw === "in-progress" || raw === "open" || raw === "activa" || raw === "activo") {
return "active";
}
if (raw === "resuelta" || raw === "resuelto" || raw === "closed" || raw === "ended" || raw === "done") {
return "resolved";
}
return raw;
}
function parsePayload(raw: unknown) {
let parsed: unknown = raw;
if (typeof raw === "string") {
@@ -131,17 +165,54 @@ function getLocalMinutes(ts: Date, timeZone: string) {
}
}
const WEEKDAY_KEY_MAP: Record<string, string> = {
Sun: "sun",
Mon: "mon",
Tue: "tue",
Wed: "wed",
Thu: "thu",
Fri: "fri",
Sat: "sat",
};
const WEEKDAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
function getLocalDayKey(ts: Date, timeZone: string) {
try {
const weekday = new Intl.DateTimeFormat("en-US", {
timeZone,
weekday: "short",
}).format(ts);
return WEEKDAY_KEY_MAP[weekday] ?? WEEKDAY_KEYS[ts.getUTCDay()];
} catch {
return WEEKDAY_KEYS[ts.getUTCDay()];
}
}
type ShiftLike = {
name: string;
startTime?: string | null;
endTime?: string | null;
start?: string | null;
end?: string | null;
enabled?: boolean;
};
function resolveShift(
shifts: Array<{ name: string; startTime: string; endTime: string; enabled?: boolean }>,
shifts: ShiftLike[],
overrides: Record<string, ShiftLike[]> | undefined,
ts: Date,
timeZone: string
) {
if (!shifts.length) return null;
const dayKey = getLocalDayKey(ts, timeZone);
const dayOverrides = overrides?.[dayKey];
const activeShifts = dayOverrides ?? shifts;
if (!activeShifts.length) return null;
const nowMin = getLocalMinutes(ts, timeZone);
for (const shift of shifts) {
for (const shift of activeShifts) {
if (shift.enabled === false) continue;
const start = parseTimeMinutes(shift.startTime);
const end = parseTimeMinutes(shift.endTime);
const start = parseTimeMinutes(shift.startTime ?? shift.start ?? null);
const end = parseTimeMinutes(shift.endTime ?? shift.end ?? null);
if (start == null || end == null) continue;
if (start <= end) {
if (nowMin >= start && nowMin < end) return shift.name;
@@ -152,6 +223,34 @@ function resolveShift(
return null;
}
function collapseAlertEvents(events: AlertsInboxEvent[]) {
const byAlert = new Map<string, AlertsInboxEvent>();
const passthrough: AlertsInboxEvent[] = [];
for (const ev of events) {
if (!ev.alertId) {
passthrough.push(ev);
continue;
}
const statusKey = ev.status === "resolved" ? "resolved" : "active";
const key = `${ev.alertId}:${statusKey}`;
const existing = byAlert.get(key);
if (!existing) {
byAlert.set(key, ev);
continue;
}
const pickNewest = statusKey === "resolved";
const shouldReplace = pickNewest
? ev.ts.getTime() > existing.ts.getTime()
: ev.ts.getTime() < existing.ts.getTime();
if (shouldReplace) byAlert.set(key, ev);
}
const combined = [...passthrough, ...byAlert.values()];
combined.sort((a, b) => b.ts.getTime() - a.ts.getTime());
return combined;
}
export async function getAlertsInboxData(params: AlertsInboxParams) {
const {
orgId,
@@ -213,12 +312,13 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
}),
prisma.orgSettings.findUnique({
where: { orgId },
select: { timezone: true },
select: { timezone: true, shiftScheduleOverridesJson: true },
}),
]);
const timeZone = settings?.timezone || "UTC";
const mapped = [];
const shiftOverrides = normalizeShiftOverrides(settings?.shiftScheduleOverridesJson);
const mapped: AlertsInboxEvent[] = [];
for (const ev of events) {
const { payload, inner } = parsePayload(ev.data);
@@ -227,10 +327,10 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
const isAutoAck = safeBool(payload?.is_auto_ack ?? inner?.is_auto_ack);
if (!includeUpdates && (isUpdate || isAutoAck)) continue;
const shiftName = resolveShift(shifts, ev.ts, timeZone);
const shiftName = resolveShift(shifts, shiftOverrides, ev.ts, timeZone);
if (normalizedShift && shiftName !== normalizedShift) continue;
const statusLabel = rawStatus ? rawStatus.toLowerCase() : "unknown";
const statusLabel = normalizeStatus(rawStatus) ?? "unknown";
if (normalizedStatus && statusLabel !== normalizedStatus) continue;
mapped.push({
@@ -254,8 +354,10 @@ export async function getAlertsInboxData(params: AlertsInboxParams) {
});
}
const finalEvents = includeUpdates ? mapped : collapseAlertEvents(mapped);
return {
range: { range: picked.range, start: picked.start, end: picked.end },
events: mapped,
events: finalEvents,
};
}

View File

@@ -1,43 +1,98 @@
import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";
import { logLine } from "@/lib/logger";
const COOKIE_NAME = "mis_session";
const SESSION_CACHE_TTL_MS = 30000;
const LAST_SEEN_TTL_MS = 300000;
export async function requireSession() {
const jar = await cookies();
const sessionId = jar.get(COOKIE_NAME)?.value;
if (!sessionId) return null;
type SessionPayload = {
sessionId: string;
userId: string;
orgId: string;
};
const session = await prisma.session.findFirst({
where: {
id: sessionId,
revokedAt: null,
expiresAt: { gt: new Date() },
},
include: {
user: {
select: { isActive: true, emailVerifiedAt: true },
},
},
});
type CachedSession = {
value: SessionPayload;
expiresAt: number;
};
if (!session) return null;
const sessionCache = new Map<string, CachedSession>();
const lastSeenCache = new Map<string, number>();
if (!session.user?.isActive || !session.user?.emailVerifiedAt) {
await prisma.session
.update({ where: { id: session.id }, data: { revokedAt: new Date() } })
.catch(() => {});
function readCache(sessionId: string, now: number) {
const cached = sessionCache.get(sessionId);
if (!cached) return null;
if (cached.expiresAt <= now) {
sessionCache.delete(sessionId);
return null;
}
return cached.value;
}
function writeCache(sessionId: string, value: SessionPayload, now: number) {
sessionCache.set(sessionId, { value, expiresAt: now + SESSION_CACHE_TTL_MS });
}
function shouldUpdateLastSeen(sessionId: string, now: number) {
const last = lastSeenCache.get(sessionId) ?? 0;
if (now - last < LAST_SEEN_TTL_MS) return false;
lastSeenCache.set(sessionId, now);
return true;
}
export async function requireSession() {
try {
const jar = await cookies();
const sessionId = jar.get(COOKIE_NAME)?.value;
if (!sessionId) return null;
const now = Date.now();
const cached = readCache(sessionId, now);
if (cached) return cached;
const session = await prisma.session.findFirst({
where: {
id: sessionId,
revokedAt: null,
expiresAt: { gt: new Date() },
},
include: {
user: {
select: { isActive: true, emailVerifiedAt: true },
},
},
});
if (!session) return null;
if (!session.user?.isActive || !session.user?.emailVerifiedAt) {
void prisma.session
.update({ where: { id: session.id }, data: { revokedAt: new Date() } })
.catch(() => {});
sessionCache.delete(sessionId);
lastSeenCache.delete(sessionId);
return null;
}
if (shouldUpdateLastSeen(sessionId, now)) {
void prisma.session
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
.catch(() => {});
}
const payload = {
sessionId: session.id,
userId: session.userId,
orgId: session.orgId,
};
writeCache(sessionId, payload, now);
return payload;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
logLine("requireSession.error", { message, stack });
console.error("[requireSession]", err);
return null;
}
// Optional: update lastSeenAt (useful later)
await prisma.session
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
.catch(() => {});
return {
sessionId: session.id,
userId: session.userId,
orgId: session.orgId,
};
}

63
lib/financial/cache.ts Normal file
View File

@@ -0,0 +1,63 @@
import { unstable_cache } from "next/cache";
import { prisma } from "@/lib/prisma";
import { computeFinancialImpact, type FinancialImpactParams } from "@/lib/financial/impact";
export const FINANCIAL_CONFIG_TTL_SEC = 15;
export const FINANCIAL_CONFIG_SWR_SEC = 45;
export const FINANCIAL_IMPACT_TTL_SEC = 10;
export const FINANCIAL_IMPACT_SWR_SEC = 30;
async function loadFinancialConfig(orgId: string) {
const [org, locations, machines, products] = await Promise.all([
prisma.orgFinancialProfile.findUnique({ where: { orgId } }),
prisma.locationFinancialOverride.findMany({ where: { orgId }, orderBy: { location: "asc" } }),
prisma.machineFinancialOverride.findMany({ where: { orgId }, orderBy: { createdAt: "desc" } }),
prisma.productCostOverride.findMany({ where: { orgId }, orderBy: { sku: "asc" } }),
]);
return { org, locations, machines, products };
}
export type FinancialConfigPayload = Awaited<ReturnType<typeof loadFinancialConfig>>;
export async function getFinancialConfig(orgId: string, options?: { refresh?: boolean }) {
if (options?.refresh) {
return loadFinancialConfig(orgId);
}
const cached = unstable_cache(
() => loadFinancialConfig(orgId),
["financial-config", orgId],
{ revalidate: FINANCIAL_CONFIG_TTL_SEC, tags: [`financial-config:${orgId}`] }
);
return cached();
}
export async function getFinancialImpactCached(
params: FinancialImpactParams,
options?: { refresh?: boolean }
) {
if (options?.refresh) {
return computeFinancialImpact(params);
}
const keyParts = [
"financial-impact",
params.orgId,
String(params.start.getTime()),
String(params.end.getTime()),
params.machineId ?? "",
params.location ?? "",
params.sku ?? "",
params.currency ?? "",
params.includeEvents ? "1" : "0",
];
const cached = unstable_cache(
() => computeFinancialImpact(params),
keyParts,
{ revalidate: FINANCIAL_IMPACT_TTL_SEC, tags: [`financial-impact:${params.orgId}`] }
);
return cached();
}

View File

@@ -395,10 +395,24 @@
"settings.minutes": "minutes",
"settings.shiftHint": "Max 3 shifts, HH:mm",
"settings.shiftTo": "to",
"settings.shiftCompLabel": "Shift change compensation (min)",
"settings.lunchBreakLabel": "Lunch break (min)",
"settings.shift.defaultName": "Shift {index}",
"settings.thresholds": "Alert thresholds",
"settings.shiftCompLabel": "Shift change compensation (min)",
"settings.lunchBreakLabel": "Lunch break (min)",
"settings.shift.defaultName": "Shift {index}",
"settings.shiftOverrides.title": "Day-specific shifts",
"settings.shiftOverrides.subtitle": "Optional overrides for individual days.",
"settings.shiftOverrides.useDefault": "Use default",
"settings.shiftOverrides.customize": "Customize",
"settings.shiftOverrides.inherits": "Uses default shift schedule.",
"settings.shiftOverrides.dayOff": "Day off (no shifts)",
"settings.shiftOverrides.clear": "Clear shifts",
"settings.shiftOverrides.mon": "Monday",
"settings.shiftOverrides.tue": "Tuesday",
"settings.shiftOverrides.wed": "Wednesday",
"settings.shiftOverrides.thu": "Thursday",
"settings.shiftOverrides.fri": "Friday",
"settings.shiftOverrides.sat": "Saturday",
"settings.shiftOverrides.sun": "Sunday",
"settings.thresholds": "Alert thresholds",
"settings.thresholdsSubtitle": "Tune production health alerts.",
"settings.thresholds.appliesAll": "Applies to all machines",
"settings.thresholds.oee": "OEE alert threshold",
@@ -453,11 +467,12 @@
"financial.title": "Financial Impact",
"financial.subtitle": "Translate downtime, slow cycles, and scrap into money.",
"financial.ownerOnly": "Financial impact is available only to owners.",
"financial.costsMoved": "Cost settings are now in",
"financial.costsMovedLink": "Settings -> Financial",
"financial.export.html": "HTML",
"financial.export.csv": "CSV",
"financial.totalLoss": "Total Loss",
"financial.costsMoved": "Cost settings are now in",
"financial.costsMovedLink": "Settings -> Financial",
"financial.export.html": "HTML",
"financial.export.csv": "CSV",
"financial.refresh": "Refresh",
"financial.totalLoss": "Total Loss",
"financial.currencyLabel": "Currency: {currency}",
"financial.noImpact": "No impact data yet.",
"financial.chart.title": "Lost Money Over Time",

View File

@@ -395,10 +395,24 @@
"settings.minutes": "minutos",
"settings.shiftHint": "Máx 3 turnos, HH:mm",
"settings.shiftTo": "a",
"settings.shiftCompLabel": "Compensación por cambio de turno (min)",
"settings.lunchBreakLabel": "Comida (min)",
"settings.shift.defaultName": "Turno {index}",
"settings.thresholds": "Umbrales de alertas",
"settings.shiftCompLabel": "Compensación por cambio de turno (min)",
"settings.lunchBreakLabel": "Comida (min)",
"settings.shift.defaultName": "Turno {index}",
"settings.shiftOverrides.title": "Turnos por día",
"settings.shiftOverrides.subtitle": "Sobrescrituras opcionales por día.",
"settings.shiftOverrides.useDefault": "Usar predeterminado",
"settings.shiftOverrides.customize": "Personalizar",
"settings.shiftOverrides.inherits": "Usa el horario de turnos predeterminado.",
"settings.shiftOverrides.dayOff": "Día libre (sin turnos)",
"settings.shiftOverrides.clear": "Borrar turnos",
"settings.shiftOverrides.mon": "Lunes",
"settings.shiftOverrides.tue": "Martes",
"settings.shiftOverrides.wed": "Miércoles",
"settings.shiftOverrides.thu": "Jueves",
"settings.shiftOverrides.fri": "Viernes",
"settings.shiftOverrides.sat": "Sábado",
"settings.shiftOverrides.sun": "Domingo",
"settings.thresholds": "Umbrales de alertas",
"settings.thresholdsSubtitle": "Ajusta alertas de salud de producción.",
"settings.thresholds.appliesAll": "Aplica a todas las máquinas",
"settings.thresholds.oee": "Umbral de alerta OEE",
@@ -453,11 +467,12 @@
"financial.title": "Impacto financiero",
"financial.subtitle": "Convierte paros, ciclos lentos y scrap en dinero.",
"financial.ownerOnly": "El impacto financiero solo está disponible para propietarios.",
"financial.costsMoved": "Los costos ahora están en",
"financial.costsMovedLink": "Configuración -> Finanzas",
"financial.export.html": "HTML",
"financial.export.csv": "CSV",
"financial.totalLoss": "Pérdida total",
"financial.costsMoved": "Los costos ahora están en",
"financial.costsMovedLink": "Configuración -> Finanzas",
"financial.export.html": "HTML",
"financial.export.csv": "CSV",
"financial.refresh": "Actualizar",
"financial.totalLoss": "Pérdida total",
"financial.currencyLabel": "Moneda: {currency}",
"financial.noImpact": "Sin datos de impacto.",
"financial.chart.title": "Pérdida de dinero en el tiempo",

View File

@@ -7,6 +7,7 @@ const LOCALE_COOKIE = "mis_locale";
const LOCALE_EVENT = "mis-locale-change";
function readCookieLocale(): Locale | null {
if (typeof document === "undefined") return null;
const match = document.cookie
.split(";")
.map((part) => part.trim())
@@ -18,6 +19,7 @@ function readCookieLocale(): Locale | null {
}
function readLocale(): Locale {
if (typeof document === "undefined") return defaultLocale;
const docLang = document.documentElement.getAttribute("lang");
if (docLang === "es-MX" || docLang === "en") return docLang;
return readCookieLocale() ?? defaultLocale;

View File

@@ -3,6 +3,10 @@ import path from "path";
const LOG_PATH = process.env.LOG_FILE || "/tmp/mis-control-tower.log";
export function getLogPath() {
return LOG_PATH;
}
export function logLine(event: string, data: Record<string, unknown> = {}) {
const line = JSON.stringify({
ts: new Date().toISOString(),

View File

@@ -2,7 +2,7 @@ import { prisma } from "@/lib/prisma";
type MachineAuth = { id: string; orgId: string };
const TTL_MS = 60_000;
const TTL_MS = 10_000;
const MAX_SIZE = 1000;
const cache = new Map<string, { value: MachineAuth; expiresAt: number }>();
@@ -36,3 +36,12 @@ export async function getMachineAuth(machineId: string, apiKey: string) {
cache.set(key, { value: machine, expiresAt: now + TTL_MS });
return machine;
}
export function invalidateMachineAuth(machineId: string) {
const prefix = `${machineId}:`;
for (const key of cache.keys()) {
if (key.startsWith(prefix)) {
cache.delete(key);
}
}
}

113
lib/machines/withLatest.ts Normal file
View File

@@ -0,0 +1,113 @@
import { prisma } from "@/lib/prisma";
import type { OverviewMachineRow } from "@/lib/overview/types";
type MachineBaseRow = Pick<
OverviewMachineRow,
"id" | "name" | "code" | "location" | "createdAt" | "updatedAt"
>;
type LatestHeartbeatRow = {
machineId: string;
ts: Date;
tsServer: Date | null;
status: string;
message?: string | null;
ip?: string | null;
fwVersion?: string | null;
};
type LatestKpiRow = {
machineId: string;
ts: Date;
oee?: number | null;
availability?: number | null;
performance?: number | null;
quality?: number | null;
workOrderId?: string | null;
sku?: string | null;
good?: number | null;
scrap?: number | null;
target?: number | null;
cycleTime?: number | null;
};
export async function fetchMachineBase(orgId: string): Promise<MachineBaseRow[]> {
return prisma.machine.findMany({
where: { orgId },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
code: true,
location: true,
createdAt: true,
updatedAt: true,
},
});
}
export async function fetchLatestHeartbeats(
orgId: string,
machineIds: string[]
): Promise<LatestHeartbeatRow[]> {
if (!machineIds.length) return [];
return prisma.machineHeartbeat.findMany({
where: { orgId, machineId: { in: machineIds } },
orderBy: [{ machineId: "asc" }, { tsServer: "desc" }],
distinct: ["machineId"],
select: {
machineId: true,
ts: true,
tsServer: true,
status: true,
message: true,
ip: true,
fwVersion: true,
},
});
}
export async function fetchLatestKpis(
orgId: string,
machineIds: string[]
): Promise<LatestKpiRow[]> {
if (!machineIds.length) return [];
return prisma.machineKpiSnapshot.findMany({
where: { orgId, machineId: { in: machineIds } },
orderBy: [{ machineId: "asc" }, { ts: "desc" }],
distinct: ["machineId"],
select: {
machineId: true,
ts: true,
oee: true,
availability: true,
performance: true,
quality: true,
workOrderId: true,
sku: true,
good: true,
scrap: true,
target: true,
cycleTime: true,
},
});
}
export function mergeMachineOverviewRows(params: {
machines: MachineBaseRow[];
heartbeats: LatestHeartbeatRow[];
kpis?: LatestKpiRow[];
includeKpi?: boolean;
}): OverviewMachineRow[] {
const { machines, heartbeats, kpis = [], includeKpi = false } = params;
const heartbeatMap = new Map(heartbeats.map((row) => [row.machineId, row]));
const kpiMap = new Map(kpis.map((row) => [row.machineId, row]));
return machines.map((machine) => ({
...machine,
latestHeartbeat: (heartbeatMap.get(machine.id) ?? null) as OverviewMachineRow["latestHeartbeat"],
latestKpi: includeKpi ? (kpiMap.get(machine.id) ?? null) : null,
heartbeats: undefined,
kpiSnapshots: undefined,
}));
}

View File

@@ -1,5 +1,14 @@
import { prisma } from "@/lib/prisma";
import { normalizeEvent } from "@/lib/events/normalizeEvent";
import { logLine } from "@/lib/logger";
import { elapsedMs, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
import type { OverviewEventRow, OverviewMachineRow } from "@/lib/overview/types";
import {
fetchLatestHeartbeats,
fetchLatestKpis,
fetchMachineBase,
mergeMachineOverviewRows,
} from "@/lib/machines/withLatest";
const ALLOWED_TYPES = new Set([
"slow-cycle",
@@ -37,157 +46,169 @@ export async function getOverviewData({
eventsWindowSec = 21600,
eventMachines = 6,
orgSettings,
}: OverviewParams) {
const machines = await prisma.machine.findMany({
where: { orgId },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
code: true,
location: true,
createdAt: true,
updatedAt: true,
heartbeats: {
orderBy: { tsServer: "desc" },
take: 1,
select: { ts: true, tsServer: true, status: true, message: true, ip: true, fwVersion: true },
},
kpiSnapshots: {
orderBy: { ts: "desc" },
take: 1,
select: {
ts: true,
oee: true,
availability: true,
performance: true,
quality: true,
workOrderId: true,
sku: true,
good: true,
scrap: true,
target: true,
cycleTime: true,
}: OverviewParams): Promise<{ machines: OverviewMachineRow[]; events: OverviewEventRow[] }> {
const perfEnabled = PERF_LOGS_ENABLED;
const timings: Record<string, number> = {};
const totalStart = nowMs();
try {
const machinesStart = nowMs();
const machines = await fetchMachineBase(orgId);
if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart);
const heartbeatStart = nowMs();
const machineIds = machines.map((machine) => machine.id);
const heartbeats = await fetchLatestHeartbeats(orgId, machineIds);
if (perfEnabled) timings.heartbeatsQuery = elapsedMs(heartbeatStart);
const kpiStart = nowMs();
const kpis = await fetchLatestKpis(orgId, machineIds);
if (perfEnabled) timings.kpiQuery = elapsedMs(kpiStart);
const machineRows: OverviewMachineRow[] = mergeMachineOverviewRows({
machines,
heartbeats,
kpis,
includeKpi: true,
});
const safeEventMachines = Number.isFinite(eventMachines) ? Math.max(1, Math.floor(eventMachines)) : 6;
const safeWindowSec = Number.isFinite(eventsWindowSec) ? eventsWindowSec : 21600;
const topMachines = machineRows
.slice()
.sort((a, b) => {
const at = heartbeatTime(a.latestHeartbeat);
const bt = heartbeatTime(b.latestHeartbeat);
const atMs = at ? at.getTime() : 0;
const btMs = bt ? bt.getTime() : 0;
return btMs - atMs;
})
.slice(0, safeEventMachines);
const targetIds = topMachines.map((m) => m.id);
let events: OverviewEventRow[] = [];
if (targetIds.length) {
let settings = orgSettings ?? null;
if (!settings) {
const settingsStart = nowMs();
settings = await prisma.orgSettings.findUnique({
where: { orgId },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
});
if (perfEnabled) timings.orgSettingsQuery = elapsedMs(settingsStart);
}
const microMultiplier = Number(settings?.stoppageMultiplier ?? 1.5);
const macroMultiplier = Math.max(microMultiplier, Number(settings?.macroStoppageMultiplier ?? 5));
const windowStart = new Date(Date.now() - Math.max(0, safeWindowSec) * 1000);
const eventsStart = nowMs();
const rawEvents = await prisma.machineEvent.findMany({
where: {
orgId,
machineId: { in: targetIds },
ts: { gte: windowStart },
},
},
},
});
orderBy: { ts: "desc" },
take: Math.min(300, Math.max(60, targetIds.length * 40)),
select: {
id: true,
ts: true,
topic: true,
eventType: true,
severity: true,
title: true,
description: true,
requiresAck: true,
data: true,
workOrderId: true,
machineId: true,
machine: { select: { name: true } },
},
});
if (perfEnabled) timings.eventsQuery = elapsedMs(eventsStart);
const machineRows = machines.map((m) => ({
...m,
latestHeartbeat: m.heartbeats[0] ?? null,
latestKpi: m.kpiSnapshots[0] ?? null,
heartbeats: undefined,
kpiSnapshots: undefined,
}));
const normalizeStart = nowMs();
const normalized = rawEvents
.map((row) => ({
...normalizeEvent(row, { microMultiplier, macroMultiplier }),
machineId: row.machineId,
machineName: row.machine?.name ?? null,
source: "ingested" as const,
}))
.filter((event) => event.ts);
if (perfEnabled) timings.eventsNormalize = elapsedMs(normalizeStart);
const safeEventMachines = Number.isFinite(eventMachines) ? Math.max(1, Math.floor(eventMachines)) : 6;
const safeWindowSec = Number.isFinite(eventsWindowSec) ? eventsWindowSec : 21600;
const filterStart = nowMs();
const allowed = normalized.filter((event) => ALLOWED_TYPES.has(event.eventType));
const isCritical = (event: (typeof allowed)[number]) => {
const severity = String(event.severity ?? "").toLowerCase();
return (
event.eventType === "macrostop" ||
event.requiresAck === true ||
severity === "critical" ||
severity === "error" ||
severity === "high"
);
};
const topMachines = machineRows
.slice()
.sort((a, b) => {
const at = heartbeatTime(a.latestHeartbeat);
const bt = heartbeatTime(b.latestHeartbeat);
const atMs = at ? at.getTime() : 0;
const btMs = bt ? bt.getTime() : 0;
return btMs - atMs;
})
.slice(0, safeEventMachines);
const filtered = eventsMode === "critical" ? allowed.filter(isCritical) : allowed;
const targetIds = topMachines.map((m) => m.id);
const seen = new Set<string>();
const deduped = filtered.filter((event) => {
const key = `${event.machineId}-${event.eventType}-${event.ts ?? ""}-${event.title}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
let events = [] as Array<{
id: string;
ts: Date | null;
topic: string;
eventType: string;
severity: string;
title: string;
description?: string | null;
requiresAck: boolean;
workOrderId?: string | null;
machineId: string;
machineName?: string | null;
source: "ingested";
}>;
deduped.sort((a, b) => {
const at = a.ts ? a.ts.getTime() : 0;
const bt = b.ts ? b.ts.getTime() : 0;
return bt - at;
});
if (targetIds.length) {
let settings = orgSettings ?? null;
if (!settings) {
settings = await prisma.orgSettings.findUnique({
where: { orgId },
select: { stoppageMultiplier: true, macroStoppageMultiplier: true },
events = deduped.slice(0, 30);
if (perfEnabled) timings.eventsFilter = elapsedMs(filterStart);
}
if (perfEnabled) {
timings.total = elapsedMs(totalStart);
logLine("perf.overview.getOverviewData", {
orgId,
eventsMode,
eventsWindowSec,
eventMachines,
timings,
counts: {
machines: machineRows.length,
events: events.length,
targetMachines: targetIds.length,
},
});
}
const microMultiplier = Number(settings?.stoppageMultiplier ?? 1.5);
const macroMultiplier = Math.max(microMultiplier, Number(settings?.macroStoppageMultiplier ?? 5));
const windowStart = new Date(Date.now() - Math.max(0, safeWindowSec) * 1000);
const rawEvents = await prisma.machineEvent.findMany({
where: {
return { machines: machineRows, events };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
if (perfEnabled) {
timings.total = elapsedMs(totalStart);
logLine("perf.overview.getOverviewData.error", {
orgId,
machineId: { in: targetIds },
ts: { gte: windowStart },
},
orderBy: { ts: "desc" },
take: Math.min(300, Math.max(60, targetIds.length * 40)),
select: {
id: true,
ts: true,
topic: true,
eventType: true,
severity: true,
title: true,
description: true,
requiresAck: true,
data: true,
workOrderId: true,
machineId: true,
machine: { select: { name: true } },
},
});
const normalized = rawEvents
.map((row) => ({
...normalizeEvent(row, { microMultiplier, macroMultiplier }),
machineId: row.machineId,
machineName: row.machine?.name ?? null,
source: "ingested" as const,
}))
.filter((event) => event.ts);
const allowed = normalized.filter((event) => ALLOWED_TYPES.has(event.eventType));
const isCritical = (event: (typeof allowed)[number]) => {
const severity = String(event.severity ?? "").toLowerCase();
return (
event.eventType === "macrostop" ||
event.requiresAck === true ||
severity === "critical" ||
severity === "error" ||
severity === "high"
);
};
const filtered = eventsMode === "critical" ? allowed.filter(isCritical) : allowed;
const seen = new Set<string>();
const deduped = filtered.filter((event) => {
const key = `${event.machineId}-${event.eventType}-${event.ts ?? ""}-${event.title}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
deduped.sort((a, b) => {
const at = a.ts ? a.ts.getTime() : 0;
const bt = b.ts ? b.ts.getTime() : 0;
return bt - at;
});
events = deduped.slice(0, 30);
eventsMode,
eventsWindowSec,
eventMachines,
timings,
message,
stack,
});
}
logLine("getOverviewData.error", { message, stack });
console.error("[getOverviewData]", err);
return { machines: [], events: [] };
}
return { machines: machineRows, events };
}

View File

@@ -0,0 +1,102 @@
import { logLine } from "@/lib/logger";
import { elapsedMs, nowMs, PERF_LOGS_ENABLED } from "@/lib/perf/serverTiming";
import type { OverviewMachineRow } from "@/lib/overview/types";
import {
fetchLatestHeartbeats,
fetchMachineBase,
mergeMachineOverviewRows,
} from "@/lib/machines/withLatest";
type OverviewSummaryParams = {
orgId: string;
};
const SUMMARY_CACHE_TTL_MS = 10000;
const summaryCache = new Map<string, { value: OverviewMachineRow[]; expiresAt: number; cachedAt: number }>();
const summaryInFlight = new Map<string, Promise<{ machines: OverviewMachineRow[] }>>();
export async function getOverviewSummary({
orgId,
}: OverviewSummaryParams): Promise<{ machines: OverviewMachineRow[] }> {
const now = Date.now();
const cached = summaryCache.get(orgId);
if (cached && cached.expiresAt > now) {
if (PERF_LOGS_ENABLED) {
logLine("perf.overview.summary", {
orgId,
cached: true,
timings: { total: 0 },
ageMs: now - cached.cachedAt,
counts: { machines: cached.value.length },
});
}
return { machines: cached.value };
}
const inFlight = summaryInFlight.get(orgId);
if (inFlight) return inFlight;
const promise = fetchOverviewSummary({ orgId })
.then((result) => {
summaryCache.set(orgId, {
value: result.machines,
cachedAt: now,
expiresAt: now + SUMMARY_CACHE_TTL_MS,
});
summaryInFlight.delete(orgId);
return result;
})
.catch((err) => {
summaryInFlight.delete(orgId);
throw err;
});
summaryInFlight.set(orgId, promise);
return promise;
}
async function fetchOverviewSummary({
orgId,
}: OverviewSummaryParams): Promise<{ machines: OverviewMachineRow[] }> {
const perfEnabled = PERF_LOGS_ENABLED;
const totalStart = nowMs();
const timings: Record<string, number> = {};
try {
const machinesStart = nowMs();
const machines = await fetchMachineBase(orgId);
if (perfEnabled) timings.machinesQuery = elapsedMs(machinesStart);
const heartbeatStart = nowMs();
const machineIds = machines.map((machine) => machine.id);
const heartbeats = await fetchLatestHeartbeats(orgId, machineIds);
if (perfEnabled) timings.heartbeatsQuery = elapsedMs(heartbeatStart);
const machineRows: OverviewMachineRow[] = mergeMachineOverviewRows({
machines,
heartbeats,
includeKpi: false,
});
if (perfEnabled) {
timings.total = elapsedMs(totalStart);
logLine("perf.overview.summary", {
orgId,
timings,
counts: { machines: machineRows.length },
});
}
return { machines: machineRows };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
if (perfEnabled) {
timings.total = elapsedMs(totalStart);
logLine("perf.overview.summary.error", { orgId, timings, message, stack });
}
logLine("getOverviewSummary.error", { message, stack });
console.error("[getOverviewSummary]", err);
return { machines: [] };
}
}

51
lib/overview/types.ts Normal file
View File

@@ -0,0 +1,51 @@
export type OverviewLatestHeartbeat = {
ts: Date;
tsServer?: Date | null;
status: string;
message?: string | null;
ip?: string | null;
fwVersion?: string | null;
};
export type OverviewLatestKpi = {
ts: Date;
oee?: number | null;
availability?: number | null;
performance?: number | null;
quality?: number | null;
workOrderId?: string | null;
sku?: string | null;
good?: number | null;
scrap?: number | null;
target?: number | null;
cycleTime?: number | null;
};
export type OverviewMachineRow = {
id: string;
name: string;
code?: string | null;
location?: string | null;
createdAt: Date;
updatedAt: Date;
latestHeartbeat: OverviewLatestHeartbeat | null;
latestKpi: OverviewLatestKpi | null;
heartbeats?: undefined;
kpiSnapshots?: undefined;
};
export type OverviewEventRow = {
id: string;
ts: Date | null;
topic: string;
eventType: string;
severity: string;
title: string;
description?: string | null;
requiresAck: boolean;
workOrderId?: string | null;
machineId: string;
machineName?: string | null;
source: "ingested";
};

18
lib/perf/serverTiming.ts Normal file
View File

@@ -0,0 +1,18 @@
import { performance } from "perf_hooks";
export const PERF_LOGS_ENABLED = process.env.PERF_LOGS === "1";
export function nowMs() {
return performance.now();
}
export function elapsedMs(startMs: number) {
return Math.round((performance.now() - startMs) * 100) / 100;
}
export function formatServerTiming(entries: Record<string, number>) {
return Object.entries(entries)
.filter(([, value]) => Number.isFinite(value))
.map(([name, value]) => `${name};dur=${value.toFixed(1)}`)
.join(", ");
}

200
lib/reasonCatalog.ts Normal file
View File

@@ -0,0 +1,200 @@
import { readFile } from "fs/promises";
import path from "path";
type AnyRecord = Record<string, unknown>;
export type ReasonCatalogKind = "downtime" | "scrap";
export type ReasonCatalogDetail = {
id: string;
label: string;
};
export type ReasonCatalogCategory = {
id: string;
label: string;
details: ReasonCatalogDetail[];
};
export type ReasonCatalog = {
version: number;
downtime: ReasonCatalogCategory[];
scrap: ReasonCatalogCategory[];
};
function isPlainObject(value: unknown): value is AnyRecord {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function canonicalId(input: unknown, fallback = "item") {
const text = String(input ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return text || fallback;
}
function buildReasonCode(categoryId: string, detailId: string) {
return `${canonicalId(categoryId)}__${canonicalId(detailId)}`.toUpperCase();
}
function toCategory(raw: unknown): ReasonCatalogCategory | null {
if (!isPlainObject(raw)) return null;
const labelRaw = String(raw.label ?? "").trim();
if (!labelRaw) return null;
const idRaw = String(raw.id ?? "").trim() || canonicalId(labelRaw, "category");
const detailsRaw =
(Array.isArray(raw.details) && raw.details) ||
(Array.isArray(raw.children) && raw.children) ||
(Array.isArray(raw.items) && raw.items) ||
[];
const details: ReasonCatalogDetail[] = [];
for (const detailRaw of detailsRaw) {
if (!isPlainObject(detailRaw)) continue;
const detailLabel = String(detailRaw.label ?? "").trim();
if (!detailLabel) continue;
const detailId = String(detailRaw.id ?? "").trim() || canonicalId(detailLabel, "detail");
details.push({ id: detailId, label: detailLabel });
}
if (!details.length) return null;
return { id: idRaw, label: labelRaw, details };
}
function normalizeKind(raw: unknown): ReasonCatalogCategory[] {
const arr =
(Array.isArray(raw) && raw) ||
(isPlainObject(raw) && Array.isArray(raw.categories) && raw.categories) ||
[];
const out: ReasonCatalogCategory[] = [];
for (const candidate of arr) {
const parsed = toCategory(candidate);
if (parsed) out.push(parsed);
}
return out;
}
export function normalizeReasonCatalog(raw: unknown): ReasonCatalog | null {
if (!isPlainObject(raw)) return null;
const downtime = normalizeKind(raw.downtime);
const scrap = normalizeKind(raw.scrap);
if (!downtime.length && !scrap.length) return null;
const versionNum = Number(raw.version);
const version = Number.isFinite(versionNum) ? Math.max(1, Math.trunc(versionNum)) : 1;
return {
version,
downtime,
scrap,
};
}
export function parseReasonCatalogMarkdown(markdown: string): ReasonCatalog {
const lines = markdown
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const buckets: Record<ReasonCatalogKind, Map<string, ReasonCatalogCategory>> = {
downtime: new Map(),
scrap: new Map(),
};
let activeKind: ReasonCatalogKind = "downtime";
for (const line of lines) {
const lowered = line.toLowerCase();
if (lowered === "downtime") {
activeKind = "downtime";
continue;
}
if (lowered === "scrap") {
activeKind = "scrap";
continue;
}
const slash = line.indexOf("/");
if (slash < 1 || slash === line.length - 1) continue;
const categoryLabel = line.slice(0, slash).trim();
const detailLabel = line.slice(slash + 1).trim();
if (!categoryLabel || !detailLabel) continue;
const categoryId = canonicalId(categoryLabel, "category");
const detailId = canonicalId(detailLabel, "detail");
const existing =
buckets[activeKind].get(categoryId) ?? {
id: categoryId,
label: categoryLabel,
details: [] as ReasonCatalogDetail[],
};
if (!existing.details.some((d) => d.id === detailId)) {
existing.details.push({ id: detailId, label: detailLabel });
}
buckets[activeKind].set(categoryId, existing);
}
return {
version: 1,
downtime: [...buckets.downtime.values()],
scrap: [...buckets.scrap.values()],
};
}
let catalogPromise: Promise<ReasonCatalog> | null = null;
export async function loadFallbackReasonCatalog() {
if (!catalogPromise) {
catalogPromise = readFile(path.join(process.cwd(), "downtime_menu.md"), "utf8")
.then((raw) => parseReasonCatalogMarkdown(raw))
.catch(() => ({ version: 1, downtime: [], scrap: [] }));
}
return catalogPromise;
}
export function flattenReasonCatalog(catalog: ReasonCatalog, kind: ReasonCatalogKind) {
return (catalog[kind] ?? []).flatMap((category) =>
category.details.map((detail) => ({
kind,
categoryId: category.id,
categoryLabel: category.label,
detailId: detail.id,
detailLabel: detail.label,
reasonCode: buildReasonCode(category.id, detail.id),
reasonLabel: `${category.label} > ${detail.label}`,
}))
);
}
export function findCatalogReason(
catalog: ReasonCatalog | null | undefined,
kind: ReasonCatalogKind,
categoryId: unknown,
detailId: unknown
) {
if (!catalog) return null;
const catId = canonicalId(categoryId, "");
const detId = canonicalId(detailId, "");
if (!catId || !detId) return null;
const category = (catalog[kind] ?? []).find((c) => canonicalId(c.id, "") === catId);
if (!category) return null;
const detail = category.details.find((d) => canonicalId(d.id, "") === detId);
if (!detail) return null;
return {
categoryId: category.id,
categoryLabel: category.label,
detailId: detail.id,
detailLabel: detail.label,
reasonCode: buildReasonCode(category.id, detail.id),
reasonLabel: `${category.label} > ${detail.label}`,
};
}
export function toReasonCode(categoryId: unknown, detailId: unknown) {
const cat = canonicalId(categoryId, "");
const det = canonicalId(detailId, "");
if (!cat || !det) return null;
return buildReasonCode(cat, det);
}

View File

@@ -18,6 +18,9 @@ export const DEFAULT_SHIFT = {
end: "15:00",
};
export const SHIFT_OVERRIDE_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
export type ShiftOverrideDay = (typeof SHIFT_OVERRIDE_DAYS)[number];
type AnyRecord = Record<string, unknown>;
function isPlainObject(value: unknown): value is AnyRecord {
@@ -40,6 +43,7 @@ type SettingsRow = {
timezone: string;
shiftChangeCompMin?: number | null;
lunchBreakMin?: number | null;
shiftScheduleOverridesJson?: unknown;
stoppageMultiplier?: number | null;
macroStoppageMultiplier?: number | null;
oeeAlertThresholdPct?: number | null;
@@ -59,6 +63,13 @@ type ShiftRow = {
sortOrder?: number | null;
};
type ShiftOverridePayload = {
name: string;
start: string;
end: string;
enabled: boolean;
};
export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[]) {
const ordered = [...(shifts ?? [])].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
const mappedShifts = ordered.map((s, idx) => ({
@@ -67,6 +78,13 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
end: s.endTime,
enabled: s.enabled !== false,
}));
const overrides = normalizeShiftOverrides(settings.shiftScheduleOverridesJson);
const defaults = normalizeDefaults(settings.defaultsJson);
const reasonCatalog =
isPlainObject(settings.defaultsJson) && "reasonCatalog" in settings.defaultsJson
? (settings.defaultsJson as AnyRecord).reasonCatalog
: null;
return {
orgId: settings.orgId,
@@ -74,6 +92,7 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
timezone: settings.timezone,
shiftSchedule: {
shifts: mappedShifts,
overrides: overrides && Object.keys(overrides).length ? overrides : undefined,
shiftChangeCompensationMin: settings.shiftChangeCompMin,
lunchBreakMin: settings.lunchBreakMin,
},
@@ -85,7 +104,10 @@ export function buildSettingsPayload(settings: SettingsRow, shifts: ShiftRow[])
qualitySpikeDeltaPct: settings.qualitySpikeDeltaPct,
},
alerts: normalizeAlerts(settings.alertsJson),
defaults: normalizeDefaults(settings.defaultsJson),
defaults,
reasonCatalog: reasonCatalog ?? undefined,
reasonCatalogData: reasonCatalog ?? undefined,
reasonCatalogVersion: Number((reasonCatalog as AnyRecord | null)?.version ?? 1),
updatedAt: settings.updatedAt,
updatedBy: settings.updatedBy,
};
@@ -169,6 +191,57 @@ export function validateShiftSchedule(shifts: unknown) {
return { ok: true, shifts: normalized as NormalizedShift[] };
}
export function validateShiftOverrides(overrides: unknown) {
if (overrides === null) {
return { ok: true, overrides: null as Record<string, ShiftOverridePayload[]> | null } as const;
}
if (!isPlainObject(overrides)) {
return { ok: false, error: "shift overrides must be an object" } as const;
}
const normalized: Record<string, ShiftOverridePayload[]> = {};
for (const [key, value] of Object.entries(overrides)) {
if (!SHIFT_OVERRIDE_DAYS.includes(key as ShiftOverrideDay)) {
return { ok: false, error: `invalid shift override day: ${key}` } as const;
}
const shiftResult = validateShiftSchedule(value);
if (!shiftResult.ok) {
return { ok: false, error: `shift overrides ${key}: ${shiftResult.error}` } as const;
}
normalized[key] =
shiftResult.shifts?.map((s) => ({
name: s.name,
start: s.startTime,
end: s.endTime,
enabled: s.enabled !== false,
})) ?? [];
}
return { ok: true, overrides: normalized } as const;
}
export function normalizeShiftOverrides(raw: unknown) {
if (!isPlainObject(raw)) return undefined;
const out: Record<string, ShiftOverridePayload[]> = {};
for (const day of SHIFT_OVERRIDE_DAYS) {
const value = raw[day];
if (!Array.isArray(value)) continue;
const normalized = value
.map((entry, idx) => {
const record = isPlainObject(entry) ? entry : {};
const start = String(record.start ?? record.startTime ?? "").trim();
const end = String(record.end ?? record.endTime ?? "").trim();
if (!TIME_RE.test(start) || !TIME_RE.test(end)) return null;
const name = String(record.name ?? `Shift ${idx + 1}`).trim() || `Shift ${idx + 1}`;
const enabled = record.enabled !== false;
return { name, start, end, enabled };
})
.filter((entry): entry is ShiftOverridePayload => !!entry);
out[day] = normalized;
}
return out;
}
export function validateShiftFields(shiftChangeCompensationMin?: unknown, lunchBreakMin?: unknown) {
if (shiftChangeCompensationMin != null) {
const v = Number(shiftChangeCompensationMin);