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:
2026-03-17 10:22:52 +01:00
parent b0e55786c3
commit eb283147d1
34 changed files with 1545 additions and 255 deletions
+37 -5
View File
@@ -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",
)}