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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user