Full project added

This commit is contained in:
Marcelo Dares
2025-12-17 20:24:06 +00:00
parent fc2e4fd15a
commit 0e9b2dd72d
36 changed files with 2050 additions and 84 deletions

33
app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { Sidebar } from "@/components/layout/Sidebar";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
const COOKIE_NAME = "mis_session";
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const sessionId = (await cookies()).get(COOKIE_NAME)?.value;
if (!sessionId) redirect("/login?next=/machines");
// validate session in DB (dont trust cookie existence)
const session = await prisma.session.findFirst({
where: {
id: sessionId,
revokedAt: null,
expiresAt: { gt: new Date() },
},
include: { user: true, org: true },
});
if (!session) redirect("/login?next=/machines");
return (
<div className="min-h-screen bg-black text-white">
<div className="flex">
<Sidebar />
<main className="flex-1 min-h-screen">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,281 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
type Heartbeat = {
ts: string;
status: string;
message?: string | null;
ip?: string | null;
fwVersion?: string | null;
};
type Kpi = {
ts: string;
oee?: number | null;
availability?: number | null;
performance?: number | null;
quality?: number | null;
workOrderId?: string | null;
sku?: string | null;
good?: number | null;
scrap?: number | null;
target?: number | null;
cycleTime?: number | null;
};
type EventRow = {
id: string;
ts: string;
topic: string;
eventType: string;
severity: string;
title: string;
description?: string | null;
requiresAck: boolean;
};
type MachineDetail = {
id: string;
name: string;
code?: string | null;
location?: string | null;
latestHeartbeat: Heartbeat | null;
latestKpi: Kpi | null;
};
export default function MachineDetailClient() {
const params = useParams<{ machineId: string }>();
const machineId = params?.machineId;
const [loading, setLoading] = useState(true);
const [machine, setMachine] = useState<MachineDetail | null>(null);
const [events, setEvents] = useState<EventRow[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!machineId) return; // <-- IMPORTANT guard
let alive = true;
async function load() {
try {
const res = await fetch(`/api/machines/${machineId}`, {
cache: "no-store",
credentials: "include",
});
const json = await res.json();
if (!alive) return;
if (!res.ok || json?.ok === false) {
setError(json?.error ?? "Failed to load machine");
setLoading(false);
return;
}
setMachine(json.machine ?? null);
setEvents(json.events ?? []);
setError(null);
setLoading(false);
} catch {
if (!alive) return;
setError("Network error");
setLoading(false);
}
}
load();
const t = setInterval(load, 5000);
return () => {
alive = false;
clearInterval(t);
};
}, [machineId]);
function fmtPct(v?: number | null) {
if (v === null || v === undefined || Number.isNaN(v)) return "—";
return `${v.toFixed(1)}%`;
}
function fmtNum(v?: number | null) {
if (v === null || v === undefined || Number.isNaN(v)) return "—";
return `${v}`;
}
function timeAgo(ts?: string) {
if (!ts) return "never";
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
return `${Math.floor(diff / 3600)}h ago`;
}
function isOffline(ts?: string) {
if (!ts) return true;
return Date.now() - new Date(ts).getTime() > 15000;
}
function statusBadgeClass(status?: string, offline?: boolean) {
if (offline) return "bg-white/10 text-zinc-300";
const s = (status ?? "").toUpperCase();
if (s === "RUN") return "bg-emerald-500/15 text-emerald-300";
if (s === "IDLE") return "bg-yellow-500/15 text-yellow-300";
if (s === "STOP" || s === "DOWN") return "bg-red-500/15 text-red-300";
return "bg-white/10 text-white";
}
function severityBadgeClass(sev?: string) {
const s = (sev ?? "").toLowerCase();
if (s === "critical") return "bg-red-500/15 text-red-300";
if (s === "warning") return "bg-yellow-500/15 text-yellow-300";
return "bg-white/10 text-zinc-200";
}
const hb = machine?.latestHeartbeat ?? null;
const kpi = machine?.latestKpi ?? null;
const offline = useMemo(() => isOffline(hb?.ts), [hb?.ts]);
const statusLabel = offline ? "OFFLINE" : (hb?.status ?? "UNKNOWN");
return (
<div className="p-6">
<div className="mb-6 flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-3">
<h1 className="truncate text-2xl font-semibold text-white">
{machine?.name ?? "Machine"}
</h1>
<span className={`rounded-full px-3 py-1 text-xs ${statusBadgeClass(hb?.status, offline)}`}>
{statusLabel}
</span>
</div>
<div className="mt-1 text-sm text-zinc-400">
{machine?.code ? machine.code : "—"} {machine?.location ? machine.location : "—"} Last seen{" "}
{hb?.ts ? timeAgo(hb.ts) : "never"}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Link
href="/machines"
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
>
Back
</Link>
</div>
</div>
{loading && <div className="text-sm text-zinc-400">Loading</div>}
{error && !loading && (
<div className="rounded-2xl border border-red-500/20 bg-red-500/10 p-4 text-sm text-red-200">
{error}
</div>
)}
{!loading && !error && (
<>
{/* KPI cards */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">OEE</div>
<div className="mt-2 text-3xl font-bold text-emerald-300">{fmtPct(kpi?.oee)}</div>
<div className="mt-1 text-xs text-zinc-400">Updated {kpi?.ts ? timeAgo(kpi.ts) : "never"}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">Availability</div>
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(kpi?.availability)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">Performance</div>
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(kpi?.performance)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs text-zinc-400">Quality</div>
<div className="mt-2 text-2xl font-semibold text-white">{fmtPct(kpi?.quality)}</div>
</div>
</div>
{/* Work order + recent events */}
<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="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold text-white">Current Work Order</div>
<div className="text-xs text-zinc-400">{kpi?.workOrderId ?? "—"}</div>
</div>
<div className="text-xs text-zinc-400">SKU</div>
<div className="mt-1 text-base font-semibold text-white">{kpi?.sku ?? "—"}</div>
<div className="mt-4 grid grid-cols-3 gap-3">
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Target</div>
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(kpi?.target)}</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Good</div>
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(kpi?.good)}</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-zinc-400">Scrap</div>
<div className="mt-1 text-sm font-semibold text-white">{fmtNum(kpi?.scrap)}</div>
</div>
</div>
<div className="mt-4 text-xs text-zinc-400">
Cycle target: <span className="text-white">{kpi?.cycleTime ? `${kpi.cycleTime}s` : "—"}</span>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 xl:col-span-2">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold text-white">Recent Events</div>
<div className="text-xs text-zinc-400">{events.length} shown</div>
</div>
{events.length === 0 ? (
<div className="text-sm text-zinc-400">No events yet.</div>
) : (
<div className="space-y-3">
{events.map((e) => (
<div key={e.id} className="rounded-xl border border-white/10 bg-black/20 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs ${severityBadgeClass(e.severity)}`}>
{e.severity.toUpperCase()}
</span>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-zinc-200">
{e.eventType}
</span>
{e.requiresAck && (
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">
ACK
</span>
)}
</div>
<div className="mt-2 truncate text-sm font-semibold text-white">{e.title}</div>
{e.description && (
<div className="mt-1 text-sm text-zinc-300">{e.description}</div>
)}
</div>
<div className="shrink-0 text-xs text-zinc-400">{timeAgo(e.ts)}</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import MachineDetailClient from "./MachineDetailClient";
export default function Page() {
return <MachineDetailClient />;
}

133
app/(app)/machines/page.tsx Normal file
View File

@@ -0,0 +1,133 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
type MachineRow = {
id: string;
name: string;
code?: string | null;
location?: string | null;
latestHeartbeat: null | {
ts: string;
status: string;
message?: string | null;
ip?: string | null;
fwVersion?: string | null;
};
};
function secondsAgo(ts?: string) {
if (!ts) return "never";
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
if (diff < 60) return `${diff}s ago`;
return `${Math.floor(diff / 60)}m ago`;
}
function isOffline(ts?: string) {
if (!ts) return true;
return Date.now() - new Date(ts).getTime() > 15000; // 15s threshold
}
function badgeClass(status?: string, offline?: boolean) {
if (offline) return "bg-white/10 text-zinc-300";
const s = (status ?? "").toUpperCase();
if (s === "RUN") return "bg-emerald-500/15 text-emerald-300";
if (s === "IDLE") return "bg-yellow-500/15 text-yellow-300";
if (s === "STOP" || s === "DOWN") return "bg-red-500/15 text-red-300";
return "bg-white/10 text-white";
}
export default function MachinesPage() {
const [machines, setMachines] = useState<MachineRow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
async function load() {
try {
const res = await fetch("/api/machines", { cache: "no-store" });
const json = await res.json();
if (alive) {
setMachines(json.machines ?? []);
setLoading(false);
}
} catch {
if (alive) setLoading(false);
}
}
load();
const t = setInterval(load, 5000);
return () => {
alive = false;
clearInterval(t);
};
}, []);
return (
<div className="p-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">Machines</h1>
<p className="text-sm text-zinc-400">Select a machine to view live KPIs.</p>
</div>
<Link
href="/overview"
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10"
>
Back to Overview
</Link>
</div>
{loading && <div className="mb-4 text-sm text-zinc-400">Loading machines</div>}
{!loading && machines.length === 0 && (
<div className="mb-4 text-sm text-zinc-400">No machines found for this org.</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{(!loading ? machines : []).map((m) => {
const hb = m.latestHeartbeat;
const offline = isOffline(hb?.ts);
const statusLabel = offline ? "OFFLINE" : hb?.status ?? "UNKNOWN";
const lastSeen = secondsAgo(hb?.ts);
return (
<Link
key={m.id}
href={`/machines/${m.id}`}
className="rounded-2xl border border-white/10 bg-white/5 p-5 hover:bg-white/10"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-lg font-semibold text-white">{m.name}</div>
<div className="mt-1 text-xs text-zinc-400">
{m.code ? m.code : "—"} Last seen {lastSeen}
</div>
</div>
<span
className={`shrink-0 rounded-full px-3 py-1 text-xs ${badgeClass(
hb?.status,
offline
)}`}
>
{statusLabel}
</span>
</div>
<div className="mt-4 text-sm text-zinc-400">Status</div>
<div className="text-xl font-semibold text-white">
{offline ? "No heartbeat" : hb?.message ?? "OK"}
</div>
</Link>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST(req: Request) {
const apiKey = req.headers.get("x-api-key");
if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
const body = await req.json().catch(() => null);
if (!body?.machineId || !body?.event) {
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
}
const machine = await prisma.machine.findFirst({
where: { id: String(body.machineId), apiKey },
select: { id: true, orgId: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
// Convert ms epoch -> Date if provided
const e = body.event;
const ts =
typeof e?.data?.timestamp === "number"
? new Date(e.data.timestamp)
: undefined;
// normalize inputs from event
const sev = String(e.severity ?? "").toLowerCase();
const typ = String(e.eventType ?? e.anomaly_type ?? "").toLowerCase();
const title = String(e.title ?? "").trim();
const ALLOWED_TYPES = new Set([
"slow-cycle",
"anomaly-detected",
"performance-degradation",
"scrap-spike",
"down",
"microstop",
]);
const ALLOWED_SEVERITIES = new Set(["warning", "critical"]);
// Drop generic/noise
if (!ALLOWED_SEVERITIES.has(sev) || !ALLOWED_TYPES.has(typ)) {
return NextResponse.json({ ok: true, skipped: true }, { status: 200 });
}
if (!title) return NextResponse.json({ ok: true, skipped: true }, { status: 200 });
const row = await prisma.machineEvent.create({
data: {
orgId: machine.orgId,
machineId: machine.id,
ts: ts ?? undefined,
topic: e.topic ? String(e.topic) : "event",
eventType: e.anomaly_type ? String(e.anomaly_type) : "unknown",
severity: e.severity ? String(e.severity) : "info",
requiresAck: !!e.requires_ack,
title: e.title ? String(e.title) : "Event",
description: e.description ? String(e.description) : null,
data: e.data ?? e, // store full blob
workOrderId: e?.data?.work_order_id ? String(e.data.work_order_id) : null,
},
});
return NextResponse.json({ ok: true, id: row.id, ts: row.ts });
}

View File

@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST(req: Request) {
const apiKey = req.headers.get("x-api-key");
if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
const body = await req.json().catch(() => null);
if (!body?.machineId || !body?.status) {
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
}
const machine = await prisma.machine.findFirst({
where: { id: String(body.machineId), apiKey },
select: { id: true, orgId: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const hb = await prisma.machineHeartbeat.create({
data: {
orgId: machine.orgId,
machineId: machine.id,
status: String(body.status),
message: body.message ? String(body.message) : null,
ip: body.ip ? String(body.ip) : null,
fwVersion: body.fwVersion ? String(body.fwVersion) : null,
},
});
return NextResponse.json({ ok: true, id: hb.id, ts: hb.ts });
}

View File

@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST(req: Request) {
const apiKey = req.headers.get("x-api-key");
if (!apiKey) return NextResponse.json({ ok: false, error: "Missing api key" }, { status: 401 });
const body = await req.json().catch(() => null);
if (!body?.machineId || !body?.kpis) {
return NextResponse.json({ ok: false, error: "Invalid payload" }, { status: 400 });
}
const machine = await prisma.machine.findFirst({
where: { id: String(body.machineId), apiKey },
select: { id: true, orgId: true },
});
if (!machine) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const wo = body.activeWorkOrder ?? {};
const k = body.kpis ?? {};
const row = await prisma.machineKpiSnapshot.create({
data: {
orgId: machine.orgId,
machineId: machine.id,
workOrderId: wo.id ? String(wo.id) : null,
sku: wo.sku ? String(wo.sku) : null,
target: typeof wo.target === "number" ? wo.target : null,
good: typeof wo.good === "number" ? wo.good : null,
scrap: typeof wo.scrap === "number" ? wo.scrap : null,
cycleCount: typeof body.cycle_count === "number" ? body.cycle_count : null,
goodParts: typeof body.good_parts === "number" ? body.good_parts : null,
cycleTime: typeof body.cycleTime === "number" ? body.cycleTime : null,
availability: typeof k.availability === "number" ? k.availability : null,
performance: typeof k.performance === "number" ? k.performance : null,
quality: typeof k.quality === "number" ? k.quality : null,
oee: typeof k.oee === "number" ? k.oee : null,
trackingEnabled: typeof body.trackingEnabled === "boolean" ? body.trackingEnabled : null,
productionStarted: typeof body.productionStarted === "boolean" ? body.productionStarted : null,
},
});
return NextResponse.json({ ok: true, id: row.id, ts: row.ts });
}

60
app/api/login/route.ts Normal file
View File

@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import bcrypt from "bcrypt";
import { prisma } from "@/lib/prisma";
const COOKIE_NAME = "mis_session";
const SESSION_DAYS = 7;
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}));
const email = String(body.email || "").trim().toLowerCase();
const password = String(body.password || "");
const next = String(body.next || "/machines");
if (!email || !password) {
return NextResponse.json({ ok: false, error: "Missing email/password" }, { status: 400 });
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.isActive) {
return NextResponse.json({ ok: false, error: "Invalid credentials" }, { status: 401 });
}
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) {
return NextResponse.json({ ok: false, error: "Invalid credentials" }, { status: 401 });
}
// Multiple orgs per user: pick the oldest membership for now
const membership = await prisma.orgUser.findFirst({
where: { userId: user.id },
orderBy: { createdAt: "asc" },
});
if (!membership) {
return NextResponse.json({ ok: false, error: "User has no organization" }, { status: 403 });
}
const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
const session = await prisma.session.create({
data: {
userId: user.id,
orgId: membership.orgId,
expiresAt,
// optional fields you can add later: ip/userAgent
},
});
const res = NextResponse.json({ ok: true, next });
res.cookies.set(COOKIE_NAME, session.id, {
httpOnly: true,
sameSite: "lax",
secure: false, // set true once HTTPS only
path: "/",
maxAge: SESSION_DAYS * 24 * 60 * 60,
});
return res;
}

21
app/api/logout/route.ts Normal file
View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { prisma } from "@/lib/prisma";
const COOKIE_NAME = "mis_session";
export async function POST() {
const jar = await cookies();
const sessionId = jar.get(COOKIE_NAME)?.value;
if (sessionId) {
await prisma.session.updateMany({
where: { id: sessionId, revokedAt: null },
data: { revokedAt: new Date() },
}).catch(() => {});
}
const res = NextResponse.json({ ok: true });
res.cookies.set(COOKIE_NAME, "", { path: "/", maxAge: 0 });
return res;
}

View File

@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
export async function GET(
_req: NextRequest,
{ params }: { params: { machineId: string } }
) {
const session = await requireSession();
if (!session) {
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
}
const { machineId } = params;
const machine = await prisma.machine.findFirst({
where: { id: machineId, orgId: session.orgId },
select: {
id: true,
name: true,
code: true,
location: true,
heartbeats: {
orderBy: { ts: "desc" },
take: 1,
select: { ts: true, status: true, message: true, ip: true, fwVersion: true },
},
kpiSnapshots: {
orderBy: { ts: "desc" },
take: 1,
select: {
ts: true,
oee: true,
availability: true,
performance: true,
quality: true,
workOrderId: true,
sku: true,
good: true,
scrap: true,
target: true,
cycleTime: true,
},
},
},
});
if (!machine) {
return NextResponse.json({ ok: false, error: "Not found" }, { status: 404 });
}
const events = await prisma.machineEvent.findMany({
where: {
orgId: session.orgId,
machineId,
severity: { in: ["warning", "critical"] },
eventType: { in: ["slow-cycle", "anomaly-detected", "performance-degradation", "scrap-spike", "down", "microstop"] },
},
orderBy: { ts: "desc" },
take: 30,
select: { /* same as now */ },
});
return NextResponse.json({
ok: true,
machine: {
id: machine.id,
name: machine.name,
code: machine.code,
location: machine.location,
latestHeartbeat: machine.heartbeats[0] ?? null,
latestKpi: machine.kpiSnapshots[0] ?? null,
},
events,
});
}

47
app/api/machines/route.ts Normal file
View File

@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { cookies } from "next/headers";
const COOKIE_NAME = "mis_session";
async function requireSession() {
const sessionId = (await cookies()).get(COOKIE_NAME)?.value;
if (!sessionId) return null;
return prisma.session.findFirst({
where: { id: sessionId, revokedAt: null, expiresAt: { gt: new Date() } },
include: { org: true, user: true },
});
}
export async function GET() {
const session = await requireSession();
if (!session) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const machines = await prisma.machine.findMany({
where: { orgId: session.orgId },
orderBy: { createdAt: "desc" },
select: {
id: true,
name: true,
code: true,
location: true,
createdAt: true,
updatedAt: true,
heartbeats: {
orderBy: { ts: "desc" },
take: 1,
select: { ts: true, status: true, message: true, ip: true, fwVersion: true },
},
},
});
// flatten latest heartbeat for UI convenience
const out = machines.map((m) => ({
...m,
latestHeartbeat: m.heartbeats[0] ?? null,
heartbeats: undefined,
}));
return NextResponse.json({ ok: true, machines: out });
}

23
app/api/me/route.ts Normal file
View File

@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { requireSession } from "@/lib/auth/requireSession";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const { userId, orgId } = await requireSession();
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, name: true },
});
const org = await prisma.org.findUnique({
where: { id: orgId },
select: { id: true, name: true, slug: true },
});
return NextResponse.json({ ok: true, user, org });
} catch {
return NextResponse.json({ ok: false }, { status: 401 });
}
}

View File

@@ -2,31 +2,18 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "MIS Control Tower",
description: "MaliounTech Industrial Suite",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
{children}
</body>
</html>

86
app/login/LoginForm.tsx Normal file
View File

@@ -0,0 +1,86 @@
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import { useState } from "react";
export default function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const next = searchParams.get("next") || "/machines";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setErr(null);
setLoading(true);
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, next }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
setErr(data.error || "Login failed");
return;
}
router.push(next);
router.refresh();
} catch (e: any) {
setErr(e?.message || "Network error");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen bg-black flex items-center justify-center p-6">
<form onSubmit={onSubmit} className="w-full max-w-md rounded-2xl border border-white/10 bg-white/5 p-8">
<h1 className="text-2xl font-semibold text-white">Control Tower</h1>
<p className="mt-1 text-sm text-zinc-400">Sign in to your organization</p>
<div className="mt-6 space-y-4">
<div>
<label className="text-sm text-zinc-300">Email</label>
<input
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
/>
</div>
<div>
<label className="text-sm text-zinc-300">Password</label>
<input
type="password"
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-white outline-none"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
{err && <div className="text-sm text-red-400">{err}</div>}
<button
type="submit"
disabled={loading}
className="mt-2 w-full rounded-xl bg-emerald-400 py-3 font-semibold text-black disabled:opacity-70"
>
{loading ? "Signing in..." : "Login"}
</button>
<div className="text-xs text-zinc-500">(Dev mode) This will be replaced with JWT auth later.</div>
</div>
</form>
</div>
);
}

22
app/login/page.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import LoginForm from "./LoginForm"; // adjust path if needed
export default async function LoginPage({
searchParams,
}: {
searchParams?: { next?: string };
}) {
const session = (await cookies()).get("mis_session")?.value;
// If already logged in, send to next or machines
if (session) {
const next = searchParams?.next || "/machines";
redirect(next);
}
// ...your existing login UI below
return <LoginForm />; // ✅ actually render it
}

7
app/logout/route.ts Normal file
View File

@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set("mis_session", "", { path: "/", maxAge: 0 });
return res;
}

11
app/mdares.code-workspace Normal file
View File

@@ -0,0 +1,11 @@
{
"folders": [
{
"path": "../.."
},
{
"path": "../../../../etc/nginx/sites-available"
}
],
"settings": {}
}

View File

@@ -1,65 +1,5 @@
import Image from "next/image";
import { redirect } from "next/navigation";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
redirect("/machines");
}