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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user