Final MVP valid

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

View File

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

View File

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

View File

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

View File

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

View File

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