feat: Sprint 5 — AI insights, webhooks/Slack, PWA, performance monitoring
AI-Powered Insights (G9): - Rule-based anomaly detection: budget burn rate, staffing gaps, utilization, timeline overruns across all active projects - AI narrative generation via existing Azure OpenAI integration - Cached in project dynamicFields to avoid regeneration - New /analytics/insights page with anomaly feed + project summaries - Sidebar nav: "AI Insights" under Analytics Webhook System + Slack (G10): - Webhook model in Prisma (url, secret, events, isActive) - HMAC-SHA256 signed payloads with 5s timeout fire-and-forget dispatch - Slack-aware: routes hooks.slack.com URLs through Slack formatter - 6 events integrated: allocation.created/updated/deleted, project.created/ status_changed, vacation.approved - Admin UI: /admin/webhooks with CRUD, test button, event checkboxes - webhook router: list, getById, create, update, delete, test PWA Support (G11): - manifest.json with standalone display, brand-colored icons (192+512px) - Service worker: cache-first for static, network-first for API, offline fallback - ServiceWorkerRegistration component with 60-min update checks - InstallPrompt banner with 30-day dismissal memory - Apple Web App meta tags + viewport theme color Performance Monitoring (A15): - Pino structured logging (JSON prod, pretty dev) via LOG_LEVEL env - tRPC logging middleware on all protectedProcedure calls - Request ID (UUID) per call for log correlation - Slow query warnings (>500ms) at warn level - GET /api/perf endpoint: memory, uptime, SSE connections, node version Fix: renamed scenario.apply to scenario.applyScenario (tRPC reserved word) Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Planarchy — Resource Planning",
|
||||
"short_name": "Planarchy",
|
||||
"description": "Resource planning and project staffing for 3D production",
|
||||
"start_url": "/dashboard",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#0284c7",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
const CACHE_NAME = "planarchy-v1";
|
||||
const STATIC_EXTENSIONS = /\.(js|css|png|jpg|jpeg|svg|gif|ico|woff2?|ttf|eot)$/;
|
||||
|
||||
// Offline fallback page (simple inline HTML)
|
||||
const OFFLINE_HTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Planarchy — Offline</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-height: 100vh; background: #f8fafc; color: #334155;
|
||||
}
|
||||
.container { text-align: center; padding: 2rem; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.75rem; color: #0284c7; }
|
||||
p { font-size: 1rem; margin-bottom: 1.5rem; color: #64748b; }
|
||||
button {
|
||||
padding: 0.625rem 1.5rem; border: none; border-radius: 0.5rem;
|
||||
background: #0284c7; color: white; font-size: 0.875rem;
|
||||
cursor: pointer; transition: background 0.2s;
|
||||
}
|
||||
button:hover { background: #0369a1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>You are offline</h1>
|
||||
<p>Planarchy requires an internet connection. Please check your network and try again.</p>
|
||||
<button onclick="location.reload()">Retry</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// Install: pre-cache the offline fallback
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.put(
|
||||
new Request("/_offline"),
|
||||
new Response(OFFLINE_HTML, {
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
// Activate immediately
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate: clean up old caches
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((key) => key !== CACHE_NAME)
|
||||
.map((key) => caches.delete(key))
|
||||
)
|
||||
)
|
||||
);
|
||||
// Take control of all clients immediately
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch: strategy depends on request type
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip non-GET requests
|
||||
if (request.method !== "GET") return;
|
||||
|
||||
// Skip chrome-extension, ws, etc.
|
||||
if (!url.protocol.startsWith("http")) return;
|
||||
|
||||
// API calls and tRPC: network-first
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "offline" }),
|
||||
{
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets: cache-first
|
||||
if (STATIC_EXTENSIONS.test(url.pathname) || url.pathname.startsWith("/_next/static/")) {
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
if (cached) return cached;
|
||||
return fetch(request).then((response) => {
|
||||
// Only cache successful responses
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
}
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation requests: network-first with offline fallback
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => caches.match("/_offline"))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else: network-first, silent fail
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => caches.match(request))
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { WebhooksClient } from "~/components/admin/WebhooksClient.js";
|
||||
|
||||
export default function AdminWebhooksPage() {
|
||||
return <WebhooksClient />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { InsightsPanel } from "~/components/analytics/InsightsPanel.js";
|
||||
|
||||
export default function InsightsPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">
|
||||
AI Insights
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Anomaly detection and AI-generated project narratives
|
||||
</p>
|
||||
</div>
|
||||
<InsightsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { eventBus } from "@planarchy/api/sse";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* GET /api/perf — Runtime performance metrics.
|
||||
*
|
||||
* Protected by CRON_SECRET header or query param.
|
||||
* Returns Node.js memory usage, process uptime, and SSE connection count.
|
||||
*/
|
||||
export function GET(request: Request) {
|
||||
const cronSecret = process.env["CRON_SECRET"];
|
||||
|
||||
if (cronSecret) {
|
||||
const url = new URL(request.url);
|
||||
const headerToken = request.headers.get("authorization")?.replace("Bearer ", "");
|
||||
const queryToken = url.searchParams.get("token");
|
||||
|
||||
if (headerToken !== cronSecret && queryToken !== cronSecret) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const mem = process.memoryUsage();
|
||||
|
||||
return NextResponse.json({
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: {
|
||||
seconds: Math.round(process.uptime()),
|
||||
formatted: formatUptime(process.uptime()),
|
||||
},
|
||||
memory: {
|
||||
heapUsedMB: round(mem.heapUsed / 1024 / 1024),
|
||||
heapTotalMB: round(mem.heapTotal / 1024 / 1024),
|
||||
rssMB: round(mem.rss / 1024 / 1024),
|
||||
externalMB: round(mem.external / 1024 / 1024),
|
||||
arrayBuffersMB: round(mem.arrayBuffers / 1024 / 1024),
|
||||
},
|
||||
sse: {
|
||||
activeConnections: eventBus.subscriberCount,
|
||||
},
|
||||
node: {
|
||||
version: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function round(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
const parts: string[] = [];
|
||||
if (d > 0) parts.push(`${d}d`);
|
||||
if (h > 0) parts.push(`${h}h`);
|
||||
if (m > 0) parts.push(`${m}m`);
|
||||
parts.push(`${s}s`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Manrope, Source_Sans_3 } from "next/font/google";
|
||||
import { TRPCProvider } from "~/lib/trpc/provider.js";
|
||||
import { ServiceWorkerRegistration } from "~/components/layout/ServiceWorkerRegistration.js";
|
||||
import { InstallPrompt } from "~/components/layout/InstallPrompt.js";
|
||||
import "./globals.css";
|
||||
|
||||
const uiFont = Source_Sans_3({
|
||||
@@ -19,6 +21,12 @@ export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://planarchy.hartmut-noerenberg.com"),
|
||||
title: "plANARCHY — Resource Planning",
|
||||
description: "Interactive resource planning and project staffing tool",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "Planarchy",
|
||||
},
|
||||
openGraph: {
|
||||
title: "plANARCHY — Resource Planning",
|
||||
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
|
||||
@@ -33,6 +41,10 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#0284c7",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
@@ -47,6 +59,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
</head>
|
||||
<body className={`${uiFont.variable} ${displayFont.variable} min-h-screen bg-gray-50 font-sans antialiased`}>
|
||||
<TRPCProvider>{children}</TRPCProvider>
|
||||
<ServiceWorkerRegistration />
|
||||
<InstallPrompt />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
const WEBHOOK_EVENTS = [
|
||||
"allocation.created",
|
||||
"allocation.updated",
|
||||
"allocation.deleted",
|
||||
"project.created",
|
||||
"project.status_changed",
|
||||
"vacation.approved",
|
||||
"estimate.submitted",
|
||||
"estimate.approved",
|
||||
] as const;
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
"allocation.created": "Allocation Created",
|
||||
"allocation.updated": "Allocation Updated",
|
||||
"allocation.deleted": "Allocation Deleted",
|
||||
"project.created": "Project Created",
|
||||
"project.status_changed": "Project Status Changed",
|
||||
"vacation.approved": "Vacation Approved",
|
||||
"estimate.submitted": "Estimate Submitted",
|
||||
"estimate.approved": "Estimate Approved",
|
||||
};
|
||||
|
||||
const INPUT_CLASS = "app-input";
|
||||
const LABEL_CLASS = "app-label";
|
||||
const PRIMARY_BUTTON =
|
||||
"rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:opacity-50";
|
||||
const SECONDARY_BUTTON =
|
||||
"rounded-xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800";
|
||||
const DANGER_BUTTON =
|
||||
"rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:opacity-50";
|
||||
|
||||
interface WebhookFormData {
|
||||
name: string;
|
||||
url: string;
|
||||
secret: string;
|
||||
events: string[];
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const emptyForm: WebhookFormData = {
|
||||
name: "",
|
||||
url: "",
|
||||
secret: "",
|
||||
events: [],
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
function maskUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const host = u.hostname;
|
||||
// Show scheme + host, mask the rest
|
||||
if (u.pathname.length > 1) {
|
||||
return `${u.protocol}//${host}/****`;
|
||||
}
|
||||
return `${u.protocol}//${host}`;
|
||||
} catch {
|
||||
return "****";
|
||||
}
|
||||
}
|
||||
|
||||
export function WebhooksClient() {
|
||||
const utils = trpc.useUtils();
|
||||
const { data: webhooks, isLoading } = trpc.webhook.list.useQuery();
|
||||
const createMut = trpc.webhook.create.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate();
|
||||
setModalOpen(false);
|
||||
},
|
||||
});
|
||||
const updateMut = trpc.webhook.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate();
|
||||
setModalOpen(false);
|
||||
},
|
||||
});
|
||||
const deleteMut = trpc.webhook.delete.useMutation({
|
||||
onSuccess: () => void utils.webhook.list.invalidate(),
|
||||
});
|
||||
const testMut = trpc.webhook.test.useMutation();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<WebhookFormData>(emptyForm);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [testResult, setTestResult] = useState<{
|
||||
id: string;
|
||||
success: boolean;
|
||||
statusCode: number;
|
||||
statusText: string;
|
||||
} | null>(null);
|
||||
|
||||
function openCreateModal() {
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEditModal(wh: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
secret: string | null;
|
||||
events: string[];
|
||||
isActive: boolean;
|
||||
}) {
|
||||
setEditingId(wh.id);
|
||||
setForm({
|
||||
name: wh.name,
|
||||
url: wh.url,
|
||||
secret: wh.secret ?? "",
|
||||
events: wh.events,
|
||||
isActive: wh.isActive,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function toggleEvent(event: string) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
events: prev.events.includes(event)
|
||||
? prev.events.filter((e) => e !== event)
|
||||
: [...prev.events, event],
|
||||
}));
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (editingId) {
|
||||
updateMut.mutate({
|
||||
id: editingId,
|
||||
data: {
|
||||
name: form.name,
|
||||
url: form.url,
|
||||
...(form.secret ? { secret: form.secret } : { secret: null }),
|
||||
events: form.events,
|
||||
isActive: form.isActive,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
createMut.mutate({
|
||||
name: form.name,
|
||||
url: form.url,
|
||||
...(form.secret ? { secret: form.secret } : {}),
|
||||
events: form.events,
|
||||
isActive: form.isActive,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleTest(id: string) {
|
||||
setTestResult(null);
|
||||
testMut.mutate(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
setTestResult({ id, ...result });
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function handleToggleActive(id: string, currentActive: boolean) {
|
||||
updateMut.mutate({ id, data: { isActive: !currentActive } });
|
||||
}
|
||||
|
||||
const isSaving = createMut.isPending || updateMut.isPending;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Webhooks</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Configure outbound webhooks to notify external services about events in Planarchy.
|
||||
</p>
|
||||
</div>
|
||||
<button className={PRIMARY_BUTTON} onClick={openCreateModal}>
|
||||
Add Webhook
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Webhook List */}
|
||||
{isLoading ? (
|
||||
<div className="app-surface p-8 text-center text-gray-500">Loading...</div>
|
||||
) : !webhooks?.length ? (
|
||||
<div className="app-surface p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
No webhooks configured yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{webhooks.map((wh) => (
|
||||
<div
|
||||
key={wh.id}
|
||||
className="app-surface flex items-center gap-4 p-4"
|
||||
>
|
||||
{/* Active indicator */}
|
||||
<div
|
||||
className={`h-3 w-3 shrink-0 rounded-full ${
|
||||
wh.isActive ? "bg-green-500" : "bg-gray-300 dark:bg-gray-600"
|
||||
}`}
|
||||
title={wh.isActive ? "Active" : "Inactive"}
|
||||
/>
|
||||
|
||||
{/* Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{wh.name}
|
||||
</span>
|
||||
{wh.url.includes("hooks.slack.com") && (
|
||||
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">
|
||||
Slack
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-sm text-gray-500 dark:text-gray-400">
|
||||
{maskUrl(wh.url)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{wh.events.map((ev) => (
|
||||
<span
|
||||
key={ev}
|
||||
className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
>
|
||||
{EVENT_LABELS[ev] ?? ev}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{/* Test result */}
|
||||
{testResult && testResult.id === wh.id && (
|
||||
<div
|
||||
className={`mt-1 text-xs ${
|
||||
testResult.success
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
Test: {testResult.statusCode} {testResult.statusText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => handleTest(wh.id)}
|
||||
disabled={testMut.isPending}
|
||||
title="Send test payload"
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
<button
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() =>
|
||||
handleToggleActive(wh.id, wh.isActive)
|
||||
}
|
||||
disabled={updateMut.isPending}
|
||||
>
|
||||
{wh.isActive ? "Disable" : "Enable"}
|
||||
</button>
|
||||
<button
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => openEditModal(wh)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{deleteConfirmId === wh.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className={DANGER_BUTTON}
|
||||
onClick={() => {
|
||||
deleteMut.mutate({ id: wh.id });
|
||||
setDeleteConfirmId(null);
|
||||
}}
|
||||
disabled={deleteMut.isPending}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => setDeleteConfirmId(wh.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{modalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="app-surface-strong mx-4 w-full max-w-lg space-y-5 rounded-2xl p-6 shadow-xl">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{editingId ? "Edit Webhook" : "Create Webhook"}
|
||||
</h2>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>Name</label>
|
||||
<input
|
||||
className={INPUT_CLASS}
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="e.g. Slack Notifications"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>URL</label>
|
||||
<input
|
||||
className={INPUT_CLASS}
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
|
||||
placeholder="https://hooks.slack.com/services/..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Secret */}
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>
|
||||
Secret (optional)
|
||||
</label>
|
||||
<input
|
||||
className={INPUT_CLASS}
|
||||
type="password"
|
||||
value={form.secret}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, secret: e.target.value }))}
|
||||
placeholder="HMAC signing secret"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
If set, requests include an X-Webhook-Signature header (HMAC-SHA256).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Events */}
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>Events</label>
|
||||
<div className="mt-1 grid grid-cols-2 gap-2">
|
||||
{WEBHOOK_EVENTS.map((ev) => (
|
||||
<label
|
||||
key={ev}
|
||||
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900/70"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.events.includes(ev)}
|
||||
onChange={() => toggleEvent(ev)}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{EVENT_LABELS[ev] ?? ev}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active toggle */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isActive}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, isActive: e.target.checked }))}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
|
||||
{/* Error display */}
|
||||
{(createMut.error || updateMut.error) && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{createMut.error?.message ?? updateMut.error?.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
className={SECONDARY_BUTTON}
|
||||
onClick={() => setModalOpen(false)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className={PRIMARY_BUTTON}
|
||||
onClick={handleSubmit}
|
||||
disabled={isSaving || !form.name || !form.url || form.events.length === 0}
|
||||
>
|
||||
{isSaving ? "Saving..." : editingId ? "Update" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
// ─── Anomaly type badge colors ───────────────────────────────────────────────
|
||||
|
||||
const SEVERITY_STYLES = {
|
||||
critical: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
|
||||
warning: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||
} as const;
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
budget: "Budget",
|
||||
staffing: "Staffing",
|
||||
utilization: "Utilization",
|
||||
timeline: "Timeline",
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
budget: "\u20AC", // Euro sign
|
||||
staffing: "\u2642", // Person sign
|
||||
utilization: "\u2B24", // Circle
|
||||
timeline: "\u23F0", // Clock
|
||||
};
|
||||
|
||||
// ─── Shimmer skeleton ────────────────────────────────────────────────────────
|
||||
|
||||
function Shimmer({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`animate-pulse rounded bg-gray-200 dark:bg-slate-700 ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AnomalyListSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-3 rounded-xl border border-gray-100 p-4 dark:border-slate-800">
|
||||
<Shimmer className="h-6 w-16 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Shimmer className="h-4 w-3/4" />
|
||||
<Shimmer className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NarrativeSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Shimmer className="h-4 w-full" />
|
||||
<Shimmer className="h-4 w-5/6" />
|
||||
<Shimmer className="h-4 w-4/6" />
|
||||
<Shimmer className="h-4 w-3/4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Entity link helper ──────────────────────────────────────────────────────
|
||||
|
||||
function entityLink(type: string, entityId: string): string {
|
||||
if (type === "utilization") return `/resources/${entityId}`;
|
||||
return `/projects/${entityId}`;
|
||||
}
|
||||
|
||||
// ─── Main component ──────────────────────────────────────────────────────────
|
||||
|
||||
export function InsightsPanel() {
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
|
||||
const [narrativeFilter, setNarrativeFilter] = useState<string | null>(null);
|
||||
|
||||
// Fetch anomalies
|
||||
const anomaliesQuery = trpc.insights.detectAnomalies.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Fetch AI configuration status
|
||||
const aiConfigQuery = trpc.settings.getAiConfigured.useQuery(undefined, {
|
||||
staleTime: 300_000,
|
||||
});
|
||||
|
||||
// Fetch project list for dropdown
|
||||
const projectsQuery = trpc.project.list.useQuery(
|
||||
{ page: 1, limit: 200 },
|
||||
{ staleTime: 60_000, refetchOnWindowFocus: false },
|
||||
);
|
||||
|
||||
// Fetch cached narrative for selected project
|
||||
const cachedNarrativeQuery = trpc.insights.getCachedNarrative.useQuery(
|
||||
{ projectId: selectedProjectId },
|
||||
{
|
||||
enabled: !!selectedProjectId,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
// Generate narrative mutation
|
||||
const generateMutation = trpc.insights.generateProjectNarrative.useMutation({
|
||||
onSuccess: () => {
|
||||
// Refetch the cached narrative
|
||||
void cachedNarrativeQuery.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const anomalies = anomaliesQuery.data ?? [];
|
||||
const projects = projectsQuery.data?.projects ?? [];
|
||||
|
||||
// Filter anomalies
|
||||
const filteredAnomalies = narrativeFilter
|
||||
? anomalies.filter((a) => a.type === narrativeFilter)
|
||||
: anomalies;
|
||||
|
||||
const summaryCountsByType = anomalies.reduce(
|
||||
(acc, a) => {
|
||||
acc[a.type] = (acc[a.type] ?? 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
const criticalCount = anomalies.filter((a) => a.severity === "critical").length;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* ── Summary cards ─────────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{(["budget", "staffing", "utilization", "timeline"] as const).map((type) => {
|
||||
const count = summaryCountsByType[type] ?? 0;
|
||||
const isActive = narrativeFilter === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setNarrativeFilter(isActive ? null : type)}
|
||||
className={`rounded-2xl border p-4 text-left transition-all ${
|
||||
isActive
|
||||
? "border-brand-300 bg-brand-50 shadow-sm dark:border-brand-700 dark:bg-brand-950"
|
||||
: "border-gray-200 bg-white hover:border-gray-300 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{TYPE_LABELS[type]}
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold text-gray-900 dark:text-gray-50">
|
||||
{anomaliesQuery.isLoading ? "-" : count}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Anomaly feed ──────────────────────────────────────────────── */}
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">
|
||||
Anomaly Feed
|
||||
{criticalCount > 0 && (
|
||||
<span className="ml-2 inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-300">
|
||||
{criticalCount} critical
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{narrativeFilter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNarrativeFilter(null)}
|
||||
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{anomaliesQuery.isLoading ? (
|
||||
<AnomalyListSkeleton />
|
||||
) : anomaliesQuery.error ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">
|
||||
Failed to load anomalies: {anomaliesQuery.error.message}
|
||||
</div>
|
||||
) : filteredAnomalies.length === 0 ? (
|
||||
<div className="rounded-xl border border-green-200 bg-green-50 p-6 text-center dark:border-green-900 dark:bg-green-950/30">
|
||||
<div className="text-lg font-medium text-green-800 dark:text-green-300">
|
||||
All clear
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-green-600 dark:text-green-400">
|
||||
No anomalies detected across active projects.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredAnomalies.map((anomaly, idx) => (
|
||||
<div
|
||||
key={`${anomaly.entityId}-${anomaly.type}-${idx}`}
|
||||
className="flex items-start gap-3 rounded-xl border border-gray-100 bg-white p-4 transition-colors hover:bg-gray-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:bg-slate-800/80"
|
||||
>
|
||||
{/* Severity badge */}
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center rounded-full px-2.5 py-1 text-xs font-semibold ${SEVERITY_STYLES[anomaly.severity]}`}
|
||||
>
|
||||
{anomaly.severity === "critical" ? "Critical" : "Warning"}
|
||||
</span>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
{TYPE_ICONS[anomaly.type]} {TYPE_LABELS[anomaly.type]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-gray-700 dark:text-gray-300">
|
||||
{anomaly.message}
|
||||
</p>
|
||||
<Link
|
||||
href={entityLink(anomaly.type, anomaly.entityId) as Route}
|
||||
className="mt-1 inline-block text-xs font-medium text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||
>
|
||||
{anomaly.entityName} →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Project narrative ─────────────────────────────────────────── */}
|
||||
<section>
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-50">
|
||||
Project Narrative
|
||||
</h2>
|
||||
|
||||
{!aiConfigQuery.data?.configured ? (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-900 dark:bg-amber-950/30">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-300">
|
||||
AI is not configured.{" "}
|
||||
<Link
|
||||
href={"/admin/settings" as Route}
|
||||
className="font-medium underline hover:no-underline"
|
||||
>
|
||||
Configure AI credentials in Admin Settings
|
||||
</Link>{" "}
|
||||
to enable project narratives.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
|
||||
{/* Project selector */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="project-select"
|
||||
className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Select project
|
||||
</label>
|
||||
<select
|
||||
id="project-select"
|
||||
value={selectedProjectId}
|
||||
onChange={(e) => setSelectedProjectId(e.target.value)}
|
||||
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-800 dark:text-gray-100"
|
||||
>
|
||||
<option value="">Choose a project...</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.shortCode} - {p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selectedProjectId || generateMutation.isPending}
|
||||
onClick={() => {
|
||||
if (selectedProjectId) {
|
||||
generateMutation.mutate({ projectId: selectedProjectId });
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-brand-500 dark:hover:bg-brand-600"
|
||||
>
|
||||
{generateMutation.isPending ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4 animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
"Generate Summary"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Narrative display */}
|
||||
<div className="mt-6">
|
||||
{generateMutation.isPending ? (
|
||||
<NarrativeSkeleton />
|
||||
) : generateMutation.error ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-900 dark:bg-red-950/30 dark:text-red-300">
|
||||
{generateMutation.error.message}
|
||||
</div>
|
||||
) : generateMutation.data ? (
|
||||
<div className="rounded-xl border border-brand-200 bg-brand-50/50 p-5 dark:border-brand-900/50 dark:bg-brand-950/20">
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-gray-800 dark:text-gray-200">
|
||||
{generateMutation.data.narrative}
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-gray-400 dark:text-gray-500">
|
||||
Generated {new Date(generateMutation.data.generatedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
) : cachedNarrativeQuery.data?.narrative ? (
|
||||
<div className="rounded-xl border border-gray-200 bg-gray-50/50 p-5 dark:border-slate-700 dark:bg-slate-800/50">
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-gray-800 dark:text-gray-200">
|
||||
{cachedNarrativeQuery.data.narrative}
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-gray-400 dark:text-gray-500">
|
||||
Previously generated{" "}
|
||||
{cachedNarrativeQuery.data.generatedAt
|
||||
? new Date(cachedNarrativeQuery.data.generatedAt).toLocaleString()
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
) : selectedProjectId ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Click "Generate Summary" to create an AI-powered executive narrative for this project.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Select a project above to generate or view its executive summary.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,9 @@ function ReportBuilderIcon() {
|
||||
function GraphIcon() {
|
||||
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="18" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="12" cy="18" r="2.5" strokeWidth={1.8} /><path strokeLinecap="round" strokeWidth={1.8} d="M8.5 7.5l2 7M15.5 7.5l-2 7M8.5 6h7" /></svg>;
|
||||
}
|
||||
function InsightsIcon() {
|
||||
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>;
|
||||
}
|
||||
function NotificationsIcon() {
|
||||
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>;
|
||||
}
|
||||
@@ -146,6 +149,7 @@ const navSections: NavSection[] = [
|
||||
{ href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
{ href: "/reports/builder", label: "Report Builder", icon: <ReportBuilderIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
{ href: "/analytics/computation-graph", label: "Computation Graph", icon: <GraphIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
{ href: "/analytics/insights", label: "AI Insights", icon: <InsightsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -184,6 +188,7 @@ const adminNavEntries: AdminEntry[] = [
|
||||
{ href: "/admin/settings", label: "Settings", icon: <AdminIcon /> },
|
||||
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
|
||||
{ href: "/admin/notifications", label: "Broadcasts", icon: <BroadcastIcon /> },
|
||||
{ href: "/admin/webhooks", label: "Webhooks", icon: <AdminIcon /> },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const DISMISS_KEY = "planarchy_pwa_dismiss";
|
||||
const DISMISS_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
export function InstallPrompt() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const deferredPromptRef = useRef<BeforeInstallPromptEvent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if dismissed recently
|
||||
try {
|
||||
const dismissed = localStorage.getItem(DISMISS_KEY);
|
||||
if (dismissed) {
|
||||
const timestamp = parseInt(dismissed, 10);
|
||||
if (Date.now() - timestamp < DISMISS_DURATION_MS) return;
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
|
||||
// Check if already installed (standalone mode)
|
||||
if (window.matchMedia("(display-mode: standalone)").matches) return;
|
||||
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
deferredPromptRef.current = e as BeforeInstallPromptEvent;
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
window.addEventListener("beforeinstallprompt", handler);
|
||||
return () => window.removeEventListener("beforeinstallprompt", handler);
|
||||
}, []);
|
||||
|
||||
const handleInstall = useCallback(async () => {
|
||||
const prompt = deferredPromptRef.current;
|
||||
if (!prompt) return;
|
||||
|
||||
await prompt.prompt();
|
||||
const { outcome } = await prompt.userChoice;
|
||||
|
||||
if (outcome === "accepted") {
|
||||
setVisible(false);
|
||||
}
|
||||
deferredPromptRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setVisible(false);
|
||||
deferredPromptRef.current = null;
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, String(Date.now()));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-20 left-4 right-4 z-50 mx-auto max-w-md animate-in slide-in-from-bottom-4 duration-300 sm:left-auto sm:right-6">
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-brand-200/60 bg-white/95 px-4 py-3 shadow-lg backdrop-blur-xl dark:border-brand-900/40 dark:bg-slate-900/95">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-600 text-white shadow-md shadow-brand-600/25">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-50">
|
||||
Install Planarchy
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Add to home screen for quick access
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-slate-800"
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleInstall()}
|
||||
className="rounded-lg bg-brand-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-colors hover:bg-brand-700"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function ServiceWorkerRegistration() {
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
!("serviceWorker" in navigator) ||
|
||||
process.env.NODE_ENV === "development"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.serviceWorker
|
||||
.register("/sw.js")
|
||||
.then((registration) => {
|
||||
// Check for updates every 60 minutes
|
||||
setInterval(() => {
|
||||
registration.update().catch(() => {
|
||||
// Silent fail on update check
|
||||
});
|
||||
}, 60 * 60 * 1000);
|
||||
})
|
||||
.catch(() => {
|
||||
// Service worker registration failed — non-critical
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export function ScenarioPlanner({ projectId, baseline, resources, roles }: Scena
|
||||
|
||||
// Simulation mutation
|
||||
const simulateMut = trpc.scenario.simulate.useMutation();
|
||||
const applyMut = trpc.scenario.apply.useMutation();
|
||||
const applyMut = trpc.scenario.applyScenario.useMutation();
|
||||
|
||||
// Derived: has the scenario diverged from baseline?
|
||||
const isDirty = useMemo(() => {
|
||||
|
||||
Reference in New Issue
Block a user