"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 ( onChange(!enabled)} className="flex w-full items-center justify-between gap-4 rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-left hover:bg-white/5" > {label} {helper} ); } 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. Refresh Save Changes {(error || statusLabel) && ( {error ? error : statusLabel} )} Organization Plant Name {orgInfo?.name || "Loading..."} {orgInfo?.slug ? ( Slug: {orgInfo.slug} ) : null} Time Zone setDraft((prev) => prev ? { ...prev, timezone: event.target.value, } : prev ) } className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white" /> Updated: {draft.updatedAt ? new Date(draft.updatedAt).toLocaleString() : "-"} Alert Thresholds Applies to all machines OEE Alert (%) updateThreshold("oeeAlertThresholdPct", Number(event.target.value)) } className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white" /> Stoppage Multiplier updateThreshold("stoppageMultiplier", Number(event.target.value)) } className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white" /> Performance Alert (%) updateThreshold("performanceThresholdPct", Number(event.target.value)) } className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white" /> Quality Spike Delta (%) updateThreshold("qualitySpikeDeltaPct", Number(event.target.value)) } className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white" /> 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" /> removeShift(index)} disabled={draft.shiftSchedule.shifts.length <= 1} className="ml-3 rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white disabled:opacity-40" > Remove 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 ))} = 3} className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white disabled:opacity-40" > Add Shift Shift Change Compensation (min) updateShiftField("shiftChangeCompensationMin", Number(event.target.value)) } className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white" /> Lunch Break (min) updateShiftField("lunchBreakMin", Number(event.target.value))} className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white" /> Alerts updateAlerts("oeeDropEnabled", next)} /> updateAlerts("performanceDegradationEnabled", next)} /> updateAlerts("qualitySpikeEnabled", next)} /> updateAlerts("predictiveOeeDeclineEnabled", next)} /> Mold Defaults Mold Total updateDefaults("moldTotal", Number(event.target.value))} className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white" /> Mold Active updateDefaults("moldActive", Number(event.target.value))} className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white" /> 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 Invite Email setInviteEmail(event.target.value)} className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white" /> Role setInviteRole(event.target.value)} className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white" > Member Admin Owner {inviteSubmitting ? "Creating..." : "Create Invite"} Refresh {inviteStatus && {inviteStatus}} {invites.length === 0 && ( No pending invites. )} {invites.map((invite) => ( {invite.email} {invite.role} - Expires {new Date(invite.expiresAt).toLocaleDateString()} copyInviteLink(invite.token)} className="rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs text-white hover:bg-white/10" > Copy Link revokeInvite(invite.id)} className="rounded-lg border border-red-500/30 bg-red-500/10 px-2 py-1 text-xs text-red-200 hover:bg-red-500/20" > Revoke {buildInviteUrl(invite.token)} ))} ); }
Live configuration for shifts, alerts, and defaults.