Final MVP valid

This commit is contained in:
Marcelo
2026-01-21 01:45:57 +00:00
parent c183dda383
commit 511d80b629
29 changed files with 4827 additions and 381 deletions

View File

@@ -0,0 +1,29 @@
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function getScreenlessMode(defaultsJson: unknown) {
const defaults = isPlainObject(defaultsJson) ? defaultsJson : {};
const modules = isPlainObject(defaults.modules) ? defaults.modules : {};
return modules.screenlessMode === true;
}
export default async function DowntimeLayout({ children }: { children: React.ReactNode }) {
const session = await requireSession();
if (!session) redirect("/login?next=/downtime");
const settings = await prisma.orgSettings.findUnique({
where: { orgId: session.orgId },
select: { defaultsJson: true },
});
if (getScreenlessMode(settings?.defaultsJson)) {
redirect("/overview");
}
return <>{children}</>;
}

View File

@@ -0,0 +1,5 @@
import DowntimePageClient from "@/components/downtime/DowntimePageClient";
export default function DowntimePage() {
return <DowntimePageClient />;
}

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import DowntimeParetoCard from "@/components/analytics/DowntimeParetoCard";
import {
Bar,
BarChart,
@@ -1013,6 +1014,34 @@ export default function MachineDetailClient() {
activeStoppage={activeStoppage}
/>
</div>
<div className="mt-6 rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white">Downtime (preview)</div>
<div className="mt-1 text-xs text-zinc-400">Top reasons + quick pareto</div>
</div>
<Link
href={`/downtime?machineId=${encodeURIComponent(machineId)}&range=7d`}
className="rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white hover:bg-white/10"
>
View full report
</Link>
</div>
<div className="mt-4">
<DowntimeParetoCard
machineId={machineId}
range="7d"
variant="summary"
maxBars={5}
showCoverage={true}
showOpenFullReport={false}
/>
</div>
</div>
<div className="mt-6 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">

View File

@@ -0,0 +1,15 @@
import { redirect } from "next/navigation";
export default function LegacyDowntimeParetoPage({
searchParams,
}: {
searchParams: Record<string, string | string[] | undefined>;
}) {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(searchParams)) {
if (typeof v === "string") qs.set(k, v);
else if (Array.isArray(v)) v.forEach((vv) => qs.append(k, vv));
}
const q = qs.toString();
redirect(q ? `/downtime?${q}` : "/downtime");
}

View File

@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { AlertsConfig } from "@/components/settings/AlertsConfig";
import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig";
import { useI18n } from "@/lib/i18n/useI18n";
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
type Shift = {
name: string;
@@ -16,6 +18,11 @@ type SettingsPayload = {
orgId?: string;
version?: number;
timezone?: string;
modules: {
screenlessMode: boolean;
};
shiftSchedule: {
shifts: Shift[];
shiftChangeCompensationMin: number;
@@ -42,6 +49,7 @@ type SettingsPayload = {
updatedBy?: string;
};
type OrgInfo = {
id: string;
name: string;
@@ -77,6 +85,7 @@ const DEFAULT_SETTINGS: SettingsPayload = {
orgId: "",
version: 0,
timezone: "UTC",
modules: { screenlessMode: false },
shiftSchedule: {
shifts: [],
shiftChangeCompensationMin: 10,
@@ -105,6 +114,7 @@ const DEFAULT_SETTINGS: SettingsPayload = {
const SETTINGS_TABS = [
{ id: "general", labelKey: "settings.tabs.general" },
{ id: "modules", labelKey: "settings.tabs.modules" },
{ id: "shifts", labelKey: "settings.tabs.shifts" },
{ id: "thresholds", labelKey: "settings.tabs.thresholds" },
{ id: "alerts", labelKey: "settings.tabs.alerts" },
@@ -191,6 +201,7 @@ function normalizeShift(raw: unknown, fallbackName: string): Shift {
function normalizeSettings(raw: unknown, fallbackName: (index: number) => string): SettingsPayload {
const record = asRecord(raw);
const modules = asRecord(record?.modules) ?? {};
if (!record) {
return {
...DEFAULT_SETTINGS,
@@ -253,6 +264,9 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string
moldTotal: Number(defaults.moldTotal ?? DEFAULT_SETTINGS.defaults.moldTotal),
moldActive: Number(defaults.moldActive ?? DEFAULT_SETTINGS.defaults.moldActive),
},
modules: {
screenlessMode: (modules.screenlessMode as boolean | undefined) ?? false,
},
updatedAt: record.updatedAt ? String(record.updatedAt) : "",
updatedBy: record.updatedBy ? String(record.updatedBy) : "",
};
@@ -296,6 +310,7 @@ function Toggle({
export default function SettingsPage() {
const { t, locale } = useI18n();
const { setScreenlessMode } = useScreenlessMode();
const [draft, setDraft] = useState<SettingsPayload | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -329,6 +344,7 @@ export default function SettingsPage() {
}
const next = normalizeSettings(api.record?.settings, defaultShiftName);
setDraft(next);
setScreenlessMode(next.modules.screenlessMode);
} catch (err) {
setError(err instanceof Error ? err.message : t("settings.failedLoad"));
} finally {
@@ -576,6 +592,7 @@ export default function SettingsPage() {
source: "control_tower",
version: draft.version,
timezone: draft.timezone,
modules: draft.modules,
shiftSchedule: draft.shiftSchedule,
thresholds: draft.thresholds,
alerts: draft.alerts,
@@ -593,6 +610,7 @@ export default function SettingsPage() {
}
const next = normalizeSettings(api.record?.settings, defaultShiftName);
setDraft(next);
setScreenlessMode(next.modules.screenlessMode);
setSaveStatus("saved");
} catch (err) {
setError(err instanceof Error ? err.message : t("settings.failedSave"));
@@ -680,7 +698,12 @@ export default function SettingsPage() {
: "rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-200 hover:bg-white/10"
}
>
{t(tab.labelKey)}
{(() => {
const label = t(tab.labelKey);
return label === tab.labelKey
? tab.id.charAt(0).toUpperCase() + tab.id.slice(1)
: label;
})()}
</button>
))}
</div>
@@ -766,6 +789,38 @@ export default function SettingsPage() {
</div>
</div>
)}
{activeTab === "modules" && (
<div className="space-y-6">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-sm font-semibold text-white">{t("settings.modules.title")}</div>
<div className="mt-1 text-xs text-zinc-400">{t("settings.modules.subtitle")}</div>
<div className="mt-4 space-y-3">
<Toggle
label={t("settings.modules.screenless.title")}
helper={t("settings.modules.screenless.helper")}
enabled={draft.modules.screenlessMode}
onChange={(next) =>
setDraft((prev) =>
prev
? {
...prev,
modules: { ...prev.modules, screenlessMode: next },
}
: prev
)
}
/>
</div>
<div className="mt-3 text-xs text-zinc-500">
Org-wide setting. Hides Downtime from navigation for all users in this org.
</div>
</div>
</div>
)}
{activeTab === "thresholds" && (
<div className="space-y-6">

View File

@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
const bad = (status: number, error: string) =>
NextResponse.json({ ok: false, error }, { status });
export async function GET(req: Request) {
const session = await requireSession();
if (!session) return bad(401, "Unauthorized");
const orgId = session.orgId;
const url = new URL(req.url);
// ✅ Parse params INSIDE handler
const range = coerceDowntimeRange(url.searchParams.get("range"));
const start = rangeToStart(range);
const machineId = url.searchParams.get("machineId"); // optional
const kind = (url.searchParams.get("kind") || "downtime").toLowerCase();
// coverage is only meaningful for downtime
if (kind !== "downtime") return bad(400, "Invalid kind (downtime only)");
let resolvedMachineId: string | null = null;
// If machineId provided, validate ownership
if (machineId) {
const m = await prisma.machine.findFirst({
where: { id: machineId, orgId },
select: { id: true },
});
if (!m) return bad(404, "Machine not found");
resolvedMachineId = m.id;
}
const rows = await prisma.reasonEntry.findMany({
where: {
orgId,
...(resolvedMachineId ? { machineId: resolvedMachineId } : {}),
kind: "downtime",
capturedAt: { gte: start },
},
select: { durationSeconds: true, episodeId: true },
});
const receivedEpisodes = new Set(rows.map((r) => r.episodeId).filter(Boolean)).size;
const receivedMinutes =
Math.round((rows.reduce((acc, r) => acc + (r.durationSeconds ?? 0), 0) / 60) * 10) / 10;
return NextResponse.json({
ok: true,
orgId,
machineId: resolvedMachineId, // null => org-wide
range,
start,
receivedEpisodes,
receivedMinutes,
note:
"Control Tower received coverage (sync health). True coverage vs total downtime minutes can be added once CT has total downtime minutes per window.",
});
}

View File

@@ -0,0 +1,130 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
const bad = (status: number, error: string) =>
NextResponse.json({ ok: false, error }, { status });
function toISO(d: Date | null | undefined) {
return d ? d.toISOString() : null;
}
export async function GET(req: Request) {
// ✅ Session auth (cookie)
const session = await requireSession();
if (!session) return bad(401, "Unauthorized");
const orgId = session.orgId;
const url = new URL(req.url);
// ✅ Params
const range = coerceDowntimeRange(url.searchParams.get("range"));
const start = rangeToStart(range);
const machineId = url.searchParams.get("machineId"); // optional
const reasonCode = url.searchParams.get("reasonCode"); // optional
const limitRaw = url.searchParams.get("limit");
const limit = Math.min(Math.max(Number(limitRaw || 200), 1), 500);
// Optional pagination: return events before this timestamp (capturedAt)
const before = url.searchParams.get("before"); // ISO string
const beforeDate = before ? new Date(before) : null;
if (before && isNaN(beforeDate!.getTime())) return bad(400, "Invalid before timestamp");
// ✅ If machineId provided, verify it belongs to this org
if (machineId) {
const m = await prisma.machine.findFirst({
where: { id: machineId, orgId },
select: { id: true },
});
if (!m) return bad(404, "Machine not found");
}
// ✅ Query ReasonEntry as the "episode" table for downtime
// We only return rows that have an episodeId (true downtime episodes)
const where: any = {
orgId,
kind: "downtime",
episodeId: { not: null },
capturedAt: {
gte: start,
...(beforeDate ? { lt: beforeDate } : {}),
},
...(machineId ? { machineId } : {}),
...(reasonCode ? { reasonCode } : {}),
};
const rows = await prisma.reasonEntry.findMany({
where,
orderBy: { capturedAt: "desc" },
take: limit,
select: {
id: true,
episodeId: true,
machineId: true,
reasonCode: true,
reasonLabel: true,
reasonText: true,
durationSeconds: true,
capturedAt: true,
episodeEndTs: true,
workOrderId: true,
meta: true,
createdAt: true,
machine: { select: { name: true } },
},
});
const events = rows.map((r) => {
const startAt = r.capturedAt;
const endAt =
r.episodeEndTs ??
(r.durationSeconds != null
? new Date(startAt.getTime() + r.durationSeconds * 1000)
: null);
const durationSeconds = r.durationSeconds ?? null;
const durationMinutes =
durationSeconds != null ? Math.round((durationSeconds / 60) * 10) / 10 : null;
return {
id: r.id,
episodeId: r.episodeId,
machineId: r.machineId,
machineName: r.machine?.name ?? null,
reasonCode: r.reasonCode,
reasonLabel: r.reasonLabel ?? r.reasonCode,
reasonText: r.reasonText ?? null,
durationSeconds,
durationMinutes,
startAt: toISO(startAt),
endAt: toISO(endAt),
capturedAt: toISO(r.capturedAt),
workOrderId: r.workOrderId ?? null,
meta: r.meta ?? null,
createdAt: toISO(r.createdAt),
};
});
const nextBefore =
events.length > 0 ? events[events.length - 1]?.capturedAt ?? null : null;
return NextResponse.json({
ok: true,
orgId,
range,
start,
machineId: machineId ?? null,
reasonCode: reasonCode ?? null,
limit,
before: before ?? null,
nextBefore, // pass this back for pagination
events,
});
}

View File

@@ -0,0 +1,123 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { coerceDowntimeRange, rangeToStart } from "@/lib/analytics/downtimeRange";
const bad = (status: number, error: string) =>
NextResponse.json({ ok: false, error }, { status });
export async function GET(req: Request) {
// ✅ Session auth (cookie)
const session = await requireSession();
if (!session) return bad(401, "Unauthorized");
const orgId = session.orgId;
const url = new URL(req.url);
// ✅ Parse params INSIDE handler
const range = coerceDowntimeRange(url.searchParams.get("range"));
const start = rangeToStart(range);
const machineId = url.searchParams.get("machineId"); // optional
const kind = (url.searchParams.get("kind") || "downtime").toLowerCase();
if (kind !== "downtime" && kind !== "scrap") {
return bad(400, "Invalid kind (downtime|scrap)");
}
// ✅ If machineId provided, verify it belongs to this org
if (machineId) {
const m = await prisma.machine.findFirst({
where: { id: machineId, orgId },
select: { id: true },
});
if (!m) return bad(404, "Machine not found");
}
// ✅ Scope by orgId (+ machineId if provided)
const grouped = await prisma.reasonEntry.groupBy({
by: ["reasonCode", "reasonLabel"],
where: {
orgId,
...(machineId ? { machineId } : {}),
kind,
capturedAt: { gte: start },
},
_sum: {
durationSeconds: true,
scrapQty: true,
},
_count: { _all: true },
});
const itemsRaw = grouped
.map((g) => {
const value =
kind === "downtime"
? Math.round(((g._sum.durationSeconds ?? 0) / 60) * 10) / 10 // minutes, 1 decimal
: g._sum.scrapQty ?? 0;
return {
reasonCode: g.reasonCode,
reasonLabel: g.reasonLabel ?? g.reasonCode,
value,
count: g._count._all,
};
})
.filter((x) => x.value > 0);
itemsRaw.sort((a, b) => b.value - a.value);
const total = itemsRaw.reduce((acc, x) => acc + x.value, 0);
let cum = 0;
let threshold80Index: number | null = null;
const rows = itemsRaw.map((x, idx) => {
const pctOfTotal = total > 0 ? (x.value / total) * 100 : 0;
cum += x.value;
const cumulativePct = total > 0 ? (cum / total) * 100 : 0;
if (threshold80Index === null && cumulativePct >= 80) threshold80Index = idx;
return {
reasonCode: x.reasonCode,
reasonLabel: x.reasonLabel,
minutesLost: kind === "downtime" ? x.value : undefined,
scrapQty: kind === "scrap" ? x.value : undefined,
pctOfTotal,
cumulativePct,
count: x.count,
};
});
const top3 = rows.slice(0, 3);
const threshold80 =
threshold80Index === null
? null
: {
index: threshold80Index,
reasonCode: rows[threshold80Index].reasonCode,
reasonLabel: rows[threshold80Index].reasonLabel,
};
return NextResponse.json({
ok: true,
orgId,
machineId: machineId ?? null,
kind,
range, // ✅ now defined correctly
start, // ✅ now defined correctly
totalMinutesLost: kind === "downtime" ? total : undefined,
totalScrap: kind === "scrap" ? total : undefined,
rows,
top3,
threshold80,
// (optional) keep old shape if anything else uses it:
items: itemsRaw.map((x, i) => ({
...x,
cumPct: rows[i]?.cumulativePct ?? 0,
})),
total,
});
}

View File

@@ -0,0 +1,226 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { buildDowntimeActionAssignedEmail, sendEmail } from "@/lib/email";
import { getBaseUrl } from "@/lib/appUrl";
const STATUS = ["open", "in_progress", "blocked", "done"] as const;
const PRIORITY = ["low", "medium", "high"] as const;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const updateSchema = z.object({
machineId: z.string().trim().min(1).optional().nullable(),
reasonCode: z.string().trim().min(1).max(64).optional().nullable(),
hmDay: z.number().int().min(0).max(6).optional().nullable(),
hmHour: z.number().int().min(0).max(23).optional().nullable(),
title: z.string().trim().min(1).max(160).optional(),
notes: z.string().trim().max(4000).optional().nullable(),
ownerUserId: z.string().trim().min(1).optional().nullable(),
dueDate: z.string().trim().regex(DATE_RE).optional().nullable(),
status: z.enum(STATUS).optional(),
priority: z.enum(PRIORITY).optional(),
});
function parseDueDate(value?: string | null) {
if (value === undefined) return undefined;
if (!value) return null;
return new Date(`${value}T00:00:00.000Z`);
}
function formatDueDate(value?: Date | null) {
if (!value) return null;
return value.toISOString().slice(0, 10);
}
function buildActionUrl(baseUrl: string, action: { machineId: string | null; reasonCode: string | null; hmDay: number | null; hmHour: number | null }) {
const params = new URLSearchParams();
if (action.machineId) params.set("machineId", action.machineId);
if (action.reasonCode) params.set("reasonCode", action.reasonCode);
if (action.hmDay != null && action.hmHour != null) {
params.set("hmDay", String(action.hmDay));
params.set("hmHour", String(action.hmHour));
}
const qs = params.toString();
return qs ? `${baseUrl}/downtime?${qs}` : `${baseUrl}/downtime`;
}
function serializeAction(action: {
id: string;
createdAt: Date;
updatedAt: Date;
machineId: string | null;
reasonCode: string | null;
hmDay: number | null;
hmHour: number | null;
title: string;
notes: string | null;
ownerUserId: string | null;
dueDate: Date | null;
status: string;
priority: string;
ownerUser?: { name: string | null; email: string } | null;
}) {
return {
id: action.id,
createdAt: action.createdAt.toISOString(),
updatedAt: action.updatedAt.toISOString(),
machineId: action.machineId,
reasonCode: action.reasonCode,
hmDay: action.hmDay,
hmHour: action.hmHour,
title: action.title,
notes: action.notes ?? "",
ownerUserId: action.ownerUserId,
ownerName: action.ownerUser?.name ?? null,
ownerEmail: action.ownerUser?.email ?? null,
dueDate: formatDueDate(action.dueDate),
status: action.status,
priority: action.priority,
};
}
export async function PATCH(req: NextRequest, context: { params: Promise<{ id: string }> }) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const { id } = await context.params;
const body = await req.json().catch(() => ({}));
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid action payload" }, { status: 400 });
}
const data = parsed.data;
if (("hmDay" in data || "hmHour" in data) && (data.hmDay == null) !== (data.hmHour == null)) {
return NextResponse.json({ ok: false, error: "Heatmap requires hmDay and hmHour" }, { status: 400 });
}
const existing = await prisma.downtimeAction.findFirst({
where: { id, orgId: session.orgId },
include: { ownerUser: { select: { name: true, email: true } } },
});
if (!existing) {
return NextResponse.json({ ok: false, error: "Action not found" }, { status: 404 });
}
if (data.machineId) {
const machine = await prisma.machine.findFirst({
where: { id: data.machineId, orgId: session.orgId },
select: { id: true },
});
if (!machine) {
return NextResponse.json({ ok: false, error: "Invalid machineId" }, { status: 400 });
}
}
let ownerMembership: { user: { name: string | null; email: string } } | null = null;
if (data.ownerUserId) {
ownerMembership = await prisma.orgUser.findUnique({
where: { orgId_userId: { orgId: session.orgId, userId: data.ownerUserId } },
include: { user: { select: { name: true, email: true } } },
});
if (!ownerMembership) {
return NextResponse.json({ ok: false, error: "Invalid ownerUserId" }, { status: 400 });
}
}
let completedAt: Date | null | undefined = undefined;
if ("status" in data) {
completedAt = data.status === "done" ? existing.completedAt ?? new Date() : null;
}
const updateData: Prisma.DowntimeActionUncheckedUpdateInput = {};
let shouldResetReminder = false;
if ("machineId" in data) updateData.machineId = data.machineId;
if ("reasonCode" in data) updateData.reasonCode = data.reasonCode;
if ("hmDay" in data) updateData.hmDay = data.hmDay;
if ("hmHour" in data) updateData.hmHour = data.hmHour;
if ("title" in data) updateData.title = data.title?.trim();
if ("notes" in data) updateData.notes = data.notes == null ? null : data.notes.trim() || null;
if ("ownerUserId" in data) updateData.ownerUserId = data.ownerUserId;
if ("dueDate" in data) {
const nextDue = parseDueDate(data.dueDate);
const prev = formatDueDate(existing.dueDate);
const next = formatDueDate(nextDue ?? null);
updateData.dueDate = nextDue;
if (prev !== next) {
shouldResetReminder = true;
}
}
if ("status" in data) updateData.status = data.status;
if ("priority" in data) updateData.priority = data.priority;
if (completedAt !== undefined) updateData.completedAt = completedAt;
if (shouldResetReminder) {
updateData.reminderStage = null;
updateData.lastReminderAt = null;
}
const updated = await prisma.downtimeAction.update({
where: { id: existing.id },
data: updateData,
include: { ownerUser: { select: { name: true, email: true } } },
});
const ownerChanged = "ownerUserId" in data && data.ownerUserId !== existing.ownerUserId;
const dueChanged =
"dueDate" in data && formatDueDate(existing.dueDate) !== formatDueDate(updated.dueDate);
let emailSent = false;
let emailError: string | null = null;
if ((ownerChanged || dueChanged) && updated.ownerUser?.email) {
try {
const org = await prisma.org.findUnique({
where: { id: session.orgId },
select: { name: true },
});
const baseUrl = getBaseUrl(req);
const actionUrl = buildActionUrl(baseUrl, updated);
const content = buildDowntimeActionAssignedEmail({
appName: "MIS Control Tower",
orgName: org?.name || "your organization",
actionTitle: updated.title,
assigneeName: updated.ownerUser.name ?? updated.ownerUser.email,
dueDate: formatDueDate(updated.dueDate),
actionUrl,
priority: updated.priority,
status: updated.status,
});
await sendEmail({
to: updated.ownerUser.email,
subject: content.subject,
text: content.text,
html: content.html,
});
emailSent = true;
} catch (err: unknown) {
emailError = err instanceof Error ? err.message : "Failed to send assignment email";
}
}
return NextResponse.json({
ok: true,
action: serializeAction(updated),
emailSent,
emailError,
});
}
export async function DELETE(req: NextRequest, context: { params: Promise<{ id: string }> }) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const { id } = await context.params;
const existing = await prisma.downtimeAction.findFirst({
where: { id, orgId: session.orgId },
select: { id: true },
});
if (!existing) {
return NextResponse.json({ ok: false, error: "Action not found" }, { status: 404 });
}
await prisma.downtimeAction.delete({ where: { id: existing.id } });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,123 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { buildDowntimeActionReminderEmail, sendEmail } from "@/lib/email";
import { getBaseUrl } from "@/lib/appUrl";
const DEFAULT_DUE_DAYS = 7;
const DEFAULT_LIMIT = 100;
const MS_PER_HOUR = 60 * 60 * 1000;
type ReminderStage = "week" | "day" | "hour" | "overdue";
function formatDueDate(value?: Date | null) {
if (!value) return null;
return value.toISOString().slice(0, 10);
}
function getReminderStage(dueDate: Date, now: Date): ReminderStage | null {
const diffMs = dueDate.getTime() - now.getTime();
if (diffMs <= 0) return "overdue";
if (diffMs <= MS_PER_HOUR) return "hour";
if (diffMs <= 24 * MS_PER_HOUR) return "day";
if (diffMs <= 7 * 24 * MS_PER_HOUR) return "week";
return null;
}
function buildActionUrl(baseUrl: string, action: { machineId: string | null; reasonCode: string | null; hmDay: number | null; hmHour: number | null }) {
const params = new URLSearchParams();
if (action.machineId) params.set("machineId", action.machineId);
if (action.reasonCode) params.set("reasonCode", action.reasonCode);
if (action.hmDay != null && action.hmHour != null) {
params.set("hmDay", String(action.hmDay));
params.set("hmHour", String(action.hmHour));
}
const qs = params.toString();
return qs ? `${baseUrl}/downtime?${qs}` : `${baseUrl}/downtime`;
}
async function authorizeRequest(req: Request) {
const secret = process.env.DOWNTIME_ACTION_REMINDER_SECRET;
if (!secret) {
const session = await requireSession();
return { ok: !!session };
}
const authHeader = req.headers.get("authorization") || "";
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : null;
const urlToken = new URL(req.url).searchParams.get("token");
return { ok: token === secret || urlToken === secret };
}
export async function POST(req: Request) {
const auth = await authorizeRequest(req);
if (!auth.ok) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const sp = new URL(req.url).searchParams;
const dueInDays = Number(sp.get("dueInDays") || DEFAULT_DUE_DAYS);
const limit = Number(sp.get("limit") || DEFAULT_LIMIT);
const now = new Date();
const dueBy = new Date(now.getTime() + dueInDays * 24 * 60 * 60 * 1000);
const actions = await prisma.downtimeAction.findMany({
where: {
status: { not: "done" },
ownerUserId: { not: null },
dueDate: { not: null, lte: dueBy },
},
include: {
ownerUser: { select: { name: true, email: true } },
org: { select: { name: true } },
},
orderBy: { dueDate: "asc" },
take: Number.isFinite(limit) ? Math.max(1, Math.min(500, limit)) : DEFAULT_LIMIT,
});
const baseUrl = getBaseUrl(req);
const sentIds: string[] = [];
const failures: Array<{ id: string; error: string }> = [];
for (const action of actions) {
const email = action.ownerUser?.email;
if (!email) continue;
if (!action.dueDate) continue;
const stage = getReminderStage(action.dueDate, now);
if (!stage) continue;
if (action.reminderStage === stage) continue;
try {
const content = buildDowntimeActionReminderEmail({
appName: "MIS Control Tower",
orgName: action.org.name,
actionTitle: action.title,
assigneeName: action.ownerUser?.name ?? email,
dueDate: formatDueDate(action.dueDate),
actionUrl: buildActionUrl(baseUrl, action),
priority: action.priority,
status: action.status,
});
await sendEmail({
to: email,
subject: content.subject,
text: content.text,
html: content.html,
});
sentIds.push(action.id);
await prisma.downtimeAction.update({
where: { id: action.id },
data: { reminderStage: stage, lastReminderAt: now },
});
} catch (err: unknown) {
failures.push({
id: action.id,
error: err instanceof Error ? err.message : "Failed to send reminder email",
});
}
}
return NextResponse.json({
ok: true,
sent: sentIds.length,
failed: failures.length,
failures,
});
}

View File

@@ -0,0 +1,226 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { buildDowntimeActionAssignedEmail, sendEmail } from "@/lib/email";
import { getBaseUrl } from "@/lib/appUrl";
const STATUS = ["open", "in_progress", "blocked", "done"] as const;
const PRIORITY = ["low", "medium", "high"] as const;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const createSchema = z.object({
machineId: z.string().trim().min(1).optional().nullable(),
reasonCode: z.string().trim().min(1).max(64).optional().nullable(),
hmDay: z.number().int().min(0).max(6).optional().nullable(),
hmHour: z.number().int().min(0).max(23).optional().nullable(),
title: z.string().trim().min(1).max(160),
notes: z.string().trim().max(4000).optional().nullable(),
ownerUserId: z.string().trim().min(1).optional().nullable(),
dueDate: z.string().trim().regex(DATE_RE).optional().nullable(),
status: z.enum(STATUS).optional(),
priority: z.enum(PRIORITY).optional(),
});
function parseDueDate(value?: string | null) {
if (!value) return null;
return new Date(`${value}T00:00:00.000Z`);
}
function formatDueDate(value?: Date | null) {
if (!value) return null;
return value.toISOString().slice(0, 10);
}
function buildActionUrl(baseUrl: string, action: { machineId: string | null; reasonCode: string | null; hmDay: number | null; hmHour: number | null }) {
const params = new URLSearchParams();
if (action.machineId) params.set("machineId", action.machineId);
if (action.reasonCode) params.set("reasonCode", action.reasonCode);
if (action.hmDay != null && action.hmHour != null) {
params.set("hmDay", String(action.hmDay));
params.set("hmHour", String(action.hmHour));
}
const qs = params.toString();
return qs ? `${baseUrl}/downtime?${qs}` : `${baseUrl}/downtime`;
}
function serializeAction(action: {
id: string;
createdAt: Date;
updatedAt: Date;
machineId: string | null;
reasonCode: string | null;
hmDay: number | null;
hmHour: number | null;
title: string;
notes: string | null;
ownerUserId: string | null;
dueDate: Date | null;
status: string;
priority: string;
ownerUser?: { name: string | null; email: string } | null;
}) {
return {
id: action.id,
createdAt: action.createdAt.toISOString(),
updatedAt: action.updatedAt.toISOString(),
machineId: action.machineId,
reasonCode: action.reasonCode,
hmDay: action.hmDay,
hmHour: action.hmHour,
title: action.title,
notes: action.notes ?? "",
ownerUserId: action.ownerUserId,
ownerName: action.ownerUser?.name ?? null,
ownerEmail: action.ownerUser?.email ?? null,
dueDate: formatDueDate(action.dueDate),
status: action.status,
priority: action.priority,
};
}
export async function GET(req: Request) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const sp = new URL(req.url).searchParams;
const machineId = sp.get("machineId");
const reasonCode = sp.get("reasonCode");
const hmDayStr = sp.get("hmDay");
const hmHourStr = sp.get("hmHour");
const hmDay = hmDayStr != null ? Number(hmDayStr) : null;
const hmHour = hmHourStr != null ? Number(hmHourStr) : null;
if ((hmDayStr != null || hmHourStr != null) && (!Number.isFinite(hmDay) || !Number.isFinite(hmHour))) {
return NextResponse.json({ ok: false, error: "Invalid heatmap selection" }, { status: 400 });
}
if ((hmDayStr != null || hmHourStr != null) && (hmDay == null || hmHour == null)) {
return NextResponse.json({ ok: false, error: "Heatmap requires hmDay and hmHour" }, { status: 400 });
}
const where: {
orgId: string;
AND?: Array<Record<string, unknown>>;
} = { orgId: session.orgId };
if (machineId) {
where.AND = [...(where.AND ?? []), { OR: [{ machineId }, { machineId: null }] }];
}
if (reasonCode) {
where.AND = [...(where.AND ?? []), { OR: [{ reasonCode }, { reasonCode: null }] }];
}
if (hmDay != null && hmHour != null) {
where.AND = [
...(where.AND ?? []),
{ OR: [{ hmDay, hmHour }, { hmDay: null, hmHour: null }] },
];
}
const actions = await prisma.downtimeAction.findMany({
where,
orderBy: { updatedAt: "desc" },
include: { ownerUser: { select: { name: true, email: true } } },
});
return NextResponse.json({
ok: true,
actions: actions.map(serializeAction),
});
}
export async function POST(req: Request) {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const body = await req.json().catch(() => ({}));
const parsed = createSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ ok: false, error: "Invalid action payload" }, { status: 400 });
}
const data = parsed.data;
if ((data.hmDay == null) !== (data.hmHour == null)) {
return NextResponse.json({ ok: false, error: "Heatmap requires hmDay and hmHour" }, { status: 400 });
}
if (data.machineId) {
const machine = await prisma.machine.findFirst({
where: { id: data.machineId, orgId: session.orgId },
select: { id: true },
});
if (!machine) {
return NextResponse.json({ ok: false, error: "Invalid machineId" }, { status: 400 });
}
}
let ownerMembership: { user: { name: string | null; email: string } } | null = null;
if (data.ownerUserId) {
ownerMembership = await prisma.orgUser.findUnique({
where: { orgId_userId: { orgId: session.orgId, userId: data.ownerUserId } },
include: { user: { select: { name: true, email: true } } },
});
if (!ownerMembership) {
return NextResponse.json({ ok: false, error: "Invalid ownerUserId" }, { status: 400 });
}
}
const created = await prisma.downtimeAction.create({
data: {
orgId: session.orgId,
machineId: data.machineId ?? null,
reasonCode: data.reasonCode ?? null,
hmDay: data.hmDay ?? null,
hmHour: data.hmHour ?? null,
title: data.title.trim(),
notes: data.notes?.trim() || null,
ownerUserId: data.ownerUserId ?? null,
dueDate: parseDueDate(data.dueDate),
status: data.status ?? "open",
priority: data.priority ?? "medium",
completedAt: data.status === "done" ? new Date() : null,
createdBy: session.userId,
},
include: { ownerUser: { select: { name: true, email: true } } },
});
let emailSent = false;
let emailError: string | null = null;
if (ownerMembership?.user?.email) {
try {
const org = await prisma.org.findUnique({
where: { id: session.orgId },
select: { name: true },
});
const baseUrl = getBaseUrl(req);
const actionUrl = buildActionUrl(baseUrl, created);
const content = buildDowntimeActionAssignedEmail({
appName: "MIS Control Tower",
orgName: org?.name || "your organization",
actionTitle: created.title,
assigneeName: ownerMembership.user.name ?? ownerMembership.user.email,
dueDate: formatDueDate(created.dueDate),
actionUrl,
priority: created.priority,
status: created.status,
});
await sendEmail({
to: ownerMembership.user.email,
subject: content.subject,
text: content.text,
html: content.html,
});
emailSent = true;
} catch (err: unknown) {
emailError = err instanceof Error ? err.message : "Failed to send assignment email";
}
}
return NextResponse.json({
ok: true,
action: serializeAction(created),
emailSent,
emailError,
});
}

View File

@@ -0,0 +1,150 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
const bad = (status: number, error: string) =>
NextResponse.json({ ok: false, error }, { status });
const asTrimmedString = (v: any) => {
if (v == null) return "";
return String(v).trim();
};
export async function POST(req: Request) {
const apiKey = req.headers.get("x-api-key");
if (!apiKey) return bad(401, "Missing api key");
const body = await req.json().catch(() => null);
if (!body?.machineId || !body?.reason) return bad(400, "Invalid payload");
const machine = await prisma.machine.findFirst({
where: { id: String(body.machineId), apiKey },
select: { id: true, orgId: true },
});
if (!machine) return bad(401, "Unauthorized");
const r = body.reason;
const reasonId = asTrimmedString(r.reasonId);
if (!reasonId) return bad(400, "Missing reason.reasonId");
const kind = asTrimmedString(r.kind).toLowerCase();
if (kind !== "downtime" && kind !== "scrap")
return bad(400, "Invalid reason.kind");
const capturedAtMs = r.capturedAtMs;
if (typeof capturedAtMs !== "number" || !Number.isFinite(capturedAtMs)) {
return bad(400, "Invalid reason.capturedAtMs");
}
const capturedAt = new Date(capturedAtMs);
const reasonCodeRaw = asTrimmedString(r.reasonCode);
if (!reasonCodeRaw) return bad(400, "Missing reason.reasonCode");
const reasonCode = reasonCodeRaw.toUpperCase(); // normalize for grouping/pareto
const reasonLabel = r.reasonLabel != null ? String(r.reasonLabel) : null;
let reasonText = r.reasonText != null ? String(r.reasonText).trim() : null;
if (reasonCode === "OTHER") {
if (!reasonText || reasonText.length < 2)
return bad(400, "reason.reasonText required when reasonCode=OTHER");
} else {
// Non-OTHER must not store free text
reasonText = null;
}
// Optional shared fields
const workOrderId =
r.workOrderId != null && String(r.workOrderId).trim()
? String(r.workOrderId).trim()
: null;
const schemaVersion =
typeof r.schemaVersion === "number" && Number.isFinite(r.schemaVersion)
? Math.trunc(r.schemaVersion)
: 1;
const meta = r.meta != null ? r.meta : null;
// Kind-specific fields
let episodeId: string | null = null;
let durationSeconds: number | null = null;
let episodeEndTs: Date | null = null;
let scrapEntryId: string | null = null;
let scrapQty: number | null = null;
let scrapUnit: string | null = null;
if (kind === "downtime") {
episodeId = asTrimmedString(r.episodeId) || null;
if (!episodeId) return bad(400, "Missing reason.episodeId for downtime");
if (typeof r.durationSeconds !== "number" || !Number.isFinite(r.durationSeconds)) {
return bad(400, "Invalid reason.durationSeconds for downtime");
}
durationSeconds = Math.max(0, Math.trunc(r.durationSeconds));
const episodeEndTsMs = r.episodeEndTsMs;
if (episodeEndTsMs != null) {
if (typeof episodeEndTsMs !== "number" || !Number.isFinite(episodeEndTsMs)) {
return bad(400, "Invalid reason.episodeEndTsMs");
}
episodeEndTs = new Date(episodeEndTsMs);
}
} else {
scrapEntryId = asTrimmedString(r.scrapEntryId) || null;
if (!scrapEntryId) return bad(400, "Missing reason.scrapEntryId for scrap");
if (typeof r.scrapQty !== "number" || !Number.isFinite(r.scrapQty)) {
return bad(400, "Invalid reason.scrapQty for scrap");
}
scrapQty = Math.max(0, Math.trunc(r.scrapQty));
scrapUnit =
r.scrapUnit != null && String(r.scrapUnit).trim()
? String(r.scrapUnit).trim()
: null;
}
// Idempotent upsert keyed by reasonId
const row = await prisma.reasonEntry.upsert({
where: { reasonId },
create: {
orgId: machine.orgId,
machineId: machine.id,
reasonId,
kind,
episodeId,
durationSeconds,
episodeEndTs,
scrapEntryId,
scrapQty,
scrapUnit,
reasonCode,
reasonLabel,
reasonText,
capturedAt,
workOrderId,
meta,
schemaVersion,
},
update: {
kind,
episodeId,
durationSeconds,
episodeEndTs,
scrapEntryId,
scrapQty,
scrapUnit,
reasonCode,
reasonLabel,
reasonText,
capturedAt,
workOrderId,
meta,
schemaVersion,
},
select: { id: true, reasonId: true },
});
return NextResponse.json({ ok: true, id: row.id, reasonId: row.reasonId });
}

View File

@@ -37,6 +37,7 @@ function canManageSettings(role?: string | null) {
const settingsPayloadSchema = z
.object({
source: z.string().trim().max(40).optional(),
modules: z.any().optional(),
timezone: z.string().trim().max(64).optional(),
shiftSchedule: z.any().optional(),
thresholds: z.any().optional(),
@@ -87,7 +88,7 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
performanceThresholdPct: 85,
qualitySpikeDeltaPct: 5,
alertsJson: DEFAULT_ALERTS,
defaultsJson: DEFAULT_DEFAULTS,
defaultsJson: { ...(DEFAULT_DEFAULTS as any), modules: { screenlessMode: false } },
updatedBy: userId,
},
});
@@ -122,7 +123,13 @@ export async function GET() {
});
const payload = buildSettingsPayload(loaded.settings, loaded.shifts ?? []);
return NextResponse.json({ ok: true, settings: payload });
const defaultsRaw = isPlainObject(loaded.settings.defaultsJson) ? (loaded.settings.defaultsJson as any) : {};
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
const modules = { screenlessMode: modulesRaw.screenlessMode === true };
return NextResponse.json({ ok: true, settings: { ...payload, modules } });
} catch (err) {
console.error("[settings GET] failed", err);
const message = err instanceof Error ? err.message : "Internal error";
@@ -156,13 +163,18 @@ export async function PUT(req: Request) {
const alerts = parsed.data.alerts;
const defaults = parsed.data.defaults;
const expectedVersion = parsed.data.version;
const modules = parsed.data.modules;
if (
timezone === undefined &&
shiftSchedule === undefined &&
thresholds === undefined &&
alerts === undefined &&
defaults === undefined
defaults === undefined &&
modules === undefined
) {
return NextResponse.json({ ok: false, error: "No settings provided" }, { status: 400 });
}
@@ -179,6 +191,16 @@ export async function PUT(req: Request) {
if (defaults !== undefined && !isPlainObject(defaults)) {
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 });
}
if (modules !== undefined && !isPlainObject(modules)) {
return NextResponse.json({ ok: false, error: "Invalid modules payload" }, { status: 400 });
}
const screenlessMode =
modules && typeof (modules as any).screenlessMode === "boolean"
? (modules as any).screenlessMode
: undefined;
const shiftValidation = validateShiftFields(
shiftSchedule?.shiftChangeCompensationMin,
@@ -193,11 +215,6 @@ export async function PUT(req: Request) {
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: ValidShift[] | null = null;
if (shiftSchedule?.shifts !== undefined) {
const shiftResult = validateShiftSchedule(shiftSchedule.shifts);
@@ -218,8 +235,34 @@ export async function PUT(req: Request) {
const nextAlerts =
alerts !== undefined ? { ...normalizeAlerts(current.settings.alertsJson), ...alerts } : undefined;
const nextDefaults =
defaults !== undefined ? { ...normalizeDefaults(current.settings.defaultsJson), ...defaults } : undefined;
const currentDefaultsRaw = isPlainObject(current.settings.defaultsJson)
? (current.settings.defaultsJson as any)
: {};
const currentModulesRaw = isPlainObject(currentDefaultsRaw.modules) ? currentDefaultsRaw.modules : {};
// Merge defaults core (moldTotal, etc.)
const nextDefaultsCore =
defaults !== undefined ? { ...normalizeDefaults(currentDefaultsRaw), ...defaults } : undefined;
// Validate merged defaults
if (nextDefaultsCore) {
const dv = validateDefaults(nextDefaultsCore);
if (!dv.ok) return { error: dv.error } as const;
}
// Merge modules
const nextModules =
screenlessMode === undefined
? currentModulesRaw
: { ...currentModulesRaw, screenlessMode };
// Write defaultsJson if either defaults changed OR modules changed
const shouldWriteDefaultsJson = !!nextDefaultsCore || screenlessMode !== undefined;
const nextDefaultsJson = shouldWriteDefaultsJson
? { ...(nextDefaultsCore ?? normalizeDefaults(currentDefaultsRaw)), modules: nextModules }
: undefined;
const updateData = stripUndefined({
timezone: timezone !== undefined ? String(timezone) : undefined,
@@ -244,7 +287,7 @@ export async function PUT(req: Request) {
qualitySpikeDeltaPct:
thresholds?.qualitySpikeDeltaPct !== undefined ? Number(thresholds.qualitySpikeDeltaPct) : undefined,
alertsJson: nextAlerts,
defaultsJson: nextDefaults,
defaultsJson: nextDefaultsJson,
});
const hasShiftUpdate = shiftRows !== null;
@@ -326,7 +369,12 @@ export async function PUT(req: Request) {
} catch (err) {
console.warn("[settings PUT] MQTT publish failed", err);
}
return NextResponse.json({ ok: true, settings: payload });
const defaultsRaw = isPlainObject(updated.settings.defaultsJson) ? (updated.settings.defaultsJson as any) : {};
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
const modulesOut = { screenlessMode: modulesRaw.screenlessMode === true };
return NextResponse.json({ ok: true, settings: { ...payload, modules: modulesOut } });
} catch (err) {
console.error("[settings PUT] failed", err);
const message = err instanceof Error ? err.message : "Internal error";