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:
@@ -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">
|
||||
—
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</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">
|
||||
|
||||
@@ -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">×</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">×</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 */}
|
||||
|
||||
Reference in New Issue
Block a user