"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useI18n } from "@/lib/i18n/useI18n"; type Shift = { name: string; start: string; end: string; enabled: boolean; }; type SettingsPayload = { orgId?: string; version?: number; timezone?: string; shiftSchedule: { shifts: Shift[]; shiftChangeCompensationMin: number; lunchBreakMin: number; }; thresholds: { stoppageMultiplier: 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", shiftSchedule: { shifts: [], shiftChangeCompensationMin: 10, lunchBreakMin: 30, }, thresholds: { stoppageMultiplier: 1.5, oeeAlertThresholdPct: 90, performanceThresholdPct: 85, qualitySpikeDeltaPct: 5, }, alerts: { oeeDropEnabled: true, performanceDegradationEnabled: true, qualitySpikeEnabled: true, predictiveOeeDeclineEnabled: true, }, defaults: { moldTotal: 1, moldActive: 1, }, updatedAt: "", updatedBy: "", }; async function readResponse(response: Response) { const text = await response.text(); if (!text) { return { data: null as any, text: "" }; } try { return { data: JSON.parse(text), text }; } catch { return { data: null as any, 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; return { name, start, end, enabled }; } function normalizeSettings(raw: any, fallbackName: (index: number) => string): SettingsPayload { if (!raw || typeof raw !== "object") { return { ...DEFAULT_SETTINGS, shiftSchedule: { ...DEFAULT_SETTINGS.shiftSchedule, shifts: [{ name: fallbackName(1), ...DEFAULT_SHIFT }], }, }; } const shiftSchedule = raw.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))) : [{ name: fallbackName(1), ...DEFAULT_SHIFT }]; return { orgId: String(raw.orgId || ""), version: Number(raw.version || 0), timezone: String(raw.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( raw.thresholds?.stoppageMultiplier ?? DEFAULT_SETTINGS.thresholds.stoppageMultiplier ), oeeAlertThresholdPct: Number( raw.thresholds?.oeeAlertThresholdPct ?? DEFAULT_SETTINGS.thresholds.oeeAlertThresholdPct ), performanceThresholdPct: Number( raw.thresholds?.performanceThresholdPct ?? DEFAULT_SETTINGS.thresholds.performanceThresholdPct ), qualitySpikeDeltaPct: Number( raw.thresholds?.qualitySpikeDeltaPct ?? DEFAULT_SETTINGS.thresholds.qualitySpikeDeltaPct ), }, alerts: { oeeDropEnabled: raw.alerts?.oeeDropEnabled ?? DEFAULT_SETTINGS.alerts.oeeDropEnabled, performanceDegradationEnabled: raw.alerts?.performanceDegradationEnabled ?? DEFAULT_SETTINGS.alerts.performanceDegradationEnabled, qualitySpikeEnabled: raw.alerts?.qualitySpikeEnabled ?? DEFAULT_SETTINGS.alerts.qualitySpikeEnabled, predictiveOeeDeclineEnabled: raw.alerts?.predictiveOeeDeclineEnabled ?? DEFAULT_SETTINGS.alerts.predictiveOeeDeclineEnabled, }, defaults: { moldTotal: Number(raw.defaults?.moldTotal ?? DEFAULT_SETTINGS.defaults.moldTotal), moldActive: Number(raw.defaults?.moldActive ?? DEFAULT_SETTINGS.defaults.moldActive), }, updatedAt: raw.updatedAt ? String(raw.updatedAt) : "", updatedBy: raw.updatedBy ? String(raw.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 [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 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); if (!response.ok || !data?.ok) { const message = data?.error || data?.message || text || t("settings.failedLoad"); throw new Error(message); } const next = normalizeSettings(data.settings, defaultShiftName); setDraft(next); } 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); if (!response.ok || !data?.ok) { const message = data?.error || data?.message || 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 : []); } 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" | "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); if (!response.ok || !data?.ok) { const message = data?.error || data?.message || 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); if (!response.ok || !data?.ok) { const message = data?.error || data?.message || text || t("settings.inviteStatus.createFailed"); throw new Error(message); } const nextInvite = data.invite; if (nextInvite) { setInvites((prev) => [nextInvite, ...prev.filter((invite) => invite.id !== nextInvite.id)]); const inviteUrl = buildInviteUrl(nextInvite.token); if (data.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, shiftSchedule: draft.shiftSchedule, thresholds: draft.thresholds, alerts: draft.alerts, defaults: draft.defaults, }), }); const { data, text } = await readResponse(response); if (!response.ok || !data?.ok) { if (response.status === 409) { throw new Error(t("settings.conflict")); } const message = data?.error || data?.message || text || t("settings.failedSave"); throw new Error(message); } const next = normalizeSettings(data.settings, defaultShiftName); setDraft(next); 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}
)}
{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.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
{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}
{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)}
))}
); }