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",
|
||||
)}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user