diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 04f2a11..a2ade41 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -1,6 +1,149 @@ "use client"; -import { useState } from "react"; +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, @@ -39,17 +182,254 @@ function Toggle({ } export default function SettingsPage() { - const [emailEnabled, setEmailEnabled] = useState(true); - const [smsEnabled, setSmsEnabled] = useState(false); - const [webhookEnabled, setWebhookEnabled] = useState(true); + 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

-

Configure alerts, shifts, and integrations.

+
+
+

Settings

+

Live configuration for shifts, alerts, and defaults.

+
+
+ + +
+ {(error || statusLabel) && ( +
+ {error ? error : statusLabel} +
+ )} +
Organization
@@ -58,13 +438,26 @@ export default function SettingsPage() {
Plant Name
MIS Plant
-
-
Time Zone
-
America/Mexico_City
+ +
+ Updated: {draft.updatedAt ? new Date(draft.updatedAt).toLocaleString() : "-"}
-
@@ -75,25 +468,59 @@ export default function SettingsPage() {
- {[ - { label: "OEE Alert", value: "85%", helper: "Trigger when OEE drops below this" }, - { label: "Availability Alert", value: "85%", helper: "Low run time detection" }, - { label: "Performance Alert", value: "85%", helper: "Slow cycle detection" }, - { label: "Quality Alert", value: "95%", helper: "Scrap spike detection" }, - { label: "Microstop (sec)", value: "60s", helper: "Stop longer than this" }, - { label: "Macrostop (sec)", value: "300s", helper: "Major stop threshold" }, - ].map((row) => ( -
-
{row.label}
-
-
{row.value}
- -
-
{row.helper}
-
- ))} + + + +
@@ -102,50 +529,119 @@ export default function SettingsPage() {
Shift Schedule
-
Used for Availability calculations
+
Max 3 shifts, HH:mm
-
- {[ - { label: "Shift A", time: "06:00 - 14:00", days: "Mon - Fri" }, - { label: "Shift B", time: "14:00 - 22:00", days: "Mon - Fri" }, - { label: "Shift C", time: "22:00 - 06:00", days: "Mon - Fri" }, - ].map((shift) => ( -
-
{shift.label}
-
{shift.time}
-
{shift.days}
+
+ {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 +
))}
-
- +
+ + +
-
Notification Channels
+
Alerts
updateAlerts("oeeDropEnabled", next)} /> updateAlerts("performanceDegradationEnabled", next)} /> updateAlerts("qualitySpikeEnabled", next)} + /> + updateAlerts("predictiveOeeDeclineEnabled", next)} />
@@ -153,10 +649,33 @@ export default function SettingsPage() {
-
-
Integrations
-
Live endpoints
+
Mold Defaults
+
+ +
+
+ +
+
Integrations
Webhook URL
@@ -168,32 +687,6 @@ export default function SettingsPage() {
- -
-
Users & Roles
-
- {[ - { name: "Juan Perez", role: "Plant Manager" }, - { name: "Sandra Rivera", role: "Supervisor" }, - { name: "Maintenance", role: "Technician" }, - ].map((user) => ( -
-
-
{user.name}
-
{user.role}
-
- -
- ))} -
-
- -
-
); diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index d88f8a0..375587b 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; import { Prisma } from "@prisma/client"; import { prisma } from "@/lib/prisma"; import { requireSession } from "@/lib/auth/requireSession"; @@ -87,84 +88,90 @@ export async function GET() { const session = await requireSession(); if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); - const loaded = await prisma.$transaction(async (tx) => { - const found = await ensureOrgSettings(tx, session.orgId, session.userId); - if (!found?.settings) throw new Error("SETTINGS_NOT_FOUND"); - return found; - }); + try { + const loaded = await prisma.$transaction(async (tx) => { + const found = await ensureOrgSettings(tx, session.orgId, session.userId); + if (!found?.settings) throw new Error("SETTINGS_NOT_FOUND"); + return found; + }); - const payload = buildSettingsPayload(loaded.settings, loaded.shifts ?? []); - return NextResponse.json({ ok: true, settings: payload }); + const payload = buildSettingsPayload(loaded.settings, loaded.shifts ?? []); + return NextResponse.json({ ok: true, settings: payload }); + } catch (err) { + console.error("[settings GET] failed", err); + const message = err instanceof Error ? err.message : "Internal error"; + return NextResponse.json({ ok: false, error: message }, { status: 500 }); + } } export async function PUT(req: Request) { const session = await requireSession(); if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); - const body = await req.json().catch(() => ({})); - const source = String(body.source ?? "control_tower"); - const timezone = body.timezone; - const shiftSchedule = body.shiftSchedule; - const thresholds = body.thresholds; - const alerts = body.alerts; - const defaults = body.defaults; - const expectedVersion = body.version; + try { + const body = await req.json().catch(() => ({})); + const source = String(body.source ?? "control_tower"); + const timezone = body.timezone; + const shiftSchedule = body.shiftSchedule; + const thresholds = body.thresholds; + const alerts = body.alerts; + const defaults = body.defaults; + const expectedVersion = body.version; - if ( - timezone === undefined && - shiftSchedule === undefined && - thresholds === undefined && - alerts === undefined && - defaults === undefined - ) { - return NextResponse.json({ ok: false, error: "No settings provided" }, { status: 400 }); - } - - if (shiftSchedule && !isPlainObject(shiftSchedule)) { - return NextResponse.json({ ok: false, error: "shiftSchedule must be an object" }, { status: 400 }); - } - if (thresholds !== undefined && !isPlainObject(thresholds)) { - return NextResponse.json({ ok: false, error: "thresholds must be an object" }, { status: 400 }); - } - if (alerts !== undefined && !isPlainObject(alerts)) { - return NextResponse.json({ ok: false, error: "alerts must be an object" }, { status: 400 }); - } - if (defaults !== undefined && !isPlainObject(defaults)) { - return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 }); - } - - const shiftValidation = validateShiftFields( - shiftSchedule?.shiftChangeCompensationMin, - shiftSchedule?.lunchBreakMin - ); - if (!shiftValidation.ok) { - return NextResponse.json({ ok: false, error: shiftValidation.error }, { status: 400 }); - } - - const thresholdsValidation = validateThresholds(thresholds); - if (!thresholdsValidation.ok) { - return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 }); - } - - const defaultsValidation = validateDefaults(defaults); - if (!defaultsValidation.ok) { - return NextResponse.json({ ok: false, error: defaultsValidation.error }, { status: 400 }); - } - - let shiftRows: any[] = []; - let hasShiftUpdate = false; - if (shiftSchedule?.shifts !== undefined) { - const shiftResult = validateShiftSchedule(shiftSchedule.shifts); - if (!shiftResult.ok) { - return NextResponse.json({ ok: false, error: shiftResult.error }, { status: 400 }); + if ( + timezone === undefined && + shiftSchedule === undefined && + thresholds === undefined && + alerts === undefined && + defaults === undefined + ) { + return NextResponse.json({ ok: false, error: "No settings provided" }, { status: 400 }); } - shiftRows = shiftResult.shifts ?? []; - hasShiftUpdate = true; - } - const updated = await prisma.$transaction(async (tx) => { - const current = await ensureOrgSettings(tx, session.orgId, session.userId); - if (!current?.settings) throw new Error("SETTINGS_NOT_FOUND"); + if (shiftSchedule && !isPlainObject(shiftSchedule)) { + return NextResponse.json({ ok: false, error: "shiftSchedule must be an object" }, { status: 400 }); + } + if (thresholds !== undefined && !isPlainObject(thresholds)) { + return NextResponse.json({ ok: false, error: "thresholds must be an object" }, { status: 400 }); + } + if (alerts !== undefined && !isPlainObject(alerts)) { + return NextResponse.json({ ok: false, error: "alerts must be an object" }, { status: 400 }); + } + if (defaults !== undefined && !isPlainObject(defaults)) { + return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 }); + } + + const shiftValidation = validateShiftFields( + shiftSchedule?.shiftChangeCompensationMin, + shiftSchedule?.lunchBreakMin + ); + if (!shiftValidation.ok) { + return NextResponse.json({ ok: false, error: shiftValidation.error }, { status: 400 }); + } + + const thresholdsValidation = validateThresholds(thresholds); + if (!thresholdsValidation.ok) { + return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 }); + } + + const defaultsValidation = validateDefaults(defaults); + if (!defaultsValidation.ok) { + return NextResponse.json({ ok: false, error: defaultsValidation.error }, { status: 400 }); + } + + let shiftRows: any[] | null = null; + if (shiftSchedule?.shifts !== undefined) { + const shiftResult = validateShiftSchedule(shiftSchedule.shifts); + if (!shiftResult.ok) { + return NextResponse.json({ ok: false, error: shiftResult.error }, { status: 400 }); + } + shiftRows = shiftResult.shifts ?? []; + } + const shiftRowsSafe = shiftRows ?? []; + + const updated = await prisma.$transaction(async (tx) => { + const current = await ensureOrgSettings(tx, session.orgId, session.userId); + if (!current?.settings) throw new Error("SETTINGS_NOT_FOUND"); if (expectedVersion != null && Number(expectedVersion) !== Number(current.settings.version)) { return { error: "VERSION_MISMATCH", currentVersion: current.settings.version } as const; @@ -197,6 +204,7 @@ export async function PUT(req: Request) { defaultsJson: nextDefaults, }); + const hasShiftUpdate = shiftRows !== null; const hasSettingsUpdate = Object.keys(updateData).length > 0; if (!hasShiftUpdate && !hasSettingsUpdate) { @@ -216,9 +224,9 @@ export async function PUT(req: Request) { if (hasShiftUpdate) { await tx.orgShift.deleteMany({ where: { orgId: session.orgId } }); - if (shiftRows.length) { + if (shiftRowsSafe.length) { await tx.orgShift.createMany({ - data: shiftRows.map((s) => ({ + data: shiftRowsSafe.map((s) => ({ ...s, orgId: session.orgId, })), @@ -244,20 +252,25 @@ export async function PUT(req: Request) { }, }); - return { settings: refreshed, shifts: refreshedShifts }; - }); + return { settings: refreshed, shifts: refreshedShifts }; + }); - if ((updated as any)?.error === "VERSION_MISMATCH") { - return NextResponse.json( - { ok: false, error: "Version mismatch", currentVersion: (updated as any).currentVersion }, - { status: 409 } - ); + if ((updated as any)?.error === "VERSION_MISMATCH") { + return NextResponse.json( + { ok: false, error: "Version mismatch", currentVersion: (updated as any).currentVersion }, + { status: 409 } + ); + } + + if ((updated as any)?.error) { + return NextResponse.json({ ok: false, error: (updated as any).error }, { status: 400 }); + } + + const payload = buildSettingsPayload(updated.settings, updated.shifts ?? []); + return NextResponse.json({ ok: true, settings: payload }); + } catch (err) { + console.error("[settings PUT] failed", err); + const message = err instanceof Error ? err.message : "Internal error"; + return NextResponse.json({ ok: false, error: message }, { status: 500 }); } - - if ((updated as any)?.error) { - return NextResponse.json({ ok: false, error: (updated as any).error }, { status: 400 }); - } - - const payload = buildSettingsPayload(updated.settings, updated.shifts ?? []); - return NextResponse.json({ ok: true, settings: payload }); } diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml deleted file mode 100644 index 044d57c..0000000 --- a/prisma/migrations/migration_lock.toml +++ /dev/null @@ -1,3 +0,0 @@ -# Please do not edit this file manually -# It should be added in your version-control system (e.g., Git) -provider = "postgresql"