diff --git a/README.md b/README.md index e215bc4..957d7cf 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,61 @@ You can start editing the page by modifying `app/page.tsx`. The page auto-update This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## Downtime Action Reminders + +Reminders are sent by calling `POST /api/downtime/actions/reminders`. This endpoint does not run automatically, so you need to schedule it with cron or systemd. It sends at most one reminder per threshold (1w/1d/1h/overdue) and resets if the due date changes. +The secret can be any random string; it just needs to match what your scheduler sends in the Authorization header. + +1) Set a secret in your env file (example: `/etc/mis-control-tower.env`): + +``` +DOWNTIME_ACTION_REMINDER_SECRET=your-secret-here +APP_BASE_URL=https://your-domain +``` + +2) Cron example (runs hourly for 1w/1d/1h/overdue thresholds): + +``` +0 * * * * . /etc/mis-control-tower.env && curl -s -X POST "$APP_BASE_URL/api/downtime/actions/reminders?dueInDays=7" -H "Authorization: Bearer $DOWNTIME_ACTION_REMINDER_SECRET" +``` + +If you prefer systemd instead of cron, you can create a small service + timer that runs the same curl command. + +Example systemd units: + +`/etc/systemd/system/mis-control-tower-reminders.service` + +``` +[Unit] +Description=MIS Control Tower downtime action reminders + +[Service] +Type=oneshot +EnvironmentFile=/etc/mis-control-tower.env +ExecStart=/usr/bin/curl -s -X POST "$APP_BASE_URL/api/downtime/actions/reminders?dueInDays=7" -H "Authorization: Bearer $DOWNTIME_ACTION_REMINDER_SECRET" +``` + +`/etc/systemd/system/mis-control-tower-reminders.timer` + +``` +[Unit] +Description=Run MIS Control Tower reminders hourly + +[Timer] +OnCalendar=hourly +Persistent=true + +[Install] +WantedBy=timers.target +``` + +Enable with: + +``` +sudo systemctl daemon-reload +sudo systemctl enable --now mis-control-tower-reminders.timer +``` + ## Learn More To learn more about Next.js, take a look at the following resources: diff --git a/app/(app)/downtime/layout.tsx b/app/(app)/downtime/layout.tsx new file mode 100644 index 0000000..8e5a67d --- /dev/null +++ b/app/(app)/downtime/layout.tsx @@ -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 { + 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}; +} diff --git a/app/(app)/downtime/page.tsx b/app/(app)/downtime/page.tsx new file mode 100644 index 0000000..fe40a84 --- /dev/null +++ b/app/(app)/downtime/page.tsx @@ -0,0 +1,5 @@ +import DowntimePageClient from "@/components/downtime/DowntimePageClient"; + +export default function DowntimePage() { + return ; +} diff --git a/app/(app)/machines/[machineId]/MachineDetailClient.tsx b/app/(app)/machines/[machineId]/MachineDetailClient.tsx index 7777f0c..b364cb8 100644 --- a/app/(app)/machines/[machineId]/MachineDetailClient.tsx +++ b/app/(app)/machines/[machineId]/MachineDetailClient.tsx @@ -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} /> +
+
+
+
Downtime (preview)
+
Top reasons + quick pareto
+
+ + View full report → + +
+ +
+ +
+
+ + +
diff --git a/app/(app)/reports/downtime-pareto/page.tsx b/app/(app)/reports/downtime-pareto/page.tsx new file mode 100644 index 0000000..12fe18a --- /dev/null +++ b/app/(app)/reports/downtime-pareto/page.tsx @@ -0,0 +1,15 @@ +import { redirect } from "next/navigation"; + +export default function LegacyDowntimeParetoPage({ + searchParams, +}: { + searchParams: Record; +}) { + 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"); +} diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 4368189..a4df35b 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -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(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; + })()} ))}
@@ -766,6 +789,38 @@ export default function SettingsPage() {
)} + {activeTab === "modules" && ( +
+
+
{t("settings.modules.title")}
+
{t("settings.modules.subtitle")}
+ +
+ + setDraft((prev) => + prev + ? { + ...prev, + modules: { ...prev.modules, screenlessMode: next }, + } + : prev + ) + } + /> +
+ +
+ Org-wide setting. Hides Downtime from navigation for all users in this org. +
+
+
+ )} + + {activeTab === "thresholds" && (
diff --git a/app/api/analytics/coverage/route.ts b/app/api/analytics/coverage/route.ts new file mode 100644 index 0000000..19bed3c --- /dev/null +++ b/app/api/analytics/coverage/route.ts @@ -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.", + }); +} diff --git a/app/api/analytics/downtime-events/route.ts b/app/api/analytics/downtime-events/route.ts new file mode 100644 index 0000000..c234f4a --- /dev/null +++ b/app/api/analytics/downtime-events/route.ts @@ -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, + }); +} diff --git a/app/api/analytics/pareto/route.ts b/app/api/analytics/pareto/route.ts new file mode 100644 index 0000000..1b53cc7 --- /dev/null +++ b/app/api/analytics/pareto/route.ts @@ -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, + }); +} diff --git a/app/api/downtime/actions/[id]/route.ts b/app/api/downtime/actions/[id]/route.ts new file mode 100644 index 0000000..ec3a0c0 --- /dev/null +++ b/app/api/downtime/actions/[id]/route.ts @@ -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 }); +} diff --git a/app/api/downtime/actions/reminders/route.ts b/app/api/downtime/actions/reminders/route.ts new file mode 100644 index 0000000..da1b471 --- /dev/null +++ b/app/api/downtime/actions/reminders/route.ts @@ -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, + }); +} diff --git a/app/api/downtime/actions/route.ts b/app/api/downtime/actions/route.ts new file mode 100644 index 0000000..81b8fe4 --- /dev/null +++ b/app/api/downtime/actions/route.ts @@ -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>; + } = { 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, + }); +} diff --git a/app/api/ingest/reason/route.ts b/app/api/ingest/reason/route.ts new file mode 100644 index 0000000..41ea992 --- /dev/null +++ b/app/api/ingest/reason/route.ts @@ -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 }); +} diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 76a96ab..42d4e55 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -37,6 +37,7 @@ function canManageSettings(role?: string | null) { const settingsPayloadSchema = z .object({ source: z.string().trim().max(40).optional(), + modules: z.any().optional(), timezone: z.string().trim().max(64).optional(), shiftSchedule: z.any().optional(), thresholds: z.any().optional(), @@ -87,7 +88,7 @@ async function ensureOrgSettings(tx: Prisma.TransactionClient, orgId: string, us performanceThresholdPct: 85, qualitySpikeDeltaPct: 5, alertsJson: DEFAULT_ALERTS, - defaultsJson: DEFAULT_DEFAULTS, + defaultsJson: { ...(DEFAULT_DEFAULTS as any), modules: { screenlessMode: false } }, updatedBy: userId, }, }); @@ -122,7 +123,13 @@ export async function GET() { }); const payload = buildSettingsPayload(loaded.settings, loaded.shifts ?? []); - return NextResponse.json({ ok: true, settings: payload }); + + const defaultsRaw = isPlainObject(loaded.settings.defaultsJson) ? (loaded.settings.defaultsJson as any) : {}; + const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {}; + const modules = { screenlessMode: modulesRaw.screenlessMode === true }; + + return NextResponse.json({ ok: true, settings: { ...payload, modules } }); + } catch (err) { console.error("[settings GET] failed", err); const message = err instanceof Error ? err.message : "Internal error"; @@ -156,13 +163,18 @@ export async function PUT(req: Request) { const alerts = parsed.data.alerts; const defaults = parsed.data.defaults; const expectedVersion = parsed.data.version; + const modules = parsed.data.modules; + + if ( timezone === undefined && shiftSchedule === undefined && thresholds === undefined && alerts === undefined && - defaults === undefined + defaults === undefined && + modules === undefined + ) { return NextResponse.json({ ok: false, error: "No settings provided" }, { status: 400 }); } @@ -179,6 +191,16 @@ export async function PUT(req: Request) { if (defaults !== undefined && !isPlainObject(defaults)) { return NextResponse.json({ ok: false, error: "defaults must be an object" }, { status: 400 }); } + if (modules !== undefined && !isPlainObject(modules)) { + return NextResponse.json({ ok: false, error: "Invalid modules payload" }, { status: 400 }); + } + + const screenlessMode = + modules && typeof (modules as any).screenlessMode === "boolean" + ? (modules as any).screenlessMode + : undefined; + + const shiftValidation = validateShiftFields( shiftSchedule?.shiftChangeCompensationMin, @@ -193,11 +215,6 @@ export async function PUT(req: Request) { return NextResponse.json({ ok: false, error: thresholdsValidation.error }, { status: 400 }); } - const defaultsValidation = validateDefaults(defaults); - if (!defaultsValidation.ok) { - return NextResponse.json({ ok: false, error: defaultsValidation.error }, { status: 400 }); - } - let shiftRows: ValidShift[] | null = null; if (shiftSchedule?.shifts !== undefined) { const shiftResult = validateShiftSchedule(shiftSchedule.shifts); @@ -218,8 +235,34 @@ export async function PUT(req: Request) { const nextAlerts = alerts !== undefined ? { ...normalizeAlerts(current.settings.alertsJson), ...alerts } : undefined; - const nextDefaults = - defaults !== undefined ? { ...normalizeDefaults(current.settings.defaultsJson), ...defaults } : undefined; + const currentDefaultsRaw = isPlainObject(current.settings.defaultsJson) + ? (current.settings.defaultsJson as any) + : {}; + const currentModulesRaw = isPlainObject(currentDefaultsRaw.modules) ? currentDefaultsRaw.modules : {}; + + // Merge defaults core (moldTotal, etc.) + const nextDefaultsCore = + defaults !== undefined ? { ...normalizeDefaults(currentDefaultsRaw), ...defaults } : undefined; + + // Validate merged defaults + if (nextDefaultsCore) { + const dv = validateDefaults(nextDefaultsCore); + if (!dv.ok) return { error: dv.error } as const; + } + + // Merge modules + const nextModules = + screenlessMode === undefined + ? currentModulesRaw + : { ...currentModulesRaw, screenlessMode }; + + // Write defaultsJson if either defaults changed OR modules changed + const shouldWriteDefaultsJson = !!nextDefaultsCore || screenlessMode !== undefined; + + const nextDefaultsJson = shouldWriteDefaultsJson + ? { ...(nextDefaultsCore ?? normalizeDefaults(currentDefaultsRaw)), modules: nextModules } + : undefined; + const updateData = stripUndefined({ timezone: timezone !== undefined ? String(timezone) : undefined, @@ -244,7 +287,7 @@ export async function PUT(req: Request) { qualitySpikeDeltaPct: thresholds?.qualitySpikeDeltaPct !== undefined ? Number(thresholds.qualitySpikeDeltaPct) : undefined, alertsJson: nextAlerts, - defaultsJson: nextDefaults, + defaultsJson: nextDefaultsJson, }); const hasShiftUpdate = shiftRows !== null; @@ -326,7 +369,12 @@ export async function PUT(req: Request) { } catch (err) { console.warn("[settings PUT] MQTT publish failed", err); } - return NextResponse.json({ ok: true, settings: payload }); + const defaultsRaw = isPlainObject(updated.settings.defaultsJson) ? (updated.settings.defaultsJson as any) : {}; + const modulesRaw = isPlainObject(defaultsRaw.modules) ? defaultsRaw.modules : {}; + const modulesOut = { screenlessMode: modulesRaw.screenlessMode === true }; + + return NextResponse.json({ ok: true, settings: { ...payload, modules: modulesOut } }); + } catch (err) { console.error("[settings PUT] failed", err); const message = err instanceof Error ? err.message : "Internal error"; diff --git a/components/analytics/DowntimeParetoCard.tsx b/components/analytics/DowntimeParetoCard.tsx new file mode 100644 index 0000000..ddb089c --- /dev/null +++ b/components/analytics/DowntimeParetoCard.tsx @@ -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(null); + const [pareto, setPareto] = useState(null); + const [coverage, setCoverage] = useState(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 ( +
+
+
+
{title}
+
+ Total: {totalMinutes.toFixed(0)} min + {covPct != null ? ( + <> + + Coverage: {covPct.toFixed(0)}% + {pending != null ? ( + <> + + Pending: {pending} + + ) : null} + + ) : null} +
+
+ {showOpenFullReport ? ( + + View full report → + + ) : null} +
+ + {loading ? ( +
{t("machine.detail.loading")}
+ ) : err ? ( +
+ {err} +
+ ) : rows.length === 0 ? ( +
No downtime reasons found for this range.
+ ) : ( +
+
+ + + + + + `${v}%`} + width={44} + /> + { + if (name === "minutes") return [`${Number(val).toFixed(1)} min`, "Minutes"]; + if (name === "cumulativePct") return [`${Number(val).toFixed(1)}%`, "Cumulative"]; + return [val, name]; + }} + /> + + + + + + + +
+ +
+
Top 3 reasons
+
+ {top3.map((r) => ( +
+
+
+
+ {r.reasonLabel || r.reasonCode} +
+
{r.reasonCode}
+
+
+
+ {(r.minutesLost ?? 0).toFixed(0)}m +
+
{(r.pctOfTotal ?? 0).toFixed(1)}%
+
+
+
+ ))} +
+ + {!isSummary && pareto?.threshold80 ? ( +
+ 80% cutoff:{" "} + + {pareto.threshold80.reasonLabel} ({pareto.threshold80.reasonCode}) + +
+ ) : null} +
+
+ )} +
+ ); +} diff --git a/components/downtime/DowntimePageClient.tsx b/components/downtime/DowntimePageClient.tsx new file mode 100644 index 0000000..4b5a3c6 --- /dev/null +++ b/components/downtime/DowntimePageClient.tsx @@ -0,0 +1,2205 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { + Bar, + CartesianGrid, + ComposedChart, + Line, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +/** + * API SHAPES (from your route.ts) + */ +type ApiParetoRow = { + reasonCode: string; + reasonLabel: string; + minutesLost?: number; + scrapQty?: number; + pctOfTotal: number; // percent 0..100 + cumulativePct: number; // percent 0..100 + count: number; +}; + +type ApiParetoRes = { + ok: boolean; + error?: string; + orgId?: string; + machineId?: string | null; + kind?: "downtime" | "scrap"; + range?: "24h" | "7d" | "30d"; + start?: string; + totalMinutesLost?: number; + totalScrap?: number; + rows?: ApiParetoRow[]; + top3?: ApiParetoRow[]; + threshold80?: { index: number; reasonCode: string; reasonLabel: string } | null; + total?: number; +}; + +type ApiDowntimeEvent = { + id: string; + episodeId: string | null; + machineId: string; + machineName: string | null; + + reasonCode: string; + reasonLabel: string; + reasonText: string | null; + + durationSeconds: number | null; + durationMinutes: number | null; + + startAt: string | null; + endAt: string | null; + capturedAt: string | null; + + workOrderId: string | null; + meta: any | null; + createdAt: string | null; +}; + +type ApiDowntimeEventsRes = { + ok: boolean; + error?: string; + orgId?: string; + range?: "24h" | "7d" | "30d"; + start?: string; + machineId?: string | null; + reasonCode?: string | null; + limit?: number; + before?: string | null; + nextBefore?: string | null; + events?: ApiDowntimeEvent[]; +}; + +function fmtDT(iso: string | null) { + if (!iso) return "—"; + const d = new Date(iso); + return d.toLocaleString("en-US", { hour12: true }); +} + + +type ApiCoverageRes = { + ok: boolean; + error?: string; + orgId?: string; + machineId?: string | null; + range?: "24h" | "7d" | "30d"; + start?: string; + receivedEpisodes?: number; + receivedMinutes?: number; + note?: string; +}; + +type Range = "24h" | "7d" | "30d"; +type Metric = "minutes" | "count"; + +type MetricRow = { + reasonCode: string; + reasonLabel: string; + value: number; // minutes OR count + count: number; // always count (stops) + pctOfTotal: number; // percent 0..100 in selected metric + cumulativePct: number; // percent 0..100 in selected metric + minutesLost?: number; // if available +}; + +function fmtNum(n: number, digits = 0) { + return new Intl.NumberFormat("en-US", { + maximumFractionDigits: digits, + minimumFractionDigits: digits, + }).format(n); +} + +function fmtPct(pct: number, digits = 0) { + return `${fmtNum(pct, digits)}%`; +} + +function fmtHoursFromMinutes(min: number) { + const hrs = min / 60; + return hrs >= 10 ? `${fmtNum(hrs, 0)} hrs` : `${fmtNum(hrs, 1)} hrs`; +} + +function cn(...xs: Array) { + return xs.filter(Boolean).join(" "); +} + +function buildSearch(params: URLSearchParams, patch: Record) { + const next = new URLSearchParams(params.toString()); + Object.entries(patch).forEach(([k, v]) => { + if (v === null) next.delete(k); + else next.set(k, v); + }); + return next.toString(); +} + +/** + * Derive a Pareto set for Minutes or Count from the same API response. + * - Your API always returns rows sorted by VALUE (minutes for downtime, scrapQty for scrap). + * - For Metric=COUNT, we re-sort by count and recompute pct/cum on client. + */ +function computeMetricRows(base: ApiParetoRow[], metric: Metric): MetricRow[] { + const safe = base ?? []; + + if (metric === "minutes") { + const rows: MetricRow[] = safe.map((r) => ({ + reasonCode: r.reasonCode, + reasonLabel: r.reasonLabel, + value: r.minutesLost ?? 0, + count: r.count ?? 0, + pctOfTotal: r.pctOfTotal ?? 0, + cumulativePct: r.cumulativePct ?? 0, + minutesLost: r.minutesLost ?? 0, + })); + return rows; + } + + // metric === "count" + const sorted = [...safe].sort((a, b) => (b.count ?? 0) - (a.count ?? 0)); + const total = sorted.reduce((acc, r) => acc + (r.count ?? 0), 0); + + let cum = 0; + const out: MetricRow[] = sorted.map((r) => { + const v = r.count ?? 0; + const pct = total > 0 ? (v / total) * 100 : 0; + cum += v; + const cumPct = total > 0 ? (cum / total) * 100 : 0; + + return { + reasonCode: r.reasonCode, + reasonLabel: r.reasonLabel, + value: v, + count: v, + pctOfTotal: pct, + cumulativePct: cumPct, + minutesLost: r.minutesLost ?? 0, + }; + }); + + return out; +} + +function findUnclassifiedPct(rows: MetricRow[]) { + const hit = rows.find((r) => { + const code = (r.reasonCode ?? "").toLowerCase(); + const label = (r.reasonLabel ?? "").toLowerCase(); + return code.includes("unclass") || code.includes("unknown") || label.includes("unclass") || label.includes("unknown"); + }); + return hit ? hit.pctOfTotal : 0; +} + +/** + * Right-side drawer (investigation) + * Built in the same style as MachineDetailClient’s Modal overlay. + */ +function ReasonDrawer({ + open, + onClose, + row, + metric, +}: { + open: boolean; + onClose: () => void; + row: MetricRow | null; + metric: Metric; +}) { + if (!open || !row) return null; + + const avgMin = + row.count > 0 && row.minutesLost != null ? row.minutesLost / row.count : null; + + return ( +
+
+
+
+
+
+
+
Reason detail
+
{row.reasonLabel}
+
+ +
+ +
+
+
+
+ {metric === "minutes" ? "Downtime" : "Stops"} +
+
+ {metric === "minutes" ? `${fmtNum(row.value, 1)} min` : fmtNum(row.value, 0)} +
+
{fmtPct(row.pctOfTotal, 1)} share
+
+ +
+
Stops
+
{fmtNum(row.count, 0)}
+
+ {avgMin == null ? "Avg duration —" : `Avg ${fmtNum(avgMin, 1)} min`} +
+
+
+ +
+
Investigation (next)
+
+ Hook the following panels once you add endpoints for events + breakdowns: +
+
    +
  • Last 10 events (timestamp, duration, operator note)
  • +
  • Breakdown by machine / shift / work order
  • +
  • Duration histogram (micro vs macro)
  • +
  • Create action (owner, due date, status)
  • +
+
+ +
+ Tip: keep this drawer “fast”. The table + drawer combo is what makes the page feel like a tool. +
+
+
+
+
+ ); +} + +function KPI({ + label, + value, + sub, + accent, +}: { + label: string; + value: string; + sub?: string; + accent?: "emerald" | "yellow" | "rose" | "zinc"; +}) { + const ring = + accent === "emerald" + ? "border-emerald-500/20" + : accent === "yellow" + ? "border-yellow-500/20" + : accent === "rose" + ? "border-rose-500/20" + : "border-white/10"; + + return ( +
+
{label}
+
{value}
+ {sub ?
{sub}
: null} +
+ ); +} +const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +function nextHourBoundary(d: Date) { + const x = new Date(d); + x.setMinutes(0, 0, 0); + x.setHours(x.getHours() + 1); + return x; +} + +function getEventInterval(e: ApiDowntimeEvent): { start: Date | null; end: Date | null } { + const startIso = e.startAt ?? e.capturedAt; + if (!startIso) return { start: null, end: null }; + + const start = new Date(startIso); + if (Number.isNaN(start.getTime())) return { start: null, end: null }; + + // Prefer endAt if present + if (e.endAt) { + const end = new Date(e.endAt); + if (!Number.isNaN(end.getTime()) && end > start) return { start, end }; + } + + // Fall back to duration fields + const durMin = + e.durationMinutes ?? + (e.durationSeconds != null ? e.durationSeconds / 60 : null); + + if (durMin != null && durMin > 0) { + const end = new Date(start.getTime() + durMin * 60_000); + return { start, end }; + } + + return { start, end: null }; +} + +/** + * Build heatmap matrix [7 days][24 hours] + * - metric="minutes": distributes duration across hour buckets (accurate) + * - metric="count": increments the start hour bucket + */ +function buildHeatmapMatrix(events: ApiDowntimeEvent[], metric: Metric) { + const m = Array.from({ length: 7 }, () => Array.from({ length: 24 }, () => 0)); + + for (const e of events) { + const { start, end } = getEventInterval(e); + if (!start) continue; + + if (metric === "count") { + m[start.getDay()][start.getHours()] += 1; + continue; + } + + if (!end) continue; + + let t = start; + while (t < end) { + const day = t.getDay(); + const hour = t.getHours(); + + const boundary = nextHourBoundary(t); + const segEnd = boundary < end ? boundary : end; + const segMin = (segEnd.getTime() - t.getTime()) / 60_000; + + m[day][hour] += segMin; + t = segEnd; + } + } + + let max = 0; + for (let d = 0; d < 7; d++) { + for (let h = 0; h < 24; h++) max = Math.max(max, m[d][h]); + } + + return { matrix: m, max }; +} + +function eventTouchesSlot(e: ApiDowntimeEvent, slotDay: number, slotHour: number) { + const { start, end } = getEventInterval(e); + if (!start) return false; + + // Count metric: consider start bucket + if (!end) return start.getDay() === slotDay && start.getHours() === slotHour; + + // Minutes metric: any overlap with that (day, hour) bucket + let t = start; + while (t < end) { + if (t.getDay() === slotDay && t.getHours() === slotHour) return true; + const boundary = nextHourBoundary(t); + t = boundary < end ? boundary : end; + } + return false; +} + +function heatColor(v: number, metric: Metric) { + // "Good" = green even when v=0 + if (v <= 0) return { bg: "rgba(34,197,94,0.18)", label: "Good" }; + + if (metric === "minutes") { + // per-hour downtime minutes severity + if (v < 2) return { bg: "rgba(34,197,94,0.45)", label: "Low" }; + if (v < 6) return { bg: "rgba(234,179,8,0.55)", label: "Watch" }; // yellow + if (v < 15) return { bg: "rgba(249,115,22,0.65)", label: "High" }; // orange + return { bg: "rgba(239,68,68,0.75)", label: "Critical" }; // red + } + + // metric === "count" + if (v <= 1) return { bg: "rgba(34,197,94,0.45)", label: "Low" }; + if (v <= 3) return { bg: "rgba(234,179,8,0.55)", label: "Watch" }; + if (v <= 6) return { bg: "rgba(249,115,22,0.65)", label: "High" }; + return { bg: "rgba(239,68,68,0.75)", label: "Critical" }; +} + +function Heatmap({ + events, + metric, + selected, + onSelect, + onClear, +}: { + events: ApiDowntimeEvent[]; + metric: Metric; + selected: { day: number; hour: number } | null; + onSelect: (day: number, hour: number) => void; + onClear: () => void; +}) { + const { matrix, max } = useMemo(() => buildHeatmapMatrix(events, metric), [events, metric]); + + const hourLabels = Array.from({ length: 24 }, (_, h) => + h % 2 === 0 ? String(h).padStart(2, "0") : "" + ); + + const hasData = max > 0; + + return ( +
+
+
+
+ Click a cell to filter Event list by day/hour +
+ {selected ? ( + + ) : null} +
+ + {/* Header row */} +
+
+ {hourLabels.map((t, h) => ( +
+ {t} +
+ ))} +
+ + {/* Rows */} + {matrix.map((row, dayIdx) => ( +
+
+ {DAY_LABELS[dayIdx]} +
+ + {row.map((v, hour) => { + const c = heatColor(v, metric); + const isSelected = selected?.day === dayIdx && selected?.hour === hour; + + const title = `${DAY_LABELS[dayIdx]} ${String(hour).padStart(2, "0")}:00–${String( + (hour + 1) % 24 + ).padStart(2, "0")}:00\n${ + metric === "minutes" ? `${fmtNum(v, 1)} min` : `${fmtNum(v, 0)} stops` + }\n${c.label}`; + + return ( +
+ ))} + + {/* Legend */} +
+
+ + Good +
+
+ + Watch +
+
+ + High +
+
+ + Critical +
+ +
+ {events.length === 0 + ? "No events loaded for this scope" + : hasData + ? `Max cell: ${metric === "minutes" ? `${fmtNum(max, 1)} min` : `${fmtNum(max, 0)} stops`}` + : "Events loaded, but no usable durations/endAt yet"} +
+
+
+
+ ); +} + +type ActionStatus = "open" | "in_progress" | "blocked" | "done"; +type ActionPriority = "low" | "medium" | "high"; + +type HeatmapSel = { day: number; hour: number }; + +type ActionItem = { + id: string; + createdAt: string; + updatedAt: string; + + machineId: string | null; + reasonCode: string | null; + hmDay: number | null; + hmHour: number | null; + + title: string; + notes: string; + ownerUserId: string | null; + ownerName: string | null; + ownerEmail: string | null; + dueDate: string | null; // YYYY-MM-DD + status: ActionStatus; + priority: ActionPriority; +}; + +type MemberOption = { + id: string; + name?: string | null; + email: string; + role: string; + isActive: boolean; +}; + +function statusPill(status: ActionStatus) { + switch (status) { + case "done": + return "border-emerald-500/25 bg-emerald-500/10 text-emerald-200"; + case "blocked": + return "border-rose-500/25 bg-rose-500/10 text-rose-200"; + case "in_progress": + return "border-sky-500/25 bg-sky-500/10 text-sky-200"; + default: + return "border-amber-500/25 bg-amber-500/10 text-amber-200"; + } +} + +function priorityPill(p: ActionPriority) { + switch (p) { + case "high": + return "border-rose-500/25 bg-rose-500/10 text-rose-200"; + case "medium": + return "border-yellow-500/25 bg-yellow-500/10 text-yellow-200"; + default: + return "border-white/10 bg-white/5 text-zinc-200"; + } +} + +function isValidNum(x: any) { + const n = Number(x); + return Number.isFinite(n); +} + +function ActionModal({ + open, + onClose, + initial, + onSave, + onDelete, + members, + isNew, +}: { + open: boolean; + onClose: () => void; + initial: ActionItem; + onSave: (a: ActionItem, isNew: boolean) => Promise<{ ok: boolean; error?: string }>; + onDelete?: (id: string) => Promise<{ ok: boolean; error?: string }>; + members: MemberOption[]; + isNew: boolean; +}) { + const [draft, setDraft] = React.useState(initial); + const [saving, setSaving] = React.useState(false); + const [saveError, setSaveError] = React.useState(null); + const availableMembers = React.useMemo(() => members, [members]); + + React.useEffect(() => { + setDraft(initial); + setSaveError(null); + }, [initial]); + + if (!open) return null; + + return ( +
+
+
+
+
+
Action
+
+ Assign ownership + due date. Keep it short and clear. +
+
+ +
+ +
+
+
Title
+ setDraft((d) => ({ ...d, title: e.target.value }))} + placeholder="e.g. Add checklist for material feed before start-up" + className="mt-1 h-10 w-full rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none placeholder:text-zinc-500" + /> +
+ +
+
+
Owner
+ +
+ +
+
Due date
+ setDraft((d) => ({ ...d, dueDate: e.target.value || null }))} + className="mt-1 h-10 w-full rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none" + /> +
+
+ +
+
+
Status
+ +
+ +
+
Priority
+ +
+
+ +
+
Notes
+