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",
)}
@@ -20,7 +20,7 @@ const ACCENT_OPTIONS: { value: AccentColor; label: string; swatch: string }[] =
export function PreferencesModal({ onClose }: PreferencesModalProps) {
const { prefs, setMode, setAccent } = useTheme();
const { prefs: appPrefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme } = useAppPreferences();
const { prefs: appPrefs, setHideCompletedProjects, setTimelineDisplayMode, setHeatmapColorScheme, setShowDemandProjects } = useAppPreferences();
return (
<div
@@ -245,6 +245,33 @@ export function PreferencesModal({ onClose }: PreferencesModalProps) {
</span>
</div>
</label>
<label className="flex items-start gap-3 cursor-pointer mt-3">
<div className="relative mt-0.5 flex-shrink-0">
<input
type="checkbox"
checked={appPrefs.showDemandProjects}
onChange={(e) => setShowDemandProjects(e.target.checked)}
className="sr-only peer"
/>
<div className={clsx(
"w-9 h-5 rounded-full transition-colors",
appPrefs.showDemandProjects ? "bg-brand-600" : "bg-gray-200 dark:bg-gray-700",
)} />
<div className={clsx(
"absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform",
appPrefs.showDemandProjects ? "translate-x-4" : "translate-x-0",
)} />
</div>
<div>
<span className="text-sm text-gray-800 dark:text-gray-200 font-medium leading-tight block">
Include demand projects on load
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
Show open staffing demands (dashed bars) when loading pages.
</span>
</div>
</label>
</div>
{/* Preview note */}