feat: timeline UI overhaul with project/resource panel redesign, quick filters, and API improvements

Redesigned timeline project and resource panels with expanded detail views,
added quick filter toolbar, improved drag handling, and enhanced vacation/entitlement
router logic. Includes e2e test updates and minor API fixes.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-15 09:28:59 +01:00
parent fa2019f521
commit a83edb2f9d
23 changed files with 2464 additions and 734 deletions
+216 -80
View File
@@ -57,48 +57,115 @@ function AdminIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 8a4 4 0 100 8 4 4 0 000-8zm8 4l-2.1.7a7.9 7.9 0 01-.6 1.5l1 2-2.1 2.1-2-1a7.9 7.9 0 01-1.5.6L12 20l-1.7-2.1a7.9 7.9 0 01-1.5-.6l-2 1-2.1-2.1 1-2a7.9 7.9 0 01-.6-1.5L4 12l2.1-1.7a7.9 7.9 0 01.6-1.5l-1-2 2.1-2.1 2 1a7.9 7.9 0 011.5-.6L12 4l1.7 2.1a7.9 7.9 0 011.5.6l2-1 2.1 2.1-1 2a7.9 7.9 0 01.6 1.5L20 12z" /></svg>;
}
const allNavItems = [
{ href: "/dashboard", label: "Dashboard", icon: <DashboardIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/resources", label: "Resources", icon: <ResourcesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/projects", label: "Projects", icon: <ProjectsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/estimates", label: "Estimates", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/allocations", label: "Allocations", icon: <AllocationsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/timeline", label: "Timeline", icon: <TimelineIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/staffing", label: "Staffing", icon: <StaffingIcon />, roles: ["ADMIN", "MANAGER"] },
{ href: "/vacations", label: "Vacations", icon: <VacationIcon />, roles: ["ADMIN", "MANAGER"] },
{ href: "/vacations/my", label: "My Vacations", icon: <VacationIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/roles", label: "Roles", icon: <RolesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/analytics/skills", label: "Skills Analytics", icon: <SkillsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
type NavItem = { href: string; label: string; icon: ReactNode; roles: string[] };
type NavSection = { label: string; collapsed?: boolean; items: NavItem[] };
const navSections: NavSection[] = [
{
label: "Planning",
items: [
{ href: "/dashboard", label: "Dashboard", icon: <DashboardIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/timeline", label: "Timeline", icon: <TimelineIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/allocations", label: "Allocations", icon: <AllocationsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/staffing", label: "Staffing", icon: <StaffingIcon />, roles: ["ADMIN", "MANAGER"] },
],
},
{
label: "Estimating",
collapsed: true,
items: [
{ href: "/estimates", label: "Estimates", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/admin/rate-cards", label: "Rate Cards", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/admin/effort-rules", label: "Effort Rules", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/admin/experience-multipliers", label: "Exp. Multipliers", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
],
},
{
label: "Resources",
items: [
{ href: "/resources", label: "Resources", icon: <ResourcesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/projects", label: "Projects", icon: <ProjectsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/roles", label: "Roles", icon: <RolesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
],
},
{
label: "Analytics",
items: [
{ href: "/analytics/skills", label: "Skills Analytics", icon: <SkillsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
],
},
{
label: "Time Off",
items: [
{ href: "/vacations/my", label: "My Vacations", icon: <VacationIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/vacations", label: "Vacation Mgmt", icon: <VacationIcon />, roles: ["ADMIN", "MANAGER"] },
],
},
];
const adminNavItems = [
type AdminNavItem = { href: string; label: string; icon: ReactNode };
type AdminSubGroup = { label: string; collapsed: boolean; items: AdminNavItem[] };
type AdminEntry = AdminNavItem | AdminSubGroup;
function isSubGroup(entry: AdminEntry): entry is AdminSubGroup {
return "items" in entry;
}
const adminNavEntries: AdminEntry[] = [
{ href: "/admin/blueprints", label: "Blueprints", icon: <AdminIcon /> },
{ href: "/admin/countries", label: "Countries", icon: <AdminIcon /> },
{ href: "/admin/org-units", label: "Org Units", icon: <AdminIcon /> },
{ href: "/admin/utilization-categories", label: "Util. Categories", icon: <AdminIcon /> },
{ href: "/admin/clients", label: "Clients", icon: <AdminIcon /> },
{ href: "/admin/rate-cards", label: "Rate Cards", icon: <AdminIcon /> },
{ href: "/admin/effort-rules", label: "Effort Rules", icon: <AdminIcon /> },
{ href: "/admin/experience-multipliers", label: "Exp. Multipliers", icon: <AdminIcon /> },
{ href: "/admin/management-levels", label: "Mgmt Levels", icon: <AdminIcon /> },
{
label: "ACN-Orga",
collapsed: true,
items: [
{ href: "/admin/countries", label: "Countries", icon: <AdminIcon /> },
{ href: "/admin/org-units", label: "Org Units", icon: <AdminIcon /> },
{ href: "/admin/utilization-categories", label: "Util. Categories", icon: <AdminIcon /> },
{ href: "/admin/management-levels", label: "Mgmt Levels", icon: <AdminIcon /> },
],
},
{ href: "/admin/calculation-rules", label: "Calc. Rules", icon: <AdminIcon /> },
{ href: "/admin/users", label: "Users", icon: <AdminIcon /> },
{ href: "/admin/settings", label: "Settings", icon: <AdminIcon /> },
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
];
const managerNavItems = [
{ href: "/admin/vacations", label: "Vacation Mgmt", icon: <VacationIcon /> },
];
function Sidebar({ userRole }: { userRole: string }) {
const pathname = usePathname();
const [prefsOpen, setPrefsOpen] = useState(false);
const visibleNavItems = allNavItems.filter((item) => item.roles.includes(userRole));
const visibleSections = navSections
.map((section) => ({
...section,
items: section.items.filter((item) => item.roles.includes(userRole)),
}))
.filter((section) => section.items.length > 0);
const showAdmin = userRole === "ADMIN";
const showManagerSection = userRole === "ADMIN" || userRole === "MANAGER";
// Sections and sub-groups auto-expand when the current route matches an item inside them
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {};
for (const section of visibleSections) {
if (section.collapsed) {
const hasActiveRoute = section.items.some((item) => pathname.startsWith(item.href));
initial[section.label] = !hasActiveRoute;
}
}
for (const entry of adminNavEntries) {
if (isSubGroup(entry) && entry.collapsed) {
const hasActiveRoute = entry.items.some((item) => pathname.startsWith(item.href));
initial[entry.label] = !hasActiveRoute;
}
}
return initial;
});
const toggleSection = (label: string) => {
setCollapsedSections((prev) => ({ ...prev, [label]: !prev[label] }));
};
return (
<>
<nav className="w-72 shrink-0 border-r border-white/60 bg-white/80 backdrop-blur-xl dark:border-slate-800 dark:bg-slate-950/75 flex flex-col">
@@ -118,63 +185,132 @@ function Sidebar({ userRole }: { userRole: string }) {
</div>
{/* Nav links */}
<div className="flex-1 space-y-1 overflow-y-auto px-4 py-5">
{visibleNavItems.map((item) => (
<Link
key={item.href}
href={item.href as Route}
className={clsx(
"group flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-all",
pathname.startsWith(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",
)}
>
<IconFrame>{item.icon}</IconFrame>
<span className="flex-1">{item.label}</span>
</Link>
))}
<div className="flex-1 overflow-y-auto px-4 py-5">
{visibleSections.map((section, idx) => {
const isCollapsed = collapsedSections[section.label] ?? false;
const isCollapsible = section.collapsed === true;
{showManagerSection && (
<>
<div className="pb-1 pt-4">
<div className="border-t border-gray-200 pt-4 dark:border-slate-800">
<span className="px-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500">
{showAdmin ? "Admin" : "Management"}
return (
<div key={section.label} className={idx > 0 ? "mt-2" : ""}>
<button
type="button"
onClick={isCollapsible ? () => toggleSection(section.label) : undefined}
className={clsx(
"flex w-full items-center px-3 pb-1.5 pt-3",
isCollapsible && "cursor-pointer hover:text-gray-600 dark:hover:text-gray-300",
)}
>
<span className="flex-1 text-left text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500">
{section.label}
</span>
</div>
{isCollapsible && (
<svg
className={clsx(
"h-3 w-3 text-gray-400 transition-transform dark:text-gray-500",
isCollapsed ? "-rotate-90" : "rotate-0",
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
</button>
{!isCollapsed && (
<div className="space-y-0.5">
{section.items.map((item) => (
<Link
key={item.href}
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",
pathname.startsWith(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",
)}
>
<IconFrame>{item.icon}</IconFrame>
<span className="flex-1">{item.label}</span>
</Link>
))}
</div>
)}
</div>
{showAdmin && adminNavItems.map((item) => (
<Link
key={item.href}
href={item.href as Route}
className={clsx(
"group flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-all",
pathname.startsWith(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",
)}
>
<IconFrame>{item.icon}</IconFrame>
<span className="flex-1">{item.label}</span>
</Link>
))}
{managerNavItems.map((item) => (
<Link
key={item.href}
href={item.href as Route}
className={clsx(
"group flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-all",
pathname.startsWith(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",
)}
>
<IconFrame>{item.icon}</IconFrame>
<span className="flex-1">{item.label}</span>
</Link>
))}
</>
);
})}
{showManagerSection && showAdmin && (
<div className="mt-2">
<div className="px-3 pb-1.5 pt-3">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500">
Admin
</span>
</div>
<div className="space-y-0.5">
{adminNavEntries.map((entry) => {
if (isSubGroup(entry)) {
const subCollapsed = collapsedSections[entry.label] ?? false;
return (
<div key={entry.label}>
<button
type="button"
onClick={() => toggleSection(entry.label)}
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium text-gray-500 transition-all hover:bg-gray-100/90 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-slate-900 dark:hover:text-gray-200"
>
<IconFrame><AdminIcon /></IconFrame>
<span className="flex-1 text-left">{entry.label}</span>
<svg
className={clsx(
"h-3 w-3 transition-transform",
subCollapsed ? "-rotate-90" : "rotate-0",
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{!subCollapsed && (
<div className="ml-4 space-y-0.5 border-l border-gray-200 pl-2 dark:border-slate-800">
{entry.items.map((item) => (
<Link
key={item.href}
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",
pathname.startsWith(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",
)}
>
<span className="flex-1">{item.label}</span>
</Link>
))}
</div>
)}
</div>
);
}
return (
<Link
key={entry.href}
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",
pathname.startsWith(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",
)}
>
<IconFrame>{entry.icon}</IconFrame>
<span className="flex-1">{entry.label}</span>
</Link>
);
})}
</div>
</div>
)}
</div>