"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"; import { useScreenlessMode } from "@/lib/ui/screenlessMode"; type Shift = { name: string; start: string; end: string; enabled: boolean; }; type SettingsPayload = { orgId?: string; version?: number; timezone?: string; modules: { screenlessMode: boolean; }; shiftSchedule: { shifts: Shift[]; shiftChangeCompensationMin: number; lunchBreakMin: number; }; thresholds: { stoppageMultiplier: number; macroStoppageMultiplier: number; oeeAlertThresholdPct: number; performanceThresholdPct: number; qualitySpikeDeltaPct: number; }; alerts: { oeeDropEnabled: boolean; performanceDegradationEnabled: boolean; qualitySpikeEnabled: boolean; predictiveOeeDeclineEnabled: boolean; }; defaults: { moldTotal: number; moldActive: number; }; updatedAt?: string; updatedBy?: string; }; type OrgInfo = { id: string; name: string; slug: string; }; type MemberRow = { id: string; membershipId: string; name?: string | null; email: string; role: string; isActive: boolean; joinedAt: string; }; type InviteRow = { id: string; email: string; role: string; token: string; createdAt: string; expiresAt: string; }; const DEFAULT_SHIFT: Omit = { start: "06:00", end: "15:00", enabled: true, }; const DEFAULT_SETTINGS: SettingsPayload = { orgId: "", version: 0, timezone: "UTC", modules: { screenlessMode: false }, shiftSchedule: { shifts: [], shiftChangeCompensationMin: 10, lunchBreakMin: 30, }, thresholds: { stoppageMultiplier: 1.5, oeeAlertThresholdPct: 90, macroStoppageMultiplier: 5, performanceThresholdPct: 85, qualitySpikeDeltaPct: 5, }, alerts: { oeeDropEnabled: true, performanceDegradationEnabled: true, qualitySpikeEnabled: true, predictiveOeeDeclineEnabled: true, }, defaults: { moldTotal: 1, moldActive: 1, }, updatedAt: "", updatedBy: "", }; const SETTINGS_TABS = [ { id: "general", labelKey: "settings.tabs.general" }, { id: "modules", labelKey: "settings.tabs.modules" }, { 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, text: "" }; } try { return { data: JSON.parse(text) as T, text }; } catch { return { data: null, text }; } } 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: unknown, fallbackName: (index: number) => string): SettingsPayload { const record = asRecord(raw); const modules = asRecord(record?.modules) ?? {}; if (!record) { return { ...DEFAULT_SETTINGS, shiftSchedule: { ...DEFAULT_SETTINGS.shiftSchedule, shifts: [{ name: fallbackName(1), ...DEFAULT_SHIFT }], }, }; } const shiftSchedule = asRecord(record.shiftSchedule) ?? {}; const shiftsRaw = Array.isArray(shiftSchedule.shifts) ? shiftSchedule.shifts : []; const shifts = shiftsRaw.length ? 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(record.orgId ?? ""), version: Number(record.version ?? 0), timezone: String(record.timezone ?? DEFAULT_SETTINGS.timezone), shiftSchedule: { shifts, shiftChangeCompensationMin: Number( shiftSchedule.shiftChangeCompensationMin ?? DEFAULT_SETTINGS.shiftSchedule.shiftChangeCompensationMin ), lunchBreakMin: Number(shiftSchedule.lunchBreakMin ?? DEFAULT_SETTINGS.shiftSchedule.lunchBreakMin), }, thresholds: { stoppageMultiplier: Number( thresholds.stoppageMultiplier ?? DEFAULT_SETTINGS.thresholds.stoppageMultiplier ), macroStoppageMultiplier: Number( thresholds.macroStoppageMultiplier ?? DEFAULT_SETTINGS.thresholds.macroStoppageMultiplier ), oeeAlertThresholdPct: Number( thresholds.oeeAlertThresholdPct ?? DEFAULT_SETTINGS.thresholds.oeeAlertThresholdPct ), performanceThresholdPct: Number( thresholds.performanceThresholdPct ?? DEFAULT_SETTINGS.thresholds.performanceThresholdPct ), qualitySpikeDeltaPct: Number( thresholds.qualitySpikeDeltaPct ?? DEFAULT_SETTINGS.thresholds.qualitySpikeDeltaPct ), }, alerts: { oeeDropEnabled: (alerts.oeeDropEnabled as boolean | undefined) ?? DEFAULT_SETTINGS.alerts.oeeDropEnabled, performanceDegradationEnabled: (alerts.performanceDegradationEnabled as boolean | undefined) ?? DEFAULT_SETTINGS.alerts.performanceDegradationEnabled, qualitySpikeEnabled: (alerts.qualitySpikeEnabled as boolean | undefined) ?? DEFAULT_SETTINGS.alerts.qualitySpikeEnabled, predictiveOeeDeclineEnabled: (alerts.predictiveOeeDeclineEnabled as boolean | undefined) ?? DEFAULT_SETTINGS.alerts.predictiveOeeDeclineEnabled, }, defaults: { moldTotal: Number(defaults.moldTotal ?? DEFAULT_SETTINGS.defaults.moldTotal), moldActive: Number(defaults.moldActive ?? DEFAULT_SETTINGS.defaults.moldActive), }, modules: { screenlessMode: (modules.screenlessMode as boolean | undefined) ?? false, }, updatedAt: record.updatedAt ? String(record.updatedAt) : "", updatedBy: record.updatedBy ? String(record.updatedBy) : "", }; } function Toggle({ label, helper, enabled, onChange, }: { label: string; helper: string; enabled: boolean; onChange: (next: boolean) => void; }) { return ( ); } export default function SettingsPage() { const { t, locale } = useI18n(); const { setScreenlessMode } = useScreenlessMode(); const [draft, setDraft] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [saveStatus, setSaveStatus] = useState<"saved" | null>(null); const [orgInfo, setOrgInfo] = useState(null); const [members, setMembers] = useState([]); const [invites, setInvites] = useState([]); const [teamLoading, setTeamLoading] = useState(true); const [teamError, setTeamError] = useState(null); const [inviteEmail, setInviteEmail] = useState(""); 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] ); const loadSettings = useCallback(async () => { setLoading(true); setError(null); try { const response = await fetch("/api/settings", { cache: "no-store" }); const { data, text } = await readResponse(response); const api = unwrapApiResponse(data); if (!response.ok || !api.ok) { const message = api.error || text || t("settings.failedLoad"); throw new Error(message); } const next = normalizeSettings(api.record?.settings, defaultShiftName); setDraft(next); setScreenlessMode(next.modules.screenlessMode); } catch (err) { setError(err instanceof Error ? err.message : t("settings.failedLoad")); } finally { setLoading(false); } }, [defaultShiftName, t]); const buildInviteUrl = useCallback((token: string) => { if (typeof window === "undefined") return `/invite/${token}`; return `${window.location.origin}/invite/${token}`; }, []); const loadTeam = useCallback(async () => { setTeamLoading(true); setTeamError(null); try { const response = await fetch("/api/org/members", { cache: "no-store" }); const { data, text } = await readResponse(response); const api = unwrapApiResponse(data); if (!response.ok || !api.ok) { const message = api.error || text || t("settings.failedTeam"); throw new Error(message); } 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 { setTeamLoading(false); } }, [t]); useEffect(() => { loadSettings(); loadTeam(); }, [loadSettings, loadTeam]); const updateShift = useCallback((index: number, patch: Partial) => { setDraft((prev) => { if (!prev) return prev; const shifts = prev.shiftSchedule.shifts.map((shift, idx) => idx === index ? { ...shift, ...patch } : shift ); return { ...prev, shiftSchedule: { ...prev.shiftSchedule, shifts, }, }; }); }, []); const addShift = useCallback(() => { setDraft((prev) => { if (!prev) return prev; if (prev.shiftSchedule.shifts.length >= 3) return prev; const nextIndex = prev.shiftSchedule.shifts.length + 1; const newShift: Shift = { name: defaultShiftName(nextIndex), ...DEFAULT_SHIFT, }; return { ...prev, shiftSchedule: { ...prev.shiftSchedule, shifts: [...prev.shiftSchedule.shifts, newShift], }, }; }); }, [defaultShiftName]); const removeShift = useCallback((index: number) => { setDraft((prev) => { if (!prev) return prev; if (prev.shiftSchedule.shifts.length <= 1) return prev; const shifts = prev.shiftSchedule.shifts.filter((_, idx) => idx !== index); return { ...prev, shiftSchedule: { ...prev.shiftSchedule, shifts, }, }; }); }, []); const updateShiftField = useCallback((key: "shiftChangeCompensationMin" | "lunchBreakMin", value: number) => { setDraft((prev) => { if (!prev) return prev; return { ...prev, shiftSchedule: { ...prev.shiftSchedule, [key]: value, }, }; }); }, []); const updateThreshold = useCallback( ( key: | "stoppageMultiplier" | "macroStoppageMultiplier" | "oeeAlertThresholdPct" | "performanceThresholdPct" | "qualitySpikeDeltaPct", value: number ) => { setDraft((prev) => { if (!prev) return prev; return { ...prev, thresholds: { ...prev.thresholds, [key]: value, }, }; }); }, [] ); const updateDefaults = useCallback((key: "moldTotal" | "moldActive", value: number) => { setDraft((prev) => { if (!prev) return prev; return { ...prev, defaults: { ...prev.defaults, [key]: value, }, }; }); }, []); const updateAlerts = useCallback( ( key: | "oeeDropEnabled" | "performanceDegradationEnabled" | "qualitySpikeEnabled" | "predictiveOeeDeclineEnabled", value: boolean ) => { setDraft((prev) => { if (!prev) return prev; return { ...prev, alerts: { ...prev.alerts, [key]: value, }, }; }); }, [] ); const copyInviteLink = useCallback( async (token: string) => { const url = buildInviteUrl(token); try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(url); setInviteStatus(t("settings.inviteStatus.copied")); } else { setInviteStatus(url); } } catch { setInviteStatus(url); } }, [buildInviteUrl, t] ); const revokeInvite = useCallback(async (inviteId: string) => { setInviteStatus(null); try { const response = await fetch(`/api/org/invites/${inviteId}`, { method: "DELETE" }); const { data, text } = await readResponse(response); 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)); } catch (err) { setInviteStatus(err instanceof Error ? err.message : t("settings.inviteStatus.failed")); } }, [t]); const createInvite = useCallback(async () => { if (!inviteEmail.trim()) { setInviteStatus(t("settings.inviteStatus.emailRequired")); return; } setInviteSubmitting(true); setInviteStatus(null); try { const response = await fetch("/api/org/members", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: inviteEmail, role: inviteRole }), }); const { data, text } = await readResponse(response); const api = unwrapApiResponse(data); if (!response.ok || !api.ok) { const message = api.error || text || t("settings.inviteStatus.createFailed"); throw new Error(message); } const nextInvite = api.record?.invite; if (isInviteRow(nextInvite)) { setInvites((prev) => [nextInvite, ...prev.filter((invite) => invite.id !== nextInvite.id)]); const inviteUrl = buildInviteUrl(nextInvite.token); if (api.record?.emailSent === false) { setInviteStatus(t("settings.inviteStatus.emailFailed", { url: inviteUrl })); } else { setInviteStatus(t("settings.inviteStatus.sent")); } } setInviteEmail(""); await loadTeam(); } catch (err) { setInviteStatus(err instanceof Error ? err.message : t("settings.inviteStatus.createFailed")); } finally { setInviteSubmitting(false); } }, [buildInviteUrl, inviteEmail, inviteRole, loadTeam, t]); const saveSettings = useCallback(async () => { if (!draft) return; setSaving(true); setSaveStatus(null); setError(null); try { const response = await fetch("/api/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source: "control_tower", version: draft.version, timezone: draft.timezone, modules: draft.modules, shiftSchedule: draft.shiftSchedule, thresholds: draft.thresholds, alerts: draft.alerts, defaults: draft.defaults, }), }); const { data, text } = await readResponse(response); const api = unwrapApiResponse(data); if (!response.ok || !api.ok) { if (response.status === 409) { throw new Error(t("settings.conflict")); } const message = api.error || text || t("settings.failedSave"); throw new Error(message); } const next = normalizeSettings(api.record?.settings, defaultShiftName); setDraft(next); setScreenlessMode(next.modules.screenlessMode); setSaveStatus("saved"); } catch (err) { setError(err instanceof Error ? err.message : t("settings.failedSave")); } finally { setSaving(false); } }, [defaultShiftName, draft, t]); const statusLabel = useMemo(() => { if (loading) return t("settings.loading"); if (saving) return t("settings.saving"); if (saveStatus === "saved") return t("settings.saved"); return null; }, [loading, saving, saveStatus, t]); const formatRole = useCallback( (role?: string | null) => { if (!role) return ""; const key = `settings.role.${role.toLowerCase()}`; const label = t(key); return label === key ? role : label; }, [t] ); if (loading && !draft) { return (
{t("settings.loading")}
); } if (!draft) { return (
{error || t("settings.unavailable")}
); } return (

{t("settings.title")}

{t("settings.subtitle")}

{(error || statusLabel) && (
{error ? error : statusLabel}
)}
{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.defaults")}
{t("settings.integrations")}
{t("settings.integrations.webhook")}
https://hooks.example.com/iiot
{t("settings.integrations.erp")}
{t("settings.integrations.erpNotConfigured")}
)} {activeTab === "modules" && (
{t("settings.modules.title")}
{t("settings.modules.subtitle")}
setDraft((prev) => prev ? { ...prev, modules: { ...prev.modules, screenlessMode: next }, } : prev ) } />
Org-wide setting. Hides Downtime from navigation for all users in this org.
)} {activeTab === "thresholds" && (
{t("settings.thresholds")}
{t("settings.thresholds.appliesAll")}
)} {activeTab === "shifts" && (
{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")}
))}
)} {activeTab === "alerts" && (
{t("settings.alerts")}
updateAlerts("oeeDropEnabled", next)} /> updateAlerts("performanceDegradationEnabled", next)} /> updateAlerts("qualitySpikeEnabled", next)} /> updateAlerts("predictiveOeeDeclineEnabled", next)} />
)} {activeTab === "financial" && (
)} {activeTab === "team" && (
{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}
{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)}
))}
)}
); }