Final MVP valid
This commit is contained in:
55
README.md
55
README.md
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
29
app/(app)/downtime/layout.tsx
Normal file
29
app/(app)/downtime/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
5
app/(app)/downtime/page.tsx
Normal file
5
app/(app)/downtime/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import DowntimePageClient from "@/components/downtime/DowntimePageClient";
|
||||
|
||||
export default function DowntimePage() {
|
||||
return <DowntimePageClient />;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import DowntimeParetoCard from "@/components/analytics/DowntimeParetoCard";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
@@ -1013,6 +1014,34 @@ export default function MachineDetailClient() {
|
||||
activeStoppage={activeStoppage}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">Downtime (preview)</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">Top reasons + quick pareto</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/downtime?machineId=${encodeURIComponent(machineId)}&range=7d`}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white hover:bg-white/10"
|
||||
>
|
||||
View full report →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<DowntimeParetoCard
|
||||
machineId={machineId}
|
||||
range="7d"
|
||||
variant="summary"
|
||||
maxBars={5}
|
||||
showCoverage={true}
|
||||
showOpenFullReport={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-1">
|
||||
|
||||
15
app/(app)/reports/downtime-pareto/page.tsx
Normal file
15
app/(app)/reports/downtime-pareto/page.tsx
Normal 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");
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AlertsConfig } from "@/components/settings/AlertsConfig";
|
||||
import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||
|
||||
|
||||
type Shift = {
|
||||
name: string;
|
||||
@@ -16,6 +18,11 @@ type SettingsPayload = {
|
||||
orgId?: string;
|
||||
version?: number;
|
||||
timezone?: string;
|
||||
|
||||
modules: {
|
||||
screenlessMode: boolean;
|
||||
};
|
||||
|
||||
shiftSchedule: {
|
||||
shifts: Shift[];
|
||||
shiftChangeCompensationMin: number;
|
||||
@@ -42,6 +49,7 @@ type SettingsPayload = {
|
||||
updatedBy?: string;
|
||||
};
|
||||
|
||||
|
||||
type OrgInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -77,6 +85,7 @@ const DEFAULT_SETTINGS: SettingsPayload = {
|
||||
orgId: "",
|
||||
version: 0,
|
||||
timezone: "UTC",
|
||||
modules: { screenlessMode: false },
|
||||
shiftSchedule: {
|
||||
shifts: [],
|
||||
shiftChangeCompensationMin: 10,
|
||||
@@ -105,6 +114,7 @@ const DEFAULT_SETTINGS: SettingsPayload = {
|
||||
|
||||
const SETTINGS_TABS = [
|
||||
{ id: "general", labelKey: "settings.tabs.general" },
|
||||
{ id: "modules", labelKey: "settings.tabs.modules" },
|
||||
{ id: "shifts", labelKey: "settings.tabs.shifts" },
|
||||
{ id: "thresholds", labelKey: "settings.tabs.thresholds" },
|
||||
{ id: "alerts", labelKey: "settings.tabs.alerts" },
|
||||
@@ -191,6 +201,7 @@ function normalizeShift(raw: unknown, fallbackName: string): Shift {
|
||||
|
||||
function normalizeSettings(raw: unknown, fallbackName: (index: number) => string): SettingsPayload {
|
||||
const record = asRecord(raw);
|
||||
const modules = asRecord(record?.modules) ?? {};
|
||||
if (!record) {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
@@ -253,6 +264,9 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string
|
||||
moldTotal: Number(defaults.moldTotal ?? DEFAULT_SETTINGS.defaults.moldTotal),
|
||||
moldActive: Number(defaults.moldActive ?? DEFAULT_SETTINGS.defaults.moldActive),
|
||||
},
|
||||
modules: {
|
||||
screenlessMode: (modules.screenlessMode as boolean | undefined) ?? false,
|
||||
},
|
||||
updatedAt: record.updatedAt ? String(record.updatedAt) : "",
|
||||
updatedBy: record.updatedBy ? String(record.updatedBy) : "",
|
||||
};
|
||||
@@ -296,6 +310,7 @@ function Toggle({
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t, locale } = useI18n();
|
||||
const { setScreenlessMode } = useScreenlessMode();
|
||||
const [draft, setDraft] = useState<SettingsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -329,6 +344,7 @@ export default function SettingsPage() {
|
||||
}
|
||||
const next = normalizeSettings(api.record?.settings, defaultShiftName);
|
||||
setDraft(next);
|
||||
setScreenlessMode(next.modules.screenlessMode);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("settings.failedLoad"));
|
||||
} finally {
|
||||
@@ -576,6 +592,7 @@ export default function SettingsPage() {
|
||||
source: "control_tower",
|
||||
version: draft.version,
|
||||
timezone: draft.timezone,
|
||||
modules: draft.modules,
|
||||
shiftSchedule: draft.shiftSchedule,
|
||||
thresholds: draft.thresholds,
|
||||
alerts: draft.alerts,
|
||||
@@ -593,6 +610,7 @@ export default function SettingsPage() {
|
||||
}
|
||||
const next = normalizeSettings(api.record?.settings, defaultShiftName);
|
||||
setDraft(next);
|
||||
setScreenlessMode(next.modules.screenlessMode);
|
||||
setSaveStatus("saved");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("settings.failedSave"));
|
||||
@@ -680,7 +698,12 @@ export default function SettingsPage() {
|
||||
: "rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-200 hover:bg-white/10"
|
||||
}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
{(() => {
|
||||
const label = t(tab.labelKey);
|
||||
return label === tab.labelKey
|
||||
? tab.id.charAt(0).toUpperCase() + tab.id.slice(1)
|
||||
: label;
|
||||
})()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -766,6 +789,38 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "modules" && (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-sm font-semibold text-white">{t("settings.modules.title")}</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">{t("settings.modules.subtitle")}</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<Toggle
|
||||
label={t("settings.modules.screenless.title")}
|
||||
helper={t("settings.modules.screenless.helper")}
|
||||
enabled={draft.modules.screenlessMode}
|
||||
onChange={(next) =>
|
||||
setDraft((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
modules: { ...prev.modules, screenlessMode: next },
|
||||
}
|
||||
: prev
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-zinc-500">
|
||||
Org-wide setting. Hides Downtime from navigation for all users in this org.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{activeTab === "thresholds" && (
|
||||
<div className="space-y-6">
|
||||
|
||||
64
app/api/analytics/coverage/route.ts
Normal file
64
app/api/analytics/coverage/route.ts
Normal 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.",
|
||||
});
|
||||
}
|
||||
130
app/api/analytics/downtime-events/route.ts
Normal file
130
app/api/analytics/downtime-events/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
123
app/api/analytics/pareto/route.ts
Normal file
123
app/api/analytics/pareto/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
226
app/api/downtime/actions/[id]/route.ts
Normal file
226
app/api/downtime/actions/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
123
app/api/downtime/actions/reminders/route.ts
Normal file
123
app/api/downtime/actions/reminders/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
226
app/api/downtime/actions/route.ts
Normal file
226
app/api/downtime/actions/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
150
app/api/ingest/reason/route.ts
Normal file
150
app/api/ingest/reason/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -37,6 +37,7 @@ function canManageSettings(role?: string | null) {
|
||||
const settingsPayloadSchema = z
|
||||
.object({
|
||||
source: z.string().trim().max(40).optional(),
|
||||
modules: z.any().optional(),
|
||||
timezone: z.string().trim().max(64).optional(),
|
||||
shiftSchedule: z.any().optional(),
|
||||
thresholds: z.any().optional(),
|
||||
@@ -87,7 +88,7 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
|
||||
performanceThresholdPct: 85,
|
||||
qualitySpikeDeltaPct: 5,
|
||||
alertsJson: DEFAULT_ALERTS,
|
||||
defaultsJson: DEFAULT_DEFAULTS,
|
||||
defaultsJson: { ...(DEFAULT_DEFAULTS as any), modules: { screenlessMode: false } },
|
||||
updatedBy: userId,
|
||||
},
|
||||
});
|
||||
@@ -122,7 +123,13 @@ export async function GET() {
|
||||
});
|
||||
|
||||
const payload = buildSettingsPayload(loaded.settings, loaded.shifts ?? []);
|
||||
return NextResponse.json({ ok: true, settings: payload });
|
||||
|
||||
const defaultsRaw = isPlainObject(loaded.settings.defaultsJson) ? (loaded.settings.defaultsJson as any) : {};
|
||||
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
|
||||
const modules = { screenlessMode: modulesRaw.screenlessMode === true };
|
||||
|
||||
return NextResponse.json({ ok: true, settings: { ...payload, modules } });
|
||||
|
||||
} catch (err) {
|
||||
console.error("[settings GET] failed", err);
|
||||
const message = err instanceof Error ? err.message : "Internal error";
|
||||
@@ -156,13 +163,18 @@ export async function PUT(req: Request) {
|
||||
const alerts = parsed.data.alerts;
|
||||
const defaults = parsed.data.defaults;
|
||||
const expectedVersion = parsed.data.version;
|
||||
const modules = parsed.data.modules;
|
||||
|
||||
|
||||
|
||||
if (
|
||||
timezone === undefined &&
|
||||
shiftSchedule === undefined &&
|
||||
thresholds === undefined &&
|
||||
alerts === undefined &&
|
||||
defaults === undefined
|
||||
defaults === undefined &&
|
||||
modules === undefined
|
||||
|
||||
) {
|
||||
return NextResponse.json({ ok: false, error: "No settings provided" }, { status: 400 });
|
||||
}
|
||||
@@ -179,6 +191,16 @@ export async function PUT(req: Request) {
|
||||
if (defaults !== undefined && !isPlainObject(defaults)) {
|
||||
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 });
|
||||
}
|
||||
if (modules !== undefined && !isPlainObject(modules)) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid modules payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
const screenlessMode =
|
||||
modules && typeof (modules as any).screenlessMode === "boolean"
|
||||
? (modules as any).screenlessMode
|
||||
: undefined;
|
||||
|
||||
|
||||
|
||||
const shiftValidation = validateShiftFields(
|
||||
shiftSchedule?.shiftChangeCompensationMin,
|
||||
@@ -193,11 +215,6 @@ export async function PUT(req: Request) {
|
||||
return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const defaultsValidation = validateDefaults(defaults);
|
||||
if (!defaultsValidation.ok) {
|
||||
return NextResponse.json({ ok: false, error: defaultsValidation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
let shiftRows: ValidShift[] | null = null;
|
||||
if (shiftSchedule?.shifts !== undefined) {
|
||||
const shiftResult = validateShiftSchedule(shiftSchedule.shifts);
|
||||
@@ -218,8 +235,34 @@ export async function PUT(req: Request) {
|
||||
|
||||
const nextAlerts =
|
||||
alerts !== undefined ? { ...normalizeAlerts(current.settings.alertsJson), ...alerts } : undefined;
|
||||
const nextDefaults =
|
||||
defaults !== undefined ? { ...normalizeDefaults(current.settings.defaultsJson), ...defaults } : undefined;
|
||||
const currentDefaultsRaw = isPlainObject(current.settings.defaultsJson)
|
||||
? (current.settings.defaultsJson as any)
|
||||
: {};
|
||||
const currentModulesRaw = isPlainObject(currentDefaultsRaw.modules) ? currentDefaultsRaw.modules : {};
|
||||
|
||||
// Merge defaults core (moldTotal, etc.)
|
||||
const nextDefaultsCore =
|
||||
defaults !== undefined ? { ...normalizeDefaults(currentDefaultsRaw), ...defaults } : undefined;
|
||||
|
||||
// Validate merged defaults
|
||||
if (nextDefaultsCore) {
|
||||
const dv = validateDefaults(nextDefaultsCore);
|
||||
if (!dv.ok) return { error: dv.error } as const;
|
||||
}
|
||||
|
||||
// Merge modules
|
||||
const nextModules =
|
||||
screenlessMode === undefined
|
||||
? currentModulesRaw
|
||||
: { ...currentModulesRaw, screenlessMode };
|
||||
|
||||
// Write defaultsJson if either defaults changed OR modules changed
|
||||
const shouldWriteDefaultsJson = !!nextDefaultsCore || screenlessMode !== undefined;
|
||||
|
||||
const nextDefaultsJson = shouldWriteDefaultsJson
|
||||
? { ...(nextDefaultsCore ?? normalizeDefaults(currentDefaultsRaw)), modules: nextModules }
|
||||
: undefined;
|
||||
|
||||
|
||||
const updateData = stripUndefined({
|
||||
timezone: timezone !== undefined ? String(timezone) : undefined,
|
||||
@@ -244,7 +287,7 @@ export async function PUT(req: Request) {
|
||||
qualitySpikeDeltaPct:
|
||||
thresholds?.qualitySpikeDeltaPct !== undefined ? Number(thresholds.qualitySpikeDeltaPct) : undefined,
|
||||
alertsJson: nextAlerts,
|
||||
defaultsJson: nextDefaults,
|
||||
defaultsJson: nextDefaultsJson,
|
||||
});
|
||||
|
||||
const hasShiftUpdate = shiftRows !== null;
|
||||
@@ -326,7 +369,12 @@ export async function PUT(req: Request) {
|
||||
} catch (err) {
|
||||
console.warn("[settings PUT] MQTT publish failed", err);
|
||||
}
|
||||
return NextResponse.json({ ok: true, settings: payload });
|
||||
const defaultsRaw = isPlainObject(updated.settings.defaultsJson) ? (updated.settings.defaultsJson as any) : {};
|
||||
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
|
||||
const modulesOut = { screenlessMode: modulesRaw.screenlessMode === true };
|
||||
|
||||
return NextResponse.json({ ok: true, settings: { ...payload, modules: modulesOut } });
|
||||
|
||||
} catch (err) {
|
||||
console.error("[settings PUT] failed", err);
|
||||
const message = err instanceof Error ? err.message : "Internal error";
|
||||
|
||||
329
components/analytics/DowntimeParetoCard.tsx
Normal file
329
components/analytics/DowntimeParetoCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2205
components/downtime/DowntimePageClient.tsx
Normal file
2205
components/downtime/DowntimePageClient.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -30,10 +30,10 @@ export function AppShell({
|
||||
}, [drawerOpen]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
<div className="flex min-h-screen">
|
||||
<div className="h-screen overflow-hidden bg-black text-white">
|
||||
<div className="flex h-full">
|
||||
<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">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
@@ -50,7 +50,7 @@ export function AppShell({
|
||||
</div>
|
||||
<UtilityControls initialTheme={initialTheme} />
|
||||
</header>
|
||||
<main className="flex-1">{children}</main>
|
||||
<main className="flex-1 overflow-y-auto">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { BarChart3, Bell, DollarSign, LayoutGrid, LogOut, Settings, Wrench, X } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
@@ -21,6 +23,8 @@ const items: NavItem[] = [
|
||||
{ href: "/alerts", labelKey: "nav.alerts", icon: Bell },
|
||||
{ href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true },
|
||||
{ href: "/settings", labelKey: "nav.settings", icon: Settings },
|
||||
{ href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 },
|
||||
|
||||
];
|
||||
|
||||
type SidebarProps = {
|
||||
@@ -33,6 +37,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { screenlessMode } = useScreenlessMode();
|
||||
const [me, setMe] = useState<{
|
||||
user?: { name?: string | null; email?: 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 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(() => {
|
||||
visibleItems.forEach((it) => {
|
||||
@@ -75,7 +101,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
||||
});
|
||||
}, [router, visibleItems]);
|
||||
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]",
|
||||
].join(" ");
|
||||
|
||||
|
||||
110
components/reports/DowntimeParetoReportClient.tsx
Normal file
110
components/reports/DowntimeParetoReportClient.tsx
Normal 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 don’t 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>
|
||||
);
|
||||
}
|
||||
24
lib/analytics/downtimeRange.ts
Normal file
24
lib/analytics/downtimeRange.ts
Normal 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",
|
||||
};
|
||||
68
lib/email.ts
68
lib/email.ts
@@ -144,3 +144,71 @@ export function buildInviteEmail(params: {
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -505,5 +505,12 @@
|
||||
"financial.field.energyMultiplier": "Energy multiplier",
|
||||
"financial.field.energyCostPerMin": "Energy cost / min",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -505,5 +505,12 @@
|
||||
"financial.field.energyMultiplier": "Multiplicador de energía",
|
||||
"financial.field.energyCostPerMin": "Costo energía / min",
|
||||
"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
52
lib/ui/screenlessMode.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "downtime_actions" ADD COLUMN "reminder_stage" TEXT;
|
||||
@@ -32,6 +32,9 @@ model Org {
|
||||
locationFinancialOverrides LocationFinancialOverride[]
|
||||
machineFinancialOverrides MachineFinancialOverride[]
|
||||
productCostOverrides ProductCostOverride[]
|
||||
reasonEntries ReasonEntry[]
|
||||
downtimeActions DowntimeAction[]
|
||||
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -51,6 +54,8 @@ model User {
|
||||
sentInvites OrgInvite[] @relation("OrgInviteInviter")
|
||||
alertContacts AlertContact[]
|
||||
alertNotifications AlertNotification[]
|
||||
downtimeActionsOwned DowntimeAction[] @relation("DowntimeActionOwner")
|
||||
downtimeActionsCreated DowntimeAction[] @relation("DowntimeActionCreator")
|
||||
}
|
||||
|
||||
model OrgUser {
|
||||
@@ -135,6 +140,9 @@ model Machine {
|
||||
settingsAudits SettingsAudit[]
|
||||
alertNotifications AlertNotification[]
|
||||
financialOverrides MachineFinancialOverride[]
|
||||
reasonEntries ReasonEntry[]
|
||||
downtimeActions DowntimeAction[]
|
||||
|
||||
|
||||
@@unique([orgId, name])
|
||||
@@index([orgId])
|
||||
@@ -517,3 +525,83 @@ model SettingsAudit {
|
||||
@@index([machineId, createdAt])
|
||||
@@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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user