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

@@ -20,6 +20,61 @@ You can start editing the page by modifying `app/page.tsx`. The page auto-update
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Downtime Action Reminders
Reminders are sent by calling `POST /api/downtime/actions/reminders`. This endpoint does not run automatically, so you need to schedule it with cron or systemd. It sends at most one reminder per threshold (1w/1d/1h/overdue) and resets if the due date changes.
The secret can be any random string; it just needs to match what your scheduler sends in the Authorization header.
1) Set a secret in your env file (example: `/etc/mis-control-tower.env`):
```
DOWNTIME_ACTION_REMINDER_SECRET=your-secret-here
APP_BASE_URL=https://your-domain
```
2) Cron example (runs hourly for 1w/1d/1h/overdue thresholds):
```
0 * * * * . /etc/mis-control-tower.env && curl -s -X POST "$APP_BASE_URL/api/downtime/actions/reminders?dueInDays=7" -H "Authorization: Bearer $DOWNTIME_ACTION_REMINDER_SECRET"
```
If you prefer systemd instead of cron, you can create a small service + timer that runs the same curl command.
Example systemd units:
`/etc/systemd/system/mis-control-tower-reminders.service`
```
[Unit]
Description=MIS Control Tower downtime action reminders
[Service]
Type=oneshot
EnvironmentFile=/etc/mis-control-tower.env
ExecStart=/usr/bin/curl -s -X POST "$APP_BASE_URL/api/downtime/actions/reminders?dueInDays=7" -H "Authorization: Bearer $DOWNTIME_ACTION_REMINDER_SECRET"
```
`/etc/systemd/system/mis-control-tower-reminders.timer`
```
[Unit]
Description=Run MIS Control Tower reminders hourly
[Timer]
OnCalendar=hourly
Persistent=true
[Install]
WantedBy=timers.target
```
Enable with:
```
sudo systemctl daemon-reload
sudo systemctl enable --now mis-control-tower-reminders.timer
```
## Learn More ## Learn More
To learn more about Next.js, take a look at the following resources: To learn more about Next.js, take a look at the following resources:

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 { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import DowntimeParetoCard from "@/components/analytics/DowntimeParetoCard";
import { import {
Bar, Bar,
BarChart, BarChart,
@@ -1013,6 +1014,34 @@ export default function MachineDetailClient() {
activeStoppage={activeStoppage} activeStoppage={activeStoppage}
/> />
</div> </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="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"> <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 { AlertsConfig } from "@/components/settings/AlertsConfig";
import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig"; import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
type Shift = { type Shift = {
name: string; name: string;
@@ -16,6 +18,11 @@ type SettingsPayload = {
orgId?: string; orgId?: string;
version?: number; version?: number;
timezone?: string; timezone?: string;
modules: {
screenlessMode: boolean;
};
shiftSchedule: { shiftSchedule: {
shifts: Shift[]; shifts: Shift[];
shiftChangeCompensationMin: number; shiftChangeCompensationMin: number;
@@ -42,6 +49,7 @@ type SettingsPayload = {
updatedBy?: string; updatedBy?: string;
}; };
type OrgInfo = { type OrgInfo = {
id: string; id: string;
name: string; name: string;
@@ -77,6 +85,7 @@ const DEFAULT_SETTINGS: SettingsPayload = {
orgId: "", orgId: "",
version: 0, version: 0,
timezone: "UTC", timezone: "UTC",
modules: { screenlessMode: false },
shiftSchedule: { shiftSchedule: {
shifts: [], shifts: [],
shiftChangeCompensationMin: 10, shiftChangeCompensationMin: 10,
@@ -105,6 +114,7 @@ const DEFAULT_SETTINGS: SettingsPayload = {
const SETTINGS_TABS = [ const SETTINGS_TABS = [
{ id: "general", labelKey: "settings.tabs.general" }, { id: "general", labelKey: "settings.tabs.general" },
{ id: "modules", labelKey: "settings.tabs.modules" },
{ id: "shifts", labelKey: "settings.tabs.shifts" }, { id: "shifts", labelKey: "settings.tabs.shifts" },
{ id: "thresholds", labelKey: "settings.tabs.thresholds" }, { id: "thresholds", labelKey: "settings.tabs.thresholds" },
{ id: "alerts", labelKey: "settings.tabs.alerts" }, { 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 { function normalizeSettings(raw: unknown, fallbackName: (index: number) => string): SettingsPayload {
const record = asRecord(raw); const record = asRecord(raw);
const modules = asRecord(record?.modules) ?? {};
if (!record) { if (!record) {
return { return {
...DEFAULT_SETTINGS, ...DEFAULT_SETTINGS,
@@ -253,6 +264,9 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string
moldTotal: Number(defaults.moldTotal ?? DEFAULT_SETTINGS.defaults.moldTotal), moldTotal: Number(defaults.moldTotal ?? DEFAULT_SETTINGS.defaults.moldTotal),
moldActive: Number(defaults.moldActive ?? DEFAULT_SETTINGS.defaults.moldActive), moldActive: Number(defaults.moldActive ?? DEFAULT_SETTINGS.defaults.moldActive),
}, },
modules: {
screenlessMode: (modules.screenlessMode as boolean | undefined) ?? false,
},
updatedAt: record.updatedAt ? String(record.updatedAt) : "", updatedAt: record.updatedAt ? String(record.updatedAt) : "",
updatedBy: record.updatedBy ? String(record.updatedBy) : "", updatedBy: record.updatedBy ? String(record.updatedBy) : "",
}; };
@@ -296,6 +310,7 @@ function Toggle({
export default function SettingsPage() { export default function SettingsPage() {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { setScreenlessMode } = useScreenlessMode();
const [draft, setDraft] = useState<SettingsPayload | null>(null); const [draft, setDraft] = useState<SettingsPayload | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -329,6 +344,7 @@ export default function SettingsPage() {
} }
const next = normalizeSettings(api.record?.settings, defaultShiftName); const next = normalizeSettings(api.record?.settings, defaultShiftName);
setDraft(next); setDraft(next);
setScreenlessMode(next.modules.screenlessMode);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : t("settings.failedLoad")); setError(err instanceof Error ? err.message : t("settings.failedLoad"));
} finally { } finally {
@@ -576,6 +592,7 @@ export default function SettingsPage() {
source: "control_tower", source: "control_tower",
version: draft.version, version: draft.version,
timezone: draft.timezone, timezone: draft.timezone,
modules: draft.modules,
shiftSchedule: draft.shiftSchedule, shiftSchedule: draft.shiftSchedule,
thresholds: draft.thresholds, thresholds: draft.thresholds,
alerts: draft.alerts, alerts: draft.alerts,
@@ -593,6 +610,7 @@ export default function SettingsPage() {
} }
const next = normalizeSettings(api.record?.settings, defaultShiftName); const next = normalizeSettings(api.record?.settings, defaultShiftName);
setDraft(next); setDraft(next);
setScreenlessMode(next.modules.screenlessMode);
setSaveStatus("saved"); setSaveStatus("saved");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : t("settings.failedSave")); 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" : "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> </button>
))} ))}
</div> </div>
@@ -766,6 +789,38 @@ export default function SettingsPage() {
</div> </div>
</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" && ( {activeTab === "thresholds" && (
<div className="space-y-6"> <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 const settingsPayloadSchema = z
.object({ .object({
source: z.string().trim().max(40).optional(), source: z.string().trim().max(40).optional(),
modules: z.any().optional(),
timezone: z.string().trim().max(64).optional(), timezone: z.string().trim().max(64).optional(),
shiftSchedule: z.any().optional(), shiftSchedule: z.any().optional(),
thresholds: z.any().optional(), thresholds: z.any().optional(),
@@ -87,7 +88,7 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
performanceThresholdPct: 85, performanceThresholdPct: 85,
qualitySpikeDeltaPct: 5, qualitySpikeDeltaPct: 5,
alertsJson: DEFAULT_ALERTS, alertsJson: DEFAULT_ALERTS,
defaultsJson: DEFAULT_DEFAULTS, defaultsJson: { ...(DEFAULT_DEFAULTS as any), modules: { screenlessMode: false } },
updatedBy: userId, updatedBy: userId,
}, },
}); });
@@ -122,7 +123,13 @@ 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 });
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) { } catch (err) {
console.error("[settings GET] failed", err); console.error("[settings GET] failed", err);
const message = err instanceof Error ? err.message : "Internal error"; 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 alerts = parsed.data.alerts;
const defaults = parsed.data.defaults; const defaults = parsed.data.defaults;
const expectedVersion = parsed.data.version; const expectedVersion = parsed.data.version;
const modules = parsed.data.modules;
if ( if (
timezone === undefined && timezone === undefined &&
shiftSchedule === undefined && shiftSchedule === undefined &&
thresholds === undefined && thresholds === undefined &&
alerts === undefined && alerts === undefined &&
defaults === undefined defaults === undefined &&
modules === undefined
) { ) {
return NextResponse.json({ ok: false, error: "No settings provided" }, { status: 400 }); 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)) { if (defaults !== undefined && !isPlainObject(defaults)) {
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 }); 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( const shiftValidation = validateShiftFields(
shiftSchedule?.shiftChangeCompensationMin, shiftSchedule?.shiftChangeCompensationMin,
@@ -193,11 +215,6 @@ export async function PUT(req: Request) {
return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 }); 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; let shiftRows: ValidShift[] | null = null;
if (shiftSchedule?.shifts !== undefined) { if (shiftSchedule?.shifts !== undefined) {
const shiftResult = validateShiftSchedule(shiftSchedule.shifts); const shiftResult = validateShiftSchedule(shiftSchedule.shifts);
@@ -218,8 +235,34 @@ export async function PUT(req: Request) {
const nextAlerts = const nextAlerts =
alerts !== undefined ? { ...normalizeAlerts(current.settings.alertsJson), ...alerts } : undefined; alerts !== undefined ? { ...normalizeAlerts(current.settings.alertsJson), ...alerts } : undefined;
const nextDefaults = const currentDefaultsRaw = isPlainObject(current.settings.defaultsJson)
defaults !== undefined ? { ...normalizeDefaults(current.settings.defaultsJson), ...defaults } : undefined; ? (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({ const updateData = stripUndefined({
timezone: timezone !== undefined ? String(timezone) : undefined, timezone: timezone !== undefined ? String(timezone) : undefined,
@@ -244,7 +287,7 @@ export async function PUT(req: Request) {
qualitySpikeDeltaPct: qualitySpikeDeltaPct:
thresholds?.qualitySpikeDeltaPct !== undefined ? Number(thresholds.qualitySpikeDeltaPct) : undefined, thresholds?.qualitySpikeDeltaPct !== undefined ? Number(thresholds.qualitySpikeDeltaPct) : undefined,
alertsJson: nextAlerts, alertsJson: nextAlerts,
defaultsJson: nextDefaults, defaultsJson: nextDefaultsJson,
}); });
const hasShiftUpdate = shiftRows !== null; const hasShiftUpdate = shiftRows !== null;
@@ -326,7 +369,12 @@ export async function PUT(req: Request) {
} catch (err) { } catch (err) {
console.warn("[settings PUT] MQTT publish failed", 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) { } catch (err) {
console.error("[settings PUT] failed", err); console.error("[settings PUT] failed", err);
const message = err instanceof Error ? err.message : "Internal error"; const message = err instanceof Error ? err.message : "Internal error";

View File

@@ -0,0 +1,329 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { DOWNTIME_RANGES, type DowntimeRange } from "@/lib/analytics/downtimeRange";
import Link from "next/link";
import {
Bar,
CartesianGrid,
ComposedChart,
Line,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useI18n } from "@/lib/i18n/useI18n";
type ParetoRow = {
reasonCode: string;
reasonLabel: string;
minutesLost?: number; // downtime
scrapQty?: number; // scrap (future)
pctOfTotal: number; // 0..100
cumulativePct: number; // 0..100
};
type ParetoResponse = {
ok?: boolean;
rows?: ParetoRow[];
top3?: ParetoRow[];
totalMinutesLost?: number;
threshold80?: { reasonCode: string; reasonLabel: string; index: number } | null;
error?: string;
};
type CoverageResponse = {
ok?: boolean;
totalDowntimeMinutes?: number;
receivedMinutes?: number;
receivedCoveragePct?: number; // could be 0..1 or 0..100 depending on your impl
pendingEpisodesCount?: number;
};
function clampLabel(s: string, max = 18) {
if (!s) return "";
return s.length > max ? `${s.slice(0, max - 1)}` : s;
}
function normalizePct(v?: number | null) {
if (v == null || Number.isNaN(v)) return null;
// If API returns 0..1, convert to 0..100
return v <= 1 ? v * 100 : v;
}
export default function DowntimeParetoCard({
machineId,
range = "7d",
showCoverage = true,
showOpenFullReport = true,
variant = "summary",
maxBars,
}: {
machineId?: string;
range?: DowntimeRange;
showCoverage?: boolean;
showOpenFullReport?: boolean;
variant?: "summary" | "full";
maxBars?: number; // optional override
}) {
const { t } = useI18n();
const isSummary = variant === "summary";
const barsLimit = maxBars ?? (isSummary ? 5 : 12);
const chartHeightClass = isSummary ? "h-[240px]" : "h-[360px]";
const containerPad = isSummary ? "p-4" : "p-5";
const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null);
const [pareto, setPareto] = useState<ParetoResponse | null>(null);
const [coverage, setCoverage] = useState<CoverageResponse | null>(null);
useEffect(() => {
const controller = new AbortController();
async function load() {
setLoading(true);
setErr(null);
try {
const qs = new URLSearchParams();
qs.set("kind", "downtime");
qs.set("range", range);
if (machineId) qs.set("machineId", machineId);
const res = await fetch(`/api/analytics/pareto?${qs.toString()}`, {
cache: "no-cache",
credentials: "include",
signal: controller.signal,
});
const json = (await res.json().catch(() => ({}))) as ParetoResponse;
if (!res.ok || json?.ok === false) {
setPareto(null);
setErr(json?.error ?? "Failed to load pareto.");
setLoading(false);
return;
}
setPareto(json);
// Optional coverage (fail silently if endpoint not ready)
if (showCoverage) {
const cqs = new URLSearchParams();
cqs.set("kind", "downtime");
cqs.set("range", range);
if (machineId) cqs.set("machineId", machineId);
fetch(`/api/analytics/coverage?${cqs.toString()}`, {
cache: "no-cache",
credentials: "include",
signal: controller.signal,
})
.then((r) => (r.ok ? r.json() : null))
.then((cj) => (cj ? (cj as CoverageResponse) : null))
.then((cj) => {
if (cj) setCoverage(cj);
})
.catch(() => {
// ignore
});
}
setLoading(false);
} catch (e: any) {
if (e?.name === "AbortError") return;
setErr("Network error.");
setLoading(false);
}
}
load();
return () => controller.abort();
}, [machineId, range, showCoverage]);
const rows = pareto?.rows ?? [];
const chartData = useMemo(() => {
return rows.slice(0, barsLimit).map((r, idx) => ({
i: idx,
reasonCode: r.reasonCode,
reasonLabel: r.reasonLabel,
label: clampLabel(r.reasonLabel || r.reasonCode, isSummary ? 16 : 22),
minutes: Number(r.minutesLost ?? 0),
pctOfTotal: Number(r.pctOfTotal ?? 0),
cumulativePct: Number(r.cumulativePct ?? 0),
}));
}, [rows, barsLimit, isSummary]);
const top3 = useMemo(() => {
if (pareto?.top3?.length) return pareto.top3.slice(0, 3);
return [...rows]
.sort((a, b) => Number(b.minutesLost ?? 0) - Number(a.minutesLost ?? 0))
.slice(0, 3);
}, [pareto?.top3, rows]);
const totalMinutes = Number(pareto?.totalMinutesLost ?? 0);
const covPct = normalizePct(coverage?.receivedCoveragePct ?? null);
const pending = coverage?.pendingEpisodesCount ?? null;
const title =
range === "24h"
? "Downtime Pareto (24h)"
: range === "30d"
? "Downtime Pareto (30d)"
: range === "mtd"
? "Downtime Pareto (MTD)"
: "Downtime Pareto (7d)";
const reportHref = machineId
? `/downtime?machineId=${encodeURIComponent(machineId)}&range=${encodeURIComponent(range)}`
: `/downtime?range=${encodeURIComponent(range)}`;
return (
<div className={`rounded-2xl border border-white/10 bg-white/5 ${containerPad}`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-white">{title}</div>
<div className="mt-1 text-xs text-zinc-400">
Total: <span className="text-white">{totalMinutes.toFixed(0)} min</span>
{covPct != null ? (
<>
<span className="mx-2 text-zinc-600"></span>
Coverage: <span className="text-white">{covPct.toFixed(0)}%</span>
{pending != null ? (
<>
<span className="mx-2 text-zinc-600"></span>
Pending: <span className="text-white">{pending}</span>
</>
) : null}
</>
) : null}
</div>
</div>
{showOpenFullReport ? (
<Link
href={reportHref}
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>
) : null}
</div>
{loading ? (
<div className="mt-4 text-sm text-zinc-400">{t("machine.detail.loading")}</div>
) : err ? (
<div className="mt-4 rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-200">
{err}
</div>
) : rows.length === 0 ? (
<div className="mt-4 text-sm text-zinc-400">No downtime reasons found for this range.</div>
) : (
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
<div
className={`${chartHeightClass} rounded-3xl border border-white/10 bg-black/30 p-4 backdrop-blur lg:col-span-2`}
style={{ boxShadow: "var(--app-chart-shadow)" }}
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 10, right: 24, left: 0, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--app-chart-grid)" />
<XAxis
dataKey="label"
interval={0}
tick={{ fill: "var(--app-chart-tick)", fontSize: 11 }}
/>
<YAxis
yAxisId="left"
tick={{ fill: "var(--app-chart-tick)" }}
width={40}
/>
<YAxis
yAxisId="right"
orientation="right"
domain={[0, 100]}
tick={{ fill: "var(--app-chart-tick)" }}
tickFormatter={(v) => `${v}%`}
width={44}
/>
<Tooltip
cursor={{ stroke: "var(--app-chart-grid)" }}
contentStyle={{
background: "var(--app-chart-tooltip-bg)",
border: "1px solid var(--app-chart-tooltip-border)",
}}
labelStyle={{ color: "var(--app-chart-label)" }}
formatter={(val: any, name: any, ctx: any) => {
if (name === "minutes") return [`${Number(val).toFixed(1)} min`, "Minutes"];
if (name === "cumulativePct") return [`${Number(val).toFixed(1)}%`, "Cumulative"];
return [val, name];
}}
/>
<ReferenceLine
yAxisId="right"
y={80}
stroke="rgba(255,255,255,0.25)"
strokeDasharray="6 6"
/>
<Bar
yAxisId="left"
dataKey="minutes"
radius={[10, 10, 0, 0]}
isAnimationActive={false}
fill="#FF7A00"
/>
<Line
yAxisId="right"
dataKey="cumulativePct"
dot={false}
strokeWidth={2}
isAnimationActive={false}
stroke="#12D18E"
/>
</ComposedChart>
</ResponsiveContainer>
</div>
<div className={`rounded-2xl border border-white/10 bg-black/20 ${isSummary ? "p-3" : "p-4"}`}>
<div className="text-xs font-semibold text-white">Top 3 reasons</div>
<div className="mt-3 space-y-3">
{top3.map((r) => (
<div key={r.reasonCode} className="rounded-xl border border-white/10 bg-white/5 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">
{r.reasonLabel || r.reasonCode}
</div>
<div className="mt-1 text-xs text-zinc-400">{r.reasonCode}</div>
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-semibold text-white">
{(r.minutesLost ?? 0).toFixed(0)}m
</div>
<div className="text-xs text-zinc-400">{(r.pctOfTotal ?? 0).toFixed(1)}%</div>
</div>
</div>
</div>
))}
</div>
{!isSummary && pareto?.threshold80 ? (
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 p-3 text-xs text-zinc-300">
80% cutoff:{" "}
<span className="text-white">
{pareto.threshold80.reasonLabel} ({pareto.threshold80.reasonCode})
</span>
</div>
) : null}
</div>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -30,10 +30,10 @@ export function AppShell({
}, [drawerOpen]); }, [drawerOpen]);
return ( return (
<div className="min-h-screen bg-black text-white"> <div className="h-screen overflow-hidden bg-black text-white">
<div className="flex min-h-screen"> <div className="flex h-full">
<Sidebar /> <Sidebar />
<div className="flex min-h-screen flex-1 flex-col"> <div className="flex h-full flex-1 flex-col">
<header className="sticky top-0 z-30 flex min-h-[3.5rem] flex-wrap items-center justify-between gap-3 border-b border-white/10 bg-black/20 px-4 py-2 backdrop-blur"> <header className="sticky top-0 z-30 flex min-h-[3.5rem] flex-wrap items-center justify-between gap-3 border-b border-white/10 bg-black/20 px-4 py-2 backdrop-blur">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
@@ -50,7 +50,7 @@ export function AppShell({
</div> </div>
<UtilityControls initialTheme={initialTheme} /> <UtilityControls initialTheme={initialTheme} />
</header> </header>
<main className="flex-1">{children}</main> <main className="flex-1 overflow-y-auto">{children}</main>
</div> </div>
</div> </div>

View File

@@ -6,6 +6,8 @@ import { useEffect, useMemo, useState } from "react";
import { BarChart3, Bell, DollarSign, LayoutGrid, LogOut, Settings, Wrench, X } from "lucide-react"; import { BarChart3, Bell, DollarSign, LayoutGrid, LogOut, Settings, Wrench, X } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { useI18n } from "@/lib/i18n/useI18n"; import { useI18n } from "@/lib/i18n/useI18n";
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
type NavItem = { type NavItem = {
href: string; href: string;
@@ -21,6 +23,8 @@ const items: NavItem[] = [
{ href: "/alerts", labelKey: "nav.alerts", icon: Bell }, { href: "/alerts", labelKey: "nav.alerts", icon: Bell },
{ href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true }, { href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true },
{ href: "/settings", labelKey: "nav.settings", icon: Settings }, { href: "/settings", labelKey: "nav.settings", icon: Settings },
{ href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 },
]; ];
type SidebarProps = { type SidebarProps = {
@@ -33,6 +37,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
const { screenlessMode } = useScreenlessMode();
const [me, setMe] = useState<{ const [me, setMe] = useState<{
user?: { name?: string | null; email?: string | null }; user?: { name?: string | null; email?: string | null };
org?: { name?: string | null }; org?: { name?: string | null };
@@ -67,7 +72,28 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
const roleKey = (me?.membership?.role || "MEMBER").toLowerCase(); const roleKey = (me?.membership?.role || "MEMBER").toLowerCase();
const isOwner = roleKey === "owner"; const isOwner = roleKey === "owner";
const visibleItems = useMemo(() => items.filter((it) => !it.ownerOnly || isOwner), [isOwner]); const visibleItems = useMemo(() => {
return items.filter((it) => {
if (it.ownerOnly && !isOwner) return false;
if (screenlessMode && it.href === "/downtime") return false;
return true;
});
}, [isOwner, screenlessMode]);
useEffect(() => {
if (screenlessMode && pathname.startsWith("/downtime")) {
router.replace("/overview");
}
}, [screenlessMode, pathname, router]);
useEffect(() => {
if (!screenlessMode) return;
if (pathname === "/downtime" || pathname.startsWith("/downtime/")) {
router.replace("/overview");
}
}, [screenlessMode, pathname, router]);
useEffect(() => { useEffect(() => {
visibleItems.forEach((it) => { visibleItems.forEach((it) => {
@@ -75,7 +101,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
}); });
}, [router, visibleItems]); }, [router, visibleItems]);
const shellClass = [ const shellClass = [
"relative z-20 flex flex-col border-r border-white/10 bg-black/40", "relative z-20 flex flex-col border-r border-white/10 bg-black/40 shrink-0",
variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]", variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]",
].join(" "); ].join(" ");

View File

@@ -0,0 +1,110 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import DowntimeParetoCard from "@/components/analytics/DowntimeParetoCard";
import { usePathname } from "next/navigation";
import { DOWNTIME_RANGES, coerceDowntimeRange, type DowntimeRange } from "@/lib/analytics/downtimeRange";
type MachineLite = {
id: string;
name: string;
siteName?: string | null; // optional for later
};
export default function DowntimeParetoReportClient() {
const sp = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const [range, setRange] = useState<DowntimeRange>(coerceDowntimeRange(sp.get("range")));
const [machineId, setMachineId] = useState<string>(sp.get("machineId") || "");
const [machines, setMachines] = useState<MachineLite[]>([]);
const [loadingMachines, setLoadingMachines] = useState(true);
// Keep URL in sync (so deep-links work)
useEffect(() => {
const qs = new URLSearchParams();
if (range) qs.set("range", range);
if (machineId) qs.set("machineId", machineId);
const next = `${pathname}?${qs.toString()}`;
const current = `${pathname}?${sp.toString()}`;
// avoid needless replace loops
if (next !== current) router.replace(next);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [range, machineId, pathname]);
useEffect(() => {
let cancelled = false;
async function loadMachines() {
setLoadingMachines(true);
try {
// Use whatever endpoint you already have for listing machines:
// If you dont have one, easiest is GET /api/machines returning [{id,name}]
const res = await fetch("/api/machines", { credentials: "include" });
const json = await res.json();
if (!cancelled && res.ok) setMachines(json.machines ?? json ?? []);
} finally {
if (!cancelled) setLoadingMachines(false);
}
}
loadMachines();
return () => {
cancelled = true;
};
}, []);
const machineOptions = useMemo(() => {
return [{ id: "", name: "All machines" }, ...machines];
}, [machines]);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-lg font-semibold text-white">Downtime Pareto</div>
<div className="text-sm text-zinc-400">Org-wide report with drilldown</div>
</div>
<div className="flex flex-wrap gap-2">
<select
className="rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm text-white"
value={range}
onChange={(e) => setRange(e.target.value as DowntimeRange)}
>
<option className="bg-black text-white" value="24h">Last 24h</option>
<option className="bg-black text-white" value="7d">Last 7d</option>
<option className="bg-black text-white" value="30d">Last 30d</option>
</select>
<select
className="min-w-[240px] rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white"
value={machineId}
onChange={(e) => setMachineId(e.target.value)}
disabled={loadingMachines}
>
{machineOptions.map((m) => (
<option className="bg-black text-white" key={m.id || "all"} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
</div>
<DowntimeParetoCard
range={range}
machineId={machineId || undefined}
showOpenFullReport={false}
/>
</div>
);
}

View File

@@ -0,0 +1,24 @@
export const DOWNTIME_RANGES = ["24h", "7d", "30d", "mtd"] as const;
export type DowntimeRange = (typeof DOWNTIME_RANGES)[number];
export function coerceDowntimeRange(v?: string | null): DowntimeRange {
const s = (v ?? "").toLowerCase();
return (DOWNTIME_RANGES as readonly string[]).includes(s) ? (s as DowntimeRange) : "7d";
}
// server-friendly helper
export function rangeToStart(range: DowntimeRange) {
const now = new Date();
if (range === "24h") return new Date(Date.now() - 24 * 60 * 60 * 1000);
if (range === "30d") return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
if (range === "mtd") return new Date(now.getFullYear(), now.getMonth(), 1);
return new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
}
// UI label helper (replaces ternaries everywhere)
export const DOWNTIME_RANGE_LABEL: Record<DowntimeRange, string> = {
"24h": "Last 24h",
"7d": "Last 7d",
"30d": "Last 30d",
"mtd": "MTD",
};

View File

@@ -144,3 +144,71 @@ export function buildInviteEmail(params: {
return { subject, text, html }; return { subject, text, html };
} }
export function buildDowntimeActionAssignedEmail(params: {
appName: string;
orgName: string;
actionTitle: string;
assigneeName: string;
dueDate: string | null;
actionUrl: string;
priority: string;
status: string;
}) {
const dueLabel = params.dueDate ? `Due ${params.dueDate}` : "No due date";
const subject = `Action assigned: ${params.actionTitle}`;
const text =
`Hi ${params.assigneeName},\n\n` +
`You have been assigned an action in ${params.orgName} (${params.appName}).\n\n` +
`Title: ${params.actionTitle}\n` +
`Status: ${params.status}\n` +
`Priority: ${params.priority}\n` +
`${dueLabel}\n\n` +
`Open in Control Tower:\n${params.actionUrl}\n\n` +
`If you did not expect this assignment, please contact your admin.`;
const html =
`<p>Hi ${params.assigneeName},</p>` +
`<p>You have been assigned an action in ${params.orgName} (${params.appName}).</p>` +
`<p><strong>Title:</strong> ${params.actionTitle}<br />` +
`<strong>Status:</strong> ${params.status}<br />` +
`<strong>Priority:</strong> ${params.priority}<br />` +
`<strong>${dueLabel}</strong></p>` +
`<p><a href="${params.actionUrl}">Open in Control Tower</a></p>` +
`<p>If you did not expect this assignment, please contact your admin.</p>`;
return { subject, text, html };
}
export function buildDowntimeActionReminderEmail(params: {
appName: string;
orgName: string;
actionTitle: string;
assigneeName: string;
dueDate: string | null;
actionUrl: string;
priority: string;
status: string;
}) {
const dueLabel = params.dueDate ? `Due ${params.dueDate}` : "No due date";
const subject = `Reminder: ${params.actionTitle}`;
const text =
`Hi ${params.assigneeName},\n\n` +
`Reminder for your action in ${params.orgName} (${params.appName}).\n\n` +
`Title: ${params.actionTitle}\n` +
`Status: ${params.status}\n` +
`Priority: ${params.priority}\n` +
`${dueLabel}\n\n` +
`Open in Control Tower:\n${params.actionUrl}\n\n` +
`If you have already completed this action, you can mark it done in the app.`;
const html =
`<p>Hi ${params.assigneeName},</p>` +
`<p>Reminder for your action in ${params.orgName} (${params.appName}).</p>` +
`<p><strong>Title:</strong> ${params.actionTitle}<br />` +
`<strong>Status:</strong> ${params.status}<br />` +
`<strong>Priority:</strong> ${params.priority}<br />` +
`<strong>${dueLabel}</strong></p>` +
`<p><a href="${params.actionUrl}">Open in Control Tower</a></p>` +
`<p>If you have already completed this action, you can mark it done in the app.</p>`;
return { subject, text, html };
}

View File

@@ -9,12 +9,12 @@
"common.close": "Close", "common.close": "Close",
"common.save": "Save", "common.save": "Save",
"common.copy": "Copy", "common.copy": "Copy",
"nav.overview": "Overview", "nav.overview": "Overview",
"nav.machines": "Machines", "nav.machines": "Machines",
"nav.reports": "Reports", "nav.reports": "Reports",
"nav.alerts": "Alerts", "nav.alerts": "Alerts",
"nav.financial": "Financial", "nav.financial": "Financial",
"nav.settings": "Settings", "nav.settings": "Settings",
"sidebar.productTitle": "MIS", "sidebar.productTitle": "MIS",
"sidebar.productSubtitle": "Control Tower", "sidebar.productSubtitle": "Control Tower",
"sidebar.userFallback": "User", "sidebar.userFallback": "User",
@@ -170,11 +170,11 @@
"machine.detail.tooltip.deviation": "Deviation", "machine.detail.tooltip.deviation": "Deviation",
"machine.detail.kpi.updated": "Updated {time}", "machine.detail.kpi.updated": "Updated {time}",
"machine.detail.currentWorkOrder": "Current Work Order", "machine.detail.currentWorkOrder": "Current Work Order",
"machine.detail.recentEvents": "Critical Events", "machine.detail.recentEvents": "Critical Events",
"machine.detail.noEvents": "No events yet.", "machine.detail.noEvents": "No events yet.",
"machine.detail.cycleTarget": "Cycle target", "machine.detail.cycleTarget": "Cycle target",
"machine.detail.mini.events": "Detected Events", "machine.detail.mini.events": "Detected Events",
"machine.detail.mini.events.subtitle": "Canonical events (all)", "machine.detail.mini.events.subtitle": "Canonical events (all)",
"machine.detail.mini.deviation": "Actual vs Standard Cycle", "machine.detail.mini.deviation": "Actual vs Standard Cycle",
"machine.detail.mini.deviation.subtitle": "Average deviation", "machine.detail.mini.deviation.subtitle": "Average deviation",
"machine.detail.mini.impact": "Production Impact", "machine.detail.mini.impact": "Production Impact",
@@ -219,106 +219,106 @@
"reports.scrapTrend": "Scrap Trend", "reports.scrapTrend": "Scrap Trend",
"reports.topLossDrivers": "Top Loss Drivers", "reports.topLossDrivers": "Top Loss Drivers",
"reports.qualitySummary": "Quality Summary", "reports.qualitySummary": "Quality Summary",
"reports.notes": "Notes for Ops", "reports.notes": "Notes for Ops",
"alerts.title": "Alerts", "alerts.title": "Alerts",
"alerts.subtitle": "Alert history with filters and drilldowns.", "alerts.subtitle": "Alert history with filters and drilldowns.",
"alerts.comingSoon": "Alert configuration UI is coming soon.", "alerts.comingSoon": "Alert configuration UI is coming soon.",
"alerts.loading": "Loading alerts...", "alerts.loading": "Loading alerts...",
"alerts.error.loadPolicy": "Failed to load alert policy.", "alerts.error.loadPolicy": "Failed to load alert policy.",
"alerts.error.savePolicy": "Failed to save alert policy.", "alerts.error.savePolicy": "Failed to save alert policy.",
"alerts.error.loadContacts": "Failed to load alert contacts.", "alerts.error.loadContacts": "Failed to load alert contacts.",
"alerts.error.saveContacts": "Failed to save alert contact.", "alerts.error.saveContacts": "Failed to save alert contact.",
"alerts.error.deleteContact": "Failed to delete alert contact.", "alerts.error.deleteContact": "Failed to delete alert contact.",
"alerts.error.createContact": "Failed to create alert contact.", "alerts.error.createContact": "Failed to create alert contact.",
"alerts.policy.title": "Alert policy", "alerts.policy.title": "Alert policy",
"alerts.policy.subtitle": "Configure escalation by role, channel, and duration.", "alerts.policy.subtitle": "Configure escalation by role, channel, and duration.",
"alerts.policy.save": "Save policy", "alerts.policy.save": "Save policy",
"alerts.policy.saving": "Saving...", "alerts.policy.saving": "Saving...",
"alerts.policy.defaults": "Default escalation (per role)", "alerts.policy.defaults": "Default escalation (per role)",
"alerts.policy.enabled": "Enabled", "alerts.policy.enabled": "Enabled",
"alerts.policy.afterMinutes": "After minutes", "alerts.policy.afterMinutes": "After minutes",
"alerts.policy.channels": "Channels", "alerts.policy.channels": "Channels",
"alerts.policy.repeatMinutes": "Repeat (min)", "alerts.policy.repeatMinutes": "Repeat (min)",
"alerts.policy.readOnly": "You can view alert policy settings, but only owners can edit.", "alerts.policy.readOnly": "You can view alert policy settings, but only owners can edit.",
"alerts.policy.defaultsHelp": "Defaults apply when a specific event is reset or not customized.", "alerts.policy.defaultsHelp": "Defaults apply when a specific event is reset or not customized.",
"alerts.policy.eventSelectLabel": "Event type", "alerts.policy.eventSelectLabel": "Event type",
"alerts.policy.eventSelectHelper": "Adjust escalation rules for a single event type.", "alerts.policy.eventSelectHelper": "Adjust escalation rules for a single event type.",
"alerts.policy.applyDefaults": "Apply defaults", "alerts.policy.applyDefaults": "Apply defaults",
"alerts.event.macrostop": "Macrostop", "alerts.event.macrostop": "Macrostop",
"alerts.event.microstop": "Microstop", "alerts.event.microstop": "Microstop",
"alerts.event.slow-cycle": "Slow cycle", "alerts.event.slow-cycle": "Slow cycle",
"alerts.event.offline": "Offline", "alerts.event.offline": "Offline",
"alerts.event.error": "Error", "alerts.event.error": "Error",
"alerts.contacts.title": "Alert contacts", "alerts.contacts.title": "Alert contacts",
"alerts.contacts.subtitle": "External recipients and role targeting.", "alerts.contacts.subtitle": "External recipients and role targeting.",
"alerts.contacts.name": "Name", "alerts.contacts.name": "Name",
"alerts.contacts.roleScope": "Role scope", "alerts.contacts.roleScope": "Role scope",
"alerts.contacts.email": "Email", "alerts.contacts.email": "Email",
"alerts.contacts.phone": "Phone", "alerts.contacts.phone": "Phone",
"alerts.contacts.eventTypes": "Event types (optional)", "alerts.contacts.eventTypes": "Event types (optional)",
"alerts.contacts.eventTypesPlaceholder": "macrostop, microstop, offline", "alerts.contacts.eventTypesPlaceholder": "macrostop, microstop, offline",
"alerts.contacts.eventTypesHelper": "Leave empty to receive all event types.", "alerts.contacts.eventTypesHelper": "Leave empty to receive all event types.",
"alerts.contacts.add": "Add contact", "alerts.contacts.add": "Add contact",
"alerts.contacts.creating": "Adding...", "alerts.contacts.creating": "Adding...",
"alerts.contacts.empty": "No alert contacts yet.", "alerts.contacts.empty": "No alert contacts yet.",
"alerts.contacts.save": "Save", "alerts.contacts.save": "Save",
"alerts.contacts.saving": "Saving...", "alerts.contacts.saving": "Saving...",
"alerts.contacts.delete": "Delete", "alerts.contacts.delete": "Delete",
"alerts.contacts.deleting": "Deleting...", "alerts.contacts.deleting": "Deleting...",
"alerts.contacts.active": "Active", "alerts.contacts.active": "Active",
"alerts.contacts.linkedUser": "Linked user (edit in profile)", "alerts.contacts.linkedUser": "Linked user (edit in profile)",
"alerts.contacts.role.custom": "Custom", "alerts.contacts.role.custom": "Custom",
"alerts.contacts.role.member": "Member", "alerts.contacts.role.member": "Member",
"alerts.contacts.role.admin": "Admin", "alerts.contacts.role.admin": "Admin",
"alerts.contacts.role.owner": "Owner", "alerts.contacts.role.owner": "Owner",
"alerts.contacts.readOnly": "You can view contacts, but only owners can add or edit.", "alerts.contacts.readOnly": "You can view contacts, but only owners can add or edit.",
"alerts.inbox.title": "Alerts Inbox", "alerts.inbox.title": "Alerts Inbox",
"alerts.inbox.loading": "Loading alerts...", "alerts.inbox.loading": "Loading alerts...",
"alerts.inbox.loadingFilters": "Loading filters...", "alerts.inbox.loadingFilters": "Loading filters...",
"alerts.inbox.empty": "No alerts found.", "alerts.inbox.empty": "No alerts found.",
"alerts.inbox.error": "Failed to load alerts.", "alerts.inbox.error": "Failed to load alerts.",
"alerts.inbox.range.24h": "Last 24 hours", "alerts.inbox.range.24h": "Last 24 hours",
"alerts.inbox.range.7d": "Last 7 days", "alerts.inbox.range.7d": "Last 7 days",
"alerts.inbox.range.30d": "Last 30 days", "alerts.inbox.range.30d": "Last 30 days",
"alerts.inbox.range.custom": "Custom", "alerts.inbox.range.custom": "Custom",
"alerts.inbox.filters.title": "Filters", "alerts.inbox.filters.title": "Filters",
"alerts.inbox.filters.range": "Range", "alerts.inbox.filters.range": "Range",
"alerts.inbox.filters.start": "Start", "alerts.inbox.filters.start": "Start",
"alerts.inbox.filters.end": "End", "alerts.inbox.filters.end": "End",
"alerts.inbox.filters.machine": "Machine", "alerts.inbox.filters.machine": "Machine",
"alerts.inbox.filters.site": "Site", "alerts.inbox.filters.site": "Site",
"alerts.inbox.filters.shift": "Shift", "alerts.inbox.filters.shift": "Shift",
"alerts.inbox.filters.type": "Classification", "alerts.inbox.filters.type": "Classification",
"alerts.inbox.filters.severity": "Severity", "alerts.inbox.filters.severity": "Severity",
"alerts.inbox.filters.status": "Status", "alerts.inbox.filters.status": "Status",
"alerts.inbox.filters.search": "Search", "alerts.inbox.filters.search": "Search",
"alerts.inbox.filters.searchPlaceholder": "Title, description, machine...", "alerts.inbox.filters.searchPlaceholder": "Title, description, machine...",
"alerts.inbox.filters.includeUpdates": "Include updates", "alerts.inbox.filters.includeUpdates": "Include updates",
"alerts.inbox.filters.allMachines": "All machines", "alerts.inbox.filters.allMachines": "All machines",
"alerts.inbox.filters.allSites": "All sites", "alerts.inbox.filters.allSites": "All sites",
"alerts.inbox.filters.allShifts": "All shifts", "alerts.inbox.filters.allShifts": "All shifts",
"alerts.inbox.filters.allTypes": "All types", "alerts.inbox.filters.allTypes": "All types",
"alerts.inbox.filters.allSeverities": "All severities", "alerts.inbox.filters.allSeverities": "All severities",
"alerts.inbox.filters.allStatuses": "All statuses", "alerts.inbox.filters.allStatuses": "All statuses",
"alerts.inbox.table.time": "Time", "alerts.inbox.table.time": "Time",
"alerts.inbox.table.machine": "Machine", "alerts.inbox.table.machine": "Machine",
"alerts.inbox.table.site": "Site", "alerts.inbox.table.site": "Site",
"alerts.inbox.table.shift": "Shift", "alerts.inbox.table.shift": "Shift",
"alerts.inbox.table.type": "Type", "alerts.inbox.table.type": "Type",
"alerts.inbox.table.severity": "Severity", "alerts.inbox.table.severity": "Severity",
"alerts.inbox.table.status": "Status", "alerts.inbox.table.status": "Status",
"alerts.inbox.table.duration": "Duration", "alerts.inbox.table.duration": "Duration",
"alerts.inbox.table.title": "Title", "alerts.inbox.table.title": "Title",
"alerts.inbox.table.unknown": "Unknown", "alerts.inbox.table.unknown": "Unknown",
"alerts.inbox.status.active": "Active", "alerts.inbox.status.active": "Active",
"alerts.inbox.status.resolved": "Resolved", "alerts.inbox.status.resolved": "Resolved",
"alerts.inbox.status.unknown": "Unknown", "alerts.inbox.status.unknown": "Unknown",
"alerts.inbox.duration.na": "n/a", "alerts.inbox.duration.na": "n/a",
"alerts.inbox.duration.sec": "s", "alerts.inbox.duration.sec": "s",
"alerts.inbox.duration.min": " min", "alerts.inbox.duration.min": " min",
"alerts.inbox.duration.hr": " h", "alerts.inbox.duration.hr": " h",
"alerts.inbox.meta.workOrder": "WO", "alerts.inbox.meta.workOrder": "WO",
"alerts.inbox.meta.sku": "SKU", "alerts.inbox.meta.sku": "SKU",
"reports.notes.suggested": "Suggested actions", "reports.notes.suggested": "Suggested actions",
"reports.notes.none": "No insights yet. Generate reports after data collection.", "reports.notes.none": "No insights yet. Generate reports after data collection.",
"reports.noTrend": "No trend data yet.", "reports.noTrend": "No trend data yet.",
@@ -355,14 +355,14 @@
"reports.pdf.cycleDistribution": "Cycle Time Distribution", "reports.pdf.cycleDistribution": "Cycle Time Distribution",
"reports.pdf.notes": "Notes for Ops", "reports.pdf.notes": "Notes for Ops",
"reports.pdf.none": "None", "reports.pdf.none": "None",
"settings.title": "Settings", "settings.title": "Settings",
"settings.subtitle": "Live configuration for shifts, alerts, and defaults.", "settings.subtitle": "Live configuration for shifts, alerts, and defaults.",
"settings.tabs.general": "General", "settings.tabs.general": "General",
"settings.tabs.shifts": "Shifts", "settings.tabs.shifts": "Shifts",
"settings.tabs.thresholds": "Thresholds", "settings.tabs.thresholds": "Thresholds",
"settings.tabs.alerts": "Alerts", "settings.tabs.alerts": "Alerts",
"settings.tabs.financial": "Financial", "settings.tabs.financial": "Financial",
"settings.tabs.team": "Team", "settings.tabs.team": "Team",
"settings.loading": "Loading settings...", "settings.loading": "Loading settings...",
"settings.loadingTeam": "Loading team...", "settings.loadingTeam": "Loading team...",
"settings.refresh": "Refresh", "settings.refresh": "Refresh",
@@ -442,68 +442,75 @@
"settings.role.admin": "Admin", "settings.role.admin": "Admin",
"settings.role.member": "Member", "settings.role.member": "Member",
"settings.role.inactive": "Inactive", "settings.role.inactive": "Inactive",
"settings.integrations": "Integrations", "settings.integrations": "Integrations",
"settings.integrations.webhook": "Webhook URL", "settings.integrations.webhook": "Webhook URL",
"settings.integrations.erp": "ERP Sync", "settings.integrations.erp": "ERP Sync",
"settings.integrations.erpNotConfigured": "Not configured", "settings.integrations.erpNotConfigured": "Not configured",
"financial.title": "Financial Impact", "financial.title": "Financial Impact",
"financial.subtitle": "Translate downtime, slow cycles, and scrap into money.", "financial.subtitle": "Translate downtime, slow cycles, and scrap into money.",
"financial.ownerOnly": "Financial impact is available only to owners.", "financial.ownerOnly": "Financial impact is available only to owners.",
"financial.costsMoved": "Cost settings are now in", "financial.costsMoved": "Cost settings are now in",
"financial.costsMovedLink": "Settings -> Financial", "financial.costsMovedLink": "Settings -> Financial",
"financial.export.html": "HTML", "financial.export.html": "HTML",
"financial.export.csv": "CSV", "financial.export.csv": "CSV",
"financial.totalLoss": "Total Loss", "financial.totalLoss": "Total Loss",
"financial.currencyLabel": "Currency: {currency}", "financial.currencyLabel": "Currency: {currency}",
"financial.noImpact": "No impact data yet.", "financial.noImpact": "No impact data yet.",
"financial.chart.title": "Lost Money Over Time", "financial.chart.title": "Lost Money Over Time",
"financial.chart.subtitle": "Stacked by event type", "financial.chart.subtitle": "Stacked by event type",
"financial.range.day": "Day", "financial.range.day": "Day",
"financial.range.week": "Week", "financial.range.week": "Week",
"financial.range.month": "Month", "financial.range.month": "Month",
"financial.filters.title": "Filters", "financial.filters.title": "Filters",
"financial.filters.machine": "Machine", "financial.filters.machine": "Machine",
"financial.filters.location": "Location", "financial.filters.location": "Location",
"financial.filters.sku": "SKU", "financial.filters.sku": "SKU",
"financial.filters.currency": "Currency", "financial.filters.currency": "Currency",
"financial.filters.allMachines": "All machines", "financial.filters.allMachines": "All machines",
"financial.filters.allLocations": "All locations", "financial.filters.allLocations": "All locations",
"financial.filters.skuPlaceholder": "Filter by SKU", "financial.filters.skuPlaceholder": "Filter by SKU",
"financial.filters.currencyPlaceholder": "MXN", "financial.filters.currencyPlaceholder": "MXN",
"financial.loadingMachines": "Loading machines...", "financial.loadingMachines": "Loading machines...",
"financial.config.title": "Cost Parameters", "financial.config.title": "Cost Parameters",
"financial.config.subtitle": "Defaults apply to all machines unless overridden.", "financial.config.subtitle": "Defaults apply to all machines unless overridden.",
"financial.config.applyOrg": "Apply org defaults to all machines", "financial.config.applyOrg": "Apply org defaults to all machines",
"financial.config.save": "Save", "financial.config.save": "Save",
"financial.config.saving": "Saving...", "financial.config.saving": "Saving...",
"financial.config.saved": "Saved", "financial.config.saved": "Saved",
"financial.config.saveFailed": "Save failed", "financial.config.saveFailed": "Save failed",
"financial.config.orgDefaults": "Org Defaults", "financial.config.orgDefaults": "Org Defaults",
"financial.config.locationOverrides": "Location Overrides", "financial.config.locationOverrides": "Location Overrides",
"financial.config.machineOverrides": "Machine Overrides", "financial.config.machineOverrides": "Machine Overrides",
"financial.config.productOverrides": "Product Overrides", "financial.config.productOverrides": "Product Overrides",
"financial.config.addLocation": "Add location override", "financial.config.addLocation": "Add location override",
"financial.config.addMachine": "Add machine override", "financial.config.addMachine": "Add machine override",
"financial.config.addProduct": "Add product override", "financial.config.addProduct": "Add product override",
"financial.config.noneLocation": "No location overrides yet.", "financial.config.noneLocation": "No location overrides yet.",
"financial.config.noneMachine": "No machine overrides yet.", "financial.config.noneMachine": "No machine overrides yet.",
"financial.config.noneProduct": "No product overrides yet.", "financial.config.noneProduct": "No product overrides yet.",
"financial.config.location": "Location", "financial.config.location": "Location",
"financial.config.selectLocation": "Select location", "financial.config.selectLocation": "Select location",
"financial.config.machine": "Machine", "financial.config.machine": "Machine",
"financial.config.selectMachine": "Select machine", "financial.config.selectMachine": "Select machine",
"financial.config.currency": "Currency", "financial.config.currency": "Currency",
"financial.config.sku": "SKU", "financial.config.sku": "SKU",
"financial.config.rawMaterialUnit": "Raw material / unit", "financial.config.rawMaterialUnit": "Raw material / unit",
"financial.config.ownerOnly": "Financial cost settings are available only to owners.", "financial.config.ownerOnly": "Financial cost settings are available only to owners.",
"financial.config.loading": "Loading financials...", "financial.config.loading": "Loading financials...",
"financial.field.machineCostPerMin": "Machine cost / min", "financial.field.machineCostPerMin": "Machine cost / min",
"financial.field.operatorCostPerMin": "Operator cost / min", "financial.field.operatorCostPerMin": "Operator cost / min",
"financial.field.ratedRunningKw": "Running kW", "financial.field.ratedRunningKw": "Running kW",
"financial.field.idleKw": "Idle kW", "financial.field.idleKw": "Idle kW",
"financial.field.kwhRate": "kWh rate", "financial.field.kwhRate": "kWh rate",
"financial.field.energyMultiplier": "Energy multiplier", "financial.field.energyMultiplier": "Energy multiplier",
"financial.field.energyCostPerMin": "Energy cost / min", "financial.field.energyCostPerMin": "Energy cost / min",
"financial.field.scrapCostPerUnit": "Scrap cost / unit", "financial.field.scrapCostPerUnit": "Scrap cost / unit",
"financial.field.rawMaterialCostPerUnit": "Raw material / unit" "financial.field.rawMaterialCostPerUnit": "Raw material / unit",
} "nav.downtime": "Downtime",
"settings.tabs.modules": "Modules",
"settings.modules.title": "Modules",
"settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.",
"settings.modules.screenless.title": "Screenless mode",
"settings.modules.screenless.helper": "Hide the Downtime module from navigation (for plants without Node-RED reason capture).",
"settings.modules.note": "This setting is org-wide."
}

View File

@@ -9,12 +9,12 @@
"common.close": "Cerrar", "common.close": "Cerrar",
"common.save": "Guardar", "common.save": "Guardar",
"common.copy": "Copiar", "common.copy": "Copiar",
"nav.overview": "Resumen", "nav.overview": "Resumen",
"nav.machines": "Máquinas", "nav.machines": "Máquinas",
"nav.reports": "Reportes", "nav.reports": "Reportes",
"nav.alerts": "Alertas", "nav.alerts": "Alertas",
"nav.financial": "Finanzas", "nav.financial": "Finanzas",
"nav.settings": "Configuración", "nav.settings": "Configuración",
"sidebar.productTitle": "MIS", "sidebar.productTitle": "MIS",
"sidebar.productSubtitle": "Control Tower", "sidebar.productSubtitle": "Control Tower",
"sidebar.userFallback": "Usuario", "sidebar.userFallback": "Usuario",
@@ -170,11 +170,11 @@
"machine.detail.tooltip.deviation": "Desviación", "machine.detail.tooltip.deviation": "Desviación",
"machine.detail.kpi.updated": "Actualizado {time}", "machine.detail.kpi.updated": "Actualizado {time}",
"machine.detail.currentWorkOrder": "Orden de trabajo actual", "machine.detail.currentWorkOrder": "Orden de trabajo actual",
"machine.detail.recentEvents": "Eventos críticos", "machine.detail.recentEvents": "Eventos críticos",
"machine.detail.noEvents": "Sin eventos aún.", "machine.detail.noEvents": "Sin eventos aún.",
"machine.detail.cycleTarget": "Ciclo objetivo", "machine.detail.cycleTarget": "Ciclo objetivo",
"machine.detail.mini.events": "Eventos detectados", "machine.detail.mini.events": "Eventos detectados",
"machine.detail.mini.events.subtitle": "Eventos canónicos (todos)", "machine.detail.mini.events.subtitle": "Eventos canónicos (todos)",
"machine.detail.mini.deviation": "Ciclo real vs estándar", "machine.detail.mini.deviation": "Ciclo real vs estándar",
"machine.detail.mini.deviation.subtitle": "Desviación promedio", "machine.detail.mini.deviation.subtitle": "Desviación promedio",
"machine.detail.mini.impact": "Impacto en producción", "machine.detail.mini.impact": "Impacto en producción",
@@ -219,106 +219,106 @@
"reports.scrapTrend": "Tendencia de scrap", "reports.scrapTrend": "Tendencia de scrap",
"reports.topLossDrivers": "Principales causas de pérdida", "reports.topLossDrivers": "Principales causas de pérdida",
"reports.qualitySummary": "Resumen de calidad", "reports.qualitySummary": "Resumen de calidad",
"reports.notes": "Notas para operaciones", "reports.notes": "Notas para operaciones",
"alerts.title": "Alertas", "alerts.title": "Alertas",
"alerts.subtitle": "Historial de alertas con filtros y detalle.", "alerts.subtitle": "Historial de alertas con filtros y detalle.",
"alerts.comingSoon": "La configuracion de alertas estara disponible pronto.", "alerts.comingSoon": "La configuracion de alertas estara disponible pronto.",
"alerts.loading": "Cargando alertas...", "alerts.loading": "Cargando alertas...",
"alerts.error.loadPolicy": "No se pudo cargar la politica de alertas.", "alerts.error.loadPolicy": "No se pudo cargar la politica de alertas.",
"alerts.error.savePolicy": "No se pudo guardar la politica de alertas.", "alerts.error.savePolicy": "No se pudo guardar la politica de alertas.",
"alerts.error.loadContacts": "No se pudieron cargar los contactos de alertas.", "alerts.error.loadContacts": "No se pudieron cargar los contactos de alertas.",
"alerts.error.saveContacts": "No se pudo guardar el contacto de alertas.", "alerts.error.saveContacts": "No se pudo guardar el contacto de alertas.",
"alerts.error.deleteContact": "No se pudo eliminar el contacto de alertas.", "alerts.error.deleteContact": "No se pudo eliminar el contacto de alertas.",
"alerts.error.createContact": "No se pudo crear el contacto de alertas.", "alerts.error.createContact": "No se pudo crear el contacto de alertas.",
"alerts.policy.title": "Politica de alertas", "alerts.policy.title": "Politica de alertas",
"alerts.policy.subtitle": "Configura escalamiento por rol, canal y duracion.", "alerts.policy.subtitle": "Configura escalamiento por rol, canal y duracion.",
"alerts.policy.save": "Guardar politica", "alerts.policy.save": "Guardar politica",
"alerts.policy.saving": "Guardando...", "alerts.policy.saving": "Guardando...",
"alerts.policy.defaults": "Escalamiento por defecto (por rol)", "alerts.policy.defaults": "Escalamiento por defecto (por rol)",
"alerts.policy.enabled": "Habilitado", "alerts.policy.enabled": "Habilitado",
"alerts.policy.afterMinutes": "Despues de minutos", "alerts.policy.afterMinutes": "Despues de minutos",
"alerts.policy.channels": "Canales", "alerts.policy.channels": "Canales",
"alerts.policy.repeatMinutes": "Repetir (min)", "alerts.policy.repeatMinutes": "Repetir (min)",
"alerts.policy.readOnly": "Puedes ver la politica de alertas, pero solo propietarios pueden editar.", "alerts.policy.readOnly": "Puedes ver la politica de alertas, pero solo propietarios pueden editar.",
"alerts.policy.defaultsHelp": "Los valores por defecto aplican cuando un evento se reinicia o no se personaliza.", "alerts.policy.defaultsHelp": "Los valores por defecto aplican cuando un evento se reinicia o no se personaliza.",
"alerts.policy.eventSelectLabel": "Tipo de evento", "alerts.policy.eventSelectLabel": "Tipo de evento",
"alerts.policy.eventSelectHelper": "Ajusta escalamiento para un solo tipo de evento.", "alerts.policy.eventSelectHelper": "Ajusta escalamiento para un solo tipo de evento.",
"alerts.policy.applyDefaults": "Aplicar por defecto", "alerts.policy.applyDefaults": "Aplicar por defecto",
"alerts.event.macrostop": "Macroparo", "alerts.event.macrostop": "Macroparo",
"alerts.event.microstop": "Microparo", "alerts.event.microstop": "Microparo",
"alerts.event.slow-cycle": "Ciclo lento", "alerts.event.slow-cycle": "Ciclo lento",
"alerts.event.offline": "Fuera de linea", "alerts.event.offline": "Fuera de linea",
"alerts.event.error": "Error", "alerts.event.error": "Error",
"alerts.contacts.title": "Contactos de alertas", "alerts.contacts.title": "Contactos de alertas",
"alerts.contacts.subtitle": "Destinatarios externos y alcance por rol.", "alerts.contacts.subtitle": "Destinatarios externos y alcance por rol.",
"alerts.contacts.name": "Nombre", "alerts.contacts.name": "Nombre",
"alerts.contacts.roleScope": "Rol", "alerts.contacts.roleScope": "Rol",
"alerts.contacts.email": "Correo", "alerts.contacts.email": "Correo",
"alerts.contacts.phone": "Telefono", "alerts.contacts.phone": "Telefono",
"alerts.contacts.eventTypes": "Tipos de evento (opcional)", "alerts.contacts.eventTypes": "Tipos de evento (opcional)",
"alerts.contacts.eventTypesPlaceholder": "macroparo, microparo, fuera-de-linea", "alerts.contacts.eventTypesPlaceholder": "macroparo, microparo, fuera-de-linea",
"alerts.contacts.eventTypesHelper": "Deja vacío para recibir todos los tipos de evento.", "alerts.contacts.eventTypesHelper": "Deja vacío para recibir todos los tipos de evento.",
"alerts.contacts.add": "Agregar contacto", "alerts.contacts.add": "Agregar contacto",
"alerts.contacts.creating": "Agregando...", "alerts.contacts.creating": "Agregando...",
"alerts.contacts.empty": "Sin contactos de alertas.", "alerts.contacts.empty": "Sin contactos de alertas.",
"alerts.contacts.save": "Guardar", "alerts.contacts.save": "Guardar",
"alerts.contacts.saving": "Guardando...", "alerts.contacts.saving": "Guardando...",
"alerts.contacts.delete": "Eliminar", "alerts.contacts.delete": "Eliminar",
"alerts.contacts.deleting": "Eliminando...", "alerts.contacts.deleting": "Eliminando...",
"alerts.contacts.active": "Activo", "alerts.contacts.active": "Activo",
"alerts.contacts.linkedUser": "Usuario vinculado (editar en perfil)", "alerts.contacts.linkedUser": "Usuario vinculado (editar en perfil)",
"alerts.contacts.role.custom": "Personalizado", "alerts.contacts.role.custom": "Personalizado",
"alerts.contacts.role.member": "Miembro", "alerts.contacts.role.member": "Miembro",
"alerts.contacts.role.admin": "Admin", "alerts.contacts.role.admin": "Admin",
"alerts.contacts.role.owner": "Propietario", "alerts.contacts.role.owner": "Propietario",
"alerts.contacts.readOnly": "Puedes ver contactos, pero solo propietarios pueden agregar o editar.", "alerts.contacts.readOnly": "Puedes ver contactos, pero solo propietarios pueden agregar o editar.",
"alerts.inbox.title": "Bandeja de alertas", "alerts.inbox.title": "Bandeja de alertas",
"alerts.inbox.loading": "Cargando alertas...", "alerts.inbox.loading": "Cargando alertas...",
"alerts.inbox.loadingFilters": "Cargando filtros...", "alerts.inbox.loadingFilters": "Cargando filtros...",
"alerts.inbox.empty": "No se encontraron alertas.", "alerts.inbox.empty": "No se encontraron alertas.",
"alerts.inbox.error": "No se pudieron cargar las alertas.", "alerts.inbox.error": "No se pudieron cargar las alertas.",
"alerts.inbox.range.24h": "Últimas 24 horas", "alerts.inbox.range.24h": "Últimas 24 horas",
"alerts.inbox.range.7d": "Últimos 7 días", "alerts.inbox.range.7d": "Últimos 7 días",
"alerts.inbox.range.30d": "Últimos 30 días", "alerts.inbox.range.30d": "Últimos 30 días",
"alerts.inbox.range.custom": "Personalizado", "alerts.inbox.range.custom": "Personalizado",
"alerts.inbox.filters.title": "Filtros", "alerts.inbox.filters.title": "Filtros",
"alerts.inbox.filters.range": "Rango", "alerts.inbox.filters.range": "Rango",
"alerts.inbox.filters.start": "Inicio", "alerts.inbox.filters.start": "Inicio",
"alerts.inbox.filters.end": "Fin", "alerts.inbox.filters.end": "Fin",
"alerts.inbox.filters.machine": "Máquina", "alerts.inbox.filters.machine": "Máquina",
"alerts.inbox.filters.site": "Sitio", "alerts.inbox.filters.site": "Sitio",
"alerts.inbox.filters.shift": "Turno", "alerts.inbox.filters.shift": "Turno",
"alerts.inbox.filters.type": "Clasificación", "alerts.inbox.filters.type": "Clasificación",
"alerts.inbox.filters.severity": "Severidad", "alerts.inbox.filters.severity": "Severidad",
"alerts.inbox.filters.status": "Estado", "alerts.inbox.filters.status": "Estado",
"alerts.inbox.filters.search": "Buscar", "alerts.inbox.filters.search": "Buscar",
"alerts.inbox.filters.searchPlaceholder": "Título, descripción, máquina...", "alerts.inbox.filters.searchPlaceholder": "Título, descripción, máquina...",
"alerts.inbox.filters.includeUpdates": "Incluir actualizaciones", "alerts.inbox.filters.includeUpdates": "Incluir actualizaciones",
"alerts.inbox.filters.allMachines": "Todas las máquinas", "alerts.inbox.filters.allMachines": "Todas las máquinas",
"alerts.inbox.filters.allSites": "Todos los sitios", "alerts.inbox.filters.allSites": "Todos los sitios",
"alerts.inbox.filters.allShifts": "Todos los turnos", "alerts.inbox.filters.allShifts": "Todos los turnos",
"alerts.inbox.filters.allTypes": "Todas las clasificaciones", "alerts.inbox.filters.allTypes": "Todas las clasificaciones",
"alerts.inbox.filters.allSeverities": "Todas las severidades", "alerts.inbox.filters.allSeverities": "Todas las severidades",
"alerts.inbox.filters.allStatuses": "Todos los estados", "alerts.inbox.filters.allStatuses": "Todos los estados",
"alerts.inbox.table.time": "Hora", "alerts.inbox.table.time": "Hora",
"alerts.inbox.table.machine": "Máquina", "alerts.inbox.table.machine": "Máquina",
"alerts.inbox.table.site": "Sitio", "alerts.inbox.table.site": "Sitio",
"alerts.inbox.table.shift": "Turno", "alerts.inbox.table.shift": "Turno",
"alerts.inbox.table.type": "Tipo", "alerts.inbox.table.type": "Tipo",
"alerts.inbox.table.severity": "Severidad", "alerts.inbox.table.severity": "Severidad",
"alerts.inbox.table.status": "Estado", "alerts.inbox.table.status": "Estado",
"alerts.inbox.table.duration": "Duración", "alerts.inbox.table.duration": "Duración",
"alerts.inbox.table.title": "Título", "alerts.inbox.table.title": "Título",
"alerts.inbox.table.unknown": "Sin dato", "alerts.inbox.table.unknown": "Sin dato",
"alerts.inbox.status.active": "Activa", "alerts.inbox.status.active": "Activa",
"alerts.inbox.status.resolved": "Resuelta", "alerts.inbox.status.resolved": "Resuelta",
"alerts.inbox.status.unknown": "Sin dato", "alerts.inbox.status.unknown": "Sin dato",
"alerts.inbox.duration.na": "n/d", "alerts.inbox.duration.na": "n/d",
"alerts.inbox.duration.sec": "s", "alerts.inbox.duration.sec": "s",
"alerts.inbox.duration.min": " min", "alerts.inbox.duration.min": " min",
"alerts.inbox.duration.hr": " h", "alerts.inbox.duration.hr": " h",
"alerts.inbox.meta.workOrder": "OT", "alerts.inbox.meta.workOrder": "OT",
"alerts.inbox.meta.sku": "SKU", "alerts.inbox.meta.sku": "SKU",
"reports.notes.suggested": "Acciones sugeridas", "reports.notes.suggested": "Acciones sugeridas",
"reports.notes.none": "Sin insights todavía. Genera reportes tras recolectar datos.", "reports.notes.none": "Sin insights todavía. Genera reportes tras recolectar datos.",
"reports.noTrend": "Sin datos de tendencia.", "reports.noTrend": "Sin datos de tendencia.",
@@ -355,14 +355,14 @@
"reports.pdf.cycleDistribution": "Distribución de tiempos de ciclo", "reports.pdf.cycleDistribution": "Distribución de tiempos de ciclo",
"reports.pdf.notes": "Notas para operaciones", "reports.pdf.notes": "Notas para operaciones",
"reports.pdf.none": "Ninguna", "reports.pdf.none": "Ninguna",
"settings.title": "Configuración", "settings.title": "Configuración",
"settings.subtitle": "Configuración en vivo para turnos, alertas y valores predeterminados.", "settings.subtitle": "Configuración en vivo para turnos, alertas y valores predeterminados.",
"settings.tabs.general": "General", "settings.tabs.general": "General",
"settings.tabs.shifts": "Turnos", "settings.tabs.shifts": "Turnos",
"settings.tabs.thresholds": "Umbrales", "settings.tabs.thresholds": "Umbrales",
"settings.tabs.alerts": "Alertas", "settings.tabs.alerts": "Alertas",
"settings.tabs.financial": "Finanzas", "settings.tabs.financial": "Finanzas",
"settings.tabs.team": "Equipo", "settings.tabs.team": "Equipo",
"settings.loading": "Cargando configuración...", "settings.loading": "Cargando configuración...",
"settings.loadingTeam": "Cargando equipo...", "settings.loadingTeam": "Cargando equipo...",
"settings.refresh": "Actualizar", "settings.refresh": "Actualizar",
@@ -442,68 +442,75 @@
"settings.role.admin": "Admin", "settings.role.admin": "Admin",
"settings.role.member": "Miembro", "settings.role.member": "Miembro",
"settings.role.inactive": "Inactivo", "settings.role.inactive": "Inactivo",
"settings.integrations": "Integraciones", "settings.integrations": "Integraciones",
"settings.integrations.webhook": "Webhook URL", "settings.integrations.webhook": "Webhook URL",
"settings.integrations.erp": "ERP Sync", "settings.integrations.erp": "ERP Sync",
"settings.integrations.erpNotConfigured": "No configurado", "settings.integrations.erpNotConfigured": "No configurado",
"financial.title": "Impacto financiero", "financial.title": "Impacto financiero",
"financial.subtitle": "Convierte paros, ciclos lentos y scrap en dinero.", "financial.subtitle": "Convierte paros, ciclos lentos y scrap en dinero.",
"financial.ownerOnly": "El impacto financiero solo está disponible para propietarios.", "financial.ownerOnly": "El impacto financiero solo está disponible para propietarios.",
"financial.costsMoved": "Los costos ahora están en", "financial.costsMoved": "Los costos ahora están en",
"financial.costsMovedLink": "Configuración -> Finanzas", "financial.costsMovedLink": "Configuración -> Finanzas",
"financial.export.html": "HTML", "financial.export.html": "HTML",
"financial.export.csv": "CSV", "financial.export.csv": "CSV",
"financial.totalLoss": "Pérdida total", "financial.totalLoss": "Pérdida total",
"financial.currencyLabel": "Moneda: {currency}", "financial.currencyLabel": "Moneda: {currency}",
"financial.noImpact": "Sin datos de impacto.", "financial.noImpact": "Sin datos de impacto.",
"financial.chart.title": "Pérdida de dinero en el tiempo", "financial.chart.title": "Pérdida de dinero en el tiempo",
"financial.chart.subtitle": "Acumulado por tipo de evento", "financial.chart.subtitle": "Acumulado por tipo de evento",
"financial.range.day": "Día", "financial.range.day": "Día",
"financial.range.week": "Semana", "financial.range.week": "Semana",
"financial.range.month": "Mes", "financial.range.month": "Mes",
"financial.filters.title": "Filtros", "financial.filters.title": "Filtros",
"financial.filters.machine": "Máquina", "financial.filters.machine": "Máquina",
"financial.filters.location": "Ubicación", "financial.filters.location": "Ubicación",
"financial.filters.sku": "SKU", "financial.filters.sku": "SKU",
"financial.filters.currency": "Moneda", "financial.filters.currency": "Moneda",
"financial.filters.allMachines": "Todas las máquinas", "financial.filters.allMachines": "Todas las máquinas",
"financial.filters.allLocations": "Todas las ubicaciones", "financial.filters.allLocations": "Todas las ubicaciones",
"financial.filters.skuPlaceholder": "Filtrar por SKU", "financial.filters.skuPlaceholder": "Filtrar por SKU",
"financial.filters.currencyPlaceholder": "MXN", "financial.filters.currencyPlaceholder": "MXN",
"financial.loadingMachines": "Cargando máquinas...", "financial.loadingMachines": "Cargando máquinas...",
"financial.config.title": "Parámetros de costo", "financial.config.title": "Parámetros de costo",
"financial.config.subtitle": "Los valores aplican a todas las máquinas salvo override.", "financial.config.subtitle": "Los valores aplican a todas las máquinas salvo override.",
"financial.config.applyOrg": "Aplicar valores de organización a todas", "financial.config.applyOrg": "Aplicar valores de organización a todas",
"financial.config.save": "Guardar", "financial.config.save": "Guardar",
"financial.config.saving": "Guardando...", "financial.config.saving": "Guardando...",
"financial.config.saved": "Guardado", "financial.config.saved": "Guardado",
"financial.config.saveFailed": "No se pudo guardar", "financial.config.saveFailed": "No se pudo guardar",
"financial.config.orgDefaults": "Valores de organización", "financial.config.orgDefaults": "Valores de organización",
"financial.config.locationOverrides": "Overrides por ubicación", "financial.config.locationOverrides": "Overrides por ubicación",
"financial.config.machineOverrides": "Overrides por máquina", "financial.config.machineOverrides": "Overrides por máquina",
"financial.config.productOverrides": "Overrides por producto", "financial.config.productOverrides": "Overrides por producto",
"financial.config.addLocation": "Agregar override de ubicación", "financial.config.addLocation": "Agregar override de ubicación",
"financial.config.addMachine": "Agregar override de máquina", "financial.config.addMachine": "Agregar override de máquina",
"financial.config.addProduct": "Agregar override de producto", "financial.config.addProduct": "Agregar override de producto",
"financial.config.noneLocation": "Sin overrides de ubicación.", "financial.config.noneLocation": "Sin overrides de ubicación.",
"financial.config.noneMachine": "Sin overrides de máquina.", "financial.config.noneMachine": "Sin overrides de máquina.",
"financial.config.noneProduct": "Sin overrides de producto.", "financial.config.noneProduct": "Sin overrides de producto.",
"financial.config.location": "Ubicación", "financial.config.location": "Ubicación",
"financial.config.selectLocation": "Selecciona ubicación", "financial.config.selectLocation": "Selecciona ubicación",
"financial.config.machine": "Máquina", "financial.config.machine": "Máquina",
"financial.config.selectMachine": "Selecciona máquina", "financial.config.selectMachine": "Selecciona máquina",
"financial.config.currency": "Moneda", "financial.config.currency": "Moneda",
"financial.config.sku": "SKU", "financial.config.sku": "SKU",
"financial.config.rawMaterialUnit": "Materia prima / unidad", "financial.config.rawMaterialUnit": "Materia prima / unidad",
"financial.config.ownerOnly": "Los costos financieros solo están disponibles para propietarios.", "financial.config.ownerOnly": "Los costos financieros solo están disponibles para propietarios.",
"financial.config.loading": "Cargando finanzas...", "financial.config.loading": "Cargando finanzas...",
"financial.field.machineCostPerMin": "Costo máquina / min", "financial.field.machineCostPerMin": "Costo máquina / min",
"financial.field.operatorCostPerMin": "Costo operador / min", "financial.field.operatorCostPerMin": "Costo operador / min",
"financial.field.ratedRunningKw": "kW en operación", "financial.field.ratedRunningKw": "kW en operación",
"financial.field.idleKw": "kW en espera", "financial.field.idleKw": "kW en espera",
"financial.field.kwhRate": "Tarifa kWh", "financial.field.kwhRate": "Tarifa kWh",
"financial.field.energyMultiplier": "Multiplicador de energía", "financial.field.energyMultiplier": "Multiplicador de energía",
"financial.field.energyCostPerMin": "Costo energía / min", "financial.field.energyCostPerMin": "Costo energía / min",
"financial.field.scrapCostPerUnit": "Costo scrap / unidad", "financial.field.scrapCostPerUnit": "Costo scrap / unidad",
"financial.field.rawMaterialCostPerUnit": "Costo materia prima / unidad" "financial.field.rawMaterialCostPerUnit": "Costo materia prima / unidad",
} "nav.downtime": "Downtime",
"settings.tabs.modules": "Módulos",
"settings.modules.title": "Módulos",
"settings.modules.subtitle": "Activa/desactiva módulos según cómo opera la planta.",
"settings.modules.screenless.title": "Modo sin pantalla",
"settings.modules.screenless.helper": "Oculta el módulo de Paros (Downtime) del menú (para plantas sin captura de razones en Node-RED).",
"settings.modules.note": "Este ajuste aplica a toda la organización."
}

52
lib/ui/screenlessMode.ts Normal file
View File

@@ -0,0 +1,52 @@
"use client";
import { useEffect, useState } from "react";
let current: boolean | null = null;
let inflight: Promise<void> | null = null;
const listeners = new Set<(next: boolean) => void>();
function notify(next: boolean) {
current = next;
listeners.forEach((fn) => fn(next));
}
async function loadScreenlessMode() {
if (inflight) return inflight;
inflight = (async () => {
try {
const res = await fetch("/api/settings", { cache: "no-store" });
const data = await res.json().catch(() => ({}));
if (res.ok && data?.ok) {
const mode = data?.settings?.modules?.screenlessMode === true;
notify(mode);
}
} catch {
// ignore fetch failures; keep current state
} finally {
inflight = null;
}
})();
return inflight;
}
export function useScreenlessMode() {
const [screenlessMode, setScreenlessMode] = useState(current ?? false);
useEffect(() => {
listeners.add(setScreenlessMode);
return () => {
listeners.delete(setScreenlessMode);
};
}, []);
useEffect(() => {
void loadScreenlessMode();
}, []);
function set(next: boolean) {
notify(next);
}
return { screenlessMode, setScreenlessMode: set };
}

View File

@@ -0,0 +1,98 @@
-- AlterTable
ALTER TABLE "public"."User" ADD COLUMN "phone" TEXT;
-- CreateTable
CREATE TABLE "public"."alert_contacts" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"user_id" TEXT,
"name" TEXT NOT NULL,
"role_scope" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"event_types" JSONB,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "alert_contacts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."alert_notifications" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"machine_id" TEXT NOT NULL,
"event_id" TEXT NOT NULL,
"event_type" TEXT NOT NULL,
"rule_id" TEXT NOT NULL,
"role" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"contact_id" TEXT,
"user_id" TEXT,
"sent_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"status" TEXT NOT NULL,
"error" TEXT,
CONSTRAINT "alert_notifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."alert_policies" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"policy_json" JSONB NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" TEXT,
CONSTRAINT "alert_policies_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "alert_contacts_org_id_idx" ON "public"."alert_contacts"("org_id" ASC);
-- CreateIndex
CREATE INDEX "alert_contacts_org_id_role_scope_idx" ON "public"."alert_contacts"("org_id" ASC, "role_scope" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "alert_contacts_org_id_user_id_key" ON "public"."alert_contacts"("org_id" ASC, "user_id" ASC);
-- CreateIndex
CREATE INDEX "alert_notifications_contact_id_idx" ON "public"."alert_notifications"("contact_id" ASC);
-- CreateIndex
CREATE INDEX "alert_notifications_org_event_role_channel_idx" ON "public"."alert_notifications"("org_id" ASC, "event_id" ASC, "role" ASC, "channel" ASC);
-- CreateIndex
CREATE INDEX "alert_notifications_org_machine_sent_idx" ON "public"."alert_notifications"("org_id" ASC, "machine_id" ASC, "sent_at" ASC);
-- CreateIndex
CREATE INDEX "alert_notifications_user_id_idx" ON "public"."alert_notifications"("user_id" ASC);
-- CreateIndex
CREATE INDEX "alert_policies_org_id_idx" ON "public"."alert_policies"("org_id" ASC);
-- CreateIndex
CREATE UNIQUE INDEX "alert_policies_org_id_key" ON "public"."alert_policies"("org_id" ASC);
-- AddForeignKey
ALTER TABLE "public"."alert_contacts" ADD CONSTRAINT "alert_contacts_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."Org"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "public"."alert_contacts" ADD CONSTRAINT "alert_contacts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "public"."alert_notifications" ADD CONSTRAINT "alert_notifications_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "public"."alert_contacts"("id") ON DELETE SET NULL ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "public"."alert_notifications" ADD CONSTRAINT "alert_notifications_machine_id_fkey" FOREIGN KEY ("machine_id") REFERENCES "public"."Machine"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "public"."alert_notifications" ADD CONSTRAINT "alert_notifications_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."Org"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "public"."alert_notifications" ADD CONSTRAINT "alert_notifications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "public"."alert_policies" ADD CONSTRAINT "alert_policies_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."Org"("id") ON DELETE CASCADE ON UPDATE NO ACTION;

View File

@@ -0,0 +1,99 @@
-- DropForeignKey
ALTER TABLE "alert_contacts" DROP CONSTRAINT "alert_contacts_org_id_fkey";
-- DropForeignKey
ALTER TABLE "alert_contacts" DROP CONSTRAINT "alert_contacts_user_id_fkey";
-- DropForeignKey
ALTER TABLE "alert_notifications" DROP CONSTRAINT "alert_notifications_contact_id_fkey";
-- DropForeignKey
ALTER TABLE "alert_notifications" DROP CONSTRAINT "alert_notifications_machine_id_fkey";
-- DropForeignKey
ALTER TABLE "alert_notifications" DROP CONSTRAINT "alert_notifications_org_id_fkey";
-- DropForeignKey
ALTER TABLE "alert_notifications" DROP CONSTRAINT "alert_notifications_user_id_fkey";
-- DropForeignKey
ALTER TABLE "alert_policies" DROP CONSTRAINT "alert_policies_org_id_fkey";
-- AlterTable
ALTER TABLE "alert_contacts" ALTER COLUMN "updated_at" DROP DEFAULT;
-- AlterTable
ALTER TABLE "alert_policies" ALTER COLUMN "updated_at" DROP DEFAULT;
-- CreateTable
CREATE TABLE "ReasonEntry" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"machineId" TEXT NOT NULL,
"reasonId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"episodeId" TEXT,
"durationSeconds" INTEGER,
"episodeEndTs" TIMESTAMP(3),
"scrapEntryId" TEXT,
"scrapQty" INTEGER,
"scrapUnit" TEXT,
"reasonCode" TEXT NOT NULL,
"reasonLabel" TEXT,
"reasonText" TEXT,
"capturedAt" TIMESTAMP(3) NOT NULL,
"workOrderId" TEXT,
"meta" JSONB,
"schemaVersion" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ReasonEntry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ReasonEntry_reasonId_key" ON "ReasonEntry"("reasonId");
-- CreateIndex
CREATE INDEX "ReasonEntry_orgId_machineId_capturedAt_idx" ON "ReasonEntry"("orgId", "machineId", "capturedAt");
-- CreateIndex
CREATE INDEX "ReasonEntry_orgId_kind_capturedAt_idx" ON "ReasonEntry"("orgId", "kind", "capturedAt");
-- CreateIndex
CREATE UNIQUE INDEX "ReasonEntry_orgId_kind_episodeId_key" ON "ReasonEntry"("orgId", "kind", "episodeId");
-- CreateIndex
CREATE UNIQUE INDEX "ReasonEntry_orgId_kind_scrapEntryId_key" ON "ReasonEntry"("orgId", "kind", "scrapEntryId");
-- AddForeignKey
ALTER TABLE "alert_policies" ADD CONSTRAINT "alert_policies_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "alert_contacts" ADD CONSTRAINT "alert_contacts_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "alert_contacts" ADD CONSTRAINT "alert_contacts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "alert_notifications" ADD CONSTRAINT "alert_notifications_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "alert_notifications" ADD CONSTRAINT "alert_notifications_machine_id_fkey" FOREIGN KEY ("machine_id") REFERENCES "Machine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "alert_notifications" ADD CONSTRAINT "alert_notifications_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "alert_contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "alert_notifications" ADD CONSTRAINT "alert_notifications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReasonEntry" ADD CONSTRAINT "ReasonEntry_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReasonEntry" ADD CONSTRAINT "ReasonEntry_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- RenameIndex
ALTER INDEX "alert_notifications_org_event_role_channel_idx" RENAME TO "alert_notifications_org_id_event_id_role_channel_idx";
-- RenameIndex
ALTER INDEX "alert_notifications_org_machine_sent_idx" RENAME TO "alert_notifications_org_id_machine_id_sent_at_idx";

View File

@@ -0,0 +1,53 @@
-- CreateTable
CREATE TABLE "downtime_actions" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"machine_id" TEXT,
"reason_code" TEXT,
"hm_day" INTEGER,
"hm_hour" INTEGER,
"title" TEXT NOT NULL,
"notes" TEXT,
"status" TEXT NOT NULL DEFAULT 'open',
"priority" TEXT NOT NULL DEFAULT 'medium',
"due_date" TIMESTAMP(3),
"reminder_at" TIMESTAMP(3),
"last_reminder_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"owner_user_id" TEXT,
"created_by" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "downtime_actions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "downtime_actions_org_id_idx" ON "downtime_actions"("org_id");
-- CreateIndex
CREATE INDEX "downtime_actions_org_id_machine_id_idx" ON "downtime_actions"("org_id", "machine_id");
-- CreateIndex
CREATE INDEX "downtime_actions_org_id_reason_code_idx" ON "downtime_actions"("org_id", "reason_code");
-- CreateIndex
CREATE INDEX "downtime_actions_org_id_hm_day_hm_hour_idx" ON "downtime_actions"("org_id", "hm_day", "hm_hour");
-- CreateIndex
CREATE INDEX "downtime_actions_org_id_status_due_date_idx" ON "downtime_actions"("org_id", "status", "due_date");
-- CreateIndex
CREATE INDEX "downtime_actions_owner_user_id_idx" ON "downtime_actions"("owner_user_id");
-- AddForeignKey
ALTER TABLE "downtime_actions" ADD CONSTRAINT "downtime_actions_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "downtime_actions" ADD CONSTRAINT "downtime_actions_machine_id_fkey" FOREIGN KEY ("machine_id") REFERENCES "Machine"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "downtime_actions" ADD CONSTRAINT "downtime_actions_owner_user_id_fkey" FOREIGN KEY ("owner_user_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "downtime_actions" ADD CONSTRAINT "downtime_actions_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "downtime_actions" ADD COLUMN "reminder_stage" TEXT;

View File

@@ -32,6 +32,9 @@ model Org {
locationFinancialOverrides LocationFinancialOverride[] locationFinancialOverrides LocationFinancialOverride[]
machineFinancialOverrides MachineFinancialOverride[] machineFinancialOverrides MachineFinancialOverride[]
productCostOverrides ProductCostOverride[] productCostOverrides ProductCostOverride[]
reasonEntries ReasonEntry[]
downtimeActions DowntimeAction[]
} }
model User { model User {
@@ -51,6 +54,8 @@ model User {
sentInvites OrgInvite[] @relation("OrgInviteInviter") sentInvites OrgInvite[] @relation("OrgInviteInviter")
alertContacts AlertContact[] alertContacts AlertContact[]
alertNotifications AlertNotification[] alertNotifications AlertNotification[]
downtimeActionsOwned DowntimeAction[] @relation("DowntimeActionOwner")
downtimeActionsCreated DowntimeAction[] @relation("DowntimeActionCreator")
} }
model OrgUser { model OrgUser {
@@ -135,6 +140,9 @@ model Machine {
settingsAudits SettingsAudit[] settingsAudits SettingsAudit[]
alertNotifications AlertNotification[] alertNotifications AlertNotification[]
financialOverrides MachineFinancialOverride[] financialOverrides MachineFinancialOverride[]
reasonEntries ReasonEntry[]
downtimeActions DowntimeAction[]
@@unique([orgId, name]) @@unique([orgId, name])
@@index([orgId]) @@index([orgId])
@@ -517,3 +525,83 @@ model SettingsAudit {
@@index([machineId, createdAt]) @@index([machineId, createdAt])
@@map("settings_audit") @@map("settings_audit")
} }
model ReasonEntry {
id String @id @default(uuid())
orgId String
machineId String
// idempotency key from Edge (rsn_<ulid>)
reasonId String @unique
// "downtime" | "scrap"
kind String
// For downtime reasons
episodeId String?
durationSeconds Int?
episodeEndTs DateTime?
// For scrap reasons
scrapEntryId String?
scrapQty Int?
scrapUnit String?
// Required reason
reasonCode String
reasonLabel String?
reasonText String?
capturedAt DateTime
workOrderId String?
meta Json?
schemaVersion Int @default(1)
createdAt DateTime @default(now())
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
@@index([orgId, machineId, capturedAt])
@@index([orgId, kind, capturedAt])
@@unique([orgId, kind, episodeId])
@@unique([orgId, kind, scrapEntryId])
}
model DowntimeAction {
id String @id @default(uuid())
orgId String @map("org_id")
machineId String? @map("machine_id")
reasonCode String? @map("reason_code")
hmDay Int? @map("hm_day")
hmHour Int? @map("hm_hour")
title String
notes String?
status String @default("open")
priority String @default("medium")
dueDate DateTime? @map("due_date")
reminderAt DateTime? @map("reminder_at")
lastReminderAt DateTime? @map("last_reminder_at")
reminderStage String? @map("reminder_stage")
completedAt DateTime? @map("completed_at")
ownerUserId String? @map("owner_user_id")
createdBy String? @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
machine Machine? @relation(fields: [machineId], references: [id], onDelete: SetNull)
ownerUser User? @relation("DowntimeActionOwner", fields: [ownerUserId], references: [id], onDelete: SetNull)
creator User? @relation("DowntimeActionCreator", fields: [createdBy], references: [id], onDelete: SetNull)
@@index([orgId])
@@index([orgId, machineId])
@@index([orgId, reasonCode])
@@index([orgId, hmDay, hmHour])
@@index([orgId, status, dueDate])
@@index([ownerUserId])
@@map("downtime_actions")
}