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
+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 */}