625a842d89
Dashboard: expanded chargeability widget, resource/project table widgets with sorting and filters, stat cards with formatMoney integration. Chargeability: new report client with filtering, chargeability-bookings use case, updated dashboard overview logic. Dispo import: TBD project handling, parse-dispo-matrix improvements, stage-dispo-projects resource value scores, new tests. Estimates: CommercialTermsEditor component, commercial-terms engine module, expanded estimate schemas and types. UI: AppShell navigation updates, timeline filter/toolbar enhancements, role management improvements, signin page redesign, Tailwind/globals polish, SystemSettings SMTP section, anonymization support. Tests: new router tests (anonymization, chargeability, effort-rule, entitlement, estimate, experience-multiplier, notification, resource, staffing, vacation). Co-Authored-By: claude-flow <ruv@ruv.net>
70 lines
2.4 KiB
TypeScript
70 lines
2.4 KiB
TypeScript
"use client";
|
|
|
|
import { usePathname, useSearchParams } from "next/navigation";
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
/**
|
|
* Thin brand-colored progress bar at the top of the page.
|
|
* Animates to 100% on route change, then fades out.
|
|
* Pure CSS animation — no external dependency.
|
|
*/
|
|
export function NavProgressBar() {
|
|
const pathname = usePathname();
|
|
const searchParams = useSearchParams();
|
|
const [visible, setVisible] = useState(false);
|
|
const [width, setWidth] = useState(0);
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const prevPathRef = useRef<string | null>(null);
|
|
|
|
// Detect link clicks to start the bar early
|
|
useEffect(() => {
|
|
function handleClick(e: MouseEvent) {
|
|
const target = (e.target as Element).closest("a");
|
|
if (!target) return;
|
|
const href = target.getAttribute("href");
|
|
if (!href || href.startsWith("http") || href.startsWith("#") || href.startsWith("mailto")) return;
|
|
// Internal navigation — start bar
|
|
setVisible(true);
|
|
setWidth(60); // jump to 60% immediately, await route change for completion
|
|
}
|
|
document.addEventListener("click", handleClick);
|
|
return () => document.removeEventListener("click", handleClick);
|
|
}, []);
|
|
|
|
// Complete bar when route actually changes
|
|
useEffect(() => {
|
|
const current = pathname + searchParams.toString();
|
|
if (prevPathRef.current !== null && prevPathRef.current !== current) {
|
|
// Route changed — complete the bar
|
|
setWidth(100);
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
timerRef.current = setTimeout(() => {
|
|
setVisible(false);
|
|
setWidth(0);
|
|
}, 350);
|
|
}
|
|
prevPathRef.current = current;
|
|
}, [pathname, searchParams]);
|
|
|
|
// Cleanup
|
|
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
|
|
|
|
if (!visible && width === 0) return null;
|
|
|
|
return (
|
|
<div
|
|
aria-hidden="true"
|
|
className="pointer-events-none fixed left-0 right-0 top-0 z-[9999] h-1"
|
|
>
|
|
<div
|
|
className="h-full rounded-r-full bg-gradient-to-r from-brand-400 via-brand-500 to-brand-600 shadow-[0_0_18px_rgba(var(--accent-500),0.45)] transition-all ease-out"
|
|
style={{
|
|
width: `${width}%`,
|
|
transitionDuration: width === 100 ? "200ms" : "400ms",
|
|
opacity: visible ? 1 : 0,
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|