feat: timeline multi-select, demand popover, resource hover card, merged tooltips, dark mode fixes

Major timeline enhancements:
- Right-click drag multi-selection with floating action bar (batch delete/assign)
- DemandPopover for demand strip details (replaces broken "Loading" modal)
- ResourceHoverCard on name hover showing skills, rates, role, chapter
- Merged heatmap+vacation tooltips into unified TimelineTooltip component
- Fixed overbooking blink animation (date normalization, z-index ordering)
- Fixed dark mode sticky column bleed-through in project view
- System roles admin page, notification task management, performance review docs

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-18 23:43:51 +01:00
parent d0f04f13f8
commit ddec3a927a
67 changed files with 4930 additions and 1166 deletions
@@ -0,0 +1,21 @@
import dynamic from "next/dynamic";
const SystemRolesClient = dynamic(
() => import("~/components/admin/SystemRolesClient.js").then((m) => m.SystemRolesClient),
{
loading: () => (
<div className="animate-pulse p-6 space-y-4 max-w-4xl mx-auto">
<div className="h-8 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-24 w-full bg-gray-200 dark:bg-gray-700 rounded-xl" />
))}
</div>
</div>
),
},
);
export default function SystemRolesPage() {
return <SystemRolesClient />;
}
+18 -1
View File
@@ -1,4 +1,21 @@
import { AllocationsClient } from "~/components/allocations/AllocationsClient.js";
import dynamic from "next/dynamic";
const AllocationsClient = dynamic(
() => import("~/components/allocations/AllocationsClient.js").then((m) => m.AllocationsClient),
{
loading: () => (
<div className="animate-pulse p-6 space-y-4">
<div className="h-8 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
<div className="space-y-2">
{[...Array(8)].map((_, i) => (
<div key={i} className="h-12 w-full bg-gray-200 dark:bg-gray-700 rounded" />
))}
</div>
</div>
),
},
);
export default function AllocationsPage() {
return <AllocationsClient />;
@@ -70,18 +70,18 @@ type EstimateDetail = {
};
const STATUS_STYLES: Record<EstimateStatus, string> = {
DRAFT: "bg-slate-100 text-slate-700",
IN_REVIEW: "bg-amber-100 text-amber-700",
APPROVED: "bg-emerald-100 text-emerald-700",
ARCHIVED: "bg-zinc-200 text-zinc-700",
DRAFT: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
IN_REVIEW: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
ARCHIVED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
};
const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
WORKING: "bg-sky-100 text-sky-700",
BASELINE: "bg-violet-100 text-violet-700",
SUBMITTED: "bg-amber-100 text-amber-700",
APPROVED: "bg-emerald-100 text-emerald-700",
SUPERSEDED: "bg-zinc-200 text-zinc-700",
WORKING: "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300",
BASELINE: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
SUBMITTED: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
SUPERSEDED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
};
function formatMetricValue(metric: EstimateMetric) {
@@ -146,7 +146,7 @@ function EstimateDetailPanel({
<div className="mt-4 flex gap-2">
<Link
href={`/estimates/${estimate.id}`}
className="inline-flex items-center justify-center rounded-2xl border border-brand-200 bg-brand-50 px-4 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-100"
className="inline-flex items-center justify-center rounded-2xl border border-brand-200 dark:border-sky-700 bg-brand-50 dark:bg-sky-950/40 px-4 py-2 text-sm font-semibold text-brand-700 dark:text-sky-300 transition hover:border-brand-300 dark:hover:border-sky-600 hover:bg-brand-100 dark:hover:bg-sky-900/40"
>
Open workspace
</Link>
@@ -165,7 +165,7 @@ function EstimateDetailPanel({
{latestVersion ? (
<>
<div className="mt-5 flex items-center gap-2">
<span className="text-sm font-medium text-gray-700">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Version {latestVersion.versionNumber}
{latestVersion.label ? ` - ${latestVersion.label}` : ""}
</span>
@@ -212,7 +212,7 @@ function EstimateDetailPanel({
</div>
<div className="mt-3 space-y-2">
{latestVersion.scopeItems.length === 0 ? (
<p className="rounded-2xl border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-400">
<p className="rounded-2xl border border-dashed border-gray-200 dark:border-gray-700 px-4 py-3 text-sm text-gray-400">
No scope rows captured yet.
</p>
) : (
@@ -245,7 +245,7 @@ function EstimateDetailPanel({
</div>
<div className="mt-3 space-y-2">
{latestVersion.demandLines.length === 0 ? (
<p className="rounded-2xl border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-400">
<p className="rounded-2xl border border-dashed border-gray-200 dark:border-gray-700 px-4 py-3 text-sm text-gray-400">
No staffing demand captured yet.
</p>
) : (
@@ -273,7 +273,7 @@ function EstimateDetailPanel({
</div>
</>
) : (
<p className="mt-6 rounded-2xl border border-dashed border-gray-200 px-4 py-6 text-sm text-gray-400">
<p className="mt-6 rounded-2xl border border-dashed border-gray-200 dark:border-gray-700 px-4 py-6 text-sm text-gray-400">
No versions available for this estimate yet.
</p>
)}
@@ -302,8 +302,8 @@ function EstimateCard({
className={clsx(
"w-full rounded-3xl border p-5 text-left transition",
active
? "border-brand-500 bg-brand-50 shadow-sm dark:bg-brand-950/30"
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm dark:border-gray-800 dark:bg-gray-950 dark:hover:border-gray-700",
? "border-brand-500 bg-brand-50 shadow-sm dark:border-sky-400 dark:bg-sky-950/30"
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600",
!canInspect && "cursor-default",
)}
>
@@ -319,7 +319,7 @@ function EstimateCard({
{estimate.status.replace("_", " ")}
</span>
{estimate.project && (
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600">
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-2.5 py-1 text-xs font-medium text-gray-600 dark:text-gray-400">
{estimate.project.shortCode}
</span>
)}
@@ -408,7 +408,7 @@ export function EstimatesClient() {
return (
<>
<div className="app-page space-y-6">
<div className="app-surface-strong overflow-hidden bg-gradient-to-br from-white via-white to-brand-50 p-6 dark:from-gray-950 dark:via-gray-950 dark:to-brand-950/40">
<div className="app-surface-strong overflow-hidden bg-gradient-to-br from-white via-white to-brand-50 p-6 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">
@@ -1296,7 +1296,7 @@ export function ResourcesClient() {
{skills.slice(0, 3).map((s) => (
<span
key={s.skill}
className="inline-block rounded-full bg-brand-50 px-2 py-0.5 text-xs text-brand-700 dark:bg-brand-950/30 dark:text-brand-200"
className="inline-block rounded-full bg-brand-50 px-2 py-0.5 text-xs text-brand-700 dark:bg-brand-900/60 dark:text-brand-100"
>
{s.skill}
</span>
+19 -7
View File
@@ -1,10 +1,22 @@
import { Suspense } from "react";
import { ResourcesClient } from "./ResourcesClient.js";
import dynamic from "next/dynamic";
const ResourcesClient = dynamic(
() => import("./ResourcesClient.js").then((m) => m.ResourcesClient),
{
loading: () => (
<div className="animate-pulse p-6 space-y-4">
<div className="h-8 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
<div className="space-y-2">
{[...Array(10)].map((_, i) => (
<div key={i} className="h-12 w-full bg-gray-200 dark:bg-gray-700 rounded" />
))}
</div>
</div>
),
},
);
export default function ResourcesPage() {
return (
<Suspense>
<ResourcesClient />
</Suspense>
);
return <ResourcesClient />;
}
+14 -1
View File
@@ -1,4 +1,17 @@
import { TimelineView } from "~/components/timeline/TimelineView.js";
import dynamic from "next/dynamic";
const TimelineView = dynamic(
() => import("~/components/timeline/TimelineView.js").then((m) => m.TimelineView),
{
loading: () => (
<div className="animate-pulse flex flex-col gap-4 h-full p-6">
<div className="h-8 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
),
},
);
export default function TimelinePage() {
return (
+23 -2
View File
@@ -1,10 +1,25 @@
import { createTRPCContext } from "@planarchy/api";
import { createTRPCContext, loadRoleDefaults } from "@planarchy/api";
import { appRouter } from "@planarchy/api/router";
import { prisma } from "@planarchy/db";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import type { NextRequest } from "next/server";
import { auth } from "~/server/auth.js";
// Throttle lastActiveAt updates: max once per 60s per user
const lastActiveCache = new Map<string, number>();
const ACTIVITY_THROTTLE_MS = 60_000;
function trackActivity(userId: string) {
const now = Date.now();
const last = lastActiveCache.get(userId) ?? 0;
if (now - last < ACTIVITY_THROTTLE_MS) return;
lastActiveCache.set(userId, now);
prisma.user.update({
where: { id: userId },
data: { lastActiveAt: new Date(now) },
}).catch(() => {/* ignore */});
}
const handler = async (req: NextRequest) => {
const session = await auth();
@@ -15,12 +30,18 @@ const handler = async (req: NextRequest) => {
})
: null;
// Track user activity (throttled, fire-and-forget)
if (dbUser) trackActivity(dbUser.id);
// Load configurable role defaults (cached, 60s TTL)
const roleDefaults = await loadRoleDefaults();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options: any = {
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createTRPCContext({ session, dbUser }),
createContext: () => createTRPCContext({ session, dbUser, roleDefaults }),
};
if (process.env["NODE_ENV"] === "development") {
+17 -1
View File
@@ -335,7 +335,7 @@
color: rgb(196 181 253) !important;
}
.dark .bg-amber-50 {
background-color: rgb(120 53 15 / 0.2) !important;
background-color: rgb(120 53 15) !important;
}
/* Modal / overlay */
@@ -427,3 +427,19 @@
@apply opacity-75 shadow-lg scale-105;
}
}
/* ─── Overbooking blink animation ──────────────────────────────────────────── */
@keyframes overbooking-blink {
0%, 100% { background-color: rgba(239, 68, 68, 0); }
50% { background-color: rgba(239, 68, 68, 0.18); }
}
.dark .animate-overbooking-blink {
animation: overbooking-blink-dark 2s ease-in-out infinite;
}
@keyframes overbooking-blink-dark {
0%, 100% { background-color: rgba(239, 68, 68, 0); }
50% { background-color: rgba(239, 68, 68, 0.25); }
}
.animate-overbooking-blink {
animation: overbooking-blink 2s ease-in-out infinite;
}