Final MVP valid
This commit is contained in:
29
app/(app)/downtime/layout.tsx
Normal file
29
app/(app)/downtime/layout.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/auth/requireSession";
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getScreenlessMode(defaultsJson: unknown) {
|
||||
const defaults = isPlainObject(defaultsJson) ? defaultsJson : {};
|
||||
const modules = isPlainObject(defaults.modules) ? defaults.modules : {};
|
||||
return modules.screenlessMode === true;
|
||||
}
|
||||
|
||||
export default async function DowntimeLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await requireSession();
|
||||
if (!session) redirect("/login?next=/downtime");
|
||||
|
||||
const settings = await prisma.orgSettings.findUnique({
|
||||
where: { orgId: session.orgId },
|
||||
select: { defaultsJson: true },
|
||||
});
|
||||
|
||||
if (getScreenlessMode(settings?.defaultsJson)) {
|
||||
redirect("/overview");
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
5
app/(app)/downtime/page.tsx
Normal file
5
app/(app)/downtime/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import DowntimePageClient from "@/components/downtime/DowntimePageClient";
|
||||
|
||||
export default function DowntimePage() {
|
||||
return <DowntimePageClient />;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import DowntimeParetoCard from "@/components/analytics/DowntimeParetoCard";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
@@ -1013,6 +1014,34 @@ export default function MachineDetailClient() {
|
||||
activeStoppage={activeStoppage}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">Downtime (preview)</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">Top reasons + quick pareto</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/downtime?machineId=${encodeURIComponent(machineId)}&range=7d`}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white hover:bg-white/10"
|
||||
>
|
||||
View full report →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<DowntimeParetoCard
|
||||
machineId={machineId}
|
||||
range="7d"
|
||||
variant="summary"
|
||||
maxBars={5}
|
||||
showCoverage={true}
|
||||
showOpenFullReport={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-1">
|
||||
|
||||
15
app/(app)/reports/downtime-pareto/page.tsx
Normal file
15
app/(app)/reports/downtime-pareto/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function LegacyDowntimeParetoPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(searchParams)) {
|
||||
if (typeof v === "string") qs.set(k, v);
|
||||
else if (Array.isArray(v)) v.forEach((vv) => qs.append(k, vv));
|
||||
}
|
||||
const q = qs.toString();
|
||||
redirect(q ? `/downtime?${q}` : "/downtime");
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AlertsConfig } from "@/components/settings/AlertsConfig";
|
||||
import { FinancialCostConfig } from "@/components/settings/FinancialCostConfig";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import { useScreenlessMode } from "@/lib/ui/screenlessMode";
|
||||
|
||||
|
||||
type Shift = {
|
||||
name: string;
|
||||
@@ -16,6 +18,11 @@ type SettingsPayload = {
|
||||
orgId?: string;
|
||||
version?: number;
|
||||
timezone?: string;
|
||||
|
||||
modules: {
|
||||
screenlessMode: boolean;
|
||||
};
|
||||
|
||||
shiftSchedule: {
|
||||
shifts: Shift[];
|
||||
shiftChangeCompensationMin: number;
|
||||
@@ -42,6 +49,7 @@ type SettingsPayload = {
|
||||
updatedBy?: string;
|
||||
};
|
||||
|
||||
|
||||
type OrgInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -77,6 +85,7 @@ const DEFAULT_SETTINGS: SettingsPayload = {
|
||||
orgId: "",
|
||||
version: 0,
|
||||
timezone: "UTC",
|
||||
modules: { screenlessMode: false },
|
||||
shiftSchedule: {
|
||||
shifts: [],
|
||||
shiftChangeCompensationMin: 10,
|
||||
@@ -105,6 +114,7 @@ const DEFAULT_SETTINGS: SettingsPayload = {
|
||||
|
||||
const SETTINGS_TABS = [
|
||||
{ id: "general", labelKey: "settings.tabs.general" },
|
||||
{ id: "modules", labelKey: "settings.tabs.modules" },
|
||||
{ id: "shifts", labelKey: "settings.tabs.shifts" },
|
||||
{ id: "thresholds", labelKey: "settings.tabs.thresholds" },
|
||||
{ id: "alerts", labelKey: "settings.tabs.alerts" },
|
||||
@@ -191,6 +201,7 @@ function normalizeShift(raw: unknown, fallbackName: string): Shift {
|
||||
|
||||
function normalizeSettings(raw: unknown, fallbackName: (index: number) => string): SettingsPayload {
|
||||
const record = asRecord(raw);
|
||||
const modules = asRecord(record?.modules) ?? {};
|
||||
if (!record) {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
@@ -253,6 +264,9 @@ function normalizeSettings(raw: unknown, fallbackName: (index: number) => string
|
||||
moldTotal: Number(defaults.moldTotal ?? DEFAULT_SETTINGS.defaults.moldTotal),
|
||||
moldActive: Number(defaults.moldActive ?? DEFAULT_SETTINGS.defaults.moldActive),
|
||||
},
|
||||
modules: {
|
||||
screenlessMode: (modules.screenlessMode as boolean | undefined) ?? false,
|
||||
},
|
||||
updatedAt: record.updatedAt ? String(record.updatedAt) : "",
|
||||
updatedBy: record.updatedBy ? String(record.updatedBy) : "",
|
||||
};
|
||||
@@ -296,6 +310,7 @@ function Toggle({
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t, locale } = useI18n();
|
||||
const { setScreenlessMode } = useScreenlessMode();
|
||||
const [draft, setDraft] = useState<SettingsPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -329,6 +344,7 @@ export default function SettingsPage() {
|
||||
}
|
||||
const next = normalizeSettings(api.record?.settings, defaultShiftName);
|
||||
setDraft(next);
|
||||
setScreenlessMode(next.modules.screenlessMode);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("settings.failedLoad"));
|
||||
} finally {
|
||||
@@ -576,6 +592,7 @@ export default function SettingsPage() {
|
||||
source: "control_tower",
|
||||
version: draft.version,
|
||||
timezone: draft.timezone,
|
||||
modules: draft.modules,
|
||||
shiftSchedule: draft.shiftSchedule,
|
||||
thresholds: draft.thresholds,
|
||||
alerts: draft.alerts,
|
||||
@@ -593,6 +610,7 @@ export default function SettingsPage() {
|
||||
}
|
||||
const next = normalizeSettings(api.record?.settings, defaultShiftName);
|
||||
setDraft(next);
|
||||
setScreenlessMode(next.modules.screenlessMode);
|
||||
setSaveStatus("saved");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("settings.failedSave"));
|
||||
@@ -680,7 +698,12 @@ export default function SettingsPage() {
|
||||
: "rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-200 hover:bg-white/10"
|
||||
}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
{(() => {
|
||||
const label = t(tab.labelKey);
|
||||
return label === tab.labelKey
|
||||
? tab.id.charAt(0).toUpperCase() + tab.id.slice(1)
|
||||
: label;
|
||||
})()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -766,6 +789,38 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "modules" && (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<div className="text-sm font-semibold text-white">{t("settings.modules.title")}</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">{t("settings.modules.subtitle")}</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<Toggle
|
||||
label={t("settings.modules.screenless.title")}
|
||||
helper={t("settings.modules.screenless.helper")}
|
||||
enabled={draft.modules.screenlessMode}
|
||||
onChange={(next) =>
|
||||
setDraft((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
modules: { ...prev.modules, screenlessMode: next },
|
||||
}
|
||||
: prev
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-zinc-500">
|
||||
Org-wide setting. Hides Downtime from navigation for all users in this org.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{activeTab === "thresholds" && (
|
||||
<div className="space-y-6">
|
||||
|
||||
Reference in New Issue
Block a user