From c183dda383e4d85bc9cce71b2f56b5205155c8ee Mon Sep 17 00:00:00 2001 From: Marcelo Date: Fri, 16 Jan 2026 22:39:16 +0000 Subject: [PATCH] Mobile friendly, lint correction, typescript error clear --- app/(app)/alerts/AlertsClient.tsx | 499 ++++++++++ app/(app)/alerts/page.tsx | 826 +-------------- app/(app)/financial/FinancialClient.tsx | 388 ++++++++ app/(app)/financial/page.tsx | 45 + app/(app)/layout.tsx | 7 +- app/(app)/machines/MachinesClient.tsx | 325 ++++++ .../[machineId]/MachineDetailClient.tsx | 321 +++--- app/(app)/machines/page.tsx | 353 +------ app/(app)/overview/OverviewClient.tsx | 465 +++++++++ app/(app)/overview/page.tsx | 551 +--------- app/(app)/reports/loading.tsx | 21 + app/(app)/reports/page.tsx | 79 +- app/(app)/settings/page.tsx | 942 ++++++++++-------- app/api/alerts/contacts/[id]/route.ts | 1 + app/api/alerts/inbox/route.ts | 48 + app/api/financial/costs/route.ts | 262 +++++ app/api/financial/export/excel/route.ts | 156 +++ app/api/financial/export/pdf/route.ts | 246 +++++ app/api/financial/impact/route.ts | 71 ++ app/api/ingest/cycle/route.ts | 51 +- app/api/ingest/event/route.ts | 120 ++- app/api/ingest/heartbeat/route.ts | 22 +- app/api/ingest/kpi/route.ts | 57 +- app/api/machines/[machineId]/route.ts | 317 +++--- app/api/machines/route.ts | 7 +- app/api/org/members/route.ts | 9 +- app/api/overview/route.ts | 101 ++ app/api/reports/route.ts | 9 +- .../settings/machines/[machineId]/route.ts | 15 +- app/api/settings/route.ts | 21 +- app/api/signup/route.ts | 13 +- app/api/work-orders/route.ts | 16 +- app/globals.css | 2 + app/invite/[token]/InviteAcceptForm.tsx | 12 +- app/layout.tsx | 6 +- app/login/LoginForm.tsx | 5 +- app/signup/SignupForm.tsx | 5 +- components/auth/RequireAuth.tsx | 25 +- components/layout/AppShell.tsx | 18 +- components/layout/Sidebar.tsx | 28 +- components/layout/UtilityControls.tsx | 18 +- components/settings/AlertsConfig.tsx | 777 +++++++++++++++ components/settings/FinancialCostConfig.tsx | 682 +++++++++++++ lib/alerts/engine.ts | 85 +- lib/alerts/getAlertsInboxData.ts | 261 +++++ lib/contracts/v1.ts | 188 ++-- lib/email.ts | 36 +- lib/events/normalizeEvent.ts | 214 ++++ lib/financial/impact.ts | 418 ++++++++ lib/i18n/en.json | 130 ++- lib/i18n/es-MX.json | 130 ++- lib/logger.ts | 2 +- lib/overview/getOverviewData.ts | 193 ++++ lib/prismaJson.ts | 33 + lib/settings.ts | 74 +- lib/sms.ts | 3 +- .../migration.sql | 110 ++ prisma/schema.prisma | 94 ++ 58 files changed, 7199 insertions(+), 2714 deletions(-) create mode 100644 app/(app)/alerts/AlertsClient.tsx create mode 100644 app/(app)/financial/FinancialClient.tsx create mode 100644 app/(app)/financial/page.tsx create mode 100644 app/(app)/machines/MachinesClient.tsx create mode 100644 app/(app)/overview/OverviewClient.tsx create mode 100644 app/(app)/reports/loading.tsx create mode 100644 app/api/alerts/inbox/route.ts create mode 100644 app/api/financial/costs/route.ts create mode 100644 app/api/financial/export/excel/route.ts create mode 100644 app/api/financial/export/pdf/route.ts create mode 100644 app/api/financial/impact/route.ts create mode 100644 app/api/overview/route.ts create mode 100644 components/settings/AlertsConfig.tsx create mode 100644 components/settings/FinancialCostConfig.tsx create mode 100644 lib/alerts/getAlertsInboxData.ts create mode 100644 lib/events/normalizeEvent.ts create mode 100644 lib/financial/impact.ts create mode 100644 lib/overview/getOverviewData.ts create mode 100644 lib/prismaJson.ts create mode 100644 prisma/migrations/20260115120000_add_financial_profiles/migration.sql diff --git a/app/(app)/alerts/AlertsClient.tsx b/app/(app)/alerts/AlertsClient.tsx new file mode 100644 index 0000000..662b574 --- /dev/null +++ b/app/(app)/alerts/AlertsClient.tsx @@ -0,0 +1,499 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useI18n } from "@/lib/i18n/useI18n"; + +type MachineRow = { + id: string; + name: string; + location?: string | null; +}; + +type ShiftRow = { + name: string; + enabled: boolean; +}; + +type AlertEvent = { + id: string; + ts: string; + 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; +}; + +const RANGE_OPTIONS = [ + { value: "24h", labelKey: "alerts.inbox.range.24h" }, + { value: "7d", labelKey: "alerts.inbox.range.7d" }, + { value: "30d", labelKey: "alerts.inbox.range.30d" }, + { value: "custom", labelKey: "alerts.inbox.range.custom" }, +] as const; + +function formatDuration(seconds: number | null | undefined, t: (key: string) => string) { + if (seconds == null || !Number.isFinite(seconds)) return t("alerts.inbox.duration.na"); + if (seconds < 60) return `${Math.round(seconds)}${t("alerts.inbox.duration.sec")}`; + if (seconds < 3600) return `${Math.round(seconds / 60)}${t("alerts.inbox.duration.min")}`; + return `${(seconds / 3600).toFixed(1)}${t("alerts.inbox.duration.hr")}`; +} + +function normalizeLabel(value?: string | null) { + if (!value) return ""; + return String(value).trim(); +} + +export default function AlertsClient({ + initialMachines = [], + initialShifts = [], + initialEvents = [], +}: { + initialMachines?: MachineRow[]; + initialShifts?: ShiftRow[]; + initialEvents?: AlertEvent[]; +}) { + const { t, locale } = useI18n(); + const [events, setEvents] = useState(() => initialEvents); + const [machines, setMachines] = useState(() => initialMachines); + const [shifts, setShifts] = useState(() => initialShifts); + const [loading, setLoading] = useState(() => initialMachines.length === 0 || initialShifts.length === 0); + const [loadingEvents, setLoadingEvents] = useState(false); + const [error, setError] = useState(null); + const [range, setRange] = useState("24h"); + const [start, setStart] = useState(""); + const [end, setEnd] = useState(""); + const [machineId, setMachineId] = useState(""); + const [location, setLocation] = useState(""); + const [shift, setShift] = useState(""); + const [eventType, setEventType] = useState(""); + const [severity, setSeverity] = useState(""); + const [status, setStatus] = useState(""); + const [includeUpdates, setIncludeUpdates] = useState(false); + const [search, setSearch] = useState(""); + const skipInitialEventsRef = useRef(true); + + const locations = useMemo(() => { + const seen = new Set(); + for (const machine of machines) { + if (!machine.location) continue; + seen.add(machine.location); + } + return Array.from(seen).sort(); + }, [machines]); + + useEffect(() => { + if (initialMachines.length && initialShifts.length) { + setLoading(false); + return; + } + let alive = true; + + async function loadFilters() { + setLoading(true); + try { + const [machinesRes, settingsRes] = await Promise.all([ + fetch("/api/machines", { cache: "no-store" }), + fetch("/api/settings", { cache: "no-store" }), + ]); + const machinesJson = await machinesRes.json().catch(() => ({})); + const settingsJson = await settingsRes.json().catch(() => ({})); + if (!alive) return; + setMachines(machinesJson.machines ?? []); + const shiftRows = settingsJson?.settings?.shiftSchedule?.shifts ?? []; + setShifts( + Array.isArray(shiftRows) + ? shiftRows + .map((row: unknown) => { + const data = row && typeof row === "object" ? (row as Record) : {}; + const name = typeof data.name === "string" ? data.name : ""; + const enabled = data.enabled !== false; + return { name, enabled }; + }) + .filter((row) => row.name) + : [] + ); + } catch { + if (!alive) return; + } finally { + if (alive) setLoading(false); + } + } + + loadFilters(); + return () => { + alive = false; + }; + }, [initialMachines, initialShifts]); + + useEffect(() => { + let alive = true; + const controller = new AbortController(); + + async function loadEvents() { + const isDefault = + range === "24h" && + !start && + !end && + !machineId && + !location && + !shift && + !eventType && + !severity && + !status && + !includeUpdates; + if (skipInitialEventsRef.current) { + skipInitialEventsRef.current = false; + if (initialEvents.length && isDefault) return; + } + + setLoadingEvents(true); + setError(null); + const params = new URLSearchParams(); + params.set("range", range); + if (range === "custom") { + if (start) params.set("start", start); + if (end) params.set("end", end); + } + if (machineId) params.set("machineId", machineId); + if (location) params.set("location", location); + if (shift) params.set("shift", shift); + if (eventType) params.set("eventType", eventType); + if (severity) params.set("severity", severity); + if (status) params.set("status", status); + if (includeUpdates) params.set("includeUpdates", "1"); + params.set("limit", "250"); + + try { + const res = await fetch(`/api/alerts/inbox?${params.toString()}`, { + cache: "no-store", + signal: controller.signal, + }); + const json = await res.json().catch(() => ({})); + if (!alive) return; + if (!res.ok || !json?.ok) { + setError(json?.error || t("alerts.inbox.error")); + setEvents([]); + } else { + setEvents(json.events ?? []); + } + } catch { + if (alive) { + setError(t("alerts.inbox.error")); + setEvents([]); + } + } finally { + if (alive) setLoadingEvents(false); + } + } + + loadEvents(); + return () => { + alive = false; + controller.abort(); + }; + }, [ + range, + start, + end, + machineId, + location, + shift, + eventType, + severity, + status, + includeUpdates, + t, + initialEvents.length, + ]); + + const eventTypes = useMemo(() => { + const seen = new Set(); + for (const ev of events) { + if (ev.eventType) seen.add(ev.eventType); + } + return Array.from(seen).sort(); + }, [events]); + + const severities = useMemo(() => { + const seen = new Set(); + for (const ev of events) { + if (ev.severity) seen.add(ev.severity); + } + return Array.from(seen).sort(); + }, [events]); + + const statuses = useMemo(() => { + const seen = new Set(); + for (const ev of events) { + if (ev.status) seen.add(ev.status); + } + return Array.from(seen).sort(); + }, [events]); + + const filteredEvents = useMemo(() => { + if (!search.trim()) return events; + const needle = search.trim().toLowerCase(); + return events.filter((ev) => { + return ( + normalizeLabel(ev.title).toLowerCase().includes(needle) || + normalizeLabel(ev.description).toLowerCase().includes(needle) || + normalizeLabel(ev.machineName).toLowerCase().includes(needle) || + normalizeLabel(ev.location).toLowerCase().includes(needle) || + normalizeLabel(ev.eventType).toLowerCase().includes(needle) + ); + }); + }, [events, search]); + + function formatEventTypeLabel(value: string) { + const key = `alerts.event.${value}`; + const label = t(key); + return label === key ? value : label; + } + + function formatStatusLabel(value?: string | null) { + if (!value) return t("alerts.inbox.table.unknown"); + const key = `alerts.inbox.status.${value}`; + const label = t(key); + return label === key ? value : label; + } + + return ( +
+
+

{t("alerts.title")}

+

{t("alerts.subtitle")}

+
+ +
+
+
{t("alerts.inbox.filters.title")}
+ {loading &&
{t("alerts.inbox.loadingFilters")}
} +
+
+ + {range === "custom" && ( + <> + + + + )} + + + + + + + + +
+
+ +
+
+
{t("alerts.inbox.title")}
+ {loadingEvents &&
{t("alerts.inbox.loading")}
} +
+ + {error && ( +
+ {error} +
+ )} + + {!loadingEvents && !filteredEvents.length && ( +
{t("alerts.inbox.empty")}
+ )} + + {!!filteredEvents.length && ( +
+ + + + + + + + + + + + + + + + {filteredEvents.map((ev) => ( + + + + + + + + + + + + ))} + +
{t("alerts.inbox.table.time")}{t("alerts.inbox.table.machine")}{t("alerts.inbox.table.site")}{t("alerts.inbox.table.shift")}{t("alerts.inbox.table.type")}{t("alerts.inbox.table.severity")}{t("alerts.inbox.table.status")}{t("alerts.inbox.table.duration")}{t("alerts.inbox.table.title")}
+ {new Date(ev.ts).toLocaleString(locale)} + {ev.machineName || t("alerts.inbox.table.unknown")}{ev.location || t("alerts.inbox.table.unknown")}{ev.shift || t("alerts.inbox.table.unknown")}{formatEventTypeLabel(ev.eventType)}{ev.severity || t("alerts.inbox.table.unknown")}{formatStatusLabel(ev.status)}{formatDuration(ev.durationSec, t)} +
{ev.title}
+ {ev.description && ( +
{ev.description}
+ )} + {(ev.workOrderId || ev.sku) && ( +
+ {ev.workOrderId ? `${t("alerts.inbox.meta.workOrder")}: ${ev.workOrderId}` : null} + {ev.workOrderId && ev.sku ? " • " : null} + {ev.sku ? `${t("alerts.inbox.meta.sku")}: ${ev.sku}` : null} +
+ )} +
+
+ )} +
+
+ ); +} diff --git a/app/(app)/alerts/page.tsx b/app/(app)/alerts/page.tsx index f9eb1ab..5545be2 100644 --- a/app/(app)/alerts/page.tsx +++ b/app/(app)/alerts/page.tsx @@ -1,796 +1,46 @@ -"use client"; +import { redirect } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import { requireSession } from "@/lib/auth/requireSession"; +import { getAlertsInboxData } from "@/lib/alerts/getAlertsInboxData"; +import AlertsClient from "./AlertsClient"; -import { useEffect, useMemo, useState } from "react"; -import { useI18n } from "@/lib/i18n/useI18n"; +export default async function AlertsPage() { + const session = await requireSession(); + if (!session) redirect("/login?next=/alerts"); -type RoleName = "MEMBER" | "ADMIN" | "OWNER"; -type Channel = "email" | "sms"; + const [machines, shiftRows, inbox] = await Promise.all([ + prisma.machine.findMany({ + where: { orgId: session.orgId }, + orderBy: { createdAt: "desc" }, + select: { id: true, name: true, location: true }, + }), + prisma.orgShift.findMany({ + where: { orgId: session.orgId }, + orderBy: { sortOrder: "asc" }, + select: { name: true, enabled: true }, + }), + getAlertsInboxData({ + orgId: session.orgId, + range: "24h", + limit: 250, + }), + ]); -type RoleRule = { - enabled: boolean; - afterMinutes: number; - channels: Channel[]; -}; + const initialEvents = inbox.events.map((event) => ({ + ...event, + ts: event.ts ? event.ts.toISOString() : "", + })); -type AlertRule = { - id: string; - eventType: string; - roles: Record; - repeatMinutes?: number; -}; - -type AlertPolicy = { - version: number; - defaults: Record; - rules: AlertRule[]; -}; - -type AlertContact = { - id: string; - name: string; - roleScope: string; - email?: string | null; - phone?: string | null; - eventTypes?: string[] | null; - isActive: boolean; - userId?: string | null; -}; - -type ContactDraft = { - name: string; - roleScope: string; - email: string; - phone: string; - eventTypes: string[]; - isActive: boolean; -}; - -const ROLE_ORDER: RoleName[] = ["MEMBER", "ADMIN", "OWNER"]; -const CHANNELS: Channel[] = ["email", "sms"]; -const EVENT_TYPES = [ - { value: "macrostop", labelKey: "alerts.event.macrostop" }, - { value: "microstop", labelKey: "alerts.event.microstop" }, - { value: "slow-cycle", labelKey: "alerts.event.slow-cycle" }, - { value: "offline", labelKey: "alerts.event.offline" }, - { value: "error", labelKey: "alerts.event.error" }, -] as const; - -function normalizeContactDraft(contact: AlertContact): ContactDraft { - return { - name: contact.name, - roleScope: contact.roleScope, - email: contact.email ?? "", - phone: contact.phone ?? "", - eventTypes: Array.isArray(contact.eventTypes) ? contact.eventTypes : [], - isActive: contact.isActive, - }; -} - -export default function AlertsPage() { - const { t } = useI18n(); - const [policy, setPolicy] = useState(null); - const [policyDraft, setPolicyDraft] = useState(null); - const [contacts, setContacts] = useState([]); - const [contactEdits, setContactEdits] = useState>({}); - const [loading, setLoading] = useState(true); - const [role, setRole] = useState("MEMBER"); - const [savingPolicy, setSavingPolicy] = useState(false); - const [policyError, setPolicyError] = useState(null); - const [contactsError, setContactsError] = useState(null); - const [savingContactId, setSavingContactId] = useState(null); - const [deletingContactId, setDeletingContactId] = useState(null); - const [selectedEventType, setSelectedEventType] = useState(""); - - const [newContact, setNewContact] = useState({ - name: "", - roleScope: "CUSTOM", - email: "", - phone: "", - eventTypes: [], - isActive: true, - }); - const [creatingContact, setCreatingContact] = useState(false); - const [createError, setCreateError] = useState(null); - - useEffect(() => { - let alive = true; - - async function load() { - setLoading(true); - setPolicyError(null); - setContactsError(null); - try { - const [policyRes, contactsRes, meRes] = await Promise.all([ - fetch("/api/alerts/policy", { cache: "no-store" }), - fetch("/api/alerts/contacts", { cache: "no-store" }), - fetch("/api/me", { cache: "no-store" }), - ]); - const policyJson = await policyRes.json().catch(() => ({})); - const contactsJson = await contactsRes.json().catch(() => ({})); - const meJson = await meRes.json().catch(() => ({})); - - if (!alive) return; - - if (!policyRes.ok || !policyJson?.ok) { - setPolicyError(policyJson?.error || t("alerts.error.loadPolicy")); - } else { - setPolicy(policyJson.policy); - setPolicyDraft(policyJson.policy); - if (!selectedEventType && policyJson.policy?.rules?.length) { - setSelectedEventType(policyJson.policy.rules[0].eventType); - } - } - - if (!contactsRes.ok || !contactsJson?.ok) { - setContactsError(contactsJson?.error || t("alerts.error.loadContacts")); - } else { - setContacts(contactsJson.contacts ?? []); - const nextEdits: Record = {}; - for (const contact of contactsJson.contacts ?? []) { - nextEdits[contact.id] = normalizeContactDraft(contact); - } - setContactEdits(nextEdits); - } - - if (meRes.ok && meJson?.ok && meJson?.membership?.role) { - setRole(String(meJson.membership.role).toUpperCase() as RoleName); - } - } catch { - if (!alive) return; - setPolicyError(t("alerts.error.loadPolicy")); - setContactsError(t("alerts.error.loadContacts")); - } finally { - if (alive) setLoading(false); - } - } - - load(); - return () => { - alive = false; - }; - }, [t]); - - useEffect(() => { - if (!policyDraft?.rules?.length) return; - setSelectedEventType((prev) => { - if (prev && policyDraft.rules.some((rule) => rule.eventType === prev)) { - return prev; - } - return policyDraft.rules[0].eventType; - }); - }, [policyDraft]); - - function updatePolicyDefaults(role: RoleName, patch: Partial) { - setPolicyDraft((prev) => { - if (!prev) return prev; - return { - ...prev, - defaults: { - ...prev.defaults, - [role]: { - ...prev.defaults[role], - ...patch, - }, - }, - }; - }); - } - - function updateRule(eventType: string, patch: Partial) { - setPolicyDraft((prev) => { - if (!prev) return prev; - return { - ...prev, - rules: prev.rules.map((rule) => - rule.eventType === eventType ? { ...rule, ...patch } : rule - ), - }; - }); - } - - function updateRuleRole(eventType: string, role: RoleName, patch: Partial) { - setPolicyDraft((prev) => { - if (!prev) return prev; - return { - ...prev, - rules: prev.rules.map((rule) => { - if (rule.eventType !== eventType) return rule; - return { - ...rule, - roles: { - ...rule.roles, - [role]: { - ...rule.roles[role], - ...patch, - }, - }, - }; - }), - }; - }); - } - - function applyDefaultsToEvent(eventType: string) { - setPolicyDraft((prev) => { - if (!prev) return prev; - return { - ...prev, - rules: prev.rules.map((rule) => { - if (rule.eventType !== eventType) return rule; - return { - ...rule, - roles: { - MEMBER: { ...prev.defaults.MEMBER }, - ADMIN: { ...prev.defaults.ADMIN }, - OWNER: { ...prev.defaults.OWNER }, - }, - }; - }), - }; - }); - } - async function savePolicy() { - if (!policyDraft) return; - setSavingPolicy(true); - setPolicyError(null); - try { - const res = await fetch("/api/alerts/policy", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ policy: policyDraft }), - }); - const json = await res.json().catch(() => ({})); - if (!res.ok || !json?.ok) { - setPolicyError(json?.error || t("alerts.error.savePolicy")); - return; - } - setPolicy(policyDraft); - } catch { - setPolicyError(t("alerts.error.savePolicy")); - } finally { - setSavingPolicy(false); - } - } - - function updateContactDraft(id: string, patch: Partial) { - setContactEdits((prev) => ({ - ...prev, - [id]: { - ...(prev[id] ?? normalizeContactDraft(contacts.find((c) => c.id === id)!)), - ...patch, - }, - })); - } - - async function saveContact(id: string) { - const draft = contactEdits[id]; - if (!draft) return; - setSavingContactId(id); - try { - const eventTypes = draft.eventTypes.length ? draft.eventTypes : null; - const res = await fetch(`/api/alerts/contacts/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: draft.name, - roleScope: draft.roleScope, - email: draft.email || null, - phone: draft.phone || null, - eventTypes, - isActive: draft.isActive, - }), - }); - const json = await res.json().catch(() => ({})); - if (!res.ok || !json?.ok) { - setContactsError(json?.error || t("alerts.error.saveContacts")); - return; - } - const updated = json.contact as AlertContact; - setContacts((prev) => prev.map((c) => (c.id === id ? updated : c))); - setContactEdits((prev) => ({ ...prev, [id]: normalizeContactDraft(updated) })); - } catch { - setContactsError(t("alerts.error.saveContacts")); - } finally { - setSavingContactId(null); - } - } - - async function deleteContact(id: string) { - setDeletingContactId(id); - try { - const res = await fetch(`/api/alerts/contacts/${id}`, { method: "DELETE" }); - const json = await res.json().catch(() => ({})); - if (!res.ok || !json?.ok) { - setContactsError(json?.error || t("alerts.error.deleteContact")); - return; - } - setContacts((prev) => prev.filter((c) => c.id !== id)); - setContactEdits((prev) => { - const next = { ...prev }; - delete next[id]; - return next; - }); - } catch { - setContactsError(t("alerts.error.deleteContact")); - } finally { - setDeletingContactId(null); - } - } - - async function createContact() { - setCreatingContact(true); - setCreateError(null); - try { - const eventTypes = newContact.eventTypes.length ? newContact.eventTypes : null; - const res = await fetch("/api/alerts/contacts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: newContact.name, - roleScope: newContact.roleScope, - email: newContact.email || null, - phone: newContact.phone || null, - eventTypes, - }), - }); - const json = await res.json().catch(() => ({})); - if (!res.ok || !json?.ok) { - setCreateError(json?.error || t("alerts.error.createContact")); - return; - } - const contact = json.contact as AlertContact; - setContacts((prev) => [contact, ...prev]); - setContactEdits((prev) => ({ ...prev, [contact.id]: normalizeContactDraft(contact) })); - setNewContact({ - name: "", - roleScope: "CUSTOM", - email: "", - phone: "", - eventTypes: [], - isActive: true, - }); - } catch { - setCreateError(t("alerts.error.createContact")); - } finally { - setCreatingContact(false); - } - } - - const policyDirty = useMemo( - () => JSON.stringify(policy) !== JSON.stringify(policyDraft), - [policy, policyDraft] - ); - const canEdit = role === "OWNER"; + const initialShifts = shiftRows.map((shift) => ({ + name: shift.name, + enabled: shift.enabled !== false, + })); return ( -
-
-

{t("alerts.title")}

-

{t("alerts.subtitle")}

-
- - {loading && ( -
{t("alerts.loading")}
- )} - - {!loading && policyError && ( -
- {policyError} -
- )} - - {!loading && policyDraft && ( -
-
-
-
{t("alerts.policy.title")}
-
{t("alerts.policy.subtitle")}
-
- -
- - {!canEdit && ( -
- {t("alerts.policy.readOnly")} -
- )} - -
-
{t("alerts.policy.defaults")}
-
{t("alerts.policy.defaultsHelp")}
-
- {ROLE_ORDER.map((role) => { - const rule = policyDraft.defaults[role]; - return ( -
-
{role}
- - -
{t("alerts.policy.channels")}
-
- {CHANNELS.map((channel) => ( - - ))} -
-
- ); - })} -
-
- -
-
-
-
-
{t("alerts.policy.eventSelectLabel")}
-
{t("alerts.policy.eventSelectHelper")}
-
-
- - -
-
-
- - {policyDraft.rules - .filter((rule) => rule.eventType === selectedEventType) - .map((rule) => ( -
-
-
- {t(`alerts.event.${rule.eventType}`)} -
- -
- -
- {ROLE_ORDER.map((role) => { - const roleRule = rule.roles[role]; - return ( -
-
{role}
- - -
{t("alerts.policy.channels")}
-
- {CHANNELS.map((channel) => ( - - ))} -
-
- ); - })} -
-
- ))} -
-
- )} - -
-
-
-
{t("alerts.contacts.title")}
-
{t("alerts.contacts.subtitle")}
-
-
- - {!canEdit && ( -
- {t("alerts.contacts.readOnly")} -
- )} - - {contactsError && ( -
- {contactsError} -
- )} - -
- - - - - -
- - {createError && ( -
- {createError} -
- )} - -
- -
- -
- {contacts.length === 0 && ( -
{t("alerts.contacts.empty")}
- )} - {contacts.map((contact) => { - const draft = contactEdits[contact.id] ?? normalizeContactDraft(contact); - const locked = !!contact.userId; - return ( -
-
- - - - - - -
-
- - - {locked && ( - {t("alerts.contacts.linkedUser")} - )} -
-
- ); - })} -
-
-
+ ); } diff --git a/app/(app)/financial/FinancialClient.tsx b/app/(app)/financial/FinancialClient.tsx new file mode 100644 index 0000000..6e88898 --- /dev/null +++ b/app/(app)/financial/FinancialClient.tsx @@ -0,0 +1,388 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import Link from "next/link"; +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { useI18n } from "@/lib/i18n/useI18n"; + +type MachineRow = { + id: string; + name: string; + location?: string | null; +}; + +type ImpactSummary = { + currency: string; + totals: { + total: number; + slowCycle: number; + microstop: number; + macrostop: number; + scrap: number; + }; + byDay: Array<{ + day: string; + total: number; + slowCycle: number; + microstop: number; + macrostop: number; + scrap: number; + }>; +}; + +type ImpactResponse = { + ok: boolean; + currencySummaries: ImpactSummary[]; +}; + +function formatMoney(value: number, currency: string, locale: string) { + if (!Number.isFinite(value)) return "--"; + try { + return new Intl.NumberFormat(locale, { + style: "currency", + currency, + maximumFractionDigits: 0, + }).format(value); + } catch { + return `${value.toFixed(0)} ${currency}`; + } +} + +export default function FinancialClient({ + initialRole = null, + initialMachines = [], + initialImpact = null, +}: { + initialRole?: string | null; + initialMachines?: MachineRow[]; + initialImpact?: ImpactResponse | null; +}) { + const { locale, t } = useI18n(); + const [role, setRole] = useState(initialRole); + const [machines, setMachines] = useState(() => initialMachines); + const [impact, setImpact] = useState(initialImpact); + const [range, setRange] = useState("7d"); + const [machineFilter, setMachineFilter] = useState(""); + const [locationFilter, setLocationFilter] = useState(""); + const [skuFilter, setSkuFilter] = useState(""); + const [currencyFilter, setCurrencyFilter] = useState(""); + const [loading, setLoading] = useState(() => initialMachines.length === 0); + const skipInitialImpactRef = useRef(true); + + const locations = useMemo(() => { + const seen = new Set(); + for (const m of machines) { + if (!m.location) continue; + seen.add(m.location); + } + return Array.from(seen).sort(); + }, [machines]); + + useEffect(() => { + if (initialRole != null) return; + let alive = true; + + async function loadMe() { + try { + const res = await fetch("/api/me", { cache: "no-store" }); + const data = await res.json().catch(() => ({})); + if (!alive) return; + setRole(data?.membership?.role ?? null); + } catch { + if (alive) setRole(null); + } + } + + loadMe(); + return () => { + alive = false; + }; + }, [initialRole]); + + useEffect(() => { + if (initialMachines.length) { + setLoading(false); + return; + } + let alive = true; + + async function loadMachines() { + try { + const res = await fetch("/api/machines", { cache: "no-store" }); + const json = await res.json().catch(() => ({})); + if (!alive) return; + setMachines(json.machines ?? []); + } catch { + if (!alive) return; + } finally { + if (alive) setLoading(false); + } + } + + loadMachines(); + return () => { + alive = false; + }; + }, [initialMachines]); + + useEffect(() => { + let alive = true; + const controller = new AbortController(); + + async function loadImpact() { + if (role == null) return; + if (role !== "OWNER") return; + + const isDefault = + range === "7d" && + !machineFilter && + !locationFilter && + !skuFilter && + !currencyFilter; + if (skipInitialImpactRef.current) { + skipInitialImpactRef.current = false; + if (initialImpact && isDefault) return; + } + + const params = new URLSearchParams(); + params.set("range", range); + if (machineFilter) params.set("machineId", machineFilter); + if (locationFilter) params.set("location", locationFilter); + if (skuFilter) params.set("sku", skuFilter); + if (currencyFilter) params.set("currency", currencyFilter); + + try { + const res = await fetch(`/api/financial/impact?${params.toString()}`, { + cache: "no-store", + signal: controller.signal, + }); + const json = await res.json().catch(() => ({})); + if (!alive) return; + setImpact(json); + } catch { + if (alive) setImpact(null); + } + } + + loadImpact(); + return () => { + alive = false; + controller.abort(); + }; + }, [currencyFilter, initialImpact, locationFilter, machineFilter, range, role, skuFilter]); + + const selectedSummary = impact?.currencySummaries?.[0] ?? null; + const chartData = selectedSummary?.byDay ?? []; + const exportQuery = useMemo(() => { + const params = new URLSearchParams(); + params.set("range", range); + if (machineFilter) params.set("machineId", machineFilter); + if (locationFilter) params.set("location", locationFilter); + if (skuFilter) params.set("sku", skuFilter); + if (currencyFilter) params.set("currency", currencyFilter); + return params.toString(); + }, [range, machineFilter, locationFilter, skuFilter, currencyFilter]); + + const htmlHref = `/api/financial/export/pdf?${exportQuery}`; + const csvHref = `/api/financial/export/excel?${exportQuery}`; + + if (role && role !== "OWNER") { + return ( +
+
+ {t("financial.ownerOnly")} +
+
+ ); + } + + return ( +
+
+
+

{t("financial.title")}

+

{t("financial.subtitle")}

+
+ +
+ +
+ {t("financial.costsMoved")}{" "} + + {t("financial.costsMovedLink")} + + . +
+ +
+ {(impact?.currencySummaries ?? []).slice(0, 4).map((summary) => ( +
+
{t("financial.totalLoss")}
+
+ {formatMoney(summary.totals.total, summary.currency, locale)} +
+
+ {t("financial.currencyLabel", { currency: summary.currency })} +
+
+ ))} + {!impact?.currencySummaries?.length && ( +
+ {t("financial.noImpact")} +
+ )} +
+ +
+
+
+
+

{t("financial.chart.title")}

+

{t("financial.chart.subtitle")}

+
+
+ {["24h", "7d", "30d"].map((value) => ( + + ))} +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+

{t("financial.filters.title")}

+
+
+ + +
+
+ + +
+
+ + setSkuFilter(event.target.value)} + placeholder={t("financial.filters.skuPlaceholder")} + /> +
+
+ + setCurrencyFilter(event.target.value.toUpperCase())} + placeholder={t("financial.filters.currencyPlaceholder")} + /> +
+
+
+
+ + {loading &&
{t("financial.loadingMachines")}
} +
+ ); +} diff --git a/app/(app)/financial/page.tsx b/app/(app)/financial/page.tsx new file mode 100644 index 0000000..9b94612 --- /dev/null +++ b/app/(app)/financial/page.tsx @@ -0,0 +1,45 @@ +import { redirect } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import { requireSession } from "@/lib/auth/requireSession"; +import { computeFinancialImpact } from "@/lib/financial/impact"; +import FinancialClient from "./FinancialClient"; + +const RANGE_MS = 7 * 24 * 60 * 60 * 1000; + +export default async function FinancialPage() { + const session = await requireSession(); + if (!session) redirect("/login?next=/financial"); + + const membership = await prisma.orgUser.findUnique({ + where: { orgId_userId: { orgId: session.orgId, userId: session.userId } }, + select: { role: true }, + }); + + const role = membership?.role ?? null; + if (role !== "OWNER") { + return ; + } + + const machines = await prisma.machine.findMany({ + where: { orgId: session.orgId }, + orderBy: { createdAt: "desc" }, + select: { id: true, name: true, location: true }, + }); + + const end = new Date(); + const start = new Date(end.getTime() - RANGE_MS); + const impact = await computeFinancialImpact({ + orgId: session.orgId, + start, + end, + includeEvents: false, + }); + + return ( + + ); +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 70aa185..010dade 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -6,7 +6,10 @@ import { prisma } from "@/lib/prisma"; const COOKIE_NAME = "mis_session"; export default async function AppLayout({ children }: { children: React.ReactNode }) { - const sessionId = (await cookies()).get(COOKIE_NAME)?.value; + const cookieJar = await cookies(); + const sessionId = cookieJar.get(COOKIE_NAME)?.value; + const themeCookie = cookieJar.get("mis_theme")?.value; + const initialTheme = themeCookie === "light" ? "light" : "dark"; if (!sessionId) redirect("/login?next=/machines"); @@ -24,5 +27,5 @@ export default async function AppLayout({ children }: { children: React.ReactNod redirect("/login?next=/machines"); } - return {children}; + return {children}; } diff --git a/app/(app)/machines/MachinesClient.tsx b/app/(app)/machines/MachinesClient.tsx new file mode 100644 index 0000000..1a27552 --- /dev/null +++ b/app/(app)/machines/MachinesClient.tsx @@ -0,0 +1,325 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { useI18n } from "@/lib/i18n/useI18n"; + +type MachineRow = { + id: string; + name: string; + code?: string | null; + location?: string | null; + latestHeartbeat: null | { + ts: string; + tsServer?: string | null; + status: string; + message?: string | null; + ip?: string | null; + fwVersion?: string | null; + }; +}; + +function secondsAgo(ts: string | undefined, locale: string, fallback: string) { + if (!ts) return fallback; + const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000); + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + if (diff < 60) return rtf.format(-diff, "second"); + return rtf.format(-Math.floor(diff / 60), "minute"); +} + +function isOffline(ts?: string) { + if (!ts) return true; + return Date.now() - new Date(ts).getTime() > 30000; // 30s threshold +} + +function normalizeStatus(status?: string) { + const s = (status ?? "").toUpperCase(); + if (s === "ONLINE") return "RUN"; + return s; +} + +function badgeClass(status?: string, offline?: boolean) { + if (offline) return "bg-white/10 text-zinc-300"; + const s = (status ?? "").toUpperCase(); + if (s === "RUN") return "bg-emerald-500/15 text-emerald-300"; + if (s === "IDLE") return "bg-yellow-500/15 text-yellow-300"; + if (s === "STOP" || s === "DOWN") return "bg-red-500/15 text-red-300"; + return "bg-white/10 text-white"; +} + +export default function MachinesClient({ initialMachines = [] }: { initialMachines?: MachineRow[] }) { + const { t, locale } = useI18n(); + const [machines, setMachines] = useState(() => initialMachines); + const [loading, setLoading] = useState(false); + const [showCreate, setShowCreate] = useState(false); + const [createName, setCreateName] = useState(""); + const [createCode, setCreateCode] = useState(""); + const [createLocation, setCreateLocation] = useState(""); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + const [createdMachine, setCreatedMachine] = useState<{ + id: string; + name: string; + pairingCode: string; + pairingExpiresAt: string; + } | null>(null); + const [copyStatus, setCopyStatus] = useState(null); + + useEffect(() => { + let alive = true; + + async function load() { + try { + const res = await fetch("/api/machines", { cache: "no-store" }); + const json = await res.json(); + if (alive) { + setMachines(json.machines ?? []); + setLoading(false); + } + } catch { + if (alive) setLoading(false); + } + } + + load(); + const t = setInterval(load, 15000); + + return () => { + alive = false; + clearInterval(t); + }; + }, []); + + async function createMachine() { + if (!createName.trim()) { + setCreateError(t("machines.create.error.nameRequired")); + return; + } + + setCreating(true); + setCreateError(null); + + try { + const res = await fetch("/api/machines", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: createName, + code: createCode, + location: createLocation, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.ok) { + throw new Error(data.error || t("machines.create.error.failed")); + } + + const nextMachine = { + ...data.machine, + latestHeartbeat: null, + }; + setMachines((prev) => [nextMachine, ...prev]); + setCreatedMachine({ + id: data.machine.id, + name: data.machine.name, + pairingCode: data.machine.pairingCode, + pairingExpiresAt: data.machine.pairingCodeExpiresAt, + }); + setCreateName(""); + setCreateCode(""); + setCreateLocation(""); + setShowCreate(false); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : null; + setCreateError(message || t("machines.create.error.failed")); + } finally { + setCreating(false); + } + } + + async function copyText(text: string) { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + setCopyStatus(t("machines.pairing.copied")); + } else { + setCopyStatus(t("machines.pairing.copyUnsupported")); + } + } catch { + setCopyStatus(t("machines.pairing.copyFailed")); + } + setTimeout(() => setCopyStatus(null), 2000); + } + + const showCreateCard = showCreate || (!loading && machines.length === 0); + + return ( +
+
+
+

{t("machines.title")}

+

{t("machines.subtitle")}

+
+ +
+ + + {t("machines.backOverview")} + +
+
+ + {showCreateCard && ( +
+
+
+
{t("machines.addCardTitle")}
+
{t("machines.addCardSubtitle")}
+
+
+ +
+ + + +
+ +
+ + {createError &&
{createError}
} +
+
+ )} + + {createdMachine && ( +
+
{t("machines.pairing.title")}
+
+ {t("machines.pairing.machine")} {createdMachine.name} +
+
+
{t("machines.pairing.codeLabel")}
+
{createdMachine.pairingCode}
+
+ {t("machines.pairing.expires")}{" "} + {createdMachine.pairingExpiresAt + ? new Date(createdMachine.pairingExpiresAt).toLocaleString(locale) + : t("machines.pairing.soon")} +
+
+
+ {t("machines.pairing.instructions")} +
+
+ + {copyStatus &&
{copyStatus}
} +
+
+ )} + + {loading &&
{t("machines.loading")}
} + + {!loading && machines.length === 0 && ( +
{t("machines.empty")}
+ )} + +
+ {(!loading ? machines : []).map((m) => { + const hb = m.latestHeartbeat; + const hbTs = hb?.tsServer ?? hb?.ts; + const offline = isOffline(hbTs); + const normalizedStatus = normalizeStatus(hb?.status); + const statusLabel = offline ? t("machines.status.offline") : (normalizedStatus || t("machines.status.unknown")); + const lastSeen = secondsAgo(hbTs, locale, t("common.never")); + + return ( + +
+
+
{m.name}
+
+ {m.code ? m.code : t("common.na")} - {t("machines.lastSeen", { time: lastSeen })} +
+
+ + + {statusLabel} + +
+ +
{t("machines.status")}
+
+ {offline ? ( + <> +
+ + ); + })} +
+
+ ); +} diff --git a/app/(app)/machines/[machineId]/MachineDetailClient.tsx b/app/(app)/machines/[machineId]/MachineDetailClient.tsx index c4afd8a..7777f0c 100644 --- a/app/(app)/machines/[machineId]/MachineDetailClient.tsx +++ b/app/(app)/machines/[machineId]/MachineDetailClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { @@ -73,6 +73,7 @@ type MachineDetail = { name: string; code?: string | null; location?: string | null; + effectiveCycleTime?: number | null; latestHeartbeat: Heartbeat | null; latestKpi: Kpi | null; }; @@ -111,11 +112,54 @@ type WorkOrderUpload = { cycleTime?: number; }; +type WorkOrderRow = Record; +type TooltipPayload = { payload?: T; name?: string; value?: number | string }; +type SimpleTooltipProps = { + active?: boolean; + payload?: Array>; + label?: string | number; +}; +type ActiveRingProps = { cx?: number; cy?: number; fill?: string }; +type ScatterPointProps = { cx?: number; cy?: number; payload?: { bucket?: string } }; + const TOL = 0.10; const DEFAULT_MICRO_MULT = 1.5; const DEFAULT_MACRO_MULT = 5; const NORMAL_TOL_SEC = 0.1; +const BUCKET = { + normal: { + labelKey: "machine.detail.bucket.normal", + dot: "#12D18E", + glow: "rgba(18,209,142,.35)", + chip: "bg-emerald-500/15 text-emerald-300 border-emerald-500/20", + }, + slow: { + labelKey: "machine.detail.bucket.slow", + dot: "#F7B500", + glow: "rgba(247,181,0,.35)", + chip: "bg-yellow-500/15 text-yellow-300 border-yellow-500/20", + }, + microstop: { + labelKey: "machine.detail.bucket.microstop", + dot: "#FF7A00", + glow: "rgba(255,122,0,.35)", + chip: "bg-orange-500/15 text-orange-300 border-orange-500/20", + }, + macrostop: { + labelKey: "machine.detail.bucket.macrostop", + dot: "#FF3B5C", + glow: "rgba(255,59,92,.35)", + chip: "bg-rose-500/15 text-rose-300 border-rose-500/20", + }, + unknown: { + labelKey: "machine.detail.bucket.unknown", + dot: "#A1A1AA", + glow: "rgba(161,161,170,.25)", + chip: "bg-white/10 text-zinc-200 border-white/10", + }, +} as const; + function resolveMultipliers(thresholds?: Thresholds | null) { const micro = Number(thresholds?.stoppageMultiplier ?? DEFAULT_MICRO_MULT); @@ -213,14 +257,14 @@ function parseCsvText(text: string) { }); } -function pickRowValue(row: Record, keys: Set) { +function pickRowValue(row: WorkOrderRow, keys: Set) { for (const [key, value] of Object.entries(row)) { if (keys.has(normalizeKey(key))) return value; } return undefined; } -function rowsToWorkOrders(rows: Array>): WorkOrderUpload[] { +function rowsToWorkOrders(rows: WorkOrderRow[]): WorkOrderUpload[] { const seen = new Set(); const out: WorkOrderUpload[] = []; @@ -261,42 +305,8 @@ export default function MachineDetailClient() { const [open, setOpen] = useState(null); const fileInputRef = useRef(null); const [uploadState, setUploadState] = useState({ status: "idle" }); - const [nowMs, setNowMs] = useState(() => Date.now()); - const BUCKET = { - normal: { - labelKey: "machine.detail.bucket.normal", - dot: "#12D18E", - glow: "rgba(18,209,142,.35)", - chip: "bg-emerald-500/15 text-emerald-300 border-emerald-500/20", - }, - slow: { - labelKey: "machine.detail.bucket.slow", - dot: "#F7B500", - glow: "rgba(247,181,0,.35)", - chip: "bg-yellow-500/15 text-yellow-300 border-yellow-500/20", - }, - microstop: { - labelKey: "machine.detail.bucket.microstop", - dot: "#FF7A00", - glow: "rgba(255,122,0,.35)", - chip: "bg-orange-500/15 text-orange-300 border-orange-500/20", - }, - macrostop: { - labelKey: "machine.detail.bucket.macrostop", - dot: "#FF3B5C", - glow: "rgba(255,59,92,.35)", - chip: "bg-rose-500/15 text-rose-300 border-rose-500/20", - }, - unknown: { - labelKey: "machine.detail.bucket.unknown", - dot: "#A1A1AA", - glow: "rgba(161,161,170,.25)", - chip: "bg-white/10 text-zinc-200 border-white/10", - }, - } as const; - useEffect(() => { if (!machineId) return; @@ -305,9 +315,16 @@ export default function MachineDetailClient() { async function load() { try { const res = await fetch(`/api/machines/${machineId}?windowSec=3600&events=critical`, { - cache: "no-store", + cache: "no-cache", credentials: "include", }); + + if (res.status === 304) { + if (!alive) return; + setLoading(false); + return; + } + const json = await res.json().catch(() => ({})); if (!alive) return; @@ -341,31 +358,30 @@ export default function MachineDetailClient() { }; }, [machineId, t]); - useEffect(() => { - const timer = setInterval(() => setNowMs(Date.now()), 1000); - return () => clearInterval(timer); - }, []); - useEffect(() => { if (open !== "events" || !machineId) return; let alive = true; setDetectedEventsLoading(true); - fetch(`/api/machines/${machineId}?events=all&eventsOnly=1&eventsWindowSec=21600`, { - cache: "no-store", - credentials: "include", - }) - .then((res) => res.json()) - .then((json) => { + async function loadDetected() { + try { + const res = await fetch(`/api/machines/${machineId}?events=all&eventsOnly=1&eventsWindowSec=21600`, { + cache: "no-cache", + credentials: "include", + }); + if (res.status === 304) return; + const json = await res.json().catch(() => ({})); if (!alive) return; setDetectedEvents(json.events ?? []); setEventsCountAll(typeof json.eventsCountAll === "number" ? json.eventsCountAll : eventsCountAll); - }) - .catch(() => {}) - .finally(() => { + } catch { + } finally { if (alive) setDetectedEventsLoading(false); - }); + } + } + + loadDetected(); return () => { alive = false; @@ -385,15 +401,15 @@ export default function MachineDetailClient() { const workbook = xlsx.read(buffer, { type: "array" }); const sheet = workbook.Sheets[workbook.SheetNames[0]]; if (!sheet) return []; - const rows = xlsx.utils.sheet_to_json(sheet, { defval: "" }); - return rowsToWorkOrders(rows as Array>); + const rows = xlsx.utils.sheet_to_json(sheet, { defval: "" }); + return rowsToWorkOrders(rows); } return null; } - async function handleWorkOrderUpload(event: any) { - const file = event?.target?.files?.[0] as File | undefined; + async function handleWorkOrderUpload(event: ChangeEvent) { + const file = event.target.files?.[0]; if (!file) return; if (!machineId) { @@ -477,19 +493,6 @@ export default function MachineDetailClient() { return `${v}`; } - function formatDurationShort(totalSec?: number | null) { - if (totalSec === null || totalSec === undefined || Number.isNaN(totalSec)) { - return t("common.na"); - } - const sec = Math.max(0, Math.floor(totalSec)); - const h = Math.floor(sec / 3600); - const m = Math.floor((sec % 3600) / 60); - const s = sec % 60; - if (h > 0) return `${h}h ${m}m`; - if (m > 0) return `${m}m ${s}s`; - return `${s}s`; - } - function timeAgo(ts?: string) { if (!ts) return t("common.never"); @@ -554,15 +557,14 @@ export default function MachineDetailClient() { const label = t(key); return label === key ? normalizedStatus : label; })(); - const cycleTarget = (machine as any)?.effectiveCycleTime ?? kpi?.cycleTime ?? null; + const cycleTarget = machine?.effectiveCycleTime ?? kpi?.cycleTime ?? null; const machineCode = machine?.code ?? t("common.na"); const machineLocation = machine?.location ?? t("common.na"); const lastSeenLabel = t("machine.detail.lastSeen", { time: hbTs ? timeAgo(hbTs) : t("common.never"), }); - const ActiveRing = (props: any) => { - const { cx, cy, fill } = props; + const ActiveRing = ({ cx, cy, fill }: ActiveRingProps) => { if (cx == null || cy == null) return null; return ( @@ -609,12 +611,84 @@ export default function MachineDetailClient() { } function MachineActivityTimeline({ - segments, - windowSec, + cycles, + cycleTarget, + thresholds, + activeStoppage, }: { - segments: TimelineSeg[]; - windowSec: number; + cycles: CycleRow[]; + cycleTarget: number | null; + thresholds: Thresholds | null; + activeStoppage: ActiveStoppage | null; }) { + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + const timer = setInterval(() => setNowMs(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + + 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, + }); + } + } + + segs.sort((a, b) => a.start - b.start); + + return { windowSec, segments: segs, start, end }; + }, [activeStoppage, cycles, cycleTarget, nowMs, thresholds]); + + const { segments, windowSec } = timeline; + return (
@@ -647,7 +721,7 @@ export default function MachineDetailClient() {
) : ( segments.map((seg, idx) => { - const wPct = Math.max(0.25, (seg.durationSec / windowSec) * 100); + const wPct = Math.max(0, (seg.durationSec / windowSec) * 100); const meta = BUCKET[seg.state]; const glow = seg.state === "microstop" || seg.state === "macrostop" @@ -718,11 +792,16 @@ export default function MachineDetailClient() { ); } - function CycleTooltip({ active, payload, label }: any) { + function CycleTooltip({ + active, + payload, + label, + }: SimpleTooltipProps<{ actual?: number; ideal?: number; deltaPct?: number }>) { if (!active || !payload?.length) return null; const p = payload[0]?.payload; if (!p) return null; + const safeLabel = label ?? ""; const ideal = p.ideal ?? null; const actual = p.actual ?? null; @@ -731,7 +810,7 @@ export default function MachineDetailClient() { return (
- {t("machine.detail.tooltip.cycle", { label })} + {t("machine.detail.tooltip.cycle", { label: safeLabel })}
@@ -756,7 +835,6 @@ export default function MachineDetailClient() { const cycleDerived = useMemo(() => { const rows = cycles ?? []; - const { micro, macro } = resolveMultipliers(thresholds); const mapped: CycleDerivedRow[] = rows.map((cycle) => { const ideal = cycle.ideal ?? null; @@ -833,62 +911,15 @@ export default function MachineDetailClient() { const total = rows.reduce((sum, row) => sum + row.seconds, 0); return { rows, total }; - }, [BUCKET, cycleDerived.mapped, t]); - - const timeline = useMemo(() => { - const rows = cycles ?? []; - if (rows.length < 1) { - return { - windowSec: 10800, - segments: [] as TimelineSeg[], - start: null as number | null, - end: null as number | null, - }; - } - - const windowSec = 3600; - const end = rows[rows.length - 1].t; - const start = end - windowSec * 1000; - - 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, - }); - } - - - - return { windowSec, segments: segs, start, end }; - }, [cycles, cycleTarget, thresholds]); + }, [cycleDerived.mapped, t]); const cycleTargetLabel = cycleTarget ? `${cycleTarget}s` : t("common.na"); const workOrderLabel = kpi?.workOrderId ?? t("common.na"); const skuLabel = kpi?.sku ?? t("common.na"); return ( -
-
+
+

@@ -903,8 +934,8 @@ export default function MachineDetailClient() {

-
-
+
+
fileInputRef.current?.click()} disabled={isUploading} - className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-2 text-sm text-emerald-100 transition hover:bg-emerald-500/20 disabled:cursor-not-allowed disabled:opacity-60" + className="w-full rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-2 text-sm text-emerald-100 transition hover:bg-emerald-500/20 disabled:cursor-not-allowed disabled:opacity-60 sm:w-auto" > {uploadButtonLabel} {t("machine.detail.back")}
-
+
{t("machine.detail.workOrders.uploadHint")}
{uploadState.status !== "idle" && uploadState.message && ( @@ -975,7 +1006,12 @@ export default function MachineDetailClient() {
- +
@@ -1197,9 +1233,9 @@ export default function MachineDetailClient() { dataKey="actual" isAnimationActive={false} activeShape={} - shape={(props: any) => { - const { cx, cy, payload } = props; - const meta = BUCKET[payload.bucket as keyof typeof BUCKET] ?? BUCKET.unknown; + shape={({ cx, cy, payload }: ScatterPointProps) => { + const meta = + BUCKET[(payload?.bucket as keyof typeof BUCKET) ?? "unknown"] ?? BUCKET.unknown; return ( [`${Number(val).toFixed(1)}s`, t("machine.detail.modal.extraTimeLabel")]} + formatter={(val: number | string | undefined) => [ + `${val == null ? 0 : Number(val).toFixed(1)}s`, + t("machine.detail.modal.extraTimeLabel"), + ]} /> {impactAgg.rows.map((row, idx) => { diff --git a/app/(app)/machines/page.tsx b/app/(app)/machines/page.tsx index 2b9f0e0..362b7a6 100644 --- a/app/(app)/machines/page.tsx +++ b/app/(app)/machines/page.tsx @@ -1,324 +1,45 @@ -"use client"; +import { redirect } from "next/navigation"; +import { prisma } from "@/lib/prisma"; +import { requireSession } from "@/lib/auth/requireSession"; +import MachinesClient from "./MachinesClient"; -import Link from "next/link"; -import { useEffect, useState } from "react"; -import { useI18n } from "@/lib/i18n/useI18n"; - -type MachineRow = { - id: string; - name: string; - code?: string | null; - location?: string | null; - latestHeartbeat: null | { - ts: string; - tsServer?: string | null; - status: string; - message?: string | null; - ip?: string | null; - fwVersion?: string | null; - }; -}; - -function secondsAgo(ts: string | undefined, locale: string, fallback: string) { - if (!ts) return fallback; - const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000); - const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); - if (diff < 60) return rtf.format(-diff, "second"); - return rtf.format(-Math.floor(diff / 60), "minute"); +function toIso(value?: Date | null) { + return value ? value.toISOString() : null; } -function isOffline(ts?: string) { - if (!ts) return true; - return Date.now() - new Date(ts).getTime() > 30000; // 30s threshold -} +export default async function MachinesPage() { + const session = await requireSession(); + if (!session) redirect("/login?next=/machines"); -function normalizeStatus(status?: string) { - const s = (status ?? "").toUpperCase(); - if (s === "ONLINE") return "RUN"; - return s; -} + const machines = await prisma.machine.findMany({ + where: { orgId: session.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 }, + }, + }, + }); -function badgeClass(status?: string, offline?: boolean) { - if (offline) return "bg-white/10 text-zinc-300"; - const s = (status ?? "").toUpperCase(); - if (s === "RUN") return "bg-emerald-500/15 text-emerald-300"; - if (s === "IDLE") return "bg-yellow-500/15 text-yellow-300"; - if (s === "STOP" || s === "DOWN") return "bg-red-500/15 text-red-300"; - return "bg-white/10 text-white"; -} - -export default function MachinesPage() { - const { t, locale } = useI18n(); - const [machines, setMachines] = useState([]); - const [loading, setLoading] = useState(true); - const [showCreate, setShowCreate] = useState(false); - const [createName, setCreateName] = useState(""); - const [createCode, setCreateCode] = useState(""); - const [createLocation, setCreateLocation] = useState(""); - const [creating, setCreating] = useState(false); - const [createError, setCreateError] = useState(null); - const [createdMachine, setCreatedMachine] = useState<{ - id: string; - name: string; - pairingCode: string; - pairingExpiresAt: string; - } | null>(null); - const [copyStatus, setCopyStatus] = useState(null); - - useEffect(() => { - let alive = true; - - async function load() { - try { - const res = await fetch("/api/machines", { cache: "no-store" }); - const json = await res.json(); - if (alive) { - setMachines(json.machines ?? []); - setLoading(false); + const initialMachines = machines.map((machine) => ({ + ...machine, + latestHeartbeat: machine.heartbeats[0] + ? { + ...machine.heartbeats[0], + ts: toIso(machine.heartbeats[0].ts) ?? "", + tsServer: toIso(machine.heartbeats[0].tsServer), } - } catch { - if (alive) setLoading(false); - } - } + : null, + heartbeats: undefined, + })); - load(); - const t = setInterval(load, 15000); - - return () => { - alive = false; - clearInterval(t); - }; - }, []); - - async function createMachine() { - if (!createName.trim()) { - setCreateError(t("machines.create.error.nameRequired")); - return; - } - - setCreating(true); - setCreateError(null); - - try { - const res = await fetch("/api/machines", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: createName, - code: createCode, - location: createLocation, - }), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok || !data.ok) { - throw new Error(data.error || t("machines.create.error.failed")); - } - - const nextMachine = { - ...data.machine, - latestHeartbeat: null, - }; - setMachines((prev) => [nextMachine, ...prev]); - setCreatedMachine({ - id: data.machine.id, - name: data.machine.name, - pairingCode: data.machine.pairingCode, - pairingExpiresAt: data.machine.pairingCodeExpiresAt, - }); - setCreateName(""); - setCreateCode(""); - setCreateLocation(""); - setShowCreate(false); - } catch (err: any) { - setCreateError(err?.message || t("machines.create.error.failed")); - } finally { - setCreating(false); - } - } - - async function copyText(text: string) { - try { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - setCopyStatus(t("machines.pairing.copied")); - } else { - setCopyStatus(t("machines.pairing.copyUnsupported")); - } - } catch { - setCopyStatus(t("machines.pairing.copyFailed")); - } - setTimeout(() => setCopyStatus(null), 2000); - } - - const showCreateCard = showCreate || (!loading && machines.length === 0); - - return ( -
-
-
-

{t("machines.title")}

-

{t("machines.subtitle")}

-
- -
- - - {t("machines.backOverview")} - -
-
- - {showCreateCard && ( -
-
-
-
{t("machines.addCardTitle")}
-
{t("machines.addCardSubtitle")}
-
-
- -
- - - -
- -
- - {createError &&
{createError}
} -
-
- )} - - {createdMachine && ( -
-
{t("machines.pairing.title")}
-
- {t("machines.pairing.machine")} {createdMachine.name} -
-
-
{t("machines.pairing.codeLabel")}
-
{createdMachine.pairingCode}
-
- {t("machines.pairing.expires")}{" "} - {createdMachine.pairingExpiresAt - ? new Date(createdMachine.pairingExpiresAt).toLocaleString(locale) - : t("machines.pairing.soon")} -
-
-
- {t("machines.pairing.instructions")} -
-
- - {copyStatus &&
{copyStatus}
} -
-
- )} - - {loading &&
{t("machines.loading")}
} - - {!loading && machines.length === 0 && ( -
{t("machines.empty")}
- )} - -
- {(!loading ? machines : []).map((m) => { - const hb = m.latestHeartbeat; - const hbTs = hb?.tsServer ?? hb?.ts; - const offline = isOffline(hbTs); - const normalizedStatus = normalizeStatus(hb?.status); - const statusLabel = offline ? t("machines.status.offline") : (normalizedStatus || t("machines.status.unknown")); - const lastSeen = secondsAgo(hbTs, locale, t("common.never")); - - return ( - -
-
-
{m.name}
-
- {m.code ? m.code : t("common.na")} - {t("machines.lastSeen", { time: lastSeen })} -
-
- - - {statusLabel} - -
- -
{t("machines.status")}
-
- {offline ? ( - <> -
- - ); - })} -
-
- ); + return ; } diff --git a/app/(app)/overview/OverviewClient.tsx b/app/(app)/overview/OverviewClient.tsx new file mode 100644 index 0000000..eaf545b --- /dev/null +++ b/app/(app)/overview/OverviewClient.tsx @@ -0,0 +1,465 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; +import { useI18n } from "@/lib/i18n/useI18n"; + +type Heartbeat = { + ts: string; + tsServer?: string | null; + status: string; + message?: string | null; + ip?: string | null; + fwVersion?: string | null; +}; + +type Kpi = { + ts: string; + 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; +}; + +type MachineRow = { + id: string; + name: string; + code?: string | null; + location?: string | null; + latestHeartbeat: Heartbeat | null; + latestKpi?: Kpi | null; +}; + +type EventRow = { + id: string; + ts: string; + topic?: string; + eventType: string; + severity: string; + title: string; + description?: string | null; + requiresAck: boolean; + machineId?: string; + machineName?: string; + source: "ingested"; +}; + +const OFFLINE_MS = 30000; +const MAX_EVENT_MACHINES = 6; + +function secondsAgo(ts: string | undefined, locale: string, fallback: string) { + if (!ts) return fallback; + const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000); + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + if (diff < 60) return rtf.format(-diff, "second"); + if (diff < 3600) return rtf.format(-Math.floor(diff / 60), "minute"); + return rtf.format(-Math.floor(diff / 3600), "hour"); +} + +function isOffline(ts?: string) { + if (!ts) return true; + return Date.now() - new Date(ts).getTime() > OFFLINE_MS; +} + +function normalizeStatus(status?: string) { + const s = (status ?? "").toUpperCase(); + if (s === "ONLINE") return "RUN"; + return s; +} + +function heartbeatTime(hb?: Heartbeat | null) { + return hb?.tsServer ?? hb?.ts; +} + +function fmtPct(v?: number | null) { + if (v === null || v === undefined || Number.isNaN(v)) return "--"; + return `${v.toFixed(1)}%`; +} + +function fmtNum(v?: number | null) { + if (v === null || v === undefined || Number.isNaN(v)) return "--"; + return `${Math.round(v)}`; +} + +function severityClass(sev?: string) { + const s = (sev ?? "").toLowerCase(); + if (s === "critical") return "bg-red-500/15 text-red-300"; + if (s === "warning") return "bg-yellow-500/15 text-yellow-300"; + if (s === "info") return "bg-blue-500/15 text-blue-300"; + return "bg-white/10 text-zinc-200"; +} + +function sourceClass(src: EventRow["source"]) { + if (src === "ingested") return "bg-white/10 text-zinc-200"; + return "bg-white/10 text-zinc-200"; +} + +export default function OverviewClient({ + initialMachines = [], + initialEvents = [], +}: { + initialMachines?: MachineRow[]; + initialEvents?: EventRow[]; +}) { + const { t, locale } = useI18n(); + const [machines, setMachines] = useState(() => initialMachines); + const [events, setEvents] = useState(() => initialEvents); + const [loading, setLoading] = useState(false); + const [eventsLoading, setEventsLoading] = useState(false); + + useEffect(() => { + let alive = true; + + async function load() { + try { + setEventsLoading(true); + const res = await fetch(`/api/overview?events=critical&eventMachines=${MAX_EVENT_MACHINES}`, { + cache: "no-cache", + }); + if (res.status === 304) { + if (alive) setLoading(false); + return; + } + const json = await res.json().catch(() => ({})); + if (!alive) return; + setMachines(json.machines ?? []); + setEvents(json.events ?? []); + setLoading(false); + } catch { + if (!alive) return; + setMachines([]); + setEvents([]); + setLoading(false); + } finally { + if (alive) setEventsLoading(false); + } + } + + load(); + const t = setInterval(load, 30000); + return () => { + alive = false; + clearInterval(t); + }; + }, []); + + const stats = useMemo(() => { + const total = machines.length; + let online = 0; + let running = 0; + let idle = 0; + let stopped = 0; + let oeeSum = 0; + let oeeCount = 0; + let availSum = 0; + let availCount = 0; + let perfSum = 0; + let perfCount = 0; + let qualSum = 0; + let qualCount = 0; + let goodSum = 0; + let scrapSum = 0; + let targetSum = 0; + + for (const m of machines) { + const hb = m.latestHeartbeat; + const offline = isOffline(heartbeatTime(hb)); + if (!offline) online += 1; + + const status = normalizeStatus(hb?.status); + if (!offline) { + if (status === "RUN") running += 1; + else if (status === "IDLE") idle += 1; + else if (status === "STOP" || status === "DOWN") stopped += 1; + } + + const k = m.latestKpi; + if (k?.oee != null) { + oeeSum += Number(k.oee); + oeeCount += 1; + } + if (k?.availability != null) { + availSum += Number(k.availability); + availCount += 1; + } + if (k?.performance != null) { + perfSum += Number(k.performance); + perfCount += 1; + } + if (k?.quality != null) { + qualSum += Number(k.quality); + qualCount += 1; + } + if (k?.good != null) goodSum += Number(k.good); + if (k?.scrap != null) scrapSum += Number(k.scrap); + if (k?.target != null) targetSum += Number(k.target); + } + + return { + total, + online, + offline: total - online, + running, + idle, + stopped, + oee: oeeCount ? oeeSum / oeeCount : null, + availability: availCount ? availSum / availCount : null, + performance: perfCount ? perfSum / perfCount : null, + quality: qualCount ? qualSum / qualCount : null, + goodSum, + scrapSum, + targetSum, + }; + }, [machines]); + + const attention = useMemo(() => { + const list = machines + .map((m) => { + const hb = m.latestHeartbeat; + const offline = isOffline(heartbeatTime(hb)); + const k = m.latestKpi; + const oee = k?.oee ?? null; + let score = 0; + if (offline) score += 100; + if (oee != null && oee < 75) score += 50; + if (oee != null && oee < 85) score += 25; + return { machine: m, offline, oee, score }; + }) + .filter((x) => x.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 6); + + return list; + }, [machines]); + + const formatEventType = (eventType?: string) => { + if (!eventType) return ""; + const key = `overview.event.${eventType}`; + const label = t(key); + return label === key ? eventType : label; + }; + + const formatSource = (source?: string) => { + if (!source) return ""; + const key = `overview.source.${source}`; + const label = t(key); + return label === key ? source : label; + }; + + const formatSeverity = (severity?: string) => { + if (!severity) return ""; + const key = `overview.severity.${severity}`; + const label = t(key); + return label === key ? severity.toUpperCase() : label; + }; + + return ( +
+
+
+

{t("overview.title")}

+

{t("overview.subtitle")}

+
+ + + {t("overview.viewMachines")} + +
+ + {loading &&
{t("overview.loading")}
} + +
+
+
{t("overview.fleetHealth")}
+
{stats.total}
+
{t("overview.machinesTotal")}
+
+ + {t("overview.online")} {stats.online} + + + {t("overview.offline")} {stats.offline} + + + {t("overview.run")} {stats.running} + + + {t("overview.idle")} {stats.idle} + + + {t("overview.stop")} {stats.stopped} + +
+
+ +
+
{t("overview.productionTotals")}
+
+
+
{t("overview.good")}
+
{fmtNum(stats.goodSum)}
+
+
+
{t("overview.scrap")}
+
{fmtNum(stats.scrapSum)}
+
+
+
{t("overview.target")}
+
{fmtNum(stats.targetSum)}
+
+
+
{t("overview.kpiSumNote")}
+
+ +
+
{t("overview.activityFeed")}
+
{events.length}
+
+ {eventsLoading ? t("overview.eventsRefreshing") : t("overview.eventsLast30")} +
+
+ {events.slice(0, 3).map((e) => ( +
+
+ {e.machineName ? `${e.machineName}: ` : ""} + {e.title} +
+
+ {secondsAgo(e.ts, locale, t("common.never"))} +
+
+ ))} + {events.length === 0 && !eventsLoading ? ( +
{t("overview.eventsNone")}
+ ) : null} +
+
+
+ +
+
+
{t("overview.oeeAvg")}
+
{fmtPct(stats.oee)}
+
+
+
{t("overview.availabilityAvg")}
+
{fmtPct(stats.availability)}
+
+
+
{t("overview.performanceAvg")}
+
{fmtPct(stats.performance)}
+
+
+
{t("overview.qualityAvg")}
+
{fmtPct(stats.quality)}
+
+
+ +
+
+
+
{t("overview.attentionList")}
+
+ {attention.length} {t("overview.shown")} +
+
+ {attention.length === 0 ? ( +
{t("overview.noUrgent")}
+ ) : ( +
+ {attention.map(({ machine, offline, oee }) => ( +
+
+
+
{machine.name}
+
+ {machine.code ?? ""} {machine.location ? `- ${machine.location}` : ""} +
+
+
+ {secondsAgo(heartbeatTime(machine.latestHeartbeat), locale, t("common.never"))} +
+
+
+ + {offline ? t("overview.status.offline") : t("overview.status.online")} + + {oee != null && ( + + OEE {fmtPct(oee)} + + )} +
+
+ ))} +
+ )} +
+ +
+
+
{t("overview.timeline")}
+
+ {events.length} {t("overview.items")} +
+
+ + {events.length === 0 && !eventsLoading ? ( +
{t("overview.noEvents")}
+ ) : ( +
+ {events.map((e) => ( +
+
+
+
+ + {formatSeverity(e.severity)} + + + {formatEventType(e.eventType)} + + + {formatSource(e.source)} + + {e.requiresAck ? ( + + {t("overview.ack")} + + ) : null} +
+ +
+ {e.machineName ? `${e.machineName}: ` : ""} + {e.title} +
+ {e.description ? ( +
{e.description}
+ ) : null} +
+
+ {secondsAgo(e.ts, locale, t("common.never"))} +
+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/app/(app)/overview/page.tsx b/app/(app)/overview/page.tsx index 9c1fbe5..6789ba6 100644 --- a/app/(app)/overview/page.tsx +++ b/app/(app)/overview/page.tsx @@ -1,522 +1,47 @@ -"use client"; +import { redirect } from "next/navigation"; +import { requireSession } from "@/lib/auth/requireSession"; +import { getOverviewData } from "@/lib/overview/getOverviewData"; +import OverviewClient from "./OverviewClient"; -import Link from "next/link"; -import { useEffect, useMemo, useState } from "react"; -import { useI18n } from "@/lib/i18n/useI18n"; - -type Heartbeat = { - ts: string; - tsServer?: string | null; - status: string; - message?: string | null; - ip?: string | null; - fwVersion?: string | null; -}; - -type Kpi = { - ts: string; - 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; -}; - -type MachineRow = { - id: string; - name: string; - code?: string | null; - location?: string | null; - latestHeartbeat: Heartbeat | null; - latestKpi?: Kpi | null; -}; - -type EventRow = { - id: string; - ts: string; - topic?: string; - eventType: string; - severity: string; - title: string; - description?: string | null; - requiresAck: boolean; - machineId?: string; - machineName?: string; - source: "ingested"; -}; - -const OFFLINE_MS = 30000; -const MAX_EVENT_MACHINES = 6; - -function secondsAgo(ts: string | undefined, locale: string, fallback: string) { - if (!ts) return fallback; - const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000); - const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); - if (diff < 60) return rtf.format(-diff, "second"); - if (diff < 3600) return rtf.format(-Math.floor(diff / 60), "minute"); - return rtf.format(-Math.floor(diff / 3600), "hour"); +function toIso(value?: Date | null) { + return value ? value.toISOString() : null; } -function isOffline(ts?: string) { - if (!ts) return true; - return Date.now() - new Date(ts).getTime() > OFFLINE_MS; -} +export default async function OverviewPage() { + const session = await requireSession(); + if (!session) redirect("/login?next=/overview"); -function normalizeStatus(status?: string) { - const s = (status ?? "").toUpperCase(); - if (s === "ONLINE") return "RUN"; - return s; -} - -function heartbeatTime(hb?: Heartbeat | null) { - return hb?.tsServer ?? hb?.ts; -} - -function fmtPct(v?: number | null) { - if (v === null || v === undefined || Number.isNaN(v)) return "--"; - return `${v.toFixed(1)}%`; -} - -function fmtNum(v?: number | null) { - if (v === null || v === undefined || Number.isNaN(v)) return "--"; - return `${Math.round(v)}`; -} - -function severityClass(sev?: string) { - const s = (sev ?? "").toLowerCase(); - if (s === "critical") return "bg-red-500/15 text-red-300"; - if (s === "warning") return "bg-yellow-500/15 text-yellow-300"; - if (s === "info") return "bg-blue-500/15 text-blue-300"; - return "bg-white/10 text-zinc-200"; -} - -function sourceClass(_src: EventRow["source"]) { - return "bg-white/10 text-zinc-200"; -} - -export default function OverviewPage() { - const { t, locale } = useI18n(); - const [machines, setMachines] = useState([]); - const [events, setEvents] = useState([]); - const [loading, setLoading] = useState(true); - const [eventsLoading, setEventsLoading] = useState(false); - - useEffect(() => { - let alive = true; - - async function load() { - try { - const res = await fetch("/api/machines", { cache: "no-store" }); - const json = await res.json(); - if (!alive) return; - setMachines(json.machines ?? []); - setLoading(false); - } catch { - if (!alive) return; - setLoading(false); - } - } - - load(); - const t = setInterval(load, 30000); - return () => { - alive = false; - clearInterval(t); - }; - }, []); - - useEffect(() => { - if (!machines.length) { - setEvents([]); - return; - } - - let alive = true; - const controller = new AbortController(); - - async function loadEvents() { - setEventsLoading(true); - - const sorted = [...machines].sort((a, b) => { - const at = heartbeatTime(a.latestHeartbeat); - const bt = heartbeatTime(b.latestHeartbeat); - const atMs = at ? new Date(at).getTime() : 0; - const btMs = bt ? new Date(bt).getTime() : 0; - return btMs - atMs; - }); - - const targets = sorted.slice(0, MAX_EVENT_MACHINES); - - try { - const results = await Promise.all( - targets.map(async (m) => { - const res = await fetch(`/api/machines/${m.id}?events=critical&eventsOnly=1`, { - cache: "no-store", - signal: controller.signal, - }); - const json = await res.json(); - return { machine: m, payload: json }; - }) - ); - - if (!alive) return; - - const combined: EventRow[] = []; - for (const { machine, payload } of results) { - const ingested = Array.isArray(payload?.events) ? payload.events : []; - for (const e of ingested) { - if (!e?.ts) continue; - combined.push({ - ...e, - machineId: machine.id, - machineName: machine.name, - source: "ingested", - }); - } + const { machines, events } = await getOverviewData({ + orgId: session.orgId, + eventsMode: "critical", + eventsWindowSec: 21600, + eventMachines: 6, + }); + const initialMachines = machines.map((machine) => ({ + ...machine, + createdAt: toIso(machine.createdAt), + updatedAt: toIso(machine.updatedAt), + latestHeartbeat: machine.latestHeartbeat + ? { + ...machine.latestHeartbeat, + ts: toIso(machine.latestHeartbeat.ts) ?? "", + tsServer: toIso(machine.latestHeartbeat.tsServer), } + : null, + latestKpi: machine.latestKpi + ? { + ...machine.latestKpi, + ts: toIso(machine.latestKpi.ts) ?? "", + } + : null, + })); - const seen = new Set(); - const deduped = combined.filter((e) => { - const key = `${e.machineId ?? ""}-${e.eventType}-${e.ts}-${e.title}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); + const initialEvents = events.map((event) => ({ + ...event, + ts: event.ts ? event.ts.toISOString() : "", + machineName: event.machineName ?? undefined, + })); - deduped.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime()); - setEvents(deduped.slice(0, 30)); - } catch { - if (!alive) return; - setEvents([]); - } finally { - if (alive) setEventsLoading(false); - } - } - - loadEvents(); - return () => { - alive = false; - controller.abort(); - }; - }, [machines]); - - const stats = useMemo(() => { - const total = machines.length; - let online = 0; - let running = 0; - let idle = 0; - let stopped = 0; - let oeeSum = 0; - let oeeCount = 0; - let availSum = 0; - let availCount = 0; - let perfSum = 0; - let perfCount = 0; - let qualSum = 0; - let qualCount = 0; - let goodSum = 0; - let scrapSum = 0; - let targetSum = 0; - - for (const m of machines) { - const hb = m.latestHeartbeat; - const offline = isOffline(heartbeatTime(hb)); - if (!offline) online += 1; - - const status = normalizeStatus(hb?.status); - if (!offline) { - if (status === "RUN") running += 1; - else if (status === "IDLE") idle += 1; - else if (status === "STOP" || status === "DOWN") stopped += 1; - } - - const k = m.latestKpi; - if (k?.oee != null) { - oeeSum += Number(k.oee); - oeeCount += 1; - } - if (k?.availability != null) { - availSum += Number(k.availability); - availCount += 1; - } - if (k?.performance != null) { - perfSum += Number(k.performance); - perfCount += 1; - } - if (k?.quality != null) { - qualSum += Number(k.quality); - qualCount += 1; - } - if (k?.good != null) goodSum += Number(k.good); - if (k?.scrap != null) scrapSum += Number(k.scrap); - if (k?.target != null) targetSum += Number(k.target); - } - - return { - total, - online, - offline: total - online, - running, - idle, - stopped, - oee: oeeCount ? oeeSum / oeeCount : null, - availability: availCount ? availSum / availCount : null, - performance: perfCount ? perfSum / perfCount : null, - quality: qualCount ? qualSum / qualCount : null, - goodSum, - scrapSum, - targetSum, - }; - }, [machines]); - - const attention = useMemo(() => { - const list = machines - .map((m) => { - const hb = m.latestHeartbeat; - const offline = isOffline(heartbeatTime(hb)); - const k = m.latestKpi; - const oee = k?.oee ?? null; - let score = 0; - if (offline) score += 100; - if (oee != null && oee < 75) score += 50; - if (oee != null && oee < 85) score += 25; - return { machine: m, offline, oee, score }; - }) - .filter((x) => x.score > 0) - .sort((a, b) => b.score - a.score) - .slice(0, 6); - - return list; - }, [machines]); - - const formatEventType = (eventType?: string) => { - if (!eventType) return ""; - const key = `overview.event.${eventType}`; - const label = t(key); - return label === key ? eventType : label; - }; - - const formatSource = (source?: string) => { - if (!source) return ""; - const key = `overview.source.${source}`; - const label = t(key); - return label === key ? source : label; - }; - - const formatSeverity = (severity?: string) => { - if (!severity) return ""; - const key = `overview.severity.${severity}`; - const label = t(key); - return label === key ? severity.toUpperCase() : label; - }; - - return ( -
-
-
-

{t("overview.title")}

-

{t("overview.subtitle")}

-
- - - {t("overview.viewMachines")} - -
- - {loading &&
{t("overview.loading")}
} - -
-
-
{t("overview.fleetHealth")}
-
{stats.total}
-
{t("overview.machinesTotal")}
-
- - {t("overview.online")} {stats.online} - - - {t("overview.offline")} {stats.offline} - - - {t("overview.run")} {stats.running} - - - {t("overview.idle")} {stats.idle} - - - {t("overview.stop")} {stats.stopped} - -
-
- -
-
{t("overview.productionTotals")}
-
-
-
{t("overview.good")}
-
{fmtNum(stats.goodSum)}
-
-
-
{t("overview.scrap")}
-
{fmtNum(stats.scrapSum)}
-
-
-
{t("overview.target")}
-
{fmtNum(stats.targetSum)}
-
-
-
{t("overview.kpiSumNote")}
-
- -
-
{t("overview.activityFeed")}
-
{events.length}
-
- {eventsLoading ? t("overview.eventsRefreshing") : t("overview.eventsLast30")} -
-
- {events.slice(0, 3).map((e) => ( -
-
- {e.machineName ? `${e.machineName}: ` : ""} - {e.title} -
-
- {secondsAgo(e.ts, locale, t("common.never"))} -
-
- ))} - {events.length === 0 && !eventsLoading ? ( -
{t("overview.eventsNone")}
- ) : null} -
-
-
- -
-
-
{t("overview.oeeAvg")}
-
{fmtPct(stats.oee)}
-
-
-
{t("overview.availabilityAvg")}
-
{fmtPct(stats.availability)}
-
-
-
{t("overview.performanceAvg")}
-
{fmtPct(stats.performance)}
-
-
-
{t("overview.qualityAvg")}
-
{fmtPct(stats.quality)}
-
-
- -
-
-
-
{t("overview.attentionList")}
-
- {attention.length} {t("overview.shown")} -
-
- {attention.length === 0 ? ( -
{t("overview.noUrgent")}
- ) : ( -
- {attention.map(({ machine, offline, oee }) => ( -
-
-
-
{machine.name}
-
- {machine.code ?? ""} {machine.location ? `- ${machine.location}` : ""} -
-
-
- {secondsAgo(heartbeatTime(machine.latestHeartbeat), locale, t("common.never"))} -
-
-
- - {offline ? t("overview.status.offline") : t("overview.status.online")} - - {oee != null && ( - - OEE {fmtPct(oee)} - - )} -
-
- ))} -
- )} -
- -
-
-
{t("overview.timeline")}
-
- {events.length} {t("overview.items")} -
-
- - {events.length === 0 && !eventsLoading ? ( -
{t("overview.noEvents")}
- ) : ( -
- {events.map((e) => ( -
-
-
-
- - {formatSeverity(e.severity)} - - - {formatEventType(e.eventType)} - - - {formatSource(e.source)} - - {e.requiresAck ? ( - - {t("overview.ack")} - - ) : null} -
- -
- {e.machineName ? `${e.machineName}: ` : ""} - {e.title} -
- {e.description ? ( -
{e.description}
- ) : null} -
-
- {secondsAgo(e.ts, locale, t("common.never"))} -
-
-
- ))} -
- )} -
-
-
- ); + return ; } diff --git a/app/(app)/reports/loading.tsx b/app/(app)/reports/loading.tsx new file mode 100644 index 0000000..da5dc21 --- /dev/null +++ b/app/(app)/reports/loading.tsx @@ -0,0 +1,21 @@ +export default function ReportsLoading() { + return ( +
+
+
+ {Array.from({ length: 4 }).map((_, idx) => ( +
+ ))} +
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, idx) => ( +
+ ))} +
+
+ ); +} diff --git a/app/(app)/reports/page.tsx b/app/(app)/reports/page.tsx index 2eb12f1..4fcd028 100644 --- a/app/(app)/reports/page.tsx +++ b/app/(app)/reports/page.tsx @@ -68,17 +68,19 @@ type ReportPayload = { type MachineOption = { id: string; name: string }; type FilterOptions = { workOrders: string[]; skus: string[] }; type Translator = (key: string, vars?: Record) => string; +type TooltipPayload = { payload?: T; name?: string; value?: number | string }; +type SimpleTooltipProps = { + active?: boolean; + payload?: Array>; + label?: string | number; +}; +type CycleHistogramRow = ReportPayload["distribution"]["cycleTime"][number]; function fmtPct(v?: number | null) { if (v === null || v === undefined || Number.isNaN(v)) return "--"; return `${v.toFixed(1)}%`; } -function fmtNum(v?: number | null) { - if (v === null || v === undefined || Number.isNaN(v)) return "--"; - return `${Math.round(v)}`; -} - function fmtDuration(sec?: number | null) { if (!sec) return "--"; const h = Math.floor(sec / 3600); @@ -104,7 +106,7 @@ function formatTickLabel(ts: string, range: RangeKey) { return `${month}-${day}`; } -function CycleTooltip({ active, payload, t }: any) { +function CycleTooltip({ active, payload, t }: SimpleTooltipProps & { t: Translator }) { if (!active || !payload?.length) return null; const p = payload[0]?.payload; if (!p) return null; @@ -141,7 +143,7 @@ function CycleTooltip({ active, payload, t }: any) { ); } -function DowntimeTooltip({ active, payload, t }: any) { +function DowntimeTooltip({ active, payload, t }: SimpleTooltipProps<{ name?: string; value?: number }> & { t: Translator }) { if (!active || !payload?.length) return null; const row = payload[0]?.payload ?? {}; const label = row.name ?? payload[0]?.name ?? ""; @@ -157,6 +159,15 @@ function DowntimeTooltip({ active, payload, t }: any) { ); } +function toMachineOption(value: unknown): MachineOption | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + const id = typeof record.id === "string" ? record.id : ""; + const name = typeof record.name === "string" ? record.name : ""; + if (!id || !name) return null; + return { id, name }; +} + function buildCsv(report: ReportPayload, t: Translator) { const rows = new Map>(); const addSeries = (series: ReportTrendPoint[], key: string) => { @@ -386,13 +397,29 @@ export default function ReportsPage() { const res = await fetch("/api/machines", { cache: "no-store" }); const json = await res.json(); if (!alive) return; - setMachines((json?.machines ?? []).map((m: any) => ({ id: m.id, name: m.name }))); + const rows: unknown[] = Array.isArray(json?.machines) ? json.machines : []; + const options: MachineOption[] = []; + rows.forEach((row) => { + const option = toMachineOption(row); + if (option) options.push(option); + }); + setMachines(options); } catch { if (!alive) return; setMachines([]); } } + loadMachines(); + return () => { + alive = false; + }; + }, []); + + useEffect(() => { + let alive = true; + const controller = new AbortController(); + async function load() { setLoading(true); setError(null); @@ -402,7 +429,10 @@ export default function ReportsPage() { if (workOrderId) params.set("workOrderId", workOrderId); if (sku) params.set("sku", sku); - const res = await fetch(`/api/reports?${params.toString()}`, { cache: "no-store" }); + const res = await fetch(`/api/reports?${params.toString()}`, { + cache: "no-store", + signal: controller.signal, + }); const json = await res.json(); if (!alive) return; if (!res.ok || json?.ok === false) { @@ -420,21 +450,25 @@ export default function ReportsPage() { } } - loadMachines(); load(); return () => { alive = false; + controller.abort(); }; - }, [range, machineId, workOrderId, sku]); + }, [range, machineId, workOrderId, sku, t]); useEffect(() => { let alive = true; + const controller = new AbortController(); async function loadFilters() { try { const params = new URLSearchParams({ range }); if (machineId) params.set("machineId", machineId); - const res = await fetch(`/api/reports/filters?${params.toString()}`, { cache: "no-store" }); + const res = await fetch(`/api/reports/filters?${params.toString()}`, { + cache: "no-store", + signal: controller.signal, + }); const json = await res.json(); if (!alive) return; if (!res.ok || json?.ok === false) { @@ -454,6 +488,7 @@ export default function ReportsPage() { loadFilters(); return () => { alive = false; + controller.abort(); }; }, [range, machineId]); @@ -536,23 +571,23 @@ export default function ReportsPage() { }; return ( -
-
+
+

{t("reports.title")}

{t("reports.subtitle")}

-
+
@@ -681,7 +716,10 @@ export default function ReportsPage() { const row = payload?.[0]?.payload; return row?.ts ? new Date(row.ts).toLocaleString(locale) : ""; }} - formatter={(val: any) => [`${Number(val).toFixed(1)}%`, "OEE"]} + formatter={(val: number | string | undefined) => [ + val == null ? "--" : `${Number(val).toFixed(1)}%`, + "OEE", + ]} /> @@ -761,7 +799,10 @@ export default function ReportsPage() { const row = payload?.[0]?.payload; return row?.ts ? new Date(row.ts).toLocaleString(locale) : ""; }} - formatter={(val: any) => [`${Number(val).toFixed(1)}%`, t("reports.scrapRate")]} + formatter={(val: number | string | undefined) => [ + val == null ? "--" : `${Number(val).toFixed(1)}%`, + t("reports.scrapRate"), + ]} /> diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 55637bd..4368189 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -1,6 +1,8 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { AlertsConfig } from "@/components/settings/AlertsConfig"; +import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig"; import { useI18n } from "@/lib/i18n/useI18n"; type Shift = { @@ -101,28 +103,95 @@ const DEFAULT_SETTINGS: SettingsPayload = { updatedBy: "", }; -async function readResponse(response: Response) { +const SETTINGS_TABS = [ + { id: "general", labelKey: "settings.tabs.general" }, + { id: "shifts", labelKey: "settings.tabs.shifts" }, + { id: "thresholds", labelKey: "settings.tabs.thresholds" }, + { id: "alerts", labelKey: "settings.tabs.alerts" }, + { id: "financial", labelKey: "settings.tabs.financial" }, + { id: "team", labelKey: "settings.tabs.team" }, +] as const; + +type ReadResponse = { data: T | null; text: string }; +type ApiEnvelope = { ok: boolean; error?: string; message?: string }; + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +function unwrapApiResponse(data: unknown): { ok: boolean; error: string | null; record: Record | null } { + const record = asRecord(data); + const ok = typeof record?.ok === "boolean" ? record.ok : false; + const error = + typeof record?.error === "string" + ? record.error + : typeof record?.message === "string" + ? record.message + : null; + return { ok, error, record }; +} + +function isOrgInfo(value: unknown): value is OrgInfo { + const record = asRecord(value); + return ( + !!record && + typeof record.id === "string" && + typeof record.name === "string" && + typeof record.slug === "string" + ); +} + +function isMemberRow(value: unknown): value is MemberRow { + const record = asRecord(value); + return ( + !!record && + typeof record.id === "string" && + typeof record.membershipId === "string" && + typeof record.email === "string" && + typeof record.role === "string" && + typeof record.isActive === "boolean" && + typeof record.joinedAt === "string" + ); +} + +function isInviteRow(value: unknown): value is InviteRow { + const record = asRecord(value); + return ( + !!record && + typeof record.id === "string" && + typeof record.email === "string" && + typeof record.role === "string" && + typeof record.token === "string" && + typeof record.createdAt === "string" && + typeof record.expiresAt === "string" + ); +} + +async function readResponse(response: Response): Promise> { const text = await response.text(); if (!text) { - return { data: null as any, text: "" }; + return { data: null, text: "" }; } try { - return { data: JSON.parse(text), text }; + return { data: JSON.parse(text) as T, text }; } catch { - return { data: null as any, text }; + return { data: null, text }; } } -function normalizeShift(raw: any, index: number, fallbackName: string): Shift { - const name = String(raw?.name || fallbackName); - const start = String(raw?.start || raw?.startTime || DEFAULT_SHIFT.start); - const end = String(raw?.end || raw?.endTime || DEFAULT_SHIFT.end); - const enabled = raw?.enabled !== false; +function normalizeShift(raw: unknown, fallbackName: string): Shift { + const record = asRecord(raw); + const name = String(record?.name ?? fallbackName); + const start = String(record?.start ?? record?.startTime ?? DEFAULT_SHIFT.start); + const end = String(record?.end ?? record?.endTime ?? DEFAULT_SHIFT.end); + const enabled = record?.enabled !== false; return { name, start, end, enabled }; } -function normalizeSettings(raw: any, fallbackName: (index: number) => string): SettingsPayload { - if (!raw || typeof raw !== "object") { +function normalizeSettings(raw: unknown, fallbackName: (index: number) => string): SettingsPayload { + const record = asRecord(raw); + if (!record) { return { ...DEFAULT_SETTINGS, shiftSchedule: { @@ -132,16 +201,19 @@ function normalizeSettings(raw: any, fallbackName: (index: number) => string): S }; } - const shiftSchedule = raw.shiftSchedule || {}; + const shiftSchedule = asRecord(record.shiftSchedule) ?? {}; const shiftsRaw = Array.isArray(shiftSchedule.shifts) ? shiftSchedule.shifts : []; const shifts = shiftsRaw.length - ? shiftsRaw.map((s: any, idx: number) => normalizeShift(s, idx, fallbackName(idx + 1))) + ? shiftsRaw.map((s, idx) => normalizeShift(s, fallbackName(idx + 1))) : [{ name: fallbackName(1), ...DEFAULT_SHIFT }]; + const thresholds = asRecord(record.thresholds) ?? {}; + const alerts = asRecord(record.alerts) ?? {}; + const defaults = asRecord(record.defaults) ?? {}; return { - orgId: String(raw.orgId || ""), - version: Number(raw.version || 0), - timezone: String(raw.timezone || DEFAULT_SETTINGS.timezone), + orgId: String(record.orgId ?? ""), + version: Number(record.version ?? 0), + timezone: String(record.timezone ?? DEFAULT_SETTINGS.timezone), shiftSchedule: { shifts, shiftChangeCompensationMin: Number( @@ -151,35 +223,38 @@ function normalizeSettings(raw: any, fallbackName: (index: number) => string): S }, thresholds: { stoppageMultiplier: Number( - raw.thresholds?.stoppageMultiplier ?? DEFAULT_SETTINGS.thresholds.stoppageMultiplier + thresholds.stoppageMultiplier ?? DEFAULT_SETTINGS.thresholds.stoppageMultiplier ), macroStoppageMultiplier: Number( - raw.thresholds?.macroStoppageMultiplier ?? DEFAULT_SETTINGS.thresholds.macroStoppageMultiplier + thresholds.macroStoppageMultiplier ?? DEFAULT_SETTINGS.thresholds.macroStoppageMultiplier ), oeeAlertThresholdPct: Number( - raw.thresholds?.oeeAlertThresholdPct ?? DEFAULT_SETTINGS.thresholds.oeeAlertThresholdPct + thresholds.oeeAlertThresholdPct ?? DEFAULT_SETTINGS.thresholds.oeeAlertThresholdPct ), performanceThresholdPct: Number( - raw.thresholds?.performanceThresholdPct ?? DEFAULT_SETTINGS.thresholds.performanceThresholdPct + thresholds.performanceThresholdPct ?? DEFAULT_SETTINGS.thresholds.performanceThresholdPct ), qualitySpikeDeltaPct: Number( - raw.thresholds?.qualitySpikeDeltaPct ?? DEFAULT_SETTINGS.thresholds.qualitySpikeDeltaPct + thresholds.qualitySpikeDeltaPct ?? DEFAULT_SETTINGS.thresholds.qualitySpikeDeltaPct ), }, alerts: { - oeeDropEnabled: raw.alerts?.oeeDropEnabled ?? DEFAULT_SETTINGS.alerts.oeeDropEnabled, + oeeDropEnabled: (alerts.oeeDropEnabled as boolean | undefined) ?? DEFAULT_SETTINGS.alerts.oeeDropEnabled, performanceDegradationEnabled: - raw.alerts?.performanceDegradationEnabled ?? DEFAULT_SETTINGS.alerts.performanceDegradationEnabled, - qualitySpikeEnabled: raw.alerts?.qualitySpikeEnabled ?? DEFAULT_SETTINGS.alerts.qualitySpikeEnabled, + (alerts.performanceDegradationEnabled as boolean | undefined) ?? + DEFAULT_SETTINGS.alerts.performanceDegradationEnabled, + qualitySpikeEnabled: + (alerts.qualitySpikeEnabled as boolean | undefined) ?? DEFAULT_SETTINGS.alerts.qualitySpikeEnabled, predictiveOeeDeclineEnabled: - raw.alerts?.predictiveOeeDeclineEnabled ?? DEFAULT_SETTINGS.alerts.predictiveOeeDeclineEnabled, + (alerts.predictiveOeeDeclineEnabled as boolean | undefined) ?? + DEFAULT_SETTINGS.alerts.predictiveOeeDeclineEnabled, }, defaults: { - moldTotal: Number(raw.defaults?.moldTotal ?? DEFAULT_SETTINGS.defaults.moldTotal), - moldActive: Number(raw.defaults?.moldActive ?? DEFAULT_SETTINGS.defaults.moldActive), + moldTotal: Number(defaults.moldTotal ?? DEFAULT_SETTINGS.defaults.moldTotal), + moldActive: Number(defaults.moldActive ?? DEFAULT_SETTINGS.defaults.moldActive), }, - updatedAt: raw.updatedAt ? String(raw.updatedAt) : "", - updatedBy: raw.updatedBy ? String(raw.updatedBy) : "", + updatedAt: record.updatedAt ? String(record.updatedAt) : "", + updatedBy: record.updatedBy ? String(record.updatedBy) : "", }; } @@ -235,6 +310,7 @@ export default function SettingsPage() { const [inviteRole, setInviteRole] = useState("MEMBER"); const [inviteStatus, setInviteStatus] = useState(null); const [inviteSubmitting, setInviteSubmitting] = useState(false); + const [activeTab, setActiveTab] = useState<(typeof SETTINGS_TABS)[number]["id"]>("general"); const defaultShiftName = useCallback( (index: number) => t("settings.shift.defaultName", { index }), [t] @@ -246,11 +322,12 @@ export default function SettingsPage() { try { const response = await fetch("/api/settings", { cache: "no-store" }); const { data, text } = await readResponse(response); - if (!response.ok || !data?.ok) { - const message = data?.error || data?.message || text || t("settings.failedLoad"); + const api = unwrapApiResponse(data); + if (!response.ok || !api.ok) { + const message = api.error || text || t("settings.failedLoad"); throw new Error(message); } - const next = normalizeSettings(data.settings, defaultShiftName); + const next = normalizeSettings(api.record?.settings, defaultShiftName); setDraft(next); } catch (err) { setError(err instanceof Error ? err.message : t("settings.failedLoad")); @@ -270,13 +347,16 @@ export default function SettingsPage() { try { const response = await fetch("/api/org/members", { cache: "no-store" }); const { data, text } = await readResponse(response); - if (!response.ok || !data?.ok) { - const message = data?.error || data?.message || text || t("settings.failedTeam"); + const api = unwrapApiResponse(data); + if (!response.ok || !api.ok) { + const message = api.error || text || t("settings.failedTeam"); throw new Error(message); } - setOrgInfo(data.org ?? null); - setMembers(Array.isArray(data.members) ? data.members : []); - setInvites(Array.isArray(data.invites) ? data.invites : []); + setOrgInfo(isOrgInfo(api.record?.org) ? api.record?.org : null); + const membersRaw = Array.isArray(api.record?.members) ? api.record?.members : []; + const invitesRaw = Array.isArray(api.record?.invites) ? api.record?.invites : []; + setMembers(membersRaw.filter(isMemberRow)); + setInvites(invitesRaw.filter(isInviteRow)); } catch (err) { setTeamError(err instanceof Error ? err.message : t("settings.failedTeam")); } finally { @@ -434,8 +514,9 @@ export default function SettingsPage() { try { const response = await fetch(`/api/org/invites/${inviteId}`, { method: "DELETE" }); const { data, text } = await readResponse(response); - if (!response.ok || !data?.ok) { - const message = data?.error || data?.message || text || t("settings.inviteStatus.failed"); + const api = unwrapApiResponse(data); + if (!response.ok || !api.ok) { + const message = api.error || text || t("settings.inviteStatus.failed"); throw new Error(message); } setInvites((prev) => prev.filter((invite) => invite.id !== inviteId)); @@ -458,15 +539,16 @@ export default function SettingsPage() { body: JSON.stringify({ email: inviteEmail, role: inviteRole }), }); const { data, text } = await readResponse(response); - if (!response.ok || !data?.ok) { - const message = data?.error || data?.message || text || t("settings.inviteStatus.createFailed"); + const api = unwrapApiResponse(data); + if (!response.ok || !api.ok) { + const message = api.error || text || t("settings.inviteStatus.createFailed"); throw new Error(message); } - const nextInvite = data.invite; - if (nextInvite) { + const nextInvite = api.record?.invite; + if (isInviteRow(nextInvite)) { setInvites((prev) => [nextInvite, ...prev.filter((invite) => invite.id !== nextInvite.id)]); const inviteUrl = buildInviteUrl(nextInvite.token); - if (data.emailSent === false) { + if (api.record?.emailSent === false) { setInviteStatus(t("settings.inviteStatus.emailFailed", { url: inviteUrl })); } else { setInviteStatus(t("settings.inviteStatus.sent")); @@ -501,14 +583,15 @@ export default function SettingsPage() { }), }); const { data, text } = await readResponse(response); - if (!response.ok || !data?.ok) { + const api = unwrapApiResponse(data); + if (!response.ok || !api.ok) { if (response.status === 409) { throw new Error(t("settings.conflict")); } - const message = data?.error || data?.message || text || t("settings.failedSave"); + const message = api.error || text || t("settings.failedSave"); throw new Error(message); } - const next = normalizeSettings(data.settings, defaultShiftName); + const next = normalizeSettings(api.record?.settings, defaultShiftName); setDraft(next); setSaveStatus("saved"); } catch (err) { @@ -537,7 +620,7 @@ export default function SettingsPage() { if (loading && !draft) { return ( -
+
{t("settings.loading")}
@@ -547,7 +630,7 @@ export default function SettingsPage() { if (!draft) { return ( -
+
{error || t("settings.unavailable")}
@@ -556,23 +639,23 @@ export default function SettingsPage() { } return ( -
-
+
+

{t("settings.title")}

{t("settings.subtitle")}

-
+
@@ -585,79 +668,143 @@ export default function SettingsPage() {
)} -
-
-
{t("settings.org.title")}
-
-
-
{t("settings.org.plantName")}
-
{orgInfo?.name || t("common.loading")}
- {orgInfo?.slug ? ( -
- {t("settings.org.slug")}: {orgInfo.slug} -
- ) : null} +
+ {SETTINGS_TABS.map((tab) => ( + + ))} +
+ + {activeTab === "general" && ( +
+
+
{t("settings.org.title")}
+
+
+
{t("settings.org.plantName")}
+
{orgInfo?.name || t("common.loading")}
+ {orgInfo?.slug ? ( +
+ {t("settings.org.slug")}: {orgInfo.slug} +
+ ) : null} +
+ +
+ {t("settings.updated")}:{" "} + {draft.updatedAt ? new Date(draft.updatedAt).toLocaleString(locale) : t("common.na")} +
- -
- {t("settings.updated")}:{" "} - {draft.updatedAt ? new Date(draft.updatedAt).toLocaleString(locale) : t("common.na")} +
+ +
+
+
{t("settings.defaults")}
+
+ + +
+
+ +
+
{t("settings.integrations")}
+
+
+
{t("settings.integrations.webhook")}
+
https://hooks.example.com/iiot
+
+
+
{t("settings.integrations.erp")}
+
{t("settings.integrations.erpNotConfigured")}
+
+
+ )} -
-
-
{t("settings.thresholds")}
-
{t("settings.thresholds.appliesAll")}
-
+ {activeTab === "thresholds" && ( +
+
+
+
{t("settings.thresholds")}
+
{t("settings.thresholds.appliesAll")}
+
-
- - -
-
- -
-
-
-
{t("settings.shiftSchedule")}
-
{t("settings.shiftHint")}
-
- -
- {draft.shiftSchedule.shifts.map((shift, index) => ( -
-
- updateShift(index, { name: event.target.value })} - className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-sm text-white" - /> - -
-
- updateShift(index, { start: event.target.value })} - className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-sm text-white" - /> - {t("settings.shiftTo")} - updateShift(index, { end: event.target.value })} - className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-sm text-white" - /> -
-
- updateShift(index, { enabled: event.target.checked })} - className="h-4 w-4 rounded border border-white/20 bg-black/20" - /> - {t("settings.shiftEnabled")} -
-
- ))} -
- -
- -
- -
+ )} -
-
{t("settings.alerts")}
-
- updateAlerts("oeeDropEnabled", next)} - /> - updateAlerts("performanceDegradationEnabled", next)} - /> - updateAlerts("qualitySpikeEnabled", next)} - /> - updateAlerts("predictiveOeeDeclineEnabled", next)} - /> -
-
-
- -
-
-
{t("settings.defaults")}
-
- - -
-
- -
-
{t("settings.integrations")}
-
-
-
{t("settings.integrations.webhook")}
-
https://hooks.example.com/iiot
+ {activeTab === "shifts" && ( +
+
+
+
{t("settings.shiftSchedule")}
+
{t("settings.shiftHint")}
-
-
{t("settings.integrations.erp")}
-
{t("settings.integrations.erpNotConfigured")}
-
-
-
-
-
-
-
-
{t("settings.team")}
-
{t("settings.teamTotal", { count: members.length })}
-
- - {teamLoading &&
{t("settings.loadingTeam")}
} - {teamError && ( -
- {teamError} -
- )} - - {!teamLoading && !teamError && members.length === 0 && ( -
{t("settings.teamNone")}
- )} - - {!teamLoading && !teamError && members.length > 0 && ( -
- {members.map((member) => ( -
-
-
- {member.name || member.email} -
-
{member.email}
+
+ {draft.shiftSchedule.shifts.map((shift, index) => ( +
+
+ updateShift(index, { name: event.target.value })} + className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-sm text-white" + /> +
-
- - {formatRole(member.role)} - - {!member.isActive ? ( - - {t("settings.role.inactive")} - - ) : null} +
+ updateShift(index, { start: event.target.value })} + className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-sm text-white" + /> + {t("settings.shiftTo")} + updateShift(index, { end: event.target.value })} + className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-sm text-white" + /> +
+
+ updateShift(index, { enabled: event.target.checked })} + className="h-4 w-4 rounded border border-white/20 bg-black/20" + /> + {t("settings.shiftEnabled")}
))}
- )} -
-
-
{t("settings.invites")}
-
- - + {t("settings.shiftAdd")} + +
+ + +
+
+
+
+ )} + + {activeTab === "alerts" && ( +
+
+
{t("settings.alerts")}
+
+ updateAlerts("oeeDropEnabled", next)} + /> + updateAlerts("performanceDegradationEnabled", next)} + /> + updateAlerts("qualitySpikeEnabled", next)} + /> + updateAlerts("predictiveOeeDeclineEnabled", next)} + /> +
-
- - - {inviteStatus &&
{inviteStatus}
} -
+ +
+ )} -
- {invites.length === 0 && ( -
{t("settings.inviteNone")}
+ {activeTab === "financial" && ( +
+ +
+ )} + + {activeTab === "team" && ( +
+
+
+
{t("settings.team")}
+
{t("settings.teamTotal", { count: members.length })}
+
+ + {teamLoading &&
{t("settings.loadingTeam")}
} + {teamError && ( +
+ {teamError} +
)} - {invites.map((invite) => ( -
-
-
-
{invite.email}
-
- {formatRole(invite.role)} -{" "} - {t("settings.inviteExpires", { - date: new Date(invite.expiresAt).toLocaleDateString(locale), - })} + + {!teamLoading && !teamError && members.length === 0 && ( +
{t("settings.teamNone")}
+ )} + + {!teamLoading && !teamError && members.length > 0 && ( +
+ {members.map((member) => ( +
+
+
+ {member.name || member.email} +
+
{member.email}
+
+
+ + {formatRole(member.role)} + + {!member.isActive ? ( + + {t("settings.role.inactive")} + + ) : null}
-
- - + ))} +
+ )} +
+ +
+
{t("settings.invites")}
+
+ + +
+ +
+ + + {inviteStatus &&
{inviteStatus}
} +
+ +
+ {invites.length === 0 && ( +
{t("settings.inviteNone")}
+ )} + {invites.map((invite) => ( +
+
+
+
{invite.email}
+
+ {formatRole(invite.role)} -{" "} + {t("settings.inviteExpires", { + date: new Date(invite.expiresAt).toLocaleDateString(locale), + })} +
+
+
+ + +
+
+
+ {buildInviteUrl(invite.token)}
-
- {buildInviteUrl(invite.token)} -
-
- ))} + ))} +
-
+ )}
); } diff --git a/app/api/alerts/contacts/[id]/route.ts b/app/api/alerts/contacts/[id]/route.ts index fde53ad..f9f9a4c 100644 --- a/app/api/alerts/contacts/[id]/route.ts +++ b/app/api/alerts/contacts/[id]/route.ts @@ -50,6 +50,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st } const { userId: _userId, eventTypes, ...updateData } = parsed.data; + void _userId; const normalizedEventTypes = eventTypes === null ? Prisma.DbNull : eventTypes ?? undefined; const data = normalizedEventTypes === undefined diff --git a/app/api/alerts/inbox/route.ts b/app/api/alerts/inbox/route.ts new file mode 100644 index 0000000..e000dbc --- /dev/null +++ b/app/api/alerts/inbox/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { requireSession } from "@/lib/auth/requireSession"; +import { getAlertsInboxData } from "@/lib/alerts/getAlertsInboxData"; + +function parseDate(input?: string | null) { + if (!input) return null; + const n = Number(input); + if (!Number.isNaN(n)) return new Date(n); + const d = new Date(input); + return Number.isNaN(d.getTime()) ? null : d; +} + +export async function GET(req: NextRequest) { + const session = await requireSession(); + if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + + const url = new URL(req.url); + const range = url.searchParams.get("range") ?? "24h"; + const machineId = url.searchParams.get("machineId") ?? undefined; + const location = url.searchParams.get("location") ?? undefined; + const eventType = url.searchParams.get("eventType") ?? undefined; + const severity = url.searchParams.get("severity") ?? undefined; + const status = url.searchParams.get("status") ?? undefined; + const shift = url.searchParams.get("shift") ?? undefined; + const includeUpdates = url.searchParams.get("includeUpdates") === "1"; + const limitRaw = Number(url.searchParams.get("limit") ?? "200"); + const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(limitRaw, 1), 500) : 200; + const start = parseDate(url.searchParams.get("start")); + const end = parseDate(url.searchParams.get("end")); + + const result = await getAlertsInboxData({ + orgId: session.orgId, + range, + start, + end, + machineId, + location, + eventType, + severity, + status, + shift, + includeUpdates, + limit, + }); + + return NextResponse.json({ ok: true, range: result.range, events: result.events }); +} diff --git a/app/api/financial/costs/route.ts b/app/api/financial/costs/route.ts new file mode 100644 index 0000000..dd47c2c --- /dev/null +++ b/app/api/financial/costs/route.ts @@ -0,0 +1,262 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireSession } from "@/lib/auth/requireSession"; +import { Prisma } from "@prisma/client"; +import { z } from "zod"; + +function canManageFinancials(role?: string | null) { + return role === "OWNER"; +} + +function stripUndefined>(input: T) { + const out: Record = {}; + for (const [key, value] of Object.entries(input)) { + if (value !== undefined) out[key] = value; + } + return out as T; +} + +function normalizeCurrency(value?: string | null) { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + return trimmed.toUpperCase(); +} + +const numberField = z.preprocess( + (value) => { + if (value === "" || value === null || value === undefined) return null; + const n = Number(value); + return Number.isFinite(n) ? n : value; + }, + z.number().finite().nullable() +); + +const numericFields = { + machineCostPerMin: numberField.optional(), + operatorCostPerMin: numberField.optional(), + ratedRunningKw: numberField.optional(), + idleKw: numberField.optional(), + kwhRate: numberField.optional(), + energyMultiplier: numberField.optional(), + energyCostPerMin: numberField.optional(), + scrapCostPerUnit: numberField.optional(), + rawMaterialCostPerUnit: numberField.optional(), +}; + +const orgSchema = z + .object({ + defaultCurrency: z.string().trim().min(1).max(8).optional(), + ...numericFields, + }) + .strict(); + +const locationSchema = z + .object({ + location: z.string().trim().min(1).max(80), + currency: z.string().trim().min(1).max(8).optional().nullable(), + ...numericFields, + }) + .strict(); + +const machineSchema = z + .object({ + machineId: z.string().uuid(), + currency: z.string().trim().min(1).max(8).optional().nullable(), + ...numericFields, + }) + .strict(); + +const productSchema = z + .object({ + sku: z.string().trim().min(1).max(64), + currency: z.string().trim().min(1).max(8).optional().nullable(), + rawMaterialCostPerUnit: numberField.optional(), + }) + .strict(); + +const payloadSchema = z + .object({ + org: orgSchema.optional(), + locations: z.array(locationSchema).optional(), + machines: z.array(machineSchema).optional(), + products: z.array(productSchema).optional(), + }) + .strict(); + +async function ensureOrgFinancialProfile( + tx: Prisma.TransactionClient, + orgId: string, + userId: string +) { + const existing = await tx.orgFinancialProfile.findUnique({ where: { orgId } }); + if (existing) return existing; + return tx.orgFinancialProfile.create({ + data: { + orgId, + defaultCurrency: "USD", + energyMultiplier: 1.0, + updatedBy: userId, + }, + }); +} + +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 async function GET() { + const session = await requireSession(); + if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + + const membership = await prisma.orgUser.findUnique({ + where: { orgId_userId: { orgId: session.orgId, userId: session.userId } }, + select: { role: true }, + }); + if (!canManageFinancials(membership?.role)) { + return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }); + } + + await prisma.$transaction((tx) => ensureOrgFinancialProfile(tx, session.orgId, session.userId)); + const payload = await loadFinancialConfig(session.orgId); + return NextResponse.json({ ok: true, ...payload }); +} + +export async function POST(req: Request) { + const session = await requireSession(); + if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + + const membership = await prisma.orgUser.findUnique({ + where: { orgId_userId: { orgId: session.orgId, userId: session.userId } }, + select: { role: true }, + }); + if (!canManageFinancials(membership?.role)) { + return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }); + } + + const body = await req.json().catch(() => ({})); + const parsed = payloadSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 }); + } + + const data = parsed.data; + + await prisma.$transaction(async (tx) => { + await ensureOrgFinancialProfile(tx, session.orgId, session.userId); + + if (data.org) { + const updateData = stripUndefined({ + defaultCurrency: data.org.defaultCurrency?.trim().toUpperCase(), + machineCostPerMin: data.org.machineCostPerMin, + operatorCostPerMin: data.org.operatorCostPerMin, + ratedRunningKw: data.org.ratedRunningKw, + idleKw: data.org.idleKw, + kwhRate: data.org.kwhRate, + energyMultiplier: data.org.energyMultiplier == null ? undefined : data.org.energyMultiplier, + energyCostPerMin: data.org.energyCostPerMin, + scrapCostPerUnit: data.org.scrapCostPerUnit, + rawMaterialCostPerUnit: data.org.rawMaterialCostPerUnit, + updatedBy: session.userId, + }); + + if (Object.keys(updateData).length > 0) { + await tx.orgFinancialProfile.update({ + where: { orgId: session.orgId }, + data: updateData, + }); + } + } + + const machineIds = new Set((data.machines ?? []).map((m) => m.machineId)); + const validMachineIds = new Set(); + if (machineIds.size > 0) { + const rows = await tx.machine.findMany({ + where: { orgId: session.orgId, id: { in: Array.from(machineIds) } }, + select: { id: true }, + }); + rows.forEach((m) => validMachineIds.add(m.id)); + } + + for (const loc of data.locations ?? []) { + const updateData = stripUndefined({ + currency: normalizeCurrency(loc.currency), + machineCostPerMin: loc.machineCostPerMin, + operatorCostPerMin: loc.operatorCostPerMin, + ratedRunningKw: loc.ratedRunningKw, + idleKw: loc.idleKw, + kwhRate: loc.kwhRate, + energyMultiplier: loc.energyMultiplier, + energyCostPerMin: loc.energyCostPerMin, + scrapCostPerUnit: loc.scrapCostPerUnit, + rawMaterialCostPerUnit: loc.rawMaterialCostPerUnit, + updatedBy: session.userId, + }); + + await tx.locationFinancialOverride.upsert({ + where: { orgId_location: { orgId: session.orgId, location: loc.location } }, + update: updateData, + create: { + orgId: session.orgId, + location: loc.location, + ...updateData, + }, + }); + } + + for (const machine of data.machines ?? []) { + if (!validMachineIds.has(machine.machineId)) continue; + const updateData = stripUndefined({ + currency: normalizeCurrency(machine.currency), + machineCostPerMin: machine.machineCostPerMin, + operatorCostPerMin: machine.operatorCostPerMin, + ratedRunningKw: machine.ratedRunningKw, + idleKw: machine.idleKw, + kwhRate: machine.kwhRate, + energyMultiplier: machine.energyMultiplier, + energyCostPerMin: machine.energyCostPerMin, + scrapCostPerUnit: machine.scrapCostPerUnit, + rawMaterialCostPerUnit: machine.rawMaterialCostPerUnit, + updatedBy: session.userId, + }); + + await tx.machineFinancialOverride.upsert({ + where: { orgId_machineId: { orgId: session.orgId, machineId: machine.machineId } }, + update: updateData, + create: { + orgId: session.orgId, + machineId: machine.machineId, + ...updateData, + }, + }); + } + + for (const product of data.products ?? []) { + const updateData = stripUndefined({ + currency: normalizeCurrency(product.currency), + rawMaterialCostPerUnit: product.rawMaterialCostPerUnit, + updatedBy: session.userId, + }); + + await tx.productCostOverride.upsert({ + where: { orgId_sku: { orgId: session.orgId, sku: product.sku } }, + update: updateData, + create: { + orgId: session.orgId, + sku: product.sku, + ...updateData, + }, + }); + } + }); + + const payload = await loadFinancialConfig(session.orgId); + return NextResponse.json({ ok: true, ...payload }); +} diff --git a/app/api/financial/export/excel/route.ts b/app/api/financial/export/excel/route.ts new file mode 100644 index 0000000..2516419 --- /dev/null +++ b/app/api/financial/export/excel/route.ts @@ -0,0 +1,156 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { requireSession } from "@/lib/auth/requireSession"; +import { prisma } from "@/lib/prisma"; +import { computeFinancialImpact } from "@/lib/financial/impact"; + +const RANGE_MS: Record = { + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, + "30d": 30 * 24 * 60 * 60 * 1000, +}; + +function canManageFinancials(role?: string | null) { + return role === "OWNER"; +} + +function parseDate(input?: string | null) { + if (!input) return null; + const n = Number(input); + if (!Number.isNaN(n)) return new Date(n); + const d = new Date(input); + return Number.isNaN(d.getTime()) ? null : d; +} + +function pickRange(req: NextRequest) { + const url = new URL(req.url); + const range = url.searchParams.get("range") ?? "7d"; + const now = new Date(); + + if (range === "custom") { + const start = parseDate(url.searchParams.get("start")) ?? new Date(now.getTime() - RANGE_MS["24h"]); + const end = parseDate(url.searchParams.get("end")) ?? now; + return { start, end }; + } + + const ms = RANGE_MS[range] ?? RANGE_MS["24h"]; + return { start: new Date(now.getTime() - ms), end: now }; +} + +function csvValue(value: string | number | null | undefined) { + if (value === null || value === undefined) return ""; + const text = String(value); + if (/[",\n]/.test(text)) { + return `"${text.replace(/"/g, "\"\"")}"`; + } + return text; +} + +function formatNumber(value: number | null) { + if (value == null || !Number.isFinite(value)) return ""; + return value.toFixed(4); +} + +function slugify(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60) || "report"; +} + +export async function GET(req: NextRequest) { + const session = await requireSession(); + if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + + const membership = await prisma.orgUser.findUnique({ + where: { orgId_userId: { orgId: session.orgId, userId: session.userId } }, + select: { role: true }, + }); + if (!canManageFinancials(membership?.role)) { + return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }); + } + + const url = new URL(req.url); + const { start, end } = pickRange(req); + const machineId = url.searchParams.get("machineId") ?? undefined; + const location = url.searchParams.get("location") ?? undefined; + const sku = url.searchParams.get("sku") ?? undefined; + const currency = url.searchParams.get("currency") ?? undefined; + + const [org, impact] = await Promise.all([ + prisma.org.findUnique({ where: { id: session.orgId }, select: { name: true } }), + computeFinancialImpact({ + orgId: session.orgId, + start, + end, + machineId, + location, + sku, + currency, + includeEvents: true, + }), + ]); + + const orgName = org?.name ?? "Organization"; + const header = [ + "org_name", + "range_start", + "range_end", + "event_id", + "event_ts", + "event_type", + "status", + "severity", + "category", + "machine_id", + "machine_name", + "location", + "work_order_id", + "sku", + "duration_sec", + "cost_machine", + "cost_operator", + "cost_energy", + "cost_scrap", + "cost_raw_material", + "cost_total", + "currency", + ]; + + const rows = impact.events.map((event) => [ + orgName, + start.toISOString(), + end.toISOString(), + event.id, + event.ts.toISOString(), + event.eventType, + event.status, + event.severity, + event.category, + event.machineId, + event.machineName ?? "", + event.location ?? "", + event.workOrderId ?? "", + event.sku ?? "", + formatNumber(event.durationSec), + formatNumber(event.costMachine), + formatNumber(event.costOperator), + formatNumber(event.costEnergy), + formatNumber(event.costScrap), + formatNumber(event.costRawMaterial), + formatNumber(event.costTotal), + event.currency, + ]); + + const lines = [header, ...rows].map((row) => row.map(csvValue).join(",")); + const csv = lines.join("\n"); + + const fileName = `financial_events_${slugify(orgName)}.csv`; + return new NextResponse(csv, { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename=\"${fileName}\"`, + }, + }); +} diff --git a/app/api/financial/export/pdf/route.ts b/app/api/financial/export/pdf/route.ts new file mode 100644 index 0000000..1004ae2 --- /dev/null +++ b/app/api/financial/export/pdf/route.ts @@ -0,0 +1,246 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { requireSession } from "@/lib/auth/requireSession"; +import { prisma } from "@/lib/prisma"; +import { computeFinancialImpact } from "@/lib/financial/impact"; + +const RANGE_MS: Record = { + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, + "30d": 30 * 24 * 60 * 60 * 1000, +}; + +function canManageFinancials(role?: string | null) { + return role === "OWNER"; +} + +function parseDate(input?: string | null) { + if (!input) return null; + const n = Number(input); + if (!Number.isNaN(n)) return new Date(n); + const d = new Date(input); + return Number.isNaN(d.getTime()) ? null : d; +} + +function pickRange(req: NextRequest) { + const url = new URL(req.url); + const range = url.searchParams.get("range") ?? "7d"; + const now = new Date(); + + if (range === "custom") { + const start = parseDate(url.searchParams.get("start")) ?? new Date(now.getTime() - RANGE_MS["24h"]); + const end = parseDate(url.searchParams.get("end")) ?? now; + return { start, end }; + } + + const ms = RANGE_MS[range] ?? RANGE_MS["24h"]; + return { start: new Date(now.getTime() - ms), end: now }; +} + +function escapeHtml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/\"/g, """) + .replace(/'/g, "'"); +} + +function formatMoney(value: number, currency: string) { + if (!Number.isFinite(value)) return "--"; + try { + return new Intl.NumberFormat("en-US", { style: "currency", currency, maximumFractionDigits: 2 }).format(value); + } catch { + return `${value.toFixed(2)} ${currency}`; + } +} + +function formatNumber(value: number | null, digits = 2) { + if (value == null || !Number.isFinite(value)) return "--"; + return value.toFixed(digits); +} + +function slugify(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60) || "report"; +} + +export async function GET(req: NextRequest) { + const session = await requireSession(); + if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + + const membership = await prisma.orgUser.findUnique({ + where: { orgId_userId: { orgId: session.orgId, userId: session.userId } }, + select: { role: true }, + }); + if (!canManageFinancials(membership?.role)) { + return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }); + } + + const url = new URL(req.url); + const { start, end } = pickRange(req); + const machineId = url.searchParams.get("machineId") ?? undefined; + const location = url.searchParams.get("location") ?? undefined; + const sku = url.searchParams.get("sku") ?? undefined; + const currency = url.searchParams.get("currency") ?? undefined; + + const [org, impact] = await Promise.all([ + prisma.org.findUnique({ where: { id: session.orgId }, select: { name: true } }), + computeFinancialImpact({ + orgId: session.orgId, + start, + end, + machineId, + location, + sku, + currency, + includeEvents: true, + }), + ]); + + const orgName = org?.name ?? "Organization"; + const summaryBlocks = impact.currencySummaries + .map( + (summary) => ` +
+
${escapeHtml(summary.currency)}
+
${escapeHtml(formatMoney(summary.totals.total, summary.currency))}
+
Slow: ${escapeHtml(formatMoney(summary.totals.slowCycle, summary.currency))}
+
Micro: ${escapeHtml(formatMoney(summary.totals.microstop, summary.currency))}
+
Macro: ${escapeHtml(formatMoney(summary.totals.macrostop, summary.currency))}
+
Scrap: ${escapeHtml(formatMoney(summary.totals.scrap, summary.currency))}
+
+ ` + ) + .join(""); + + const dailyTables = impact.currencySummaries + .map((summary) => { + const rows = summary.byDay + .map( + (row) => ` + + ${escapeHtml(row.day)} + ${escapeHtml(formatMoney(row.total, summary.currency))} + ${escapeHtml(formatMoney(row.slowCycle, summary.currency))} + ${escapeHtml(formatMoney(row.microstop, summary.currency))} + ${escapeHtml(formatMoney(row.macrostop, summary.currency))} + ${escapeHtml(formatMoney(row.scrap, summary.currency))} + + ` + ) + .join(""); + + return ` +
+

${escapeHtml(summary.currency)} Daily Breakdown

+ + + + + + + + + + + + + ${rows || ""} + +
DayTotalSlowMicroMacroScrap
No data
+
+ `; + }) + .join(""); + + const eventRows = impact.events + .map( + (e) => ` + + ${escapeHtml(e.ts.toISOString())} + ${escapeHtml(e.eventType)} + ${escapeHtml(e.category)} + ${escapeHtml(e.machineName ?? "-")} + ${escapeHtml(e.location ?? "-")} + ${escapeHtml(e.sku ?? "-")} + ${escapeHtml(e.workOrderId ?? "-")} + ${escapeHtml(formatNumber(e.durationSec))} + ${escapeHtml(formatMoney(e.costTotal, e.currency))} + ${escapeHtml(e.currency)} + + ` + ) + .join(""); + + const html = ` + + + + Financial Impact Report + + + +
+

Financial Impact Report

+
${escapeHtml(orgName)} | ${escapeHtml(start.toISOString())} - ${escapeHtml(end.toISOString())}
+
+ +
+ ${summaryBlocks || "
No totals yet.
"} +
+ + ${dailyTables} + +
+

Event Details

+ + + + + + + + + + + + + + + + + ${eventRows || ""} + +
TimestampEventCategoryMachineLocationSKUWork OrderDuration (sec)CostCurrency
No events
+
+ +
Power by MaliounTech
+ +`; + + const fileName = `financial_report_${slugify(orgName)}.html`; + return new NextResponse(html, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Content-Disposition": `attachment; filename=\"${fileName}\"`, + }, + }); +} diff --git a/app/api/financial/impact/route.ts b/app/api/financial/impact/route.ts new file mode 100644 index 0000000..6bc4061 --- /dev/null +++ b/app/api/financial/impact/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { requireSession } from "@/lib/auth/requireSession"; +import { computeFinancialImpact } from "@/lib/financial/impact"; + +const RANGE_MS: Record = { + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, + "30d": 30 * 24 * 60 * 60 * 1000, +}; + +function canManageFinancials(role?: string | null) { + return role === "OWNER"; +} + +function parseDate(input?: string | null) { + if (!input) return null; + const n = Number(input); + if (!Number.isNaN(n)) return new Date(n); + const d = new Date(input); + return Number.isNaN(d.getTime()) ? null : d; +} + +function pickRange(req: NextRequest) { + const url = new URL(req.url); + const range = url.searchParams.get("range") ?? "7d"; + const now = new Date(); + + if (range === "custom") { + const start = parseDate(url.searchParams.get("start")) ?? new Date(now.getTime() - RANGE_MS["24h"]); + const end = parseDate(url.searchParams.get("end")) ?? now; + return { start, end }; + } + + const ms = RANGE_MS[range] ?? RANGE_MS["24h"]; + return { start: new Date(now.getTime() - ms), end: now }; +} + +export async function GET(req: NextRequest) { + const session = await requireSession(); + if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + + const membership = await prisma.orgUser.findUnique({ + where: { orgId_userId: { orgId: session.orgId, userId: session.userId } }, + select: { role: true }, + }); + if (!canManageFinancials(membership?.role)) { + return NextResponse.json({ ok: false, error: "Forbidden" }, { status: 403 }); + } + + const url = new URL(req.url); + const { start, end } = pickRange(req); + const machineId = url.searchParams.get("machineId") ?? undefined; + const location = url.searchParams.get("location") ?? undefined; + const sku = url.searchParams.get("sku") ?? undefined; + const currency = url.searchParams.get("currency") ?? undefined; + + const result = await computeFinancialImpact({ + orgId: session.orgId, + start, + end, + machineId, + location, + sku, + currency, + includeEvents: false, + }); + + return NextResponse.json({ ok: true, ...result }); +} diff --git a/app/api/ingest/cycle/route.ts b/app/api/ingest/cycle/route.ts index c9a93ef..e9fae07 100644 --- a/app/api/ingest/cycle/route.ts +++ b/app/api/ingest/cycle/route.ts @@ -3,27 +3,33 @@ import { prisma } from "@/lib/prisma"; import { getMachineAuth } from "@/lib/machineAuthCache"; import { z } from "zod"; -function unwrapEnvelope(raw: any) { - if (!raw || typeof raw !== "object") return raw; - const payload = raw.payload; - if (!payload || typeof payload !== "object") return raw; +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +function unwrapEnvelope(raw: unknown) { + const record = asRecord(raw); + if (!record) return raw; + const payload = asRecord(record.payload); + if (!payload) return raw; const hasMeta = - raw.schemaVersion !== undefined || - raw.machineId !== undefined || - raw.tsMs !== undefined || - raw.tsDevice !== undefined || - raw.seq !== undefined || - raw.type !== undefined; + record.schemaVersion !== undefined || + record.machineId !== undefined || + record.tsMs !== undefined || + record.tsDevice !== undefined || + record.seq !== undefined || + record.type !== undefined; if (!hasMeta) return raw; return { ...payload, - machineId: raw.machineId ?? payload.machineId, - tsMs: raw.tsMs ?? payload.tsMs, - tsDevice: raw.tsDevice ?? payload.tsDevice, - schemaVersion: raw.schemaVersion ?? payload.schemaVersion, - seq: raw.seq ?? payload.seq, + machineId: record.machineId ?? payload.machineId, + tsMs: record.tsMs ?? payload.tsMs, + tsDevice: record.tsDevice ?? payload.tsDevice, + schemaVersion: record.schemaVersion ?? payload.schemaVersion, + seq: record.seq ?? payload.seq, }; } @@ -61,10 +67,14 @@ export async function POST(req: Request) { const apiKey = req.headers.get("x-api-key"); if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 }); - let body = await req.json().catch(() => null); + let body: unknown = await req.json().catch(() => null); body = unwrapEnvelope(body); + const bodyRecord = asRecord(body) ?? {}; - const machineId = body?.machineId ?? body?.machine_id ?? body?.machine?.id; + const machineId = + bodyRecord.machineId ?? + bodyRecord.machine_id ?? + (asRecord(bodyRecord.machine)?.id ?? null); if (!machineId || !machineIdSchema.safeParse(String(machineId)).success) { return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 }); } @@ -72,8 +82,7 @@ export async function POST(req: Request) { const machine = await getMachineAuth(String(machineId), apiKey); if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); - const raw = body as any; - const cyclesRaw = raw?.cycles ?? raw?.cycle; + const cyclesRaw = bodyRecord.cycles ?? bodyRecord.cycle; if (!cyclesRaw) { return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 }); } @@ -85,8 +94,8 @@ export async function POST(req: Request) { } const fallbackTsMs = - (typeof raw?.tsMs === "number" && raw.tsMs) || - (typeof raw?.tsDevice === "number" && raw.tsDevice) || + (typeof bodyRecord.tsMs === "number" && bodyRecord.tsMs) || + (typeof bodyRecord.tsDevice === "number" && bodyRecord.tsDevice) || undefined; const rows = parsedCycles.data.map((data) => { diff --git a/app/api/ingest/event/route.ts b/app/api/ingest/event/route.ts index 3bbb33e..74d4622 100644 --- a/app/api/ingest/event/route.ts +++ b/app/api/ingest/event/route.ts @@ -3,13 +3,19 @@ import { prisma } from "@/lib/prisma"; import { getMachineAuth } from "@/lib/machineAuthCache"; import { z } from "zod"; import { evaluateAlertsForEvent } from "@/lib/alerts/engine"; +import { toJsonValue } from "@/lib/prismaJson"; -const normalizeType = (t: any) => +const normalizeType = (t: unknown) => String(t ?? "") .trim() .toLowerCase() .replace(/_/g, "-"); +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + const CANON_TYPE: Record = { // Node-RED "production-stopped": "stop", @@ -56,29 +62,35 @@ export async function POST(req: Request) { const apiKey = req.headers.get("x-api-key"); if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 }); - let body: any = await req.json().catch(() => null); + let body: unknown = await req.json().catch(() => null); // ✅ if Node-RED sent an array as the whole body, unwrap it if (Array.isArray(body)) body = body[0]; + const bodyRecord = asRecord(body) ?? {}; + const payloadRecord = asRecord(bodyRecord.payload) ?? {}; // ✅ accept multiple common keys - const machineId = body?.machineId ?? body?.machine_id ?? body?.machine?.id; + const machineId = + bodyRecord.machineId ?? + bodyRecord.machine_id ?? + (asRecord(bodyRecord.machine)?.id ?? null); let rawEvent = - body?.event ?? - body?.events ?? - body?.anomalies ?? - body?.payload?.event ?? - body?.payload?.events ?? - body?.payload?.anomalies ?? - body?.payload ?? - body?.data; // sometimes "data" + bodyRecord.event ?? + bodyRecord.events ?? + bodyRecord.anomalies ?? + payloadRecord.event ?? + payloadRecord.events ?? + payloadRecord.anomalies ?? + payloadRecord ?? + bodyRecord.data; // sometimes "data" - if (rawEvent?.event && typeof rawEvent.event === "object") rawEvent = rawEvent.event; - if (Array.isArray(rawEvent?.events)) rawEvent = rawEvent.events; + const rawEventRecord = asRecord(rawEvent); + if (rawEventRecord?.event && typeof rawEventRecord.event === "object") rawEvent = rawEventRecord.event; + if (Array.isArray(rawEventRecord?.events)) rawEvent = rawEventRecord.events; if (!machineId || !rawEvent) { return NextResponse.json( - { ok: false, error: "Invalid payload", got: { hasMachineId: !!machineId, keys: Object.keys(body ?? {}) } }, + { ok: false, error: "Invalid payload", got: { hasMachineId: !!machineId, keys: Object.keys(bodyRecord) } }, { status: 400 } ); } @@ -108,55 +120,50 @@ export async function POST(req: Request) { } const created: { id: string; ts: Date; eventType: string }[] = []; - const skipped: any[] = []; + const skipped: Array> = []; for (const ev of events) { - if (!ev || typeof ev !== "object") { + const evRecord = asRecord(ev); + if (!evRecord) { skipped.push({ reason: "invalid_event_object" }); continue; } + const evData = asRecord(evRecord.data) ?? {}; - const rawType = (ev as any).eventType ?? (ev as any).anomaly_type ?? (ev as any).topic ?? body.topic ?? ""; + const rawType = evRecord.eventType ?? evRecord.anomaly_type ?? evRecord.topic ?? bodyRecord.topic ?? ""; const typ0 = normalizeType(rawType); const typ = CANON_TYPE[typ0] ?? typ0; // Determine timestamp const tsMs = - (typeof (ev as any)?.timestamp === "number" && (ev as any).timestamp) || - (typeof (ev as any)?.data?.timestamp === "number" && (ev as any).data.timestamp) || - (typeof (ev as any)?.data?.event_timestamp === "number" && (ev as any).data.event_timestamp) || + (typeof evRecord.timestamp === "number" && evRecord.timestamp) || + (typeof evData.timestamp === "number" && evData.timestamp) || + (typeof evData.event_timestamp === "number" && evData.event_timestamp) || null; const ts = tsMs ? new Date(tsMs) : new Date(); // Severity defaulting (do not skip on severity — store for audit) - let sev = String((ev as any).severity ?? "").trim().toLowerCase(); + let sev = String(evRecord.severity ?? "").trim().toLowerCase(); if (!sev) sev = "warning"; // Stop classification -> microstop/macrostop let finalType = typ; if (typ === "stop") { const stopSec = - (typeof (ev as any)?.data?.stoppage_duration_seconds === "number" && (ev as any).data.stoppage_duration_seconds) || - (typeof (ev as any)?.data?.stop_duration_seconds === "number" && (ev as any).data.stop_duration_seconds) || + (typeof evData.stoppage_duration_seconds === "number" && evData.stoppage_duration_seconds) || + (typeof evData.stop_duration_seconds === "number" && evData.stop_duration_seconds) || null; if (stopSec != null) { - const theoretical = - Number( - (ev as any)?.data?.theoretical_cycle_time ?? - (ev as any)?.data?.theoreticalCycleTime ?? - 0 - ) || 0; + const theoretical = Number(evData.theoretical_cycle_time ?? evData.theoreticalCycleTime ?? 0) || 0; const microMultiplier = Number( - (ev as any)?.data?.micro_threshold_multiplier ?? - (ev as any)?.data?.threshold_multiplier ?? - defaultMicroMultiplier + evData.micro_threshold_multiplier ?? evData.threshold_multiplier ?? defaultMicroMultiplier ); const macroMultiplier = Math.max( microMultiplier, - Number((ev as any)?.data?.macro_threshold_multiplier ?? defaultMacroMultiplier) + Number(evData.macro_threshold_multiplier ?? defaultMacroMultiplier) ); if (theoretical > 0) { @@ -177,39 +184,60 @@ export async function POST(req: Request) { } const title = - clampText((ev as any).title, 160) || + clampText(evRecord.title, 160) || (finalType === "slow-cycle" ? "Slow Cycle Detected" : finalType === "macrostop" ? "Macrostop Detected" : finalType === "microstop" ? "Microstop Detected" : "Event"); - const description = clampText((ev as any).description, 1000); + const description = clampText(evRecord.description, 1000); // store full blob, ensure object - const rawData = (ev as any).data ?? ev; - const dataObj = typeof rawData === "string" ? (() => { - try { return JSON.parse(rawData); } catch { return { raw: rawData }; } - })() : rawData; + const rawData = evRecord.data ?? evRecord; + const parsedData = typeof rawData === "string" + ? (() => { + try { + return JSON.parse(rawData); + } catch { + return { raw: rawData }; + } + })() + : rawData; + const dataObj: Record = + parsedData && typeof parsedData === "object" && !Array.isArray(parsedData) + ? { ...(parsedData as Record) } + : { raw: parsedData }; + if (evRecord.status != null && dataObj.status == null) dataObj.status = evRecord.status; + if (evRecord.alert_id != null && dataObj.alert_id == null) dataObj.alert_id = evRecord.alert_id; + if (evRecord.is_update != null && dataObj.is_update == null) dataObj.is_update = evRecord.is_update; + if (evRecord.is_auto_ack != null && dataObj.is_auto_ack == null) dataObj.is_auto_ack = evRecord.is_auto_ack; + + const activeWorkOrder = asRecord(evRecord.activeWorkOrder); + const dataActiveWorkOrder = asRecord(evData.activeWorkOrder); const row = await prisma.machineEvent.create({ data: { orgId: machine.orgId, machineId: machine.id, ts, - topic: clampText((ev as any).topic ?? finalType, 64) ?? finalType, + topic: clampText(evRecord.topic ?? finalType, 64) ?? finalType, eventType: finalType, severity: sev, - requiresAck: !!(ev as any).requires_ack, + requiresAck: !!evRecord.requires_ack, title, description, - data: dataObj, + data: toJsonValue(dataObj), workOrderId: - clampText((ev as any)?.work_order_id, 64) ?? - clampText((ev as any)?.data?.work_order_id, 64) ?? + clampText(evRecord.work_order_id, 64) ?? + clampText(evData.work_order_id, 64) ?? + clampText(activeWorkOrder?.id, 64) ?? + clampText(dataActiveWorkOrder?.id, 64) ?? null, sku: - clampText((ev as any)?.sku, 64) ?? - clampText((ev as any)?.data?.sku, 64) ?? + clampText(evRecord.sku, 64) ?? + clampText(evData.sku, 64) ?? + clampText(activeWorkOrder?.sku, 64) ?? + clampText(dataActiveWorkOrder?.sku, 64) ?? null, }, }); diff --git a/app/api/ingest/heartbeat/route.ts b/app/api/ingest/heartbeat/route.ts index 06454f9..3f1c4fd 100644 --- a/app/api/ingest/heartbeat/route.ts +++ b/app/api/ingest/heartbeat/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { getMachineAuth } from "@/lib/machineAuthCache"; import { normalizeHeartbeatV1 } from "@/lib/contracts/v1"; +import { toJsonValue } from "@/lib/prismaJson"; function getClientIp(req: Request) { const xf = req.headers.get("x-forwarded-for"); @@ -24,7 +25,7 @@ export async function POST(req: Request) { const ip = getClientIp(req); const userAgent = req.headers.get("user-agent"); - let rawBody: any = null; + let rawBody: unknown = null; let orgId: string | null = null; let machineId: string | null = null; let seq: bigint | null = null; @@ -48,7 +49,16 @@ export async function POST(req: Request) { const normalized = normalizeHeartbeatV1(rawBody); if (!normalized.ok) { await prisma.ingestLog.create({ - data: { endpoint, ok: false, status: 400, errorCode: "INVALID_PAYLOAD", errorMsg: normalized.error, body: rawBody, ip, userAgent }, + data: { + endpoint, + ok: false, + status: 400, + errorCode: "INVALID_PAYLOAD", + errorMsg: normalized.error, + body: toJsonValue(rawBody), + ip, + userAgent, + }, }); return NextResponse.json({ ok: false, error: "Invalid payload", detail: normalized.error }, { status: 400 }); } @@ -70,7 +80,7 @@ export async function POST(req: Request) { status: 401, errorCode: "UNAUTHORIZED", errorMsg: "Unauthorized (machineId/apiKey mismatch)", - body: rawBody, + body: toJsonValue(rawBody), machineId, schemaVersion, seq, @@ -123,8 +133,8 @@ export async function POST(req: Request) { tsDevice: hb.ts, tsServer: hb.tsServer, }); - } catch (err: any) { - const msg = err?.message ? String(err.message) : "Unknown error"; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Unknown error"; try { await prisma.ingestLog.create({ @@ -139,7 +149,7 @@ export async function POST(req: Request) { schemaVersion, seq, tsDevice: tsDeviceDate ?? undefined, - body: rawBody, + body: toJsonValue(rawBody), ip, userAgent, }, diff --git a/app/api/ingest/kpi/route.ts b/app/api/ingest/kpi/route.ts index 8f2871a..dabfbc2 100644 --- a/app/api/ingest/kpi/route.ts +++ b/app/api/ingest/kpi/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { getMachineAuth } from "@/lib/machineAuthCache"; import { normalizeSnapshotV1 } from "@/lib/contracts/v1"; +import { toJsonValue } from "@/lib/prismaJson"; function getClientIp(req: Request) { const xf = req.headers.get("x-forwarded-for"); @@ -26,7 +27,7 @@ export async function POST(req: Request) { const ip = getClientIp(req); const userAgent = req.headers.get("user-agent"); - let rawBody: any = null; + let rawBody: unknown = null; let orgId: string | null = null; let machineId: string | null = null; let seq: bigint | null = null; @@ -60,7 +61,7 @@ export async function POST(req: Request) { status: 400, errorCode: "INVALID_PAYLOAD", errorMsg: normalized.error, - body: rawBody, + body: toJsonValue(rawBody), ip, userAgent, }, @@ -86,7 +87,7 @@ export async function POST(req: Request) { status: 401, errorCode: "UNAUTHORIZED", errorMsg: "Unauthorized (machineId/apiKey mismatch)", - body: rawBody, + body: toJsonValue(rawBody), machineId, schemaVersion, seq, @@ -100,19 +101,37 @@ export async function POST(req: Request) { orgId = machine.orgId; - const wo = body.activeWorkOrder ?? {}; - const good = typeof wo.good === "number" ? wo.good : (typeof wo.goodParts === "number" ? wo.goodParts : null); - const scrap = typeof wo.scrap === "number" ? wo.scrap : (typeof wo.scrapParts === "number" ? wo.scrapParts : null) + const woRecord = (body.activeWorkOrder ?? {}) as Record; + const good = + typeof woRecord.good === "number" + ? woRecord.good + : typeof woRecord.goodParts === "number" + ? woRecord.goodParts + : typeof woRecord.good_parts === "number" + ? woRecord.good_parts + : null; + const scrap = + typeof woRecord.scrap === "number" + ? woRecord.scrap + : typeof woRecord.scrapParts === "number" + ? woRecord.scrapParts + : typeof woRecord.scrap_parts === "number" + ? woRecord.scrap_parts + : null; const k = body.kpis ?? {}; const safeCycleTime = - typeof body.cycleTime === "number" && body.cycleTime > 0 - ? body.cycleTime - : (typeof (wo as any).cycleTime === "number" && (wo as any).cycleTime > 0 ? (wo as any).cycleTime : null); + typeof body.cycleTime === "number" && body.cycleTime > 0 + ? body.cycleTime + : typeof woRecord.cycleTime === "number" && woRecord.cycleTime > 0 + ? woRecord.cycleTime + : null; - const safeCavities = - typeof body.cavities === "number" && body.cavities > 0 - ? body.cavities - : (typeof (wo as any).cavities === "number" && (wo as any).cavities > 0 ? (wo as any).cavities : null); + const safeCavities = + typeof body.cavities === "number" && body.cavities > 0 + ? body.cavities + : typeof woRecord.cavities === "number" && woRecord.cavities > 0 + ? woRecord.cavities + : null; // Write snapshot (ts = tsDevice; tsServer auto) const row = await prisma.machineKpiSnapshot.create({ data: { @@ -125,9 +144,9 @@ export async function POST(req: Request) { ts: tsDeviceDate, // store device-time in ts; server-time goes to ts_server // Work order fields - workOrderId: wo.id ? String(wo.id) : null, - sku: wo.sku ? String(wo.sku) : null, - target: typeof wo.target === "number" ? Math.trunc(wo.target) : null, + workOrderId: woRecord.id != null ? String(woRecord.id) : null, + sku: woRecord.sku != null ? String(woRecord.sku) : null, + target: typeof woRecord.target === "number" ? Math.trunc(woRecord.target) : null, good: good != null ? Math.trunc(good) : null, scrap: scrap != null ? Math.trunc(scrap) : null, @@ -169,8 +188,8 @@ export async function POST(req: Request) { tsDevice: row.ts, tsServer: row.tsServer, }); - } catch (err: any) { - const msg = err?.message ? String(err.message) : "Unknown error"; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Unknown error"; // Never fail the request because logging failed try { @@ -186,7 +205,7 @@ export async function POST(req: Request) { schemaVersion, seq, tsDevice: tsDeviceDate ?? undefined, - body: rawBody, + body: toJsonValue(rawBody), ip, userAgent, }, diff --git a/app/api/machines/[machineId]/route.ts b/app/api/machines/[machineId]/route.ts index 568db52..bc03092 100644 --- a/app/api/machines/[machineId]/route.ts +++ b/app/api/machines/[machineId]/route.ts @@ -1,177 +1,9 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { createHash } from "crypto"; import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; - -function normalizeEvent( - row: any, - thresholds: { microMultiplier: number; macroMultiplier: number } -) { - // ----------------------------- - // 1) Parse row.data safely - // data may be: - // - object - // - array of objects - // - JSON string of either - // ----------------------------- - const raw = row.data; - - let parsed: any = raw; - if (typeof raw === "string") { - try { - parsed = JSON.parse(raw); - } catch { - parsed = raw; // keep as string if not JSON - } - } - - // data can be object OR [object] - const blob = Array.isArray(parsed) ? parsed[0] : parsed; - - // some payloads nest details under blob.data - const inner = blob?.data ?? blob ?? {}; - - const normalizeType = (t: any) => - String(t ?? "") - .trim() - .toLowerCase() - .replace(/_/g, "-"); - - // ----------------------------- - // 2) Alias mapping (canonical types) - // ----------------------------- - const ALIAS: Record = { - // Spanish / synonyms - macroparo: "macrostop", - "macro-stop": "macrostop", - macro_stop: "macrostop", - - microparo: "microstop", - "micro-paro": "microstop", - micro_stop: "microstop", - - // Node-RED types - "production-stopped": "stop", // we'll classify to micro/macro below - - // legacy / generic - down: "stop", - }; - - // ----------------------------- - // 3) Determine event type from DB or blob - // ----------------------------- - const fromDbType = - row.eventType && row.eventType !== "unknown" ? row.eventType : null; - - const fromBlobType = - blob?.anomaly_type ?? - blob?.eventType ?? - blob?.topic ?? - inner?.anomaly_type ?? - inner?.eventType ?? - null; - - // infer slow-cycle if signature exists - const inferredType = - fromDbType ?? - fromBlobType ?? - ((inner?.actual_cycle_time && inner?.theoretical_cycle_time) || - (blob?.actual_cycle_time && blob?.theoretical_cycle_time) - ? "slow-cycle" - : "unknown"); - - const eventTypeRaw = normalizeType(inferredType); - let eventType = ALIAS[eventTypeRaw] ?? eventTypeRaw; - - // ----------------------------- - // 4) Optional: classify "stop" into micro/macro based on duration if present - // (keeps old rows usable even if they stored production-stopped) - // ----------------------------- - if (eventType === "stop") { - const stopSec = - (typeof inner?.stoppage_duration_seconds === "number" && inner.stoppage_duration_seconds) || - (typeof blob?.stoppage_duration_seconds === "number" && blob.stoppage_duration_seconds) || - (typeof inner?.stop_duration_seconds === "number" && inner.stop_duration_seconds) || - null; - - const microMultiplier = Number(thresholds?.microMultiplier ?? 1.5); - const macroMultiplier = Math.max( - microMultiplier, - Number(thresholds?.macroMultiplier ?? 5) - ); - - const theoreticalCycle = - Number(inner?.theoretical_cycle_time ?? blob?.theoretical_cycle_time) || 0; - - if (stopSec != null) { - if (theoreticalCycle > 0) { - const macroThresholdSec = theoreticalCycle * macroMultiplier; - eventType = stopSec >= macroThresholdSec ? "macrostop" : "microstop"; - } else { - const fallbackMacroSec = 300; - eventType = stopSec >= fallbackMacroSec ? "macrostop" : "microstop"; - } - } - } - - // ----------------------------- - // 5) Severity, title, description, timestamp - // ----------------------------- - const severity = - String( - (row.severity && row.severity !== "info" ? row.severity : null) ?? - blob?.severity ?? - inner?.severity ?? - "info" - ) - .trim() - .toLowerCase(); - - const title = - String( - (row.title && row.title !== "Event" ? row.title : null) ?? - blob?.title ?? - inner?.title ?? - (eventType === "slow-cycle" ? "Slow Cycle Detected" : "Event") - ).trim(); - - const description = - row.description ?? - blob?.description ?? - inner?.description ?? - (eventType === "slow-cycle" && - (inner?.actual_cycle_time ?? blob?.actual_cycle_time) && - (inner?.theoretical_cycle_time ?? blob?.theoretical_cycle_time) && - (inner?.delta_percent ?? blob?.delta_percent) != null - ? `Cycle took ${Number(inner?.actual_cycle_time ?? blob?.actual_cycle_time).toFixed(1)}s (+${Number(inner?.delta_percent ?? blob?.delta_percent)}% vs ${Number(inner?.theoretical_cycle_time ?? blob?.theoretical_cycle_time).toFixed(1)}s objetivo)` - : null); - - const ts = - row.ts ?? - (typeof blob?.timestamp === "number" ? new Date(blob.timestamp) : null) ?? - (typeof inner?.timestamp === "number" ? new Date(inner.timestamp) : null) ?? - null; - - const workOrderId = - row.workOrderId ?? - blob?.work_order_id ?? - inner?.work_order_id ?? - null; - - return { - id: row.id, - ts, - topic: String(row.topic ?? blob?.topic ?? eventType), - eventType, - severity, - title, - description, - requiresAck: !!row.requiresAck, - workOrderId, - }; -} - - +import { normalizeEvent } from "@/lib/events/normalizeEvent"; export async function GET( @@ -188,9 +20,89 @@ export async function GET( const eventsOnly = url.searchParams.get("eventsOnly") === "1"; const eventsWindowSec = Number(url.searchParams.get("eventsWindowSec") ?? "21600"); // default 6h const eventsWindowStart = new Date(Date.now() - Math.max(0, eventsWindowSec) * 1000); + const windowSec = Number(url.searchParams.get("windowSec") ?? "3600"); // default 1h const { machineId } = await params; + const machineBase = await prisma.machine.findFirst({ + where: { id: machineId, orgId: session.orgId }, + select: { id: true, updatedAt: true }, + }); + + if (!machineBase) { + return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 }); + } + + const [heartbeatAgg, kpiAgg, eventAgg, cycleAgg, orgSettingsAgg] = await Promise.all([ + prisma.machineHeartbeat.aggregate({ + where: { orgId: session.orgId, machineId }, + _max: { tsServer: true }, + }), + prisma.machineKpiSnapshot.aggregate({ + where: { orgId: session.orgId, machineId }, + _max: { tsServer: true }, + }), + prisma.machineEvent.aggregate({ + where: { orgId: session.orgId, machineId, ts: { gte: eventsWindowStart } }, + _max: { tsServer: true }, + }), + prisma.machineCycle.aggregate({ + where: { orgId: session.orgId, machineId }, + _max: { ts: true }, + }), + prisma.orgSettings.findUnique({ + where: { orgId: session.orgId }, + select: { updatedAt: true, stoppageMultiplier: true, macroStoppageMultiplier: true }, + }), + ]); + + const toMs = (value?: Date | null) => (value ? value.getTime() : 0); + const lastModifiedMs = Math.max( + toMs(machineBase.updatedAt), + toMs(heartbeatAgg._max.tsServer), + toMs(kpiAgg._max.tsServer), + toMs(eventAgg._max.tsServer), + toMs(cycleAgg._max.ts), + toMs(orgSettingsAgg?.updatedAt) + ); + + const versionParts = [ + session.orgId, + machineId, + eventsMode, + eventsOnly ? "1" : "0", + eventsWindowSec, + windowSec, + toMs(machineBase.updatedAt), + toMs(heartbeatAgg._max.tsServer), + toMs(kpiAgg._max.tsServer), + toMs(eventAgg._max.tsServer), + toMs(cycleAgg._max.ts), + toMs(orgSettingsAgg?.updatedAt), + ]; + + const etag = `W/"${createHash("sha1").update(versionParts.join("|")).digest("hex")}"`; + const lastModified = new Date(lastModifiedMs || 0).toUTCString(); + const responseHeaders = new Headers({ + "Cache-Control": "private, no-cache, max-age=0, must-revalidate", + ETag: etag, + "Last-Modified": lastModified, + Vary: "Cookie", + }); + + const ifNoneMatch = _req.headers.get("if-none-match"); + if (ifNoneMatch && ifNoneMatch === etag) { + return new NextResponse(null, { status: 304, headers: responseHeaders }); + } + + const ifModifiedSince = _req.headers.get("if-modified-since"); + if (!ifNoneMatch && ifModifiedSince) { + const since = Date.parse(ifModifiedSince); + if (!Number.isNaN(since) && lastModifiedMs <= since) { + return new NextResponse(null, { status: 304, headers: responseHeaders }); + } + } + const machine = await prisma.machine.findFirst({ where: { id: machineId, orgId: session.orgId }, select: { @@ -227,15 +139,10 @@ export async function GET( return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 }); } - const orgSettings = await prisma.orgSettings.findUnique({ - where: { orgId: session.orgId }, - select: { stoppageMultiplier: true, macroStoppageMultiplier: true }, - }); - - const microMultiplier = Number(orgSettings?.stoppageMultiplier ?? 1.5); + const microMultiplier = Number(orgSettingsAgg?.stoppageMultiplier ?? 1.5); const macroMultiplier = Math.max( microMultiplier, - Number(orgSettings?.macroStoppageMultiplier ?? 5) + Number(orgSettingsAgg?.macroStoppageMultiplier ?? 5) ); const rawEvents = await prisma.machineEvent.findMany({ @@ -296,12 +203,14 @@ export async function GET( const eventsCountCritical = allEvents.filter(isCritical).length; if (eventsOnly) { - return NextResponse.json({ ok: true, events, eventsCountAll, eventsCountCritical }); + return NextResponse.json( + { ok: true, events, eventsCountAll, eventsCountCritical }, + { headers: responseHeaders } + ); } // ---- cycles window ---- -const windowSec = Number(url.searchParams.get("windowSec") ?? "3600"); // default 1h const latestKpi = machine.kpiSnapshots[0] ?? null; @@ -380,30 +289,28 @@ const cycles = rawCycles workOrderId: c.workOrderId ?? null, sku: c.sku ?? null, })); - - - - - return NextResponse.json({ - ok: true, - machine: { - id: machine.id, - name: machine.name, - code: machine.code, - location: machine.location, - latestHeartbeat: machine.heartbeats[0] ?? null, - latestKpi: machine.kpiSnapshots[0] ?? null, - effectiveCycleTime, - }, - thresholds: { - stoppageMultiplier: microMultiplier, - macroStoppageMultiplier: macroMultiplier, - }, - activeStoppage, - events, - eventsCountAll, - eventsCountCritical, - cycles, -}); - + return NextResponse.json( + { + ok: true, + machine: { + id: machine.id, + name: machine.name, + code: machine.code, + location: machine.location, + latestHeartbeat: machine.heartbeats[0] ?? null, + latestKpi: machine.kpiSnapshots[0] ?? null, + effectiveCycleTime, + }, + thresholds: { + stoppageMultiplier: microMultiplier, + macroStoppageMultiplier: macroMultiplier, + }, + activeStoppage, + events, + eventsCountAll, + eventsCountCritical, + cycles, + }, + { headers: responseHeaders } + ); } diff --git a/app/api/machines/route.ts b/app/api/machines/route.ts index f4d4cde..44c4e4a 100644 --- a/app/api/machines/route.ts +++ b/app/api/machines/route.ts @@ -139,10 +139,9 @@ export async function POST(req: Request) { }, }); break; - } catch (err: any) { - if (err?.code !== "P2002") { - throw err; - } + } catch (err: unknown) { + const code = typeof err === "object" && err !== null ? (err as { code?: string }).code : undefined; + if (code !== "P2002") throw err; } } diff --git a/app/api/org/members/route.ts b/app/api/org/members/route.ts index 89e06b9..e6399de 100644 --- a/app/api/org/members/route.ts +++ b/app/api/org/members/route.ts @@ -154,8 +154,9 @@ export async function POST(req: Request) { }, }); break; - } catch (err: any) { - if (err?.code !== "P2002") throw err; + } catch (err: unknown) { + const code = typeof err === "object" && err !== null ? (err as { code?: string }).code : undefined; + if (code !== "P2002") throw err; } } @@ -184,9 +185,9 @@ export async function POST(req: Request) { text: content.text, html: content.html, }); - } catch (err: any) { + } catch (err: unknown) { emailSent = false; - emailError = err?.message || "Failed to send invite email"; + emailError = err instanceof Error ? err.message : "Failed to send invite email"; } return NextResponse.json({ ok: true, invite, emailSent, emailError }); diff --git a/app/api/overview/route.ts b/app/api/overview/route.ts new file mode 100644 index 0000000..1e2e528 --- /dev/null +++ b/app/api/overview/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; +import { createHash } from "crypto"; +import { prisma } from "@/lib/prisma"; +import { requireSession } from "@/lib/auth/requireSession"; +import { getOverviewData } from "@/lib/overview/getOverviewData"; + +function toMs(value?: Date | null) { + return value ? value.getTime() : 0; +} + +export async function GET(req: NextRequest) { + const session = await requireSession(); + if (!session) { + return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(req.url); + const eventsMode = url.searchParams.get("events") ?? "critical"; + const eventsWindowSecRaw = Number(url.searchParams.get("eventsWindowSec") ?? "21600"); + const eventsWindowSec = Number.isFinite(eventsWindowSecRaw) ? eventsWindowSecRaw : 21600; + const eventMachinesRaw = Number(url.searchParams.get("eventMachines") ?? "6"); + const eventMachines = Number.isFinite(eventMachinesRaw) ? Math.max(1, eventMachinesRaw) : 6; + const [machineAgg, heartbeatAgg, kpiAgg, eventAgg, orgSettings] = await Promise.all([ + prisma.machine.aggregate({ + where: { orgId: session.orgId }, + _max: { updatedAt: true }, + }), + prisma.machineHeartbeat.aggregate({ + where: { orgId: session.orgId }, + _max: { tsServer: true }, + }), + prisma.machineKpiSnapshot.aggregate({ + where: { orgId: session.orgId }, + _max: { tsServer: true }, + }), + prisma.machineEvent.aggregate({ + where: { orgId: session.orgId }, + _max: { tsServer: true }, + }), + prisma.orgSettings.findUnique({ + where: { orgId: session.orgId }, + select: { updatedAt: true, stoppageMultiplier: true, macroStoppageMultiplier: true }, + }), + ]); + + const lastModifiedMs = Math.max( + toMs(machineAgg._max.updatedAt), + toMs(heartbeatAgg._max.tsServer), + toMs(kpiAgg._max.tsServer), + toMs(eventAgg._max.tsServer), + toMs(orgSettings?.updatedAt) + ); + + const versionParts = [ + session.orgId, + eventsMode, + eventsWindowSec, + eventMachines, + toMs(machineAgg._max.updatedAt), + toMs(heartbeatAgg._max.tsServer), + toMs(kpiAgg._max.tsServer), + toMs(eventAgg._max.tsServer), + toMs(orgSettings?.updatedAt), + ]; + + const etag = `W/"${createHash("sha1").update(versionParts.join("|")).digest("hex")}"`; + const lastModified = new Date(lastModifiedMs || 0).toUTCString(); + const responseHeaders = new Headers({ + "Cache-Control": "private, no-cache, max-age=0, must-revalidate", + ETag: etag, + "Last-Modified": lastModified, + Vary: "Cookie", + }); + + const ifNoneMatch = req.headers.get("if-none-match"); + if (ifNoneMatch && ifNoneMatch === etag) { + return new NextResponse(null, { status: 304, headers: responseHeaders }); + } + + const ifModifiedSince = req.headers.get("if-modified-since"); + if (!ifNoneMatch && ifModifiedSince) { + const since = Date.parse(ifModifiedSince); + if (!Number.isNaN(since) && lastModifiedMs <= since) { + return new NextResponse(null, { status: 304, headers: responseHeaders }); + } + } + + const { machines: machineRows, events } = await getOverviewData({ + orgId: session.orgId, + eventsMode, + eventsWindowSec, + eventMachines, + orgSettings, + }); + + return NextResponse.json( + { ok: true, machines: machineRows, events }, + { headers: responseHeaders } + ); +} diff --git a/app/api/reports/route.ts b/app/api/reports/route.ts index bbb5033..2566c5b 100644 --- a/app/api/reports/route.ts +++ b/app/api/reports/route.ts @@ -165,7 +165,7 @@ export async function GET(req: NextRequest) { for (const e of events) { const type = String(e.eventType ?? "").toLowerCase(); - let blob: any = e.data; + let blob: unknown = e.data; if (typeof blob === "string") { try { @@ -175,7 +175,12 @@ export async function GET(req: NextRequest) { } } - const inner = blob?.data ?? blob ?? {}; + const blobRecord = typeof blob === "object" && blob !== null ? (blob as Record) : null; + const innerCandidate = blobRecord?.data ?? blobRecord ?? {}; + const inner = + typeof innerCandidate === "object" && innerCandidate !== null + ? (innerCandidate as Record) + : {}; const stopSec = (typeof inner?.stoppage_duration_seconds === "number" && inner.stoppage_duration_seconds) || (typeof inner?.stop_duration_seconds === "number" && inner.stop_duration_seconds) || diff --git a/app/api/settings/machines/[machineId]/route.ts b/app/api/settings/machines/[machineId]/route.ts index e884f89..55e3cd3 100644 --- a/app/api/settings/machines/[machineId]/route.ts +++ b/app/api/settings/machines/[machineId]/route.ts @@ -3,6 +3,7 @@ import type { NextRequest } from "next/server"; import { Prisma } from "@prisma/client"; import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; +import { toJsonValue } from "@/lib/prismaJson"; import { DEFAULT_ALERTS, DEFAULT_DEFAULTS, @@ -18,7 +19,7 @@ import { import { publishSettingsUpdate } from "@/lib/mqtt"; import { z } from "zod"; -function isPlainObject(value: any): value is Record { +function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -34,9 +35,9 @@ const machineSettingsSchema = z }) .passthrough(); -function pickAllowedOverrides(raw: any) { +function pickAllowedOverrides(raw: unknown) { if (!isPlainObject(raw)) return {}; - const out: Record = {}; + const out: Record = {}; for (const key of ["shiftSchedule", "thresholds", "alerts", "defaults"]) { if (raw[key] !== undefined) out[key] = raw[key]; } @@ -337,24 +338,26 @@ export async function PUT( select: { overridesJson: true }, }); - let nextOverrides: any = null; + let nextOverrides: Record | null = null; if (patch === null) { nextOverrides = null; } else { const merged = applyOverridePatch(existing?.overridesJson ?? {}, patch); nextOverrides = Object.keys(merged).length ? merged : null; } + const nextOverridesJson = + nextOverrides === null ? Prisma.DbNull : toJsonValue(nextOverrides); const saved = await tx.machineSettings.upsert({ where: { machineId }, update: { - overridesJson: nextOverrides, + overridesJson: nextOverridesJson, updatedBy: session.userId, }, create: { machineId, orgId: session.orgId, - overridesJson: nextOverrides, + overridesJson: nextOverridesJson, updatedBy: session.userId, }, }); diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 44cb7e7..76a96ab 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,5 +1,4 @@ import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; import { Prisma } from "@prisma/client"; import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; @@ -19,7 +18,15 @@ import { import { publishSettingsUpdate } from "@/lib/mqtt"; import { z } from "zod"; -function isPlainObject(value: any): value is Record { +type ValidShift = { + name: string; + startTime: string; + endTime: string; + sortOrder: number; + enabled: boolean; +}; + +function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -191,7 +198,7 @@ export async function PUT(req: Request) { return NextResponse.json({ ok: false, error: defaultsValidation.error }, { status: 400 }); } - let shiftRows: any[] | null = null; + let shiftRows: ValidShift[] | null = null; if (shiftSchedule?.shifts !== undefined) { const shiftResult = validateShiftSchedule(shiftSchedule.shifts); if (!shiftResult.ok) { @@ -291,15 +298,15 @@ export async function PUT(req: Request) { return { settings: refreshed, shifts: refreshedShifts }; }); - if ((updated as any)?.error === "VERSION_MISMATCH") { + if ("error" in updated && updated.error === "VERSION_MISMATCH") { return NextResponse.json( - { ok: false, error: "Version mismatch", currentVersion: (updated as any).currentVersion }, + { ok: false, error: "Version mismatch", currentVersion: updated.currentVersion }, { status: 409 } ); } - if ((updated as any)?.error) { - return NextResponse.json({ ok: false, error: (updated as any).error }, { status: 400 }); + if ("error" in updated) { + return NextResponse.json({ ok: false, error: updated.error }, { status: 400 }); } const payload = buildSettingsPayload(updated.settings, updated.shifts ?? []); diff --git a/app/api/signup/route.ts b/app/api/signup/route.ts index 1525504..432c9d8 100644 --- a/app/api/signup/route.ts +++ b/app/api/signup/route.ts @@ -51,7 +51,7 @@ export async function POST(req: Request) { const verificationToken = randomBytes(24).toString("hex"); const verificationExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - const result = await prisma.$transaction(async (tx) => { + await prisma.$transaction(async (tx) => { const org = await tx.org.create({ data: { name: orgName, slug }, }); @@ -118,14 +118,15 @@ export async function POST(req: Request) { text: emailContent.text, html: emailContent.html, }); - } catch (err: any) { + } catch (err: unknown) { emailSent = false; + const error = err as { message?: string; code?: string; response?: unknown; responseCode?: number }; logLine("signup.verify_email.failed", { email, - message: err?.message, - code: err?.code, - response: err?.response, - responseCode: err?.responseCode, + message: error?.message, + code: error?.code, + response: error?.response, + responseCode: error?.responseCode, }); } return NextResponse.json({ diff --git a/app/api/work-orders/route.ts b/app/api/work-orders/route.ts index df594a5..8216dbc 100644 --- a/app/api/work-orders/route.ts +++ b/app/api/work-orders/route.ts @@ -53,19 +53,25 @@ type WorkOrderInput = { cycleTime?: number | null; }; -function normalizeWorkOrders(raw: any[]) { +function normalizeWorkOrders(raw: unknown[]) { const seen = new Set(); const cleaned: WorkOrderInput[] = []; for (const item of raw) { - const idRaw = cleanText(item?.workOrderId ?? item?.id ?? item?.work_order_id, MAX_WORK_ORDER_ID_LENGTH); + const record = item && typeof item === "object" ? (item as Record) : {}; + const idRaw = cleanText( + record.workOrderId ?? record.id ?? record.work_order_id, + MAX_WORK_ORDER_ID_LENGTH + ); if (!idRaw || !WORK_ORDER_ID_RE.test(idRaw) || seen.has(idRaw)) continue; seen.add(idRaw); - const sku = cleanText(item?.sku ?? item?.SKU ?? null, MAX_SKU_LENGTH); - const targetQtyRaw = toIntOrNull(item?.targetQty ?? item?.target_qty ?? item?.target ?? item?.targetQuantity); + const sku = cleanText(record.sku ?? record.SKU ?? null, MAX_SKU_LENGTH); + const targetQtyRaw = toIntOrNull( + record.targetQty ?? record.target_qty ?? record.target ?? record.targetQuantity + ); const cycleTimeRaw = toFloatOrNull( - item?.cycleTime ?? item?.theoreticalCycleTime ?? item?.theoretical_cycle_time ?? item?.cycle_time + record.cycleTime ?? record.theoreticalCycleTime ?? record.theoretical_cycle_time ?? record.cycle_time ); const targetQty = targetQtyRaw == null ? null : Math.min(Math.max(targetQtyRaw, 0), MAX_TARGET_QTY); diff --git a/app/globals.css b/app/globals.css index 7bdab9c..44fac83 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2,6 +2,8 @@ :root { color-scheme: dark; + --font-geist-sans: "Segoe UI", system-ui, sans-serif; + --font-geist-mono: ui-monospace, SFMono-Regular, Menlo, monospace; --app-bg: #0b0f14; --app-surface: rgba(255, 255, 255, 0.05); --app-surface-2: rgba(255, 255, 255, 0.08); diff --git a/app/invite/[token]/InviteAcceptForm.tsx b/app/invite/[token]/InviteAcceptForm.tsx index d02d5db..6bc9175 100644 --- a/app/invite/[token]/InviteAcceptForm.tsx +++ b/app/invite/[token]/InviteAcceptForm.tsx @@ -51,8 +51,9 @@ export default function InviteAcceptForm({ throw new Error(data.error || t("invite.error.notFound")); } if (alive) setInvite(data.invite); - } catch (err: any) { - if (alive) setError(err?.message || t("invite.error.notFound")); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : null; + if (alive) setError(message || t("invite.error.notFound")); } finally { if (alive) setLoading(false); } @@ -62,7 +63,7 @@ export default function InviteAcceptForm({ return () => { alive = false; }; - }, [cleanedToken, initialInvite, initialError]); + }, [cleanedToken, initialInvite, initialError, t]); async function onSubmit(e: React.FormEvent) { e.preventDefault(); @@ -80,8 +81,9 @@ export default function InviteAcceptForm({ } router.push("/machines"); router.refresh(); - } catch (err: any) { - setError(err?.message || t("invite.error.acceptFailed")); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : null; + setError(message || t("invite.error.acceptFailed")); } finally { setSubmitting(false); } diff --git a/app/layout.tsx b/app/layout.tsx index f2712a4..4da95b7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,11 +1,7 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import { cookies } from "next/headers"; import "./globals.css"; -const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] }); -const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] }); - export const metadata: Metadata = { title: "MIS Control Tower", description: "MaliounTech Industrial Suite", @@ -20,7 +16,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo return ( - + {children} diff --git a/app/login/LoginForm.tsx b/app/login/LoginForm.tsx index 295f0a8..cd1176e 100644 --- a/app/login/LoginForm.tsx +++ b/app/login/LoginForm.tsx @@ -35,8 +35,9 @@ export default function LoginForm() { router.push(next); router.refresh(); - } catch (e: any) { - setErr(e?.message || t("login.error.network")); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : null; + setErr(message || t("login.error.network")); } finally { setLoading(false); } diff --git a/app/signup/SignupForm.tsx b/app/signup/SignupForm.tsx index ac527ac..d42aa83 100644 --- a/app/signup/SignupForm.tsx +++ b/app/signup/SignupForm.tsx @@ -34,8 +34,9 @@ export default function SignupForm() { setVerificationSent(true); setEmailSent(data.emailSent !== false); - } catch (e: any) { - setErr(e?.message || t("signup.error.network")); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : null; + setErr(message || t("signup.error.network")); } finally { setLoading(false); } diff --git a/components/auth/RequireAuth.tsx b/components/auth/RequireAuth.tsx index 7cbeee9..83c9abe 100644 --- a/components/auth/RequireAuth.tsx +++ b/components/auth/RequireAuth.tsx @@ -1,23 +1,32 @@ "use client"; import { usePathname, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useSyncExternalStore } from "react"; + +function subscribe(callback: () => void) { + if (typeof window === "undefined") return () => {}; + window.addEventListener("storage", callback); + return () => window.removeEventListener("storage", callback); +} + +function getSnapshot() { + if (typeof window === "undefined") return null; + return localStorage.getItem("ct_token"); +} export function RequireAuth({ children }: { children: React.ReactNode }) { const router = useRouter(); const pathname = usePathname(); - const [ready, setReady] = useState(false); + const token = useSyncExternalStore(subscribe, getSnapshot, () => null); + const hasToken = Boolean(token); useEffect(() => { - const token = localStorage.getItem("ct_token"); - if (!token) { + if (!hasToken) { router.replace("/login"); - return; } - setReady(true); - }, [router, pathname]); + }, [router, pathname, hasToken]); - if (!ready) { + if (!hasToken) { return (
Loading… diff --git a/components/layout/AppShell.tsx b/components/layout/AppShell.tsx index 173d5de..de2235e 100644 --- a/components/layout/AppShell.tsx +++ b/components/layout/AppShell.tsx @@ -2,20 +2,20 @@ import { useEffect, useState } from "react"; import { Menu } from "lucide-react"; -import { usePathname } from "next/navigation"; import { Sidebar } from "@/components/layout/Sidebar"; import { UtilityControls } from "@/components/layout/UtilityControls"; import { useI18n } from "@/lib/i18n/useI18n"; -export function AppShell({ children }: { children: React.ReactNode }) { +export function AppShell({ + children, + initialTheme, +}: { + children: React.ReactNode; + initialTheme?: "dark" | "light"; +}) { const { t } = useI18n(); - const pathname = usePathname(); const [drawerOpen, setDrawerOpen] = useState(false); - useEffect(() => { - setDrawerOpen(false); - }, [pathname]); - useEffect(() => { if (!drawerOpen) return; const onKey = (event: KeyboardEvent) => { @@ -34,7 +34,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
-
+
- +
{children}
diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx index 194bb0d..0d1dbca 100644 --- a/components/layout/Sidebar.tsx +++ b/components/layout/Sidebar.tsx @@ -2,17 +2,26 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { BarChart3, Bell, LayoutGrid, LogOut, Settings, Wrench, X } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { BarChart3, Bell, DollarSign, LayoutGrid, LogOut, Settings, Wrench, X } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; import { useI18n } from "@/lib/i18n/useI18n"; -const items = [ +type NavItem = { + href: string; + labelKey: string; + icon: LucideIcon; + ownerOnly?: boolean; +}; + +const items: NavItem[] = [ { href: "/overview", labelKey: "nav.overview", icon: LayoutGrid }, { href: "/machines", labelKey: "nav.machines", icon: Wrench }, { href: "/reports", labelKey: "nav.reports", icon: BarChart3 }, { href: "/alerts", labelKey: "nav.alerts", icon: Bell }, + { href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true }, { href: "/settings", labelKey: "nav.settings", icon: Settings }, -] as const; +]; type SidebarProps = { variant?: "desktop" | "drawer"; @@ -57,6 +66,14 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro } const roleKey = (me?.membership?.role || "MEMBER").toLowerCase(); + const isOwner = roleKey === "owner"; + const visibleItems = useMemo(() => items.filter((it) => !it.ownerOnly || isOwner), [isOwner]); + + useEffect(() => { + visibleItems.forEach((it) => { + router.prefetch(it.href); + }); + }, [router, visibleItems]); const shellClass = [ "relative z-20 flex flex-col border-r border-white/10 bg-black/40", variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]", @@ -82,13 +99,14 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro