All pages working, pending enrollment
This commit is contained in:
@@ -1,6 +1,149 @@
|
|||||||
"use client";
|
"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({
|
function Toggle({
|
||||||
label,
|
label,
|
||||||
@@ -39,16 +182,253 @@ function Toggle({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [emailEnabled, setEmailEnabled] = useState(true);
|
const [draft, setDraft] = useState<SettingsPayload | null>(null);
|
||||||
const [smsEnabled, setSmsEnabled] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
const [webhookEnabled, setWebhookEnabled] = useState(true);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saveStatus, setSaveStatus] = useState<string | null>(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<Shift>) => {
|
||||||
|
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 (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-6 text-sm text-zinc-300">
|
||||||
|
Loading settings...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!draft) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="rounded-2xl border border-red-500/30 bg-red-500/10 p-6 text-sm text-red-200">
|
||||||
|
{error || "Settings are unavailable."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-white">Settings</h1>
|
<h1 className="text-2xl font-semibold text-white">Settings</h1>
|
||||||
<p className="text-sm text-zinc-400">Configure alerts, shifts, and integrations.</p>
|
<p className="text-sm text-zinc-400">Live configuration for shifts, alerts, and defaults.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={loadSettings}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={saveSettings}
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded-xl border border-emerald-400/40 bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 hover:bg-emerald-500/30 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(error || statusLabel) && (
|
||||||
|
<div className="mb-6 rounded-2xl border border-white/10 bg-white/5 p-4 text-sm text-zinc-300">
|
||||||
|
{error ? error : statusLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-1">
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-1">
|
||||||
@@ -58,13 +438,26 @@ export default function SettingsPage() {
|
|||||||
<div className="text-xs text-zinc-400">Plant Name</div>
|
<div className="text-xs text-zinc-400">Plant Name</div>
|
||||||
<div className="mt-1 text-sm text-zinc-300">MIS Plant</div>
|
<div className="mt-1 text-sm text-zinc-300">MIS Plant</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
<label className="block rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||||
<div className="text-xs text-zinc-400">Time Zone</div>
|
Time Zone
|
||||||
<div className="mt-1 text-sm text-zinc-300">America/Mexico_City</div>
|
<input
|
||||||
|
value={draft.timezone || ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
Updated: {draft.updatedAt ? new Date(draft.updatedAt).toLocaleString() : "-"}
|
||||||
</div>
|
</div>
|
||||||
<button className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10">
|
|
||||||
Edit Organization
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -75,25 +468,59 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
{[
|
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||||
{ label: "OEE Alert", value: "85%", helper: "Trigger when OEE drops below this" },
|
OEE Alert (%)
|
||||||
{ label: "Availability Alert", value: "85%", helper: "Low run time detection" },
|
<input
|
||||||
{ label: "Performance Alert", value: "85%", helper: "Slow cycle detection" },
|
type="number"
|
||||||
{ label: "Quality Alert", value: "95%", helper: "Scrap spike detection" },
|
min={50}
|
||||||
{ label: "Microstop (sec)", value: "60s", helper: "Stop longer than this" },
|
max={100}
|
||||||
{ label: "Macrostop (sec)", value: "300s", helper: "Major stop threshold" },
|
value={draft.thresholds.oeeAlertThresholdPct}
|
||||||
].map((row) => (
|
onChange={(event) =>
|
||||||
<div key={row.label} className="rounded-xl border border-white/10 bg-black/20 p-3">
|
updateThreshold("oeeAlertThresholdPct", Number(event.target.value))
|
||||||
<div className="text-xs text-zinc-400">{row.label}</div>
|
}
|
||||||
<div className="mt-2 flex items-center justify-between">
|
className="mt-2 w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white"
|
||||||
<div className="text-sm text-white">{row.value}</div>
|
/>
|
||||||
<button className="rounded-lg border border-white/10 bg-white/5 px-3 py-1 text-xs text-white hover:bg-white/10">
|
</label>
|
||||||
Edit
|
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||||
</button>
|
Stoppage Multiplier
|
||||||
</div>
|
<input
|
||||||
<div className="mt-2 text-xs text-zinc-500">{row.helper}</div>
|
type="number"
|
||||||
</div>
|
min={1.1}
|
||||||
))}
|
max={5}
|
||||||
|
step={0.1}
|
||||||
|
value={draft.thresholds.stoppageMultiplier}
|
||||||
|
onChange={(event) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||||
|
Performance Alert (%)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={50}
|
||||||
|
max={100}
|
||||||
|
value={draft.thresholds.performanceThresholdPct}
|
||||||
|
onChange={(event) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||||
|
Quality Spike Delta (%)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={draft.thresholds.qualitySpikeDeltaPct}
|
||||||
|
onChange={(event) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,50 +529,119 @@ export default function SettingsPage() {
|
|||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2">
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2">
|
||||||
<div className="mb-3 flex items-center justify-between gap-4">
|
<div className="mb-3 flex items-center justify-between gap-4">
|
||||||
<div className="text-sm font-semibold text-white">Shift Schedule</div>
|
<div className="text-sm font-semibold text-white">Shift Schedule</div>
|
||||||
<div className="text-xs text-zinc-400">Used for Availability calculations</div>
|
<div className="text-xs text-zinc-400">Max 3 shifts, HH:mm</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
{[
|
{draft.shiftSchedule.shifts.map((shift, index) => (
|
||||||
{ label: "Shift A", time: "06:00 - 14:00", days: "Mon - Fri" },
|
<div key={`${shift.name}-${index}`} className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
{ label: "Shift B", time: "14:00 - 22:00", days: "Mon - Fri" },
|
<div className="flex items-center justify-between">
|
||||||
{ label: "Shift C", time: "22:00 - 06:00", days: "Mon - Fri" },
|
<input
|
||||||
].map((shift) => (
|
value={shift.name}
|
||||||
<div key={shift.label} className="rounded-xl border border-white/10 bg-black/20 p-3">
|
onChange={(event) => updateShift(index, { name: event.target.value })}
|
||||||
<div className="text-sm font-semibold text-white">{shift.label}</div>
|
className="w-full rounded-md border border-white/10 bg-black/30 px-2 py-1 text-sm text-white"
|
||||||
<div className="mt-1 text-xs text-zinc-400">{shift.time}</div>
|
/>
|
||||||
<div className="mt-2 text-xs text-zinc-500">{shift.days}</div>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => 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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={shift.start}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-zinc-400">to</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={shift.end}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-2 text-xs text-zinc-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={shift.enabled}
|
||||||
|
onChange={(event) => updateShift(index, { enabled: event.target.checked })}
|
||||||
|
className="h-4 w-4 rounded border border-white/20 bg-black/20"
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
<button className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10">
|
<button
|
||||||
Edit Shifts
|
type="button"
|
||||||
|
onClick={addShift}
|
||||||
|
disabled={draft.shiftSchedule.shifts.length >= 3}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Add Shift
|
||||||
</button>
|
</button>
|
||||||
|
<div className="flex flex-1 flex-wrap gap-3">
|
||||||
|
<label className="flex-1 rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||||
|
Shift Change Compensation (min)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={480}
|
||||||
|
value={draft.shiftSchedule.shiftChangeCompensationMin}
|
||||||
|
onChange={(event) =>
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex-1 rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||||
|
Lunch Break (min)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={480}
|
||||||
|
value={draft.shiftSchedule.lunchBreakMin}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
<div className="text-sm font-semibold text-white">Notification Channels</div>
|
<div className="text-sm font-semibold text-white">Alerts</div>
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
<Toggle
|
<Toggle
|
||||||
label="Email Alerts"
|
label="OEE Drop"
|
||||||
helper="Send alerts to supervisors and managers"
|
helper="Notify when OEE falls below threshold"
|
||||||
enabled={emailEnabled}
|
enabled={draft.alerts.oeeDropEnabled}
|
||||||
onChange={setEmailEnabled}
|
onChange={(next) => updateAlerts("oeeDropEnabled", next)}
|
||||||
/>
|
/>
|
||||||
<Toggle
|
<Toggle
|
||||||
label="SMS Alerts"
|
label="Performance Degradation"
|
||||||
helper="Send critical alerts to on-call staff"
|
helper="Flag prolonged slow cycles"
|
||||||
enabled={smsEnabled}
|
enabled={draft.alerts.performanceDegradationEnabled}
|
||||||
onChange={setSmsEnabled}
|
onChange={(next) => updateAlerts("performanceDegradationEnabled", next)}
|
||||||
/>
|
/>
|
||||||
<Toggle
|
<Toggle
|
||||||
label="Webhook"
|
label="Quality Spike"
|
||||||
helper="POST events to external systems"
|
helper="Alert on scrap spikes"
|
||||||
enabled={webhookEnabled}
|
enabled={draft.alerts.qualitySpikeEnabled}
|
||||||
onChange={setWebhookEnabled}
|
onChange={(next) => updateAlerts("qualitySpikeEnabled", next)}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Predictive OEE Decline"
|
||||||
|
helper="Warn before OEE drops"
|
||||||
|
enabled={draft.alerts.predictiveOeeDeclineEnabled}
|
||||||
|
onChange={(next) => updateAlerts("predictiveOeeDeclineEnabled", next)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,10 +649,33 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 text-sm font-semibold text-white">Mold Defaults</div>
|
||||||
<div className="text-sm font-semibold text-white">Integrations</div>
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<div className="text-xs text-zinc-400">Live endpoints</div>
|
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||||
|
Mold Total
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={draft.defaults.moldTotal}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="rounded-xl border border-white/10 bg-black/20 p-3 text-xs text-zinc-400">
|
||||||
|
Mold Active
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={draft.defaults.moldActive}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="mb-3 text-sm font-semibold text-white">Integrations</div>
|
||||||
<div className="space-y-3 text-sm text-zinc-300">
|
<div className="space-y-3 text-sm text-zinc-300">
|
||||||
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||||
<div className="text-xs text-zinc-400">Webhook URL</div>
|
<div className="text-xs text-zinc-400">Webhook URL</div>
|
||||||
@@ -168,32 +687,6 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
|
||||||
<div className="mb-3 text-sm font-semibold text-white">Users & Roles</div>
|
|
||||||
<div className="space-y-3 text-sm text-zinc-300">
|
|
||||||
{[
|
|
||||||
{ name: "Juan Perez", role: "Plant Manager" },
|
|
||||||
{ name: "Sandra Rivera", role: "Supervisor" },
|
|
||||||
{ name: "Maintenance", role: "Technician" },
|
|
||||||
].map((user) => (
|
|
||||||
<div key={user.name} className="flex items-center justify-between rounded-xl border border-white/10 bg-black/20 p-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-white">{user.name}</div>
|
|
||||||
<div className="text-xs text-zinc-400">{user.role}</div>
|
|
||||||
</div>
|
|
||||||
<button className="rounded-lg border border-white/10 bg-white/5 px-3 py-1 text-xs text-white hover:bg-white/10">
|
|
||||||
Manage
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<button className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10">
|
|
||||||
Invite User
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireSession } from "@/lib/auth/requireSession";
|
import { requireSession } from "@/lib/auth/requireSession";
|
||||||
@@ -87,6 +88,7 @@ export async function GET() {
|
|||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
try {
|
||||||
const loaded = await prisma.$transaction(async (tx) => {
|
const loaded = await prisma.$transaction(async (tx) => {
|
||||||
const found = await ensureOrgSettings(tx, session.orgId, session.userId);
|
const found = await ensureOrgSettings(tx, session.orgId, session.userId);
|
||||||
if (!found?.settings) throw new Error("SETTINGS_NOT_FOUND");
|
if (!found?.settings) throw new Error("SETTINGS_NOT_FOUND");
|
||||||
@@ -95,12 +97,18 @@ export async function GET() {
|
|||||||
|
|
||||||
const payload = buildSettingsPayload(loaded.settings, loaded.shifts ?? []);
|
const payload = buildSettingsPayload(loaded.settings, loaded.shifts ?? []);
|
||||||
return NextResponse.json({ ok: true, settings: payload });
|
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) {
|
export async function PUT(req: Request) {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
try {
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const source = String(body.source ?? "control_tower");
|
const source = String(body.source ?? "control_tower");
|
||||||
const timezone = body.timezone;
|
const timezone = body.timezone;
|
||||||
@@ -151,16 +159,15 @@ export async function PUT(req: Request) {
|
|||||||
return NextResponse.json({ ok: false, error: defaultsValidation.error }, { status: 400 });
|
return NextResponse.json({ ok: false, error: defaultsValidation.error }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let shiftRows: any[] = [];
|
let shiftRows: any[] | null = null;
|
||||||
let hasShiftUpdate = false;
|
|
||||||
if (shiftSchedule?.shifts !== undefined) {
|
if (shiftSchedule?.shifts !== undefined) {
|
||||||
const shiftResult = validateShiftSchedule(shiftSchedule.shifts);
|
const shiftResult = validateShiftSchedule(shiftSchedule.shifts);
|
||||||
if (!shiftResult.ok) {
|
if (!shiftResult.ok) {
|
||||||
return NextResponse.json({ ok: false, error: shiftResult.error }, { status: 400 });
|
return NextResponse.json({ ok: false, error: shiftResult.error }, { status: 400 });
|
||||||
}
|
}
|
||||||
shiftRows = shiftResult.shifts ?? [];
|
shiftRows = shiftResult.shifts ?? [];
|
||||||
hasShiftUpdate = true;
|
|
||||||
}
|
}
|
||||||
|
const shiftRowsSafe = shiftRows ?? [];
|
||||||
|
|
||||||
const updated = await prisma.$transaction(async (tx) => {
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
const current = await ensureOrgSettings(tx, session.orgId, session.userId);
|
const current = await ensureOrgSettings(tx, session.orgId, session.userId);
|
||||||
@@ -197,6 +204,7 @@ export async function PUT(req: Request) {
|
|||||||
defaultsJson: nextDefaults,
|
defaultsJson: nextDefaults,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasShiftUpdate = shiftRows !== null;
|
||||||
const hasSettingsUpdate = Object.keys(updateData).length > 0;
|
const hasSettingsUpdate = Object.keys(updateData).length > 0;
|
||||||
|
|
||||||
if (!hasShiftUpdate && !hasSettingsUpdate) {
|
if (!hasShiftUpdate && !hasSettingsUpdate) {
|
||||||
@@ -216,9 +224,9 @@ export async function PUT(req: Request) {
|
|||||||
|
|
||||||
if (hasShiftUpdate) {
|
if (hasShiftUpdate) {
|
||||||
await tx.orgShift.deleteMany({ where: { orgId: session.orgId } });
|
await tx.orgShift.deleteMany({ where: { orgId: session.orgId } });
|
||||||
if (shiftRows.length) {
|
if (shiftRowsSafe.length) {
|
||||||
await tx.orgShift.createMany({
|
await tx.orgShift.createMany({
|
||||||
data: shiftRows.map((s) => ({
|
data: shiftRowsSafe.map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
orgId: session.orgId,
|
orgId: session.orgId,
|
||||||
})),
|
})),
|
||||||
@@ -260,4 +268,9 @@ export async function PUT(req: Request) {
|
|||||||
|
|
||||||
const payload = buildSettingsPayload(updated.settings, updated.shifts ?? []);
|
const payload = buildSettingsPayload(updated.settings, updated.shifts ?? []);
|
||||||
return NextResponse.json({ ok: true, settings: payload });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
Reference in New Issue
Block a user