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";
const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ["recharts", "date-fns"],
},
transpilePackages: [
"@planarchy/api",
"@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() {
return <AllocationsClient />;
@@ -70,18 +70,18 @@ type EstimateDetail = {
};
const STATUS_STYLES: Record<EstimateStatus, string> = {
DRAFT: "bg-slate-100 text-slate-700",
IN_REVIEW: "bg-amber-100 text-amber-700",
APPROVED: "bg-emerald-100 text-emerald-700",
ARCHIVED: "bg-zinc-200 text-zinc-700",
DRAFT: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
IN_REVIEW: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
ARCHIVED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
};
const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
WORKING: "bg-sky-100 text-sky-700",
BASELINE: "bg-violet-100 text-violet-700",
SUBMITTED: "bg-amber-100 text-amber-700",
APPROVED: "bg-emerald-100 text-emerald-700",
SUPERSEDED: "bg-zinc-200 text-zinc-700",
WORKING: "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300",
BASELINE: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
SUBMITTED: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
SUPERSEDED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
};
function formatMetricValue(metric: EstimateMetric) {
@@ -146,7 +146,7 @@ function EstimateDetailPanel({
<div className="mt-4 flex gap-2">
<Link
href={`/estimates/${estimate.id}`}
className="inline-flex items-center justify-center rounded-2xl border border-brand-200 bg-brand-50 px-4 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-100"
className="inline-flex items-center justify-center rounded-2xl border border-brand-200 dark:border-sky-700 bg-brand-50 dark:bg-sky-950/40 px-4 py-2 text-sm font-semibold text-brand-700 dark:text-sky-300 transition hover:border-brand-300 dark:hover:border-sky-600 hover:bg-brand-100 dark:hover:bg-sky-900/40"
>
Open workspace
</Link>
@@ -165,7 +165,7 @@ function EstimateDetailPanel({
{latestVersion ? (
<>
<div className="mt-5 flex items-center gap-2">
<span className="text-sm font-medium text-gray-700">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Version {latestVersion.versionNumber}
{latestVersion.label ? ` - ${latestVersion.label}` : ""}
</span>
@@ -212,7 +212,7 @@ function EstimateDetailPanel({
</div>
<div className="mt-3 space-y-2">
{latestVersion.scopeItems.length === 0 ? (
<p className="rounded-2xl border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-400">
<p className="rounded-2xl border border-dashed border-gray-200 dark:border-gray-700 px-4 py-3 text-sm text-gray-400">
No scope rows captured yet.
</p>
) : (
@@ -245,7 +245,7 @@ function EstimateDetailPanel({
</div>
<div className="mt-3 space-y-2">
{latestVersion.demandLines.length === 0 ? (
<p className="rounded-2xl border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-400">
<p className="rounded-2xl border border-dashed border-gray-200 dark:border-gray-700 px-4 py-3 text-sm text-gray-400">
No staffing demand captured yet.
</p>
) : (
@@ -273,7 +273,7 @@ function EstimateDetailPanel({
</div>
</>
) : (
<p className="mt-6 rounded-2xl border border-dashed border-gray-200 px-4 py-6 text-sm text-gray-400">
<p className="mt-6 rounded-2xl border border-dashed border-gray-200 dark:border-gray-700 px-4 py-6 text-sm text-gray-400">
No versions available for this estimate yet.
</p>
)}
@@ -302,8 +302,8 @@ function EstimateCard({
className={clsx(
"w-full rounded-3xl border p-5 text-left transition",
active
? "border-brand-500 bg-brand-50 shadow-sm dark:bg-brand-950/30"
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm dark:border-gray-800 dark:bg-gray-950 dark:hover:border-gray-700",
? "border-brand-500 bg-brand-50 shadow-sm dark:border-sky-400 dark:bg-sky-950/30"
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600",
!canInspect && "cursor-default",
)}
>
@@ -319,7 +319,7 @@ function EstimateCard({
{estimate.status.replace("_", " ")}
</span>
{estimate.project && (
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600">
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-2.5 py-1 text-xs font-medium text-gray-600 dark:text-gray-400">
{estimate.project.shortCode}
</span>
)}
@@ -408,7 +408,7 @@ export function EstimatesClient() {
return (
<>
<div className="app-page space-y-6">
<div className="app-surface-strong overflow-hidden bg-gradient-to-br from-white via-white to-brand-50 p-6 dark:from-gray-950 dark:via-gray-950 dark:to-brand-950/40">
<div className="app-surface-strong overflow-hidden bg-gradient-to-br from-white via-white to-brand-50 p-6 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">
@@ -1296,7 +1296,7 @@ export function ResourcesClient() {
{skills.slice(0, 3).map((s) => (
<span
key={s.skill}
className="inline-block rounded-full bg-brand-50 px-2 py-0.5 text-xs text-brand-700 dark:bg-brand-950/30 dark:text-brand-200"
className="inline-block rounded-full bg-brand-50 px-2 py-0.5 text-xs text-brand-700 dark:bg-brand-900/60 dark:text-brand-100"
>
{s.skill}
</span>
+19 -7
View File
@@ -1,10 +1,22 @@
import { Suspense } from "react";
import { ResourcesClient } from "./ResourcesClient.js";
import dynamic from "next/dynamic";
const ResourcesClient = dynamic(
() => import("./ResourcesClient.js").then((m) => m.ResourcesClient),
{
loading: () => (
<div className="animate-pulse p-6 space-y-4">
<div className="h-8 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
<div className="space-y-2">
{[...Array(10)].map((_, i) => (
<div key={i} className="h-12 w-full bg-gray-200 dark:bg-gray-700 rounded" />
))}
</div>
</div>
),
},
);
export default function ResourcesPage() {
return (
<Suspense>
<ResourcesClient />
</Suspense>
);
return <ResourcesClient />;
}
+14 -1
View File
@@ -1,4 +1,17 @@
import { TimelineView } from "~/components/timeline/TimelineView.js";
import dynamic from "next/dynamic";
const TimelineView = dynamic(
() => import("~/components/timeline/TimelineView.js").then((m) => m.TimelineView),
{
loading: () => (
<div className="animate-pulse flex flex-col gap-4 h-full p-6">
<div className="h-8 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
</div>
),
},
);
export default function TimelinePage() {
return (
+23 -2
View File
@@ -1,10 +1,25 @@
import { createTRPCContext } from "@planarchy/api";
import { createTRPCContext, loadRoleDefaults } from "@planarchy/api";
import { appRouter } from "@planarchy/api/router";
import { prisma } from "@planarchy/db";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import type { NextRequest } from "next/server";
import { auth } from "~/server/auth.js";
// Throttle lastActiveAt updates: max once per 60s per user
const lastActiveCache = new Map<string, number>();
const ACTIVITY_THROTTLE_MS = 60_000;
function trackActivity(userId: string) {
const now = Date.now();
const last = lastActiveCache.get(userId) ?? 0;
if (now - last < ACTIVITY_THROTTLE_MS) return;
lastActiveCache.set(userId, now);
prisma.user.update({
where: { id: userId },
data: { lastActiveAt: new Date(now) },
}).catch(() => {/* ignore */});
}
const handler = async (req: NextRequest) => {
const session = await auth();
@@ -15,12 +30,18 @@ const handler = async (req: NextRequest) => {
})
: null;
// Track user activity (throttled, fire-and-forget)
if (dbUser) trackActivity(dbUser.id);
// Load configurable role defaults (cached, 60s TTL)
const roleDefaults = await loadRoleDefaults();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const options: any = {
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createTRPCContext({ session, dbUser }),
createContext: () => createTRPCContext({ session, dbUser, roleDefaults }),
};
if (process.env["NODE_ENV"] === "development") {
+17 -1
View File
@@ -335,7 +335,7 @@
color: rgb(196 181 253) !important;
}
.dark .bg-amber-50 {
background-color: rgb(120 53 15 / 0.2) !important;
background-color: rgb(120 53 15) !important;
}
/* Modal / overlay */
@@ -427,3 +427,19 @@
@apply opacity-75 shadow-lg scale-105;
}
}
/* ─── Overbooking blink animation ──────────────────────────────────────────── */
@keyframes overbooking-blink {
0%, 100% { background-color: rgba(239, 68, 68, 0); }
50% { background-color: rgba(239, 68, 68, 0.18); }
}
.dark .animate-overbooking-blink {
animation: overbooking-blink-dark 2s ease-in-out infinite;
}
@keyframes overbooking-blink-dark {
0%, 100% { background-color: rgba(239, 68, 68, 0); }
50% { background-color: rgba(239, 68, 68, 0.25); }
}
.animate-overbooking-blink {
animation: overbooking-blink 2s ease-in-out infinite;
}
@@ -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 [vacationSaved, setVacationSaved] = useState(false);
// Timeline
const [undoMaxSteps, setUndoMaxSteps] = useState(50);
const [timelineSaved, setTimelineSaved] = useState(false);
const { data: settings, isLoading } = trpc.settings.getSystemSettings.useQuery(undefined, {
staleTime: 0,
});
@@ -152,6 +156,8 @@ export function SystemSettingsClient() {
setAnonymizationSeed("");
// Vacation
setVacationDefaultDays(settings.vacationDefaultDays ?? 28);
// Timeline
setUndoMaxSteps(settings.timelineUndoMaxSteps ?? 50);
}
}, [settings]);
@@ -227,6 +233,13 @@ export function SystemSettingsClient() {
},
});
const saveTimelineMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setTimelineSaved(true);
setTimeout(() => setTimelineSaved(false), 3000);
},
});
function handleSaveSmtp() {
saveSmtpMutation.mutate({
smtpHost: smtpHost || undefined,
@@ -242,6 +255,10 @@ export function SystemSettingsClient() {
saveVacationMutation.mutate({ vacationDefaultDays });
}
function handleSaveTimeline() {
saveTimelineMutation.mutate({ timelineUndoMaxSteps: undoMaxSteps });
}
function handleSaveAnonymization() {
saveAnonymizationMutation.mutate({
anonymizationEnabled,
@@ -1226,6 +1243,46 @@ export function SystemSettingsClient() {
</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>
<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";
import { useState } from "react";
import { SystemRole, PermissionKey, type PermissionOverrides } from "@planarchy/shared";
import { useState, useMemo } from "react";
import { SystemRole, PermissionKey, ROLE_DEFAULT_PERMISSIONS, type PermissionOverrides } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { FilterChips } from "~/components/ui/FilterChips.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
@@ -56,6 +56,9 @@ type UserRow = {
email: string;
systemRole: string;
createdAt: Date;
lastLoginAt: Date | null;
lastActiveAt: Date | null;
permissionOverrides: PermissionOverrides | null;
};
type EditState = {
@@ -94,6 +97,25 @@ export function UsersClient() {
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(
{ userId: selectedUserId ?? "" },
{ enabled: !!selectedUserId },
@@ -146,13 +168,14 @@ export function UsersClient() {
function openEdit(user: UserRow) {
const role = (user.systemRole as SystemRole) ?? SystemRole.USER;
const overrides = user.permissionOverrides as PermissionOverrides | null;
setSelectedUserId(user.id);
setEditState({
userId: user.id,
systemRole: role,
granted: new Set(),
denied: new Set(),
chapterIds: "",
granted: new Set(overrides?.granted ?? []),
denied: new Set(overrides?.denied ?? []),
chapterIds: (overrides?.chapterIds ?? []).join(", "),
});
setActionError(null);
}
@@ -280,6 +303,21 @@ export function UsersClient() {
...(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 (
<div className="p-6 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
@@ -289,7 +327,18 @@ export function UsersClient() {
Manage user roles and permission overrides
</p>
</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
type="button"
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="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" />
<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." />
<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>
@@ -373,14 +424,14 @@ export function UsersClient() {
<tbody>
{isLoading && (
<tr>
<td colSpan={5} className="text-center py-8 text-gray-400">
<td colSpan={7} className="text-center py-8 text-gray-400">
Loading
</td>
</tr>
)}
{!isLoading && sorted.length === 0 && (
<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.
</td>
</tr>
@@ -403,6 +454,22 @@ export function UsersClient() {
{SYSTEM_ROLE_LABELS[user.systemRole as SystemRole] ?? user.systemRole}
</span>
</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">
{new Date(user.createdAt).toLocaleDateString("en-GB")}
</td>
@@ -576,83 +643,108 @@ export function UsersClient() {
</div>
</section>
{/* Effective Permissions */}
{effectivePerms && (
<section>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2 flex items-center">
Effective Permissions <InfoTooltip content="The final set of permissions after combining the role's defaults with any overrides below. Green = granted, strikethrough = denied." />
</h3>
<div className="flex flex-wrap gap-1.5">
{ALL_PERMISSION_KEYS.map((key) => {
const isActive = effectivePerms.effectivePermissions.includes(key);
return (
<span
key={key}
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
isActive
? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400"
: "bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-600 line-through"
}`}
>
{/* Permissions */}
<section>
<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." />
</h3>
<div className="flex gap-1.5 mb-3 text-[11px]">
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
<span className="inline-block w-3 h-3 rounded border border-green-400 bg-green-100 dark:bg-green-900/40" /> Role default
</span>
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
<span className="inline-block w-3 h-3 rounded border border-blue-400 bg-blue-100 dark:bg-blue-900/40" /> Extra grant
</span>
<span className="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
<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
</span>
</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}
</span>
);
})}
</div>
</section>
)}
{/* Permission Overrides */}
<section>
<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." />
</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>
{state === "default" && (
<span className="text-[10px] text-green-600 dark:text-green-400 font-medium uppercase tracking-wide">Role</span>
)}
{state === "granted" && (
<span className="text-[10px] text-blue-600 dark:text-blue-400 font-medium uppercase tracking-wide">Extra</span>
)}
{state === "denied" && (
<span className="text-[10px] text-red-600 dark:text-red-400 font-medium uppercase tracking-wide">Denied</span>
)}
</button>
);
})}
</div>
{/* Chapter Scope */}
@@ -23,7 +23,10 @@ interface AllocationModalProps {
function toDateInputValue(date: Date | string | null | undefined): string {
if (!date) return "";
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) {
@@ -35,7 +35,7 @@ const TABS: Array<{ id: WorkspaceTab; label: string }> = [
function EmptyState({ children }: { children: React.ReactNode }) {
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}
</div>
);
@@ -53,8 +53,8 @@ function ActionNotice({
className={clsx(
"rounded-2xl border px-4 py-3 text-sm",
tone === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-rose-200 bg-rose-50 text-rose-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 dark:border-rose-800 dark:bg-rose-950/50 dark:text-rose-300",
)}
>
{children}
@@ -182,7 +182,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
<div className="mx-auto max-w-7xl space-y-6 p-6">
<Link
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">
<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
</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>
<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>
<h1 className="mt-2 text-3xl font-semibold text-gray-900">
<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 dark:text-gray-50">
{estimate?.name ?? "Loading estimate"}
</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.
</p>
</div>
{estimate && (
<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>Updated {formatDateLong(estimate.updatedAt)}</span>
</div>
@@ -215,7 +215,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
if (!editableTab && !isEditing) return;
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"}
</button>
@@ -238,7 +238,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
{actionMessage && <ActionNotice tone="success">{actionMessage}</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) => (
<button
key={item.id}
@@ -247,8 +247,8 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
className={clsx(
"rounded-t-2xl border-b-2 px-4 py-3 text-sm font-medium transition-colors",
tab === item.id
? "border-brand-600 text-brand-700"
: "border-transparent text-gray-500 hover:text-gray-800",
? "border-brand-600 text-brand-700 dark:border-sky-400 dark:text-sky-300"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200",
)}
>
{item.label}
@@ -23,10 +23,10 @@ function getDefaultDateRange(): { start: string; end: string } {
function heatColor(hours: number, maxHours: number): string {
if (hours === 0 || maxHours === 0) return "";
const ratio = Math.min(hours / maxHours, 1);
if (ratio < 0.25) return "bg-blue-50";
if (ratio < 0.5) return "bg-blue-100";
if (ratio < 0.75) return "bg-blue-200";
return "bg-blue-300";
if (ratio < 0.25) return "bg-blue-50 dark:bg-blue-900/20";
if (ratio < 0.5) return "bg-blue-100 dark:bg-blue-900/30";
if (ratio < 0.75) return "bg-blue-200 dark:bg-blue-900/40";
return "bg-blue-300 dark:bg-blue-900/50";
}
export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProps) {
@@ -116,43 +116,43 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return (
<div className="space-y-6">
{/* Header / Controls */}
<div className="rounded-3xl border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold text-gray-900">
<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 dark:text-gray-100">
Weekly Phasing (4Dispo)
</h3>
{canEdit && (
<div className="flex flex-wrap items-end gap-4">
<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
</label>
<input
type="date"
value={data?.hasPhasing ? effectiveStart : startDate}
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>
<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
</label>
<input
type="date"
value={data?.hasPhasing ? effectiveEnd : endDate}
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>
<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
</label>
<select
value={pattern}
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="front_loaded">Front Loaded (60/40)</option>
@@ -198,8 +198,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
className={clsx(
"rounded-lg px-3 py-1.5 text-sm font-medium",
viewMode === "by_line"
? "bg-sky-100 text-sky-700"
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
? "bg-sky-100 dark:bg-sky-900/40 text-sky-700 dark:text-sky-300"
: "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
@@ -210,8 +210,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
className={clsx(
"rounded-lg px-3 py-1.5 text-sm font-medium",
viewMode === "by_chapter"
? "bg-sky-100 text-sky-700"
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
? "bg-sky-100 dark:bg-sky-900/40 text-sky-700 dark:text-sky-300"
: "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
@@ -221,25 +221,25 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
{/* Phasing Grid */}
{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...
</div>
)}
{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
phasing distribution.
</div>
)}
{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">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]">
<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 dark:bg-gray-900 px-4 py-3 text-left font-medium text-gray-700 dark:text-gray-300 min-w-[200px]">
Demand Line
</th>
{data.weeks.map((week) => {
@@ -247,13 +247,13 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return (
<th
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}
</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
</th>
</tr>
@@ -267,14 +267,14 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return (
<tr
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}>
{line.name}
</div>
{line.chapter && (
<div className="text-xs text-gray-500">
<div className="text-xs text-gray-500 dark:text-gray-400">
{line.chapter}
</div>
)}
@@ -286,7 +286,7 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
<td
key={key}
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),
)}
>
@@ -294,7 +294,7 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
</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)}
</td>
</tr>
@@ -302,8 +302,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
})}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700">
<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 dark:bg-gray-900 px-4 py-3 text-gray-700 dark:text-gray-300">
Total
</td>
{data.weeks.map((week) => {
@@ -312,13 +312,13 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return (
<td
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) : "-"}
</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)
.reduce((sum, h) => sum + h, 0)
.toFixed(1)}
@@ -331,12 +331,12 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
)}
{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">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]">
<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 dark:bg-gray-900 px-4 py-3 text-left font-medium text-gray-700 dark:text-gray-300 min-w-[200px]">
Chapter
</th>
{data.weeks.map((week) => {
@@ -344,13 +344,13 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return (
<th
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}
</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
</th>
</tr>
@@ -366,9 +366,9 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return (
<tr
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}
</td>
{data.weeks.map((week) => {
@@ -378,7 +378,7 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
<td
key={key}
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),
)}
>
@@ -386,7 +386,7 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
</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)}
</td>
</tr>
@@ -394,8 +394,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
})}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700">
<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 dark:bg-gray-900 px-4 py-3 text-gray-700 dark:text-gray-300">
Total
</td>
{data.weeks.map((week) => {
@@ -404,13 +404,13 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
return (
<td
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) : "-"}
</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)
.reduce((sum, h) => sum + h, 0)
.toFixed(1)}
@@ -424,8 +424,8 @@ export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProp
{/* Info about current phasing config */}
{data?.hasPhasing && data.config && (
<div className="rounded-3xl border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-600">
<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 dark:text-gray-400">
<span className="font-medium">Current phasing:</span>{" "}
{data.config.pattern.replace("_", " ")} distribution from{" "}
{data.config.startDate} to {data.config.endDate} across{" "}
@@ -8,7 +8,7 @@ import type {
function EmptyState({ children }: { children: React.ReactNode }) {
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}
</div>
);
@@ -24,25 +24,25 @@ export function AssumptionsTab({ estimate }: { estimate: EstimateWorkspaceView }
}
return (
<div className="rounded-3xl border border-gray-200 bg-white shadow-sm">
<div className="border-b border-gray-100 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>
<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 dark:border-gray-700/50 px-6 py-4">
<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 className="divide-y divide-gray-100">
<div className="divide-y divide-gray-100 dark:divide-gray-700/50">
{assumptions.map((assumption) => (
<div key={assumption.id} className="grid gap-3 px-6 py-4 md:grid-cols-[160px,1fr,1fr]">
<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="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>
<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>
</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="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>
))}
@@ -98,11 +98,11 @@ export function ExportsTab({
return (
<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>
<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>
<p className="mt-2 text-sm text-gray-500">
<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 dark:text-gray-400">
Generate format-specific artifacts from the current version and download them directly from the stored serializer payload.
</p>
</div>
@@ -114,7 +114,7 @@ export function ExportsTab({
type="button"
onClick={() => onCreateExport(latestVersion.id, format)}
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}`}
</button>
@@ -124,16 +124,16 @@ export function ExportsTab({
</div>
</div>
<div className="rounded-3xl border border-gray-200 bg-white shadow-sm">
<div className="border-b border-gray-100 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>
<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 dark:border-gray-700/50 px-6 py-4">
<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>
{exports.length === 0 ? (
<div className="px-6 py-8">
<p className="text-sm text-gray-400">No exports have been generated for the current version yet.</p>
</div>
) : (
<div className="divide-y divide-gray-100">
<div className="divide-y divide-gray-100 dark:divide-gray-700/50">
{exports.map((estimateExport) => {
const payload = isEstimateExportArtifactPayload(estimateExport.payload)
? estimateExport.payload
@@ -144,57 +144,57 @@ export function ExportsTab({
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-gray-900">{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">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{estimateExport.fileName}</p>
<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}
</span>
{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
</span>
) : null}
</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>
{payload ? <span>{formatBytes(payload.byteLength)}</span> : null}
{payload?.rowCount != null ? <span>{payload.rowCount} rows</span> : null}
{payload?.lineCount != null ? <span>{payload.lineCount} lines</span> : null}
</div>
{payload ? (
<div className="mt-3 grid gap-2 text-xs text-gray-600 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<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 dark:bg-gray-900 px-3 py-2">
<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)}
</p>
</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="mt-1 font-semibold text-gray-900">
<p className="mt-1 font-semibold text-gray-900 dark:text-gray-100">
{formatMoney(
payload.summary.totalCostCents,
payload.summary.baseCurrency,
)}
</p>
</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="mt-1 font-semibold text-gray-900">
<p className="mt-1 font-semibold text-gray-900 dark:text-gray-100">
{formatMoney(
payload.summary.totalPriceCents,
payload.summary.baseCurrency,
)}
</p>
</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="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)}%
</p>
</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.
</p>
)}
@@ -204,7 +204,7 @@ export function ExportsTab({
<button
type="button"
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
</button>
@@ -11,7 +11,7 @@ import { formatMoney } from "~/lib/format.js";
function EmptyState({ children }: { children: React.ReactNode }) {
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}
</div>
);
@@ -77,33 +77,33 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
<div className="space-y-6">
{/* Summary cards */}
<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="mt-2 text-2xl font-semibold text-gray-900">{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-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 dark:text-gray-400">Avg {formatMoney(Math.round(avgCostRate), estimate.baseCurrency)}/h</p>
</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="mt-2 text-2xl font-semibold text-gray-900">{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-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 dark:text-gray-400">Avg {formatMoney(Math.round(avgBillRate), estimate.baseCurrency)}/h</p>
</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={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)}
</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 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="mt-2 text-2xl font-semibold text-gray-900">{totals.hours.toFixed(1)} h</p>
<p className="mt-1 text-xs text-gray-500">{demandLines.length} demand lines</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 dark:text-gray-400">{demandLines.length} demand lines</p>
</div>
</div>
{/* Margin waterfall: Cost -> Margin -> Price */}
<div className="rounded-3xl border border-gray-200 bg-white 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>
<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 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">
{(() => {
const maxVal = Math.max(totals.costCents, totals.priceCents, 1);
@@ -113,22 +113,22 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
return (
<>
<div className="flex-1 flex flex-col items-center gap-1">
<div className="w-full rounded-t-xl bg-gray-300" style={{ height: `${costH}%` }} />
<span className="text-xs font-medium text-gray-600">Cost</span>
<span className="text-xs text-gray-500">{formatMoney(totals.costCents, estimate.baseCurrency)}</span>
<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 dark:text-gray-400">Cost</span>
<span className="text-xs text-gray-500 dark:text-gray-400">{formatMoney(totals.costCents, estimate.baseCurrency)}</span>
</div>
<div className="flex-1 flex flex-col items-center gap-1">
<div
className={clsx("w-full rounded-t-xl", marginCents >= 0 ? "bg-emerald-400" : "bg-red-400")}
style={{ height: `${marginH}%` }}
/>
<span className="text-xs font-medium text-gray-600">Margin</span>
<span className="text-xs text-gray-500">{formatMoney(marginCents, estimate.baseCurrency)}</span>
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">Margin</span>
<span className="text-xs text-gray-500 dark:text-gray-400">{formatMoney(marginCents, estimate.baseCurrency)}</span>
</div>
<div className="flex-1 flex flex-col items-center gap-1">
<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 text-gray-500">{formatMoney(totals.priceCents, estimate.baseCurrency)}</span>
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">Price</span>
<span className="text-xs text-gray-500 dark:text-gray-400">{formatMoney(totals.priceCents, estimate.baseCurrency)}</span>
</div>
</>
);
@@ -137,12 +137,12 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
</div>
{/* Chapter breakdown */}
<div className="rounded-3xl border border-gray-200 bg-white 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>
<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 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">
<table className="w-full text-left text-sm">
<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="px-3 py-2 text-right font-medium">Lines</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 chapterMarginPct = data.priceCents > 0 ? (chapterMargin / data.priceCents) * 100 : 0;
return (
<tr key={chapter} className="border-b border-gray-100">
<td className="py-2 pr-3 font-medium text-gray-900">{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-700">{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">{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")}>
<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 dark:text-gray-100">{chapter}</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 dark:text-gray-300">{data.hours.toFixed(1)}</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 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 dark:text-emerald-400" : "text-red-700 dark:text-red-400")}>
{formatMoney(chapterMargin, estimate.baseCurrency)}
</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)}%
</td>
</tr>
);
})}
<tr className="border-t-2 border-gray-300 font-semibold">
<td className="py-2 pr-3 text-gray-900">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">{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">{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")}>
<tr className="border-t-2 border-gray-300 dark:border-gray-600 font-semibold">
<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 dark:text-gray-100">{demandLines.length}</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 dark:text-gray-100">{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.priceCents, estimate.baseCurrency)}</td>
<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)}
</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)}%
</td>
</tr>
@@ -192,12 +192,12 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
{/* Monthly cost/price phasing */}
{sortedMonths.length > 0 && (
<div className="rounded-3xl border border-gray-200 bg-white 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>
<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 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">
<table className="w-full text-left text-sm">
<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="px-3 py-2 text-right font-medium">Hours</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 mMargin = data.priceCents - data.costCents;
return (
<tr key={month} className="border-b border-gray-100">
<td className="py-2 pr-3 font-medium text-gray-900">{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">{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={clsx("pl-3 py-2 text-right tabular-nums", mMargin >= 0 ? "text-emerald-700" : "text-red-700")}>
<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 dark:text-gray-100">{month}</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 dark:text-gray-300">{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.priceCents, estimate.baseCurrency)}</td>
<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)}
</td>
</tr>
@@ -11,18 +11,18 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
const STATUS_STYLES: Record<EstimateStatus, string> = {
DRAFT: "bg-slate-100 text-slate-700",
IN_REVIEW: "bg-amber-100 text-amber-700",
APPROVED: "bg-emerald-100 text-emerald-700",
ARCHIVED: "bg-zinc-200 text-zinc-700",
DRAFT: "bg-slate-100 text-slate-700 dark:bg-slate-900/30 dark:text-slate-300",
IN_REVIEW: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
ARCHIVED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
};
const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
WORKING: "bg-sky-100 text-sky-700",
BASELINE: "bg-violet-100 text-violet-700",
SUBMITTED: "bg-amber-100 text-amber-700",
APPROVED: "bg-emerald-100 text-emerald-700",
SUPERSEDED: "bg-zinc-200 text-zinc-700",
WORKING: "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300",
BASELINE: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
SUBMITTED: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
SUPERSEDED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
};
function formatMetricValue(metric: EstimateMetricView) {
@@ -43,13 +43,13 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
return (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),340px]">
<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">
<span className={clsx("rounded-full px-3 py-1 text-xs font-semibold", STATUS_STYLES[estimate.status])}>
{estimate.status.replace("_", " ")}
</span>
{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}
</span>
)}
@@ -58,43 +58,43 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
<div className="mt-5 grid gap-4 md:grid-cols-2">
<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="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>
<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>
<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"}
</p>
</div>
<div>
<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>
{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="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 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">
<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>
</div>
<div className="mt-4 space-y-2">
{(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">
<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>
</div>
</div>
@@ -103,17 +103,17 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
</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">
<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>
</div>
<div className="mt-4 space-y-2">
{(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">
<p className="text-sm font-medium text-gray-900">{line.name}</p>
<span className="text-xs text-gray-500">{line.hours.toFixed(1)} h</span>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{line.name}</p>
<span className="text-xs text-gray-500 dark:text-gray-400">{line.hours.toFixed(1)} h</span>
</div>
</div>
))}
@@ -124,44 +124,44 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
</section>
<aside className="space-y-4">
<div className="rounded-3xl border border-gray-200 bg-white 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>
<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 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">
{latestMetrics.length === 0 ? (
<p className="text-sm text-gray-400">No derived metrics available yet.</p>
) : (
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-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 className="rounded-3xl border border-gray-200 bg-white 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>
<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 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">
{latestVersion ? (
<>
<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])}>
{latestVersion.status}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Assumptions</span>
<span className="text-sm font-medium text-gray-900">{latestVersion.assumptions.length}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">Assumptions</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{latestVersion.assumptions.length}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Snapshots</span>
<span className="text-sm font-medium text-gray-900">{latestVersion.resourceSnapshots.length}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">Snapshots</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{latestVersion.resourceSnapshots.length}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Exports</span>
<span className="text-sm font-medium text-gray-900">{latestVersion.exports.length}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">Exports</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{latestVersion.exports.length}</span>
</div>
</>
) : (
@@ -8,7 +8,7 @@ import type {
function EmptyState({ children }: { children: React.ReactNode }) {
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}
</div>
);
@@ -25,24 +25,24 @@ export function ScopeTab({ estimate }: { estimate: EstimateWorkspaceView }) {
return (
<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>
<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>
{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>
<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}
</span>
<span className="rounded-full bg-brand-50 px-2.5 py-1 text-xs font-medium text-brand-700">
{item.scopeType}
</span>
</div>
<h3 className="mt-3 text-lg font-semibold text-gray-900">{item.name}</h3>
{item.description && <p className="mt-2 text-sm text-gray-600">{item.description}</p>}
<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 dark:text-gray-400">{item.description}</p>}
</div>
<div className="grid gap-2 text-right text-xs text-gray-400">
{item.frameCount != null && <span>{item.frameCount} frames</span>}
@@ -18,7 +18,7 @@ import { formatMoney } from "~/lib/format.js";
function EmptyState({ children }: { children: React.ReactNode }) {
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}
</div>
);
@@ -71,11 +71,11 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
});
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>
<h3 className="text-lg font-semibold text-gray-900">{line.name}</h3>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500">
<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 dark:text-gray-400">
<span>{line.lineType}</span>
{line.chapter && <span>{line.chapter}</span>}
{line.rateSource && <span>{line.rateSource}</span>}
@@ -84,8 +84,8 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
className={clsx(
"rounded-full px-2.5 py-1 font-medium",
calculation.costRateMode === "resource"
? "bg-emerald-50 text-emerald-700"
: "bg-amber-50 text-amber-700",
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
: "bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
)}
>
Cost {calculation.costRateMode === "resource" ? "live" : "manual"}
@@ -94,8 +94,8 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
className={clsx(
"rounded-full px-2.5 py-1 font-medium",
calculation.billRateMode === "resource"
? "bg-emerald-50 text-emerald-700"
: "bg-amber-50 text-amber-700",
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
: "bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
)}
>
Sell {calculation.billRateMode === "resource" ? "live" : "manual"}
@@ -103,37 +103,37 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
</div>
</div>
<div className="text-right">
<p className="text-sm font-semibold text-gray-900">{line.hours.toFixed(1)} h</p>
<p className="mt-1 text-xs text-gray-500">{effectiveValues.currency}</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 dark:text-gray-400">{effectiveValues.currency}</p>
</div>
</div>
<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="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" && (
<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)}
</p>
)}
</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="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" && (
<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)}
</p>
)}
</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="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 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="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>
@@ -144,9 +144,9 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
{Object.entries(line.monthlySpread)
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, hours]) => (
<div key={month} className="rounded-xl bg-gray-50 px-3 py-1.5 text-xs">
<span className="text-gray-500">{month}</span>
<span className="ml-1.5 font-medium text-gray-900">{hours.toFixed(1)} h</span>
<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 dark:text-gray-400">{month}</span>
<span className="ml-1.5 font-medium text-gray-900 dark:text-gray-100">{hours.toFixed(1)} h</span>
</div>
))}
</div>
@@ -165,17 +165,17 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
const months = Object.keys(aggregated).sort();
if (months.length === 0) return null;
return (
<div className="rounded-3xl border border-gray-200 bg-white 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>
<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 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">
{months.map((month) => (
<div key={month} className="rounded-xl bg-gray-50 px-3 py-2 text-sm">
<span className="text-gray-500">{month}</span>
<span className="ml-2 font-semibold text-gray-900">{(aggregated[month] ?? 0).toFixed(1)} h</span>
<div key={month} className="rounded-xl bg-gray-50 dark:bg-gray-900 px-3 py-2 text-sm">
<span className="text-gray-500 dark:text-gray-400">{month}</span>
<span className="ml-2 font-semibold text-gray-900 dark:text-gray-100">{(aggregated[month] ?? 0).toFixed(1)} h</span>
</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
</div>
</div>
@@ -12,11 +12,11 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
WORKING: "bg-sky-100 text-sky-700",
BASELINE: "bg-violet-100 text-violet-700",
SUBMITTED: "bg-amber-100 text-amber-700",
APPROVED: "bg-emerald-100 text-emerald-700",
SUPERSEDED: "bg-zinc-200 text-zinc-700",
WORKING: "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300",
BASELINE: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
SUBMITTED: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
SUPERSEDED: "bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
};
function formatMetricValue(metric: EstimateMetricView) {
@@ -31,7 +31,7 @@ function formatMetricValue(metric: EstimateMetricView) {
function EmptyState({ children }: { children: React.ReactNode }) {
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}
</div>
);
@@ -75,24 +75,24 @@ export function VersionsTab({
return (
<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>
<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>
{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>
<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])}>
{version.status}
</span>
</div>
<p className="mt-2 text-sm text-gray-600">{version.label ?? "Unlabeled version"}</p>
{version.notes && <p className="mt-2 text-sm text-gray-500">{version.notes}</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 dark:text-gray-400">{version.notes}</p>}
</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>
{version.lockedAt && (
<p className="mt-1">Locked {formatDateLong(version.lockedAt)}</p>
@@ -130,7 +130,7 @@ export function VersionsTab({
type="button"
onClick={() => onCreateRevision(version.id)}
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"}
</button>
@@ -160,7 +160,7 @@ export function VersionsTab({
)}
{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.
</p>
)}
@@ -168,9 +168,9 @@ export function VersionsTab({
{version.metrics.length > 0 && (
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
{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="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>
+16 -6
View File
@@ -6,7 +6,7 @@ import Link from "next/link";
import type { Route } from "next";
import { usePathname } from "next/navigation";
import { clsx } from "clsx";
import { Suspense, useState } from "react";
import { Suspense, useMemo, useState } from "react";
import { PreferencesModal } from "./PreferencesModal.js";
import { ThemeProvider } from "./ThemeProvider.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/users", label: "Users", icon: <AdminIcon /> },
{ href: "/admin/system-roles", label: "System Roles", icon: <AdminIcon /> },
{ href: "/admin/settings", label: "Settings", icon: <AdminIcon /> },
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
{ href: "/admin/notifications", label: "Broadcasts", icon: <BroadcastIcon /> },
@@ -180,6 +181,15 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
const pathname = usePathname();
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
.map((section) => ({
...section,
@@ -194,13 +204,13 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
const initial: Record<string, boolean> = {};
for (const section of visibleSections) {
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;
}
}
for (const entry of adminNavEntries) {
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;
}
}
@@ -270,7 +280,7 @@ function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () =>
href={item.href as Route}
className={clsx(
"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"
: "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}
className={clsx(
"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"
: "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}
className={clsx(
"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"
: "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) {
const { prefs, setMode, setAccent } = useTheme();
const { prefs: appPrefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme, setShowDemandProjects } = useAppPreferences();
const { prefs: appPrefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme, setShowDemandProjects, setBlinkOverbookedDays } = useAppPreferences();
return (
<div
@@ -219,6 +219,34 @@ export function PreferencesModal({ onClose }: PreferencesModalProps) {
</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">
<div className="relative mt-0.5 flex-shrink-0">
<input
@@ -3,6 +3,7 @@
import { useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { BroadcastModal } from "./BroadcastModal.js";
import { CreateTaskModal } from "./CreateTaskModal.js";
function formatDate(date: string | Date): string {
const d = typeof date === "string" ? new Date(date) : date;
@@ -25,6 +26,7 @@ const TARGET_LABELS: Record<string, string> = {
export function BroadcastManagementClient() {
const [showModal, setShowModal] = useState(false);
const [showTaskModal, setShowTaskModal] = useState(false);
const { data: broadcasts = [], isLoading } = trpc.notification.listBroadcasts.useQuery(
{ limit: 50 },
@@ -42,16 +44,28 @@ export function BroadcastManagementClient() {
{/* Header */}
<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>
<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 className="flex items-center gap-3">
<button
type="button"
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="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" />
</svg>
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>
{/* Loading */}
@@ -139,6 +153,14 @@ export function BroadcastManagementClient() {
onSuccess={handleSuccess}
/>
)}
{/* Create Task Modal */}
{showTaskModal && (
<CreateTaskModal
onClose={() => setShowTaskModal(false)}
onSuccess={handleSuccess}
/>
)}
</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";
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 Link from "next/link";
import type { Route } from "next";
@@ -27,6 +28,9 @@ export function NotificationBell() {
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<TabKey>("all");
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 isAuthenticated = status === "authenticated" && !!session?.user?.email;
@@ -34,13 +38,13 @@ export function NotificationBell() {
const { data: unreadCount = 0 } = trpc.notification.unreadCount.useQuery(undefined, {
enabled: isAuthenticated,
refetchInterval: 30_000,
refetchInterval: 60_000,
retry: false,
});
const { data: taskCounts } = trpc.notification.taskCounts.useQuery(undefined, {
enabled: isAuthenticated,
refetchInterval: 30_000,
refetchInterval: 60_000,
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
useEffect(() => {
if (!open) return;
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);
}
}
@@ -113,6 +138,7 @@ export function NotificationBell() {
<div ref={ref} className="relative">
{/* Bell button */}
<button
ref={bellRef}
type="button"
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"
@@ -146,9 +172,13 @@ export function NotificationBell() {
)}
</button>
{/* Dropdown panel */}
{open && (
<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">
{/* Dropdown panel — rendered via portal to escape sidebar overflow */}
{open && createPortal(
<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 */}
<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">
@@ -335,7 +365,8 @@ export function NotificationBell() {
View all &rarr;
</Link>
</div>
</div>
</div>,
document.body,
)}
</div>
);
@@ -3,8 +3,10 @@
import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { trpc } from "~/lib/trpc/client.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { TaskCard } from "./TaskCard.js";
import { ReminderModal } from "./ReminderModal.js";
import { CreateTaskModal } from "./CreateTaskModal.js";
type TabKey = "all" | "notifications" | "tasks" | "reminders" | "approvals";
@@ -27,6 +29,8 @@ export function NotificationCenterClient() {
const searchParams = useSearchParams();
const initialTab = (searchParams.get("tab") as TabKey) || "all";
const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
const { canEdit } = usePermissions();
const [showTaskModal, setShowTaskModal] = useState(false);
const [reminderModal, setReminderModal] = useState<{
open: boolean;
reminder: {
@@ -124,6 +128,18 @@ export function NotificationCenterClient() {
<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>
<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" && (
<button
type="button"
@@ -389,6 +405,14 @@ export function NotificationCenterClient() {
onSuccess={() => setReminderModal({ open: false, reminder: null })}
/>
)}
{/* Create Task Modal */}
{showTaskModal && (
<CreateTaskModal
onClose={() => setShowTaskModal(false)}
onSuccess={() => setShowTaskModal(false)}
/>
)}
</div>
);
}
@@ -28,7 +28,10 @@ interface ReminderModalProps {
function toDateInputValue(date: Date | string | null | undefined): string {
if (!date) return "";
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 {
@@ -25,11 +25,11 @@ const proficiencyLabel: Record<number, string> = {
};
const proficiencyColor: Record<number, string> = {
1: "bg-gray-100 text-gray-600",
2: "bg-blue-50 text-blue-600",
3: "bg-brand-50 text-brand-700",
4: "bg-amber-50 text-amber-700",
5: "bg-green-50 text-green-700",
1: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300",
2: "bg-blue-50 text-blue-600 dark:bg-blue-900/50 dark:text-blue-300",
3: "bg-brand-50 text-brand-700 dark:bg-brand-900/50 dark:text-brand-200",
4: "bg-amber-50 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
5: "bg-green-50 text-green-700 dark:bg-green-900/50 dark:text-green-300",
};
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="min-w-0">
<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
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"}
@@ -359,11 +359,11 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{/* Profile meta (area role, portfolio, last import) */}
{(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 && (
<div className="flex items-center gap-2">
<span className="text-gray-500 text-xs">Area:</span>
<span className="font-medium text-gray-800">{resourceWithMeta.areaRole.name}</span>
<span className="text-gray-500 dark:text-gray-400 text-xs">Area:</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{resourceWithMeta.areaRole.name}</span>
</div>
)}
{resourceWithMeta.portfolioUrl && (
@@ -398,13 +398,13 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{/* Main Skills Badges */}
{mainSkills.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 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>
<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 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">
{mainSkills.map((s) => (
<span
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>
{s.skill}
@@ -422,8 +422,8 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{/* Roles */}
{resourceRoles.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 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>
<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 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">
{resourceRoles.map((rr) => (
<span
@@ -445,13 +445,13 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{/* Skills */}
{skills.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 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>
<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 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">
{skills.map((s) => (
<span
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.proficiency != null && (
@@ -464,7 +464,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
</span>
)}
{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>
))}
@@ -8,10 +8,14 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
export function StaffingPanel() {
const [requiredSkills, setRequiredSkills] = useState<string[]>(["TypeScript", "React"]);
const [startDate, setStartDate] = useState(new Date().toISOString().split("T")[0] ?? "");
const [endDate, setEndDate] = useState(
new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split("T")[0] ?? "",
);
const [startDate, setStartDate] = useState(() => {
const d = new Date();
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 [submitted, setSubmitted] = useState(false);
@@ -76,7 +76,10 @@ export function AllocationPopover({
}, [onClose]);
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() {
@@ -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 {
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({
@@ -50,7 +53,8 @@ export function NewAllocationPopover({
{ 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)
?? (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 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 (
<div
ref={ref}
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 */}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-100">
<span className="text-sm font-semibold text-gray-700">Assign to Project</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">&times;</button>
<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">Assign to Project</span>
<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">
{/* Date range */}
<div className="grid grid-cols-2 gap-2">
<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
value={start}
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>
<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
value={end}
onChange={setEnd}
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>
{/* Project picker */}
<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 ? (
<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(""); }}
>
<span className="text-sm text-gray-800 truncate flex-1">{selectedProject.name}</span>
<span className="text-xs text-gray-400"></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 dark:text-gray-500"></span>
</div>
) : (
<div className="relative">
@@ -155,18 +152,18 @@ export function NewAllocationPopover({
value={search}
onChange={(e) => setSearch(e.target.value)}
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 && (
<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) => (
<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 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>
))}
</div>
@@ -177,18 +174,18 @@ export function NewAllocationPopover({
{/* Role */}
<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
type="text"
value={role}
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>
{/* Hours per day */}
<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">
<input
type="number"
@@ -197,7 +194,7 @@ export function NewAllocationPopover({
step={0.5}
value={hoursPerDay}
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">
{[4, 6, 8].map((h) => (
@@ -209,7 +206,7 @@ export function NewAllocationPopover({
"px-2 py-1 rounded text-xs font-medium border transition-colors",
hoursPerDay === h
? "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
@@ -220,13 +217,13 @@ export function NewAllocationPopover({
</div>
{/* 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.
</p>
{/* Error */}
{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 */}
@@ -243,7 +240,7 @@ export function NewAllocationPopover({
</button>
<button
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
</button>
@@ -1,7 +1,7 @@
"use client";
import { clsx } from "clsx";
import { useState } from "react";
import { useEffect, useState } from "react";
import { AllocationStatus, type StaffingRequirement } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
@@ -77,7 +77,11 @@ const STATUS_COLORS = {
};
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 {
@@ -518,6 +522,17 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
}
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 (
<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">
@@ -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
displayMode: TimelineDisplayMode;
heatmapScheme: HeatmapColorScheme;
blinkOverbookedDays: boolean;
// ─ Loading
isLoading: boolean;
@@ -287,6 +288,7 @@ export function TimelineProvider({
const { prefs: appPrefs } = useAppPreferences();
const displayMode = appPrefs.timelineDisplayMode;
const heatmapScheme = appPrefs.heatmapColorScheme;
const blinkOverbookedDays = appPrefs.blinkOverbookedDays;
// ─── Data queries ──────────────────────────────────────────────────────────
const { data: entriesView, isLoading } = trpc.timeline.getEntriesView.useQuery(
@@ -300,7 +302,7 @@ export function TimelineProvider({
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
},
// 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
) as { data: TimelineEntriesView | undefined; isLoading: boolean };
@@ -309,7 +311,7 @@ export function TimelineProvider({
const { data: vacationEntries = [] } = trpc.vacation.list.useQuery(
{ 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(() => {
@@ -593,6 +595,7 @@ export function TimelineProvider({
today,
displayMode,
heatmapScheme,
blinkOverbookedDays,
isLoading,
isInitialLoading,
totalAllocCount,
@@ -618,6 +621,7 @@ export function TimelineProvider({
today,
displayMode,
heatmapScheme,
blinkOverbookedDays,
isLoading,
isInitialLoading,
totalAllocCount,
@@ -30,15 +30,15 @@ export function TimelineHeader({
<>
{/* Month header */}
<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 }}
>
<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">
{monthGroups.map((m, i) => (
<div
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 }}
>
{m.label}
@@ -50,11 +50,11 @@ export function TimelineHeader({
{/* Day header — hidden at month zoom (cells too narrow for labels) */}
{zoom !== "month" && (
<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 }}
>
<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 }}
>
{viewMode === "resource" ? "Resource" : "Project / Resource"}
@@ -72,10 +72,10 @@ export function TimelineHeader({
key={i}
className={clsx(
"flex-shrink-0 border-r flex flex-col items-center justify-center text-xs overflow-hidden",
isToday ? "bg-brand-50 border-brand-200" :
isSaturday ? "bg-amber-50/60 border-amber-200" :
isSunday ? "bg-gray-100/80 border-gray-200" :
isMonday ? "border-gray-200" : "border-gray-100",
isToday ? "bg-brand-50 dark:bg-brand-950/40 border-brand-200 dark:border-brand-800" :
isSaturday ? "bg-amber-50/60 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800" :
isSunday ? "bg-gray-100/80 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700" :
isMonday ? "border-gray-200 dark:border-gray-700" : "border-gray-100 dark:border-gray-800",
)}
style={{ width: CELL_WIDTH, height: HEADER_DAY_HEIGHT }}
>
@@ -83,7 +83,7 @@ export function TimelineHeader({
<>
<span className={clsx(
"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"
? `${date.getDate()} ${MONTHS_SHORT[date.getMonth()]}`
@@ -92,7 +92,7 @@ export function TimelineHeader({
{zoom === "day" && (
<span className={clsx(
"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()]}
</span>
@@ -12,6 +12,7 @@ import {
import { heatmapColor } from "./heatmapUtils.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { formatDateLong } from "~/lib/format.js";
import { TimelineTooltip } from "./TimelineTooltip.js";
import {
ROW_HEIGHT,
SUB_LANE_HEIGHT,
@@ -19,7 +20,7 @@ import {
PROJECT_HEADER_HEIGHT,
ORDER_TYPE_COLORS,
} 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";
// ─── Props ──────────────────────────────────────────────────────────────────
@@ -42,6 +43,7 @@ interface TimelineProjectPanelProps {
anchorX: number,
anchorY: number,
) => void;
multiSelectState: MultiSelectState;
// Layout from useTimelineLayout
CELL_WIDTH: number;
dates: Date[];
@@ -185,6 +187,7 @@ export function TimelineProjectPanel({
onOpenPanel,
onOpenDemandClick,
onAllocationContextMenu,
multiSelectState,
CELL_WIDTH,
dates,
totalCanvasWidth,
@@ -201,6 +204,7 @@ export function TimelineProjectPanel({
filters,
displayMode,
heatmapScheme,
blinkOverbookedDays,
activeFilterCount,
today,
} = useTimelineContext();
@@ -411,7 +415,7 @@ export function TimelineProjectPanel({
const laneCount = assignDemandLanes(row.openDemands).size > 0
? Math.max(...assignDemandLanes(row.openDemands).values()) + 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;
},
@@ -602,7 +606,7 @@ export function TimelineProjectPanel({
const colors = ORDER_TYPE_COLORS[project.orderType] ?? {
bg: "bg-gray-400",
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 =
dragState.isDragging && dragState.projectId === project.id;
@@ -620,12 +624,12 @@ export function TimelineProjectPanel({
return (
<div
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 }}
>
<div
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,
)}
style={{ width: LABEL_WIDTH }}
@@ -694,31 +698,39 @@ export function TimelineProjectPanel({
) : row.type === "open-demand" ? (
renderOpenDemandRow(
row.openDemands,
row.projectId,
CELL_WIDTH,
totalCanvasWidth,
toLeft,
toWidth,
resourceRowGridStyle,
onOpenDemandClick,
onAllocMouseDown,
onAllocTouchStart,
onAllocationContextMenu,
multiSelectState,
allocDragState,
)
) : (
<div
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 }}
>
<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 }}
>
<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()}
</div>
<div className="min-w-0">
<div className="text-xs font-medium text-gray-800 truncate">
<div className="min-w-0" data-resource-hover-id={row.resource.id}>
<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}
</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>
@@ -771,6 +783,7 @@ export function TimelineProjectPanel({
onAllocMouseDown,
onAllocTouchStart,
onAllocationContextMenu,
multiSelectState,
)}
{renderVacationBlocksForProjectRow(
vacationsByResource.get(row.resource.id) ?? [],
@@ -781,6 +794,12 @@ export function TimelineProjectPanel({
totalCanvasWidth,
filters.showVacations,
)}
{blinkOverbookedDays &&
renderOverbookingBlinkProject(
allocsByResource.get(row.resource.id) ?? [],
dates,
CELL_WIDTH,
)}
{renderRangeOverlayProject(
rangeState,
row.resource.id,
@@ -796,7 +815,7 @@ export function TimelineProjectPanel({
);
})}
<ProjectPanelTooltips
<TimelineTooltip
heatmapTooltipRef={heatmapTooltipRef}
heatmapTooltipPos={heatmapTooltipPosRef.current}
vacationTooltipRef={vacationTooltipRef}
@@ -808,111 +827,7 @@ export function TimelineProjectPanel({
);
}
function ProjectPanelTooltips({
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}
</>
);
}
// ProjectPanelTooltips removed — now uses shared TimelineTooltip component
// ─── Pure render functions ──────────────────────────────────────────────────
@@ -949,55 +864,97 @@ function assignDemandLanes(
return laneMap;
}
const DEMAND_LANE_HEIGHT = 30;
const DEMAND_LANE_GAP = 2;
function renderOpenDemandRow(
openDemands: TimelineDemandEntry[],
projectId: string,
CELL_WIDTH: number,
totalCanvasWidth: number,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
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;
const laneMap = assignDemandLanes(openDemands);
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 (
<div
className="flex border-b border-dashed border-amber-200 bg-amber-50/30 hover:bg-amber-50/50 group"
style={{ minHeight: rowHeight }}
data-project-demand-row="true"
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
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"
style={{ width: LABEL_WIDTH, minHeight: rowHeight }}
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, 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>
<div className="min-w-0">
<div className="text-xs font-medium text-amber-700 truncate">Open demand</div>
<div className="text-[10px] text-amber-500 truncate">
{openDemands.length} open demand{openDemands.length > 1 ? "s" : ""}
<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 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>
<div className="min-w-0">
<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
className="relative overflow-hidden"
style={{ width: totalCanvasWidth, minHeight: rowHeight, ...rowGridStyle }}
className="relative overflow-hidden bg-amber-50 touch-none dark:bg-slate-950"
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) => {
const allocStart = new Date(alloc.startDate);
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 (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 = (
alloc as { roleEntity?: { id: string; name: string; color: string | null } | null }
).roleEntity;
@@ -1006,39 +963,99 @@ function renderOpenDemandRow(
const roleColor = roleEntity?.color ?? "#f59e0b";
const headcount = (alloc as { headcount?: number }).headcount ?? 1;
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 (
<div
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={{
left: left + 2,
width: width - 4,
top,
height: DEMAND_LANE_HEIGHT,
backgroundColor: `${roleColor}33`,
border: `2px dashed ${roleColor}99`,
height: blockHeight,
backgroundColor: `${roleColor}4D`,
border: `2px dashed ${roleColor}B3`,
...(multiDragPx && multiDragMode === "move"
? { transform: `translateX(${multiDragPx}px)` }
: {}),
}}
onClick={() => {
onOpenDemandClick({
id: getPlanningEntryMutationId(alloc),
projectId: alloc.projectId,
roleId: (alloc as { roleId?: string | null }).roleId ?? null,
role: (alloc as { role?: string | null }).role ?? null,
headcount,
startDate: allocStart,
endDate: allocEnd,
hoursPerDay: alloc.hoursPerDay,
roleEntity: roleEntity ?? null,
project: alloc.project as { id: string; name: string; shortCode: string },
});
onMouseDown={(e) => {
if (e.button === 2) e.stopPropagation();
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onAllocationContextMenu(
{ allocationId: alloc.id, projectId: alloc.projectId },
e.clientX,
e.clientY,
);
}}
>
<span className="text-xs font-medium truncate" style={{ color: roleColor }}>
{roleName}
{headcount > 1 ? ` x${headcount}` : ""}
</span>
{/* Left 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-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>
);
})}
@@ -1157,6 +1174,7 @@ function renderProjectDragHandles(
anchorX: number,
anchorY: number,
) => void,
multiSelectState: MultiSelectState,
) {
return allocs.map((alloc) => {
const allocStart = new Date(alloc.startDate);
@@ -1170,10 +1188,24 @@ function renderProjectDragHandles(
const dispEnd =
isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd;
const left = toLeft(dispStart);
const width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
let left = toLeft(dispStart);
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
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
const HANDLE_W = width >= 48 ? 8 : 6;
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence;
@@ -1198,8 +1230,20 @@ function renderProjectDragHandles(
isAllocDragged
? "ring-2 ring-brand-400 z-20"
: "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) => {
e.preventDefault();
e.stopPropagation();
@@ -1315,6 +1359,40 @@ function renderVacationBlocksForProjectRow(
// ─── 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(
rangeState: RangeState,
resourceId: string,
@@ -12,7 +12,7 @@ import { ConflictOverlay } from "./ConflictOverlay.js";
import { computeSubLanes } from "./utils.js";
import { heatmapBgColor, heatmapColor } from "./heatmapUtils.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { formatDateLong } from "~/lib/format.js";
import { TimelineTooltip } from "./TimelineTooltip.js";
import {
ROW_HEIGHT,
SUB_LANE_HEIGHT,
@@ -24,6 +24,7 @@ import type {
AllocDragState,
RangeState,
ShiftPreviewData,
MultiSelectState,
} from "~/hooks/useTimelineDrag.js";
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
@@ -45,6 +46,7 @@ interface TimelineResourcePanelProps {
anchorX: number,
anchorY: number,
) => void;
multiSelectState: MultiSelectState;
// Layout from useTimelineLayout
CELL_WIDTH: number;
dates: Date[];
@@ -86,6 +88,7 @@ export function TimelineResourcePanel({
onRowMouseDown,
onRowTouchStart,
onAllocationContextMenu,
multiSelectState,
CELL_WIDTH,
dates,
totalCanvasWidth,
@@ -103,6 +106,7 @@ export function TimelineResourcePanel({
viewEnd,
displayMode,
heatmapScheme,
blinkOverbookedDays,
activeFilterCount,
} = useTimelineContext();
@@ -407,7 +411,7 @@ export function TimelineResourcePanel({
>
<div
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",
)}
style={{ height: rowHeight }}
@@ -415,19 +419,19 @@ export function TimelineResourcePanel({
{/* Label column */}
<div
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",
dragState.isDragging && isContextResource && "bg-brand-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 dark:bg-brand-950/40",
)}
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()}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">
<div className="min-w-0" data-resource-hover-id={resource.id}>
<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}
</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}
</div>
</div>
@@ -467,6 +471,7 @@ export function TimelineResourcePanel({
toLeft,
toWidth,
totalCanvasWidth,
multiSelectState,
)
: renderAllocBlocksFromData(
precomputed?.blockData ?? [],
@@ -480,6 +485,7 @@ export function TimelineResourcePanel({
onAllocMouseDown,
onAllocTouchStart,
onAllocationContextMenu,
multiSelectState,
)}
{renderVacationBlocksForRow(
vacationBlocksByResource.get(resource.id) ?? [],
@@ -488,6 +494,8 @@ export function TimelineResourcePanel({
{displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)}
{displayMode === "heatmap" &&
renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)}
{blinkOverbookedDays &&
renderOverbookingBlink(allocs, dates, CELL_WIDTH)}
{renderRangeOverlay(
rangeState,
resource.id,
@@ -523,7 +531,7 @@ export function TimelineResourcePanel({
})}
{/* Tooltips rendered inside the panel so they live near their data source */}
<ResourcePanelTooltips
<TimelineTooltip
heatmapTooltipRef={heatmapTooltipRef}
heatmapTooltipPos={heatmapTooltipPosRef.current}
vacationTooltipRef={vacationTooltipRef}
@@ -535,113 +543,7 @@ export function TimelineResourcePanel({
);
}
// ─── Tooltip sub-component (portal-free: positioned fixed) ──────────────────
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}
</>
);
}
// ResourcePanelTooltips removed — now uses shared TimelineTooltip component
// ─── Helper types ───────────────────────────────────────────────────────────
@@ -749,6 +651,7 @@ function renderAllocBlocksFromData(
anchorX: number,
anchorY: number,
) => void,
multiSelectState: MultiSelectState,
) {
const anyDragActive = dragState.isDragging || allocDragState.isActive;
@@ -771,8 +674,23 @@ function renderAllocBlocksFromData(
dispEnd = allocDragState.currentEndDate;
}
const left = toLeft(dispStart);
const width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
// Multi-drag offset: shift selected allocations visually during multi-drag
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;
const blockTop = 8 + lane * SUB_LANE_HEIGHT;
@@ -811,6 +729,7 @@ function renderAllocBlocksFromData(
: isOtherDragged
? "opacity-30 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={{
left: left + 2,
@@ -818,6 +737,12 @@ function renderAllocBlocksFromData(
top: blockTop,
height: blockHeight,
...(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) => {
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 ────────────────────────────────────────────
function renderDailyBars(
@@ -983,6 +947,7 @@ function renderDailyBars(
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
totalCanvasWidth: number,
multiSelectState: MultiSelectState,
) {
const BAR_AREA = rowHeight - 8;
const REF_H = 8;
@@ -1061,8 +1026,21 @@ function renderDailyBars(
isBeingDragged
? "opacity-90 ring-2 ring-white ring-offset-1 z-20"
: "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) => {
e.preventDefault();
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";
import { clsx } from "clsx";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAllocationHistory } from "~/hooks/useAllocationHistory.js";
import { useProjectDragContext } from "~/hooks/useProjectDragContext.js";
import { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
import { useTimelineLayout } from "~/hooks/useTimelineLayout.js";
import { trpc } from "~/lib/trpc/client.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.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 { ProjectPanel } from "./ProjectPanel.js";
import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js";
@@ -31,9 +37,11 @@ import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProje
export function TimelineView() {
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);
pushHistoryRef.current = pushHistory;
const pushBatchHistoryRef = useRef(pushBatchHistory);
pushBatchHistoryRef.current = pushBatchHistory;
const [popover, setPopover] = useState<{
allocationId: string;
@@ -48,6 +56,10 @@ export function TimelineView() {
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
/** Selection coordinates to keep the overlay visible while popover is open */
selectionResourceId: string;
selectionStart: Date;
selectionEnd: Date;
} | null>(null);
// 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.
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 {
dragState,
allocDragState,
rangeState,
multiSelectState,
setMultiSelectState,
shiftPreview,
isPreviewLoading,
isApplying,
@@ -69,6 +93,8 @@ export function TimelineView() {
onCanvasMouseMove,
onCanvasMouseUp,
onCanvasMouseLeave,
onCanvasRightMouseDown,
clearMultiSelect,
onProjectBarTouchStart,
onAllocTouchStart,
onRowTouchStart,
@@ -92,11 +118,33 @@ export function TimelineView() {
suggestedProjectId: info.suggestedProjectId,
anchorX: info.anchorX,
anchorY: info.anchorY,
selectionResourceId: info.resourceId,
selectionStart: info.startDate,
selectionEnd: info.endDate,
});
},
onAllocationMoved: (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);
@@ -115,6 +163,10 @@ export function TimelineView() {
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
multiSelectState={multiSelectState}
setMultiSelectState={setMultiSelectState}
onCanvasRightMouseDown={onCanvasRightMouseDown}
clearMultiSelect={clearMultiSelect}
shiftPreview={shiftPreview}
isPreviewLoading={isPreviewLoading}
isApplying={isApplying}
@@ -154,6 +206,10 @@ function TimelineViewContent({
dragState,
allocDragState,
rangeState,
multiSelectState,
setMultiSelectState,
onCanvasRightMouseDown,
clearMultiSelect,
shiftPreview,
isPreviewLoading,
isApplying,
@@ -186,6 +242,10 @@ function TimelineViewContent({
dragState: ReturnType<typeof useTimelineDrag>["dragState"];
allocDragState: ReturnType<typeof useTimelineDrag>["allocDragState"];
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"];
isPreviewLoading: boolean;
isApplying: boolean;
@@ -211,6 +271,9 @@ function TimelineViewContent({
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
selectionResourceId: string;
selectionStart: Date;
selectionEnd: Date;
} | null;
setNewAllocPopover: React.Dispatch<React.SetStateAction<typeof newAllocPopover>>;
openPanelProjectId: string | null;
@@ -224,6 +287,8 @@ function TimelineViewContent({
const {
resources,
projectGroups,
allocsByResource,
openDemandsByProject,
viewStart,
viewEnd,
viewDays,
@@ -248,12 +313,69 @@ function TimelineViewContent({
const dragTooltipRef = useRef<HTMLDivElement>(null);
const allocTooltipRef = useRef<HTMLDivElement>(null);
const rangeHintRef = useRef<HTMLDivElement>(null);
const multiDragTooltipRef = useRef<HTMLDivElement>(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 } =
useTimelineLayout(viewStart, viewDays, filters.zoom, filters.showWeekends, today);
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(
info: {
@@ -263,6 +385,13 @@ function TimelineViewContent({
anchorX: 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({
allocationId: info.allocationId,
projectId: info.projectId,
@@ -295,10 +424,16 @@ function TimelineViewContent({
rangeHintRef.current.style.left = `${x + 12}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 });
return () => el.removeEventListener("mousemove", handler);
}, [hasActivePointerOverlay, isLoading, mousePosRef]); // eslint-disable-line react-hooks/exhaustive-deps
// During multi-drag, listen on document (cursor may leave canvas)
const target: EventTarget = multiSelectState.isMultiDragging ? document : el;
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 ──────────────────────────────────────
useEffect(() => {
@@ -333,6 +468,92 @@ function TimelineViewContent({
return () => window.removeEventListener("keydown", handler);
}, [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 ─────────────────────────────────────
function handleContainerScroll() {
const el = scrollContainerRef.current;
@@ -348,6 +569,126 @@ function TimelineViewContent({
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 (
<div className="relative flex flex-1 flex-col gap-4 min-h-0">
{/* Toolbar */}
@@ -404,14 +745,21 @@ function TimelineViewContent({
onMouseMove={handleMouseMove}
onMouseUp={(e) => void onCanvasMouseUp(e)}
onMouseLeave={onCanvasMouseLeave}
onMouseDown={(e) => {
if (e.button === 2) {
onCanvasRightMouseDown(e);
}
}}
onContextMenu={(e) => e.preventDefault()}
onTouchMove={(e) => {
if (!hasActivePointerOverlay) return;
onCanvasTouchMove(e);
}}
onTouchEnd={(e) => void onCanvasTouchEnd(e)}
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",
multiSelectState.isSelecting && "cursor-crosshair select-none",
)}
>
{viewMode === "resource" && (
@@ -419,7 +767,7 @@ function TimelineViewContent({
scrollContainerRef={scrollContainerRef}
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
rangeState={effectiveRangeState}
shiftPreview={shiftPreview}
contextResourceIds={contextResourceIds}
onAllocMouseDown={onAllocMouseDown}
@@ -427,6 +775,7 @@ function TimelineViewContent({
onRowMouseDown={onRowMouseDown}
onRowTouchStart={onRowTouchStart}
onAllocationContextMenu={openAllocationPopoverAt}
multiSelectState={multiSelectState}
CELL_WIDTH={CELL_WIDTH}
dates={dates}
totalCanvasWidth={totalCanvasWidth}
@@ -442,7 +791,8 @@ function TimelineViewContent({
scrollContainerRef={scrollContainerRef}
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
rangeState={effectiveRangeState}
multiSelectState={multiSelectState}
onProjectBarMouseDown={onProjectBarMouseDown}
onProjectBarTouchStart={onProjectBarTouchStart}
onAllocMouseDown={onAllocMouseDown}
@@ -466,6 +816,19 @@ function TimelineViewContent({
)}
</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 */}
{(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">
@@ -540,18 +903,95 @@ function TimelineViewContent({
</div>
)}
{/* Allocation popover */}
{popover && (
<AllocationPopover
allocationId={popover.allocationId}
projectId={popover.projectId}
onClose={() => setPopover(null)}
{/* Multi-drag tooltip */}
{multiSelectState.isMultiDragging && multiSelectState.multiDragDaysDelta !== 0 && (
<div
ref={multiDragTooltipRef}
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"
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) => {
setPopover(null);
setDemandPopover(null);
setOpenPanelProjectId(pid);
}}
anchorX={popover.x}
anchorY={popover.y}
onFillDemand={(d) => {
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}
startDate={newAllocPopover.startDate}
endDate={newAllocPopover.endDate}
suggestedProjectId={newAllocPopover.suggestedProjectId}
suggestedProjectId={enrichedSuggestedProjectId}
anchorX={newAllocPopover.anchorX}
anchorY={newAllocPopover.anchorY}
onClose={() => setNewAllocPopover(null)}
@@ -582,6 +1022,45 @@ function TimelineViewContent({
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>
);
}
@@ -26,7 +26,10 @@ interface VacationModalProps {
function toDateInputValue(date: Date | string | null | undefined): string {
if (!date) return "";
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) {
+75 -18
View File
@@ -5,16 +5,27 @@ import type { AllocationMovedSnapshot } from "./useTimelineDrag.js";
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() {
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const past = useRef<AllocationMovedSnapshot[]>([]);
const future = useRef<AllocationMovedSnapshot[]>([]);
const past = useRef<HistoryEntry[]>([]);
const future = useRef<HistoryEntry[]>([]);
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({
onSuccess: () => {
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) => {
past.current = [...past.current.slice(-MAX_HISTORY + 1), snapshot];
past.current = [...past.current.slice(-(maxHistory - 1)), { type: "single", snapshot }];
future.current = [];
setCanUndo(true);
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 last = past.current[past.current.length - 1];
@@ -38,12 +72,25 @@ export function useAllocationHistory() {
future.current = [last, ...future.current];
setCanUndo(past.current.length > 0);
setCanRedo(true);
await updateMutation.mutateAsync({
allocationId: last.mutationAllocationId,
startDate: last.before.startDate,
endDate: last.before.endDate,
});
}, [updateMutation]);
if (last.type === "single") {
await updateMutation.mutateAsync({
allocationId: last.snapshot.mutationAllocationId,
startDate: last.snapshot.before.startDate,
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 next = future.current[0];
@@ -52,12 +99,22 @@ export function useAllocationHistory() {
past.current = [...past.current, next];
setCanUndo(true);
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;
/** Show open demand / placeholder entries by default when loading the timeline. Default: true. */
showDemandProjects: boolean;
/** Blink overbooked days (>8h) as a warning on the timeline. Default: false. */
blinkOverbookedDays: boolean;
}
const STORAGE_KEY = "planarchy_prefs";
@@ -28,6 +30,7 @@ const DEFAULT: AppPreferences = {
timelineDisplayMode: "strip",
heatmapColorScheme: "green-red",
showDemandProjects: true,
blinkOverbookedDays: false,
};
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,
};
// ─── 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 ───────────────────────────────────────────────────────────────────
export interface AllocationMovedSnapshot {
@@ -134,20 +167,28 @@ export function useTimelineDrag({
onBlockClick,
onRangeSelected,
onAllocationMoved,
onShiftClickAlloc,
onMultiDragComplete,
}: {
cellWidth: number;
onShiftApplied?: (projectId: string) => void;
onBlockClick?: (info: BlockClickInfo) => void;
onRangeSelected?: (info: RangeSelectedInfo) => void;
onAllocationMoved?: (snapshot: AllocationMovedSnapshot) => void;
onShiftClickAlloc?: (allocationId: string) => void;
onMultiDragComplete?: (daysDelta: number, mode: AllocDragMode) => void;
}) {
const [dragState, setDragState] = useState<DragState>(INITIAL_DRAG_STATE);
const [allocDragState, setAllocDragState] = useState<AllocDragState>(INITIAL_ALLOC_DRAG);
const [rangeState, setRangeState] = useState<RangeState>(INITIAL_RANGE_STATE);
const [multiSelectState, setMultiSelectState] = useState<MultiSelectState>(INITIAL_MULTI_SELECT);
const dragStateRef = useRef<DragState>(INITIAL_DRAG_STATE);
const allocDragRef = useRef<AllocDragState>(INITIAL_ALLOC_DRAG);
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
const cellWidthRef = useRef(cellWidth);
@@ -166,6 +207,12 @@ export function useTimelineDrag({
const onAllocationMovedRef = useRef(onAllocationMoved);
onAllocationMovedRef.current = onAllocationMoved;
const onShiftClickAllocRef = useRef(onShiftClickAlloc);
onShiftClickAllocRef.current = onShiftClickAlloc;
const onMultiDragCompleteRef = useRef(onMultiDragComplete);
onMultiDragCompleteRef.current = onMultiDragComplete;
const utils = trpc.useUtils();
// Project-shift preview
@@ -312,6 +359,54 @@ export function useTimelineDrag({
e.preventDefault();
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 = {
isActive: true,
mode: opts.mode,
@@ -375,14 +470,20 @@ export function useTimelineDrag({
if (!alloc.isActive) return;
if (alloc.daysDelta === 0 && alloc.allocationId) {
// No movement → treat as click, open alloc popover
onBlockClickRef.current?.({
allocationId: alloc.allocationId,
projectId: alloc.projectId ?? "",
projectName: alloc.projectName ?? "",
startDate: alloc.originalStartDate!,
endDate: alloc.originalEndDate!,
});
// No movement → treat as click
if (wasShift) {
// Shift+Click → toggle multi-selection for this allocation
onShiftClickAllocRef.current?.(alloc.allocationId);
} else {
// Normal click → open alloc popover
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) {
pendingSnapshotRef.current = {
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 ───────────────────────────────────────────────────────────
// Helper: extract clientX from a touch event (first active touch, then changedTouches as fallback)
@@ -682,6 +858,8 @@ export function useTimelineDrag({
dragState,
allocDragState,
rangeState,
multiSelectState,
setMultiSelectState,
shiftPreview,
isPreviewLoading,
isApplying: applyShiftMutation.isPending,
@@ -693,6 +871,8 @@ export function useTimelineDrag({
onCanvasMouseMove,
onCanvasMouseUp,
onCanvasMouseLeave,
onCanvasRightMouseDown,
clearMultiSelect,
// Touch equivalents
onProjectBarTouchStart,
onAllocTouchStart,
+4 -4
View File
@@ -44,10 +44,10 @@ export function useTimelineLayout(
key={i}
className={clsx(
"absolute top-0 bottom-0 border-r",
isToday ? "border-brand-300 border-r-2" :
isSaturday ? "border-amber-200 bg-amber-50/40" :
isSunday ? "border-gray-200 bg-gray-100/60" :
"border-gray-100",
isToday ? "border-brand-300 dark:border-brand-700 border-r-2" :
isSaturday ? "border-amber-200 dark:border-amber-800 bg-amber-50/40 dark:bg-amber-950/20" :
isSunday ? "border-gray-200 dark:border-gray-700 bg-gray-100/60 dark:bg-gray-800/40" :
"border-gray-100 dark:border-gray-800",
)}
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({
defaultOptions: {
queries: {
staleTime: 30 * 1000, // 30 seconds
staleTime: 60 * 1000, // 60 seconds — reduces refetches on navigation
retry: 1,
},
},
+6
View File
@@ -28,6 +28,12 @@ const authConfig = {
const isValid = await verify(user.passwordHash, password);
if (!isValid) return null;
// Track last login time
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
return {
id: user.id,
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 { createCallerFactory } from "@planarchy/api/trpc";
import { prisma } from "@planarchy/db";
@@ -18,7 +18,8 @@ export async function createCaller() {
})
: null;
const ctx = createTRPCContext({ session, dbUser });
const roleDefaults = await loadRoleDefaults();
const ctx = createTRPCContext({ session, dbUser, roleDefaults });
const callerFactory = createCallerFactory(appRouter);
return callerFactory(ctx);
}