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.
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Downtime Action Reminders
|
||||||
|
|
||||||
|
Reminders are sent by calling `POST /api/downtime/actions/reminders`. This endpoint does not run automatically, so you need to schedule it with cron or systemd. It sends at most one reminder per threshold (1w/1d/1h/overdue) and resets if the due date changes.
|
||||||
|
The secret can be any random string; it just needs to match what your scheduler sends in the Authorization header.
|
||||||
|
|
||||||
|
1) Set a secret in your env file (example: `/etc/mis-control-tower.env`):
|
||||||
|
|
||||||
|
```
|
||||||
|
DOWNTIME_ACTION_REMINDER_SECRET=your-secret-here
|
||||||
|
APP_BASE_URL=https://your-domain
|
||||||
|
```
|
||||||
|
|
||||||
|
2) Cron example (runs hourly for 1w/1d/1h/overdue thresholds):
|
||||||
|
|
||||||
|
```
|
||||||
|
0 * * * * . /etc/mis-control-tower.env && curl -s -X POST "$APP_BASE_URL/api/downtime/actions/reminders?dueInDays=7" -H "Authorization: Bearer $DOWNTIME_ACTION_REMINDER_SECRET"
|
||||||
|
```
|
||||||
|
|
||||||
|
If you prefer systemd instead of cron, you can create a small service + timer that runs the same curl command.
|
||||||
|
|
||||||
|
Example systemd units:
|
||||||
|
|
||||||
|
`/etc/systemd/system/mis-control-tower-reminders.service`
|
||||||
|
|
||||||
|
```
|
||||||
|
[Unit]
|
||||||
|
Description=MIS Control Tower downtime action reminders
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
EnvironmentFile=/etc/mis-control-tower.env
|
||||||
|
ExecStart=/usr/bin/curl -s -X POST "$APP_BASE_URL/api/downtime/actions/reminders?dueInDays=7" -H "Authorization: Bearer $DOWNTIME_ACTION_REMINDER_SECRET"
|
||||||
|
```
|
||||||
|
|
||||||
|
`/etc/systemd/system/mis-control-tower-reminders.timer`
|
||||||
|
|
||||||
|
```
|
||||||
|
[Unit]
|
||||||
|
Description=Run MIS Control Tower reminders hourly
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=hourly
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable with:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now mis-control-tower-reminders.timer
|
||||||
|
```
|
||||||
|
|
||||||
## Learn More
|
## Learn More
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|||||||
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 { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
import DowntimeParetoCard from "@/components/analytics/DowntimeParetoCard";
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -1013,6 +1014,34 @@ export default function MachineDetailClient() {
|
|||||||
activeStoppage={activeStoppage}
|
activeStoppage={activeStoppage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-6 rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-white">Downtime (preview)</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-400">Top reasons + quick pareto</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/downtime?machineId=${encodeURIComponent(machineId)}&range=7d`}
|
||||||
|
className="rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
View full report →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<DowntimeParetoCard
|
||||||
|
machineId={machineId}
|
||||||
|
range="7d"
|
||||||
|
variant="summary"
|
||||||
|
maxBars={5}
|
||||||
|
showCoverage={true}
|
||||||
|
showOpenFullReport={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-1">
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-1">
|
||||||
|
|||||||
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 { AlertsConfig } from "@/components/settings/AlertsConfig";
|
||||||
import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig";
|
import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig";
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||||
|
|
||||||
|
|
||||||
type Shift = {
|
type Shift = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -16,6 +18,11 @@ type SettingsPayload = {
|
|||||||
orgId?: string;
|
orgId?: string;
|
||||||
version?: number;
|
version?: number;
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
|
|
||||||
|
modules: {
|
||||||
|
screenlessMode: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
shiftSchedule: {
|
shiftSchedule: {
|
||||||
shifts: Shift[];
|
shifts: Shift[];
|
||||||
shiftChangeCompensationMin: number;
|
shiftChangeCompensationMin: number;
|
||||||
@@ -42,6 +49,7 @@ type SettingsPayload = {
|
|||||||
updatedBy?: string;
|
updatedBy?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
type OrgInfo = {
|
type OrgInfo = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -77,6 +85,7 @@ const DEFAULT_SETTINGS: SettingsPayload = {
|
|||||||
orgId: "",
|
orgId: "",
|
||||||
version: 0,
|
version: 0,
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
|
modules: { screenlessMode: false },
|
||||||
shiftSchedule: {
|
shiftSchedule: {
|
||||||
shifts: [],
|
shifts: [],
|
||||||
shiftChangeCompensationMin: 10,
|
shiftChangeCompensationMin: 10,
|
||||||
@@ -105,6 +114,7 @@ const DEFAULT_SETTINGS: SettingsPayload = {
|
|||||||
|
|
||||||
const SETTINGS_TABS = [
|
const SETTINGS_TABS = [
|
||||||
{ id: "general", labelKey: "settings.tabs.general" },
|
{ id: "general", labelKey: "settings.tabs.general" },
|
||||||
|
{ id: "modules", labelKey: "settings.tabs.modules" },
|
||||||
{ id: "shifts", labelKey: "settings.tabs.shifts" },
|
{ id: "shifts", labelKey: "settings.tabs.shifts" },
|
||||||
{ id: "thresholds", labelKey: "settings.tabs.thresholds" },
|
{ id: "thresholds", labelKey: "settings.tabs.thresholds" },
|
||||||
{ id: "alerts", labelKey: "settings.tabs.alerts" },
|
{ id: "alerts", labelKey: "settings.tabs.alerts" },
|
||||||
@@ -191,6 +201,7 @@ function normalizeShift(raw: unknown, fallbackName: string): Shift {
|
|||||||
|
|
||||||
function normalizeSettings(raw: unknown, fallbackName: (index: number) => string): SettingsPayload {
|
function normalizeSettings(raw: unknown, fallbackName: (index: number) => string): SettingsPayload {
|
||||||
const record = asRecord(raw);
|
const record = asRecord(raw);
|
||||||
|
const modules = asRecord(record?.modules) ?? {};
|
||||||
if (!record) {
|
if (!record) {
|
||||||
return {
|
return {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
@@ -253,6 +264,9 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string
|
|||||||
moldTotal: Number(defaults.moldTotal ?? DEFAULT_SETTINGS.defaults.moldTotal),
|
moldTotal: Number(defaults.moldTotal ?? DEFAULT_SETTINGS.defaults.moldTotal),
|
||||||
moldActive: Number(defaults.moldActive ?? DEFAULT_SETTINGS.defaults.moldActive),
|
moldActive: Number(defaults.moldActive ?? DEFAULT_SETTINGS.defaults.moldActive),
|
||||||
},
|
},
|
||||||
|
modules: {
|
||||||
|
screenlessMode: (modules.screenlessMode as boolean | undefined) ?? false,
|
||||||
|
},
|
||||||
updatedAt: record.updatedAt ? String(record.updatedAt) : "",
|
updatedAt: record.updatedAt ? String(record.updatedAt) : "",
|
||||||
updatedBy: record.updatedBy ? String(record.updatedBy) : "",
|
updatedBy: record.updatedBy ? String(record.updatedBy) : "",
|
||||||
};
|
};
|
||||||
@@ -296,6 +310,7 @@ function Toggle({
|
|||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
const { setScreenlessMode } = useScreenlessMode();
|
||||||
const [draft, setDraft] = useState<SettingsPayload | null>(null);
|
const [draft, setDraft] = useState<SettingsPayload | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -329,6 +344,7 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
const next = normalizeSettings(api.record?.settings, defaultShiftName);
|
const next = normalizeSettings(api.record?.settings, defaultShiftName);
|
||||||
setDraft(next);
|
setDraft(next);
|
||||||
|
setScreenlessMode(next.modules.screenlessMode);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("settings.failedLoad"));
|
setError(err instanceof Error ? err.message : t("settings.failedLoad"));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -576,6 +592,7 @@ export default function SettingsPage() {
|
|||||||
source: "control_tower",
|
source: "control_tower",
|
||||||
version: draft.version,
|
version: draft.version,
|
||||||
timezone: draft.timezone,
|
timezone: draft.timezone,
|
||||||
|
modules: draft.modules,
|
||||||
shiftSchedule: draft.shiftSchedule,
|
shiftSchedule: draft.shiftSchedule,
|
||||||
thresholds: draft.thresholds,
|
thresholds: draft.thresholds,
|
||||||
alerts: draft.alerts,
|
alerts: draft.alerts,
|
||||||
@@ -593,6 +610,7 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
const next = normalizeSettings(api.record?.settings, defaultShiftName);
|
const next = normalizeSettings(api.record?.settings, defaultShiftName);
|
||||||
setDraft(next);
|
setDraft(next);
|
||||||
|
setScreenlessMode(next.modules.screenlessMode);
|
||||||
setSaveStatus("saved");
|
setSaveStatus("saved");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t("settings.failedSave"));
|
setError(err instanceof Error ? err.message : t("settings.failedSave"));
|
||||||
@@ -680,7 +698,12 @@ export default function SettingsPage() {
|
|||||||
: "rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-200 hover:bg-white/10"
|
: "rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-200 hover:bg-white/10"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t(tab.labelKey)}
|
{(() => {
|
||||||
|
const label = t(tab.labelKey);
|
||||||
|
return label === tab.labelKey
|
||||||
|
? tab.id.charAt(0).toUpperCase() + tab.id.slice(1)
|
||||||
|
: label;
|
||||||
|
})()}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -766,6 +789,38 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{activeTab === "modules" && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<div className="text-sm font-semibold text-white">{t("settings.modules.title")}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-400">{t("settings.modules.subtitle")}</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<Toggle
|
||||||
|
label={t("settings.modules.screenless.title")}
|
||||||
|
helper={t("settings.modules.screenless.helper")}
|
||||||
|
enabled={draft.modules.screenlessMode}
|
||||||
|
onChange={(next) =>
|
||||||
|
setDraft((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
modules: { ...prev.modules, screenlessMode: next },
|
||||||
|
}
|
||||||
|
: prev
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 text-xs text-zinc-500">
|
||||||
|
Org-wide setting. Hides Downtime from navigation for all users in this org.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{activeTab === "thresholds" && (
|
{activeTab === "thresholds" && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
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
|
const settingsPayloadSchema = z
|
||||||
.object({
|
.object({
|
||||||
source: z.string().trim().max(40).optional(),
|
source: z.string().trim().max(40).optional(),
|
||||||
|
modules: z.any().optional(),
|
||||||
timezone: z.string().trim().max(64).optional(),
|
timezone: z.string().trim().max(64).optional(),
|
||||||
shiftSchedule: z.any().optional(),
|
shiftSchedule: z.any().optional(),
|
||||||
thresholds: z.any().optional(),
|
thresholds: z.any().optional(),
|
||||||
@@ -87,7 +88,7 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us
|
|||||||
performanceThresholdPct: 85,
|
performanceThresholdPct: 85,
|
||||||
qualitySpikeDeltaPct: 5,
|
qualitySpikeDeltaPct: 5,
|
||||||
alertsJson: DEFAULT_ALERTS,
|
alertsJson: DEFAULT_ALERTS,
|
||||||
defaultsJson: DEFAULT_DEFAULTS,
|
defaultsJson: { ...(DEFAULT_DEFAULTS as any), modules: { screenlessMode: false } },
|
||||||
updatedBy: userId,
|
updatedBy: userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -122,7 +123,13 @@ export async function GET() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const payload = buildSettingsPayload(loaded.settings, loaded.shifts ?? []);
|
const payload = buildSettingsPayload(loaded.settings, loaded.shifts ?? []);
|
||||||
return NextResponse.json({ ok: true, settings: payload });
|
|
||||||
|
const defaultsRaw = isPlainObject(loaded.settings.defaultsJson) ? (loaded.settings.defaultsJson as any) : {};
|
||||||
|
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
|
||||||
|
const modules = { screenlessMode: modulesRaw.screenlessMode === true };
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, settings: { ...payload, modules } });
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[settings GET] failed", err);
|
console.error("[settings GET] failed", err);
|
||||||
const message = err instanceof Error ? err.message : "Internal error";
|
const message = err instanceof Error ? err.message : "Internal error";
|
||||||
@@ -156,13 +163,18 @@ export async function PUT(req: Request) {
|
|||||||
const alerts = parsed.data.alerts;
|
const alerts = parsed.data.alerts;
|
||||||
const defaults = parsed.data.defaults;
|
const defaults = parsed.data.defaults;
|
||||||
const expectedVersion = parsed.data.version;
|
const expectedVersion = parsed.data.version;
|
||||||
|
const modules = parsed.data.modules;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
timezone === undefined &&
|
timezone === undefined &&
|
||||||
shiftSchedule === undefined &&
|
shiftSchedule === undefined &&
|
||||||
thresholds === undefined &&
|
thresholds === undefined &&
|
||||||
alerts === undefined &&
|
alerts === undefined &&
|
||||||
defaults === undefined
|
defaults === undefined &&
|
||||||
|
modules === undefined
|
||||||
|
|
||||||
) {
|
) {
|
||||||
return NextResponse.json({ ok: false, error: "No settings provided" }, { status: 400 });
|
return NextResponse.json({ ok: false, error: "No settings provided" }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -179,6 +191,16 @@ export async function PUT(req: Request) {
|
|||||||
if (defaults !== undefined && !isPlainObject(defaults)) {
|
if (defaults !== undefined && !isPlainObject(defaults)) {
|
||||||
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 });
|
return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
if (modules !== undefined && !isPlainObject(modules)) {
|
||||||
|
return NextResponse.json({ ok: false, error: "Invalid modules payload" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const screenlessMode =
|
||||||
|
modules && typeof (modules as any).screenlessMode === "boolean"
|
||||||
|
? (modules as any).screenlessMode
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const shiftValidation = validateShiftFields(
|
const shiftValidation = validateShiftFields(
|
||||||
shiftSchedule?.shiftChangeCompensationMin,
|
shiftSchedule?.shiftChangeCompensationMin,
|
||||||
@@ -193,11 +215,6 @@ export async function PUT(req: Request) {
|
|||||||
return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 });
|
return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultsValidation = validateDefaults(defaults);
|
|
||||||
if (!defaultsValidation.ok) {
|
|
||||||
return NextResponse.json({ ok: false, error: defaultsValidation.error }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
let shiftRows: ValidShift[] | null = null;
|
let shiftRows: ValidShift[] | null = null;
|
||||||
if (shiftSchedule?.shifts !== undefined) {
|
if (shiftSchedule?.shifts !== undefined) {
|
||||||
const shiftResult = validateShiftSchedule(shiftSchedule.shifts);
|
const shiftResult = validateShiftSchedule(shiftSchedule.shifts);
|
||||||
@@ -218,8 +235,34 @@ export async function PUT(req: Request) {
|
|||||||
|
|
||||||
const nextAlerts =
|
const nextAlerts =
|
||||||
alerts !== undefined ? { ...normalizeAlerts(current.settings.alertsJson), ...alerts } : undefined;
|
alerts !== undefined ? { ...normalizeAlerts(current.settings.alertsJson), ...alerts } : undefined;
|
||||||
const nextDefaults =
|
const currentDefaultsRaw = isPlainObject(current.settings.defaultsJson)
|
||||||
defaults !== undefined ? { ...normalizeDefaults(current.settings.defaultsJson), ...defaults } : undefined;
|
? (current.settings.defaultsJson as any)
|
||||||
|
: {};
|
||||||
|
const currentModulesRaw = isPlainObject(currentDefaultsRaw.modules) ? currentDefaultsRaw.modules : {};
|
||||||
|
|
||||||
|
// Merge defaults core (moldTotal, etc.)
|
||||||
|
const nextDefaultsCore =
|
||||||
|
defaults !== undefined ? { ...normalizeDefaults(currentDefaultsRaw), ...defaults } : undefined;
|
||||||
|
|
||||||
|
// Validate merged defaults
|
||||||
|
if (nextDefaultsCore) {
|
||||||
|
const dv = validateDefaults(nextDefaultsCore);
|
||||||
|
if (!dv.ok) return { error: dv.error } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge modules
|
||||||
|
const nextModules =
|
||||||
|
screenlessMode === undefined
|
||||||
|
? currentModulesRaw
|
||||||
|
: { ...currentModulesRaw, screenlessMode };
|
||||||
|
|
||||||
|
// Write defaultsJson if either defaults changed OR modules changed
|
||||||
|
const shouldWriteDefaultsJson = !!nextDefaultsCore || screenlessMode !== undefined;
|
||||||
|
|
||||||
|
const nextDefaultsJson = shouldWriteDefaultsJson
|
||||||
|
? { ...(nextDefaultsCore ?? normalizeDefaults(currentDefaultsRaw)), modules: nextModules }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
|
||||||
const updateData = stripUndefined({
|
const updateData = stripUndefined({
|
||||||
timezone: timezone !== undefined ? String(timezone) : undefined,
|
timezone: timezone !== undefined ? String(timezone) : undefined,
|
||||||
@@ -244,7 +287,7 @@ export async function PUT(req: Request) {
|
|||||||
qualitySpikeDeltaPct:
|
qualitySpikeDeltaPct:
|
||||||
thresholds?.qualitySpikeDeltaPct !== undefined ? Number(thresholds.qualitySpikeDeltaPct) : undefined,
|
thresholds?.qualitySpikeDeltaPct !== undefined ? Number(thresholds.qualitySpikeDeltaPct) : undefined,
|
||||||
alertsJson: nextAlerts,
|
alertsJson: nextAlerts,
|
||||||
defaultsJson: nextDefaults,
|
defaultsJson: nextDefaultsJson,
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasShiftUpdate = shiftRows !== null;
|
const hasShiftUpdate = shiftRows !== null;
|
||||||
@@ -326,7 +369,12 @@ export async function PUT(req: Request) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[settings PUT] MQTT publish failed", err);
|
console.warn("[settings PUT] MQTT publish failed", err);
|
||||||
}
|
}
|
||||||
return NextResponse.json({ ok: true, settings: payload });
|
const defaultsRaw = isPlainObject(updated.settings.defaultsJson) ? (updated.settings.defaultsJson as any) : {};
|
||||||
|
const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {};
|
||||||
|
const modulesOut = { screenlessMode: modulesRaw.screenlessMode === true };
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, settings: { ...payload, modules: modulesOut } });
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[settings PUT] failed", err);
|
console.error("[settings PUT] failed", err);
|
||||||
const message = err instanceof Error ? err.message : "Internal error";
|
const message = err instanceof Error ? err.message : "Internal error";
|
||||||
|
|||||||
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]);
|
}, [drawerOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-black text-white">
|
<div className="h-screen overflow-hidden bg-black text-white">
|
||||||
<div className="flex min-h-screen">
|
<div className="flex h-full">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="flex min-h-screen flex-1 flex-col">
|
<div className="flex h-full flex-1 flex-col">
|
||||||
<header className="sticky top-0 z-30 flex min-h-[3.5rem] flex-wrap items-center justify-between gap-3 border-b border-white/10 bg-black/20 px-4 py-2 backdrop-blur">
|
<header className="sticky top-0 z-30 flex min-h-[3.5rem] flex-wrap items-center justify-between gap-3 border-b border-white/10 bg-black/20 px-4 py-2 backdrop-blur">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
@@ -50,7 +50,7 @@ export function AppShell({
|
|||||||
</div>
|
</div>
|
||||||
<UtilityControls initialTheme={initialTheme} />
|
<UtilityControls initialTheme={initialTheme} />
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1 overflow-y-auto">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { BarChart3, Bell, DollarSign, LayoutGrid, LogOut, Settings, Wrench, X } from "lucide-react";
|
import { BarChart3, Bell, DollarSign, LayoutGrid, LogOut, Settings, Wrench, X } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { useI18n } from "@/lib/i18n/useI18n";
|
import { useI18n } from "@/lib/i18n/useI18n";
|
||||||
|
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||||
|
|
||||||
|
|
||||||
type NavItem = {
|
type NavItem = {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -21,6 +23,8 @@ const items: NavItem[] = [
|
|||||||
{ href: "/alerts", labelKey: "nav.alerts", icon: Bell },
|
{ href: "/alerts", labelKey: "nav.alerts", icon: Bell },
|
||||||
{ href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true },
|
{ href: "/financial", labelKey: "nav.financial", icon: DollarSign, ownerOnly: true },
|
||||||
{ href: "/settings", labelKey: "nav.settings", icon: Settings },
|
{ href: "/settings", labelKey: "nav.settings", icon: Settings },
|
||||||
|
{ href: "/downtime", labelKey: "nav.downtime", icon: BarChart3 },
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
type SidebarProps = {
|
type SidebarProps = {
|
||||||
@@ -33,6 +37,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { screenlessMode } = useScreenlessMode();
|
||||||
const [me, setMe] = useState<{
|
const [me, setMe] = useState<{
|
||||||
user?: { name?: string | null; email?: string | null };
|
user?: { name?: string | null; email?: string | null };
|
||||||
org?: { name?: string | null };
|
org?: { name?: string | null };
|
||||||
@@ -67,7 +72,28 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
|||||||
|
|
||||||
const roleKey = (me?.membership?.role || "MEMBER").toLowerCase();
|
const roleKey = (me?.membership?.role || "MEMBER").toLowerCase();
|
||||||
const isOwner = roleKey === "owner";
|
const isOwner = roleKey === "owner";
|
||||||
const visibleItems = useMemo(() => items.filter((it) => !it.ownerOnly || isOwner), [isOwner]);
|
const visibleItems = useMemo(() => {
|
||||||
|
return items.filter((it) => {
|
||||||
|
if (it.ownerOnly && !isOwner) return false;
|
||||||
|
if (screenlessMode && it.href === "/downtime") return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [isOwner, screenlessMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (screenlessMode && pathname.startsWith("/downtime")) {
|
||||||
|
router.replace("/overview");
|
||||||
|
}
|
||||||
|
}, [screenlessMode, pathname, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!screenlessMode) return;
|
||||||
|
if (pathname === "/downtime" || pathname.startsWith("/downtime/")) {
|
||||||
|
router.replace("/overview");
|
||||||
|
}
|
||||||
|
}, [screenlessMode, pathname, router]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
visibleItems.forEach((it) => {
|
visibleItems.forEach((it) => {
|
||||||
@@ -75,7 +101,7 @@ export function Sidebar({ variant = "desktop", onNavigate, onClose }: SidebarPro
|
|||||||
});
|
});
|
||||||
}, [router, visibleItems]);
|
}, [router, visibleItems]);
|
||||||
const shellClass = [
|
const shellClass = [
|
||||||
"relative z-20 flex flex-col border-r border-white/10 bg-black/40",
|
"relative z-20 flex flex-col border-r border-white/10 bg-black/40 shrink-0",
|
||||||
variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]",
|
variant === "desktop" ? "hidden md:flex h-screen w-64" : "flex h-full w-72 max-w-[85vw]",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
|
||||||
|
|||||||
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 };
|
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.energyMultiplier": "Energy multiplier",
|
||||||
"financial.field.energyCostPerMin": "Energy cost / min",
|
"financial.field.energyCostPerMin": "Energy cost / min",
|
||||||
"financial.field.scrapCostPerUnit": "Scrap cost / unit",
|
"financial.field.scrapCostPerUnit": "Scrap cost / unit",
|
||||||
"financial.field.rawMaterialCostPerUnit": "Raw material / unit"
|
"financial.field.rawMaterialCostPerUnit": "Raw material / unit",
|
||||||
|
"nav.downtime": "Downtime",
|
||||||
|
"settings.tabs.modules": "Modules",
|
||||||
|
"settings.modules.title": "Modules",
|
||||||
|
"settings.modules.subtitle": "Enable/disable UI modules depending on how the plant operates.",
|
||||||
|
"settings.modules.screenless.title": "Screenless mode",
|
||||||
|
"settings.modules.screenless.helper": "Hide the Downtime module from navigation (for plants without Node-RED reason capture).",
|
||||||
|
"settings.modules.note": "This setting is org-wide."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -505,5 +505,12 @@
|
|||||||
"financial.field.energyMultiplier": "Multiplicador de energía",
|
"financial.field.energyMultiplier": "Multiplicador de energía",
|
||||||
"financial.field.energyCostPerMin": "Costo energía / min",
|
"financial.field.energyCostPerMin": "Costo energía / min",
|
||||||
"financial.field.scrapCostPerUnit": "Costo scrap / unidad",
|
"financial.field.scrapCostPerUnit": "Costo scrap / unidad",
|
||||||
"financial.field.rawMaterialCostPerUnit": "Costo materia prima / unidad"
|
"financial.field.rawMaterialCostPerUnit": "Costo materia prima / unidad",
|
||||||
|
"nav.downtime": "Downtime",
|
||||||
|
"settings.tabs.modules": "Módulos",
|
||||||
|
"settings.modules.title": "Módulos",
|
||||||
|
"settings.modules.subtitle": "Activa/desactiva módulos según cómo opera la planta.",
|
||||||
|
"settings.modules.screenless.title": "Modo sin pantalla",
|
||||||
|
"settings.modules.screenless.helper": "Oculta el módulo de Paros (Downtime) del menú (para plantas sin captura de razones en Node-RED).",
|
||||||
|
"settings.modules.note": "Este ajuste aplica a toda la organización."
|
||||||
}
|
}
|
||||||
|
|||||||
52
lib/ui/screenlessMode.ts
Normal file
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[]
|
locationFinancialOverrides LocationFinancialOverride[]
|
||||||
machineFinancialOverrides MachineFinancialOverride[]
|
machineFinancialOverrides MachineFinancialOverride[]
|
||||||
productCostOverrides ProductCostOverride[]
|
productCostOverrides ProductCostOverride[]
|
||||||
|
reasonEntries ReasonEntry[]
|
||||||
|
downtimeActions DowntimeAction[]
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -51,6 +54,8 @@ model User {
|
|||||||
sentInvites OrgInvite[] @relation("OrgInviteInviter")
|
sentInvites OrgInvite[] @relation("OrgInviteInviter")
|
||||||
alertContacts AlertContact[]
|
alertContacts AlertContact[]
|
||||||
alertNotifications AlertNotification[]
|
alertNotifications AlertNotification[]
|
||||||
|
downtimeActionsOwned DowntimeAction[] @relation("DowntimeActionOwner")
|
||||||
|
downtimeActionsCreated DowntimeAction[] @relation("DowntimeActionCreator")
|
||||||
}
|
}
|
||||||
|
|
||||||
model OrgUser {
|
model OrgUser {
|
||||||
@@ -135,6 +140,9 @@ model Machine {
|
|||||||
settingsAudits SettingsAudit[]
|
settingsAudits SettingsAudit[]
|
||||||
alertNotifications AlertNotification[]
|
alertNotifications AlertNotification[]
|
||||||
financialOverrides MachineFinancialOverride[]
|
financialOverrides MachineFinancialOverride[]
|
||||||
|
reasonEntries ReasonEntry[]
|
||||||
|
downtimeActions DowntimeAction[]
|
||||||
|
|
||||||
|
|
||||||
@@unique([orgId, name])
|
@@unique([orgId, name])
|
||||||
@@index([orgId])
|
@@index([orgId])
|
||||||
@@ -517,3 +525,83 @@ model SettingsAudit {
|
|||||||
@@index([machineId, createdAt])
|
@@index([machineId, createdAt])
|
||||||
@@map("settings_audit")
|
@@map("settings_audit")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ReasonEntry {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
orgId String
|
||||||
|
machineId String
|
||||||
|
|
||||||
|
// idempotency key from Edge (rsn_<ulid>)
|
||||||
|
reasonId String @unique
|
||||||
|
|
||||||
|
// "downtime" | "scrap"
|
||||||
|
kind String
|
||||||
|
|
||||||
|
// For downtime reasons
|
||||||
|
episodeId String?
|
||||||
|
durationSeconds Int?
|
||||||
|
episodeEndTs DateTime?
|
||||||
|
|
||||||
|
// For scrap reasons
|
||||||
|
scrapEntryId String?
|
||||||
|
scrapQty Int?
|
||||||
|
scrapUnit String?
|
||||||
|
|
||||||
|
// Required reason
|
||||||
|
reasonCode String
|
||||||
|
reasonLabel String?
|
||||||
|
reasonText String?
|
||||||
|
|
||||||
|
capturedAt DateTime
|
||||||
|
workOrderId String?
|
||||||
|
meta Json?
|
||||||
|
schemaVersion Int @default(1)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
machine Machine @relation(fields: [machineId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([orgId, machineId, capturedAt])
|
||||||
|
@@index([orgId, kind, capturedAt])
|
||||||
|
@@unique([orgId, kind, episodeId])
|
||||||
|
@@unique([orgId, kind, scrapEntryId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model DowntimeAction {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
orgId String @map("org_id")
|
||||||
|
machineId String? @map("machine_id")
|
||||||
|
reasonCode String? @map("reason_code")
|
||||||
|
hmDay Int? @map("hm_day")
|
||||||
|
hmHour Int? @map("hm_hour")
|
||||||
|
|
||||||
|
title String
|
||||||
|
notes String?
|
||||||
|
status String @default("open")
|
||||||
|
priority String @default("medium")
|
||||||
|
dueDate DateTime? @map("due_date")
|
||||||
|
reminderAt DateTime? @map("reminder_at")
|
||||||
|
lastReminderAt DateTime? @map("last_reminder_at")
|
||||||
|
reminderStage String? @map("reminder_stage")
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
|
||||||
|
ownerUserId String? @map("owner_user_id")
|
||||||
|
createdBy String? @map("created_by")
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
machine Machine? @relation(fields: [machineId], references: [id], onDelete: SetNull)
|
||||||
|
ownerUser User? @relation("DowntimeActionOwner", fields: [ownerUserId], references: [id], onDelete: SetNull)
|
||||||
|
creator User? @relation("DowntimeActionCreator", fields: [createdBy], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([orgId])
|
||||||
|
@@index([orgId, machineId])
|
||||||
|
@@index([orgId, reasonCode])
|
||||||
|
@@index([orgId, hmDay, hmHour])
|
||||||
|
@@index([orgId, status, dueDate])
|
||||||
|
@@index([ownerUserId])
|
||||||
|
@@map("downtime_actions")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user