"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; 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: Shift = { name: "Shift 1", start: "06:00", end: "15:00", enabled: true, }; const DEFAULT_SETTINGS: SettingsPayload = { orgId: "", version: 0, timezone: "UTC", shiftSchedule: { shifts: [DEFAULT_SHIFT], 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): Shift { const name = String(raw?.name || `Shift ${index + 1}`); 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): SettingsPayload { if (!raw || typeof raw !== "object") return { ...DEFAULT_SETTINGS }; 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)) : [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 [draft, setDraft] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [saveStatus, setSaveStatus] = useState(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 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 || `Failed to load settings (${response.status})`; throw new Error(message); } const next = normalizeSettings(data.settings); setDraft(next); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load settings"); } finally { setLoading(false); } }, []); 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 || `Failed to load team (${response.status})`; 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 : "Failed to load team"); } finally { setTeamLoading(false); } }, []); 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: `Shift ${nextIndex}`, start: DEFAULT_SHIFT.start, end: DEFAULT_SHIFT.end, enabled: true, }; return { ...prev, shiftSchedule: { ...prev.shiftSchedule, shifts: [...prev.shiftSchedule.shifts, newShift], }, }; }); }, []); 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("Invite link copied"); } else { setInviteStatus(url); } } catch { setInviteStatus(url); } }, [buildInviteUrl] ); 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 || `Failed to revoke invite (${response.status})`; throw new Error(message); } setInvites((prev) => prev.filter((invite) => invite.id !== inviteId)); } catch (err) { setInviteStatus(err instanceof Error ? err.message : "Failed to revoke invite"); } }, []); const createInvite = useCallback(async () => { if (!inviteEmail.trim()) { setInviteStatus("Email is required"); 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 || `Failed to create invite (${response.status})`; 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(`Invite created, email failed: ${inviteUrl}`); } else { setInviteStatus("Invite email sent"); } } setInviteEmail(""); await loadTeam(); } catch (err) { setInviteStatus(err instanceof Error ? err.message : "Failed to create invite"); } finally { setInviteSubmitting(false); } }, [buildInviteUrl, inviteEmail, inviteRole, loadTeam]); 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("Settings changed elsewhere. Refresh and try again."); } const message = data?.error || data?.message || text || `Failed to save settings (${response.status})`; throw new Error(message); } const next = normalizeSettings(data.settings); setDraft(next); setSaveStatus("Saved"); } catch (err) { setError(err instanceof Error ? err.message : "Failed to save settings"); } finally { setSaving(false); } }, [draft]); const statusLabel = useMemo(() => { if (loading) return "Loading settings..."; if (saving) return "Saving..."; return saveStatus; }, [loading, saving, saveStatus]); if (loading && !draft) { return (
Loading settings...
); } if (!draft) { return (
{error || "Settings are unavailable."}
); } return (

Settings

Live configuration for shifts, alerts, and defaults.

{(error || statusLabel) && (
{error ? error : statusLabel}
)}
Organization
Plant Name
{orgInfo?.name || "Loading..."}
{orgInfo?.slug ? (
Slug: {orgInfo.slug}
) : null}
Updated: {draft.updatedAt ? new Date(draft.updatedAt).toLocaleString() : "-"}
Alert Thresholds
Applies to all machines
Shift Schedule
Max 3 shifts, HH:mm
{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" /> to 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" /> Enabled
))}
Alerts
updateAlerts("oeeDropEnabled", next)} /> updateAlerts("performanceDegradationEnabled", next)} /> updateAlerts("qualitySpikeEnabled", next)} /> updateAlerts("predictiveOeeDeclineEnabled", next)} />
Mold Defaults
Integrations
Webhook URL
https://hooks.example.com/iiot
ERP Sync
Not configured
Team Members
{members.length} total
{teamLoading &&
Loading team...
} {teamError && (
{teamError}
)} {!teamLoading && !teamError && members.length === 0 && (
No team members yet.
)} {!teamLoading && !teamError && members.length > 0 && (
{members.map((member) => (
{member.name || member.email}
{member.email}
{member.role} {!member.isActive ? ( Inactive ) : null}
))}
)}
Invitations
{inviteStatus &&
{inviteStatus}
}
{invites.length === 0 && (
No pending invites.
)} {invites.map((invite) => (
{invite.email}
{invite.role} - Expires {new Date(invite.expiresAt).toLocaleDateString()}
{buildInviteUrl(invite.token)}
))}
); }