"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; }; 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 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); } }, []); useEffect(() => { loadSettings(); }, [loadSettings]); 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 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 MIS Plant 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 ); }
Live configuration for shifts, alerts, and defaults.