feat: project colors, timeline filters, sidebar fix, GitLooper agent, and misc improvements
- Fix sidebar double-highlight on /vacations/my (Gitea #6): add isNavItemActive() helper - Add project color picker (schema + API + modal + timeline rendering) - Add ProjectCombobox/ResourceCombobox to timeline toolbar - Show PENDING vacations on timeline with dashed/dimmed style - Add "show demand projects" preference with localStorage persistence - Add ProjectAssignmentsTable with total hours/cost columns - Extend vacation API to accept status arrays - Add GitLooper formal YAML agent configuration - Extend user admin with permission overrides UI - Add delete-assignment use case tests - Add status-styles.ts shared badge constants - Centralize formatMoney/formatCents in format.ts Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -136,6 +136,38 @@ const adminNavEntries: AdminEntry[] = [
|
||||
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
|
||||
];
|
||||
|
||||
/**
|
||||
* Collect every href registered in the sidebar so that the active-check
|
||||
* can determine whether a more-specific sibling matches the current path.
|
||||
* Example: when pathname is `/vacations/my`, the item `/vacations` must NOT
|
||||
* highlight because `/vacations/my` is a more-specific registered route.
|
||||
*/
|
||||
const ALL_NAV_HREFS: string[] = (() => {
|
||||
const hrefs: string[] = [];
|
||||
for (const section of navSections) {
|
||||
for (const item of section.items) hrefs.push(item.href);
|
||||
}
|
||||
for (const entry of adminNavEntries) {
|
||||
if (isSubGroup(entry)) {
|
||||
for (const item of entry.items) hrefs.push(item.href);
|
||||
} else {
|
||||
hrefs.push(entry.href);
|
||||
}
|
||||
}
|
||||
return hrefs;
|
||||
})();
|
||||
|
||||
function isNavItemActive(pathname: string, href: string): boolean {
|
||||
if (pathname === href) return true;
|
||||
if (!pathname.startsWith(href + "/")) return false;
|
||||
// pathname starts with `href/...` — but a more-specific registered route may match.
|
||||
// If another nav href is a longer prefix match, this shorter one should NOT be active.
|
||||
const hasMoreSpecificSibling = ALL_NAV_HREFS.some(
|
||||
(other) => other !== href && other.length > href.length && pathname.startsWith(other),
|
||||
);
|
||||
return !hasMoreSpecificSibling;
|
||||
}
|
||||
|
||||
function Sidebar({ userRole, onChatOpen }: { userRole: string; onChatOpen: () => void }) {
|
||||
const pathname = usePathname();
|
||||
const [prefsOpen, setPrefsOpen] = useState(false);
|
||||
@@ -154,13 +186,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) => pathname.startsWith(item.href));
|
||||
const hasActiveRoute = section.items.some((item) => isNavItemActive(pathname, 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));
|
||||
const hasActiveRoute = entry.items.some((item) => isNavItemActive(pathname, item.href));
|
||||
initial[entry.label] = !hasActiveRoute;
|
||||
}
|
||||
}
|
||||
@@ -230,7 +262,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",
|
||||
pathname.startsWith(item.href)
|
||||
isNavItemActive(pathname, 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",
|
||||
)}
|
||||
@@ -285,7 +317,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",
|
||||
pathname.startsWith(item.href)
|
||||
isNavItemActive(pathname, 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",
|
||||
)}
|
||||
@@ -304,7 +336,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",
|
||||
pathname.startsWith(entry.href)
|
||||
isNavItemActive(pathname, 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",
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user