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
+3
View File
@@ -2,6 +2,9 @@ import path from "path";
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ["recharts", "date-fns"],
},
transpilePackages: [ transpilePackages: [
"@planarchy/api", "@planarchy/api",
"@planarchy/db", "@planarchy/db",
@@ -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() { export default function AllocationsPage() {
return <AllocationsClient />; return <AllocationsClient />;
@@ -70,18 +70,18 @@ type EstimateDetail = {
}; };
const STATUS_STYLES: Record<EstimateStatus, string> = { const STATUS_STYLES: Record<EstimateStatus, string> = {
DRAFT: "bg-slate-100 text-slate-700", DRAFT: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
IN_REVIEW: "bg-amber-100 text-amber-700", IN_REVIEW: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
APPROVED: "bg-emerald-100 text-emerald-700", APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
ARCHIVED: "bg-zinc-200 text-zinc-700", ARCHIVED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
}; };
const VERSION_STYLES: Record<EstimateVersionStatus, string> = { const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
WORKING: "bg-sky-100 text-sky-700", WORKING: "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300",
BASELINE: "bg-violet-100 text-violet-700", BASELINE: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
SUBMITTED: "bg-amber-100 text-amber-700", SUBMITTED: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
APPROVED: "bg-emerald-100 text-emerald-700", APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
SUPERSEDED: "bg-zinc-200 text-zinc-700", SUPERSEDED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
}; };
function formatMetricValue(metric: EstimateMetric) { function formatMetricValue(metric: EstimateMetric) {
@@ -146,7 +146,7 @@ function EstimateDetailPanel({
<div className="mt-4 flex gap-2"> <div className="mt-4 flex gap-2">
<Link <Link
href={`/estimates/${estimate.id}`} 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 Open workspace
</Link> </Link>
@@ -165,7 +165,7 @@ function EstimateDetailPanel({
{latestVersion ? ( {latestVersion ? (
<> <>
<div className="mt-5 flex items-center gap-2"> <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} Version {latestVersion.versionNumber}
{latestVersion.label ? ` - ${latestVersion.label}` : ""} {latestVersion.label ? ` - ${latestVersion.label}` : ""}
</span> </span>
@@ -212,7 +212,7 @@ function EstimateDetailPanel({
</div> </div>
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{latestVersion.scopeItems.length === 0 ? ( {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. No scope rows captured yet.
</p> </p>
) : ( ) : (
@@ -245,7 +245,7 @@ function EstimateDetailPanel({
</div> </div>
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{latestVersion.demandLines.length === 0 ? ( {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. No staffing demand captured yet.
</p> </p>
) : ( ) : (
@@ -273,7 +273,7 @@ function EstimateDetailPanel({
</div> </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. No versions available for this estimate yet.
</p> </p>
)} )}
@@ -302,8 +302,8 @@ function EstimateCard({
className={clsx( className={clsx(
"w-full rounded-3xl border p-5 text-left transition", "w-full rounded-3xl border p-5 text-left transition",
active active
? "border-brand-500 bg-brand-50 shadow-sm dark:bg-brand-950/30" ? "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-800 dark:bg-gray-950 dark:hover:border-gray-700", : "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", !canInspect && "cursor-default",
)} )}
> >
@@ -319,7 +319,7 @@ function EstimateCard({
{estimate.status.replace("_", " ")} {estimate.status.replace("_", " ")}
</span> </span>
{estimate.project && ( {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} {estimate.project.shortCode}
</span> </span>
)} )}
@@ -408,7 +408,7 @@ export function EstimatesClient() {
return ( return (
<> <>
<div className="app-page space-y-6"> <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 className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600"> <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) => ( {skills.slice(0, 3).map((s) => (
<span <span
key={s.skill} 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} {s.skill}
</span> </span>
+19 -7
View File
@@ -1,10 +1,22 @@
import { Suspense } from "react"; import dynamic from "next/dynamic";
import { ResourcesClient } from "./ResourcesClient.js";
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() { export default function ResourcesPage() {
return ( return <ResourcesClient />;
<Suspense>
<ResourcesClient />
</Suspense>
);
} }
+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() { export default function TimelinePage() {
return ( 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 { appRouter } from "@planarchy/api/router";
import { prisma } from "@planarchy/db"; import { prisma } from "@planarchy/db";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { auth } from "~/server/auth.js"; 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 handler = async (req: NextRequest) => {
const session = await auth(); const session = await auth();
@@ -15,12 +30,18 @@ const handler = async (req: NextRequest) => {
}) })
: null; : 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const options: any = { const options: any = {
endpoint: "/api/trpc", endpoint: "/api/trpc",
req, req,
router: appRouter, router: appRouter,
createContext: () => createTRPCContext({ session, dbUser }), createContext: () => createTRPCContext({ session, dbUser, roleDefaults }),
}; };
if (process.env["NODE_ENV"] === "development") { if (process.env["NODE_ENV"] === "development") {
+17 -1
View File
@@ -335,7 +335,7 @@
color: rgb(196 181 253) !important; color: rgb(196 181 253) !important;
} }
.dark .bg-amber-50 { .dark .bg-amber-50 {
background-color: rgb(120 53 15 / 0.2) !important; background-color: rgb(120 53 15) !important;
} }
/* Modal / overlay */ /* Modal / overlay */
@@ -427,3 +427,19 @@
@apply opacity-75 shadow-lg scale-105; @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;
}
@@ -0,0 +1,448 @@
"use client";
import { useState } from "react";
import { PermissionKey } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
const PERMISSION_LABELS: Record<string, string> = {
viewCosts: "View Costs",
exportData: "Export Data",
importData: "Import Data",
approveVacations: "Approve Vacations",
manageBlueprints: "Manage Blueprints",
viewAllResources: "View All Resources",
manageResources: "Manage Resources",
manageProjects: "Manage Projects",
manageAllocations: "Manage Allocations",
manageRoles: "Manage Roles",
manageUsers: "Manage Users",
viewScores: "View Scores",
};
const PERMISSION_DESCRIPTIONS: Record<string, string> = {
viewCosts: "Access to cost data, budget views, and financial reports",
exportData: "Export data to Excel, CSV, or PDF formats",
importData: "Import data from external sources (Dispo, Excel)",
approveVacations: "Approve or reject vacation requests",
manageBlueprints: "Create and edit blueprint field definitions",
viewAllResources: "View all resources (not just own team)",
manageResources: "Create, edit, and deactivate resource records",
manageProjects: "Create, edit, and manage project records",
manageAllocations: "Create, edit, and delete allocations",
manageRoles: "Create and edit project roles",
manageUsers: "Manage user accounts and permissions",
viewScores: "View value scores and skill analytics",
};
const COLOR_OPTIONS = [
{ value: "purple", label: "Purple", class: "bg-purple-500" },
{ value: "blue", label: "Blue", class: "bg-blue-500" },
{ value: "amber", label: "Amber", class: "bg-amber-500" },
{ value: "green", label: "Green", class: "bg-green-500" },
{ value: "red", label: "Red", class: "bg-red-500" },
{ value: "gray", label: "Gray", class: "bg-gray-500" },
{ value: "indigo", label: "Indigo", class: "bg-indigo-500" },
{ value: "teal", label: "Teal", class: "bg-teal-500" },
];
const ROLE_COLOR_MAP: Record<string, string> = {
purple: "border-purple-300 bg-purple-50 dark:border-purple-700 dark:bg-purple-900/20",
blue: "border-blue-300 bg-blue-50 dark:border-blue-700 dark:bg-blue-900/20",
amber: "border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20",
green: "border-green-300 bg-green-50 dark:border-green-700 dark:bg-green-900/20",
red: "border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-900/20",
gray: "border-gray-300 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50",
indigo: "border-indigo-300 bg-indigo-50 dark:border-indigo-700 dark:bg-indigo-900/20",
teal: "border-teal-300 bg-teal-50 dark:border-teal-700 dark:bg-teal-900/20",
};
const ROLE_BADGE_MAP: Record<string, string> = {
purple: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400",
blue: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400",
amber: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400",
green: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400",
red: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400",
gray: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
indigo: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-400",
teal: "bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-400",
};
type RoleConfig = {
role: string;
label: string;
description: string | null;
defaultPermissions: unknown;
color: string | null;
sortOrder: number;
};
type EditingRole = {
role: string;
label: string;
description: string;
color: string;
permissions: Set<string>;
};
export function SystemRolesClient() {
const [editingRole, setEditingRole] = useState<EditingRole | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const utils = trpc.useUtils();
const { data: roleConfigs, isLoading } = trpc.systemRoleConfig.list.useQuery(undefined, {
staleTime: 10_000,
});
const updateMutation = trpc.systemRoleConfig.update.useMutation({
onSuccess: async () => {
await utils.systemRoleConfig.list.invalidate();
setEditingRole(null);
setActionError(null);
setSuccessMessage("Role permissions updated successfully");
setTimeout(() => setSuccessMessage(null), 3000);
},
onError: (err) => setActionError(err.message),
});
function openEdit(config: RoleConfig) {
setEditingRole({
role: config.role,
label: config.label,
description: config.description ?? "",
color: config.color ?? "gray",
permissions: new Set(config.defaultPermissions as string[]),
});
setActionError(null);
setSuccessMessage(null);
}
function togglePermission(key: string) {
if (!editingRole) return;
const next = new Set(editingRole.permissions);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
setEditingRole({ ...editingRole, permissions: next });
}
function selectAll() {
if (!editingRole) return;
setEditingRole({ ...editingRole, permissions: new Set(ALL_PERMISSION_KEYS) });
}
function selectNone() {
if (!editingRole) return;
setEditingRole({ ...editingRole, permissions: new Set() });
}
async function handleSave() {
if (!editingRole) return;
setActionError(null);
await updateMutation.mutateAsync({
role: editingRole.role,
label: editingRole.label,
description: editingRole.description || null,
color: editingRole.color,
defaultPermissions: Array.from(editingRole.permissions),
});
}
const configs = (roleConfigs ?? []) as unknown as RoleConfig[];
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">System Role Management</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Configure default permissions for each system role. Changes apply to all users with that role.
</p>
</div>
{successMessage && (
<div className="mb-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 px-4 py-3 text-sm text-green-700 dark:text-green-400">
{successMessage}
</div>
)}
{isLoading && (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-24 bg-gray-200 dark:bg-gray-700 rounded-xl animate-pulse" />
))}
</div>
)}
{/* Role Cards */}
<div className="space-y-3">
{configs.map((config) => {
const perms = config.defaultPermissions as string[];
const color = config.color ?? "gray";
return (
<div
key={config.role}
className={`rounded-xl border-2 p-4 transition-colors ${ROLE_COLOR_MAP[color] ?? ROLE_COLOR_MAP.gray}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold ${ROLE_BADGE_MAP[color] ?? ROLE_BADGE_MAP.gray}`}>
{config.label}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono">
{config.role}
</span>
</div>
{config.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{config.description}
</p>
)}
<div className="flex flex-wrap gap-1 mt-2">
{perms.length === 0 ? (
<span className="text-xs text-gray-400 dark:text-gray-500 italic">No default permissions</span>
) : (
perms.map((p) => (
<span
key={p}
className="inline-flex items-center px-1.5 py-0.5 rounded text-[11px] font-medium bg-white/60 dark:bg-white/10 text-gray-700 dark:text-gray-300"
>
{PERMISSION_LABELS[p] ?? p}
</span>
))
)}
</div>
</div>
<button
type="button"
onClick={() => openEdit(config)}
className="flex-shrink-0 ml-4 px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-white/50 dark:hover:bg-gray-800/50 transition-colors"
>
Configure
</button>
</div>
</div>
);
})}
</div>
{/* Permission Matrix Overview */}
{configs.length > 0 && (
<div className="mt-8">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50 mb-3 flex items-center">
Permission Matrix <InfoTooltip content="Overview of which permissions each role has by default." />
</h2>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<th className="px-3 py-2 text-left font-medium text-gray-600 dark:text-gray-400 sticky left-0 bg-gray-50 dark:bg-gray-800/50">Permission</th>
{configs.map((c) => (
<th key={c.role} className="px-3 py-2 text-center font-medium text-gray-600 dark:text-gray-400 min-w-[80px]">
{c.label}
</th>
))}
</tr>
</thead>
<tbody>
{ALL_PERMISSION_KEYS.map((key) => (
<tr key={key} className="border-b border-gray-100 dark:border-gray-800">
<td className="px-3 py-1.5 text-gray-700 dark:text-gray-300 font-medium sticky left-0 bg-white dark:bg-gray-900">
{PERMISSION_LABELS[key] ?? key}
</td>
{configs.map((c) => {
const perms = c.defaultPermissions as string[];
const has = perms.includes(key);
return (
<td key={c.role} className="px-3 py-1.5 text-center">
{has ? (
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-green-100 dark:bg-green-900/40 text-green-600 dark:text-green-400">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</span>
) : (
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full text-gray-300 dark:text-gray-600">
&mdash;
</span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Edit Modal */}
{editingRole && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-lg mx-4 flex flex-col max-h-[90vh]">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Configure Role
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{editingRole.role}
</p>
</div>
<button
type="button"
onClick={() => { setEditingRole(null); setActionError(null); }}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none"
>
&times;
</button>
</div>
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-5">
{actionError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-3 py-2 text-sm text-red-700 dark:text-red-400">
{actionError}
</div>
)}
{/* Label */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Display Name
</label>
<input
type="text"
value={editingRole.label}
onChange={(e) => setEditingRole({ ...editingRole, label: e.target.value })}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<input
type="text"
value={editingRole.description}
onChange={(e) => setEditingRole({ ...editingRole, description: e.target.value })}
placeholder="Brief description of this role..."
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
{/* Color */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Badge Color
</label>
<div className="flex gap-2">
{COLOR_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setEditingRole({ ...editingRole, color: opt.value })}
className={`w-7 h-7 rounded-full ${opt.class} transition-all ${
editingRole.color === opt.value
? "ring-2 ring-offset-2 ring-brand-500 dark:ring-offset-gray-900 scale-110"
: "opacity-60 hover:opacity-100"
}`}
title={opt.label}
/>
))}
</div>
</div>
{/* Permissions */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Default Permissions ({editingRole.permissions.size}/{ALL_PERMISSION_KEYS.length})
</label>
<div className="flex gap-2">
<button
type="button"
onClick={selectAll}
className="text-[11px] text-brand-600 hover:text-brand-800 font-medium"
>
Select all
</button>
<button
type="button"
onClick={selectNone}
className="text-[11px] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 font-medium"
>
Clear all
</button>
</div>
</div>
<div className="space-y-1">
{ALL_PERMISSION_KEYS.map((key) => {
const isActive = editingRole.permissions.has(key);
return (
<button
key={key}
type="button"
onClick={() => togglePermission(key)}
className={`flex items-center gap-2.5 w-full px-3 py-2 rounded-lg border text-sm text-left transition-colors ${
isActive
? "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
}`}
>
<span className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${
isActive
? "text-green-600 border-green-300 bg-green-100 dark:bg-green-900/40"
: "border-gray-300 dark:border-gray-600"
}`}>
{isActive && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
)}
</span>
<div className="flex-1 min-w-0">
<span className={isActive ? "text-gray-900 dark:text-gray-100 font-medium" : "text-gray-500 dark:text-gray-400"}>
{PERMISSION_LABELS[key] ?? key}
</span>
<p className="text-[11px] text-gray-400 dark:text-gray-500 mt-0.5 truncate">
{PERMISSION_DESCRIPTIONS[key] ?? ""}
</p>
</div>
</button>
);
})}
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={() => { setEditingRole(null); setActionError(null); }}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
>
Cancel
</button>
<button
type="button"
onClick={() => void handleSave()}
disabled={updateMutation.isPending || !editingRole.label.trim()}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{updateMutation.isPending ? "Saving..." : "Save Changes"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -118,6 +118,10 @@ export function SystemSettingsClient() {
const [vacationDefaultDays, setVacationDefaultDays] = useState(28); const [vacationDefaultDays, setVacationDefaultDays] = useState(28);
const [vacationSaved, setVacationSaved] = useState(false); const [vacationSaved, setVacationSaved] = useState(false);
// Timeline
const [undoMaxSteps, setUndoMaxSteps] = useState(50);
const [timelineSaved, setTimelineSaved] = useState(false);
const { data: settings, isLoading } = trpc.settings.getSystemSettings.useQuery(undefined, { const { data: settings, isLoading } = trpc.settings.getSystemSettings.useQuery(undefined, {
staleTime: 0, staleTime: 0,
}); });
@@ -152,6 +156,8 @@ export function SystemSettingsClient() {
setAnonymizationSeed(""); setAnonymizationSeed("");
// Vacation // Vacation
setVacationDefaultDays(settings.vacationDefaultDays ?? 28); setVacationDefaultDays(settings.vacationDefaultDays ?? 28);
// Timeline
setUndoMaxSteps(settings.timelineUndoMaxSteps ?? 50);
} }
}, [settings]); }, [settings]);
@@ -227,6 +233,13 @@ export function SystemSettingsClient() {
}, },
}); });
const saveTimelineMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setTimelineSaved(true);
setTimeout(() => setTimelineSaved(false), 3000);
},
});
function handleSaveSmtp() { function handleSaveSmtp() {
saveSmtpMutation.mutate({ saveSmtpMutation.mutate({
smtpHost: smtpHost || undefined, smtpHost: smtpHost || undefined,
@@ -242,6 +255,10 @@ export function SystemSettingsClient() {
saveVacationMutation.mutate({ vacationDefaultDays }); saveVacationMutation.mutate({ vacationDefaultDays });
} }
function handleSaveTimeline() {
saveTimelineMutation.mutate({ timelineUndoMaxSteps: undoMaxSteps });
}
function handleSaveAnonymization() { function handleSaveAnonymization() {
saveAnonymizationMutation.mutate({ saveAnonymizationMutation.mutate({
anonymizationEnabled, anonymizationEnabled,
@@ -1226,6 +1243,46 @@ export function SystemSettingsClient() {
</div> </div>
</div> </div>
<div className={PANEL_CLASS}>
<div>
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center">
Timeline <InfoTooltip content="Settings for the timeline view, including undo history depth." />
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Configure timeline behavior and undo/redo history.
</p>
</div>
<div className="max-w-xs">
<label className={LABEL_CLASS}>Undo History Depth</label>
<input
type="number"
className={INPUT_CLASS}
value={undoMaxSteps}
onChange={(e) => setUndoMaxSteps(parseInt(e.target.value, 10) || 50)}
min={1}
max={200}
/>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Maximum number of undo steps for timeline operations (single moves and batch shifts). Default: 50.
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSaveTimeline}
disabled={saveTimelineMutation.isPending}
className={PRIMARY_BUTTON_CLASS}
>
{saveTimelineMutation.isPending ? "Saving…" : "Save Timeline Settings"}
</button>
{timelineSaved && (
<span className="text-sm text-green-600 dark:text-green-400 font-medium">Saved!</span>
)}
</div>
</div>
<div className={PANEL_CLASS}> <div className={PANEL_CLASS}>
<div> <div>
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center"> <h2 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex items-center">
+175 -83
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useMemo } from "react";
import { SystemRole, PermissionKey, type PermissionOverrides } from "@planarchy/shared"; import { SystemRole, PermissionKey, ROLE_DEFAULT_PERMISSIONS, type PermissionOverrides } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { FilterChips } from "~/components/ui/FilterChips.js"; import { FilterChips } from "~/components/ui/FilterChips.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -56,6 +56,9 @@ type UserRow = {
email: string; email: string;
systemRole: string; systemRole: string;
createdAt: Date; createdAt: Date;
lastLoginAt: Date | null;
lastActiveAt: Date | null;
permissionOverrides: PermissionOverrides | null;
}; };
type EditState = { type EditState = {
@@ -94,6 +97,25 @@ export function UsersClient() {
staleTime: 10_000, staleTime: 10_000,
}); });
const { data: roleConfigs } = trpc.systemRoleConfig.list.useQuery(undefined, {
staleTime: 60_000,
});
// Build dynamic role defaults map from DB config (fallback to hardcoded)
const roleDefaultsMap = useMemo(() => {
if (!roleConfigs) return ROLE_DEFAULT_PERMISSIONS;
const map: Record<string, string[]> = {};
for (const c of roleConfigs) {
map[c.role] = c.defaultPermissions as string[];
}
return map as Record<SystemRole, string[]>;
}, [roleConfigs]);
const { data: activeData } = trpc.user.activeCount.useQuery(undefined, {
staleTime: 30_000,
refetchInterval: 30_000,
});
const { data: effectivePerms } = trpc.user.getEffectivePermissions.useQuery( const { data: effectivePerms } = trpc.user.getEffectivePermissions.useQuery(
{ userId: selectedUserId ?? "" }, { userId: selectedUserId ?? "" },
{ enabled: !!selectedUserId }, { enabled: !!selectedUserId },
@@ -146,13 +168,14 @@ export function UsersClient() {
function openEdit(user: UserRow) { function openEdit(user: UserRow) {
const role = (user.systemRole as SystemRole) ?? SystemRole.USER; const role = (user.systemRole as SystemRole) ?? SystemRole.USER;
const overrides = user.permissionOverrides as PermissionOverrides | null;
setSelectedUserId(user.id); setSelectedUserId(user.id);
setEditState({ setEditState({
userId: user.id, userId: user.id,
systemRole: role, systemRole: role,
granted: new Set(), granted: new Set(overrides?.granted ?? []),
denied: new Set(), denied: new Set(overrides?.denied ?? []),
chapterIds: "", chapterIds: (overrides?.chapterIds ?? []).join(", "),
}); });
setActionError(null); setActionError(null);
} }
@@ -280,6 +303,21 @@ export function UsersClient() {
...(roleFilter ? [{ label: `Role: ${SYSTEM_ROLE_LABELS[roleFilter]}`, onRemove: () => setRoleFilter("") }] : []), ...(roleFilter ? [{ label: `Role: ${SYSTEM_ROLE_LABELS[roleFilter]}`, onRemove: () => setRoleFilter("") }] : []),
]; ];
function isOnline(user: UserRow) {
if (!user.lastActiveAt) return false;
return Date.now() - new Date(user.lastActiveAt).getTime() < 5 * 60 * 1000;
}
function formatRelativeTime(date: Date | null) {
if (!date) return "Never";
const d = new Date(date);
const diff = Date.now() - d.getTime();
if (diff < 60_000) return "Just now";
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
}
return ( return (
<div className="p-6 max-w-5xl mx-auto"> <div className="p-6 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
@@ -289,7 +327,18 @@ export function UsersClient() {
Manage user roles and permission overrides Manage user roles and permission overrides
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
{activeData && (
<div className="flex items-center gap-2 rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 px-3 py-2 text-sm">
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500" />
</span>
<span className="font-medium text-green-700 dark:text-green-400">
{activeData.count} online
</span>
</div>
)}
<button <button
type="button" type="button"
onClick={() => void autoLinkMutation.mutateAsync().then((r) => { onClick={() => void autoLinkMutation.mutateAsync().then((r) => {
@@ -366,6 +415,8 @@ export function UsersClient() {
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="The user's display name. Shown in the UI and linked to a resource record if auto-linked." /> <SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="The user's display name. Shown in the UI and linked to a resource record if auto-linked." />
<SortableColumnHeader label="Email" field="email" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Login email address. Also used to auto-link user accounts to resource records by matching email." /> <SortableColumnHeader label="Email" field="email" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Login email address. Also used to auto-link user accounts to resource records by matching email." />
<SortableColumnHeader label="Role" field="systemRole" sortField={sortField} sortDir={sortDir} onSort={handleSort} align="center" tooltip="System role determines default access level. ADMIN: full access · MANAGER: manage resources/projects · CONTROLLER: read + export costs · USER: standard access · VIEWER: read-only. Individual permissions can be overridden via the edit dialog." tooltipWidth="w-80" /> <SortableColumnHeader label="Role" field="systemRole" sortField={sortField} sortDir={sortDir} onSort={handleSort} align="center" tooltip="System role determines default access level. ADMIN: full access · MANAGER: manage resources/projects · CONTROLLER: read + export costs · USER: standard access · VIEWER: read-only. Individual permissions can be overridden via the edit dialog." tooltipWidth="w-80" />
<th className="px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider text-center">Status</th>
<SortableColumnHeader label="Last Login" field="lastLoginAt" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="When the user last signed in." />
<SortableColumnHeader label="Created" field="createdAt" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Account creation date." /> <SortableColumnHeader label="Created" field="createdAt" sortField={sortField} sortDir={sortDir} onSort={handleSort} tooltip="Account creation date." />
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th> <th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
</tr> </tr>
@@ -373,14 +424,14 @@ export function UsersClient() {
<tbody> <tbody>
{isLoading && ( {isLoading && (
<tr> <tr>
<td colSpan={5} className="text-center py-8 text-gray-400"> <td colSpan={7} className="text-center py-8 text-gray-400">
Loading Loading
</td> </td>
</tr> </tr>
)} )}
{!isLoading && sorted.length === 0 && ( {!isLoading && sorted.length === 0 && (
<tr> <tr>
<td colSpan={5} className="text-center py-8 text-gray-400"> <td colSpan={7} className="text-center py-8 text-gray-400">
No users found. No users found.
</td> </td>
</tr> </tr>
@@ -403,6 +454,22 @@ export function UsersClient() {
{SYSTEM_ROLE_LABELS[user.systemRole as SystemRole] ?? user.systemRole} {SYSTEM_ROLE_LABELS[user.systemRole as SystemRole] ?? user.systemRole}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-center">
{isOnline(user) ? (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400">
<span className="h-1.5 w-1.5 rounded-full bg-green-500" />
Online
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500">
<span className="h-1.5 w-1.5 rounded-full bg-gray-400 dark:bg-gray-600" />
Offline
</span>
)}
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
{formatRelativeTime(user.lastLoginAt)}
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs"> <td className="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
{new Date(user.createdAt).toLocaleDateString("en-GB")} {new Date(user.createdAt).toLocaleDateString("en-GB")}
</td> </td>
@@ -576,83 +643,108 @@ export function UsersClient() {
</div> </div>
</section> </section>
{/* Effective Permissions */} {/* Permissions */}
{effectivePerms && ( <section>
<section> <h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center"> Permissions <InfoTooltip content="Permissions inherited from the role are shown with a filled checkbox. Click to override: grant additional permissions or deny role defaults." />
Effective Permissions <InfoTooltip content="The final set of permissions after combining the role's defaults with any overrides below. Green = granted, strikethrough = denied." /> </h3>
</h3> <div className="flex gap-1.5 mb-3 text-[11px]">
<div className="flex flex-wrap gap-1.5"> <span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
{ALL_PERMISSION_KEYS.map((key) => { <span className="inline-block w-3 h-3 rounded border border-green-400 bg-green-100 dark:bg-green-900/40" /> Role default
const isActive = effectivePerms.effectivePermissions.includes(key); </span>
return ( <span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
<span <span className="inline-block w-3 h-3 rounded border border-blue-400 bg-blue-100 dark:bg-blue-900/40" /> Extra grant
key={key} </span>
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${ <span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
isActive <span className="inline-block w-3 h-3 rounded border border-red-400 bg-red-100 dark:bg-red-900/40 relative"><span className="absolute inset-0 flex items-center justify-center text-red-500 text-[9px] leading-none">&times;</span></span> Denied
? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400" </span>
: "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-600 line-through" </div>
}`} <div className="space-y-1">
> {ALL_PERMISSION_KEYS.map((key) => {
const roleDefaults = new Set(roleDefaultsMap[editState.systemRole] ?? []);
const isRoleDefault = roleDefaults.has(key as PermissionKey);
const isGranted = editState.granted.has(key);
const isDenied = editState.denied.has(key);
// Determine display state
let state: "default" | "granted" | "denied" | "off";
if (isDenied) state = "denied";
else if (isGranted) state = "granted";
else if (isRoleDefault) state = "default";
else state = "off";
function cycleState() {
if (!editState) return;
const nextGranted = new Set(editState.granted);
const nextDenied = new Set(editState.denied);
if (isRoleDefault) {
// Role default: off → denied → off
if (isDenied) {
nextDenied.delete(key);
} else {
nextDenied.add(key);
nextGranted.delete(key);
}
} else {
// Non-default: off → granted → off
if (isGranted) {
nextGranted.delete(key);
} else {
nextGranted.add(key);
nextDenied.delete(key);
}
}
setEditState({ ...editState, granted: nextGranted, denied: nextDenied });
}
const stateStyles = {
default: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800",
granted: "bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800",
denied: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800",
off: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-gray-700",
};
const checkStyles = {
default: "text-green-600 border-green-300 bg-green-100 dark:bg-green-900/40",
granted: "text-blue-600 border-blue-300 bg-blue-100 dark:bg-blue-900/40",
denied: "text-red-600 border-red-300 bg-red-100 dark:bg-red-900/40",
off: "text-gray-400 border-gray-300 dark:border-gray-600",
};
return (
<button
key={key}
type="button"
onClick={cycleState}
className={`flex items-center gap-2.5 w-full px-3 py-1.5 rounded-lg border text-sm text-left transition-colors ${stateStyles[state]} hover:opacity-80`}
>
<span className={`flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center ${checkStyles[state]}`}>
{state === "default" && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /></svg>
)}
{state === "granted" && (
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" /></svg>
)}
{state === "denied" && (
<span className="text-xs font-bold leading-none">&times;</span>
)}
</span>
<span className={`flex-1 ${state === "denied" ? "line-through text-red-500 dark:text-red-400" : state === "off" ? "text-gray-500 dark:text-gray-400" : "text-gray-900 dark:text-gray-100"}`}>
{PERMISSION_LABELS[key] ?? key} {PERMISSION_LABELS[key] ?? key}
</span> </span>
); {state === "default" && (
})} <span className="text-[10px] text-green-600 dark:text-green-400 font-medium uppercase tracking-wide">Role</span>
</div> )}
</section> {state === "granted" && (
)} <span className="text-[10px] text-blue-600 dark:text-blue-400 font-medium uppercase tracking-wide">Extra</span>
)}
{/* Permission Overrides */} {state === "denied" && (
<section> <span className="text-[10px] text-red-600 dark:text-red-400 font-medium uppercase tracking-wide">Denied</span>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center"> )}
Permission Overrides <InfoTooltip content="Override specific permissions for this user. Grants add permissions beyond the role default; Denials remove permissions the role would normally have." /> </button>
</h3> );
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> })}
{/* Additional Grants */}
<div>
<p className="text-xs font-medium text-green-700 dark:text-green-400 mb-2 uppercase tracking-wide">
Additional Grants
</p>
<div className="space-y-1.5">
{ALL_PERMISSION_KEYS.map((key) => (
<label
key={`grant-${key}`}
className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
>
<input
type="checkbox"
checked={editState.granted.has(key)}
onChange={() => toggleGranted(key)}
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
{PERMISSION_LABELS[key] ?? key}
</label>
))}
</div>
</div>
{/* Explicit Denials */}
<div>
<p className="text-xs font-medium text-red-700 dark:text-red-400 mb-2 uppercase tracking-wide">
Explicit Denials
</p>
<div className="space-y-1.5">
{ALL_PERMISSION_KEYS.map((key) => (
<label
key={`deny-${key}`}
className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
>
<input
type="checkbox"
checked={editState.denied.has(key)}
onChange={() => toggleDenied(key)}
className="rounded border-gray-300 text-red-600 focus:ring-red-500"
/>
{PERMISSION_LABELS[key] ?? key}
</label>
))}
</div>
</div>
</div> </div>
{/* Chapter Scope */} {/* Chapter Scope */}
@@ -23,7 +23,10 @@ interface AllocationModalProps {
function toDateInputValue(date: Date | string | null | undefined): string { function toDateInputValue(date: Date | string | null | undefined): string {
if (!date) return ""; if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date; const d = typeof date === "string" ? new Date(date) : date;
return d.toISOString().split("T")[0] ?? ""; const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
} }
export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) { export function AllocationModal({ allocation, onClose, onSuccess }: AllocationModalProps) {
@@ -35,7 +35,7 @@ const TABS: Array<{ id: WorkspaceTab; label: string }> = [
function EmptyState({ children }: { children: React.ReactNode }) { function EmptyState({ children }: { children: React.ReactNode }) {
return ( return (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400"> <div className="rounded-3xl border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-5 py-10 text-center text-sm text-gray-400">
{children} {children}
</div> </div>
); );
@@ -53,8 +53,8 @@ function ActionNotice({
className={clsx( className={clsx(
"rounded-2xl border px-4 py-3 text-sm", "rounded-2xl border px-4 py-3 text-sm",
tone === "success" tone === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-800" ? "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300"
: "border-rose-200 bg-rose-50 text-rose-800", : "border-rose-200 bg-rose-50 text-rose-800 dark:border-rose-800 dark:bg-rose-950/50 dark:text-rose-300",
)} )}
> >
{children} {children}
@@ -182,7 +182,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
<div className="mx-auto max-w-7xl space-y-6 p-6"> <div className="mx-auto max-w-7xl space-y-6 p-6">
<Link <Link
href="/estimates" href="/estimates"
className="inline-flex items-center gap-1 text-sm text-gray-500 transition-colors hover:text-gray-800" className="inline-flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 transition-colors hover:text-gray-800 dark:hover:text-gray-200"
> >
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
@@ -190,21 +190,21 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
Back to Estimates Back to Estimates
</Link> </Link>
<div className="rounded-[28px] border border-gray-200 bg-gradient-to-br from-white via-white to-brand-50 p-6 shadow-sm"> <div className="rounded-[28px] border border-gray-200 dark:border-gray-700 bg-gradient-to-br from-white via-white to-brand-50 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900 p-6 shadow-sm dark:shadow-black/20">
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between"> <div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimate Workspace <InfoTooltip content="Central workspace for inspecting and editing an estimate's scope, staffing, financials, and version history." /></p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600 dark:text-sky-400">Estimate Workspace <InfoTooltip content="Central workspace for inspecting and editing an estimate's scope, staffing, financials, and version history." /></p>
<h1 className="mt-2 text-3xl font-semibold text-gray-900"> <h1 className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-50">
{estimate?.name ?? "Loading estimate"} {estimate?.name ?? "Loading estimate"}
</h1> </h1>
<p className="mt-2 max-w-3xl text-sm text-gray-600"> <p className="mt-2 max-w-3xl text-sm text-gray-600 dark:text-gray-300">
Use the tabs below to inspect the connected estimate structure, version context, and staffing breakdown without relying on spreadsheet tabs. Use the tabs below to inspect the connected estimate structure, version context, and staffing breakdown without relying on spreadsheet tabs.
</p> </p>
</div> </div>
{estimate && ( {estimate && (
<div className="flex flex-col gap-3 lg:items-end"> <div className="flex flex-col gap-3 lg:items-end">
<div className="grid gap-2 text-sm text-gray-500 lg:text-right"> <div className="grid gap-2 text-sm text-gray-500 dark:text-gray-400 lg:text-right">
<span>{estimate.project ? `${estimate.project.shortCode} - ${estimate.project.name}` : "Standalone estimate"}</span> <span>{estimate.project ? `${estimate.project.shortCode} - ${estimate.project.name}` : "Standalone estimate"}</span>
<span>Updated {formatDateLong(estimate.updatedAt)}</span> <span>Updated {formatDateLong(estimate.updatedAt)}</span>
</div> </div>
@@ -215,7 +215,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
if (!editableTab && !isEditing) return; if (!editableTab && !isEditing) return;
setIsEditing((current) => !current); setIsEditing((current) => !current);
}} }}
className="rounded-2xl border border-brand-200 bg-white px-4 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50" className="rounded-2xl border border-brand-200 dark:border-sky-700 bg-white dark:bg-gray-800 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-50 dark:hover:bg-gray-700"
> >
{isEditing ? "Close editor" : editableTab ? "Edit working draft" : "Draft editor available in editable tabs"} {isEditing ? "Close editor" : editableTab ? "Edit working draft" : "Draft editor available in editable tabs"}
</button> </button>
@@ -238,7 +238,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
{actionMessage && <ActionNotice tone="success">{actionMessage}</ActionNotice>} {actionMessage && <ActionNotice tone="success">{actionMessage}</ActionNotice>}
{actionError && <ActionNotice tone="error">{actionError}</ActionNotice>} {actionError && <ActionNotice tone="error">{actionError}</ActionNotice>}
<div className="flex flex-wrap gap-2 border-b border-gray-200"> <div className="flex flex-wrap gap-2 border-b border-gray-200 dark:border-gray-700">
{TABS.map((item) => ( {TABS.map((item) => (
<button <button
key={item.id} key={item.id}
@@ -247,8 +247,8 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
className={clsx( className={clsx(
"rounded-t-2xl border-b-2 px-4 py-3 text-sm font-medium transition-colors", "rounded-t-2xl border-b-2 px-4 py-3 text-sm font-medium transition-colors",
tab === item.id tab === item.id
? "border-brand-600 text-brand-700" ? "border-brand-600 text-brand-700 dark:border-sky-400 dark:text-sky-300"
: "border-transparent text-gray-500 hover:text-gray-800", : "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200",
)} )}
> >
{item.label} {item.label}
@@ -23,10 +23,10 @@ function getDefaultDateRange(): { start: string; end: string } {
function heatColor(hours: number, maxHours: number): string { function heatColor(hours: number, maxHours: number): string {
if (hours === 0 || maxHours === 0) return ""; if (hours === 0 || maxHours === 0) return "";
const ratio = Math.min(hours / maxHours, 1); const ratio = Math.min(hours / maxHours, 1);
if (ratio < 0.25) return "bg-blue-50"; if (ratio < 0.25) return "bg-blue-50 dark:bg-blue-900/20";
if (ratio < 0.5) return "bg-blue-100"; if (ratio < 0.5) return "bg-blue-100 dark:bg-blue-900/30";
if (ratio < 0.75) return "bg-blue-200"; if (ratio < 0.75) return "bg-blue-200 dark:bg-blue-900/40";
return "bg-blue-300"; return "bg-blue-300 dark:bg-blue-900/50";
} }
export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProps) { export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProps) {
@@ -116,43 +116,43 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header / Controls */} {/* Header / Controls */}
<div className="rounded-3xl border border-gray-200 bg-white p-6"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6">
<h3 className="mb-4 text-lg font-semibold text-gray-900"> <h3 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">
Weekly Phasing (4Dispo) Weekly Phasing (4Dispo)
</h3> </h3>
{canEdit && ( {canEdit && (
<div className="flex flex-wrap items-end gap-4"> <div className="flex flex-wrap items-end gap-4">
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"> <label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Start Date Start Date
</label> </label>
<input <input
type="date" type="date"
value={data?.hasPhasing ? effectiveStart : startDate} value={data?.hasPhasing ? effectiveStart : startDate}
onChange={(e) => setStartDate(e.target.value)} onChange={(e) => setStartDate(e.target.value)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm" className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 text-sm"
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"> <label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
End Date End Date
</label> </label>
<input <input
type="date" type="date"
value={data?.hasPhasing ? effectiveEnd : endDate} value={data?.hasPhasing ? effectiveEnd : endDate}
onChange={(e) => setEndDate(e.target.value)} onChange={(e) => setEndDate(e.target.value)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm" className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 text-sm"
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"> <label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Pattern Pattern
</label> </label>
<select <select
value={pattern} value={pattern}
onChange={(e) => setPattern(e.target.value as PhasingPattern)} onChange={(e) => setPattern(e.target.value as PhasingPattern)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm" className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 text-sm"
> >
<option value="even">Even Distribution</option> <option value="even">Even Distribution</option>
<option value="front_loaded">Front Loaded (60/40)</option> <option value="front_loaded">Front Loaded (60/40)</option>
@@ -198,8 +198,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
className={clsx( className={clsx(
"rounded-lg px-3 py-1.5 text-sm font-medium", "rounded-lg px-3 py-1.5 text-sm font-medium",
viewMode === "by_line" viewMode === "by_line"
? "bg-sky-100 text-sky-700" ? "bg-sky-100 dark:bg-sky-900/40 text-sky-700 dark:text-sky-300"
: "bg-gray-100 text-gray-600 hover:bg-gray-200", : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600",
)} )}
> >
By Line By Line
@@ -210,8 +210,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
className={clsx( className={clsx(
"rounded-lg px-3 py-1.5 text-sm font-medium", "rounded-lg px-3 py-1.5 text-sm font-medium",
viewMode === "by_chapter" viewMode === "by_chapter"
? "bg-sky-100 text-sky-700" ? "bg-sky-100 dark:bg-sky-900/40 text-sky-700 dark:text-sky-300"
: "bg-gray-100 text-gray-600 hover:bg-gray-200", : "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600",
)} )}
> >
By Chapter By Chapter
@@ -221,25 +221,25 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
{/* Phasing Grid */} {/* Phasing Grid */}
{phasingQuery.isLoading && ( {phasingQuery.isLoading && (
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-8 text-center text-gray-500 dark:text-gray-400">
Loading phasing data... Loading phasing data...
</div> </div>
)} )}
{data && !data.hasPhasing && ( {data && !data.hasPhasing && (
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-8 text-center text-gray-500 dark:text-gray-400">
No weekly phasing generated yet. Use the controls above to generate a No weekly phasing generated yet. Use the controls above to generate a
phasing distribution. phasing distribution.
</div> </div>
)} )}
{data?.hasPhasing && viewMode === "by_line" && ( {data?.hasPhasing && viewMode === "by_line" && (
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-gray-200 bg-gray-50"> <tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]"> <th className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-left font-medium text-gray-700 dark:text-gray-300 min-w-[200px]">
Demand Line Demand Line
</th> </th>
{data.weeks.map((week) => { {data.weeks.map((week) => {
@@ -247,13 +247,13 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return ( return (
<th <th
key={key} key={key}
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap" className="px-3 py-3 text-right font-medium text-gray-600 dark:text-gray-400 min-w-[80px] whitespace-nowrap"
> >
{week.label} {week.label}
</th> </th>
); );
})} })}
<th className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right font-semibold text-gray-700 min-w-[90px]"> <th className="sticky right-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300 min-w-[90px]">
Total Total
</th> </th>
</tr> </tr>
@@ -267,14 +267,14 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return ( return (
<tr <tr
key={line.id} key={line.id}
className="border-b border-gray-100 hover:bg-gray-50/50" className="border-b border-gray-100 dark:border-gray-700/50 hover:bg-gray-50/50 dark:hover:bg-gray-700/30"
> >
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900"> <td className="sticky left-0 z-10 bg-white dark:bg-gray-800 px-4 py-2 font-medium text-gray-900 dark:text-gray-100">
<div className="truncate max-w-[200px]" title={line.name}> <div className="truncate max-w-[200px]" title={line.name}>
{line.name} {line.name}
</div> </div>
{line.chapter && ( {line.chapter && (
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500 dark:text-gray-400">
{line.chapter} {line.chapter}
</div> </div>
)} )}
@@ -286,7 +286,7 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
<td <td
key={key} key={key}
className={clsx( className={clsx(
"px-3 py-2 text-right tabular-nums", "px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-200",
heatColor(hours, maxHours), heatColor(hours, maxHours),
)} )}
> >
@@ -294,7 +294,7 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
</td> </td>
); );
})} })}
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900"> <td className="sticky right-0 z-10 bg-white dark:bg-gray-800 px-4 py-2 text-right font-semibold tabular-nums text-gray-900 dark:text-gray-100">
{lineTotal.toFixed(1)} {lineTotal.toFixed(1)}
</td> </td>
</tr> </tr>
@@ -302,8 +302,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
})} })}
</tbody> </tbody>
<tfoot> <tfoot>
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold"> <tr className="border-t-2 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 font-semibold">
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700"> <td className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-gray-700 dark:text-gray-300">
Total Total
</td> </td>
{data.weeks.map((week) => { {data.weeks.map((week) => {
@@ -312,13 +312,13 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return ( return (
<td <td
key={key} key={key}
className="px-3 py-3 text-right tabular-nums text-gray-900" className="px-3 py-3 text-right tabular-nums text-gray-900 dark:text-gray-100"
> >
{total > 0 ? total.toFixed(1) : "-"} {total > 0 ? total.toFixed(1) : "-"}
</td> </td>
); );
})} })}
<td className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right tabular-nums text-gray-900"> <td className="sticky right-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-right tabular-nums text-gray-900 dark:text-gray-100">
{Object.values(columnTotals) {Object.values(columnTotals)
.reduce((sum, h) => sum + h, 0) .reduce((sum, h) => sum + h, 0)
.toFixed(1)} .toFixed(1)}
@@ -331,12 +331,12 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
)} )}
{data?.hasPhasing && viewMode === "by_chapter" && ( {data?.hasPhasing && viewMode === "by_chapter" && (
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-gray-200 bg-gray-50"> <tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]"> <th className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-left font-medium text-gray-700 dark:text-gray-300 min-w-[200px]">
Chapter Chapter
</th> </th>
{data.weeks.map((week) => { {data.weeks.map((week) => {
@@ -344,13 +344,13 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return ( return (
<th <th
key={key} key={key}
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap" className="px-3 py-3 text-right font-medium text-gray-600 dark:text-gray-400 min-w-[80px] whitespace-nowrap"
> >
{week.label} {week.label}
</th> </th>
); );
})} })}
<th className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right font-semibold text-gray-700 min-w-[90px]"> <th className="sticky right-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300 min-w-[90px]">
Total Total
</th> </th>
</tr> </tr>
@@ -366,9 +366,9 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return ( return (
<tr <tr
key={chapter} key={chapter}
className="border-b border-gray-100 hover:bg-gray-50/50" className="border-b border-gray-100 dark:border-gray-700/50 hover:bg-gray-50/50 dark:hover:bg-gray-700/30"
> >
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900"> <td className="sticky left-0 z-10 bg-white dark:bg-gray-800 px-4 py-2 font-medium text-gray-900 dark:text-gray-100">
{chapter} {chapter}
</td> </td>
{data.weeks.map((week) => { {data.weeks.map((week) => {
@@ -378,7 +378,7 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
<td <td
key={key} key={key}
className={clsx( className={clsx(
"px-3 py-2 text-right tabular-nums", "px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-200",
heatColor(hours, maxChapterHours), heatColor(hours, maxChapterHours),
)} )}
> >
@@ -386,7 +386,7 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
</td> </td>
); );
})} })}
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900"> <td className="sticky right-0 z-10 bg-white dark:bg-gray-800 px-4 py-2 text-right font-semibold tabular-nums text-gray-900 dark:text-gray-100">
{chapterTotal.toFixed(1)} {chapterTotal.toFixed(1)}
</td> </td>
</tr> </tr>
@@ -394,8 +394,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
})} })}
</tbody> </tbody>
<tfoot> <tfoot>
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold"> <tr className="border-t-2 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 font-semibold">
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700"> <td className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-gray-700 dark:text-gray-300">
Total Total
</td> </td>
{data.weeks.map((week) => { {data.weeks.map((week) => {
@@ -404,13 +404,13 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return ( return (
<td <td
key={key} key={key}
className="px-3 py-3 text-right tabular-nums text-gray-900" className="px-3 py-3 text-right tabular-nums text-gray-900 dark:text-gray-100"
> >
{total > 0 ? total.toFixed(1) : "-"} {total > 0 ? total.toFixed(1) : "-"}
</td> </td>
); );
})} })}
<td className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right tabular-nums text-gray-900"> <td className="sticky right-0 z-10 bg-gray-50 dark:bg-gray-900 px-4 py-3 text-right tabular-nums text-gray-900 dark:text-gray-100">
{Object.values(chapterColumnTotals) {Object.values(chapterColumnTotals)
.reduce((sum, h) => sum + h, 0) .reduce((sum, h) => sum + h, 0)
.toFixed(1)} .toFixed(1)}
@@ -424,8 +424,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
{/* Info about current phasing config */} {/* Info about current phasing config */}
{data?.hasPhasing && data.config && ( {data?.hasPhasing && data.config && (
<div className="rounded-3xl border border-gray-200 bg-white p-4"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600 dark:text-gray-400">
<span className="font-medium">Current phasing:</span>{" "} <span className="font-medium">Current phasing:</span>{" "}
{data.config.pattern.replace("_", " ")} distribution from{" "} {data.config.pattern.replace("_", " ")} distribution from{" "}
{data.config.startDate} to {data.config.endDate} across{" "} {data.config.startDate} to {data.config.endDate} across{" "}
@@ -8,7 +8,7 @@ import type {
function EmptyState({ children }: { children: React.ReactNode }) { function EmptyState({ children }: { children: React.ReactNode }) {
return ( return (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400"> <div className="rounded-3xl border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-5 py-10 text-center text-sm text-gray-400">
{children} {children}
</div> </div>
); );
@@ -24,25 +24,25 @@ export function AssumptionsTab({ estimate }: { estimate: EstimateWorkspaceView }
} }
return ( return (
<div className="rounded-3xl border border-gray-200 bg-white shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm">
<div className="border-b border-gray-100 px-6 py-4"> <div className="border-b border-gray-100 dark:border-gray-700/50 px-6 py-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Commercial and delivery assumptions <InfoTooltip content="Preconditions that affect the estimate validity. If any assumption changes, the estimate may need revision." /></h2> <h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Commercial and delivery assumptions <InfoTooltip content="Preconditions that affect the estimate validity. If any assumption changes, the estimate may need revision." /></h2>
</div> </div>
<div className="divide-y divide-gray-100"> <div className="divide-y divide-gray-100 dark:divide-gray-700/50">
{assumptions.map((assumption) => ( {assumptions.map((assumption) => (
<div key={assumption.id} className="grid gap-3 px-6 py-4 md:grid-cols-[160px,1fr,1fr]"> <div key={assumption.id} className="grid gap-3 px-6 py-4 md:grid-cols-[160px,1fr,1fr]">
<div> <div>
<p className="text-xs uppercase tracking-wide text-gray-400">Category <InfoTooltip content="Groups assumptions by topic, e.g. 'commercial', 'delivery', 'technical'." /></p> <p className="text-xs uppercase tracking-wide text-gray-400">Category <InfoTooltip content="Groups assumptions by topic, e.g. 'commercial', 'delivery', 'technical'." /></p>
<p className="mt-1 text-sm font-medium text-gray-900">{assumption.category}</p> <p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{assumption.category}</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-gray-400">Label <InfoTooltip content="Human-readable description of the assumption. The key below is the machine-readable identifier." /></p> <p className="text-xs uppercase tracking-wide text-gray-400">Label <InfoTooltip content="Human-readable description of the assumption. The key below is the machine-readable identifier." /></p>
<p className="mt-1 text-sm text-gray-800">{assumption.label}</p> <p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{assumption.label}</p>
<p className="mt-1 text-xs text-gray-400">{assumption.key}</p> <p className="mt-1 text-xs text-gray-400">{assumption.key}</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-gray-400">Value <InfoTooltip content="The concrete value or condition for this assumption." /></p> <p className="text-xs uppercase tracking-wide text-gray-400">Value <InfoTooltip content="The concrete value or condition for this assumption." /></p>
<p className="mt-1 text-sm text-gray-800">{String(assumption.value)}</p> <p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{String(assumption.value)}</p>
</div> </div>
</div> </div>
))} ))}
@@ -98,11 +98,11 @@ export function ExportsTab({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Export delivery <InfoTooltip content="Generate downloadable files from the current estimate version. Each format includes demand lines, scope, and financial summaries." /></h2> <h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Export delivery <InfoTooltip content="Generate downloadable files from the current estimate version. Each format includes demand lines, scope, and financial summaries." /></h2>
<p className="mt-2 text-sm text-gray-500"> <p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Generate format-specific artifacts from the current version and download them directly from the stored serializer payload. Generate format-specific artifacts from the current version and download them directly from the stored serializer payload.
</p> </p>
</div> </div>
@@ -114,7 +114,7 @@ export function ExportsTab({
type="button" type="button"
onClick={() => onCreateExport(latestVersion.id, format)} onClick={() => onCreateExport(latestVersion.id, format)}
disabled={isCreatingExport} disabled={isCreatingExport}
className="rounded-2xl border border-brand-200 bg-white px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60" className="rounded-2xl border border-brand-200 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60"
> >
{isCreatingExport ? "Generating..." : `Create ${format}`} {isCreatingExport ? "Generating..." : `Create ${format}`}
</button> </button>
@@ -124,16 +124,16 @@ export function ExportsTab({
</div> </div>
</div> </div>
<div className="rounded-3xl border border-gray-200 bg-white shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-sm">
<div className="border-b border-gray-100 px-6 py-4"> <div className="border-b border-gray-100 dark:border-gray-700/50 px-6 py-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Generated exports <InfoTooltip content="Previously generated export artifacts. XLSX/CSV contain tabular data; JSON is machine-readable; SAP/MMP are ERP integration formats." /></h2> <h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">Generated exports <InfoTooltip content="Previously generated export artifacts. XLSX/CSV contain tabular data; JSON is machine-readable; SAP/MMP are ERP integration formats." /></h2>
</div> </div>
{exports.length === 0 ? ( {exports.length === 0 ? (
<div className="px-6 py-8"> <div className="px-6 py-8">
<p className="text-sm text-gray-400">No exports have been generated for the current version yet.</p> <p className="text-sm text-gray-400">No exports have been generated for the current version yet.</p>
</div> </div>
) : ( ) : (
<div className="divide-y divide-gray-100"> <div className="divide-y divide-gray-100 dark:divide-gray-700/50">
{exports.map((estimateExport) => { {exports.map((estimateExport) => {
const payload = isEstimateExportArtifactPayload(estimateExport.payload) const payload = isEstimateExportArtifactPayload(estimateExport.payload)
? estimateExport.payload ? estimateExport.payload
@@ -144,57 +144,57 @@ export function ExportsTab({
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-gray-900">{estimateExport.fileName}</p> <p className="text-sm font-medium text-gray-900 dark:text-gray-100">{estimateExport.fileName}</p>
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-gray-600"> <span className="rounded-full bg-gray-100 dark:bg-gray-800 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">
{estimateExport.format} {estimateExport.format}
</span> </span>
{payload?.sheetNames?.length ? ( {payload?.sheetNames?.length ? (
<span className="rounded-full bg-sky-50 px-2.5 py-1 text-[11px] font-semibold text-sky-700"> <span className="rounded-full bg-sky-50 dark:bg-sky-900/30 px-2.5 py-1 text-[11px] font-semibold text-sky-700 dark:text-sky-300">
{payload.sheetNames.length} sheets {payload.sheetNames.length} sheets
</span> </span>
) : null} ) : null}
</div> </div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500"> <div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
<span>{formatDateLong(estimateExport.createdAt)}</span> <span>{formatDateLong(estimateExport.createdAt)}</span>
{payload ? <span>{formatBytes(payload.byteLength)}</span> : null} {payload ? <span>{formatBytes(payload.byteLength)}</span> : null}
{payload?.rowCount != null ? <span>{payload.rowCount} rows</span> : null} {payload?.rowCount != null ? <span>{payload.rowCount} rows</span> : null}
{payload?.lineCount != null ? <span>{payload.lineCount} lines</span> : null} {payload?.lineCount != null ? <span>{payload.lineCount} lines</span> : null}
</div> </div>
{payload ? ( {payload ? (
<div className="mt-3 grid gap-2 text-xs text-gray-600 md:grid-cols-2 xl:grid-cols-4"> <div className="mt-3 grid gap-2 text-xs text-gray-600 dark:text-gray-400 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl bg-gray-50 px-3 py-2"> <div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-3 py-2">
<p className="uppercase tracking-wide text-gray-400">Hours</p> <p className="uppercase tracking-wide text-gray-400">Hours</p>
<p className="mt-1 font-semibold text-gray-900"> <p className="mt-1 font-semibold text-gray-900 dark:text-gray-100">
{payload.summary.totalHours.toFixed(1)} {payload.summary.totalHours.toFixed(1)}
</p> </p>
</div> </div>
<div className="rounded-2xl bg-gray-50 px-3 py-2"> <div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-3 py-2">
<p className="uppercase tracking-wide text-gray-400">Cost</p> <p className="uppercase tracking-wide text-gray-400">Cost</p>
<p className="mt-1 font-semibold text-gray-900"> <p className="mt-1 font-semibold text-gray-900 dark:text-gray-100">
{formatMoney( {formatMoney(
payload.summary.totalCostCents, payload.summary.totalCostCents,
payload.summary.baseCurrency, payload.summary.baseCurrency,
)} )}
</p> </p>
</div> </div>
<div className="rounded-2xl bg-gray-50 px-3 py-2"> <div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-3 py-2">
<p className="uppercase tracking-wide text-gray-400">Price</p> <p className="uppercase tracking-wide text-gray-400">Price</p>
<p className="mt-1 font-semibold text-gray-900"> <p className="mt-1 font-semibold text-gray-900 dark:text-gray-100">
{formatMoney( {formatMoney(
payload.summary.totalPriceCents, payload.summary.totalPriceCents,
payload.summary.baseCurrency, payload.summary.baseCurrency,
)} )}
</p> </p>
</div> </div>
<div className="rounded-2xl bg-gray-50 px-3 py-2"> <div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-3 py-2">
<p className="uppercase tracking-wide text-gray-400">Margin</p> <p className="uppercase tracking-wide text-gray-400">Margin</p>
<p className="mt-1 font-semibold text-gray-900"> <p className="mt-1 font-semibold text-gray-900 dark:text-gray-100">
{payload.summary.marginPercent.toFixed(0)}% {payload.summary.marginPercent.toFixed(0)}%
</p> </p>
</div> </div>
</div> </div>
) : ( ) : (
<p className="mt-3 text-xs text-amber-700"> <p className="mt-3 text-xs text-amber-700 dark:text-amber-300">
Legacy export record detected. Regenerate it to get downloadable serializer output. Legacy export record detected. Regenerate it to get downloadable serializer output.
</p> </p>
)} )}
@@ -204,7 +204,7 @@ export function ExportsTab({
<button <button
type="button" type="button"
onClick={() => downloadEstimateExport(estimateExport)} onClick={() => downloadEstimateExport(estimateExport)}
className="rounded-2xl border border-brand-200 bg-white px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50" className="rounded-2xl border border-brand-200 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50"
> >
Download Download
</button> </button>
@@ -11,7 +11,7 @@ import { formatMoney } from "~/lib/format.js";
function EmptyState({ children }: { children: React.ReactNode }) { function EmptyState({ children }: { children: React.ReactNode }) {
return ( return (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400"> <div className="rounded-3xl border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-5 py-10 text-center text-sm text-gray-400">
{children} {children}
</div> </div>
); );
@@ -77,33 +77,33 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
<div className="space-y-6"> <div className="space-y-6">
{/* Summary cards */} {/* Summary cards */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost <InfoTooltip content="Sum of (hours x cost rate) for all demand lines. Avg shows weighted average cost per hour." /></p> <p className="text-xs uppercase tracking-wide text-gray-400">Total Cost <InfoTooltip content="Sum of (hours x cost rate) for all demand lines. Avg shows weighted average cost per hour." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(totals.costCents, estimate.baseCurrency)}</p> <p className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-100">{formatMoney(totals.costCents, estimate.baseCurrency)}</p>
<p className="mt-1 text-xs text-gray-500">Avg {formatMoney(Math.round(avgCostRate), estimate.baseCurrency)}/h</p> <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Avg {formatMoney(Math.round(avgCostRate), estimate.baseCurrency)}/h</p>
</div> </div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price <InfoTooltip content="Sum of (hours x sell rate) for all demand lines. This is the total client-facing revenue." /></p> <p className="text-xs uppercase tracking-wide text-gray-400">Total Price <InfoTooltip content="Sum of (hours x sell rate) for all demand lines. This is the total client-facing revenue." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(totals.priceCents, estimate.baseCurrency)}</p> <p className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-100">{formatMoney(totals.priceCents, estimate.baseCurrency)}</p>
<p className="mt-1 text-xs text-gray-500">Avg {formatMoney(Math.round(avgBillRate), estimate.baseCurrency)}/h</p> <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">Avg {formatMoney(Math.round(avgBillRate), estimate.baseCurrency)}/h</p>
</div> </div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">Margin <InfoTooltip content="Margin = Total Price - Total Cost. Margin % = Margin / Total Price x 100. Green = positive, red = negative." /></p> <p className="text-xs uppercase tracking-wide text-gray-400">Margin <InfoTooltip content="Margin = Total Price - Total Cost. Margin % = Margin / Total Price x 100. Green = positive, red = negative." /></p>
<p className={clsx("mt-2 text-2xl font-semibold", marginCents >= 0 ? "text-emerald-700" : "text-red-700")}> <p className={clsx("mt-2 text-2xl font-semibold", marginCents >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
{formatMoney(marginCents, estimate.baseCurrency)} {formatMoney(marginCents, estimate.baseCurrency)}
</p> </p>
<p className="mt-1 text-xs text-gray-500">{marginPercent.toFixed(1)}% of price</p> <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{marginPercent.toFixed(1)}% of price</p>
</div> </div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours <InfoTooltip content="Sum of all demand line hours. Each demand line contributes its hours to this total." /></p> <p className="text-xs uppercase tracking-wide text-gray-400">Total Hours <InfoTooltip content="Sum of all demand line hours. Each demand line contributes its hours to this total." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{totals.hours.toFixed(1)} h</p> <p className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-100">{totals.hours.toFixed(1)} h</p>
<p className="mt-1 text-xs text-gray-500">{demandLines.length} demand lines</p> <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{demandLines.length} demand lines</p>
</div> </div>
</div> </div>
{/* Margin waterfall: Cost -> Margin -> Price */} {/* Margin waterfall: Cost -> Margin -> Price */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<h3 className="mb-4 text-base font-semibold text-gray-900">Cost to price bridge <InfoTooltip content="Visual waterfall: internal cost + margin = client price. Bar heights are proportional." /></h3> <h3 className="mb-4 text-base font-semibold text-gray-900 dark:text-gray-100">Cost to price bridge <InfoTooltip content="Visual waterfall: internal cost + margin = client price. Bar heights are proportional." /></h3>
<div className="flex items-end gap-1 h-32"> <div className="flex items-end gap-1 h-32">
{(() => { {(() => {
const maxVal = Math.max(totals.costCents, totals.priceCents, 1); const maxVal = Math.max(totals.costCents, totals.priceCents, 1);
@@ -113,22 +113,22 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
return ( return (
<> <>
<div className="flex-1 flex flex-col items-center gap-1"> <div className="flex-1 flex flex-col items-center gap-1">
<div className="w-full rounded-t-xl bg-gray-300" style={{ height: `${costH}%` }} /> <div className="w-full rounded-t-xl bg-gray-300 dark:bg-gray-600" style={{ height: `${costH}%` }} />
<span className="text-xs font-medium text-gray-600">Cost</span> <span className="text-xs font-medium text-gray-600 dark:text-gray-400">Cost</span>
<span className="text-xs text-gray-500">{formatMoney(totals.costCents, estimate.baseCurrency)}</span> <span className="text-xs text-gray-500 dark:text-gray-400">{formatMoney(totals.costCents, estimate.baseCurrency)}</span>
</div> </div>
<div className="flex-1 flex flex-col items-center gap-1"> <div className="flex-1 flex flex-col items-center gap-1">
<div <div
className={clsx("w-full rounded-t-xl", marginCents >= 0 ? "bg-emerald-400" : "bg-red-400")} className={clsx("w-full rounded-t-xl", marginCents >= 0 ? "bg-emerald-400" : "bg-red-400")}
style={{ height: `${marginH}%` }} style={{ height: `${marginH}%` }}
/> />
<span className="text-xs font-medium text-gray-600">Margin</span> <span className="text-xs font-medium text-gray-600 dark:text-gray-400">Margin</span>
<span className="text-xs text-gray-500">{formatMoney(marginCents, estimate.baseCurrency)}</span> <span className="text-xs text-gray-500 dark:text-gray-400">{formatMoney(marginCents, estimate.baseCurrency)}</span>
</div> </div>
<div className="flex-1 flex flex-col items-center gap-1"> <div className="flex-1 flex flex-col items-center gap-1">
<div className="w-full rounded-t-xl bg-brand-500" style={{ height: `${priceH}%` }} /> <div className="w-full rounded-t-xl bg-brand-500" style={{ height: `${priceH}%` }} />
<span className="text-xs font-medium text-gray-600">Price</span> <span className="text-xs font-medium text-gray-600 dark:text-gray-400">Price</span>
<span className="text-xs text-gray-500">{formatMoney(totals.priceCents, estimate.baseCurrency)}</span> <span className="text-xs text-gray-500 dark:text-gray-400">{formatMoney(totals.priceCents, estimate.baseCurrency)}</span>
</div> </div>
</> </>
); );
@@ -137,12 +137,12 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
</div> </div>
{/* Chapter breakdown */} {/* Chapter breakdown */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Breakdown by chapter <InfoTooltip content="Financial aggregation by department/chapter. Chapter margin % = (price - cost) / price x 100." /></h3> <h3 className="mb-3 text-base font-semibold text-gray-900 dark:text-gray-100">Breakdown by chapter <InfoTooltip content="Financial aggregation by department/chapter. Chapter margin % = (price - cost) / price x 100." /></h3>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead> <thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500"> <tr className="border-b border-gray-200 dark:border-gray-700 text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th className="py-2 pr-3 font-medium">Chapter</th> <th className="py-2 pr-3 font-medium">Chapter</th>
<th className="px-3 py-2 text-right font-medium">Lines</th> <th className="px-3 py-2 text-right font-medium">Lines</th>
<th className="px-3 py-2 text-right font-medium">Hours</th> <th className="px-3 py-2 text-right font-medium">Hours</th>
@@ -157,31 +157,31 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
const chapterMargin = data.priceCents - data.costCents; const chapterMargin = data.priceCents - data.costCents;
const chapterMarginPct = data.priceCents > 0 ? (chapterMargin / data.priceCents) * 100 : 0; const chapterMarginPct = data.priceCents > 0 ? (chapterMargin / data.priceCents) * 100 : 0;
return ( return (
<tr key={chapter} className="border-b border-gray-100"> <tr key={chapter} className="border-b border-gray-100 dark:border-gray-700/50">
<td className="py-2 pr-3 font-medium text-gray-900">{chapter}</td> <td className="py-2 pr-3 font-medium text-gray-900 dark:text-gray-100">{chapter}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-600">{data.count}</td> <td className="px-3 py-2 text-right tabular-nums text-gray-600 dark:text-gray-400">{data.count}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{data.hours.toFixed(1)}</td> <td className="px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-300">{data.hours.toFixed(1)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.costCents, estimate.baseCurrency)}</td> <td className="px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-300">{formatMoney(data.costCents, estimate.baseCurrency)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.priceCents, estimate.baseCurrency)}</td> <td className="px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-300">{formatMoney(data.priceCents, estimate.baseCurrency)}</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", chapterMargin >= 0 ? "text-emerald-700" : "text-red-700")}> <td className={clsx("px-3 py-2 text-right tabular-nums", chapterMargin >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
{formatMoney(chapterMargin, estimate.baseCurrency)} {formatMoney(chapterMargin, estimate.baseCurrency)}
</td> </td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", chapterMarginPct >= 0 ? "text-emerald-700" : "text-red-700")}> <td className={clsx("pl-3 py-2 text-right tabular-nums", chapterMarginPct >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
{chapterMarginPct.toFixed(1)}% {chapterMarginPct.toFixed(1)}%
</td> </td>
</tr> </tr>
); );
})} })}
<tr className="border-t-2 border-gray-300 font-semibold"> <tr className="border-t-2 border-gray-300 dark:border-gray-600 font-semibold">
<td className="py-2 pr-3 text-gray-900">Total</td> <td className="py-2 pr-3 text-gray-900 dark:text-gray-100">Total</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{demandLines.length}</td> <td className="px-3 py-2 text-right tabular-nums text-gray-900 dark:text-gray-100">{demandLines.length}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{totals.hours.toFixed(1)}</td> <td className="px-3 py-2 text-right tabular-nums text-gray-900 dark:text-gray-100">{totals.hours.toFixed(1)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{formatMoney(totals.costCents, estimate.baseCurrency)}</td> <td className="px-3 py-2 text-right tabular-nums text-gray-900 dark:text-gray-100">{formatMoney(totals.costCents, estimate.baseCurrency)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{formatMoney(totals.priceCents, estimate.baseCurrency)}</td> <td className="px-3 py-2 text-right tabular-nums text-gray-900 dark:text-gray-100">{formatMoney(totals.priceCents, estimate.baseCurrency)}</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", marginCents >= 0 ? "text-emerald-700" : "text-red-700")}> <td className={clsx("px-3 py-2 text-right tabular-nums", marginCents >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
{formatMoney(marginCents, estimate.baseCurrency)} {formatMoney(marginCents, estimate.baseCurrency)}
</td> </td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", marginPercent >= 0 ? "text-emerald-700" : "text-red-700")}> <td className={clsx("pl-3 py-2 text-right tabular-nums", marginPercent >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
{marginPercent.toFixed(1)}% {marginPercent.toFixed(1)}%
</td> </td>
</tr> </tr>
@@ -192,12 +192,12 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
{/* Monthly cost/price phasing */} {/* Monthly cost/price phasing */}
{sortedMonths.length > 0 && ( {sortedMonths.length > 0 && (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Monthly financial phasing <InfoTooltip content="Monthly cost and price derived from each line's hourly spread. Cost = monthly hours x line cost rate. Price = monthly hours x line sell rate." /></h3> <h3 className="mb-3 text-base font-semibold text-gray-900 dark:text-gray-100">Monthly financial phasing <InfoTooltip content="Monthly cost and price derived from each line's hourly spread. Cost = monthly hours x line cost rate. Price = monthly hours x line sell rate." /></h3>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead> <thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500"> <tr className="border-b border-gray-200 dark:border-gray-700 text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th className="py-2 pr-3 font-medium">Month</th> <th className="py-2 pr-3 font-medium">Month</th>
<th className="px-3 py-2 text-right font-medium">Hours</th> <th className="px-3 py-2 text-right font-medium">Hours</th>
<th className="px-3 py-2 text-right font-medium">Cost</th> <th className="px-3 py-2 text-right font-medium">Cost</th>
@@ -210,12 +210,12 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
const data = monthlyFinancials.get(month)!; const data = monthlyFinancials.get(month)!;
const mMargin = data.priceCents - data.costCents; const mMargin = data.priceCents - data.costCents;
return ( return (
<tr key={month} className="border-b border-gray-100"> <tr key={month} className="border-b border-gray-100 dark:border-gray-700/50">
<td className="py-2 pr-3 font-medium text-gray-900">{month}</td> <td className="py-2 pr-3 font-medium text-gray-900 dark:text-gray-100">{month}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{data.hours.toFixed(1)}</td> <td className="px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-300">{data.hours.toFixed(1)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.costCents, estimate.baseCurrency)}</td> <td className="px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-300">{formatMoney(data.costCents, estimate.baseCurrency)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.priceCents, estimate.baseCurrency)}</td> <td className="px-3 py-2 text-right tabular-nums text-gray-700 dark:text-gray-300">{formatMoney(data.priceCents, estimate.baseCurrency)}</td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", mMargin >= 0 ? "text-emerald-700" : "text-red-700")}> <td className={clsx("pl-3 py-2 text-right tabular-nums", mMargin >= 0 ? "text-emerald-700 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
{formatMoney(mMargin, estimate.baseCurrency)} {formatMoney(mMargin, estimate.baseCurrency)}
</td> </td>
</tr> </tr>
@@ -11,18 +11,18 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatDateLong, formatMoney } from "~/lib/format.js"; import { formatDateLong, formatMoney } from "~/lib/format.js";
const STATUS_STYLES: Record<EstimateStatus, string> = { const STATUS_STYLES: Record<EstimateStatus, string> = {
DRAFT: "bg-slate-100 text-slate-700", DRAFT: "bg-slate-100 text-slate-700 dark:bg-slate-900/30 dark:text-slate-300",
IN_REVIEW: "bg-amber-100 text-amber-700", IN_REVIEW: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
APPROVED: "bg-emerald-100 text-emerald-700", APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
ARCHIVED: "bg-zinc-200 text-zinc-700", ARCHIVED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
}; };
const VERSION_STYLES: Record<EstimateVersionStatus, string> = { const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
WORKING: "bg-sky-100 text-sky-700", WORKING: "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300",
BASELINE: "bg-violet-100 text-violet-700", BASELINE: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
SUBMITTED: "bg-amber-100 text-amber-700", SUBMITTED: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
APPROVED: "bg-emerald-100 text-emerald-700", APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
SUPERSEDED: "bg-zinc-200 text-zinc-700", SUPERSEDED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
}; };
function formatMetricValue(metric: EstimateMetricView) { function formatMetricValue(metric: EstimateMetricView) {
@@ -43,13 +43,13 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
return ( return (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),340px]"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),340px]">
<section className="space-y-6"> <section className="space-y-6">
<div className="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-6 shadow-sm">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className={clsx("rounded-full px-3 py-1 text-xs font-semibold", STATUS_STYLES[estimate.status])}> <span className={clsx("rounded-full px-3 py-1 text-xs font-semibold", STATUS_STYLES[estimate.status])}>
{estimate.status.replace("_", " ")} {estimate.status.replace("_", " ")}
</span> </span>
{estimate.project && ( {estimate.project && (
<span className="rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-600"> <span className="rounded-full bg-gray-100 dark:bg-gray-800 px-3 py-1 text-xs font-medium text-gray-600 dark:text-gray-400">
{estimate.project.shortCode} {estimate.project.shortCode}
</span> </span>
)} )}
@@ -58,43 +58,43 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
<div className="mt-5 grid gap-4 md:grid-cols-2"> <div className="mt-5 grid gap-4 md:grid-cols-2">
<div> <div>
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity <InfoTooltip content="External CRM or sales reference linking this estimate to a sales opportunity." /></p> <p className="text-xs uppercase tracking-wide text-gray-400">Opportunity <InfoTooltip content="External CRM or sales reference linking this estimate to a sales opportunity." /></p>
<p className="mt-1 text-sm text-gray-800">{estimate.opportunityId ?? "Not set"}</p> <p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{estimate.opportunityId ?? "Not set"}</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-gray-400">Base currency <InfoTooltip content="The primary currency for all monetary calculations in this estimate." /></p> <p className="text-xs uppercase tracking-wide text-gray-400">Base currency <InfoTooltip content="The primary currency for all monetary calculations in this estimate." /></p>
<p className="mt-1 text-sm text-gray-800">{estimate.baseCurrency}</p> <p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{estimate.baseCurrency}</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-gray-400">Latest version <InfoTooltip content="The most recent version snapshot. Each version captures a full copy of scope, demand, and financials." /></p> <p className="text-xs uppercase tracking-wide text-gray-400">Latest version <InfoTooltip content="The most recent version snapshot. Each version captures a full copy of scope, demand, and financials." /></p>
<p className="mt-1 text-sm text-gray-800"> <p className="mt-1 text-sm text-gray-800 dark:text-gray-200">
{latestVersion ? `v${latestVersion.versionNumber}${latestVersion.label ? ` - ${latestVersion.label}` : ""}` : "No version"} {latestVersion ? `v${latestVersion.versionNumber}${latestVersion.label ? ` - ${latestVersion.label}` : ""}` : "No version"}
</p> </p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-gray-400">Updated</p> <p className="text-xs uppercase tracking-wide text-gray-400">Updated</p>
<p className="mt-1 text-sm text-gray-800">{formatDateLong(estimate.updatedAt)}</p> <p className="mt-1 text-sm text-gray-800 dark:text-gray-200">{formatDateLong(estimate.updatedAt)}</p>
</div> </div>
</div> </div>
{latestVersion?.notes && ( {latestVersion?.notes && (
<div className="mt-5 rounded-2xl border border-gray-100 bg-gray-50 p-4"> <div className="mt-5 rounded-2xl border border-gray-100 dark:border-gray-700/50 bg-gray-50 dark:bg-gray-900 p-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Version notes</p> <p className="text-xs uppercase tracking-wide text-gray-400">Version notes</p>
<p className="mt-2 text-sm text-gray-700">{latestVersion.notes}</p> <p className="mt-2 text-sm text-gray-700 dark:text-gray-300">{latestVersion.notes}</p>
</div> </div>
)} )}
</div> </div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Scope items <InfoTooltip content="Deliverables or work packages included in this estimate version." /></p> <p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Scope items <InfoTooltip content="Deliverables or work packages included in this estimate version." /></p>
<span className="text-xs text-gray-400">{latestVersion?.scopeItems.length ?? 0}</span> <span className="text-xs text-gray-400">{latestVersion?.scopeItems.length ?? 0}</span>
</div> </div>
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
{(latestVersion?.scopeItems ?? []).slice(0, 4).map((item) => ( {(latestVersion?.scopeItems ?? []).slice(0, 4).map((item) => (
<div key={item.id} className="rounded-2xl border border-gray-100 px-4 py-3"> <div key={item.id} className="rounded-2xl border border-gray-100 dark:border-gray-700/50 px-4 py-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium text-gray-900">{item.name}</p> <p className="text-sm font-medium text-gray-900 dark:text-gray-100">{item.name}</p>
<span className="text-xs text-gray-400">{item.scopeType}</span> <span className="text-xs text-gray-400">{item.scopeType}</span>
</div> </div>
</div> </div>
@@ -103,17 +103,17 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
</div> </div>
</div> </div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Demand lines <InfoTooltip content="Staffing demand rows with hours, cost rate, and sell rate per role or resource." /></p> <p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Demand lines <InfoTooltip content="Staffing demand rows with hours, cost rate, and sell rate per role or resource." /></p>
<span className="text-xs text-gray-400">{latestVersion?.demandLines.length ?? 0}</span> <span className="text-xs text-gray-400">{latestVersion?.demandLines.length ?? 0}</span>
</div> </div>
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
{(latestVersion?.demandLines ?? []).slice(0, 4).map((line) => ( {(latestVersion?.demandLines ?? []).slice(0, 4).map((line) => (
<div key={line.id} className="rounded-2xl border border-gray-100 px-4 py-3"> <div key={line.id} className="rounded-2xl border border-gray-100 dark:border-gray-700/50 px-4 py-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium text-gray-900">{line.name}</p> <p className="text-sm font-medium text-gray-900 dark:text-gray-100">{line.name}</p>
<span className="text-xs text-gray-500">{line.hours.toFixed(1)} h</span> <span className="text-xs text-gray-500 dark:text-gray-400">{line.hours.toFixed(1)} h</span>
</div> </div>
</div> </div>
))} ))}
@@ -124,44 +124,44 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
</section> </section>
<aside className="space-y-4"> <aside className="space-y-4">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<p className="text-sm font-semibold text-gray-900">Summary metrics <InfoTooltip content="Key financial indicators derived from the latest version's demand lines." /></p> <p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Summary metrics <InfoTooltip content="Key financial indicators derived from the latest version's demand lines." /></p>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
{latestMetrics.length === 0 ? ( {latestMetrics.length === 0 ? (
<p className="text-sm text-gray-400">No derived metrics available yet.</p> <p className="text-sm text-gray-400">No derived metrics available yet.</p>
) : ( ) : (
latestMetrics.map((metric) => ( latestMetrics.map((metric) => (
<div key={metric.id} className="flex items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3"> <div key={metric.id} className="flex items-center justify-between gap-3 rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<span className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</span> <span className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</span>
<span className="text-sm font-semibold text-gray-900">{formatMetricValue(metric)}</span> <span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{formatMetricValue(metric)}</span>
</div> </div>
)) ))
)} )}
</div> </div>
</div> </div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<p className="text-sm font-semibold text-gray-900">Version context <InfoTooltip content="Metadata about the latest version, including its workflow status and linked records." /></p> <p className="text-sm font-semibold text-gray-900 dark:text-gray-100">Version context <InfoTooltip content="Metadata about the latest version, including its workflow status and linked records." /></p>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
{latestVersion ? ( {latestVersion ? (
<> <>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Status</span> <span className="text-sm text-gray-500 dark:text-gray-400">Status</span>
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[latestVersion.status])}> <span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[latestVersion.status])}>
{latestVersion.status} {latestVersion.status}
</span> </span>
</div> </div>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Assumptions</span> <span className="text-sm text-gray-500 dark:text-gray-400">Assumptions</span>
<span className="text-sm font-medium text-gray-900">{latestVersion.assumptions.length}</span> <span className="text-sm font-medium text-gray-900 dark:text-gray-100">{latestVersion.assumptions.length}</span>
</div> </div>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Snapshots</span> <span className="text-sm text-gray-500 dark:text-gray-400">Snapshots</span>
<span className="text-sm font-medium text-gray-900">{latestVersion.resourceSnapshots.length}</span> <span className="text-sm font-medium text-gray-900 dark:text-gray-100">{latestVersion.resourceSnapshots.length}</span>
</div> </div>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Exports</span> <span className="text-sm text-gray-500 dark:text-gray-400">Exports</span>
<span className="text-sm font-medium text-gray-900">{latestVersion.exports.length}</span> <span className="text-sm font-medium text-gray-900 dark:text-gray-100">{latestVersion.exports.length}</span>
</div> </div>
</> </>
) : ( ) : (
@@ -8,7 +8,7 @@ import type {
function EmptyState({ children }: { children: React.ReactNode }) { function EmptyState({ children }: { children: React.ReactNode }) {
return ( return (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400"> <div className="rounded-3xl border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-5 py-10 text-center text-sm text-gray-400">
{children} {children}
</div> </div>
); );
@@ -25,24 +25,24 @@ export function ScopeTab({ estimate }: { estimate: EstimateWorkspaceView }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-gray-500"> <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<span>Scope items define the deliverables and work packages in this estimate.</span> <span>Scope items define the deliverables and work packages in this estimate.</span>
<InfoTooltip content="Each scope item represents a distinct deliverable (e.g. a shot, sequence, or asset). Scope items organize the estimate but do not directly affect cost calculations." /> <InfoTooltip content="Each scope item represents a distinct deliverable (e.g. a shot, sequence, or asset). Scope items organize the estimate but do not directly affect cost calculations." />
</div> </div>
{scopeItems.map((item) => ( {scopeItems.map((item) => (
<div key={item.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm"> <div key={item.id} className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<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">
#{item.sequenceNo} #{item.sequenceNo}
</span> </span>
<span className="rounded-full bg-brand-50 px-2.5 py-1 text-xs font-medium text-brand-700"> <span className="rounded-full bg-brand-50 px-2.5 py-1 text-xs font-medium text-brand-700">
{item.scopeType} {item.scopeType}
</span> </span>
</div> </div>
<h3 className="mt-3 text-lg font-semibold text-gray-900">{item.name}</h3> <h3 className="mt-3 text-lg font-semibold text-gray-900 dark:text-gray-100">{item.name}</h3>
{item.description && <p className="mt-2 text-sm text-gray-600">{item.description}</p>} {item.description && <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{item.description}</p>}
</div> </div>
<div className="grid gap-2 text-right text-xs text-gray-400"> <div className="grid gap-2 text-right text-xs text-gray-400">
{item.frameCount != null && <span>{item.frameCount} frames</span>} {item.frameCount != null && <span>{item.frameCount} frames</span>}
@@ -18,7 +18,7 @@ import { formatMoney } from "~/lib/format.js";
function EmptyState({ children }: { children: React.ReactNode }) { function EmptyState({ children }: { children: React.ReactNode }) {
return ( return (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400"> <div className="rounded-3xl border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-5 py-10 text-center text-sm text-gray-400">
{children} {children}
</div> </div>
); );
@@ -71,11 +71,11 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
}); });
return ( return (
<div key={line.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm"> <div key={line.id} className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<h3 className="text-lg font-semibold text-gray-900">{line.name}</h3> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{line.name}</h3>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500"> <div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
<span>{line.lineType}</span> <span>{line.lineType}</span>
{line.chapter && <span>{line.chapter}</span>} {line.chapter && <span>{line.chapter}</span>}
{line.rateSource && <span>{line.rateSource}</span>} {line.rateSource && <span>{line.rateSource}</span>}
@@ -84,8 +84,8 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
className={clsx( className={clsx(
"rounded-full px-2.5 py-1 font-medium", "rounded-full px-2.5 py-1 font-medium",
calculation.costRateMode === "resource" calculation.costRateMode === "resource"
? "bg-emerald-50 text-emerald-700" ? "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
: "bg-amber-50 text-amber-700", : "bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
)} )}
> >
Cost {calculation.costRateMode === "resource" ? "live" : "manual"} Cost {calculation.costRateMode === "resource" ? "live" : "manual"}
@@ -94,8 +94,8 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
className={clsx( className={clsx(
"rounded-full px-2.5 py-1 font-medium", "rounded-full px-2.5 py-1 font-medium",
calculation.billRateMode === "resource" calculation.billRateMode === "resource"
? "bg-emerald-50 text-emerald-700" ? "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
: "bg-amber-50 text-amber-700", : "bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
)} )}
> >
Sell {calculation.billRateMode === "resource" ? "live" : "manual"} Sell {calculation.billRateMode === "resource" ? "live" : "manual"}
@@ -103,37 +103,37 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-sm font-semibold text-gray-900">{line.hours.toFixed(1)} h</p> <p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{line.hours.toFixed(1)} h</p>
<p className="mt-1 text-xs text-gray-500">{effectiveValues.currency}</p> <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{effectiveValues.currency}</p>
</div> </div>
</div> </div>
<div className="mt-5 grid gap-3 md:grid-cols-4"> <div className="mt-5 grid gap-3 md:grid-cols-4">
<div className="rounded-2xl bg-gray-50 px-4 py-3"> <div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Cost rate <InfoTooltip content="Internal hourly cost rate. Can be synced from the live resource or manually overridden." /></p> <p className="text-xs uppercase tracking-wide text-gray-400">Cost rate <InfoTooltip content="Internal hourly cost rate. Can be synced from the live resource or manually overridden." /></p>
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(line.costRateCents, line.currency)}</p> <p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(line.costRateCents, line.currency)}</p>
{linkedSnapshot && calculation.costRateMode === "manual" && ( {linkedSnapshot && calculation.costRateMode === "manual" && (
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Live snapshot {formatMoney(linkedSnapshot.lcrCents, linkedSnapshot.currency)} Live snapshot {formatMoney(linkedSnapshot.lcrCents, linkedSnapshot.currency)}
</p> </p>
)} )}
</div> </div>
<div className="rounded-2xl bg-gray-50 px-4 py-3"> <div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Sell rate <InfoTooltip content="Client-facing hourly rate. Can be synced from the live resource or manually overridden." /></p> <p className="text-xs uppercase tracking-wide text-gray-400">Sell rate <InfoTooltip content="Client-facing hourly rate. Can be synced from the live resource or manually overridden." /></p>
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(line.billRateCents, line.currency)}</p> <p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(line.billRateCents, line.currency)}</p>
{linkedSnapshot && calculation.billRateMode === "manual" && ( {linkedSnapshot && calculation.billRateMode === "manual" && (
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Live snapshot {formatMoney(linkedSnapshot.ucrCents, linkedSnapshot.currency)} Live snapshot {formatMoney(linkedSnapshot.ucrCents, linkedSnapshot.currency)}
</p> </p>
)} )}
</div> </div>
<div className="rounded-2xl bg-gray-50 px-4 py-3"> <div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Cost total <InfoTooltip content="Line cost total = hours x cost rate. Stored in cents." /></p> <p className="text-xs uppercase tracking-wide text-gray-400">Cost total <InfoTooltip content="Line cost total = hours x cost rate. Stored in cents." /></p>
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}</p> <p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}</p>
</div> </div>
<div className="rounded-2xl bg-gray-50 px-4 py-3"> <div className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Price total <InfoTooltip content="Line price total = hours x sell rate. This is the client-facing revenue for this line." /></p> <p className="text-xs uppercase tracking-wide text-gray-400">Price total <InfoTooltip content="Line price total = hours x sell rate. This is the client-facing revenue for this line." /></p>
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}</p> <p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}</p>
</div> </div>
</div> </div>
@@ -144,9 +144,9 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
{Object.entries(line.monthlySpread) {Object.entries(line.monthlySpread)
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
.map(([month, hours]) => ( .map(([month, hours]) => (
<div key={month} className="rounded-xl bg-gray-50 px-3 py-1.5 text-xs"> <div key={month} className="rounded-xl bg-gray-50 dark:bg-gray-900 px-3 py-1.5 text-xs">
<span className="text-gray-500">{month}</span> <span className="text-gray-500 dark:text-gray-400">{month}</span>
<span className="ml-1.5 font-medium text-gray-900">{hours.toFixed(1)} h</span> <span className="ml-1.5 font-medium text-gray-900 dark:text-gray-100">{hours.toFixed(1)} h</span>
</div> </div>
))} ))}
</div> </div>
@@ -165,17 +165,17 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
const months = Object.keys(aggregated).sort(); const months = Object.keys(aggregated).sort();
if (months.length === 0) return null; if (months.length === 0) return null;
return ( return (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm"> <div className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<p className="mb-3 text-sm font-semibold text-gray-900">Aggregated monthly phasing <InfoTooltip content="Sum of hours across all demand lines per month, based on the project date range." /></p> <p className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">Aggregated monthly phasing <InfoTooltip content="Sum of hours across all demand lines per month, based on the project date range." /></p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{months.map((month) => ( {months.map((month) => (
<div key={month} className="rounded-xl bg-gray-50 px-3 py-2 text-sm"> <div key={month} className="rounded-xl bg-gray-50 dark:bg-gray-900 px-3 py-2 text-sm">
<span className="text-gray-500">{month}</span> <span className="text-gray-500 dark:text-gray-400">{month}</span>
<span className="ml-2 font-semibold text-gray-900">{(aggregated[month] ?? 0).toFixed(1)} h</span> <span className="ml-2 font-semibold text-gray-900 dark:text-gray-100">{(aggregated[month] ?? 0).toFixed(1)} h</span>
</div> </div>
))} ))}
</div> </div>
<div className="mt-3 text-right text-sm font-semibold text-gray-700"> <div className="mt-3 text-right text-sm font-semibold text-gray-700 dark:text-gray-300">
Total: {Object.values(aggregated).reduce((a, b) => a + b, 0).toFixed(1)} h Total: {Object.values(aggregated).reduce((a, b) => a + b, 0).toFixed(1)} h
</div> </div>
</div> </div>
@@ -12,11 +12,11 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatDateLong, formatMoney } from "~/lib/format.js"; import { formatDateLong, formatMoney } from "~/lib/format.js";
const VERSION_STYLES: Record<EstimateVersionStatus, string> = { const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
WORKING: "bg-sky-100 text-sky-700", WORKING: "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300",
BASELINE: "bg-violet-100 text-violet-700", BASELINE: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
SUBMITTED: "bg-amber-100 text-amber-700", SUBMITTED: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
APPROVED: "bg-emerald-100 text-emerald-700", APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
SUPERSEDED: "bg-zinc-200 text-zinc-700", SUPERSEDED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
}; };
function formatMetricValue(metric: EstimateMetricView) { function formatMetricValue(metric: EstimateMetricView) {
@@ -31,7 +31,7 @@ function formatMetricValue(metric: EstimateMetricView) {
function EmptyState({ children }: { children: React.ReactNode }) { function EmptyState({ children }: { children: React.ReactNode }) {
return ( return (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400"> <div className="rounded-3xl border border-dashed border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-5 py-10 text-center text-sm text-gray-400">
{children} {children}
</div> </div>
); );
@@ -75,24 +75,24 @@ export function VersionsTab({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-gray-500"> <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<span>Versions are immutable snapshots of the estimate for comparison and audit.</span> <span>Versions are immutable snapshots of the estimate for comparison and audit.</span>
<InfoTooltip content="Each version captures a full copy of scope, assumptions, demand lines, and metrics. WORKING versions can be edited; SUBMITTED and APPROVED versions are locked." /> <InfoTooltip content="Each version captures a full copy of scope, assumptions, demand lines, and metrics. WORKING versions can be edited; SUBMITTED and APPROVED versions are locked." />
</div> </div>
{versions.map((version) => ( {versions.map((version) => (
<div key={version.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm"> <div key={version.id} className="rounded-3xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-lg font-semibold text-gray-900">v{version.versionNumber}</span> <span className="text-lg font-semibold text-gray-900 dark:text-gray-100">v{version.versionNumber}</span>
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[version.status])}> <span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[version.status])}>
{version.status} {version.status}
</span> </span>
</div> </div>
<p className="mt-2 text-sm text-gray-600">{version.label ?? "Unlabeled version"}</p> <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{version.label ?? "Unlabeled version"}</p>
{version.notes && <p className="mt-2 text-sm text-gray-500">{version.notes}</p>} {version.notes && <p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{version.notes}</p>}
</div> </div>
<div className="text-right text-sm text-gray-500"> <div className="text-right text-sm text-gray-500 dark:text-gray-400">
<p>Updated {formatDateLong(version.updatedAt)}</p> <p>Updated {formatDateLong(version.updatedAt)}</p>
{version.lockedAt && ( {version.lockedAt && (
<p className="mt-1">Locked {formatDateLong(version.lockedAt)}</p> <p className="mt-1">Locked {formatDateLong(version.lockedAt)}</p>
@@ -130,7 +130,7 @@ export function VersionsTab({
type="button" type="button"
onClick={() => onCreateRevision(version.id)} onClick={() => onCreateRevision(version.id)}
disabled={isSubmitting || isApproving || isCreatingRevision || isCreatingPlanningHandoff} disabled={isSubmitting || isApproving || isCreatingRevision || isCreatingPlanningHandoff}
className="rounded-2xl border border-brand-200 bg-white px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60" className="rounded-2xl border border-brand-200 bg-white dark:bg-gray-800 px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60"
> >
{isCreatingRevision ? "Creating revision..." : "Create working revision"} {isCreatingRevision ? "Creating revision..." : "Create working revision"}
</button> </button>
@@ -160,7 +160,7 @@ export function VersionsTab({
)} )}
{version.status === EstimateVersionStatus.APPROVED && !hasLinkedProject && ( {version.status === EstimateVersionStatus.APPROVED && !hasLinkedProject && (
<p className="mt-3 text-sm text-amber-700"> <p className="mt-3 text-sm text-amber-700 dark:text-amber-300">
Link this estimate to a project before handing approved demand into planning. Link this estimate to a project before handing approved demand into planning.
</p> </p>
)} )}
@@ -168,9 +168,9 @@ export function VersionsTab({
{version.metrics.length > 0 && ( {version.metrics.length > 0 && (
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-5"> <div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
{version.metrics.map((metric) => ( {version.metrics.map((metric) => (
<div key={metric.id} className="rounded-2xl bg-gray-50 px-4 py-3"> <div key={metric.id} className="rounded-2xl bg-gray-50 dark:bg-gray-900 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</p> <p className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</p>
<p className="mt-1 text-sm font-semibold text-gray-900">{formatMetricValue(metric)}</p> <p className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{formatMetricValue(metric)}</p>
</div> </div>
))} ))}
</div> </div>
+16 -6
View File
@@ -6,7 +6,7 @@ import Link from "next/link";
import type { Route } from "next"; import type { Route } from "next";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { Suspense, useState } from "react"; import { Suspense, useMemo, useState } from "react";
import { PreferencesModal } from "./PreferencesModal.js"; import { PreferencesModal } from "./PreferencesModal.js";
import { ThemeProvider } from "./ThemeProvider.js"; import { ThemeProvider } from "./ThemeProvider.js";
import { NotificationBell } from "../notifications/NotificationBell.js"; import { NotificationBell } from "../notifications/NotificationBell.js";
@@ -139,6 +139,7 @@ const adminNavEntries: AdminEntry[] = [
}, },
{ href: "/admin/calculation-rules", label: "Calc. Rules", icon: <AdminIcon /> }, { href: "/admin/calculation-rules", label: "Calc. Rules", icon: <AdminIcon /> },
{ href: "/admin/users", label: "Users", icon: <AdminIcon /> }, { href: "/admin/users", label: "Users", icon: <AdminIcon /> },
{ href: "/admin/system-roles", label: "System Roles", icon: <AdminIcon /> },
{ href: "/admin/settings", label: "Settings", icon: <AdminIcon /> }, { href: "/admin/settings", label: "Settings", icon: <AdminIcon /> },
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> }, { href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
{ href: "/admin/notifications", label: "Broadcasts", icon: <BroadcastIcon /> }, { href: "/admin/notifications", label: "Broadcasts", icon: <BroadcastIcon /> },
@@ -180,6 +181,15 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
const pathname = usePathname(); const pathname = usePathname();
const [prefsOpen, setPrefsOpen] = useState(false); const [prefsOpen, setPrefsOpen] = useState(false);
// Memoize active href set — avoids O(n²) on every render
const activeHrefSet = useMemo(() => {
const set = new Set<string>();
for (const href of ALL_NAV_HREFS) {
if (isNavItemActive(pathname, href)) set.add(href);
}
return set;
}, [pathname]);
const visibleSections = navSections const visibleSections = navSections
.map((section) => ({ .map((section) => ({
...section, ...section,
@@ -194,13 +204,13 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
const initial: Record<string, boolean> = {}; const initial: Record<string, boolean> = {};
for (const section of visibleSections) { for (const section of visibleSections) {
if (section.collapsed) { if (section.collapsed) {
const hasActiveRoute = section.items.some((item) => isNavItemActive(pathname, item.href)); const hasActiveRoute = section.items.some((item) => activeHrefSet.has(item.href));
initial[section.label] = !hasActiveRoute; initial[section.label] = !hasActiveRoute;
} }
} }
for (const entry of adminNavEntries) { for (const entry of adminNavEntries) {
if (isSubGroup(entry) && entry.collapsed) { if (isSubGroup(entry) && entry.collapsed) {
const hasActiveRoute = entry.items.some((item) => isNavItemActive(pathname, item.href)); const hasActiveRoute = entry.items.some((item) => activeHrefSet.has(item.href));
initial[entry.label] = !hasActiveRoute; initial[entry.label] = !hasActiveRoute;
} }
} }
@@ -270,7 +280,7 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
href={item.href as Route} href={item.href as Route}
className={clsx( className={clsx(
"group flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium transition-all", "group flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium transition-all",
isNavItemActive(pathname, item.href) activeHrefSet.has(item.href)
? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40" ? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white", : "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)} )}
@@ -325,7 +335,7 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
href={item.href as Route} href={item.href as Route}
className={clsx( className={clsx(
"group flex items-center gap-3 rounded-2xl px-3 py-1.5 text-sm font-medium transition-all", "group flex items-center gap-3 rounded-2xl px-3 py-1.5 text-sm font-medium transition-all",
isNavItemActive(pathname, item.href) activeHrefSet.has(item.href)
? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40" ? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white", : "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)} )}
@@ -344,7 +354,7 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
href={entry.href as Route} href={entry.href as Route}
className={clsx( className={clsx(
"group flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium transition-all", "group flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium transition-all",
isNavItemActive(pathname, entry.href) activeHrefSet.has(entry.href)
? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40" ? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white", : "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)} )}
@@ -20,7 +20,7 @@ const ACCENT_OPTIONS: { value: AccentColor; label: string; swatch: string }[] =
export function PreferencesModal({ onClose }: PreferencesModalProps) { export function PreferencesModal({ onClose }: PreferencesModalProps) {
const { prefs, setMode, setAccent } = useTheme(); const { prefs, setMode, setAccent } = useTheme();
const { prefs: appPrefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme, setShowDemandProjects } = useAppPreferences(); const { prefs: appPrefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme, setShowDemandProjects, setBlinkOverbookedDays } = useAppPreferences();
return ( return (
<div <div
@@ -219,6 +219,34 @@ export function PreferencesModal({ onClose }: PreferencesModalProps) {
</div> </div>
</div> </div>
{/* Overbooked blink */}
<label className="flex items-start gap-3 cursor-pointer mb-3">
<div className="relative mt-0.5 flex-shrink-0">
<input
type="checkbox"
checked={appPrefs.blinkOverbookedDays}
onChange={(e) => setBlinkOverbookedDays(e.target.checked)}
className="sr-only peer"
/>
<div className={clsx(
"w-9 h-5 rounded-full transition-colors",
appPrefs.blinkOverbookedDays ? "bg-brand-600" : "bg-gray-200 dark:bg-gray-700",
)} />
<div className={clsx(
"absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform",
appPrefs.blinkOverbookedDays ? "translate-x-4" : "translate-x-0",
)} />
</div>
<div>
<span className="text-sm text-gray-800 dark:text-gray-200 font-medium leading-tight block">
Blink overbooked days
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
Highlight days where a resource exceeds 8h with a pulsing animation.
</span>
</div>
</label>
<label className="flex items-start gap-3 cursor-pointer"> <label className="flex items-start gap-3 cursor-pointer">
<div className="relative mt-0.5 flex-shrink-0"> <div className="relative mt-0.5 flex-shrink-0">
<input <input
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { BroadcastModal } from "./BroadcastModal.js"; import { BroadcastModal } from "./BroadcastModal.js";
import { CreateTaskModal } from "./CreateTaskModal.js";
function formatDate(date: string | Date): string { function formatDate(date: string | Date): string {
const d = typeof date === "string" ? new Date(date) : date; const d = typeof date === "string" ? new Date(date) : date;
@@ -25,6 +26,7 @@ const TARGET_LABELS: Record<string, string> = {
export function BroadcastManagementClient() { export function BroadcastManagementClient() {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [showTaskModal, setShowTaskModal] = useState(false);
const { data: broadcasts = [], isLoading } = trpc.notification.listBroadcasts.useQuery( const { data: broadcasts = [], isLoading } = trpc.notification.listBroadcasts.useQuery(
{ limit: 50 }, { limit: 50 },
@@ -42,16 +44,28 @@ export function BroadcastManagementClient() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Broadcast Management</h1> <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Broadcast Management</h1>
<button <div className="flex items-center gap-3">
type="button" <button
onClick={() => setShowModal(true)} type="button"
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 transition-colors" onClick={() => setShowTaskModal(true)}
> className="inline-flex items-center gap-1.5 rounded-lg border border-brand-600 px-4 py-2 text-sm font-medium text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /> <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</svg> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
Send Broadcast </svg>
</button> Create Task
</button>
<button
type="button"
onClick={() => setShowModal(true)}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 transition-colors"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Send Broadcast
</button>
</div>
</div> </div>
{/* Loading */} {/* Loading */}
@@ -139,6 +153,14 @@ export function BroadcastManagementClient() {
onSuccess={handleSuccess} onSuccess={handleSuccess}
/> />
)} )}
{/* Create Task Modal */}
{showTaskModal && (
<CreateTaskModal
onClose={() => setShowTaskModal(false)}
onSuccess={handleSuccess}
/>
)}
</div> </div>
); );
} }
@@ -0,0 +1,510 @@
"use client";
import { useRef, useState } from "react";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
type Mode = "single" | "group";
const TARGET_TYPES = [
{ value: "all", label: "All Users" },
{ value: "role", label: "By Role" },
{ value: "project", label: "By Project" },
{ value: "orgUnit", label: "By Org Unit" },
] as const;
const ROLES = ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] as const;
const PRIORITY_OPTIONS = [
{ value: "LOW", label: "Low" },
{ value: "NORMAL", label: "Normal" },
{ value: "HIGH", label: "High" },
{ value: "URGENT", label: "Urgent" },
] as const;
const CHANNEL_OPTIONS = [
{ value: "in_app", label: "In-App" },
{ value: "email", label: "Email" },
{ value: "both", label: "Both" },
] as const;
interface CreateTaskModalProps {
onClose: () => void;
onSuccess: () => void;
}
export function CreateTaskModal({ onClose, onSuccess }: CreateTaskModalProps) {
const [mode, setMode] = useState<Mode>("single");
const [userId, setUserId] = useState("");
const [userSearch, setUserSearch] = useState("");
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [priority, setPriority] = useState("NORMAL");
const [dueDate, setDueDate] = useState("");
const [dueTime, setDueTime] = useState("09:00");
const [channel, setChannel] = useState("in_app");
const [link, setLink] = useState("");
const [targetType, setTargetType] = useState<string>("all");
const [targetValue, setTargetValue] = useState("");
const [serverError, setServerError] = useState<string | null>(null);
const [result, setResult] = useState<{ recipientCount?: number; taskId?: string } | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const utils = trpc.useUtils();
const { data: users = [] } = trpc.user.listAssignable.useQuery(undefined, {
staleTime: 60_000,
});
const filteredUsers = userSearch.trim()
? users.filter(
(u) =>
(u.name ?? "").toLowerCase().includes(userSearch.toLowerCase()) ||
u.email.toLowerCase().includes(userSearch.toLowerCase()),
)
: users;
const createTaskMutation = trpc.notification.createTask.useMutation({
onSuccess: async (data) => {
await utils.notification.listTasks.invalidate();
await utils.notification.list.invalidate();
await utils.notification.taskCounts.invalidate();
await utils.notification.unreadCount.invalidate();
const id = (data as { id?: string }).id;
setResult(id !== undefined ? { taskId: id } : {});
},
onError: (err) => setServerError(err.message),
});
const createBroadcastMutation = trpc.notification.createBroadcast.useMutation({
onSuccess: async (data) => {
await utils.notification.listBroadcasts.invalidate();
await utils.notification.list.invalidate();
await utils.notification.taskCounts.invalidate();
await utils.notification.unreadCount.invalidate();
const count = (data as { recipientCount?: number }).recipientCount ?? 0;
setResult({ recipientCount: count });
},
onError: (err) => setServerError(err.message),
});
const isPending = createTaskMutation.isPending || createBroadcastMutation.isPending;
function buildDueDate(): Date | undefined {
if (!dueDate) return undefined;
const [hours, minutes] = dueTime.split(":").map(Number);
const d = new Date(dueDate + "T00:00:00");
d.setHours(hours ?? 9, minutes ?? 0, 0, 0);
return d;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setServerError(null);
if (!title.trim()) {
setServerError("Title is required.");
return;
}
if (mode === "single") {
if (!userId) {
setServerError("Please select a recipient.");
return;
}
const due = buildDueDate();
createTaskMutation.mutate({
userId,
title: title.trim(),
...(body.trim() ? { body: body.trim() } : {}),
priority: priority as "LOW" | "NORMAL" | "HIGH" | "URGENT",
...(due !== undefined ? { dueDate: due } : {}),
channel: channel as "in_app" | "email" | "both",
...(link.trim() ? { link: link.trim() } : {}),
});
} else {
createBroadcastMutation.mutate({
title: title.trim(),
...(body.trim() ? { body: body.trim() } : {}),
targetType: targetType as "all" | "role" | "project" | "orgUnit",
...(targetType !== "all" && targetValue.trim() ? { targetValue: targetValue.trim() } : {}),
priority: priority as "LOW" | "NORMAL" | "HIGH" | "URGENT",
channel: channel as "in_app" | "email" | "both",
...(link.trim() ? { link: link.trim() } : {}),
category: "TASK",
});
}
}
function handleCloseResult() {
onSuccess();
onClose();
}
const inputClass =
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
// After successful send, show result
if (result) {
const isGroup = mode === "group";
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => {
if (e.target === e.currentTarget) handleCloseResult();
}}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
onKeyDown={(e) => { if (e.key === "Escape") handleCloseResult(); }}
>
<div className="px-6 py-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<svg className="h-6 w-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Task Created</h3>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
{isGroup
? `Task sent to ${result.recipientCount ?? 0} recipient${(result.recipientCount ?? 0) !== 1 ? "s" : ""}`
: "Task has been assigned successfully"}
</p>
<button
type="button"
onClick={handleCloseResult}
className="mt-6 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
>
Close
</button>
</div>
</div>
</div>
);
}
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Create Task</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors text-xl leading-none"
aria-label="Close"
>
&times;
</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
{/* Mode toggle */}
<div className="flex rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<button
type="button"
onClick={() => setMode("single")}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
mode === "single"
? "bg-brand-600 text-white"
: "bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
Single User
</button>
<button
type="button"
onClick={() => setMode("group")}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
mode === "group"
? "bg-brand-600 text-white"
: "bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
Group
</button>
</div>
{/* Recipient (single mode) */}
{mode === "single" && (
<div>
<label htmlFor="task-user" className={labelClass}>
Recipient <span className="text-red-500">*</span>
</label>
<input
id="task-user-search"
type="text"
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
className={inputClass}
placeholder="Search by name or email..."
/>
{userId && (
<div className="mt-1 flex items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">
Selected: {users.find((u) => u.id === userId)?.name ?? users.find((u) => u.id === userId)?.email ?? userId}
</span>
<button
type="button"
onClick={() => setUserId("")}
className="text-xs text-red-500 hover:text-red-700"
>
Clear
</button>
</div>
)}
{userSearch.trim() && filteredUsers.length > 0 && (
<div className="mt-1 max-h-40 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900">
{filteredUsers.slice(0, 20).map((u) => (
<button
key={u.id}
type="button"
onClick={() => {
setUserId(u.id);
setUserSearch("");
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors ${
u.id === userId ? "bg-brand-50 dark:bg-brand-900/20" : ""
}`}
>
<span className="font-medium text-gray-900 dark:text-gray-100">
{u.name ?? "Unnamed"}
</span>
<span className="ml-2 text-gray-400 dark:text-gray-500">{u.email}</span>
</button>
))}
</div>
)}
{userSearch.trim() && filteredUsers.length === 0 && (
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">No users found.</p>
)}
</div>
)}
{/* Target (group mode) */}
{mode === "group" && (
<>
<div>
<label htmlFor="task-target" className={labelClass}>
Target Audience
</label>
<select
id="task-target"
value={targetType}
onChange={(e) => { setTargetType(e.target.value); setTargetValue(""); }}
className={inputClass}
>
{TARGET_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
{targetType === "role" && (
<div>
<label htmlFor="task-role" className={labelClass}>
Role
</label>
<select
id="task-role"
value={targetValue}
onChange={(e) => setTargetValue(e.target.value)}
className={inputClass}
>
<option value="">Select a role...</option>
{ROLES.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</div>
)}
{targetType === "project" && (
<div>
<label htmlFor="task-project" className={labelClass}>
Project ID
</label>
<input
id="task-project"
type="text"
value={targetValue}
onChange={(e) => setTargetValue(e.target.value)}
className={inputClass}
placeholder="Project ID..."
/>
</div>
)}
{targetType === "orgUnit" && (
<div>
<label htmlFor="task-orgunit" className={labelClass}>
Org Unit ID
</label>
<input
id="task-orgunit"
type="text"
value={targetValue}
onChange={(e) => setTargetValue(e.target.value)}
className={inputClass}
placeholder="Org Unit ID..."
/>
</div>
)}
</>
)}
{/* Title */}
<div>
<label htmlFor="task-title" className={labelClass}>
Title <span className="text-red-500">*</span>
</label>
<input
id="task-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
className={inputClass}
required
placeholder="Task title..."
/>
</div>
{/* Body */}
<div>
<label htmlFor="task-body" className={labelClass}>
Description (optional)
</label>
<textarea
id="task-body"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={3}
maxLength={2000}
className={`${inputClass} resize-none`}
placeholder="Task details..."
/>
</div>
{/* Priority + Channel */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="task-priority" className={labelClass}>
Priority
</label>
<select
id="task-priority"
value={priority}
onChange={(e) => setPriority(e.target.value)}
className={inputClass}
>
{PRIORITY_OPTIONS.map((p) => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
</div>
<div>
<label htmlFor="task-channel" className={labelClass}>
Channel
</label>
<select
id="task-channel"
value={channel}
onChange={(e) => setChannel(e.target.value)}
className={inputClass}
>
{CHANNEL_OPTIONS.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
</div>
{/* Due Date + Time */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="task-due-date" className={labelClass}>
Due Date (optional)
</label>
<DateInput
id="task-due-date"
value={dueDate}
onChange={setDueDate}
className={inputClass}
/>
</div>
<div>
<label htmlFor="task-due-time" className={labelClass}>
Due Time
</label>
<input
id="task-due-time"
type="time"
value={dueTime}
onChange={(e) => setDueTime(e.target.value)}
className={inputClass}
disabled={!dueDate}
/>
</div>
</div>
{/* Link */}
<div>
<label htmlFor="task-link" className={labelClass}>
Link (optional)
</label>
<input
id="task-link"
type="text"
value={link}
onChange={(e) => setLink(e.target.value)}
className={inputClass}
placeholder="/some/page or https://..."
/>
</div>
{/* Server error */}
{serverError && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:border-red-800 dark:text-red-300">
{serverError}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
disabled={isPending}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Creating..." : "Create Task"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
import type { Route } from "next"; import type { Route } from "next";
@@ -27,6 +28,9 @@ export function NotificationBell() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<TabKey>("all"); const [activeTab, setActiveTab] = useState<TabKey>("all");
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const bellRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const isAuthenticated = status === "authenticated" && !!session?.user?.email; const isAuthenticated = status === "authenticated" && !!session?.user?.email;
@@ -34,13 +38,13 @@ export function NotificationBell() {
const { data: unreadCount = 0 } = trpc.notification.unreadCount.useQuery(undefined, { const { data: unreadCount = 0 } = trpc.notification.unreadCount.useQuery(undefined, {
enabled: isAuthenticated, enabled: isAuthenticated,
refetchInterval: 30_000, refetchInterval: 60_000,
retry: false, retry: false,
}); });
const { data: taskCounts } = trpc.notification.taskCounts.useQuery(undefined, { const { data: taskCounts } = trpc.notification.taskCounts.useQuery(undefined, {
enabled: isAuthenticated, enabled: isAuthenticated,
refetchInterval: 30_000, refetchInterval: 60_000,
retry: false, retry: false,
}); });
@@ -77,11 +81,32 @@ export function NotificationBell() {
}, },
}); });
// Compute dropdown position when opening
const updatePosition = useCallback(() => {
if (!bellRef.current) return;
const rect = bellRef.current.getBoundingClientRect();
const panelHeight = 440; // approximate max height
let top = rect.top;
// If it would overflow the bottom, flip upward
if (top + panelHeight > window.innerHeight) {
top = Math.max(8, window.innerHeight - panelHeight - 8);
}
setDropdownPos({ top, left: rect.right + 8 });
}, []);
useEffect(() => {
if (open) updatePosition();
}, [open, updatePosition]);
// Close dropdown on outside click // Close dropdown on outside click
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
function handleClick(e: MouseEvent) { function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) { const target = e.target as Node;
if (
ref.current && !ref.current.contains(target) &&
dropdownRef.current && !dropdownRef.current.contains(target)
) {
setOpen(false); setOpen(false);
} }
} }
@@ -113,6 +138,7 @@ export function NotificationBell() {
<div ref={ref} className="relative"> <div ref={ref} className="relative">
{/* Bell button */} {/* Bell button */}
<button <button
ref={bellRef}
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="relative p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors" className="relative p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
@@ -146,9 +172,13 @@ export function NotificationBell() {
)} )}
</button> </button>
{/* Dropdown panel */} {/* Dropdown panel — rendered via portal to escape sidebar overflow */}
{open && ( {open && createPortal(
<div className="absolute right-0 mt-2 w-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-50 overflow-hidden"> <div
ref={dropdownRef}
className="fixed w-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-[9999] overflow-hidden"
style={{ top: dropdownPos.top, left: dropdownPos.left }}
>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800"> <div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
<span className="text-sm font-semibold text-gray-900 dark:text-gray-50"> <span className="text-sm font-semibold text-gray-900 dark:text-gray-50">
@@ -335,7 +365,8 @@ export function NotificationBell() {
View all &rarr; View all &rarr;
</Link> </Link>
</div> </div>
</div> </div>,
document.body,
)} )}
</div> </div>
); );
@@ -3,8 +3,10 @@
import { useState } from "react"; import { useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { TaskCard } from "./TaskCard.js"; import { TaskCard } from "./TaskCard.js";
import { ReminderModal } from "./ReminderModal.js"; import { ReminderModal } from "./ReminderModal.js";
import { CreateTaskModal } from "./CreateTaskModal.js";
type TabKey = "all" | "notifications" | "tasks" | "reminders" | "approvals"; type TabKey = "all" | "notifications" | "tasks" | "reminders" | "approvals";
@@ -27,6 +29,8 @@ export function NotificationCenterClient() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const initialTab = (searchParams.get("tab") as TabKey) || "all"; const initialTab = (searchParams.get("tab") as TabKey) || "all";
const [activeTab, setActiveTab] = useState<TabKey>(initialTab); const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
const { canEdit } = usePermissions();
const [showTaskModal, setShowTaskModal] = useState(false);
const [reminderModal, setReminderModal] = useState<{ const [reminderModal, setReminderModal] = useState<{
open: boolean; open: boolean;
reminder: { reminder: {
@@ -124,6 +128,18 @@ export function NotificationCenterClient() {
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Notification Center</h1> <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Notification Center</h1>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{canEdit && activeTab === "tasks" && (
<button
type="button"
onClick={() => setShowTaskModal(true)}
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 transition-colors"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Create Task
</button>
)}
{activeTab === "reminders" && ( {activeTab === "reminders" && (
<button <button
type="button" type="button"
@@ -389,6 +405,14 @@ export function NotificationCenterClient() {
onSuccess={() => setReminderModal({ open: false, reminder: null })} onSuccess={() => setReminderModal({ open: false, reminder: null })}
/> />
)} )}
{/* Create Task Modal */}
{showTaskModal && (
<CreateTaskModal
onClose={() => setShowTaskModal(false)}
onSuccess={() => setShowTaskModal(false)}
/>
)}
</div> </div>
); );
} }
@@ -28,7 +28,10 @@ interface ReminderModalProps {
function toDateInputValue(date: Date | string | null | undefined): string { function toDateInputValue(date: Date | string | null | undefined): string {
if (!date) return ""; if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date; const d = typeof date === "string" ? new Date(date) : date;
return d.toISOString().split("T")[0] ?? ""; const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
} }
function toTimeInputValue(date: Date | string | null | undefined): string { function toTimeInputValue(date: Date | string | null | undefined): string {
@@ -25,11 +25,11 @@ const proficiencyLabel: Record<number, string> = {
}; };
const proficiencyColor: Record<number, string> = { const proficiencyColor: Record<number, string> = {
1: "bg-gray-100 text-gray-600", 1: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300",
2: "bg-blue-50 text-blue-600", 2: "bg-blue-50 text-blue-600 dark:bg-blue-900/50 dark:text-blue-300",
3: "bg-brand-50 text-brand-700", 3: "bg-brand-50 text-brand-700 dark:bg-brand-900/50 dark:text-brand-200",
4: "bg-amber-50 text-amber-700", 4: "bg-amber-50 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
5: "bg-green-50 text-green-700", 5: "bg-green-50 text-green-700 dark:bg-green-900/50 dark:text-green-300",
}; };
const vacationStatusColor: Record<string, string> = { const vacationStatusColor: Record<string, string> = {
@@ -211,10 +211,10 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl font-bold text-gray-900 truncate">{resource.displayName}</h1> <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 truncate">{resource.displayName}</h1>
<span <span
className={`flex-shrink-0 px-2.5 py-0.5 text-xs font-medium rounded-full ${ className={`flex-shrink-0 px-2.5 py-0.5 text-xs font-medium rounded-full ${
resource.isActive ? "bg-green-100 text-green-700" : "bg-red-100 text-red-700" resource.isActive ? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300" : "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"
}`} }`}
> >
{resource.isActive ? "Active" : "Inactive"} {resource.isActive ? "Active" : "Inactive"}
@@ -359,11 +359,11 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{/* Profile meta (area role, portfolio, last import) */} {/* Profile meta (area role, portfolio, last import) */}
{(resourceWithMeta.areaRole || resourceWithMeta.portfolioUrl || resourceWithMeta.skillMatrixUpdatedAt) && ( {(resourceWithMeta.areaRole || resourceWithMeta.portfolioUrl || resourceWithMeta.skillMatrixUpdatedAt) && (
<div className="bg-white rounded-xl border border-gray-200 p-4 flex flex-wrap gap-4 text-sm"> <div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4 flex flex-wrap gap-4 text-sm">
{resourceWithMeta.areaRole && ( {resourceWithMeta.areaRole && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-gray-500 text-xs">Area:</span> <span className="text-gray-500 dark:text-gray-400 text-xs">Area:</span>
<span className="font-medium text-gray-800">{resourceWithMeta.areaRole.name}</span> <span className="font-medium text-gray-800 dark:text-gray-200">{resourceWithMeta.areaRole.name}</span>
</div> </div>
)} )}
{resourceWithMeta.portfolioUrl && ( {resourceWithMeta.portfolioUrl && (
@@ -398,13 +398,13 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{/* Main Skills Badges */} {/* Main Skills Badges */}
{mainSkills.length > 0 && ( {mainSkills.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5"> <div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 className="text-sm font-semibold text-gray-800 mb-3 flex items-center">Main Skills<InfoTooltip content="Up to 2 skills flagged as primary strengths. Used for staffing matching priority." /></h2> <h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">Main Skills<InfoTooltip content="Up to 2 skills flagged as primary strengths. Used for staffing matching priority." /></h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{mainSkills.map((s) => ( {mainSkills.map((s) => (
<span <span
key={s.skill} key={s.skill}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-full bg-amber-50 text-amber-800 border border-amber-200" className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-full bg-amber-50 text-amber-800 border border-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:border-amber-700"
> >
<span className="text-amber-500"></span> <span className="text-amber-500"></span>
{s.skill} {s.skill}
@@ -422,8 +422,8 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{/* Roles */} {/* Roles */}
{resourceRoles.length > 0 && ( {resourceRoles.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5"> <div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 className="text-sm font-semibold text-gray-800 mb-3 flex items-center">Roles<InfoTooltip content="Job functions assigned to this resource. The primary role is used in staffing and timeline displays." /></h2> <h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">Roles<InfoTooltip content="Job functions assigned to this resource. The primary role is used in staffing and timeline displays." /></h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{resourceRoles.map((rr) => ( {resourceRoles.map((rr) => (
<span <span
@@ -445,13 +445,13 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{/* Skills */} {/* Skills */}
{skills.length > 0 && ( {skills.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5"> <div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<h2 className="text-sm font-semibold text-gray-800 mb-3 flex items-center">Skills<InfoTooltip content="Full skill inventory with proficiency level (1-5) and years of experience. Imported via skill matrix XLSX." /></h2> <h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">Skills<InfoTooltip content="Full skill inventory with proficiency level (1-5) and years of experience. Imported via skill matrix XLSX." /></h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{skills.map((s) => ( {skills.map((s) => (
<span <span
key={s.skill} key={s.skill}
className="inline-flex items-center gap-1.5 px-3 py-1 text-sm rounded-full bg-gray-100 text-gray-700" className="inline-flex items-center gap-1.5 px-3 py-1 text-sm rounded-full bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200"
> >
{s.skill} {s.skill}
{s.proficiency != null && ( {s.proficiency != null && (
@@ -464,7 +464,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
</span> </span>
)} )}
{s.yearsExperience != null && ( {s.yearsExperience != null && (
<span className="text-xs text-gray-400">{s.yearsExperience}y</span> <span className="text-xs text-gray-400 dark:text-gray-500">{s.yearsExperience}y</span>
)} )}
</span> </span>
))} ))}
@@ -8,10 +8,14 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
export function StaffingPanel() { export function StaffingPanel() {
const [requiredSkills, setRequiredSkills] = useState<string[]>(["TypeScript", "React"]); const [requiredSkills, setRequiredSkills] = useState<string[]>(["TypeScript", "React"]);
const [startDate, setStartDate] = useState(new Date().toISOString().split("T")[0] ?? ""); const [startDate, setStartDate] = useState(() => {
const [endDate, setEndDate] = useState( const d = new Date();
new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split("T")[0] ?? "", return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
); });
const [endDate, setEndDate] = useState(() => {
const d = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
});
const [hoursPerDay, setHoursPerDay] = useState(8); const [hoursPerDay, setHoursPerDay] = useState(8);
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
@@ -76,7 +76,10 @@ export function AllocationPopover({
}, [onClose]); }, [onClose]);
function toDateInput(d: Date): string { function toDateInput(d: Date): string {
return d.toISOString().split("T")[0] ?? ""; const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
} }
function handleSave() { function handleSave() {
@@ -0,0 +1,259 @@
"use client";
import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react";
import { AllocationStatus } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
interface BatchAssignPopoverProps {
resourceIds: string[];
startDate: Date;
endDate: Date;
onClose: () => void;
onCreated: () => void;
}
function toDateDisplay(d: Date): string {
return d.toLocaleDateString("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
});
}
export function BatchAssignPopover({
resourceIds,
startDate,
endDate,
onClose,
onCreated,
}: BatchAssignPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
const utils = trpc.useUtils();
const [search, setSearch] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null,
);
const [hoursPerDay, setHoursPerDay] = useState(8);
const [dropdownOpen, setDropdownOpen] = useState(true);
const { data: projectsData } = trpc.project.list.useQuery(
{ search, limit: 20 },
{ staleTime: 30_000 },
);
const projects = (projectsData?.projects ?? []) as Array<{
id: string;
name: string;
}>;
const selectedProject = projects.find((p) => p.id === selectedProjectId);
const batchMutation = trpc.timeline.batchQuickAssign.useMutation({
onSuccess: () => {
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
onCreated();
onClose();
},
});
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose]);
// Close on ESC
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [onClose]);
function handleAssign() {
if (!selectedProjectId) return;
batchMutation.mutate({
assignments: resourceIds.map((resourceId) => ({
resourceId,
projectId: selectedProjectId,
startDate,
endDate,
hoursPerDay,
role: "Team Member",
status: AllocationStatus.PROPOSED,
})),
});
}
const canAssign =
!!selectedProjectId && resourceIds.length > 0 && hoursPerDay > 0;
return (
<div
ref={ref}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[60] w-[360px] bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-2xl dark:shadow-black/40 overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">
Batch Assign
</span>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-lg leading-none"
>
&times;
</button>
</div>
<div className="p-4 space-y-3">
{/* Info line */}
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-0.5">
<p>
Assigning to{" "}
<span className="font-medium text-gray-800 dark:text-gray-200">
{resourceIds.length}
</span>{" "}
resource{resourceIds.length !== 1 ? "s" : ""}
</p>
<p>
{toDateDisplay(startDate)} &ndash; {toDateDisplay(endDate)}
</p>
</div>
{/* Project picker */}
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Project
</label>
{selectedProject && !dropdownOpen ? (
<div
className="flex items-center gap-2 border border-sky-300 dark:border-sky-700 rounded-lg px-3 py-2 cursor-pointer bg-sky-50 dark:bg-sky-950/30"
onClick={() => {
setDropdownOpen(true);
setSearch("");
}}
>
<span className="text-sm text-gray-800 dark:text-gray-200 truncate flex-1">
{selectedProject.name}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
&#9662;
</span>
</div>
) : (
<div className="relative">
<input
autoFocus
type="text"
placeholder="Search projects\u2026"
value={search}
onChange={(e) => setSearch(e.target.value)}
onFocus={() => setDropdownOpen(true)}
className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-400 dark:focus:ring-sky-500"
/>
{dropdownOpen && projects.length > 0 && (
<div className="absolute top-full left-0 right-0 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg dark:shadow-black/40 mt-1 max-h-44 overflow-y-auto">
{projects.map((p) => (
<button
key={p.id}
type="button"
onClick={() => {
setSelectedProjectId(p.id);
setDropdownOpen(false);
setSearch("");
}}
className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2 border-b border-gray-50 dark:border-gray-700 last:border-0"
>
<span className="text-sm text-gray-800 dark:text-gray-200 truncate">
{p.name}
</span>
</button>
))}
</div>
)}
</div>
)}
</div>
{/* Hours per day */}
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Hours / day
</label>
<div className="flex items-center gap-2">
<input
type="number"
min={0.5}
max={24}
step={0.5}
value={hoursPerDay}
onChange={(e) => setHoursPerDay(parseFloat(e.target.value))}
className="w-24 border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-sky-400 dark:focus:ring-sky-500"
/>
<div className="flex gap-1">
{[4, 6, 8].map((h) => (
<button
key={h}
type="button"
onClick={() => setHoursPerDay(h)}
className={clsx(
"px-2 py-1 rounded text-xs font-medium border transition-colors",
hoursPerDay === h
? "bg-sky-600 text-white border-sky-600 dark:bg-sky-600 dark:border-sky-600"
: "border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700",
)}
>
{h}h
</button>
))}
</div>
</div>
</div>
{/* Error */}
{batchMutation.isError && (
<p className="text-xs text-red-600 dark:text-red-400">
{batchMutation.error.message}
</p>
)}
{/* Actions */}
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={handleAssign}
disabled={!canAssign || batchMutation.isPending}
className={clsx(
"flex-1 py-2 rounded-lg text-sm font-medium transition-colors",
"bg-sky-600 text-white hover:bg-sky-700 dark:bg-sky-600 dark:hover:bg-sky-700",
"disabled:opacity-40 disabled:cursor-not-allowed",
)}
>
{batchMutation.isPending
? "Assigning\u2026"
: `Assign All (${resourceIds.length})`}
</button>
<button
type="button"
onClick={onClose}
className="flex-1 py-2 rounded-lg text-sm font-medium border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,187 @@
"use client";
import { useEffect, useRef } from "react";
import type { TimelineDemandEntry } from "./TimelineContext.js";
import { formatDateLong } from "~/lib/format.js";
interface DemandPopoverProps {
demand: TimelineDemandEntry;
onClose: () => void;
onOpenPanel: (projectId: string) => void;
onFillDemand: (demand: TimelineDemandEntry) => void;
anchorX: number;
anchorY: number;
}
export function DemandPopover({
demand,
onClose,
onOpenPanel,
onFillDemand,
anchorX,
anchorY,
}: DemandPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose]);
const roleName = demand.roleEntity?.name ?? demand.role ?? "Unspecified";
const roleColor = demand.roleEntity?.color ?? "#f59e0b";
const startDate = new Date(demand.startDate);
const endDate = new Date(demand.endDate);
const days = Math.max(1, Math.round((endDate.getTime() - startDate.getTime()) / 86_400_000) + 1);
const totalHours = demand.hoursPerDay * days;
const budgetCents = demand.dailyCostCents * days;
const popoverStyle: React.CSSProperties = {
position: "fixed",
left: Math.min(anchorX, window.innerWidth - 320),
top: Math.min(anchorY + 8, window.innerHeight - 340),
zIndex: 50,
width: 300,
};
return (
<div
ref={ref}
style={popoverStyle}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-700"
style={{ backgroundColor: `${roleColor}18` }}
>
<div className="flex items-center gap-2 min-w-0">
<div
className="w-3 h-3 rounded-full flex-shrink-0 border-2 border-dashed"
style={{ borderColor: roleColor, backgroundColor: `${roleColor}33` }}
/>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">
{roleName}
</span>
</div>
<button
onClick={onClose}
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 text-lg leading-none ml-2"
>
&times;
</button>
</div>
<div className="p-4 space-y-3">
{/* Project */}
<div className="text-xs text-gray-500 dark:text-gray-400">
Project:{" "}
<span className="font-medium text-gray-700 dark:text-gray-200">
{demand.project.name}
</span>
{" "}
<span className="text-gray-400 dark:text-gray-500">({demand.project.shortCode})</span>
</div>
{/* Status badge */}
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400 border border-dashed border-amber-300 dark:border-amber-700">
Open Demand
</span>
<span className="text-[11px] text-gray-400 dark:text-gray-500">
{demand.status}
</span>
</div>
{/* Headcount */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Requested</div>
<div className="font-medium text-gray-800 dark:text-gray-200">
{demand.requestedHeadcount} {demand.requestedHeadcount === 1 ? "person" : "people"}
</div>
</div>
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Unfilled</div>
<div className="font-medium text-amber-600 dark:text-amber-400">
{demand.unfilledHeadcount} remaining
</div>
</div>
{/* Date range */}
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Start</div>
<div className="font-medium text-gray-800 dark:text-gray-200">
{formatDateLong(startDate)}
</div>
</div>
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">End</div>
<div className="font-medium text-gray-800 dark:text-gray-200">
{formatDateLong(endDate)}
</div>
</div>
{/* Hours */}
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Hours / day</div>
<div className="font-medium text-gray-800 dark:text-gray-200">{demand.hoursPerDay}h</div>
</div>
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Total hours</div>
<div className="font-medium text-gray-800 dark:text-gray-200">{totalHours}h ({days}d)</div>
</div>
{/* Budget */}
{budgetCents > 0 && (
<>
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Daily cost</div>
<div className="font-medium text-gray-800 dark:text-gray-200">
{(demand.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR
</div>
</div>
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Total cost</div>
<div className="font-medium text-gray-800 dark:text-gray-200">
{(budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR
</div>
</div>
</>
)}
{/* Percentage */}
{demand.percentage > 0 && (
<div>
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Percentage</div>
<div className="font-medium text-gray-800 dark:text-gray-200">{demand.percentage}%</div>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t border-gray-100 dark:border-gray-700">
{demand.unfilledHeadcount > 0 && (
<button
onClick={() => { onClose(); onFillDemand(demand); }}
className="flex-1 py-1.5 rounded-lg text-sm font-medium bg-amber-500 text-white hover:bg-amber-600 transition-colors"
>
Fill Demand
</button>
)}
<button
onClick={() => { onClose(); onOpenPanel(demand.projectId); }}
className="flex-1 py-1.5 rounded-lg text-sm font-medium border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Open Project
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,88 @@
"use client";
import { clsx } from "clsx";
interface FloatingActionBarProps {
selectedAllocationCount: number;
selectedResourceCount: number;
onDelete: () => void;
onAssign: () => void;
onClear: () => void;
isDeleting: boolean;
}
export function FloatingActionBar({
selectedAllocationCount,
selectedResourceCount,
onDelete,
onAssign,
onClear,
isDeleting,
}: FloatingActionBarProps) {
const totalCount = selectedAllocationCount + selectedResourceCount;
if (totalCount === 0) return null;
const label =
selectedAllocationCount > 0 && selectedResourceCount > 0
? `${selectedAllocationCount} allocation${selectedAllocationCount !== 1 ? "s" : ""} + ${selectedResourceCount} resource${selectedResourceCount !== 1 ? "s" : ""} selected`
: selectedAllocationCount > 0
? `${selectedAllocationCount} allocation${selectedAllocationCount !== 1 ? "s" : ""} selected`
: `${selectedResourceCount} resource${selectedResourceCount !== 1 ? "s" : ""} selected`;
return (
<div
className={clsx(
"fixed bottom-6 left-1/2 -translate-x-1/2 z-50",
"flex items-center gap-3 rounded-full px-5 py-2.5",
"bg-white dark:bg-gray-800",
"border border-gray-200 dark:border-gray-700",
"shadow-xl dark:shadow-black/40",
)}
>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
{label}
</span>
{selectedAllocationCount > 0 && (
<button
type="button"
onClick={onDelete}
disabled={isDeleting}
className={clsx(
"text-xs font-medium px-3 py-1.5 rounded-full transition-colors",
"bg-red-600 hover:bg-red-700 text-white",
"disabled:opacity-40 disabled:cursor-not-allowed",
)}
>
{isDeleting ? "Deleting\u2026" : "Delete"}
</button>
)}
{selectedResourceCount > 0 && (
<button
type="button"
onClick={onAssign}
className={clsx(
"text-xs font-medium px-3 py-1.5 rounded-full transition-colors",
"bg-sky-600 hover:bg-sky-700 dark:bg-sky-600 dark:hover:bg-sky-700 text-white",
)}
>
Assign
</button>
)}
<button
type="button"
onClick={onClear}
className={clsx(
"text-xs font-medium px-2 py-1.5 transition-colors",
"text-gray-500 dark:text-gray-400",
"hover:text-gray-700 dark:hover:text-gray-300",
)}
>
Clear{" "}
<span className="text-gray-400 dark:text-gray-500">(ESC)</span>
</button>
</div>
);
}
@@ -19,7 +19,10 @@ interface NewAllocationPopoverProps {
} }
function toDateInput(d: Date): string { function toDateInput(d: Date): string {
return d.toISOString().split("T")[0] ?? ""; const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
} }
export function NewAllocationPopover({ export function NewAllocationPopover({
@@ -50,7 +53,8 @@ export function NewAllocationPopover({
{ staleTime: 30_000 }, { staleTime: 30_000 },
); );
const projects = projectsData?.projects ?? []; // eslint-disable-next-line @typescript-eslint/no-explicit-any
const projects = (projectsData?.projects ?? []) as Array<{ id: string; name: string; orderType?: string }>;
const selectedProject = projects.find((p) => p.id === selectedProjectId) const selectedProject = projects.find((p) => p.id === selectedProjectId)
?? (suggestedProjectId ? projects.find((p) => p.id === suggestedProjectId) : null); ?? (suggestedProjectId ? projects.find((p) => p.id === suggestedProjectId) : null);
@@ -94,57 +98,50 @@ export function NewAllocationPopover({
const left = Math.min(anchorX - 10, typeof window !== "undefined" ? window.innerWidth - 340 : anchorX); const left = Math.min(anchorX - 10, typeof window !== "undefined" ? window.innerWidth - 340 : anchorX);
const top = Math.min(anchorY + 8, typeof window !== "undefined" ? window.innerHeight - 440 : anchorY); const top = Math.min(anchorY + 8, typeof window !== "undefined" ? window.innerHeight - 440 : anchorY);
const ORDER_COLORS: Record<string, string> = {
CHARGEABLE: "bg-emerald-100 text-emerald-700",
INTERNAL: "bg-blue-100 text-blue-700",
BD: "bg-violet-100 text-violet-700",
OVERHEAD: "bg-gray-100 text-gray-600",
};
return ( return (
<div <div
ref={ref} ref={ref}
style={{ position: "fixed", left, top, zIndex: 60, width: 320 }} style={{ position: "fixed", left, top, zIndex: 60, width: 320 }}
className="bg-white border border-gray-200 rounded-xl shadow-2xl overflow-hidden" className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-2xl dark:shadow-black/40 overflow-hidden"
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-100"> <div className="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
<span className="text-sm font-semibold text-gray-700">Assign to Project</span> <span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Assign to Project</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">&times;</button> <button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-lg leading-none">&times;</button>
</div> </div>
<div className="p-4 space-y-3"> <div className="p-4 space-y-3">
{/* Date range */} {/* Date range */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="block text-xs font-medium text-gray-600 mb-1">Start</label> <label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Start</label>
<DateInput <DateInput
value={start} value={start}
onChange={setStart} onChange={setStart}
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400" className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
/> />
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-gray-600 mb-1">End</label> <label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">End</label>
<DateInput <DateInput
value={end} value={end}
onChange={setEnd} onChange={setEnd}
min={start} min={start}
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400" className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
/> />
</div> </div>
</div> </div>
{/* Project picker */} {/* Project picker */}
<div> <div>
<label className="block text-xs font-medium text-gray-600 mb-1">Project</label> <label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Project</label>
{selectedProject && !dropdownOpen ? ( {selectedProject && !dropdownOpen ? (
<div <div
className="flex items-center gap-2 border border-brand-300 rounded-lg px-3 py-2 cursor-pointer bg-brand-50" className="flex items-center gap-2 border border-brand-300 dark:border-sky-700 rounded-lg px-3 py-2 cursor-pointer bg-brand-50 dark:bg-sky-950/30"
onClick={() => { setDropdownOpen(true); setSearch(""); }} onClick={() => { setDropdownOpen(true); setSearch(""); }}
> >
<span className="text-sm text-gray-800 truncate flex-1">{selectedProject.name}</span> <span className="text-sm text-gray-800 dark:text-gray-200 truncate flex-1">{selectedProject.name}</span>
<span className="text-xs text-gray-400"></span> <span className="text-xs text-gray-400 dark:text-gray-500"></span>
</div> </div>
) : ( ) : (
<div className="relative"> <div className="relative">
@@ -155,18 +152,18 @@ export function NewAllocationPopover({
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
onFocus={() => setDropdownOpen(true)} onFocus={() => setDropdownOpen(true)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
/> />
{dropdownOpen && projects.length > 0 && ( {dropdownOpen && projects.length > 0 && (
<div className="absolute top-full left-0 right-0 z-50 bg-white border border-gray-200 rounded-xl shadow-lg mt-1 max-h-44 overflow-y-auto"> <div className="absolute top-full left-0 right-0 z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg dark:shadow-black/40 mt-1 max-h-44 overflow-y-auto">
{projects.map((p) => ( {projects.map((p) => (
<button <button
key={p.id} key={p.id}
type="button" type="button"
onClick={() => { setSelectedProjectId(p.id); setDropdownOpen(false); setSearch(""); }} onClick={() => { setSelectedProjectId(p.id); setDropdownOpen(false); setSearch(""); }}
className="w-full text-left px-3 py-2 hover:bg-gray-50 flex items-center gap-2 border-b border-gray-50 last:border-0" className="w-full text-left px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2 border-b border-gray-50 dark:border-gray-700 last:border-0"
> >
<span className="text-sm text-gray-800 truncate">{p.name}</span> <span className="text-sm text-gray-800 dark:text-gray-200 truncate">{p.name}</span>
</button> </button>
))} ))}
</div> </div>
@@ -177,18 +174,18 @@ export function NewAllocationPopover({
{/* Role */} {/* Role */}
<div> <div>
<label className="block text-xs font-medium text-gray-600 mb-1">Role</label> <label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Role</label>
<input <input
type="text" type="text"
value={role} value={role}
onChange={(e) => setRole(e.target.value)} onChange={(e) => setRole(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" className="w-full border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
/> />
</div> </div>
{/* Hours per day */} {/* Hours per day */}
<div> <div>
<label className="block text-xs font-medium text-gray-600 mb-1">Hours / day</label> <label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Hours / day</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="number" type="number"
@@ -197,7 +194,7 @@ export function NewAllocationPopover({
step={0.5} step={0.5}
value={hoursPerDay} value={hoursPerDay}
onChange={(e) => setHoursPerDay(parseFloat(e.target.value))} onChange={(e) => setHoursPerDay(parseFloat(e.target.value))}
className="w-24 border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" className="w-24 border border-gray-200 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
/> />
<div className="flex gap-1"> <div className="flex gap-1">
{[4, 6, 8].map((h) => ( {[4, 6, 8].map((h) => (
@@ -209,7 +206,7 @@ export function NewAllocationPopover({
"px-2 py-1 rounded text-xs font-medium border transition-colors", "px-2 py-1 rounded text-xs font-medium border transition-colors",
hoursPerDay === h hoursPerDay === h
? "bg-brand-600 text-white border-brand-600" ? "bg-brand-600 text-white border-brand-600"
: "border-gray-200 text-gray-600 hover:bg-gray-50", : "border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700",
)} )}
> >
{h}h {h}h
@@ -220,13 +217,13 @@ export function NewAllocationPopover({
</div> </div>
{/* Overbooking notice */} {/* Overbooking notice */}
<p className="text-xs text-amber-600 bg-amber-50 px-3 py-2 rounded-lg"> <p className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 rounded-lg">
Overlapping allocations are allowed resource may be overbooked. Overlapping allocations are allowed resource may be overbooked.
</p> </p>
{/* Error */} {/* Error */}
{createMutation.isError && ( {createMutation.isError && (
<p className="text-xs text-red-600">{createMutation.error.message}</p> <p className="text-xs text-red-600 dark:text-red-400">{createMutation.error.message}</p>
)} )}
{/* Actions */} {/* Actions */}
@@ -243,7 +240,7 @@ export function NewAllocationPopover({
</button> </button>
<button <button
onClick={onClose} onClick={onClose}
className="flex-1 py-2 rounded-lg text-sm font-medium border border-gray-200 text-gray-600 hover:bg-gray-50" className="flex-1 py-2 rounded-lg text-sm font-medium border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700"
> >
Cancel Cancel
</button> </button>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { useState } from "react"; import { useEffect, useState } from "react";
import { AllocationStatus, type StaffingRequirement } from "@planarchy/shared"; import { AllocationStatus, type StaffingRequirement } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
@@ -77,7 +77,11 @@ const STATUS_COLORS = {
}; };
function toDateInput(d: Date | string): string { function toDateInput(d: Date | string): string {
return new Date(d).toISOString().split("T")[0] ?? ""; const dt = new Date(d);
const y = dt.getFullYear();
const m = String(dt.getMonth() + 1).padStart(2, "0");
const day = String(dt.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
} }
function normalizeRole(value: string | null | undefined): string { function normalizeRole(value: string | null | undefined): string {
@@ -518,6 +522,17 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
} }
function PanelShell({ children, onClose }: { children: React.ReactNode; onClose: () => void }) { function PanelShell({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
return ( return (
<div className="fixed inset-y-0 right-0 w-[420px] bg-white border-l border-gray-200 shadow-2xl z-40 flex flex-col"> <div className="fixed inset-y-0 right-0 w-[420px] bg-white border-l border-gray-200 shadow-2xl z-40 flex flex-col">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100"> <div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
@@ -0,0 +1,184 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import type { SkillEntry } from "@planarchy/shared";
interface ResourceHoverCardProps {
resourceId: string;
anchorEl: HTMLElement;
onClose: () => void;
}
export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHoverCardProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ left: 0, top: 0 });
const { data, isLoading } = trpc.resource.getHoverCard.useQuery(
{ id: resourceId },
{ staleTime: 60_000 },
);
// Position relative to anchor element
useEffect(() => {
const rect = anchorEl.getBoundingClientRect();
setPos({
left: rect.right + 8,
top: Math.min(rect.top, window.innerHeight - 320),
});
}, [anchorEl]);
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node) && !anchorEl.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose, anchorEl]);
const skills = (data?.skills ?? []) as unknown as SkillEntry[];
const mainSkills = skills.filter((s) => s.isMainSkill);
const topSkills = skills
.filter((s) => !s.isMainSkill && s.proficiency >= 4)
.sort((a, b) => b.proficiency - a.proficiency)
.slice(0, 6);
const popoverStyle: React.CSSProperties = {
position: "fixed",
left: Math.min(pos.left, window.innerWidth - 300),
top: pos.top,
zIndex: 50,
width: 280,
};
return (
<div
ref={ref}
data-resource-hover-card="true"
style={popoverStyle}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
onMouseLeave={onClose}
>
{isLoading || !data ? (
<div className="p-4 text-xs text-gray-400 dark:text-gray-500">Loading...</div>
) : (
<>
{/* Header */}
<div className="px-4 py-3 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-750">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/40 flex items-center justify-center text-xs font-bold text-brand-700 dark:text-brand-300 flex-shrink-0">
{data.displayName.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">
{data.displayName}
</div>
<div className="text-[11px] text-gray-400 dark:text-gray-500">{data.eid}</div>
</div>
</div>
</div>
<div className="p-3 space-y-2.5 text-xs">
{/* Role & Chapter */}
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
{data.areaRole && (
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Role</div>
<div className="font-medium text-gray-700 dark:text-gray-200 flex items-center gap-1">
{data.areaRole.color && (
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: data.areaRole.color }} />
)}
{data.areaRole.name}
</div>
</div>
)}
{data.chapter && (
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Chapter</div>
<div className="font-medium text-gray-700 dark:text-gray-200">{data.chapter}</div>
</div>
)}
{data.country && (
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Location</div>
<div className="font-medium text-gray-700 dark:text-gray-200">{data.country.name}</div>
</div>
)}
{data.managementLevel && (
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Level</div>
<div className="font-medium text-gray-700 dark:text-gray-200">{data.managementLevel.name}</div>
</div>
)}
</div>
{/* Rates */}
<div className="flex items-center gap-3 py-1.5 px-2 rounded-lg bg-gray-50 dark:bg-gray-750">
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">LCR</div>
<div className="font-semibold text-gray-700 dark:text-gray-200">
{(data.lcrCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} {data.currency}/h
</div>
</div>
<div className="w-px h-6 bg-gray-200 dark:bg-gray-600" />
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">UCR</div>
<div className="font-semibold text-gray-700 dark:text-gray-200">
{(data.ucrCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} {data.currency}/h
</div>
</div>
<div className="w-px h-6 bg-gray-200 dark:bg-gray-600" />
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">Chg%</div>
<div className="font-semibold text-gray-700 dark:text-gray-200">{data.chargeabilityTarget}%</div>
</div>
</div>
{/* Main Skills */}
{mainSkills.length > 0 && (
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider mb-1">Main Skills</div>
<div className="flex flex-wrap gap-1">
{mainSkills.map((s) => (
<span
key={s.skill}
className="inline-flex items-center px-1.5 py-0.5 rounded-md text-[11px] font-medium bg-brand-100 text-brand-700 dark:bg-brand-900/40 dark:text-brand-300"
>
{s.skill}
</span>
))}
</div>
</div>
)}
{/* Top Skills */}
{topSkills.length > 0 && (
<div>
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider mb-1">Top Skills</div>
<div className="flex flex-wrap gap-1">
{topSkills.map((s) => (
<span
key={s.skill}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-md text-[11px] bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
{s.skill}
<span className="text-[9px] text-gray-400 dark:text-gray-500">L{s.proficiency}</span>
</span>
))}
</div>
</div>
)}
{/* No skills */}
{skills.length === 0 && (
<div className="text-[11px] text-gray-400 dark:text-gray-500 italic">No skills imported yet.</div>
)}
</div>
</>
)}
</div>
);
}
@@ -145,6 +145,7 @@ export interface TimelineContextValue {
// ─ Display preferences // ─ Display preferences
displayMode: TimelineDisplayMode; displayMode: TimelineDisplayMode;
heatmapScheme: HeatmapColorScheme; heatmapScheme: HeatmapColorScheme;
blinkOverbookedDays: boolean;
// ─ Loading // ─ Loading
isLoading: boolean; isLoading: boolean;
@@ -287,6 +288,7 @@ export function TimelineProvider({
const { prefs: appPrefs } = useAppPreferences(); const { prefs: appPrefs } = useAppPreferences();
const displayMode = appPrefs.timelineDisplayMode; const displayMode = appPrefs.timelineDisplayMode;
const heatmapScheme = appPrefs.heatmapColorScheme; const heatmapScheme = appPrefs.heatmapColorScheme;
const blinkOverbookedDays = appPrefs.blinkOverbookedDays;
// ─── Data queries ────────────────────────────────────────────────────────── // ─── Data queries ──────────────────────────────────────────────────────────
const { data: entriesView, isLoading } = trpc.timeline.getEntriesView.useQuery( const { data: entriesView, isLoading } = trpc.timeline.getEntriesView.useQuery(
@@ -300,7 +302,7 @@ export function TimelineProvider({
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}), ...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
}, },
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
{ placeholderData: (prev: any) => prev }, { placeholderData: (prev: any) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
) as { data: TimelineEntriesView | undefined; isLoading: boolean }; ) as { data: TimelineEntriesView | undefined; isLoading: boolean };
@@ -309,7 +311,7 @@ export function TimelineProvider({
const { data: vacationEntries = [] } = trpc.vacation.list.useQuery( const { data: vacationEntries = [] } = trpc.vacation.list.useQuery(
{ startDate: viewStart, endDate: viewEnd, status: [VacationStatus.APPROVED, VacationStatus.PENDING], limit: 500 }, { startDate: viewStart, endDate: viewEnd, status: [VacationStatus.APPROVED, VacationStatus.PENDING], limit: 500 },
{ placeholderData: (prev) => prev }, { placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
); );
const vacationsByResource = useMemo(() => { const vacationsByResource = useMemo(() => {
@@ -593,6 +595,7 @@ export function TimelineProvider({
today, today,
displayMode, displayMode,
heatmapScheme, heatmapScheme,
blinkOverbookedDays,
isLoading, isLoading,
isInitialLoading, isInitialLoading,
totalAllocCount, totalAllocCount,
@@ -618,6 +621,7 @@ export function TimelineProvider({
today, today,
displayMode, displayMode,
heatmapScheme, heatmapScheme,
blinkOverbookedDays,
isLoading, isLoading,
isInitialLoading, isInitialLoading,
totalAllocCount, totalAllocCount,
@@ -30,15 +30,15 @@ export function TimelineHeader({
<> <>
{/* Month header */} {/* Month header */}
<div <div
className="sticky top-0 z-40 flex bg-white border-b border-gray-100" className="sticky top-0 z-40 flex bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800"
style={{ height: HEADER_MONTH_HEIGHT }} style={{ height: HEADER_MONTH_HEIGHT }}
> >
<div className="flex-shrink-0 border-r border-gray-200" style={{ width: LABEL_WIDTH }} /> <div className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700" style={{ width: LABEL_WIDTH }} />
<div className="flex"> <div className="flex">
{monthGroups.map((m, i) => ( {monthGroups.map((m, i) => (
<div <div
key={i} key={i}
className="text-xs font-semibold text-gray-500 border-r border-gray-200 px-2 flex items-center bg-gray-50" className="text-xs font-semibold text-gray-500 dark:text-gray-400 border-r border-gray-200 dark:border-gray-700 px-2 flex items-center bg-gray-50 dark:bg-gray-800"
style={{ width: m.colCount * CELL_WIDTH }} style={{ width: m.colCount * CELL_WIDTH }}
> >
{m.label} {m.label}
@@ -50,11 +50,11 @@ export function TimelineHeader({
{/* Day header — hidden at month zoom (cells too narrow for labels) */} {/* Day header — hidden at month zoom (cells too narrow for labels) */}
{zoom !== "month" && ( {zoom !== "month" && (
<div <div
className="sticky z-40 flex bg-gray-50 border-b border-gray-200 select-none" className="sticky z-40 flex bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 select-none"
style={{ top: HEADER_MONTH_HEIGHT, height: HEADER_DAY_HEIGHT }} style={{ top: HEADER_MONTH_HEIGHT, height: HEADER_DAY_HEIGHT }}
> >
<div <div
className="flex-shrink-0 border-r border-gray-200 flex items-center px-4 text-xs font-medium text-gray-400 uppercase tracking-wider" className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex items-center px-4 text-xs font-medium text-gray-400 dark:text-gray-500 uppercase tracking-wider"
style={{ width: LABEL_WIDTH }} style={{ width: LABEL_WIDTH }}
> >
{viewMode === "resource" ? "Resource" : "Project / Resource"} {viewMode === "resource" ? "Resource" : "Project / Resource"}
@@ -72,10 +72,10 @@ export function TimelineHeader({
key={i} key={i}
className={clsx( className={clsx(
"flex-shrink-0 border-r flex flex-col items-center justify-center text-xs overflow-hidden", "flex-shrink-0 border-r flex flex-col items-center justify-center text-xs overflow-hidden",
isToday ? "bg-brand-50 border-brand-200" : isToday ? "bg-brand-50 dark:bg-brand-950/40 border-brand-200 dark:border-brand-800" :
isSaturday ? "bg-amber-50/60 border-amber-200" : isSaturday ? "bg-amber-50/60 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800" :
isSunday ? "bg-gray-100/80 border-gray-200" : isSunday ? "bg-gray-100/80 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700" :
isMonday ? "border-gray-200" : "border-gray-100", isMonday ? "border-gray-200 dark:border-gray-700" : "border-gray-100 dark:border-gray-800",
)} )}
style={{ width: CELL_WIDTH, height: HEADER_DAY_HEIGHT }} style={{ width: CELL_WIDTH, height: HEADER_DAY_HEIGHT }}
> >
@@ -83,7 +83,7 @@ export function TimelineHeader({
<> <>
<span className={clsx( <span className={clsx(
"font-medium leading-none", "font-medium leading-none",
isToday ? "text-brand-600" : isSaturday ? "text-amber-600" : "text-gray-600", isToday ? "text-brand-600" : isSaturday ? "text-amber-600 dark:text-amber-400" : "text-gray-600 dark:text-gray-300",
)}> )}>
{zoom === "week" {zoom === "week"
? `${date.getDate()} ${MONTHS_SHORT[date.getMonth()]}` ? `${date.getDate()} ${MONTHS_SHORT[date.getMonth()]}`
@@ -92,7 +92,7 @@ export function TimelineHeader({
{zoom === "day" && ( {zoom === "day" && (
<span className={clsx( <span className={clsx(
"text-[9px] leading-none mt-0.5", "text-[9px] leading-none mt-0.5",
isSaturday ? "text-amber-400" : "text-gray-300", isSaturday ? "text-amber-400 dark:text-amber-500" : "text-gray-300 dark:text-gray-600",
)}> )}>
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][date.getDay()]} {["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"][date.getDay()]}
</span> </span>
@@ -12,6 +12,7 @@ import {
import { heatmapColor } from "./heatmapUtils.js"; import { heatmapColor } from "./heatmapUtils.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { formatDateLong } from "~/lib/format.js"; import { formatDateLong } from "~/lib/format.js";
import { TimelineTooltip } from "./TimelineTooltip.js";
import { import {
ROW_HEIGHT, ROW_HEIGHT,
SUB_LANE_HEIGHT, SUB_LANE_HEIGHT,
@@ -19,7 +20,7 @@ import {
PROJECT_HEADER_HEIGHT, PROJECT_HEADER_HEIGHT,
ORDER_TYPE_COLORS, ORDER_TYPE_COLORS,
} from "./timelineConstants.js"; } from "./timelineConstants.js";
import type { DragState, AllocDragState, RangeState } from "~/hooks/useTimelineDrag.js"; import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js";
import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js"; import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js";
// ─── Props ────────────────────────────────────────────────────────────────── // ─── Props ──────────────────────────────────────────────────────────────────
@@ -42,6 +43,7 @@ interface TimelineProjectPanelProps {
anchorX: number, anchorX: number,
anchorY: number, anchorY: number,
) => void; ) => void;
multiSelectState: MultiSelectState;
// Layout from useTimelineLayout // Layout from useTimelineLayout
CELL_WIDTH: number; CELL_WIDTH: number;
dates: Date[]; dates: Date[];
@@ -185,6 +187,7 @@ export function TimelineProjectPanel({
onOpenPanel, onOpenPanel,
onOpenDemandClick, onOpenDemandClick,
onAllocationContextMenu, onAllocationContextMenu,
multiSelectState,
CELL_WIDTH, CELL_WIDTH,
dates, dates,
totalCanvasWidth, totalCanvasWidth,
@@ -201,6 +204,7 @@ export function TimelineProjectPanel({
filters, filters,
displayMode, displayMode,
heatmapScheme, heatmapScheme,
blinkOverbookedDays,
activeFilterCount, activeFilterCount,
today, today,
} = useTimelineContext(); } = useTimelineContext();
@@ -411,7 +415,7 @@ export function TimelineProjectPanel({
const laneCount = assignDemandLanes(row.openDemands).size > 0 const laneCount = assignDemandLanes(row.openDemands).size > 0
? Math.max(...assignDemandLanes(row.openDemands).values()) + 1 ? Math.max(...assignDemandLanes(row.openDemands).values()) + 1
: 1; : 1;
return Math.max(ROW_HEIGHT, laneCount * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP) + 8); return Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
} }
return ROW_HEIGHT; return ROW_HEIGHT;
}, },
@@ -602,7 +606,7 @@ export function TimelineProjectPanel({
const colors = ORDER_TYPE_COLORS[project.orderType] ?? { const colors = ORDER_TYPE_COLORS[project.orderType] ?? {
bg: "bg-gray-400", bg: "bg-gray-400",
text: "text-white", text: "text-white",
light: "bg-gray-50 border-gray-200", light: "bg-gray-50 border-gray-200 dark:bg-gray-800 dark:border-gray-700",
}; };
const isThisProjectShifting = const isThisProjectShifting =
dragState.isDragging && dragState.projectId === project.id; dragState.isDragging && dragState.projectId === project.id;
@@ -620,12 +624,12 @@ export function TimelineProjectPanel({
return ( return (
<div <div
data-project-group="true" data-project-group="true"
className={clsx("flex border-b border-gray-200 group/proj", colors.light)} className={clsx("flex border-b border-gray-200 dark:border-gray-700 group/proj", colors.light)}
style={{ height: PROJECT_HEADER_HEIGHT }} style={{ height: PROJECT_HEADER_HEIGHT }}
> >
<div <div
className={clsx( className={clsx(
"flex-shrink-0 border-r border-gray-300 flex items-center px-4 gap-2.5 sticky left-0 z-30 cursor-pointer", "flex-shrink-0 border-r border-gray-300 dark:border-gray-600 flex items-center px-4 gap-2.5 sticky left-0 z-30 cursor-pointer",
colors.light, colors.light,
)} )}
style={{ width: LABEL_WIDTH }} style={{ width: LABEL_WIDTH }}
@@ -694,31 +698,39 @@ export function TimelineProjectPanel({
) : row.type === "open-demand" ? ( ) : row.type === "open-demand" ? (
renderOpenDemandRow( renderOpenDemandRow(
row.openDemands, row.openDemands,
row.projectId,
CELL_WIDTH, CELL_WIDTH,
totalCanvasWidth, totalCanvasWidth,
toLeft, toLeft,
toWidth, toWidth,
resourceRowGridStyle, resourceRowGridStyle,
onOpenDemandClick, onOpenDemandClick,
onAllocMouseDown,
onAllocTouchStart,
onAllocationContextMenu,
multiSelectState,
allocDragState,
) )
) : ( ) : (
<div <div
data-project-resource-row="true" data-project-resource-row="true"
className="flex border-b border-gray-100 hover:bg-blue-50/20 group" data-project-id={row.project.id}
data-resource-id={row.resource.id}
className="flex border-b border-gray-100 dark:border-gray-800 hover:bg-blue-50/20 dark:hover:bg-gray-800/30 group"
style={{ height: ROW_HEIGHT }} style={{ height: ROW_HEIGHT }}
> >
<div <div
className="flex-shrink-0 border-r border-gray-200 flex items-center pl-8 pr-4 gap-2 bg-white sticky left-0 z-30 group-hover:bg-blue-50" className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex items-center pl-8 pr-4 gap-2 bg-white dark:bg-gray-900 sticky left-0 z-30 group-hover:bg-blue-50 dark:group-hover:bg-gray-800"
style={{ width: LABEL_WIDTH }} style={{ width: LABEL_WIDTH }}
> >
<div className="w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center text-[10px] font-bold text-gray-600 flex-shrink-0"> <div className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-[10px] font-bold text-gray-600 dark:text-gray-300 flex-shrink-0">
{row.resource.displayName.slice(0, 2).toUpperCase()} {row.resource.displayName.slice(0, 2).toUpperCase()}
</div> </div>
<div className="min-w-0"> <div className="min-w-0" data-resource-hover-id={row.resource.id}>
<div className="text-xs font-medium text-gray-800 truncate"> <div className="text-xs font-medium text-gray-800 dark:text-gray-200 truncate cursor-pointer hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
{row.resource.displayName} {row.resource.displayName}
</div> </div>
<div className="text-[10px] text-gray-400 truncate">{row.resource.eid}</div> <div className="text-[10px] text-gray-400 dark:text-gray-500 truncate">{row.resource.eid}</div>
</div> </div>
</div> </div>
@@ -771,6 +783,7 @@ export function TimelineProjectPanel({
onAllocMouseDown, onAllocMouseDown,
onAllocTouchStart, onAllocTouchStart,
onAllocationContextMenu, onAllocationContextMenu,
multiSelectState,
)} )}
{renderVacationBlocksForProjectRow( {renderVacationBlocksForProjectRow(
vacationsByResource.get(row.resource.id) ?? [], vacationsByResource.get(row.resource.id) ?? [],
@@ -781,6 +794,12 @@ export function TimelineProjectPanel({
totalCanvasWidth, totalCanvasWidth,
filters.showVacations, filters.showVacations,
)} )}
{blinkOverbookedDays &&
renderOverbookingBlinkProject(
allocsByResource.get(row.resource.id) ?? [],
dates,
CELL_WIDTH,
)}
{renderRangeOverlayProject( {renderRangeOverlayProject(
rangeState, rangeState,
row.resource.id, row.resource.id,
@@ -796,7 +815,7 @@ export function TimelineProjectPanel({
); );
})} })}
<ProjectPanelTooltips <TimelineTooltip
heatmapTooltipRef={heatmapTooltipRef} heatmapTooltipRef={heatmapTooltipRef}
heatmapTooltipPos={heatmapTooltipPosRef.current} heatmapTooltipPos={heatmapTooltipPosRef.current}
vacationTooltipRef={vacationTooltipRef} vacationTooltipRef={vacationTooltipRef}
@@ -808,111 +827,7 @@ export function TimelineProjectPanel({
); );
} }
function ProjectPanelTooltips({ // ProjectPanelTooltips removed — now uses shared TimelineTooltip component
heatmapTooltipRef,
heatmapTooltipPos,
vacationTooltipRef,
vacationTooltipPos,
heatmapHover,
vacationHover,
}: {
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
heatmapTooltipPos: { left: number; top: number };
vacationTooltipRef: React.RefObject<HTMLDivElement | null>;
vacationTooltipPos: { left: number; top: number };
heatmapHover: {
date: Date;
totalH: number;
pct: number;
breakdown: {
projectId: string;
shortCode: string;
projectName: string;
orderType: string;
hoursPerDay: number;
responsiblePerson?: string | null;
}[];
} | null;
vacationHover: {
type: string;
startDate: Date | string;
endDate: Date | string;
note?: string | null;
requestedBy?: { name?: string | null; email: string } | null;
approvedBy?: { name?: string | null; email: string } | null;
approvedAt?: Date | string | null;
} | null;
}) {
return (
<>
{heatmapHover ? (
<div
ref={heatmapTooltipRef}
style={{
left: heatmapTooltipPos.left,
top: heatmapTooltipPos.top,
backgroundColor: "rgba(3, 7, 18, 0.96)",
}}
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
>
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
<span className="text-[11px] text-gray-300">
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
</span>
</div>
<div className="mt-2 space-y-1.5">
{heatmapHover.breakdown.length > 0 ? (
heatmapHover.breakdown.slice(0, 6).map((entry) => (
<div
key={`${entry.projectId}-${entry.shortCode}`}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0">
<div className="truncate font-medium text-white">
{entry.shortCode ? `${entry.shortCode} · ` : ""}
{entry.projectName}
</div>
<div className="truncate text-[11px] text-gray-400">
{entry.responsiblePerson
? `Lead: ${entry.responsiblePerson}`
: entry.orderType}
</div>
</div>
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
{entry.hoursPerDay}h
</span>
</div>
))
) : (
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
)}
</div>
</div>
) : null}
{vacationHover ? (
<div
ref={vacationTooltipRef}
style={{
left: vacationTooltipPos.left,
top: vacationTooltipPos.top,
backgroundColor: "rgba(120, 53, 15, 0.95)",
}}
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
>
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
<div className="mt-1 text-[11px] text-amber-100/90">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
) : null}
</div>
) : null}
</>
);
}
// ─── Pure render functions ────────────────────────────────────────────────── // ─── Pure render functions ──────────────────────────────────────────────────
@@ -949,55 +864,97 @@ function assignDemandLanes(
return laneMap; return laneMap;
} }
const DEMAND_LANE_HEIGHT = 30;
const DEMAND_LANE_GAP = 2;
function renderOpenDemandRow( function renderOpenDemandRow(
openDemands: TimelineDemandEntry[], openDemands: TimelineDemandEntry[],
projectId: string,
CELL_WIDTH: number, CELL_WIDTH: number,
totalCanvasWidth: number, totalCanvasWidth: number,
toLeft: (d: Date) => number, toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number, toWidth: (s: Date, e: Date) => number,
rowGridStyle: CSSProperties, rowGridStyle: CSSProperties,
onOpenDemandClick: (demand: OpenDemandAssignment) => void, _onOpenDemandClick: (demand: OpenDemandAssignment) => void,
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
onAllocationContextMenu: (
info: { allocationId: string; projectId: string },
anchorX: number,
anchorY: number,
) => void,
multiSelectState: MultiSelectState,
allocDragState: AllocDragState,
) { ) {
if (openDemands.length === 0) return null; if (openDemands.length === 0) return null;
const laneMap = assignDemandLanes(openDemands); const laneMap = assignDemandLanes(openDemands);
const laneCount = laneMap.size > 0 ? Math.max(...laneMap.values()) + 1 : 1; const laneCount = laneMap.size > 0 ? Math.max(...laneMap.values()) + 1 : 1;
const rowHeight = Math.max(ROW_HEIGHT, laneCount * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP) + 8); const rowHeight = Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
return ( return (
<div <div
className="flex border-b border-dashed border-amber-200 bg-amber-50/30 hover:bg-amber-50/50 group" data-project-demand-row="true"
style={{ minHeight: rowHeight }} data-project-id={projectId}
className="group relative isolate flex border-b border-dashed border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-slate-950 hover:bg-amber-100/80 dark:hover:bg-slate-900"
style={{ height: rowHeight }}
> >
<div <div
className="flex-shrink-0 border-r border-amber-200 flex items-center pl-8 pr-4 gap-2 bg-amber-50 sticky left-0 z-30" className="sticky left-0 z-30 flex flex-shrink-0 items-center gap-2 border-r border-amber-200 bg-amber-50 pl-8 pr-4 dark:border-amber-800 dark:bg-slate-950"
style={{ width: LABEL_WIDTH, minHeight: rowHeight }} style={{ width: LABEL_WIDTH, height: rowHeight }}
> >
<div className="w-6 h-6 rounded-full bg-amber-100 flex items-center justify-center text-[10px] font-bold text-amber-600 flex-shrink-0 border border-dashed border-amber-400"> <div className="pointer-events-none absolute inset-0 bg-amber-50 dark:bg-slate-950" />
? <div className="relative z-10 flex items-center gap-2 min-w-0">
</div> <div className="w-6 h-6 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center text-[10px] font-bold text-amber-600 dark:text-amber-400 flex-shrink-0 border border-dashed border-amber-400 dark:border-amber-600">
<div className="min-w-0"> ?
<div className="text-xs font-medium text-amber-700 truncate">Open demand</div> </div>
<div className="text-[10px] text-amber-500 truncate"> <div className="min-w-0">
{openDemands.length} open demand{openDemands.length > 1 ? "s" : ""} <div className="text-xs font-medium text-amber-700 dark:text-amber-400 truncate">Open demand</div>
<div className="text-[10px] text-amber-500 dark:text-amber-600 truncate">
{openDemands.length} open demand{openDemands.length > 1 ? "s" : ""}
</div>
</div> </div>
</div> </div>
</div> </div>
<div <div
className="relative overflow-hidden" className="relative overflow-hidden bg-amber-50 touch-none dark:bg-slate-950"
style={{ width: totalCanvasWidth, minHeight: rowHeight, ...rowGridStyle }} style={{ width: totalCanvasWidth, height: rowHeight, ...rowGridStyle }}
> >
<div className="pointer-events-none absolute inset-x-0 inset-y-0 border-y border-dashed border-amber-200/70 dark:border-amber-800/80" />
<div className="pointer-events-none absolute inset-x-0 inset-y-1 rounded-md bg-amber-100/25 dark:bg-amber-950/35" />
{openDemands.map((alloc) => { {openDemands.map((alloc) => {
const allocStart = new Date(alloc.startDate); const allocStart = new Date(alloc.startDate);
const allocEnd = new Date(alloc.endDate); const allocEnd = new Date(alloc.endDate);
const left = toLeft(allocStart);
const width = Math.max(CELL_WIDTH, toWidth(allocStart, allocEnd)); const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
const dispStart =
isAllocDragged && allocDragState.currentStartDate
? allocDragState.currentStartDate
: allocStart;
const dispEnd =
isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd;
// Multi-drag visual offset
const isMultiDragTarget =
multiSelectState.isMultiDragging &&
multiSelectState.selectedAllocationIds.includes(alloc.id);
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
const multiDragMode = multiSelectState.multiDragMode;
let left = toLeft(dispStart);
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
// Clamp negative left (bar starts before view) to avoid extending outside canvas
if (left < 0) {
width += left;
left = 0;
}
if (width <= 0 || left >= totalCanvasWidth) return null; if (width <= 0 || left >= totalCanvasWidth) return null;
if (isMultiDragTarget && multiDragMode === "resize-start") {
left += multiDragPx;
width = Math.max(CELL_WIDTH, width - multiDragPx);
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
width = Math.max(CELL_WIDTH, width + multiDragPx);
}
const roleEntity = ( const roleEntity = (
alloc as { roleEntity?: { id: string; name: string; color: string | null } | null } alloc as { roleEntity?: { id: string; name: string; color: string | null } | null }
).roleEntity; ).roleEntity;
@@ -1006,39 +963,99 @@ function renderOpenDemandRow(
const roleColor = roleEntity?.color ?? "#f59e0b"; const roleColor = roleEntity?.color ?? "#f59e0b";
const headcount = (alloc as { headcount?: number }).headcount ?? 1; const headcount = (alloc as { headcount?: number }).headcount ?? 1;
const lane = laneMap.get(alloc.id) ?? 0; const lane = laneMap.get(alloc.id) ?? 0;
const top = 4 + lane * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP); const top = 8 + lane * SUB_LANE_HEIGHT;
const blockHeight = SUB_LANE_HEIGHT - 8;
const HANDLE_W = width >= 48 ? 8 : 6;
const allocInfo: AllocMouseDownInfo = {
mode: "move",
allocationId: alloc.id,
mutationAllocationId: getPlanningEntryMutationId(alloc),
projectId: alloc.projectId,
projectName: alloc.project.name,
resourceId: null,
startDate: allocStart,
endDate: allocEnd,
};
return ( return (
<div <div
key={alloc.id} key={alloc.id}
className="absolute rounded-md flex items-center px-2 gap-1 overflow-hidden cursor-pointer hover:ring-2 hover:ring-amber-400 hover:ring-offset-1 z-[10]" className={clsx(
"absolute rounded-md flex items-stretch overflow-hidden z-[10] group/demand",
isAllocDragged
? "ring-2 ring-amber-500 z-20"
: "hover:ring-2 hover:ring-amber-400 hover:ring-offset-1",
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
)}
title={`${roleName}${headcount > 1 ? ` x${headcount}` : ""} · ${alloc.hoursPerDay}h/day · ${formatDateLong(allocStart)} ${formatDateLong(allocEnd)}`}
style={{ style={{
left: left + 2, left: left + 2,
width: width - 4, width: width - 4,
top, top,
height: DEMAND_LANE_HEIGHT, height: blockHeight,
backgroundColor: `${roleColor}33`, backgroundColor: `${roleColor}4D`,
border: `2px dashed ${roleColor}99`, border: `2px dashed ${roleColor}B3`,
...(multiDragPx && multiDragMode === "move"
? { transform: `translateX(${multiDragPx}px)` }
: {}),
}} }}
onClick={() => { onMouseDown={(e) => {
onOpenDemandClick({ if (e.button === 2) e.stopPropagation();
id: getPlanningEntryMutationId(alloc), }}
projectId: alloc.projectId, onContextMenu={(e) => {
roleId: (alloc as { roleId?: string | null }).roleId ?? null, e.preventDefault();
role: (alloc as { role?: string | null }).role ?? null, e.stopPropagation();
headcount, onAllocationContextMenu(
startDate: allocStart, { allocationId: alloc.id, projectId: alloc.projectId },
endDate: allocEnd, e.clientX,
hoursPerDay: alloc.hoursPerDay, e.clientY,
roleEntity: roleEntity ?? null, );
project: alloc.project as { id: string; name: string; shortCode: string },
});
}} }}
> >
<span className="text-xs font-medium truncate" style={{ color: roleColor }}> {/* Left resize handle */}
{roleName} <div
{headcount > 1 ? ` x${headcount}` : ""} className="flex-shrink-0 cursor-ew-resize hover:bg-black/10"
</span> style={{ width: HANDLE_W }}
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
}}
/>
{/* Center — move + click */}
<div
className={clsx(
"flex-1 min-w-0 flex items-center px-1 gap-1",
isAllocDragged ? "cursor-grabbing" : "cursor-grab",
)}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, allocInfo);
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, allocInfo);
}}
>
<span className="text-xs font-medium truncate" style={{ color: roleColor }}>
{roleName}
{headcount > 1 ? ` x${headcount}` : ""}
</span>
</div>
{/* Right resize handle */}
<div
className="flex-shrink-0 cursor-ew-resize hover:bg-black/10"
style={{ width: HANDLE_W }}
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
}}
/>
</div> </div>
); );
})} })}
@@ -1157,6 +1174,7 @@ function renderProjectDragHandles(
anchorX: number, anchorX: number,
anchorY: number, anchorY: number,
) => void, ) => void,
multiSelectState: MultiSelectState,
) { ) {
return allocs.map((alloc) => { return allocs.map((alloc) => {
const allocStart = new Date(alloc.startDate); const allocStart = new Date(alloc.startDate);
@@ -1170,10 +1188,24 @@ function renderProjectDragHandles(
const dispEnd = const dispEnd =
isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd; isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd;
const left = toLeft(dispStart); let left = toLeft(dispStart);
const width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd)); let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
if (width <= 0 || left >= totalCanvasWidth) return null; if (width <= 0 || left >= totalCanvasWidth) return null;
// Multi-drag visual offset
const isMultiDragTarget =
multiSelectState.isMultiDragging &&
multiSelectState.selectedAllocationIds.includes(alloc.id);
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
const multiDragMode = multiSelectState.multiDragMode;
if (isMultiDragTarget && multiDragMode === "resize-start") {
left += multiDragPx;
width = Math.max(CELL_WIDTH, width - multiDragPx);
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
width = Math.max(CELL_WIDTH, width + multiDragPx);
}
// Always show resize handles — for narrow bars, use overlapping handles // Always show resize handles — for narrow bars, use overlapping handles
const HANDLE_W = width >= 48 ? 8 : 6; const HANDLE_W = width >= 48 ? 8 : 6;
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence; const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence;
@@ -1198,8 +1230,20 @@ function renderProjectDragHandles(
isAllocDragged isAllocDragged
? "ring-2 ring-brand-400 z-20" ? "ring-2 ring-brand-400 z-20"
: "hover:ring-1 hover:ring-brand-300/70 z-[15]", : "hover:ring-1 hover:ring-brand-300/70 z-[15]",
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
)} )}
style={{ left: left + 2, width: width - 4, top: 2, bottom: 2 }} style={{
left: left + 2,
width: width - 4,
top: 2,
bottom: 2,
...(multiDragPx && multiDragMode === "move"
? { transform: `translateX(${multiDragPx}px)` }
: {}),
}}
onMouseDown={(e) => {
if (e.button === 2) e.stopPropagation();
}}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -1315,6 +1359,40 @@ function renderVacationBlocksForProjectRow(
// ─── Range overlay for project view ───────────────────────────────────────── // ─── Range overlay for project view ─────────────────────────────────────────
function renderOverbookingBlinkProject(
allocs: TimelineAssignmentEntry[],
dates: Date[],
CELL_WIDTH: number,
) {
const REF_H = 8;
const overbooked: number[] = [];
for (let i = 0; i < dates.length; i++) {
const d = new Date(dates[i]!);
d.setHours(0, 0, 0, 0);
const t = d.getTime();
let totalH = 0;
for (const a of allocs) {
const s = new Date(a.startDate);
s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate);
e.setHours(0, 0, 0, 0);
if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay;
}
if (totalH > REF_H) overbooked.push(i);
}
if (overbooked.length === 0) return null;
return overbooked.map((i) => (
<div
key={`ob-${i}`}
className="absolute top-0 bottom-0 pointer-events-none z-[15] animate-overbooking-blink"
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH }}
/>
));
}
function renderRangeOverlayProject( function renderRangeOverlayProject(
rangeState: RangeState, rangeState: RangeState,
resourceId: string, resourceId: string,
@@ -12,7 +12,7 @@ import { ConflictOverlay } from "./ConflictOverlay.js";
import { computeSubLanes } from "./utils.js"; import { computeSubLanes } from "./utils.js";
import { heatmapBgColor, heatmapColor } from "./heatmapUtils.js"; import { heatmapBgColor, heatmapColor } from "./heatmapUtils.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { formatDateLong } from "~/lib/format.js"; import { TimelineTooltip } from "./TimelineTooltip.js";
import { import {
ROW_HEIGHT, ROW_HEIGHT,
SUB_LANE_HEIGHT, SUB_LANE_HEIGHT,
@@ -24,6 +24,7 @@ import type {
AllocDragState, AllocDragState,
RangeState, RangeState,
ShiftPreviewData, ShiftPreviewData,
MultiSelectState,
} from "~/hooks/useTimelineDrag.js"; } from "~/hooks/useTimelineDrag.js";
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js"; import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
@@ -45,6 +46,7 @@ interface TimelineResourcePanelProps {
anchorX: number, anchorX: number,
anchorY: number, anchorY: number,
) => void; ) => void;
multiSelectState: MultiSelectState;
// Layout from useTimelineLayout // Layout from useTimelineLayout
CELL_WIDTH: number; CELL_WIDTH: number;
dates: Date[]; dates: Date[];
@@ -86,6 +88,7 @@ export function TimelineResourcePanel({
onRowMouseDown, onRowMouseDown,
onRowTouchStart, onRowTouchStart,
onAllocationContextMenu, onAllocationContextMenu,
multiSelectState,
CELL_WIDTH, CELL_WIDTH,
dates, dates,
totalCanvasWidth, totalCanvasWidth,
@@ -103,6 +106,7 @@ export function TimelineResourcePanel({
viewEnd, viewEnd,
displayMode, displayMode,
heatmapScheme, heatmapScheme,
blinkOverbookedDays,
activeFilterCount, activeFilterCount,
} = useTimelineContext(); } = useTimelineContext();
@@ -407,7 +411,7 @@ export function TimelineResourcePanel({
> >
<div <div
className={clsx( className={clsx(
"flex border-b border-gray-100 hover:bg-blue-50/20 group transition-colors", "flex border-b border-gray-100 dark:border-gray-800 hover:bg-blue-50/20 dark:hover:bg-gray-800/30 group transition-colors",
dragState.isDragging && isContextResource && "border-l-4 border-l-brand-400", dragState.isDragging && isContextResource && "border-l-4 border-l-brand-400",
)} )}
style={{ height: rowHeight }} style={{ height: rowHeight }}
@@ -415,19 +419,19 @@ export function TimelineResourcePanel({
{/* Label column */} {/* Label column */}
<div <div
className={clsx( className={clsx(
"flex-shrink-0 border-r border-gray-200 flex items-center px-4 gap-2.5 bg-white sticky left-0 z-30 group-hover:bg-blue-50", "flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex items-center px-4 gap-2.5 bg-white dark:bg-gray-900 sticky left-0 z-30 group-hover:bg-blue-50 dark:group-hover:bg-gray-800",
dragState.isDragging && isContextResource && "bg-brand-50", dragState.isDragging && isContextResource && "bg-brand-50 dark:bg-brand-950/40",
)} )}
style={{ width: LABEL_WIDTH }} style={{ width: LABEL_WIDTH }}
> >
<div className="w-8 h-8 rounded-full bg-brand-100 flex items-center justify-center text-xs font-bold text-brand-700 flex-shrink-0"> <div className="w-8 h-8 rounded-full bg-brand-100 dark:bg-brand-900/40 flex items-center justify-center text-xs font-bold text-brand-700 dark:text-brand-300 flex-shrink-0">
{resource.displayName.slice(0, 2).toUpperCase()} {resource.displayName.slice(0, 2).toUpperCase()}
</div> </div>
<div className="min-w-0"> <div className="min-w-0" data-resource-hover-id={resource.id}>
<div className="text-sm font-medium text-gray-900 truncate"> <div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate cursor-pointer hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
{resource.displayName} {resource.displayName}
</div> </div>
<div className="text-xs text-gray-400 truncate"> <div className="text-xs text-gray-400 dark:text-gray-500 truncate">
{resource.chapter ?? resource.eid} {resource.chapter ?? resource.eid}
</div> </div>
</div> </div>
@@ -467,6 +471,7 @@ export function TimelineResourcePanel({
toLeft, toLeft,
toWidth, toWidth,
totalCanvasWidth, totalCanvasWidth,
multiSelectState,
) )
: renderAllocBlocksFromData( : renderAllocBlocksFromData(
precomputed?.blockData ?? [], precomputed?.blockData ?? [],
@@ -480,6 +485,7 @@ export function TimelineResourcePanel({
onAllocMouseDown, onAllocMouseDown,
onAllocTouchStart, onAllocTouchStart,
onAllocationContextMenu, onAllocationContextMenu,
multiSelectState,
)} )}
{renderVacationBlocksForRow( {renderVacationBlocksForRow(
vacationBlocksByResource.get(resource.id) ?? [], vacationBlocksByResource.get(resource.id) ?? [],
@@ -488,6 +494,8 @@ export function TimelineResourcePanel({
{displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)} {displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)}
{displayMode === "heatmap" && {displayMode === "heatmap" &&
renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)} renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)}
{blinkOverbookedDays &&
renderOverbookingBlink(allocs, dates, CELL_WIDTH)}
{renderRangeOverlay( {renderRangeOverlay(
rangeState, rangeState,
resource.id, resource.id,
@@ -523,7 +531,7 @@ export function TimelineResourcePanel({
})} })}
{/* Tooltips rendered inside the panel so they live near their data source */} {/* Tooltips rendered inside the panel so they live near their data source */}
<ResourcePanelTooltips <TimelineTooltip
heatmapTooltipRef={heatmapTooltipRef} heatmapTooltipRef={heatmapTooltipRef}
heatmapTooltipPos={heatmapTooltipPosRef.current} heatmapTooltipPos={heatmapTooltipPosRef.current}
vacationTooltipRef={vacationTooltipRef} vacationTooltipRef={vacationTooltipRef}
@@ -535,113 +543,7 @@ export function TimelineResourcePanel({
); );
} }
// ─── Tooltip sub-component (portal-free: positioned fixed) ────────────────── // ResourcePanelTooltips removed — now uses shared TimelineTooltip component
function ResourcePanelTooltips({
heatmapTooltipRef,
heatmapTooltipPos,
vacationTooltipRef,
vacationTooltipPos,
heatmapHover,
vacationHover,
}: {
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
heatmapTooltipPos: { left: number; top: number };
vacationTooltipRef: React.RefObject<HTMLDivElement | null>;
vacationTooltipPos: { left: number; top: number };
heatmapHover: {
date: Date;
totalH: number;
pct: number;
breakdown: {
projectId: string;
shortCode: string;
projectName: string;
orderType: string;
hoursPerDay: number;
responsiblePerson?: string | null;
}[];
} | null;
vacationHover: {
type: string;
startDate: Date | string;
endDate: Date | string;
note?: string | null;
requestedBy?: { name?: string | null; email: string } | null;
approvedBy?: { name?: string | null; email: string } | null;
approvedAt?: Date | string | null;
} | null;
}) {
return (
<>
{heatmapHover ? (
<div
ref={heatmapTooltipRef}
style={{
left: heatmapTooltipPos.left,
top: heatmapTooltipPos.top,
backgroundColor: "rgba(3, 7, 18, 0.96)",
}}
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
>
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
<span className="text-[11px] text-gray-300">
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
</span>
</div>
<div className="mt-2 space-y-1.5">
{heatmapHover.breakdown.length > 0 ? (
heatmapHover.breakdown.slice(0, 6).map((entry) => (
<div
key={`${entry.projectId}-${entry.shortCode}`}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0">
<div className="truncate font-medium text-white">
{entry.shortCode ? `${entry.shortCode} · ` : ""}
{entry.projectName}
</div>
<div className="truncate text-[11px] text-gray-400">
{entry.responsiblePerson
? `Lead: ${entry.responsiblePerson}`
: entry.orderType}
</div>
</div>
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
{entry.hoursPerDay}h
</span>
</div>
))
) : (
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
)}
</div>
</div>
) : null}
{vacationHover ? (
<div
ref={vacationTooltipRef}
style={{
left: vacationTooltipPos.left,
top: vacationTooltipPos.top,
backgroundColor: "rgba(120, 53, 15, 0.95)",
}}
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
>
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
<div className="mt-1 text-[11px] text-amber-100/90">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
) : null}
</div>
) : null}
</>
);
}
// ─── Helper types ─────────────────────────────────────────────────────────── // ─── Helper types ───────────────────────────────────────────────────────────
@@ -749,6 +651,7 @@ function renderAllocBlocksFromData(
anchorX: number, anchorX: number,
anchorY: number, anchorY: number,
) => void, ) => void,
multiSelectState: MultiSelectState,
) { ) {
const anyDragActive = dragState.isDragging || allocDragState.isActive; const anyDragActive = dragState.isDragging || allocDragState.isActive;
@@ -771,8 +674,23 @@ function renderAllocBlocksFromData(
dispEnd = allocDragState.currentEndDate; dispEnd = allocDragState.currentEndDate;
} }
const left = toLeft(dispStart); // Multi-drag offset: shift selected allocations visually during multi-drag
const width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd)); const isMultiDragTarget =
multiSelectState.isMultiDragging &&
multiSelectState.selectedAllocationIds.includes(alloc.id);
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
const multiDragMode = multiSelectState.multiDragMode;
let left = toLeft(dispStart);
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
// For multi-drag resize, adjust left/width instead of using translateX
if (isMultiDragTarget && multiDragMode === "resize-start") {
left += multiDragPx;
width = Math.max(CELL_WIDTH, width - multiDragPx);
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
width = Math.max(CELL_WIDTH, width + multiDragPx);
}
if (width <= 0 || left >= totalCanvasWidth) return null; if (width <= 0 || left >= totalCanvasWidth) return null;
const blockTop = 8 + lane * SUB_LANE_HEIGHT; const blockTop = 8 + lane * SUB_LANE_HEIGHT;
@@ -811,6 +729,7 @@ function renderAllocBlocksFromData(
: isOtherDragged : isOtherDragged
? "opacity-30 z-[10]" ? "opacity-30 z-[10]"
: "hover:ring-2 hover:ring-white hover:ring-offset-1 z-[10]", : "hover:ring-2 hover:ring-white hover:ring-offset-1 z-[10]",
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
)} )}
style={{ style={{
left: left + 2, left: left + 2,
@@ -818,6 +737,12 @@ function renderAllocBlocksFromData(
top: blockTop, top: blockTop,
height: blockHeight, height: blockHeight,
...(customColor ? { backgroundColor: customColor } : {}), ...(customColor ? { backgroundColor: customColor } : {}),
...(multiDragPx && multiDragMode === "move" ? { transform: `translateX(${multiDragPx}px)` } : {}),
}}
onMouseDown={(e) => {
// Stop right-click mouseDown from bubbling to the canvas,
// which would falsely start a multi-selection rectangle.
if (e.button === 2) e.stopPropagation();
}} }}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
@@ -965,6 +890,45 @@ function renderHeatmapOverlay(
}); });
} }
// ─── Overbooking blink overlay ───────────────────────────────────────────────
function renderOverbookingBlink(
allocs: TimelineAssignmentEntry[],
dates: Date[],
CELL_WIDTH: number,
) {
const REF_H = 8;
const overbooked: number[] = [];
for (let i = 0; i < dates.length; i++) {
const d = new Date(dates[i]!);
d.setHours(0, 0, 0, 0);
const t = d.getTime();
let totalH = 0;
for (const a of allocs) {
const s = new Date(a.startDate);
s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate);
e.setHours(0, 0, 0, 0);
if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay;
}
if (totalH > REF_H) overbooked.push(i);
}
if (overbooked.length === 0) return null;
return overbooked.map((i) => (
<div
key={`ob-${i}`}
className="absolute top-0 bottom-0 pointer-events-none z-[15] animate-overbooking-blink"
style={{
left: i * CELL_WIDTH,
width: CELL_WIDTH,
}}
/>
));
}
// ─── Bar-mode: stacked daily bars ──────────────────────────────────────────── // ─── Bar-mode: stacked daily bars ────────────────────────────────────────────
function renderDailyBars( function renderDailyBars(
@@ -983,6 +947,7 @@ function renderDailyBars(
toLeft: (d: Date) => number, toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number, toWidth: (s: Date, e: Date) => number,
totalCanvasWidth: number, totalCanvasWidth: number,
multiSelectState: MultiSelectState,
) { ) {
const BAR_AREA = rowHeight - 8; const BAR_AREA = rowHeight - 8;
const REF_H = 8; const REF_H = 8;
@@ -1061,8 +1026,21 @@ function renderDailyBars(
isBeingDragged isBeingDragged
? "opacity-90 ring-2 ring-white ring-offset-1 z-20" ? "opacity-90 ring-2 ring-white ring-offset-1 z-20"
: "hover:opacity-80 z-[10]", : "hover:opacity-80 z-[10]",
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
)} )}
style={{ left: i * CELL_WIDTH + 2, width: CELL_WIDTH - 4, height: segH, bottom }} style={{
left: i * CELL_WIDTH + 2,
width: CELL_WIDTH - 4,
height: segH,
bottom,
...(multiSelectState.isMultiDragging &&
multiSelectState.selectedAllocationIds.includes(alloc.id)
? { transform: `translateX(${multiSelectState.multiDragDaysDelta * CELL_WIDTH}px)` }
: {}),
}}
onMouseDown={(e) => {
if (e.button === 2) e.stopPropagation();
}}
onContextMenu={(e) => { onContextMenu={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -0,0 +1,191 @@
"use client";
import { formatDateLong } from "~/lib/format.js";
export type HeatmapHoverData = {
date: Date;
totalH: number;
pct: number;
breakdown: {
projectId: string;
shortCode: string;
projectName: string;
orderType: string;
hoursPerDay: number;
responsiblePerson?: string | null;
}[];
};
export type VacationHoverData = {
type: string;
startDate: Date | string;
endDate: Date | string;
note?: string | null;
requestedBy?: { name?: string | null; email: string } | null;
approvedBy?: { name?: string | null; email: string } | null;
approvedAt?: Date | string | null;
};
interface TimelineTooltipProps {
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
heatmapTooltipPos: { left: number; top: number };
vacationTooltipRef: React.RefObject<HTMLDivElement | null>;
vacationTooltipPos: { left: number; top: number };
heatmapHover: HeatmapHoverData | null;
vacationHover: VacationHoverData | null;
}
export function TimelineTooltip({
heatmapTooltipRef,
heatmapTooltipPos,
vacationTooltipRef,
vacationTooltipPos,
heatmapHover,
vacationHover,
}: TimelineTooltipProps) {
// When both are active, render a single merged tooltip using the heatmap position
if (heatmapHover && vacationHover) {
return (
<div
ref={(el) => {
// Wire both refs to the same element so position updates work from either handler
(heatmapTooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
(vacationTooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
}}
style={{
left: heatmapTooltipPos.left,
top: heatmapTooltipPos.top,
backgroundColor: "rgba(3, 7, 18, 0.96)",
}}
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
>
{/* Date + hours header */}
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
<span className="text-[11px] text-gray-300">
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
</span>
</div>
{/* Project breakdown */}
<div className="mt-2 space-y-1.5">
{heatmapHover.breakdown.length > 0 ? (
heatmapHover.breakdown.slice(0, 6).map((entry) => (
<div
key={`${entry.projectId}-${entry.shortCode}`}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0">
<div className="truncate font-medium text-white">
{entry.shortCode ? `${entry.shortCode} · ` : ""}
{entry.projectName}
</div>
<div className="truncate text-[11px] text-gray-400">
{entry.responsiblePerson
? `Lead: ${entry.responsiblePerson}`
: entry.orderType}
</div>
</div>
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
{entry.hoursPerDay}h
</span>
</div>
))
) : (
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
)}
</div>
{/* Vacation section — merged below */}
<div className="mt-2 pt-2 border-t border-amber-700/40">
<div className="flex items-center gap-1.5">
<span className="inline-block w-2 h-2 rounded-full bg-amber-500 flex-shrink-0" />
<span className="font-semibold text-amber-300">
{vacationHover.type.replaceAll("_", " ")}
</span>
</div>
<div className="mt-0.5 text-[11px] text-amber-200/80">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
<div className="mt-1 text-[11px] text-amber-200/60">{vacationHover.note}</div>
) : null}
</div>
</div>
);
}
// Heatmap only
if (heatmapHover) {
return (
<div
ref={heatmapTooltipRef}
style={{
left: heatmapTooltipPos.left,
top: heatmapTooltipPos.top,
backgroundColor: "rgba(3, 7, 18, 0.96)",
}}
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
>
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
<span className="text-[11px] text-gray-300">
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
</span>
</div>
<div className="mt-2 space-y-1.5">
{heatmapHover.breakdown.length > 0 ? (
heatmapHover.breakdown.slice(0, 6).map((entry) => (
<div
key={`${entry.projectId}-${entry.shortCode}`}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0">
<div className="truncate font-medium text-white">
{entry.shortCode ? `${entry.shortCode} · ` : ""}
{entry.projectName}
</div>
<div className="truncate text-[11px] text-gray-400">
{entry.responsiblePerson
? `Lead: ${entry.responsiblePerson}`
: entry.orderType}
</div>
</div>
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
{entry.hoursPerDay}h
</span>
</div>
))
) : (
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
)}
</div>
</div>
);
}
// Vacation only
if (vacationHover) {
return (
<div
ref={vacationTooltipRef}
style={{
left: vacationTooltipPos.left,
top: vacationTooltipPos.top,
backgroundColor: "rgba(120, 53, 15, 0.95)",
}}
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
>
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
<div className="mt-1 text-[11px] text-amber-100/90">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
) : null}
</div>
);
}
return null;
}
+498 -19
View File
@@ -1,13 +1,19 @@
"use client"; "use client";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useAllocationHistory } from "~/hooks/useAllocationHistory.js"; import { useAllocationHistory } from "~/hooks/useAllocationHistory.js";
import { useProjectDragContext } from "~/hooks/useProjectDragContext.js"; import { useProjectDragContext } from "~/hooks/useProjectDragContext.js";
import { useTimelineDrag } from "~/hooks/useTimelineDrag.js"; import { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
import { useTimelineLayout } from "~/hooks/useTimelineLayout.js"; import { useTimelineLayout } from "~/hooks/useTimelineLayout.js";
import { trpc } from "~/lib/trpc/client.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js"; import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationPopover } from "./AllocationPopover.js"; import { AllocationPopover } from "./AllocationPopover.js";
import { DemandPopover } from "./DemandPopover.js";
import { ResourceHoverCard } from "./ResourceHoverCard.js";
import type { TimelineDemandEntry } from "./TimelineContext.js";
import { BatchAssignPopover } from "./BatchAssignPopover.js";
import { FloatingActionBar } from "./FloatingActionBar.js";
import { NewAllocationPopover } from "./NewAllocationPopover.js"; import { NewAllocationPopover } from "./NewAllocationPopover.js";
import { ProjectPanel } from "./ProjectPanel.js"; import { ProjectPanel } from "./ProjectPanel.js";
import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js"; import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js";
@@ -31,9 +37,11 @@ import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProje
export function TimelineView() { export function TimelineView() {
const mousePosRef = useRef({ x: 0, y: 0 }); const mousePosRef = useRef({ x: 0, y: 0 });
const { push: pushHistory, undo, redo, canUndo, canRedo } = useAllocationHistory(); const { push: pushHistory, pushBatch: pushBatchHistory, undo, redo, canUndo, canRedo } = useAllocationHistory();
const pushHistoryRef = useRef(pushHistory); const pushHistoryRef = useRef(pushHistory);
pushHistoryRef.current = pushHistory; pushHistoryRef.current = pushHistory;
const pushBatchHistoryRef = useRef(pushBatchHistory);
pushBatchHistoryRef.current = pushBatchHistory;
const [popover, setPopover] = useState<{ const [popover, setPopover] = useState<{
allocationId: string; allocationId: string;
@@ -48,6 +56,10 @@ export function TimelineView() {
suggestedProjectId: string | null; suggestedProjectId: string | null;
anchorX: number; anchorX: number;
anchorY: number; anchorY: number;
/** Selection coordinates to keep the overlay visible while popover is open */
selectionResourceId: string;
selectionStart: Date;
selectionEnd: Date;
} | null>(null); } | null>(null);
// cellWidth placeholder — the real value comes from useTimelineLayout inside the content. // cellWidth placeholder — the real value comes from useTimelineLayout inside the content.
@@ -55,10 +67,22 @@ export function TimelineView() {
// We start with 40 (day zoom default) and update via a ref. // We start with 40 (day zoom default) and update via a ref.
const cellWidthRef = useRef(40); const cellWidthRef = useRef(40);
const outerUtils = trpc.useUtils();
const batchShiftMutationOuter = trpc.timeline.batchShiftAllocations.useMutation({
onSuccess: () => {
void outerUtils.timeline.getEntries.invalidate();
void outerUtils.timeline.getEntriesView.invalidate();
void outerUtils.timeline.getProjectContext.invalidate();
void outerUtils.timeline.getBudgetStatus.invalidate();
},
});
const { const {
dragState, dragState,
allocDragState, allocDragState,
rangeState, rangeState,
multiSelectState,
setMultiSelectState,
shiftPreview, shiftPreview,
isPreviewLoading, isPreviewLoading,
isApplying, isApplying,
@@ -69,6 +93,8 @@ export function TimelineView() {
onCanvasMouseMove, onCanvasMouseMove,
onCanvasMouseUp, onCanvasMouseUp,
onCanvasMouseLeave, onCanvasMouseLeave,
onCanvasRightMouseDown,
clearMultiSelect,
onProjectBarTouchStart, onProjectBarTouchStart,
onAllocTouchStart, onAllocTouchStart,
onRowTouchStart, onRowTouchStart,
@@ -92,11 +118,33 @@ export function TimelineView() {
suggestedProjectId: info.suggestedProjectId, suggestedProjectId: info.suggestedProjectId,
anchorX: info.anchorX, anchorX: info.anchorX,
anchorY: info.anchorY, anchorY: info.anchorY,
selectionResourceId: info.resourceId,
selectionStart: info.startDate,
selectionEnd: info.endDate,
}); });
}, },
onAllocationMoved: (snapshot) => { onAllocationMoved: (snapshot) => {
pushHistoryRef.current(snapshot); pushHistoryRef.current(snapshot);
}, },
onShiftClickAlloc: (allocationId: string) => {
setMultiSelectState(prev => {
const ids = new Set(prev.selectedAllocationIds);
if (ids.has(allocationId)) {
ids.delete(allocationId);
} else {
ids.add(allocationId);
}
return { ...prev, isSelecting: false, selectedAllocationIds: [...ids] };
});
},
onMultiDragComplete: (daysDelta, mode) => {
const ids = multiSelectState.selectedAllocationIds;
if (ids.length > 0 && daysDelta !== 0) {
pushBatchHistoryRef.current(ids, daysDelta, mode);
batchShiftMutationOuter.mutate({ allocationIds: ids, daysDelta, mode });
clearMultiSelect();
}
},
}); });
const [openPanelProjectId, setOpenPanelProjectId] = useState<string | null>(null); const [openPanelProjectId, setOpenPanelProjectId] = useState<string | null>(null);
@@ -115,6 +163,10 @@ export function TimelineView() {
dragState={dragState} dragState={dragState}
allocDragState={allocDragState} allocDragState={allocDragState}
rangeState={rangeState} rangeState={rangeState}
multiSelectState={multiSelectState}
setMultiSelectState={setMultiSelectState}
onCanvasRightMouseDown={onCanvasRightMouseDown}
clearMultiSelect={clearMultiSelect}
shiftPreview={shiftPreview} shiftPreview={shiftPreview}
isPreviewLoading={isPreviewLoading} isPreviewLoading={isPreviewLoading}
isApplying={isApplying} isApplying={isApplying}
@@ -154,6 +206,10 @@ function TimelineViewContent({
dragState, dragState,
allocDragState, allocDragState,
rangeState, rangeState,
multiSelectState,
setMultiSelectState,
onCanvasRightMouseDown,
clearMultiSelect,
shiftPreview, shiftPreview,
isPreviewLoading, isPreviewLoading,
isApplying, isApplying,
@@ -186,6 +242,10 @@ function TimelineViewContent({
dragState: ReturnType<typeof useTimelineDrag>["dragState"]; dragState: ReturnType<typeof useTimelineDrag>["dragState"];
allocDragState: ReturnType<typeof useTimelineDrag>["allocDragState"]; allocDragState: ReturnType<typeof useTimelineDrag>["allocDragState"];
rangeState: ReturnType<typeof useTimelineDrag>["rangeState"]; rangeState: ReturnType<typeof useTimelineDrag>["rangeState"];
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
setMultiSelectState: ReturnType<typeof useTimelineDrag>["setMultiSelectState"];
onCanvasRightMouseDown: ReturnType<typeof useTimelineDrag>["onCanvasRightMouseDown"];
clearMultiSelect: ReturnType<typeof useTimelineDrag>["clearMultiSelect"];
shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"]; shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"];
isPreviewLoading: boolean; isPreviewLoading: boolean;
isApplying: boolean; isApplying: boolean;
@@ -211,6 +271,9 @@ function TimelineViewContent({
suggestedProjectId: string | null; suggestedProjectId: string | null;
anchorX: number; anchorX: number;
anchorY: number; anchorY: number;
selectionResourceId: string;
selectionStart: Date;
selectionEnd: Date;
} | null; } | null;
setNewAllocPopover: React.Dispatch<React.SetStateAction<typeof newAllocPopover>>; setNewAllocPopover: React.Dispatch<React.SetStateAction<typeof newAllocPopover>>;
openPanelProjectId: string | null; openPanelProjectId: string | null;
@@ -224,6 +287,8 @@ function TimelineViewContent({
const { const {
resources, resources,
projectGroups, projectGroups,
allocsByResource,
openDemandsByProject,
viewStart, viewStart,
viewEnd, viewEnd,
viewDays, viewDays,
@@ -248,12 +313,69 @@ function TimelineViewContent({
const dragTooltipRef = useRef<HTMLDivElement>(null); const dragTooltipRef = useRef<HTMLDivElement>(null);
const allocTooltipRef = useRef<HTMLDivElement>(null); const allocTooltipRef = useRef<HTMLDivElement>(null);
const rangeHintRef = useRef<HTMLDivElement>(null); const rangeHintRef = useRef<HTMLDivElement>(null);
const multiDragTooltipRef = useRef<HTMLDivElement>(null);
const [openDemandToAssign, setOpenDemandToAssign] = useState<OpenDemandAssignment | null>(null); const [openDemandToAssign, setOpenDemandToAssign] = useState<OpenDemandAssignment | null>(null);
const [demandPopover, setDemandPopover] = useState<{
demand: TimelineDemandEntry;
x: number;
y: number;
} | null>(null);
const [showBatchAssign, setShowBatchAssign] = useState(false);
const [resourceHover, setResourceHover] = useState<{
resourceId: string;
anchorEl: HTMLElement;
} | null>(null);
const resourceHoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const utils = trpc.useUtils();
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
onSuccess: () => {
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
clearMultiSelect();
},
});
const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } = const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } =
useTimelineLayout(viewStart, viewDays, filters.zoom, filters.showWeekends, today); useTimelineLayout(viewStart, viewDays, filters.zoom, filters.showWeekends, today);
const hasActivePointerOverlay = const hasActivePointerOverlay =
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting; dragState.isDragging || allocDragState.isActive || rangeState.isSelecting || multiSelectState.isMultiDragging;
// ─── Keep selection overlay visible while popover is open ───────────────────
const effectiveRangeState: typeof rangeState = rangeState.isSelecting
? rangeState
: newAllocPopover
? {
isSelecting: true,
resourceId: newAllocPopover.selectionResourceId,
startDate: newAllocPopover.selectionStart,
currentDate: newAllocPopover.selectionEnd,
suggestedProjectId: newAllocPopover.suggestedProjectId,
startClientX: 0,
}
: rangeState;
// ─── Auto-suggest project for resource-view range select ───────────────────
const enrichedSuggestedProjectId = useMemo(() => {
if (!newAllocPopover) return null;
// Already has a suggestion (e.g. from project view)
if (newAllocPopover.suggestedProjectId) return newAllocPopover.suggestedProjectId;
// Resource view: find the project with the most hours in this resource's row
const allocs = allocsByResource.get(newAllocPopover.resourceId);
if (!allocs || allocs.length === 0) return null;
const projectHours = new Map<string, number>();
for (const alloc of allocs) {
projectHours.set(alloc.projectId, (projectHours.get(alloc.projectId) ?? 0) + alloc.hoursPerDay);
}
let maxPid: string | null = null;
let maxH = 0;
for (const [pid, h] of projectHours) {
if (h > maxH) { maxH = h; maxPid = pid; }
}
return maxPid;
}, [newAllocPopover, allocsByResource]);
function openAllocationPopoverAt( function openAllocationPopoverAt(
info: { info: {
@@ -263,6 +385,13 @@ function TimelineViewContent({
anchorX: number, anchorX: number,
anchorY: number, anchorY: number,
) { ) {
// Check if this is a demand (not an assignment) — route to DemandPopover
const demands = openDemandsByProject.get(info.projectId);
const demand = demands?.find((d) => d.id === info.allocationId);
if (demand) {
setDemandPopover({ demand, x: anchorX, y: anchorY });
return;
}
setPopover({ setPopover({
allocationId: info.allocationId, allocationId: info.allocationId,
projectId: info.projectId, projectId: info.projectId,
@@ -295,10 +424,16 @@ function TimelineViewContent({
rangeHintRef.current.style.left = `${x + 12}px`; rangeHintRef.current.style.left = `${x + 12}px`;
rangeHintRef.current.style.top = `${y - 28}px`; rangeHintRef.current.style.top = `${y - 28}px`;
} }
if (multiDragTooltipRef.current) {
multiDragTooltipRef.current.style.left = `${x + 14}px`;
multiDragTooltipRef.current.style.top = `${y - 36}px`;
}
}; };
el.addEventListener("mousemove", handler, { passive: true }); // During multi-drag, listen on document (cursor may leave canvas)
return () => el.removeEventListener("mousemove", handler); const target: EventTarget = multiSelectState.isMultiDragging ? document : el;
}, [hasActivePointerOverlay, isLoading, mousePosRef]); // eslint-disable-line react-hooks/exhaustive-deps target.addEventListener("mousemove", handler as EventListener, { passive: true });
return () => target.removeEventListener("mousemove", handler as EventListener);
}, [hasActivePointerOverlay, isLoading, mousePosRef, multiSelectState.isMultiDragging]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Shift+wheel → horizontal scroll ────────────────────────────────────── // ─── Shift+wheel → horizontal scroll ──────────────────────────────────────
useEffect(() => { useEffect(() => {
@@ -333,6 +468,92 @@ function TimelineViewContent({
return () => window.removeEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler);
}, [undo, redo]); }, [undo, redo]);
// ─── ESC to close overlays (topmost first) ─────────────────────────────────
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) {
e.preventDefault();
clearMultiSelect();
return;
}
if (demandPopover) {
e.preventDefault();
setDemandPopover(null);
} else if (popover) {
e.preventDefault();
setPopover(null);
} else if (newAllocPopover) {
e.preventDefault();
setNewAllocPopover(null);
} else if (openDemandToAssign) {
e.preventDefault();
setOpenDemandToAssign(null);
} else if (openPanelProjectId) {
e.preventDefault();
setOpenPanelProjectId(null);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [demandPopover, popover, newAllocPopover, openDemandToAssign, openPanelProjectId, setPopover, setNewAllocPopover, setOpenPanelProjectId, multiSelectState.selectedAllocationIds.length, multiSelectState.selectedResourceIds.length, clearMultiSelect]);
// ─── Resource hover card — event delegation on label columns ──────────────
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const HOVER_DELAY = 400;
function onMouseOver(e: MouseEvent) {
const target = (e.target as HTMLElement).closest<HTMLElement>("[data-resource-hover-id]");
if (!target) return;
const rid = target.dataset.resourceHoverId;
if (!rid) return;
// Clear any pending hide
if (resourceHoverTimerRef.current) {
clearTimeout(resourceHoverTimerRef.current);
resourceHoverTimerRef.current = null;
}
// If already showing this resource, skip
if (resourceHover?.resourceId === rid) return;
resourceHoverTimerRef.current = setTimeout(() => {
resourceHoverTimerRef.current = null;
setResourceHover({ resourceId: rid, anchorEl: target });
}, HOVER_DELAY);
}
function onMouseOut(e: MouseEvent) {
const related = e.relatedTarget as HTMLElement | null;
// Don't close if moving into another resource-hover target or the hover card itself
if (related?.closest?.("[data-resource-hover-id]") || related?.closest?.("[data-resource-hover-card]")) return;
if (resourceHoverTimerRef.current) {
clearTimeout(resourceHoverTimerRef.current);
resourceHoverTimerRef.current = null;
}
// Small delay before hiding to allow moving into hover card
resourceHoverTimerRef.current = setTimeout(() => {
resourceHoverTimerRef.current = null;
setResourceHover(null);
}, 150);
}
canvas.addEventListener("mouseover", onMouseOver, { passive: true });
canvas.addEventListener("mouseout", onMouseOut, { passive: true });
return () => {
canvas.removeEventListener("mouseover", onMouseOver);
canvas.removeEventListener("mouseout", onMouseOut);
if (resourceHoverTimerRef.current) {
clearTimeout(resourceHoverTimerRef.current);
resourceHoverTimerRef.current = null;
}
};
}, [resourceHover?.resourceId]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Lazy-extend date range on scroll ───────────────────────────────────── // ─── Lazy-extend date range on scroll ─────────────────────────────────────
function handleContainerScroll() { function handleContainerScroll() {
const el = scrollContainerRef.current; const el = scrollContainerRef.current;
@@ -348,6 +569,126 @@ function TimelineViewContent({
onCanvasMouseMove(e); onCanvasMouseMove(e);
}; };
// ─── Multi-select intersection computation ────────────────────────────────
useEffect(() => {
// Only compute when drag just ended (isSelecting false but has coordinates)
if (multiSelectState.isSelecting) return;
if (multiSelectState.startX === 0 && multiSelectState.startY === 0) return;
if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) return;
const canvasEl = canvasRef.current;
if (!canvasEl) return;
// Selection rectangle in viewport coordinates (same coordinate space as
// getBoundingClientRect). Using viewport coords directly avoids any
// coordinate transformation errors from sticky headers or virtualizer offsets.
const selTop = Math.min(multiSelectState.startY, multiSelectState.currentY);
const selBottom = Math.max(multiSelectState.startY, multiSelectState.currentY);
const selLeft = Math.min(multiSelectState.startX, multiSelectState.currentX);
const selRight = Math.max(multiSelectState.startX, multiSelectState.currentX);
// For X-axis: convert viewport X to canvas-relative X for allocation matching.
// Query any row element to find the actual canvas area position.
const canvasRect = canvasEl.getBoundingClientRect();
const canvasXOffset = canvasRect.left + LABEL_WIDTH;
const toCanvasX = (clientX: number) => clientX - canvasXOffset;
const selLeftCanvas = toCanvasX(selLeft);
const selRightCanvas = toCanvasX(selRight);
// Derive date range from pixel X positions
const colIndexStart = Math.max(0, Math.min(dates.length - 1, Math.floor(selLeftCanvas / CELL_WIDTH)));
const colIndexEnd = Math.max(0, Math.min(dates.length - 1, Math.floor(selRightCanvas / CELL_WIDTH)));
const startDate = dates[colIndexStart] ?? today;
const endDate = dates[colIndexEnd] ?? today;
// Find allocations within the rectangle by querying actual DOM positions.
// This avoids any mismatch between computed row positions and actual rendering.
const selectedIds: string[] = [];
const selectedResIds: string[] = [];
// Query all rendered row elements (virtualizer only renders visible + overscan rows)
const rowElements = canvasEl.querySelectorAll<HTMLElement>("[data-index]");
if (viewMode === "resource") {
rowElements.forEach((rowEl) => {
const idx = Number(rowEl.dataset.index);
const resource = resources[idx];
if (!resource) return;
const rowRect = rowEl.getBoundingClientRect();
// Compare directly in viewport coordinates
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
selectedResIds.push(resource.id);
const allocs = allocsByResource.get(resource.id) ?? [];
for (const alloc of allocs) {
const allocLeft = toLeft(new Date(alloc.startDate));
const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(alloc.id);
}
}
});
} else if (viewMode === "project") {
// Project view: query actual resource row DOM elements by data attribute.
// Each row carries data-project-id and data-resource-id for alloc lookup.
const projectRowEls = canvasEl.querySelectorAll<HTMLElement>("[data-project-resource-row]");
projectRowEls.forEach((rowEl) => {
const rowRect = rowEl.getBoundingClientRect();
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
const projectId = rowEl.dataset.projectId;
const resourceId = rowEl.dataset.resourceId;
if (!projectId || !resourceId) return;
// Find matching group and row
const group = projectGroups.find((g) => g.id === projectId);
if (!group) return;
const row = group.resourceRows.find((r) => r.resource.id === resourceId);
if (!row) return;
for (const alloc of row.allocs) {
const allocLeft = toLeft(new Date(alloc.startDate));
const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(alloc.id);
}
}
});
// Also check demand rows for open demand selection
const demandRowEls = canvasEl.querySelectorAll<HTMLElement>("[data-project-demand-row]");
demandRowEls.forEach((rowEl) => {
const rowRect = rowEl.getBoundingClientRect();
if (rowRect.bottom < selTop || rowRect.top > selBottom) return;
const projectId = rowEl.dataset.projectId;
if (!projectId) return;
const demands = openDemandsByProject.get(projectId) ?? [];
for (const demand of demands) {
const allocLeft = toLeft(new Date(demand.startDate));
const allocRight = allocLeft + toWidth(new Date(demand.startDate), new Date(demand.endDate));
if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) {
selectedIds.push(demand.id);
}
}
});
}
if (selectedIds.length > 0 || selectedResIds.length > 0) {
setMultiSelectState(prev => ({
...prev,
selectedAllocationIds: selectedIds,
selectedResourceIds: selectedResIds,
dateRange: { start: startDate, end: endDate },
}));
} else {
clearMultiSelect();
}
}, [multiSelectState.isSelecting, multiSelectState.startX, multiSelectState.startY]); // eslint-disable-line react-hooks/exhaustive-deps
return ( return (
<div className="relative flex flex-1 flex-col gap-4 min-h-0"> <div className="relative flex flex-1 flex-col gap-4 min-h-0">
{/* Toolbar */} {/* Toolbar */}
@@ -404,14 +745,21 @@ function TimelineViewContent({
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={(e) => void onCanvasMouseUp(e)} onMouseUp={(e) => void onCanvasMouseUp(e)}
onMouseLeave={onCanvasMouseLeave} onMouseLeave={onCanvasMouseLeave}
onMouseDown={(e) => {
if (e.button === 2) {
onCanvasRightMouseDown(e);
}
}}
onContextMenu={(e) => e.preventDefault()}
onTouchMove={(e) => { onTouchMove={(e) => {
if (!hasActivePointerOverlay) return; if (!hasActivePointerOverlay) return;
onCanvasTouchMove(e); onCanvasTouchMove(e);
}} }}
onTouchEnd={(e) => void onCanvasTouchEnd(e)} onTouchEnd={(e) => void onCanvasTouchEnd(e)}
className={clsx( className={clsx(
(dragState.isDragging || allocDragState.isActive) && "cursor-grabbing select-none", (dragState.isDragging || allocDragState.isActive || multiSelectState.isMultiDragging) && "cursor-grabbing select-none",
rangeState.isSelecting && "cursor-crosshair select-none", rangeState.isSelecting && "cursor-crosshair select-none",
multiSelectState.isSelecting && "cursor-crosshair select-none",
)} )}
> >
{viewMode === "resource" && ( {viewMode === "resource" && (
@@ -419,7 +767,7 @@ function TimelineViewContent({
scrollContainerRef={scrollContainerRef} scrollContainerRef={scrollContainerRef}
dragState={dragState} dragState={dragState}
allocDragState={allocDragState} allocDragState={allocDragState}
rangeState={rangeState} rangeState={effectiveRangeState}
shiftPreview={shiftPreview} shiftPreview={shiftPreview}
contextResourceIds={contextResourceIds} contextResourceIds={contextResourceIds}
onAllocMouseDown={onAllocMouseDown} onAllocMouseDown={onAllocMouseDown}
@@ -427,6 +775,7 @@ function TimelineViewContent({
onRowMouseDown={onRowMouseDown} onRowMouseDown={onRowMouseDown}
onRowTouchStart={onRowTouchStart} onRowTouchStart={onRowTouchStart}
onAllocationContextMenu={openAllocationPopoverAt} onAllocationContextMenu={openAllocationPopoverAt}
multiSelectState={multiSelectState}
CELL_WIDTH={CELL_WIDTH} CELL_WIDTH={CELL_WIDTH}
dates={dates} dates={dates}
totalCanvasWidth={totalCanvasWidth} totalCanvasWidth={totalCanvasWidth}
@@ -442,7 +791,8 @@ function TimelineViewContent({
scrollContainerRef={scrollContainerRef} scrollContainerRef={scrollContainerRef}
dragState={dragState} dragState={dragState}
allocDragState={allocDragState} allocDragState={allocDragState}
rangeState={rangeState} rangeState={effectiveRangeState}
multiSelectState={multiSelectState}
onProjectBarMouseDown={onProjectBarMouseDown} onProjectBarMouseDown={onProjectBarMouseDown}
onProjectBarTouchStart={onProjectBarTouchStart} onProjectBarTouchStart={onProjectBarTouchStart}
onAllocMouseDown={onAllocMouseDown} onAllocMouseDown={onAllocMouseDown}
@@ -466,6 +816,19 @@ function TimelineViewContent({
)} )}
</div> </div>
{/* Multi-select rectangle overlay */}
{multiSelectState.isSelecting && (
<div
className="fixed border-2 border-sky-500 bg-sky-500/10 pointer-events-none z-30 rounded"
style={{
left: Math.min(multiSelectState.startX, multiSelectState.currentX),
top: Math.min(multiSelectState.startY, multiSelectState.currentY),
width: Math.abs(multiSelectState.currentX - multiSelectState.startX),
height: Math.abs(multiSelectState.currentY - multiSelectState.startY),
}}
/>
)}
{/* Saving indicators */} {/* Saving indicators */}
{(isApplying || isAllocSaving) && ( {(isApplying || isAllocSaving) && (
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-2xl bg-white/50 dark:bg-gray-950/50"> <div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-2xl bg-white/50 dark:bg-gray-950/50">
@@ -540,18 +903,95 @@ function TimelineViewContent({
</div> </div>
)} )}
{/* Allocation popover */} {/* Multi-drag tooltip */}
{popover && ( {multiSelectState.isMultiDragging && multiSelectState.multiDragDaysDelta !== 0 && (
<AllocationPopover <div
allocationId={popover.allocationId} ref={multiDragTooltipRef}
projectId={popover.projectId} className="fixed z-50 bg-sky-700 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg font-medium"
onClose={() => setPopover(null)} style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
{multiSelectState.multiDragMode === "resize-start" ? "Start " : multiSelectState.multiDragMode === "resize-end" ? "End " : ""}
{multiSelectState.multiDragDaysDelta > 0 ? "+" : ""}
{multiSelectState.multiDragDaysDelta}d
{" "}
({multiSelectState.selectedAllocationIds.length} allocations)
</div>
)}
{/* Allocation / Demand popover (click path) */}
{popover && (() => {
// Check if clicked allocation is actually a demand
const clickedDemand = openDemandsByProject.get(popover.projectId)?.find((d) => d.id === popover.allocationId);
if (clickedDemand) {
return (
<DemandPopover
demand={clickedDemand}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
onFillDemand={(d) => {
setPopover(null);
setOpenDemandToAssign({
id: d.id,
projectId: d.projectId,
roleId: d.roleId,
role: d.role,
headcount: d.requestedHeadcount,
startDate: new Date(d.startDate),
endDate: new Date(d.endDate),
hoursPerDay: d.hoursPerDay,
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
...(d.project !== undefined ? { project: d.project } : {}),
});
}}
anchorX={popover.x}
anchorY={popover.y}
/>
);
}
return (
<AllocationPopover
allocationId={popover.allocationId}
projectId={popover.projectId}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
anchorX={popover.x}
anchorY={popover.y}
/>
);
})()}
{/* Demand popover */}
{demandPopover && (
<DemandPopover
demand={demandPopover.demand}
onClose={() => setDemandPopover(null)}
onOpenPanel={(pid) => { onOpenPanel={(pid) => {
setPopover(null); setDemandPopover(null);
setOpenPanelProjectId(pid); setOpenPanelProjectId(pid);
}} }}
anchorX={popover.x} onFillDemand={(d) => {
anchorY={popover.y} setDemandPopover(null);
setOpenDemandToAssign({
id: d.id,
projectId: d.projectId,
roleId: d.roleId,
role: d.role,
headcount: d.requestedHeadcount,
startDate: new Date(d.startDate),
endDate: new Date(d.endDate),
hoursPerDay: d.hoursPerDay,
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
...(d.project !== undefined ? { project: d.project } : {}),
});
}}
anchorX={demandPopover.x}
anchorY={demandPopover.y}
/> />
)} )}
@@ -561,7 +1001,7 @@ function TimelineViewContent({
resourceId={newAllocPopover.resourceId} resourceId={newAllocPopover.resourceId}
startDate={newAllocPopover.startDate} startDate={newAllocPopover.startDate}
endDate={newAllocPopover.endDate} endDate={newAllocPopover.endDate}
suggestedProjectId={newAllocPopover.suggestedProjectId} suggestedProjectId={enrichedSuggestedProjectId}
anchorX={newAllocPopover.anchorX} anchorX={newAllocPopover.anchorX}
anchorY={newAllocPopover.anchorY} anchorY={newAllocPopover.anchorY}
onClose={() => setNewAllocPopover(null)} onClose={() => setNewAllocPopover(null)}
@@ -582,6 +1022,45 @@ function TimelineViewContent({
onSuccess={() => setOpenDemandToAssign(null)} onSuccess={() => setOpenDemandToAssign(null)}
/> />
)} )}
{/* Multi-select floating action bar */}
<FloatingActionBar
selectedAllocationCount={multiSelectState.selectedAllocationIds.length}
selectedResourceCount={multiSelectState.selectedResourceIds.length}
onDelete={() => {
if (multiSelectState.selectedAllocationIds.length === 0) return;
const msg = `Delete ${multiSelectState.selectedAllocationIds.length} allocation(s)? This cannot be undone.`;
if (window.confirm(msg)) {
batchDeleteMutation.mutate({ ids: multiSelectState.selectedAllocationIds });
}
}}
onAssign={() => setShowBatchAssign(true)}
onClear={clearMultiSelect}
isDeleting={batchDeleteMutation.isPending}
/>
{/* Batch assign popover */}
{showBatchAssign && multiSelectState.dateRange && (
<BatchAssignPopover
resourceIds={multiSelectState.selectedResourceIds}
startDate={multiSelectState.dateRange.start}
endDate={multiSelectState.dateRange.end}
onClose={() => setShowBatchAssign(false)}
onCreated={() => {
setShowBatchAssign(false);
clearMultiSelect();
}}
/>
)}
{/* Resource hover card */}
{resourceHover && (
<ResourceHoverCard
resourceId={resourceHover.resourceId}
anchorEl={resourceHover.anchorEl}
onClose={() => setResourceHover(null)}
/>
)}
</div> </div>
); );
} }
@@ -26,7 +26,10 @@ interface VacationModalProps {
function toDateInputValue(date: Date | string | null | undefined): string { function toDateInputValue(date: Date | string | null | undefined): string {
if (!date) return ""; if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date; const d = typeof date === "string" ? new Date(date) : date;
return d.toISOString().split("T")[0] ?? ""; const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
} }
export function VacationModal({ resourceId: initialResourceId, onClose, onSuccess }: VacationModalProps) { export function VacationModal({ resourceId: initialResourceId, onClose, onSuccess }: VacationModalProps) {
+75 -18
View File
@@ -5,16 +5,27 @@ import type { AllocationMovedSnapshot } from "./useTimelineDrag.js";
export type { AllocationMovedSnapshot }; export type { AllocationMovedSnapshot };
const MAX_HISTORY = 20; /** A single allocation move or a batch shift of multiple allocations */
export type HistoryEntry =
| { type: "single"; snapshot: AllocationMovedSnapshot }
| { type: "batch"; allocationIds: string[]; daysDelta: number; mode: "move" | "resize-start" | "resize-end" };
const DEFAULT_MAX_HISTORY = 50;
export function useAllocationHistory() { export function useAllocationHistory() {
const [canUndo, setCanUndo] = useState(false); const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false); const [canRedo, setCanRedo] = useState(false);
const past = useRef<AllocationMovedSnapshot[]>([]); const past = useRef<HistoryEntry[]>([]);
const future = useRef<AllocationMovedSnapshot[]>([]); const future = useRef<HistoryEntry[]>([]);
const utils = trpc.useUtils(); const utils = trpc.useUtils();
// Configurable max steps from system settings
const { data: settings } = trpc.settings.getSystemSettings.useQuery(undefined, {
staleTime: 60_000,
});
const maxHistory = settings?.timelineUndoMaxSteps ?? DEFAULT_MAX_HISTORY;
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({ const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => { onSuccess: () => {
void utils.timeline.getEntries.invalidate(); void utils.timeline.getEntries.invalidate();
@@ -24,12 +35,35 @@ export function useAllocationHistory() {
}, },
}); });
const batchShiftMutation = trpc.timeline.batchShiftAllocations.useMutation({
onSuccess: () => {
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
},
});
const push = useCallback((snapshot: AllocationMovedSnapshot) => { const push = useCallback((snapshot: AllocationMovedSnapshot) => {
past.current = [...past.current.slice(-MAX_HISTORY + 1), snapshot]; past.current = [...past.current.slice(-(maxHistory - 1)), { type: "single", snapshot }];
future.current = []; future.current = [];
setCanUndo(true); setCanUndo(true);
setCanRedo(false); setCanRedo(false);
}, []); }, [maxHistory]);
const pushBatch = useCallback((allocationIds: string[], daysDelta: number, mode: "move" | "resize-start" | "resize-end" = "move") => {
past.current = [...past.current.slice(-(maxHistory - 1)), { type: "batch", allocationIds, daysDelta, mode }];
future.current = [];
setCanUndo(true);
setCanRedo(false);
}, [maxHistory]);
const invalidateAll = useCallback(() => {
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
}, [utils]);
const undo = useCallback(async () => { const undo = useCallback(async () => {
const last = past.current[past.current.length - 1]; const last = past.current[past.current.length - 1];
@@ -38,12 +72,25 @@ export function useAllocationHistory() {
future.current = [last, ...future.current]; future.current = [last, ...future.current];
setCanUndo(past.current.length > 0); setCanUndo(past.current.length > 0);
setCanRedo(true); setCanRedo(true);
await updateMutation.mutateAsync({
allocationId: last.mutationAllocationId, if (last.type === "single") {
startDate: last.before.startDate, await updateMutation.mutateAsync({
endDate: last.before.endDate, allocationId: last.snapshot.mutationAllocationId,
}); startDate: last.snapshot.before.startDate,
}, [updateMutation]); endDate: last.snapshot.before.endDate,
});
} else {
// Batch: reverse the shift (for resize modes, reverse means shifting the same edge back)
const reverseMode = last.mode === "resize-start" ? "resize-start"
: last.mode === "resize-end" ? "resize-end"
: "move";
await batchShiftMutation.mutateAsync({
allocationIds: last.allocationIds,
daysDelta: -last.daysDelta,
mode: reverseMode,
});
}
}, [updateMutation, batchShiftMutation]);
const redo = useCallback(async () => { const redo = useCallback(async () => {
const next = future.current[0]; const next = future.current[0];
@@ -52,12 +99,22 @@ export function useAllocationHistory() {
past.current = [...past.current, next]; past.current = [...past.current, next];
setCanUndo(true); setCanUndo(true);
setCanRedo(future.current.length > 0); setCanRedo(future.current.length > 0);
await updateMutation.mutateAsync({
allocationId: next.mutationAllocationId,
startDate: next.after.startDate,
endDate: next.after.endDate,
});
}, [updateMutation]);
return { push, undo, redo, canUndo, canRedo }; if (next.type === "single") {
await updateMutation.mutateAsync({
allocationId: next.snapshot.mutationAllocationId,
startDate: next.snapshot.after.startDate,
endDate: next.snapshot.after.endDate,
});
} else {
// Batch: re-apply the shift
await batchShiftMutation.mutateAsync({
allocationIds: next.allocationIds,
daysDelta: next.daysDelta,
mode: next.mode,
});
}
}, [updateMutation, batchShiftMutation]);
return { push, pushBatch, undo, redo, canUndo, canRedo };
} }
+12 -1
View File
@@ -18,6 +18,8 @@ export interface AppPreferences {
heatmapColorScheme: HeatmapColorScheme; heatmapColorScheme: HeatmapColorScheme;
/** Show open demand / placeholder entries by default when loading the timeline. Default: true. */ /** Show open demand / placeholder entries by default when loading the timeline. Default: true. */
showDemandProjects: boolean; showDemandProjects: boolean;
/** Blink overbooked days (>8h) as a warning on the timeline. Default: false. */
blinkOverbookedDays: boolean;
} }
const STORAGE_KEY = "planarchy_prefs"; const STORAGE_KEY = "planarchy_prefs";
@@ -28,6 +30,7 @@ const DEFAULT: AppPreferences = {
timelineDisplayMode: "strip", timelineDisplayMode: "strip",
heatmapColorScheme: "green-red", heatmapColorScheme: "green-red",
showDemandProjects: true, showDemandProjects: true,
blinkOverbookedDays: false,
}; };
export function readAppPreferences(): AppPreferences { export function readAppPreferences(): AppPreferences {
@@ -94,5 +97,13 @@ export function useAppPreferences() {
}); });
}, []); }, []);
return { prefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme, setShowDemandProjects }; const setBlinkOverbookedDays = useCallback((value: boolean) => {
setPrefs((prev) => {
const next = { ...prev, blinkOverbookedDays: value };
saveAppPreferences(next);
return next;
});
}, []);
return { prefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme, setShowDemandProjects, setBlinkOverbookedDays };
} }
+188 -8
View File
@@ -118,6 +118,39 @@ const INITIAL_RANGE_STATE: RangeState = {
startClientX: 0, startClientX: 0,
}; };
// ─── Multi-select state ────────────────────────────────────────────────────
export interface MultiSelectState {
isSelecting: boolean;
startX: number;
startY: number;
currentX: number;
currentY: number;
selectedAllocationIds: string[];
selectedResourceIds: string[];
dateRange: { start: Date; end: Date } | null;
/** When multi-dragging, the number of days all selected allocations are shifted */
multiDragDaysDelta: number;
/** Whether a multi-drag is currently in progress */
isMultiDragging: boolean;
/** The drag mode during multi-drag (move, resize-start, resize-end) */
multiDragMode: AllocDragMode;
}
const INITIAL_MULTI_SELECT: MultiSelectState = {
isSelecting: false,
startX: 0,
startY: 0,
currentX: 0,
currentY: 0,
selectedAllocationIds: [],
selectedResourceIds: [],
dateRange: null,
multiDragDaysDelta: 0,
isMultiDragging: false,
multiDragMode: "move",
};
// ─── Hook ─────────────────────────────────────────────────────────────────── // ─── Hook ───────────────────────────────────────────────────────────────────
export interface AllocationMovedSnapshot { export interface AllocationMovedSnapshot {
@@ -134,20 +167,28 @@ export function useTimelineDrag({
onBlockClick, onBlockClick,
onRangeSelected, onRangeSelected,
onAllocationMoved, onAllocationMoved,
onShiftClickAlloc,
onMultiDragComplete,
}: { }: {
cellWidth: number; cellWidth: number;
onShiftApplied?: (projectId: string) => void; onShiftApplied?: (projectId: string) => void;
onBlockClick?: (info: BlockClickInfo) => void; onBlockClick?: (info: BlockClickInfo) => void;
onRangeSelected?: (info: RangeSelectedInfo) => void; onRangeSelected?: (info: RangeSelectedInfo) => void;
onAllocationMoved?: (snapshot: AllocationMovedSnapshot) => void; onAllocationMoved?: (snapshot: AllocationMovedSnapshot) => void;
onShiftClickAlloc?: (allocationId: string) => void;
onMultiDragComplete?: (daysDelta: number, mode: AllocDragMode) => void;
}) { }) {
const [dragState, setDragState] = useState<DragState>(INITIAL_DRAG_STATE); const [dragState, setDragState] = useState<DragState>(INITIAL_DRAG_STATE);
const [allocDragState, setAllocDragState] = useState<AllocDragState>(INITIAL_ALLOC_DRAG); const [allocDragState, setAllocDragState] = useState<AllocDragState>(INITIAL_ALLOC_DRAG);
const [rangeState, setRangeState] = useState<RangeState>(INITIAL_RANGE_STATE); const [rangeState, setRangeState] = useState<RangeState>(INITIAL_RANGE_STATE);
const [multiSelectState, setMultiSelectState] = useState<MultiSelectState>(INITIAL_MULTI_SELECT);
const dragStateRef = useRef<DragState>(INITIAL_DRAG_STATE); const dragStateRef = useRef<DragState>(INITIAL_DRAG_STATE);
const allocDragRef = useRef<AllocDragState>(INITIAL_ALLOC_DRAG); const allocDragRef = useRef<AllocDragState>(INITIAL_ALLOC_DRAG);
const rangeStateRef = useRef<RangeState>(INITIAL_RANGE_STATE); const rangeStateRef = useRef<RangeState>(INITIAL_RANGE_STATE);
const multiSelectRef = useRef<MultiSelectState>(INITIAL_MULTI_SELECT);
// Keep ref in sync with state so document-level handlers read the latest selection
multiSelectRef.current = multiSelectState;
// Keep always-current refs for values used inside document event handlers // Keep always-current refs for values used inside document event handlers
const cellWidthRef = useRef(cellWidth); const cellWidthRef = useRef(cellWidth);
@@ -166,6 +207,12 @@ export function useTimelineDrag({
const onAllocationMovedRef = useRef(onAllocationMoved); const onAllocationMovedRef = useRef(onAllocationMoved);
onAllocationMovedRef.current = onAllocationMoved; onAllocationMovedRef.current = onAllocationMoved;
const onShiftClickAllocRef = useRef(onShiftClickAlloc);
onShiftClickAllocRef.current = onShiftClickAlloc;
const onMultiDragCompleteRef = useRef(onMultiDragComplete);
onMultiDragCompleteRef.current = onMultiDragComplete;
const utils = trpc.useUtils(); const utils = trpc.useUtils();
// Project-shift preview // Project-shift preview
@@ -312,6 +359,54 @@ export function useTimelineDrag({
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const wasShift = e.shiftKey;
// Check if this allocation is part of a multi-selection → multi-drag mode
const ms = multiSelectRef.current;
const isMultiSelected =
ms.selectedAllocationIds.length > 1 &&
ms.selectedAllocationIds.includes(opts.allocationId);
if (isMultiSelected) {
// ── Multi-drag: move/resize all selected allocations together ──
const startMouseX = e.clientX;
let currentDaysDelta = 0;
const dragMode = opts.mode;
setMultiSelectState((prev) => ({ ...prev, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode }));
multiSelectRef.current = { ...ms, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode };
function handleMultiMove(ev: MouseEvent) {
const deltaX = ev.clientX - startMouseX;
const daysDelta = Math.round(deltaX / cellWidthRef.current);
if (daysDelta === currentDaysDelta) return;
currentDaysDelta = daysDelta;
setMultiSelectState((prev) => ({ ...prev, multiDragDaysDelta: daysDelta }));
multiSelectRef.current = { ...multiSelectRef.current, multiDragDaysDelta: daysDelta };
}
function handleMultiUp() {
document.removeEventListener("mousemove", handleMultiMove);
document.removeEventListener("mouseup", handleMultiUp);
const finalDelta = currentDaysDelta;
setMultiSelectState((prev) => ({ ...prev, isMultiDragging: false, multiDragDaysDelta: 0 }));
multiSelectRef.current = { ...multiSelectRef.current, isMultiDragging: false, multiDragDaysDelta: 0 };
if (finalDelta !== 0) {
onMultiDragCompleteRef.current?.(finalDelta, dragMode);
}
}
document.addEventListener("mousemove", handleMultiMove);
document.addEventListener("mouseup", handleMultiUp);
return;
}
// ── Single allocation drag ────────────────────────────────────────────
const initial: AllocDragState = { const initial: AllocDragState = {
isActive: true, isActive: true,
mode: opts.mode, mode: opts.mode,
@@ -375,14 +470,20 @@ export function useTimelineDrag({
if (!alloc.isActive) return; if (!alloc.isActive) return;
if (alloc.daysDelta === 0 && alloc.allocationId) { if (alloc.daysDelta === 0 && alloc.allocationId) {
// No movement → treat as click, open alloc popover // No movement → treat as click
onBlockClickRef.current?.({ if (wasShift) {
allocationId: alloc.allocationId, // Shift+Click → toggle multi-selection for this allocation
projectId: alloc.projectId ?? "", onShiftClickAllocRef.current?.(alloc.allocationId);
projectName: alloc.projectName ?? "", } else {
startDate: alloc.originalStartDate!, // Normal click → open alloc popover
endDate: alloc.originalEndDate!, onBlockClickRef.current?.({
}); allocationId: alloc.allocationId,
projectId: alloc.projectId ?? "",
projectName: alloc.projectName ?? "",
startDate: alloc.originalStartDate!,
endDate: alloc.originalEndDate!,
});
}
} else if (alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) { } else if (alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) {
pendingSnapshotRef.current = { pendingSnapshotRef.current = {
allocationId: alloc.allocationId, allocationId: alloc.allocationId,
@@ -550,6 +651,81 @@ export function useTimelineDrag({
} }
}, []); }, []);
// ── Multi-select (right-click drag) ─────────────────────────────────────────
const onCanvasRightMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 2) return;
e.preventDefault();
const initial: MultiSelectState = {
isSelecting: true,
startX: e.clientX,
startY: e.clientY,
currentX: e.clientX,
currentY: e.clientY,
selectedAllocationIds: [],
selectedResourceIds: [],
dateRange: null,
multiDragDaysDelta: 0,
isMultiDragging: false,
multiDragMode: "move",
};
multiSelectRef.current = initial;
setMultiSelectState(initial);
function handleMove(ev: MouseEvent) {
const ms = multiSelectRef.current;
if (!ms.isSelecting) return;
const updated: MultiSelectState = {
...ms,
currentX: ev.clientX,
currentY: ev.clientY,
};
multiSelectRef.current = updated;
setMultiSelectState(updated);
}
function handleUp(ev: MouseEvent) {
document.removeEventListener("mousemove", handleMove);
document.removeEventListener("mouseup", handleUp);
const ms = multiSelectRef.current;
if (!ms.isSelecting) return;
const distance = Math.hypot(ev.clientX - ms.startX, ev.clientY - ms.startY);
if (distance < 5) {
// Minimal movement → not a drag selection, reset.
// Let existing onContextMenu handlers on allocation blocks handle right-click.
multiSelectRef.current = INITIAL_MULTI_SELECT;
setMultiSelectState(INITIAL_MULTI_SELECT);
return;
}
// Keep the rectangle coordinates for the parent to compute intersection.
// isSelecting is set to false to indicate the drag is done, but the
// rectangle data (startX/Y, currentX/Y) is preserved so TimelineView
// can resolve which allocations/resources fall within the selection.
const finished: MultiSelectState = {
...ms,
isSelecting: false,
currentX: ev.clientX,
currentY: ev.clientY,
};
multiSelectRef.current = finished;
setMultiSelectState(finished);
}
document.addEventListener("mousemove", handleMove);
document.addEventListener("mouseup", handleUp);
}, []);
const clearMultiSelect = useCallback(() => {
multiSelectRef.current = INITIAL_MULTI_SELECT;
setMultiSelectState(INITIAL_MULTI_SELECT);
}, []);
// ── Touch support ─────────────────────────────────────────────────────────── // ── Touch support ───────────────────────────────────────────────────────────
// Helper: extract clientX from a touch event (first active touch, then changedTouches as fallback) // Helper: extract clientX from a touch event (first active touch, then changedTouches as fallback)
@@ -682,6 +858,8 @@ export function useTimelineDrag({
dragState, dragState,
allocDragState, allocDragState,
rangeState, rangeState,
multiSelectState,
setMultiSelectState,
shiftPreview, shiftPreview,
isPreviewLoading, isPreviewLoading,
isApplying: applyShiftMutation.isPending, isApplying: applyShiftMutation.isPending,
@@ -693,6 +871,8 @@ export function useTimelineDrag({
onCanvasMouseMove, onCanvasMouseMove,
onCanvasMouseUp, onCanvasMouseUp,
onCanvasMouseLeave, onCanvasMouseLeave,
onCanvasRightMouseDown,
clearMultiSelect,
// Touch equivalents // Touch equivalents
onProjectBarTouchStart, onProjectBarTouchStart,
onAllocTouchStart, onAllocTouchStart,
+4 -4
View File
@@ -44,10 +44,10 @@ export function useTimelineLayout(
key={i} key={i}
className={clsx( className={clsx(
"absolute top-0 bottom-0 border-r", "absolute top-0 bottom-0 border-r",
isToday ? "border-brand-300 border-r-2" : isToday ? "border-brand-300 dark:border-brand-700 border-r-2" :
isSaturday ? "border-amber-200 bg-amber-50/40" : isSaturday ? "border-amber-200 dark:border-amber-800 bg-amber-50/40 dark:bg-amber-950/20" :
isSunday ? "border-gray-200 bg-gray-100/60" : isSunday ? "border-gray-200 dark:border-gray-700 bg-gray-100/60 dark:bg-gray-800/40" :
"border-gray-100", "border-gray-100 dark:border-gray-800",
)} )}
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH }} style={{ left: i * CELL_WIDTH, width: CELL_WIDTH }}
/> />
+1 -1
View File
@@ -27,7 +27,7 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) {
new QueryClient({ new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 30 * 1000, // 30 seconds staleTime: 60 * 1000, // 60 seconds — reduces refetches on navigation
retry: 1, retry: 1,
}, },
}, },
+6
View File
@@ -28,6 +28,12 @@ const authConfig = {
const isValid = await verify(user.passwordHash, password); const isValid = await verify(user.passwordHash, password);
if (!isValid) return null; if (!isValid) return null;
// Track last login time
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
return { return {
id: user.id, id: user.id,
email: user.email, email: user.email,
+3 -2
View File
@@ -1,4 +1,4 @@
import { createTRPCContext } from "@planarchy/api"; import { createTRPCContext, loadRoleDefaults } from "@planarchy/api";
import { appRouter } from "@planarchy/api/router"; import { appRouter } from "@planarchy/api/router";
import { createCallerFactory } from "@planarchy/api/trpc"; import { createCallerFactory } from "@planarchy/api/trpc";
import { prisma } from "@planarchy/db"; import { prisma } from "@planarchy/db";
@@ -18,7 +18,8 @@ export async function createCaller() {
}) })
: null; : null;
const ctx = createTRPCContext({ session, dbUser }); const roleDefaults = await loadRoleDefaults();
const ctx = createTRPCContext({ session, dbUser, roleDefaults });
const callerFactory = createCallerFactory(appRouter); const callerFactory = createCallerFactory(appRouter);
return callerFactory(ctx); return callerFactory(ctx);
} }
@@ -0,0 +1,417 @@
# Performance Optimization Review
Date: 2026-03-18
Scope: analysis only. No runtime behavior was changed in this pass.
## Executive Summary
The biggest performance costs in Planarchy currently come from three patterns:
1. Broad data fetches followed by repeated in-memory filtering/grouping.
2. Expensive client-side derivations on screens with large datasets, especially Timeline.
3. Broad cache invalidation that forces heavy queries to refetch and recompute after small mutations.
The highest-value optimization target is the Timeline stack. After that, the best wins are server-side aggregation for chargeability/dashboard workloads and narrowing resource/report queries to visible scope.
## Main Hotspots
### 1. Timeline
Relevant files:
- [TimelineContext.tsx](/home/hartmut/Documents/Copilot/planarchy/apps/web/src/components/timeline/TimelineContext.tsx)
- [TimelineView.tsx](/home/hartmut/Documents/Copilot/planarchy/apps/web/src/components/timeline/TimelineView.tsx)
- [TimelineResourcePanel.tsx](/home/hartmut/Documents/Copilot/planarchy/apps/web/src/components/timeline/TimelineResourcePanel.tsx)
- [TimelineProjectPanel.tsx](/home/hartmut/Documents/Copilot/planarchy/apps/web/src/components/timeline/TimelineProjectPanel.tsx)
- [timeline.ts](/home/hartmut/Documents/Copilot/planarchy/packages/api/src/router/timeline.ts)
Observed issues:
- `TimelineContext` derives multiple large collections in the client from the same raw payload.
- Resource and project filtering is partly pushed to the backend, but large read-model payloads are still constructed and further filtered/grouped in the browser.
- Timeline mutations invalidate multiple heavy queries at once.
- Timeline SSE events also trigger broad invalidations.
Impact:
- Slow initial render.
- Lag on interaction and hover.
- Slow recovery after edits because the full timeline dataset is often fetched again.
### 2. Chargeability Report
Relevant files:
- [ChargeabilityReportClient.tsx](/home/hartmut/Documents/Copilot/planarchy/apps/web/src/components/reports/ChargeabilityReportClient.tsx)
- [chargeability-report.ts](/home/hartmut/Documents/Copilot/planarchy/packages/api/src/router/chargeability-report.ts)
Observed issues:
- The server loads matching resources, then all bookings in range, then repeatedly filters bookings and vacations per resource and per month.
- The client renders a wide report grid and still performs filtering/grouping locally.
- Public holiday support is still marked as TODO in the route, so future correctness work will likely add more cost unless the data model is optimized first.
Impact:
- CPU-heavy report generation.
- Larger-than-needed payloads.
- Increased render cost on wide month ranges.
### 3. Dashboard Chargeability / Analytics Widgets
Relevant files:
- [dashboard.ts](/home/hartmut/Documents/Copilot/planarchy/packages/api/src/router/dashboard.ts)
- [get-chargeability-overview.ts](/home/hartmut/Documents/Copilot/planarchy/packages/application/src/use-cases/dashboard/get-chargeability-overview.ts)
- [get-overview.ts](/home/hartmut/Documents/Copilot/planarchy/packages/application/src/use-cases/dashboard/get-overview.ts)
Observed issues:
- `getDashboardChargeabilityOverview()` fetches all relevant resources and all bookings for the month, then filters the full booking set again for each resource.
- Similar patterns exist in dashboard summary use cases where relatively small widgets are backed by broad read logic.
Impact:
- Avoidable server CPU and latency.
- Widgets become more expensive as the database grows even if the UI remains small.
### 4. Resources Screen
Relevant files:
- [ResourcesClient.tsx](/home/hartmut/Documents/Copilot/planarchy/apps/web/src/app/(app)/resources/ResourcesClient.tsx)
- [resource.ts](/home/hartmut/Documents/Copilot/planarchy/packages/api/src/router/resource.ts)
Observed issues:
- Infinite scrolling is in place, which is good, but the screen still does substantial client-side sort/filter/order work.
- Separate global stats queries can be expensive when only the currently visible rows need enriched values.
- Large dropdown/filter datasets are fully loaded to support selection UIs.
Impact:
- Good enough at small scale, but it will degrade with larger teams and imported planning data.
### 5. Shared Booking Read Model
Relevant files:
- [list-assignment-bookings.ts](/home/hartmut/Documents/Copilot/planarchy/packages/application/src/use-cases/allocation/list-assignment-bookings.ts)
Observed issues:
- This is a clean shared entry point, but it returns broad booking rows and leaves most grouping/aggregation to callers.
- Several heavy endpoints call this and then repeatedly scan the returned array.
Impact:
- Logic is easy to reuse, but performance scales poorly because each consumer re-aggregates independently.
## Current Database Index Baseline
Relevant schema:
- [schema.prisma](/home/hartmut/Documents/Copilot/planarchy/packages/db/prisma/schema.prisma)
Good coverage already exists for:
- `Assignment`: `resourceId`, `projectId`, `status`, date ranges, and composite indices.
- `DemandRequirement`: `projectId`, `status`, date ranges.
- `Vacation`: `resourceId`, `status`, date ranges.
- `Resource`: `chapter`, `isActive`, `countryId`, `orgUnitId`, `resourceType`.
- `Project`: `status`, date range, `orderType`, `clientId`.
Likely gaps for current query patterns:
- `Resource` filters used together in analytics screens: `isActive`, `chgResponsibility`, `departed`, `rolledOff`, `countryId`, `managementLevelGroupId`.
- `Project` filters used in timeline/report contexts: `clientId` combined with active date windows.
- Potential overlap-heavy access paths where PostgreSQL may benefit more from specialized query shapes or materialized read models than from adding more conventional B-tree indexes.
Conclusion:
The schema is not unindexed. The main issue is query shape and repeated in-memory work, not just missing indexes. Index work should support query redesign, not replace it.
## Recommended Optimizations
### A. Move Timeline Derivations Closer to the Backend
Proposal:
- Add a timeline read endpoint that returns data already grouped for the active view mode and active filters.
- Return normalized maps and compact slices instead of large repeated nested objects.
- Keep the raw endpoint only if another screen truly needs it.
Pros:
- Largest likely user-visible win.
- Reduces browser CPU, garbage collection, and hover lag.
- Shrinks payload size if redundant nested fields are removed.
Cons:
- More backend read-model code.
- Slightly higher coupling between API shape and timeline UI needs.
- Requires careful migration to avoid breaking existing interactions.
Recommended priority: P0
Estimated effort: medium to large
### B. Build Indexed Maps Once Instead of Repeated Array Scans
Proposal:
- In timeline/report/dashboard code, replace repeated `.filter()`, `.find()`, and `.some()` passes with prebuilt maps keyed by `resourceId`, `projectId`, `monthKey`, and status where appropriate.
- Standardize this as shared helper utilities for read-model construction.
Pros:
- Low-risk improvement.
- Can be introduced incrementally.
- Helps both client and server hot paths.
Cons:
- Does not solve oversized payloads by itself.
- Adds some code complexity if done ad hoc instead of through shared helpers.
Recommended priority: P0
Estimated effort: small to medium
### C. Replace Broad Invalidations with Scoped Cache Updates
Proposal:
- Audit timeline mutations and SSE handlers that currently invalidate `getEntries`, `getEntriesView`, `getProjectContext`, and `getBudgetStatus` together.
- Invalidate only the affected query keys.
- Where practical, patch query cache locally after small edits instead of refetching everything.
Pros:
- Faster feedback after drag/drop and edit flows.
- Less network churn.
- Lower server load during active planning sessions.
Cons:
- Cache correctness becomes more complex.
- Needs strong regression coverage around drag/drop and split allocation flows.
Recommended priority: P0
Estimated effort: medium
### D. Push Chargeability Aggregation into Dedicated Server Read Models
Proposal:
- Replace repeated per-resource filtering of a flat booking array with server-side grouped structures.
- Precompute `bookingsByResource` and `vacationsByResource` once before month iteration.
- Consider a dedicated monthly chargeability read model for reports and dashboard widgets.
Pros:
- Strong improvement for reports and widgets.
- Eliminates duplicate aggregation logic across dashboard/resource/report endpoints.
- Better foundation for public holiday integration.
Cons:
- Requires consolidating overlapping business logic.
- Read-model duplication is possible if not designed carefully.
Recommended priority: P1
Estimated effort: medium
### E. Narrow Data Fetches to Visible Scope
Proposal:
- Avoid loading large supporting datasets such as `resource.list({ limit: 500 })` or “all projects” queries when only label resolution for selected IDs is required.
- Add lightweight lookup endpoints like `getByIds()` or `resolveLabels()`.
- On the resources page, fetch chargeability/stat enrichments only for the visible page or current filtered result slice.
Pros:
- Smaller payloads.
- Faster filter bar and screen startup.
- Simple, targeted changes.
Cons:
- More API endpoints or variants.
- Some UI components become slightly more stateful.
Recommended priority: P1
Estimated effort: small to medium
### F. Add Read-Optimized Analytics Endpoints or Materialized Views
Proposal:
- For expensive monthly analytics, introduce a read-optimized table or materialized view refreshed on schedule or after import batches.
- Use this for dashboard and chargeability summaries, not for transactional edit screens.
Pros:
- Best long-term scalability for imported dispo-scale data.
- Predictable response times for reports.
- Reduces repeated heavy joins and calculations.
Cons:
- More infrastructure and refresh logic.
- Risk of stale analytics if refresh semantics are unclear.
- Higher implementation complexity than direct query improvements.
Recommended priority: P2
Estimated effort: large
### G. Add Composite Indexes Only Where Query Patterns Justify Them
Proposal:
- Review actual PostgreSQL query plans for:
- resource analytics filters
- timeline date-overlap filters
- project/client/date combinations
- Add composite indexes only after measuring the slow plans.
Pros:
- Cheap improvement when aligned to real plans.
- Complements query redesign.
Cons:
- Limited value if broad in-memory processing remains.
- Too many indexes can slow writes/imports.
Recommended priority: P1
Estimated effort: small
Suggested candidates to measure first:
- `Resource(isActive, departed, rolledOff, chgResponsibility, countryId)`
- `Resource(isActive, managementLevelGroupId, countryId)`
- `Project(clientId, status, startDate, endDate)`
### H. Consider Row Virtualization for Heavy Tables
Proposal:
- Add virtualization for very large row-based screens if profiling confirms DOM/render cost remains high after data/query optimizations.
- Candidate screens: resources table, chargeability report table, timeline side panels.
Pros:
- Strong render win when many rows are mounted.
- Independent of backend changes.
Cons:
- More UI complexity.
- Can complicate drag/drop, sticky headers, and keyboard navigation.
- Should not be the first fix for timeline if the main problem is data churn.
Recommended priority: P2
Estimated effort: medium
## Priority Order
### Phase 1
- A. Move timeline derivations closer to the backend.
- B. Replace repeated array scans with indexed maps.
- C. Scope cache invalidation for timeline mutations and SSE.
Expected result:
- Best chance of making timeline interaction materially faster without removing functionality.
### Phase 2
- D. Server-side chargeability read models.
- E. Narrow data fetches to visible scope.
- G. Add measured composite indexes.
Expected result:
- Faster reports, dashboard widgets, and filter-heavy screens.
### Phase 3
- F. Materialized analytics/read models.
- H. Virtualization where profiling still shows render bottlenecks.
Expected result:
- Better scalability once data volume grows further.
## Suggested Measurement Plan
Before implementation:
- Capture browser performance traces for:
- timeline initial load
- timeline hover/selection
- timeline mutation save path
- chargeability report load
- resources page load and scroll
- Capture server timings for:
- `timeline.getEntriesView`
- `chargeabilityReport.getReport`
- `dashboard.getChargeabilityOverview`
- `resource.getChargeabilityStats`
- Record PostgreSQL query plans for the slowest endpoints.
During implementation:
- Compare payload sizes before and after endpoint changes.
- Track React commit duration on Timeline and Resources screens.
- Measure query invalidation counts after common edits.
Success criteria:
- Timeline hover and selection feel immediate under imported dispo-scale data.
- Timeline mutation flows no longer trigger full-screen refetch storms.
- Chargeability report load time drops materially for multi-month ranges.
- Dashboard widgets stop scanning large booking arrays per resource.
## No-Regression Guidance
Do not trade away any of the following:
- Existing planning semantics for assignments, proposed work, and demand visibility.
- Timeline drag/drop correctness.
- Tooltip, right-click, and selection behavior.
- Anonymization behavior.
- Filter parity between resources, timeline, and reports.
Recommended safeguards:
- Add endpoint-level benchmarks for the heavy routes.
- Add UI regression checks around timeline interaction paths.
- Roll out timeline changes behind a temporary feature flag if the payload shape changes significantly.
- Compare old and new aggregation outputs on the same imported dataset before removing legacy paths.
## Suggested Ticket Breakdown
1. Profile and baseline the four slowest endpoints plus timeline render path.
2. Refactor timeline read model to return pre-grouped data for current filters/view.
3. Replace timeline broad invalidations with scoped invalidation and selective cache patching.
4. Refactor chargeability report aggregation into grouped server-side structures.
5. Narrow lookup/filter support queries to selected IDs or visible pages.
6. Review PostgreSQL query plans and add only measured composite indexes.
7. Re-profile and decide whether virtualization or materialized analytics are still needed.
## Bottom Line
This codebase can be made substantially faster without removing functionality. The main gains will not come from cosmetic micro-optimizations. They will come from changing where data is shaped, reducing repeated scans, and stopping broad cache churn on heavy views.
+1 -1
View File
@@ -1,4 +1,4 @@
export { appRouter, type AppRouter } from "./router/index.js"; export { appRouter, type AppRouter } from "./router/index.js";
export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedure, protectedProcedure, managerProcedure, controllerProcedure, adminProcedure, requirePermission } from "./trpc.js"; export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedure, protectedProcedure, managerProcedure, controllerProcedure, adminProcedure, requirePermission, loadRoleDefaults, invalidateRoleDefaultsCache } from "./trpc.js";
export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js"; export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js";
export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js"; export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js";
+2 -1
View File
@@ -116,10 +116,11 @@ export const assistantRouter = createTRPCRouter({
const temperature = settings?.aiTemperature ?? 0.7; const temperature = settings?.aiTemperature ?? 0.7;
const model = settings?.azureOpenAiDeployment ?? "gpt-4o-mini"; const model = settings?.azureOpenAiDeployment ?? "gpt-4o-mini";
// 2. Resolve granular permissions // 2. Resolve granular permissions (using DB-based role defaults if available)
const permissions = resolvePermissions( const permissions = resolvePermissions(
userRole as SystemRole, userRole as SystemRole,
(ctx.dbUser?.permissionOverrides as PermissionOverrides | null) ?? null, (ctx.dbUser?.permissionOverrides as PermissionOverrides | null) ?? null,
ctx.roleDefaults ?? undefined,
); );
const permissionList = [...permissions]; const permissionList = [...permissions];
+2
View File
@@ -22,6 +22,7 @@ import { resourceRouter } from "./resource.js";
import { roleRouter } from "./role.js"; import { roleRouter } from "./role.js";
import { settingsRouter } from "./settings.js"; import { settingsRouter } from "./settings.js";
import { staffingRouter } from "./staffing.js"; import { staffingRouter } from "./staffing.js";
import { systemRoleConfigRouter } from "./system-role-config.js";
import { timelineRouter } from "./timeline.js"; import { timelineRouter } from "./timeline.js";
import { userRouter } from "./user.js"; import { userRouter } from "./user.js";
import { utilizationCategoryRouter } from "./utilization-category.js"; import { utilizationCategoryRouter } from "./utilization-category.js";
@@ -55,6 +56,7 @@ export const appRouter = createTRPCRouter({
chargeabilityReport: chargeabilityReportRouter, chargeabilityReport: chargeabilityReportRouter,
calculationRule: calculationRuleRouter, calculationRule: calculationRuleRouter,
computationGraph: computationGraphRouter, computationGraph: computationGraphRouter,
systemRoleConfig: systemRoleConfigRouter,
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;
+19 -7
View File
@@ -220,7 +220,7 @@ export const notificationRouter = createTRPCRouter({
}); });
}), }),
/** Get task counts for the current user */ /** Get task counts for the current user — single groupBy instead of 5 counts */
taskCounts: protectedProcedure.query(async ({ ctx }) => { taskCounts: protectedProcedure.query(async ({ ctx }) => {
const userId = await resolveUserId(ctx); const userId = await resolveUserId(ctx);
const now = new Date(); const now = new Date();
@@ -230,11 +230,12 @@ export const notificationRouter = createTRPCRouter({
category: { in: ["TASK" as const, "APPROVAL" as const] }, category: { in: ["TASK" as const, "APPROVAL" as const] },
}; };
const [open, inProgress, done, dismissed, overdue] = await Promise.all([ const [grouped, overdue] = await Promise.all([
ctx.db.notification.count({ where: { ...where, taskStatus: "OPEN" } }), ctx.db.notification.groupBy({
ctx.db.notification.count({ where: { ...where, taskStatus: "IN_PROGRESS" } }), by: ["taskStatus"],
ctx.db.notification.count({ where: { ...where, taskStatus: "DONE" } }), where,
ctx.db.notification.count({ where: { ...where, taskStatus: "DISMISSED" } }), _count: true,
}),
ctx.db.notification.count({ ctx.db.notification.count({
where: { where: {
...where, ...where,
@@ -244,7 +245,18 @@ export const notificationRouter = createTRPCRouter({
}), }),
]); ]);
return { open, inProgress, done, dismissed, overdue }; const counts: Record<string, number> = {};
for (const g of grouped) {
if (g.taskStatus) counts[g.taskStatus] = g._count;
}
return {
open: counts["OPEN"] ?? 0,
inProgress: counts["IN_PROGRESS"] ?? 0,
done: counts["DONE"] ?? 0,
dismissed: counts["DISMISSED"] ?? 0,
overdue,
};
}), }),
/** Update task status */ /** Update task status */
+48
View File
@@ -291,6 +291,54 @@ export const resourceRouter = createTRPCRouter({
return { resources, total, page, limit, nextCursor }; return { resources, total, page, limit, nextCursor };
}), }),
/** Lightweight resource card for hover tooltips on the timeline. */
getHoverCard: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const resource = await ctx.db.resource.findUnique({
where: { id: input.id },
select: {
id: true,
displayName: true,
eid: true,
email: true,
chapter: true,
lcrCents: true,
ucrCents: true,
currency: true,
chargeabilityTarget: true,
skills: true,
availability: true,
isActive: true,
areaRole: { select: { id: true, name: true, color: true } },
country: { select: { name: true, code: true } },
managementLevel: { select: { name: true } },
resourceType: true,
},
});
if (!resource) {
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
}
const directory = await getAnonymizationDirectory(ctx.db);
const anon = anonymizeResource(resource, directory);
return {
id: anon.id,
displayName: anon.displayName ?? "",
eid: anon.eid ?? "",
chapter: resource.chapter,
lcrCents: resource.lcrCents,
ucrCents: resource.ucrCents,
currency: resource.currency,
chargeabilityTarget: resource.chargeabilityTarget,
skills: resource.skills as Record<string, unknown>[],
isActive: resource.isActive,
resourceType: resource.resourceType,
areaRole: resource.areaRole,
country: resource.country,
managementLevel: resource.managementLevel,
};
}),
getById: protectedProcedure getById: protectedProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
+6
View File
@@ -48,6 +48,8 @@ export const settingsRouter = createTRPCRouter({
hasDalleApiKey: !!settings?.azureDalleApiKey, hasDalleApiKey: !!settings?.azureDalleApiKey,
// Vacation defaults // Vacation defaults
vacationDefaultDays: settings?.vacationDefaultDays ?? 28, vacationDefaultDays: settings?.vacationDefaultDays ?? 28,
// Timeline
timelineUndoMaxSteps: settings?.timelineUndoMaxSteps ?? 50,
}; };
}), }),
@@ -94,6 +96,8 @@ export const settingsRouter = createTRPCRouter({
azureDalleApiKey: z.string().optional(), azureDalleApiKey: z.string().optional(),
// Vacation // Vacation
vacationDefaultDays: z.number().int().min(0).max(365).optional(), vacationDefaultDays: z.number().int().min(0).max(365).optional(),
// Timeline
timelineUndoMaxSteps: z.number().int().min(1).max(200).optional(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -144,6 +148,8 @@ export const settingsRouter = createTRPCRouter({
data.azureDalleApiKey = input.azureDalleApiKey || null; data.azureDalleApiKey = input.azureDalleApiKey || null;
// Vacation // Vacation
if (input.vacationDefaultDays !== undefined) data.vacationDefaultDays = input.vacationDefaultDays; if (input.vacationDefaultDays !== undefined) data.vacationDefaultDays = input.vacationDefaultDays;
// Timeline
if (input.timelineUndoMaxSteps !== undefined) data.timelineUndoMaxSteps = input.timelineUndoMaxSteps;
await ctx.db.systemSettings.upsert({ await ctx.db.systemSettings.upsert({
where: { id: "singleton" }, where: { id: "singleton" },
@@ -0,0 +1,40 @@
import { z } from "zod";
import { adminProcedure, createTRPCRouter, invalidateRoleDefaultsCache, protectedProcedure } from "../trpc.js";
export const systemRoleConfigRouter = createTRPCRouter({
/** List all role configs (sorted by sortOrder) */
list: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.systemRoleConfig.findMany({
orderBy: { sortOrder: "asc" },
});
}),
/** Update a role's default permissions, label, description, and color */
update: adminProcedure
.input(
z.object({
role: z.string(),
label: z.string().min(1).optional(),
description: z.string().nullable().optional(),
color: z.string().nullable().optional(),
defaultPermissions: z.array(z.string()).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const data: Record<string, unknown> = {};
if (input.label !== undefined) data.label = input.label;
if (input.description !== undefined) data.description = input.description;
if (input.color !== undefined) data.color = input.color;
if (input.defaultPermissions !== undefined) data.defaultPermissions = input.defaultPermissions;
const result = await ctx.db.systemRoleConfig.update({
where: { role: input.role as never },
data,
});
// Invalidate cached role defaults so changes take effect immediately
invalidateRoleDefaultsCache();
return result;
}),
});
+176
View File
@@ -721,6 +721,182 @@ export const timelineRouter = createTRPCRouter({
return allocation; return allocation;
}), }),
/**
* Batch quick-assign multiple resources to a project for a date range.
* Used by the multi-selection floating action bar.
*/
batchQuickAssign: managerProcedure
.input(
z.object({
assignments: z
.array(
z.object({
resourceId: z.string(),
projectId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0.5).max(24).default(8),
role: z.string().min(1).max(200).default("Team Member"),
status: z
.nativeEnum(AllocationStatus)
.default(AllocationStatus.PROPOSED),
}),
)
.min(1)
.max(50),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
// Validate all date ranges
for (const a of input.assignments) {
if (a.endDate < a.startDate) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "End date must be after start date",
});
}
}
const results = await ctx.db.$transaction(async (tx) => {
const created = [];
for (const a of input.assignments) {
const percentage = Math.min(
100,
Math.round((a.hoursPerDay / 8) * 100),
);
const metadata = {
source: "batchQuickAssign",
} satisfies Record<string, unknown>;
const assignment = await createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
{
resourceId: a.resourceId,
projectId: a.projectId,
startDate: a.startDate,
endDate: a.endDate,
hoursPerDay: a.hoursPerDay,
percentage,
role: a.role,
status: a.status,
metadata,
},
);
created.push(assignment);
}
return created;
});
// Fire SSE events
for (const assignment of results) {
emitAllocationCreated({
id: assignment.id,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
});
}
return { count: results.length };
}),
/**
* Batch-shift multiple allocations by the same number of days.
* Used by multi-select drag on the timeline.
*/
batchShiftAllocations: managerProcedure
.input(
z.object({
allocationIds: z.array(z.string()).min(1).max(100),
daysDelta: z.number().int().min(-3650).max(3650),
mode: z.enum(["move", "resize-start", "resize-end"]).default("move"),
}),
)
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
if (input.daysDelta === 0) return { count: 0 };
// Load all allocations
const entries = await Promise.all(
input.allocationIds.map((id) => findAllocationEntry(ctx.db, id)),
);
const resolved = entries.filter(
(e): e is NonNullable<typeof e> => e !== null,
);
if (resolved.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "No allocations found" });
}
const results = await ctx.db.$transaction(async (tx) => {
const updated = [];
for (const entry of resolved) {
const existing = entry.entry;
const newStart = new Date(existing.startDate);
const newEnd = new Date(existing.endDate);
if (input.mode === "move") {
newStart.setDate(newStart.getDate() + input.daysDelta);
newEnd.setDate(newEnd.getDate() + input.daysDelta);
} else if (input.mode === "resize-start") {
newStart.setDate(newStart.getDate() + input.daysDelta);
// Clamp: start must not exceed end
if (newStart > newEnd) newStart.setTime(newEnd.getTime());
} else {
// resize-end
newEnd.setDate(newEnd.getDate() + input.daysDelta);
// Clamp: end must not precede start
if (newEnd < newStart) newEnd.setTime(newStart.getTime());
}
const result = await updateAllocationEntry(
tx as unknown as Parameters<typeof updateAllocationEntry>[0],
{
id: existing.id,
demandRequirementUpdate: {
startDate: newStart,
endDate: newEnd,
},
assignmentUpdate: {
startDate: newStart,
endDate: newEnd,
},
},
);
updated.push(result.allocation);
}
await tx.auditLog.create({
data: {
entityType: "Allocation",
entityId: input.allocationIds.join(","),
action: "UPDATE",
changes: {
operation: "batchShift",
mode: input.mode,
daysDelta: input.daysDelta,
count: resolved.length,
} as unknown as import("@planarchy/db").Prisma.InputJsonValue,
},
});
return updated;
});
// Fire SSE events
for (const alloc of results) {
emitAllocationUpdated({
id: alloc.id,
projectId: alloc.projectId,
resourceId: alloc.resourceId,
});
}
return { count: results.length };
}),
/** /**
* Get budget status for a project. * Get budget status for a project.
*/ */
+25 -1
View File
@@ -12,9 +12,21 @@ import { Prisma } from "@planarchy/db";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js"; import { findUniqueOrThrow } from "../db/helpers.js";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js"; import { adminProcedure, createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
export const userRouter = createTRPCRouter({ export const userRouter = createTRPCRouter({
/** Lightweight user list for task assignment (ADMIN + MANAGER) */
listAssignable: managerProcedure.query(async ({ ctx }) => {
return ctx.db.user.findMany({
select: {
id: true,
name: true,
email: true,
},
orderBy: { name: "asc" },
});
}),
list: adminProcedure.query(async ({ ctx }) => { list: adminProcedure.query(async ({ ctx }) => {
return ctx.db.user.findMany({ return ctx.db.user.findMany({
select: { select: {
@@ -23,11 +35,23 @@ export const userRouter = createTRPCRouter({
email: true, email: true,
systemRole: true, systemRole: true,
createdAt: true, createdAt: true,
lastLoginAt: true,
lastActiveAt: true,
permissionOverrides: true,
}, },
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}); });
}), }),
/** Count of users active in the last 5 minutes */
activeCount: adminProcedure.query(async ({ ctx }) => {
const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000);
const count = await ctx.db.user.count({
where: { lastActiveAt: { gte: fiveMinAgo } },
});
return { count };
}),
me: protectedProcedure.query(async ({ ctx }) => { me: protectedProcedure.query(async ({ ctx }) => {
const user = await findUniqueOrThrow( const user = await findUniqueOrThrow(
ctx.db.user.findUnique({ ctx.db.user.findUnique({
+36 -3
View File
@@ -15,16 +15,47 @@ export interface TRPCContext {
session: Session | null; session: Session | null;
db: typeof prisma; db: typeof prisma;
dbUser: { id: string; systemRole: string; permissionOverrides: unknown } | null; dbUser: { id: string; systemRole: string; permissionOverrides: unknown } | null;
roleDefaults: Record<string, PermissionKey[]> | null;
}
// Cache role defaults for 60 seconds to avoid DB hit on every request
let _roleDefaultsCache: Record<string, PermissionKey[]> | null = null;
let _roleDefaultsCacheTime = 0;
const ROLE_DEFAULTS_TTL = 60_000;
export async function loadRoleDefaults(): Promise<Record<string, PermissionKey[]>> {
const now = Date.now();
if (_roleDefaultsCache && now - _roleDefaultsCacheTime < ROLE_DEFAULTS_TTL) {
return _roleDefaultsCache;
}
const configs = await prisma.systemRoleConfig.findMany({
select: { role: true, defaultPermissions: true },
});
const map: Record<string, PermissionKey[]> = {};
for (const c of configs) {
map[c.role] = c.defaultPermissions as PermissionKey[];
}
_roleDefaultsCache = map;
_roleDefaultsCacheTime = now;
return map;
}
/** Invalidate the role defaults cache (call after updating SystemRoleConfig) */
export function invalidateRoleDefaultsCache(): void {
_roleDefaultsCache = null;
_roleDefaultsCacheTime = 0;
} }
export function createTRPCContext(opts: { export function createTRPCContext(opts: {
session: Session | null; session: Session | null;
dbUser?: { id: string; systemRole: string; permissionOverrides: unknown } | null; dbUser?: { id: string; systemRole: string; permissionOverrides: unknown } | null;
roleDefaults?: Record<string, PermissionKey[]> | null;
}): TRPCContext { }): TRPCContext {
return { return {
session: opts.session, session: opts.session,
db: prisma, db: prisma,
dbUser: opts.dbUser ?? null, dbUser: opts.dbUser ?? null,
roleDefaults: opts.roleDefaults ?? null,
}; };
} }
@@ -86,7 +117,8 @@ export const managerProcedure = protectedProcedure.use(({ ctx, next }) => {
} }
const permissions = resolvePermissions( const permissions = resolvePermissions(
user.systemRole as SystemRole, user.systemRole as SystemRole,
user.permissionOverrides as import("@planarchy/shared").PermissionOverrides | null user.permissionOverrides as import("@planarchy/shared").PermissionOverrides | null,
ctx.roleDefaults ?? undefined,
); );
return next({ ctx: { ...ctx, user, permissions } }); return next({ ctx: { ...ctx, user, permissions } });
}); });
@@ -104,7 +136,8 @@ export const controllerProcedure = protectedProcedure.use(({ ctx, next }) => {
} }
const permissions = resolvePermissions( const permissions = resolvePermissions(
user.systemRole as SystemRole, user.systemRole as SystemRole,
user.permissionOverrides as import("@planarchy/shared").PermissionOverrides | null user.permissionOverrides as import("@planarchy/shared").PermissionOverrides | null,
ctx.roleDefaults ?? undefined,
); );
return next({ ctx: { ...ctx, user, permissions } }); return next({ ctx: { ...ctx, user, permissions } });
}); });
@@ -117,7 +150,7 @@ export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (!user || user.systemRole !== SystemRole.ADMIN) { if (!user || user.systemRole !== SystemRole.ADMIN) {
throw new TRPCError({ code: "FORBIDDEN", message: "Admin role required" }); throw new TRPCError({ code: "FORBIDDEN", message: "Admin role required" });
} }
const permissions = resolvePermissions(SystemRole.ADMIN, null); const permissions = resolvePermissions(SystemRole.ADMIN, null, ctx.roleDefaults ?? undefined);
return next({ ctx: { ...ctx, user, permissions } }); return next({ ctx: { ...ctx, user, permissions } });
}); });
+22
View File
@@ -177,6 +177,8 @@ model User {
dashboardLayout Json? @db.JsonB dashboardLayout Json? @db.JsonB
columnPreferences Json? @db.JsonB columnPreferences Json? @db.JsonB
favoriteProjectIds Json? @db.JsonB // string[] of project IDs favoriteProjectIds Json? @db.JsonB // string[] of project IDs
lastLoginAt DateTime?
lastActiveAt DateTime?
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
@@ -1359,6 +1361,8 @@ model Notification {
@@index([userId, category, taskStatus]) @@index([userId, category, taskStatus])
@@index([nextRemindAt]) @@index([nextRemindAt])
@@index([assigneeId, taskStatus]) @@index([assigneeId, taskStatus])
@@index([category, nextRemindAt])
@@index([userId, dueDate])
@@map("notifications") @@map("notifications")
} }
@@ -1390,6 +1394,22 @@ model NotificationBroadcast {
@@map("notification_broadcasts") @@map("notification_broadcasts")
} }
// ─── System Role Configuration ────────────────────────────────────────────────
model SystemRoleConfig {
role SystemRole @id
label String // Display label, e.g. "Manager"
description String? // Optional description of the role
defaultPermissions Json @db.JsonB // PermissionKey[] — default permissions for this role
color String? // Badge color, e.g. "purple", "blue"
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("system_role_configs")
}
// ─── System Settings ────────────────────────────────────────────────────────── // ─── System Settings ──────────────────────────────────────────────────────────
model SystemSettings { model SystemSettings {
@@ -1419,6 +1439,8 @@ model SystemSettings {
anonymizationAliases Json? @db.JsonB anonymizationAliases Json? @db.JsonB
// Vacation defaults // Vacation defaults
vacationDefaultDays Int? @default(28) // default annual entitlement vacationDefaultDays Int? @default(28) // default annual entitlement
// Timeline undo
timelineUndoMaxSteps Int? @default(50) // max undo history depth
// DALL-E image generation (Azure requires separate deployment) // DALL-E image generation (Azure requires separate deployment)
azureDalleDeployment String? // e.g. "dall-e-3" — Azure DALL-E deployment name azureDalleDeployment String? // e.g. "dall-e-3" — Azure DALL-E deployment name
azureDalleEndpoint String? // Optional: separate endpoint for DALL-E (if different from chat) azureDalleEndpoint String? // Optional: separate endpoint for DALL-E (if different from chat)
+5 -2
View File
@@ -49,9 +49,12 @@ export const ROLE_DEFAULT_PERMISSIONS: Record<SystemRole, PermissionKey[]> = {
export function resolvePermissions( export function resolvePermissions(
systemRole: SystemRole, systemRole: SystemRole,
overrides?: PermissionOverrides | null overrides?: PermissionOverrides | null,
/** Optional DB-based defaults per role. If provided, takes precedence over ROLE_DEFAULT_PERMISSIONS. */
roleDefaults?: Record<string, PermissionKey[]>,
): Set<PermissionKey> { ): Set<PermissionKey> {
const base = new Set<PermissionKey>(ROLE_DEFAULT_PERMISSIONS[systemRole] ?? []); const defaults = roleDefaults?.[systemRole] ?? ROLE_DEFAULT_PERMISSIONS[systemRole] ?? [];
const base = new Set<PermissionKey>(defaults);
if (overrides?.granted) { if (overrides?.granted) {
for (const p of overrides.granted) base.add(p); for (const p of overrides.granted) base.add(p);
} }
+296 -372
View File
@@ -1,397 +1,321 @@
# Enterprise Notification & Task Management System # Right-Click Multi-Selection on Timeline
## Anforderungsanalyse ## Anforderungsanalyse
### Was wird gebaut? Vier neue Funktionen für die Timeline:
Ein mehrstufiges Notification- und Task-Management-System, das die bestehende Notification-Infrastruktur (Prisma-Model, Bell-Icon, SSE, SMTP) zu einem vollwertigen Enterprise-System ausbaut. Vier Kernfähigkeiten:
1. **Personal Reminders** — User legen eigene Erinnerungen an (Datum/Zeit, optionale Wiederholung, verknüpft mit Entity) 1. **Right-click + Drag Multi-Selection** — Rechtsklick + Drag auf dem Canvas, um mehrere Allocation-Blocks auszuwählen. Die Selektion kann über mehrere Ressourcen-Zeilen hinweggehen (Lasso/Rectangle-Selection).
2. **Targeted Notifications** — Admins/Manager senden Notifications an User, Rollen, Projektbeteiligte, OrgUnits
3. **Task Management** — Actionable Tasks mit Status-Tracking, Dashboard-Widget, Entity-Verknüpfung
4. **AI Assistant Integration** — Assistent liest offene Tasks, führt sie aus (Urlaub genehmigen, Allokation erstellen, etc.)
### Bestehende Infrastruktur (wiederverwendbar) 2. **Shift+Click Additive Selection** — Shift+Klick auf einen Allocation-Block fügt ihn zur bestehenden Multi-Selection hinzu (oder entfernt ihn, wenn bereits selektiert = Toggle). Ermöglicht präzise Einzelauswahl ohne Lasso. Funktioniert auch als Einstieg: Erster Shift+Click startet die Multi-Selection, weitere Shift+Clicks erweitern sie.
| Komponente | Status | Datei |
|------------|--------|-------|
| `Notification` Prisma-Model | Vorhanden (einfach) | `packages/db/prisma/schema.prisma:1291` |
| Notification tRPC-Router | list, unreadCount, markRead, create | `packages/api/src/router/notification.ts` |
| NotificationBell + Drawer | Bell-Icon mit Badge, Dropdown-Panel | `apps/web/src/components/notifications/NotificationBell.tsx` |
| SSE EventBus (Redis Pub/Sub) | `NOTIFICATION_CREATED` Event | `packages/api/src/sse/event-bus.ts` |
| SMTP Email | `sendEmail()` + SystemSettings | `packages/api/src/lib/email.ts` |
| AI Assistant Tools | `list_notifications`, `mark_notification_read` | `packages/api/src/router/assistant-tools.ts` |
| Dashboard Widget-Registry | 8 Widgets, Pattern etabliert | `apps/web/src/components/dashboard/widgets/` |
### Betroffene Pakete 3. **Floating Action Bar** — Bei aktiver Multi-Selection erscheint eine schwebende Toolbar mit:
- **packages/db** — Schema-Erweiterung (Notification -> Task-Felder, neues Broadcast-Model) - **"Delete / Cancel"** — Batch-Löschung aller selektierten Allocations (`allocation.batchDelete` existiert bereits)
- **packages/shared** — Enums, Typen, Zod-Schemas - **"Assign"** — Auf leeren Zeilen: Batch-Erstellung neuer Allocations über `timeline.quickAssign` (eine pro selektierter Ressource-Zeile)
- **packages/api** — Router-Erweiterung (notification.ts, assistant-tools.ts), Targeting-Logik, Scheduler - **"Clear Selection"** — Selektion aufheben
- **apps/web** — UI (Task-Widget, Reminder-UI, Notification-Center, Admin-Panel)
--- 4. **Multi-Resource Assignment** — Right-click + Drag über leere Zeilen mehrerer Ressourcen → öffnet einen Popover/Bar, der ein Projekt wählen lässt und dann Allocations für alle selektierten Ressourcen auf einmal erstellt.
## Datenmodell-Design **Betroffene Pakete:** `apps/web` (Frontend), `packages/api` (neuer `batchQuickAssign`-Endpoint)
### Erweiterung des bestehenden `Notification`-Models
Das bestehende Model wird um Task-/Reminder-/Targeting-Felder erweitert. Kein neues Model nötig — ein einheitliches System für Notifications + Tasks + Reminders.
```prisma
model Notification {
id String @id @default(cuid())
userId String
// -- Typ & Kategorie --
category NotificationCategory @default(NOTIFICATION) // NEU
type String // z.B. "VACATION_REQUESTED", "TASK_ASSIGNED", "REMINDER"
priority NotificationPriority @default(NORMAL) // NEU
// -- Inhalt --
title String
body String?
entityId String?
entityType String?
link String? // NEU: Deep-Link zur relevanten Seite
// -- Task-Felder (nur fuer category TASK / APPROVAL) --
taskStatus TaskStatus? // NEU: OPEN / IN_PROGRESS / DONE / DISMISSED
taskAction String? // NEU: maschinenlesbare Aktion z.B. "approve_vacation:clxyz123"
assigneeId String? // NEU: wem der Task zugewiesen ist
dueDate DateTime? // NEU: Faelligkeitsdatum
completedAt DateTime? // NEU: Zeitpunkt der Erledigung
completedBy String? // NEU: wer hat erledigt (User-ID, oder "ai-assistant")
// -- Reminder-Felder --
remindAt DateTime? // NEU: wann soll erinnert werden
recurrence String? // NEU: "daily" | "weekly" | "monthly" | null
nextRemindAt DateTime? // NEU: naechster Erinnerungszeitpunkt (berechnet)
// -- Targeting-Metadaten (fuer Bulk-Sends) --
sourceId String? // NEU: Referenz auf die urspruengliche Broadcast-Nachricht
senderId String? // NEU: wer hat die Notification erstellt (User-ID)
channel String @default("in_app") // NEU: "in_app" | "email" | "both"
// -- Timestamps --
readAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt // NEU
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
assignee User? @relation("taskAssignee", fields: [assigneeId], references: [id])
sender User? @relation("notificationSender", fields: [senderId], references: [id])
@@index([userId, readAt])
@@index([userId, category, taskStatus]) // NEU: Task-Queries
@@index([nextRemindAt]) // NEU: Reminder-Scheduler
@@index([assigneeId, taskStatus]) // NEU: Assigned-Tasks
@@map("notifications")
}
enum NotificationCategory {
NOTIFICATION // System/Admin-Benachrichtigung (read-only)
REMINDER // Persoenliche Erinnerung (self-created)
TASK // Actionable Task mit Status-Tracking
APPROVAL // Genehmigungsworkflow (approve/reject)
}
enum NotificationPriority {
LOW
NORMAL
HIGH
URGENT
}
enum TaskStatus {
OPEN
IN_PROGRESS
DONE
DISMISSED
}
```
### Broadcast-Model (fuer Gruppen-Notifications)
```prisma
model NotificationBroadcast {
id String @id @default(cuid())
senderId String
title String
body String?
link String?
category NotificationCategory @default(NOTIFICATION)
priority NotificationPriority @default(NORMAL)
channel String @default("in_app")
// -- Targeting --
targetType String // "user" | "role" | "project" | "orgUnit" | "all"
targetValue String? // Role-Name, Project-ID, OrgUnit-ID, oder null fuer "all"
// -- Scheduling --
scheduledAt DateTime? // null = sofort
sentAt DateTime?
recipientCount Int @default(0)
createdAt DateTime @default(now())
sender User @relation(fields: [senderId], references: [id])
@@index([senderId])
@@index([scheduledAt, sentAt])
@@map("notification_broadcasts")
}
```
### Task-Action Registry (Enterprise-Pattern)
Maschinenlesbare Aktionen ermoeglichen dem AI-Assistenten, Tasks direkt zu erledigen:
```typescript
// packages/api/src/lib/task-actions.ts
const TASK_ACTION_REGISTRY: Record<string, TaskActionHandler> = {
"approve_vacation": { permission: "manageVacations", execute: ... },
"reject_vacation": { permission: "manageVacations", execute: ... },
"fill_demand": { permission: "manageAllocations", execute: ... },
"confirm_allocation":{ permission: "manageAllocations", execute: ... },
"review_budget": { permission: "manageProjects", execute: ... },
};
```
Format: `"action_name:entity_id"` — einfach parsbar, erweiterbar.
---
## Betroffene Pakete & Dateien ## Betroffene Pakete & Dateien
| Paket | Dateien | Art | | Paket | Dateien | Art der Änderung |
|-------|---------|-----| |-------|---------|-----------------:|
| `packages/db` | `prisma/schema.prisma` | edit — Notification erweitern, Enums, Broadcast-Model | | apps/web | `src/hooks/useTimelineDrag.ts` | edit — neuer `multiSelectState`, right-click drag handler |
| `packages/shared` | `src/types/notification.ts` | create — Typen, Enums, Zod-Schemas | | apps/web | `src/components/timeline/TimelineView.tsx` | edit — multiSelectState empfangen, FloatingActionBar rendern, batch-Actions verdrahten |
| `packages/shared` | `src/types/enums.ts` | edit — re-exportieren | | apps/web | `src/components/timeline/FloatingActionBar.tsx` | create — schwebende Toolbar-Komponente |
| `packages/api` | `src/router/notification.ts` | edit — Task-CRUD, Reminder-CRUD, Broadcast, Targeting | | apps/web | `src/components/timeline/BatchAssignPopover.tsx` | create — Projekt-Picker für Multi-Resource-Assignment |
| `packages/api` | `src/router/index.ts` | edit — ggf. neuen Router registrieren | | apps/web | `src/components/timeline/TimelineResourcePanel.tsx` | edit — Selection-Overlay rendern, onContextMenu anpassen |
| `packages/api` | `src/router/assistant-tools.ts` | edit — neue Tools: list_tasks, execute_task_action, etc. | | apps/web | `src/components/timeline/TimelineProjectPanel.tsx` | edit — Selection-Overlay rendern, onContextMenu anpassen |
| `packages/api` | `src/router/assistant.ts` | edit — TOOL_PERMISSION_MAP + System-Prompt | | packages/api | `src/router/timeline.ts` | edit — neuer `batchQuickAssign`-Endpoint |
| `packages/api` | `src/sse/event-bus.ts` | edit — neue Event-Types |
| `packages/api` | `src/lib/email.ts` | edit — Notification-Email-Templates |
| `packages/api` | `src/lib/notification-targeting.ts` | create — Recipient-Aufloesung |
| `packages/api` | `src/lib/task-actions.ts` | create — Action-Registry |
| `packages/api` | `src/lib/reminder-scheduler.ts` | create — Reminder-Dispatcher |
| `apps/web` | `src/components/notifications/NotificationBell.tsx` | edit — Tabs, Task-Badge |
| `apps/web` | `src/components/notifications/NotificationCenter.tsx` | create — Full-Page |
| `apps/web` | `src/components/notifications/ReminderModal.tsx` | create |
| `apps/web` | `src/components/notifications/BroadcastModal.tsx` | create |
| `apps/web` | `src/components/notifications/TaskCard.tsx` | create |
| `apps/web` | `src/components/dashboard/widgets/TaskWidget.tsx` | create |
| `apps/web` | `src/app/(app)/notifications/page.tsx` | create |
| `apps/web` | `src/app/(app)/admin/notifications/page.tsx` | create |
| `apps/web` | `src/components/layout/AppShell.tsx` | edit — Nav-Links |
| `apps/web` | `src/hooks/useTimelineSSE.ts` | edit — Task-Events |
--- ## Architektur-Entscheidungen
## Task-Liste (atomare Schritte) ### Right-click vs. Left-click vs. Shift-click Abgrenzung
- **Left-click drag** (button 0): Bestehende Funktionen — Alloc-Move, Alloc-Resize, Range-Select (NewAllocationPopover)
- **Right-click drag** (button 2): Neue Multi-Selection (Lasso-Rectangle)
- **Right-click ohne Drag** auf bestehende Allocation: Öffnet weiterhin `AllocationPopover` (Einzelbearbeitung) — das ist der bestehende `onContextMenu`-Handler
- **Shift+Click** auf Allocation-Block: Toggle-Selektion (addiert/entfernt Block zur/aus Multi-Selection). Startet Multi-Selection wenn noch keine aktiv. Kein Drag nötig — sofortige Einzelauswahl.
### Phase N.1 — Datenmodell & Shared Types ### Multi-Select State
Neuer State `MultiSelectState` im `useTimelineDrag`-Hook:
- [ ] **Task 1:** Shared-Typen erstellen -> `packages/shared/src/types/notification.ts` ```ts
- NotificationCategory, NotificationPriority, TaskStatus Enums interface MultiSelectState {
- CreateReminderSchema, CreateBroadcastSchema, UpdateTaskStatusSchema (Zod) isSelecting: boolean;
- TaskAction Interface // Rectangle coordinates (canvas-relative pixels)
startX: number;
- [ ] **Task 2:** Prisma-Schema erweitern -> `packages/db/prisma/schema.prisma` startY: number;
- Notification-Model: category, priority, taskStatus, taskAction, assigneeId, dueDate, completedAt, completedBy, remindAt, recurrence, nextRemindAt, sourceId, senderId, channel, link, updatedAt currentX: number;
- Enums: NotificationCategory, NotificationPriority, TaskStatus currentY: number;
- Model: NotificationBroadcast // Resolved after mouseUp:
- User-Relations: taskAssignee, notificationSender, broadcasts selectedAllocationIds: string[];
- Indexes: [userId, category, taskStatus], [nextRemindAt], [assigneeId, taskStatus] selectedResourceIds: string[]; // Resources within the rectangle (for empty-row assign)
dateRange: { start: Date; end: Date } | null;
- [ ] **Task 3:** `pnpm db:push` + Dev-Server neu starten }
### Phase N.2 — API: Router + Targeting + Scheduler
- [ ] **Task 4:** SSE Event-Types erweitern -> `packages/api/src/sse/event-bus.ts`
- TASK_ASSIGNED, TASK_COMPLETED, TASK_STATUS_CHANGED, REMINDER_DUE, BROADCAST_SENT
- Emit-Helper: emitTaskAssigned(), emitTaskCompleted(), emitReminderDue()
- [ ] **Task 5:** Notification-Router erweitern -> `packages/api/src/router/notification.ts`
- list: Filter nach category, taskStatus, priority
- listTasks (protectedProcedure): offene Tasks + zugewiesene Tasks
- taskCounts (protectedProcedure): Counts nach Status
- updateTaskStatus (protectedProcedure): OPEN->IN_PROGRESS->DONE/DISMISSED
- createReminder (protectedProcedure): eigene Erinnerung anlegen
- updateReminder / deleteReminder (protectedProcedure)
- createBroadcast (managerProcedure): Targeted Notification an Gruppe
- listBroadcasts (managerProcedure)
- createTask (managerProcedure): Task fuer User/Gruppe
- assignTask (managerProcedure): Task zuweisen
- delete (protectedProcedure): eigene Notifications loeschen
- [ ] **Task 6:** Broadcast-Targeting -> `packages/api/src/lib/notification-targeting.ts` (create)
- resolveRecipients(targetType, targetValue, db): User-IDs aufloesen
- "user" -> einzelner User
- "role" -> alle User mit SystemRole
- "project" -> Ressourcen mit aktiver Allokation -> verknuepfte User
- "orgUnit" -> Ressourcen in OrgUnit -> verknuepfte User
- "all" -> alle aktiven User
- [ ] **Task 7:** Email-Templates -> `packages/api/src/lib/email.ts` (edit)
- sendNotificationEmail(userId, notification): HTML mit Title, Body, Deep-Link
- sendTaskEmail(userId, task): Template mit Task-Details + Action-Link
- [ ] **Task 8:** Task-Action-Registry -> `packages/api/src/lib/task-actions.ts` (create)
- Registry-Pattern: action_name -> { permission, execute(entityId, ctx) }
- Initiale Actions: approve_vacation, reject_vacation, fill_demand, confirm_allocation
- [ ] **Task 9:** Reminder-Scheduler -> `packages/api/src/lib/reminder-scheduler.ts` (create)
- Intervall (60s): WHERE nextRemindAt <= NOW()
- Fuer jeden faelligen Reminder: In-App Notification + optional Email
- nextRemindAt neu berechnen oder null setzen
- Catch-up bei Start (ueberfaellige sofort ausloesen)
### Phase N.3 — AI Assistant Integration
- [ ] **Task 10:** Neue Tool-Definitionen -> `packages/api/src/router/assistant-tools.ts`
- list_tasks: offene Tasks/Approvals mit Filter
- get_task_detail: Details inkl. verknuepfter Entity
- update_task_status: Status aendern
- execute_task_action: maschinenlesbare Aktion ausfuehren
- create_reminder: Erinnerung anlegen
- create_task_for_user: Task fuer anderen User (Manager-only)
- send_broadcast: Notification an Gruppe (Manager-only)
- [ ] **Task 11:** Tool-Executors implementieren -> `packages/api/src/router/assistant-tools.ts`
- execute_task_action: parst taskAction-String, dispatcht an Action-Registry
- Permission-Check pro Action (nicht pauschal)
- [ ] **Task 12:** Permission-Map + Prompt -> `packages/api/src/router/assistant.ts`
- TOOL_PERMISSION_MAP erweitern
- System-Prompt: Tasks/Reminders als Faehigkeit beschreiben
### Phase N.4 — Frontend
- [ ] **Task 13:** NotificationBell erweitern -> `apps/web/src/components/notifications/NotificationBell.tsx`
- Zweiter Badge: Task-Count (orange) neben Notification-Count (rot)
- Tabs: "Alle" | "Tasks" | "Erinnerungen"
- Task-Items mit Quick-Actions (Done/Dismiss)
- Link zu "/notifications"
- [ ] **Task 14:** TaskCard-Komponente -> `apps/web/src/components/notifications/TaskCard.tsx` (create)
- Titel, Body, Due-Date, Priority-Badge, Entity-Link
- Aktionen: "Start" / "Done" / "Dismiss"
- Approval-Variante: "Approve" / "Reject"
- Priority-farbcodiert (URGENT=rot, HIGH=orange, NORMAL=blau, LOW=grau)
- [ ] **Task 15:** ReminderModal -> `apps/web/src/components/notifications/ReminderModal.tsx` (create)
- Titel, Body, Datum/Uhrzeit, Wiederholung (keine/taeglich/woechentlich/monatlich)
- Optional: Entity-Verknuepfung (Projekt/Ressource Dropdown)
- [ ] **Task 16:** BroadcastModal -> `apps/web/src/components/notifications/BroadcastModal.tsx` (create)
- Manager/Admin-only
- Targeting: Dropdown (Alle/Rolle/Projekt/OrgUnit) + Wert-Auswahl
- Inhalt: Titel, Body, Priority, Kategorie
- Kanal: In-App / Email / Beides
- Scheduling: Sofort / Zeitgesteuert
- Vorschau: "Wird an X Empfaenger gesendet"
- [ ] **Task 17:** NotificationCenter -> `apps/web/src/app/(app)/notifications/page.tsx` (create)
- Tabs: Alle | Notifications | Tasks | Reminders | Approvals
- Filter: Status, Priority, Zeitraum
- Bulk: "Alle lesen", "Alle erledigt"
- "Neue Erinnerung" Button
- [ ] **Task 18:** TaskWidget -> `apps/web/src/components/dashboard/widgets/TaskWidget.tsx` (create)
- Kompakte Liste offener Tasks (max 5-7)
- Sortiert: Priority -> Due-Date
- Quick-Actions: Done/Dismiss
- Footer: "X offene Tasks — Alle anzeigen"
- In Widget-Registry eintragen
- [ ] **Task 19:** Admin Broadcast-Seite -> `apps/web/src/app/(app)/admin/notifications/page.tsx` (create)
- Liste gesendeter Broadcasts
- "Neue Benachrichtigung senden" Button
- Statistiken: gesendet/gelesen pro Broadcast
- [ ] **Task 20:** AppShell Navigation -> `apps/web/src/components/layout/AppShell.tsx` (edit)
- "Notifications" fuer alle Rollen
- "Broadcast" unter Admin (ADMIN/MANAGER)
- [ ] **Task 21:** SSE-Hook -> `apps/web/src/hooks/useTimelineSSE.ts` (edit)
- Auf TASK_ASSIGNED, TASK_COMPLETED, REMINDER_DUE reagieren
- React-Query invalidieren: notification.listTasks, notification.taskCounts
### Phase N.5 — Auto-Tasks & Audit
- [ ] **Task 22:** Automatische Task-Erzeugung bei Business-Events
- vacation.create -> Task "Urlaubsantrag genehmigen" an Manager (APPROVAL)
- Ueberallokation -> Task "Ueberallokation aufloesen" an Manager
- Projekt-Deadline < 30 Tage + offene Demands -> Task "Demands besetzen"
- demand.create -> Task "Demand besetzen" an Manager
- [ ] **Task 23:** Audit-Trail -> `packages/api/src/lib/audit.ts` (create)
- logTaskAction(taskId, userId, action, details)
- completedBy: "ai-assistant" fuer AI-erledigte Tasks
---
## Abhaengigkeiten
```
Task 1 (Shared Types) ---+
Task 2 (Schema) ---------+--> Task 3 (db:push)
|
Task 3 --> Task 4 (SSE) |
Task 3 --> Task 5 (Router)|
Task 3 --> Task 6 (Targeting)
Task 3 --> Task 7 (Email) |
Task 3 --> Task 8 (Actions)|
|
Task 5 + 6 --> Task 9 (Scheduler)
Task 5 + 8 --> Task 10-12 (AI)
|
Task 5 --> Task 13-21 (Frontend, parallel moeglich)
Task 5 --> Task 22 (Auto-Tasks)
``` ```
**Parallel:** ### Intersection-Logik
- Task 4 + 5 + 6 + 7 + 8 (verschiedene Dateien) Die Selektion geschieht als **Rectangle Intersection**:
- Task 13-21 (verschiedene Dateien, 13+14 vor 17+18 empfohlen) 1. Während des Drag wird ein visuelles Rechteck gezeichnet (semi-transparenter blauer Rahmen)
2. Bei mouseUp wird berechnet, welche Allocation-Blocks innerhalb des Rechtecks liegen (Pixel-basiert: Block-Position vs. Selection-Rect)
3. Selektierte Blocks erhalten einen visuellen Highlight (z.B. `ring-2 ring-brand-500`)
**Sequentiell:** **Berechnung der Intersection:** Da die Allocation-Blocks als absolute `left/width/top/height` positioniert sind, können wir die Intersection über die `toLeft()`/`toWidth()`-Funktionen + Zeilen-Index berechnen. Die Berechnung geschieht im `TimelineResourcePanel`/`TimelineProjectPanel` und gibt IDs zurück.
- Task 1 -> 2 -> 3 (Schema)
- Task 5 -> 10 -> 11 (Router -> Tools -> Executors)
--- ### Batch-API
- **Delete:** `allocation.batchDelete` existiert bereits (max 100 IDs)
- **Assign:** Neuer `timeline.batchQuickAssign`-Endpoint, der ein Array von `{ resourceId, projectId, startDate, endDate, hoursPerDay }` akzeptiert und in einer Transaktion erstellt
## Task-Liste
### Task 1: Multi-Select State im Drag-Hook
- [ ] **Task 1a:** `MultiSelectState` Interface + Initial State definieren → Datei: `useTimelineDrag.ts`
```ts
export interface MultiSelectState {
isSelecting: boolean;
startX: number;
startY: number;
currentX: number;
currentY: number;
selectedAllocationIds: string[];
selectedResourceIds: string[];
dateRange: { start: Date; end: Date } | null;
}
```
- [ ] **Task 1b:** Right-click drag handlers implementieren → Datei: `useTimelineDrag.ts`
Neuer `onCanvasRightMouseDown` Handler:
- Prüfe `e.button === 2`
- `e.preventDefault()` (verhindert nativen Kontextmenü)
- Starte `multiSelectState` mit `isSelecting: true` und Mausposition
- Registriere `document.addEventListener("mousemove", ...)` und `document.addEventListener("mouseup", ...)` (analog zum AllocDrag-Pattern)
- Bei mousemove: Update `currentX/currentY`
- Bei mouseUp ohne Bewegung (< 5px): Fallback auf bestehenden `onAllocationContextMenu` (Einzelblock-Rechtsklick)
- Bei mouseUp mit Bewegung: Setze `isSelecting: false` aber behalte `selectedAllocationIds`/`selectedResourceIds` (werden vom Parent berechnet und reingesetzt)
Neuer `onCanvasContextMenu` Handler:
- Wird auf dem Canvas registriert, um `e.preventDefault()` global zu setzen (verhindert Browser-Kontextmenü)
Return-Werte erweitern um `multiSelectState`, `setMultiSelectState`, `onCanvasRightMouseDown`, `clearMultiSelect`.
- [ ] **Task 1c:** `clearMultiSelect` Funktion → Datei: `useTimelineDrag.ts`
Setzt `multiSelectState` auf Initial zurück. Wird von ESC-Handler und FloatingActionBar genutzt.
### Task 1d: Shift+Click Toggle-Selection
- [ ] **Task 1d:** Shift+Click Handler für Allocation-Blocks → Datei: `useTimelineDrag.ts`
Im bestehenden `onAllocMouseDown`-Handler (und im mouseUp-Pfad wo `daysDelta === 0` als Click behandelt wird):
**Logik im mouseUp (daysDelta === 0):**
```ts
if (e.shiftKey) {
// Toggle this allocation in multi-select
setMultiSelectState(prev => {
const ids = new Set(prev.selectedAllocationIds);
if (ids.has(alloc.allocationId)) {
ids.delete(alloc.allocationId); // Deselect
} else {
ids.add(alloc.allocationId); // Add to selection
}
return { ...prev, isSelecting: false, selectedAllocationIds: [...ids] };
});
return; // Don't open popover
}
// ... existing click → popover logic
```
**Wichtig:** Der Shift-Key-Check muss im mouseUp geschehen (nicht mouseDown), weil erst dort feststeht ob es ein Click (daysDelta === 0) oder Drag war.
**Interaktion mit bestehendem Code:**
- `onAllocMouseDown` startet den Drag (button 0 check auf Zeile 311)
- Im `handleUp` closure (Zeile 370): Wenn `daysDelta === 0` → aktuell wird `onBlockClickRef.current` aufgerufen
- **Änderung:** Vor dem `onBlockClick`-Call prüfen ob `shiftKey` gedrückt war (muss im mouseDown-Event gespeichert werden, da mouseUp ein document-event ist und das Original-React-Event nicht mehr verfügbar)
- → `shiftKeyRef` im Closure capturen: `const wasShift = e.shiftKey;` im `onAllocMouseDown`
Neuer Callback in Hook-Return: `onShiftClickAllocation?: (allocationId: string) => void` — wird vom Parent (`TimelineView`) gesetzt und toggelt den multiSelectState.
**Alternativ (einfacher):** Den Shift-Check direkt in `TimelineView.onBlockClick` machen, da dieser Callback bereits den `multiSelectState`-Zugriff hat.
### Task 2: Selection-Overlay in Panels rendern
- [ ] **Task 2a:** Selection-Rectangle als visuelles Overlay rendern → Datei: `TimelineResourcePanel.tsx`
Props erweitern um `multiSelectState: MultiSelectState`.
Neues `<div>` im Canvas-Bereich:
```tsx
{multiSelectState.isSelecting && (
<div
className="absolute border-2 border-brand-500 bg-brand-500/10 pointer-events-none z-30 rounded"
style={{
left: Math.min(multiSelectState.startX, multiSelectState.currentX),
top: Math.min(multiSelectState.startY, multiSelectState.currentY),
width: Math.abs(multiSelectState.currentX - multiSelectState.startX),
height: Math.abs(multiSelectState.currentY - multiSelectState.startY),
}}
/>
)}
```
Allocation-Blocks die in `selectedAllocationIds` sind: Extra CSS-Klasse `ring-2 ring-brand-500 ring-offset-1 z-20`.
- [ ] **Task 2b:** Dasselbe für `TimelineProjectPanel.tsx`
### Task 3: Intersection-Berechnung
- [ ] **Task 3a:** Funktion `computeSelectedAllocations` → Datei: `TimelineResourcePanel.tsx` (oder neue Utility-Datei)
Wird als `useMemo` in `TimelineViewContent` berechnet. Nimmt `multiSelectState` + Layout-Daten (`toLeft`, `toWidth`, Row-Höhen, Ressource-Reihenfolge) und gibt `{ allocationIds: string[], resourceIds: string[], dateRange }` zurück.
**Algorithmus:**
1. Konvertiere Pixel-Rechteck zu Datums-Range (via `xToDate`) und Zeilen-Range (via Row-Index-Berechnung)
2. Für jede Ressource im Zeilen-Range: Prüfe alle Allocations ob sie zeitlich überlappen
3. Sammle Treffer-IDs
**Wichtig:** Die Berechnung muss die Scroll-Position des Containers berücksichtigen (`scrollContainerRef.scrollLeft/scrollTop`).
- [ ] **Task 3b:** `setMultiSelectState` mit berechneten IDs updaten → Datei: `TimelineView.tsx`
Nach der Intersection-Berechnung via `useEffect`: Wenn `multiSelectState` sich ändert und nicht mehr `isSelecting`, update die `selectedAllocationIds`/`selectedResourceIds`.
### Task 4: Floating Action Bar
- [ ] **Task 4a:** `FloatingActionBar` Komponente erstellen → Datei: `FloatingActionBar.tsx`
```tsx
interface FloatingActionBarProps {
selectedCount: number;
selectedResourceCount: number;
onDelete: () => void;
onAssign: () => void;
onClear: () => void;
isDeleting: boolean;
}
```
Positionierung: `fixed bottom-6 left-1/2 -translate-x-1/2` — zentriert am unteren Bildschirmrand.
UI: Pill-förmige Bar mit:
- Zähler: "3 allocations selected" oder "5 resources × 10 days selected"
- Delete-Button (rot, nur wenn Allocations selektiert)
- Assign-Button (brand, nur wenn leere Ressource-Zeilen im Bereich)
- Clear-Button (grau)
- Keyboard hint: "ESC to clear"
Dark-Mode: `dark:bg-gray-800 dark:border-gray-700` etc.
- [ ] **Task 4b:** FloatingActionBar in `TimelineView.tsx` einbinden
Rendern wenn `multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0`.
Actions verdrahten:
- Delete → `allocation.batchDelete.mutate({ ids: selectedAllocationIds })`
- Assign → öffnet `BatchAssignPopover`
- Clear → `clearMultiSelect()`
ESC-Handler erweitern: Multi-Select hat Priorität vor anderen Overlays.
### Task 5: Batch-Assign Popover
- [ ] **Task 5a:** `BatchAssignPopover` Komponente erstellen → Datei: `BatchAssignPopover.tsx`
Ähnlich wie `NewAllocationPopover`, aber für mehrere Ressourcen:
- Projekt-Picker (Dropdown mit Suche)
- Hours/day Selector
- Anzeige: "Assigning to N resources, Start End"
- "Assign All" Button
Props:
```tsx
interface BatchAssignPopoverProps {
resourceIds: string[];
startDate: Date;
endDate: Date;
onClose: () => void;
onCreated: () => void;
}
```
- [ ] **Task 5b:** `batchQuickAssign` API-Endpoint erstellen → Datei: `packages/api/src/router/timeline.ts`
```ts
batchQuickAssign: managerProcedure
.input(z.object({
assignments: z.array(z.object({
resourceId: z.string(),
projectId: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
hoursPerDay: z.number().min(0.5).max(24).default(8),
role: z.string().min(1).max(200).default("Team Member"),
status: z.nativeEnum(AllocationStatus).default(AllocationStatus.PROPOSED),
})).min(1).max(50),
}))
.mutation(async ({ ctx, input }) => {
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
// Validate all, then create in single transaction
})
```
### Task 6: Integration & Polish
- [ ] **Task 6a:** `onContextMenu` auf Canvas-Level `e.preventDefault()` setzen → Datei: `TimelineView.tsx`
Damit das Browser-Kontextmenü nicht erscheint während der Rechtsklick-Drag aktiv ist.
- [ ] **Task 6b:** ESC-Handler Priorität anpassen → Datei: `TimelineView.tsx`
Reihenfolge: Multi-Select clear → Popover → NewAllocPopover → DemandModal → ProjectPanel
- [ ] **Task 6c:** Cursor-Style anpassen → Datei: `TimelineView.tsx`
`cursor-crosshair` wenn Multi-Select aktiv.
- [ ] **Task 6d:** Confirmation-Dialog vor Batch-Delete → Datei: `FloatingActionBar.tsx` oder `TimelineView.tsx`
Einfacher `window.confirm()` oder inline-Bestätigung: "Delete 5 allocations? This cannot be undone."
## Abhängigkeiten
- Task 1 (Hook) muss vor Task 26 abgeschlossen sein (State-Grundlage)
- Task 2 (Overlay) und Task 3 (Intersection) können parallel entwickelt werden, aber Task 3 hängt logisch von Task 2 ab (Overlay-Koordinaten)
- Task 4 (ActionBar) hängt von Task 1 ab (braucht multiSelectState)
- Task 5a (BatchAssignPopover) ist unabhängig vom Rest
- Task 5b (API) ist unabhängig vom Rest
- Task 6 (Integration) kommt zuletzt
**Empfohlene Reihenfolge:** Task 1a → 1b → 1c → parallel(Task 2a+2b, Task 5b) → Task 3a → 3b → Task 4a → 4b → Task 5a → Task 6a6d
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] `pnpm db:push` ohne Fehler - [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — keine neuen Errors
- [ ] `pnpm --filter @planarchy/api exec tsc --noEmit`0 Errors - [ ] `pnpm --filter @planarchy/api exec tsc --noEmit` — keine neuen Errors
- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — 0 Errors - [ ] Resource View: Rechtsklick + Drag zeichnet Selection-Rechteck über Canvas
- [ ] `pnpm test:unit` — alle Tests gruen - [ ] Resource View: Allocation-Blocks innerhalb des Rechtecks werden highlighted
- [ ] User kann eigene Erinnerung anlegen (Datum, Wiederholung, Entity) - [ ] Floating Action Bar erscheint mit korrektem Zähler
- [ ] Admin/Manager kann Broadcast an Rolle/Projekt/OrgUnit senden - [ ] "Delete" löscht alle selektierten Allocations nach Bestätigung
- [ ] Broadcast erzeugt individuelle Notifications pro Empfaenger - [ ] "Assign" öffnet BatchAssignPopover mit korrekten Ressourcen und Datumrange
- [ ] Tasks im Dashboard-Widget, sortiert nach Priority + Due-Date - [ ] BatchAssign erstellt Allocations für alle selektierten Ressourcen in einer Transaktion
- [ ] Task-Status aenderbar ueber UI (Open -> In Progress -> Done/Dismissed) - [ ] Project View: Multi-Selection funktioniert analog
- [ ] AI-Assistent kann list_tasks aufrufen und offene Tasks anzeigen - [ ] Rechtsklick auf einzelnen Block ohne Drag: Öffnet weiterhin AllocationPopover
- [ ] AI-Assistent kann execute_task_action ausfuehren (z.B. Urlaub genehmigen) - [ ] Shift+Click auf Allocation-Block: Fügt ihn zur Multi-Selection hinzu (FloatingActionBar erscheint)
- [ ] Erledigte Tasks zeigen completedBy (User oder "AI-Assistent") - [ ] Shift+Click auf bereits selektierten Block: Entfernt ihn aus der Selection
- [ ] Email-Versand bei channel "email" oder "both" - [ ] Shift+Click + Rechtsklick-Drag kombinierbar: Erst Shift-Clicks, dann Lasso erweitert die Selection
- [ ] SSE-Events invalidieren React-Query-Caches - [ ] ESC räumt Multi-Selection auf
- [ ] Reminder-Scheduler erzeugt puenktlich Notifications - [ ] Dark Mode: Alle neuen Komponenten haben `dark:` Klassen
- [ ] RBAC: User sehen nur eigene; Manager zugewiesene; Admin Broadcasts - [ ] Browser-Kontextmenü wird unterdrückt während Multi-Select aktiv
---
## Risiken & offene Fragen ## Risiken & offene Fragen
### Risiken - **Scroll-Position:** Die Intersection-Berechnung muss `scrollLeft`/`scrollTop` berücksichtigen. Sonst stimmen die Pixel-Koordinaten nicht mit den Allocation-Positionen überein.
1. **Reminder-Scheduler Zuverlaessigkeit**: Node.js-setInterval kann bei Restart verpassen. Mitigation: Catch-up bei Start (alle ueberfaelligen sofort ausloesen). - **Virtualisierung:** `TimelineResourcePanel` nutzt `@tanstack/react-virtual`. Nicht-sichtbare Zeilen sind nicht im DOM → die Intersection-Berechnung muss auf Daten-Ebene (nicht DOM-Ebene) erfolgen.
2. **Broadcast-Skalierung**: "An alle" mit 500 Usern = 500 Rows. Mitigation: Batch-Insert (createMany). - **Performance:** Bei vielen Allocations könnte die Intersection-Berechnung während des Drag teuer werden. Lösung: Nur bei mouseUp berechnen, nicht während mousemove.
3. **Task-Action-Sicherheit**: Permissions pro Action pruefen, nicht pauschal. Mitigation: Action-Registry mit Permission pro Handler. - **Rechtsklick-Einzelblock:** Muss sauber vom Drag unterschieden werden (< 5px Threshold). Der bestehende `onContextMenu`-Handler auf Allocation-Blocks (`e.stopPropagation()`) sollte erhalten bleiben.
4. **Schema-Migration**: Neue Felder nullable oder mit Default -> bestehende Notifications funktionieren weiter. - **Touch-Support:** Rechtsklick hat kein Touch-Äquivalent. Long-press wäre möglich, ist aber ein separates Feature. Zunächst nur Mouse.
- **`batchQuickAssign` Limit:** Max 50 Assignments pro Call, um DB-Last zu begrenzen.
### Offene Fragen
1. **Scheduler**: setInterval im SSE-Handler oder separater Worker/Cron? Empfehlung: setInterval (reicht fuer <1000 User)
2. **Task-Delegation**: User duerfen Tasks an andere weiterdelegieren? Empfehlung: Ja (Manager-only)
3. **Retention**: Wie lange alte Notifications aufbewahren? Empfehlung: 90 Tage Auto-Cleanup
4. **Recurring Tasks**: Tasks wiederkehrend wie Reminders? Empfehlung: Phase 2
5. **Approval-Chains**: Mehrstufige Genehmigung? Empfehlung: Phase 2, erstmal einstufig
Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB