feat: AI assistant (HartBOT), demand filling, budget-per-role, project favorites, and UX improvements

AI Assistant (HartBOT):
- Chat panel with inline layout, session persistence, message history (up-arrow recall)
- OpenAI function calling with 20+ tools (search, navigate, create/cancel allocations, update status)
- RBAC-aware tool filtering, fuzzy search with word-level matching
- Navigation actions (router.push) and data invalidation after mutations
- Country/metro city/org unit/role filtering on resource search

Demand Filling Enhancements:
- Two-phase fill modal: plan multiple resources, then confirm & assign all at once
- Availability preview per resource (available/partial/conflict days, existing bookings)
- Coverage bar showing demand hours distribution across assigned resources
- Fill demand from project detail page (new Assign button per demand)
- Fixed: filled demands no longer shown on timeline, demand bars no longer overlap

Budget per Role:
- DemandRequirement.budgetCents field (schema + API + UI)
- Project wizard step 3: budget input per role with allocation summary bar
- Project detail: allocated vs booked budget per demand
- Fill demand modal: role budget display with cost estimates
- AllocationModal: budget field for demand editing

Project Favorites:
- User.favoriteProjectIds (JSONB) with toggle API
- Star button on projects list and detail page (optimistic updates)
- "My Projects" dashboard widget (favorites + responsible person projects)

Project Management:
- Edit project from detail page (ProjectModal integration)
- Edit demands from detail page (AllocationModal integration)
- Admin-only project deletion (cascades assignments + demands)
- Create user accounts from admin panel

Timeline Fixes:
- Country multi-select filter with backend support
- URL param sync for same-page navigation (AI assistant integration)
- Demand lane stacking (no more overlapping bars)
- Single-day booking resize handles (always visible, min 6px)
- Single-day resize allowed (start === end)
- "All Clients" toggle (select all / deselect all)

Other Fixes:
- crypto.randomUUID fallback for non-secure contexts
- Chat message limit raised (200 max, client sends last 40)
- Status dropdown portal (no longer clipped by table overflow)
- Cents display restored in budget views (2 decimal places)
- Allocations grouped view with project sub-groups (collapsed by default)
- Server-side resource search for project wizard (no 500 limit)

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-16 15:31:48 +01:00
parent f5551e33c7
commit b0e55786c3
44 changed files with 4516 additions and 609 deletions
@@ -7,7 +7,8 @@ import {
type Assignment,
type DemandRequirement,
} from "@planarchy/shared";
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useSearchParams } from "next/navigation";
import { useTimelineSSE } from "~/hooks/useTimelineSSE.js";
import { trpc } from "~/lib/trpc/client.js";
import { readAppPreferences, useAppPreferences } from "~/hooks/useAppPreferences.js";
@@ -176,20 +177,104 @@ export function TimelineProvider({
isDragging,
children,
}: TimelineProviderProps) {
const searchParams = useSearchParams();
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
const [viewStart, setViewStart] = useState(() => addDays(today, -30));
const [viewDays, setViewDays] = useState(180);
// Support URL params: ?startDate=2026-01-01&days=120
const [viewStart, setViewStart] = useState(() => {
const sp = searchParams.get("startDate");
if (sp) {
const d = new Date(sp);
if (!isNaN(d.getTime())) return d;
}
return addDays(today, -30);
});
const [viewDays, setViewDays] = useState(() => {
const sp = searchParams.get("days");
if (sp) {
const n = parseInt(sp, 10);
if (n > 0 && n <= 365) return n;
}
return 180;
});
const viewEnd = addDays(viewStart, viewDays);
const [filters, setFilters] = useState<TimelineFilters>(() => ({
...DEFAULT_FILTERS,
hideCompletedProjects: readAppPreferences().hideCompletedProjects,
}));
// Support URL params: ?eids=EMP-001,EMP-002&projectIds=id1,id2&chapters=ch1
const [filters, setFilters] = useState<TimelineFilters>(() => {
const base: TimelineFilters = {
...DEFAULT_FILTERS,
hideCompletedProjects: readAppPreferences().hideCompletedProjects,
};
const eids = searchParams.get("eids");
if (eids) base.eids = eids.split(",").filter(Boolean);
const projectIds = searchParams.get("projectIds");
if (projectIds) base.projectIds = projectIds.split(",").filter(Boolean);
const chapters = searchParams.get("chapters");
if (chapters) base.chapters = chapters.split(",").filter(Boolean);
const clientIds = searchParams.get("clientIds");
if (clientIds) base.clientIds = clientIds.split(",").filter(Boolean);
const countryCodes = searchParams.get("countryCodes");
if (countryCodes) base.countryCodes = countryCodes.split(",").filter(Boolean);
// If URL params specify filters, also show drafts and don't hide completed
if (eids || projectIds) {
base.showDrafts = true;
base.hideCompletedProjects = false;
}
return base;
});
// Track whether this is the initial mount (URL params already applied via useState initializers)
const isInitialMount = useRef(true);
// Sync filters/viewStart/viewDays when URL search params change AFTER initial mount
// (e.g. when the AI assistant calls router.push("/timeline?eids=...") while already on /timeline)
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
// Update viewStart if param changed
const spStart = searchParams.get("startDate");
if (spStart) {
const d = new Date(spStart);
if (!isNaN(d.getTime())) setViewStart(d);
}
// Update viewDays if param changed
const spDays = searchParams.get("days");
if (spDays) {
const n = parseInt(spDays, 10);
if (n > 0 && n <= 365) setViewDays(n);
}
// Update filters if any filter params present
const eids = searchParams.get("eids");
const projectIds = searchParams.get("projectIds");
const chapters = searchParams.get("chapters");
const clientIds = searchParams.get("clientIds");
const countryCodes = searchParams.get("countryCodes");
if (eids || projectIds || chapters || clientIds || countryCodes) {
setFilters((prev) => {
const next = { ...prev };
if (eids) next.eids = eids.split(",").filter(Boolean);
if (projectIds) next.projectIds = projectIds.split(",").filter(Boolean);
if (chapters) next.chapters = chapters.split(",").filter(Boolean);
if (clientIds) next.clientIds = clientIds.split(",").filter(Boolean);
if (countryCodes) next.countryCodes = countryCodes.split(",").filter(Boolean);
if (eids || projectIds) {
next.showDrafts = true;
next.hideCompletedProjects = false;
}
return next;
});
}
}, [searchParams]);
const [filterOpen, setFilterOpen] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>("resource");
@@ -208,6 +293,7 @@ export function TimelineProvider({
...(filters.projectIds.length > 0 ? { projectIds: filters.projectIds } : {}),
...(filters.chapters.length > 0 ? { chapters: filters.chapters } : {}),
...(filters.eids.length > 0 ? { eids: filters.eids } : {}),
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ placeholderData: (prev: any) => prev },
@@ -259,6 +345,10 @@ export function TimelineProvider({
if (entry.project.status === "DRAFT" && !filters.showDrafts) return false;
if (DONE_STATUSES.has(entry.project.status) && filters.hideCompletedProjects) return false;
if (!filters.showPlaceholders) return false;
// Hide fully-filled demands (status COMPLETED or unfilledHeadcount <= 0)
const demandEntry = entry as { status?: string; unfilledHeadcount?: number };
if (demandEntry.status === "COMPLETED") return false;
if (typeof demandEntry.unfilledHeadcount === "number" && demandEntry.unfilledHeadcount <= 0) return false;
return true;
}),
[demands, filters.hideCompletedProjects, filters.showDrafts, filters.showPlaceholders],
@@ -469,7 +559,8 @@ export function TimelineProvider({
filters.clientIds.length +
filters.chapters.length +
filters.eids.length +
filters.projectIds.length;
filters.projectIds.length +
filters.countryCodes.length;
const value = useMemo<TimelineContextValue>(
() => ({
@@ -26,6 +26,8 @@ export interface TimelineFilters {
showVacations: boolean;
/** Show open-demand entries (no resource assigned yet). Default: true. */
showPlaceholders: boolean;
/** Filter to specific country IDs */
countryCodes: string[];
}
export const DEFAULT_FILTERS: TimelineFilters = {
@@ -33,6 +35,7 @@ export const DEFAULT_FILTERS: TimelineFilters = {
chapters: [],
eids: [],
projectIds: [],
countryCodes: [],
showWeekends: false,
zoom: "day",
hideCompletedProjects: true, // overridden at runtime from AppPreferences
@@ -212,7 +215,8 @@ export function TimelineFilter({
filters.clientIds.length +
filters.chapters.length +
filters.eids.length +
filters.projectIds.length;
filters.projectIds.length +
filters.countryCodes.length;
return createPortal(
<div
@@ -65,6 +65,7 @@ export interface OpenDemandAssignment {
roleId: string | null;
role: string | null;
headcount: number;
budgetCents?: number;
startDate: Date;
endDate: Date;
hoursPerDay: number;
@@ -402,7 +403,18 @@ export function TimelineProjectPanel({
const rowVirtualizer = useVirtualizer({
count: flatRows.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: (index) => (flatRows[index]?.type === "header" ? PROJECT_HEADER_HEIGHT : ROW_HEIGHT),
estimateSize: (index) => {
const row = flatRows[index];
if (!row) return ROW_HEIGHT;
if (row.type === "header") return PROJECT_HEADER_HEIGHT;
if (row.type === "open-demand") {
const laneCount = assignDemandLanes(row.openDemands).size > 0
? Math.max(...assignDemandLanes(row.openDemands).values()) + 1
: 1;
return Math.max(ROW_HEIGHT, laneCount * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP) + 8);
}
return ROW_HEIGHT;
},
overscan: 8,
getItemKey: (index) => flatRows[index]?.key ?? index,
});
@@ -902,6 +914,42 @@ function ProjectPanelTooltips({
// ─── Pure render functions ──────────────────────────────────────────────────
/** Assign lane indices to demands so overlapping bars don't stack on top of each other. */
function assignDemandLanes(
demands: TimelineDemandEntry[],
): Map<string, number> {
const laneMap = new Map<string, number>();
// Each lane tracks the latest end-date occupying it
const laneEnds: Date[] = [];
// Sort by start date for greedy lane assignment
const sorted = [...demands].sort(
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime(),
);
for (const d of sorted) {
const start = new Date(d.startDate);
let assigned = -1;
for (let i = 0; i < laneEnds.length; i++) {
if (laneEnds[i]! < start) {
assigned = i;
laneEnds[i] = new Date(d.endDate);
break;
}
}
if (assigned === -1) {
assigned = laneEnds.length;
laneEnds.push(new Date(d.endDate));
}
laneMap.set(d.id, assigned);
}
return laneMap;
}
const DEMAND_LANE_HEIGHT = 30;
const DEMAND_LANE_GAP = 2;
function renderOpenDemandRow(
openDemands: TimelineDemandEntry[],
CELL_WIDTH: number,
@@ -913,14 +961,18 @@ function renderOpenDemandRow(
) {
if (openDemands.length === 0) return null;
const laneMap = assignDemandLanes(openDemands);
const laneCount = laneMap.size > 0 ? Math.max(...laneMap.values()) + 1 : 1;
const rowHeight = Math.max(ROW_HEIGHT, laneCount * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP) + 8);
return (
<div
className="flex border-b border-dashed border-amber-200 bg-amber-50/30 hover:bg-amber-50/50 group"
style={{ height: ROW_HEIGHT }}
style={{ minHeight: rowHeight }}
>
<div
className="flex-shrink-0 border-r border-amber-200 flex items-center pl-8 pr-4 gap-2 bg-amber-50 sticky left-0 z-30"
style={{ width: LABEL_WIDTH }}
style={{ width: LABEL_WIDTH, minHeight: rowHeight }}
>
<div className="w-6 h-6 rounded-full bg-amber-100 flex items-center justify-center text-[10px] font-bold text-amber-600 flex-shrink-0 border border-dashed border-amber-400">
?
@@ -935,7 +987,7 @@ function renderOpenDemandRow(
<div
className="relative overflow-hidden"
style={{ width: totalCanvasWidth, height: ROW_HEIGHT, ...rowGridStyle }}
style={{ width: totalCanvasWidth, minHeight: rowHeight, ...rowGridStyle }}
>
{openDemands.map((alloc) => {
const allocStart = new Date(alloc.startDate);
@@ -951,6 +1003,8 @@ function renderOpenDemandRow(
roleEntity?.name ?? (alloc as { role?: string | null }).role ?? "Open demand";
const roleColor = roleEntity?.color ?? "#f59e0b";
const headcount = (alloc as { headcount?: number }).headcount ?? 1;
const lane = laneMap.get(alloc.id) ?? 0;
const top = 4 + lane * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP);
return (
<div
@@ -959,8 +1013,8 @@ function renderOpenDemandRow(
style={{
left: left + 2,
width: width - 4,
top: 8,
height: SUB_LANE_HEIGHT - 8,
top,
height: DEMAND_LANE_HEIGHT,
backgroundColor: `${roleColor}33`,
border: `2px dashed ${roleColor}99`,
}}
@@ -1118,7 +1172,8 @@ function renderProjectDragHandles(
const width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
if (width <= 0 || left >= totalCanvasWidth) return null;
const HANDLE_W = width >= 48 ? 8 : 0;
// Always show resize handles — for narrow bars, use overlapping handles
const HANDLE_W = width >= 48 ? 8 : 6;
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence;
const allocInfo: AllocMouseDownInfo = {
@@ -1153,17 +1208,15 @@ function renderProjectDragHandles(
);
}}
>
{HANDLE_W > 0 && (
<div
className="flex-shrink-0 cursor-ew-resize"
style={{ width: HANDLE_W }}
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
}}
/>
)}
<div
className="flex-shrink-0 cursor-ew-resize"
style={{ width: HANDLE_W }}
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
}}
/>
<div
className={clsx(
"flex-1 min-w-0 flex items-center",
@@ -1181,17 +1234,15 @@ function renderProjectDragHandles(
</span>
)}
</div>
{HANDLE_W > 0 && (
<div
className="flex-shrink-0 cursor-ew-resize"
style={{ width: HANDLE_W }}
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
}}
/>
)}
<div
className="flex-shrink-0 cursor-ew-resize"
style={{ width: HANDLE_W }}
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
}}
/>
</div>
);
});
@@ -162,6 +162,10 @@ export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFilters
{ isActive: true },
{ staleTime: 60_000 },
);
const { data: countriesData } = trpc.country.list.useQuery(
{ isActive: true },
{ staleTime: 60_000 },
);
const resources = ((resourceData?.resources as ResourceOption[] | undefined) ?? []).slice();
const eidSuggestions = (
@@ -189,6 +193,14 @@ export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFilters
[clientsData],
);
const countries = useMemo(
() =>
((countriesData ?? []) as Array<{ id: string; code: string; name: string }>)
.map((c) => ({ id: c.id, code: c.code, name: c.name }))
.sort((a, b) => a.name.localeCompare(b.name)),
[countriesData],
);
const resourceMap = useMemo(
() => new Map(resources.map((resource) => [resource.eid, resource])),
[resources],
@@ -197,6 +209,10 @@ export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFilters
() => new Map(clients.map((client) => [client.id, client])),
[clients],
);
const countryMap = useMemo(
() => new Map(countries.map((c) => [c.code, c])),
[countries],
);
const clientLabel = buildSelectionLabel("Clients", filters.clientIds.length, clients.length || 1);
const chapterLabel = buildSelectionLabel(
@@ -204,6 +220,11 @@ export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFilters
filters.chapters.length,
chapters.length || 1,
);
const countryLabel = buildSelectionLabel(
"Countries",
filters.countryCodes.length,
countries.length || 1,
);
const peopleLabel =
filters.eids.length === 0 ? "All people" : `People: ${filters.eids.length}`;
@@ -225,6 +246,17 @@ export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFilters
onChange({ ...filters, chapters: nextChapters });
}
function toggleCountry(code: string) {
const next = filters.countryCodes.includes(code)
? filters.countryCodes.filter((c) => c !== code)
: [...filters.countryCodes, code].sort((a, b) => {
const aName = countryMap.get(a)?.name ?? a;
const bName = countryMap.get(b)?.name ?? b;
return aName.localeCompare(bName);
});
onChange({ ...filters, countryCodes: next });
}
function addEid(eid: string) {
if (filters.eids.includes(eid)) return;
onChange({ ...filters, eids: [...filters.eids, eid] });
@@ -260,7 +292,13 @@ export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFilters
<input
type="checkbox"
checked={filters.clientIds.length === 0}
onChange={() => onChange({ ...filters, clientIds: [] })}
ref={(el) => {
if (el) el.indeterminate = filters.clientIds.length > 0 && filters.clientIds.length < clients.length;
}}
onChange={() => {
// Toggle all ↔ none: if showing all → select all explicitly; if any selected → clear filter (show all)
onChange({ ...filters, clientIds: filters.clientIds.length === 0 ? clients.map((c) => c.id) : [] });
}}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
/>
<span className="font-medium">All clients</span>
@@ -336,6 +374,56 @@ export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFilters
</div>
</TimelineFilterDropdown>
<TimelineFilterDropdown
label={countryLabel}
widthClassName="w-80"
tooltipContent="Multi-select country filter. Checked countries stay visible in both timeline views."
>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Countries</h2>
<p className="text-xs text-gray-500">Checked countries stay visible.</p>
</div>
<button
type="button"
onClick={() => onChange({ ...filters, countryCodes: [] })}
className="text-xs font-medium text-brand-600 hover:text-brand-800 dark:text-brand-300 dark:hover:text-brand-100"
>
Show all
</button>
</div>
<div className="rounded-xl border border-gray-200 dark:border-gray-700">
<label className="flex items-center gap-3 border-b border-gray-200 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:text-gray-200">
<input
type="checkbox"
checked={filters.countryCodes.length === 0}
onChange={() => onChange({ ...filters, countryCodes: [] })}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
/>
<span className="font-medium">All countries</span>
</label>
<div className="max-h-64 overflow-auto">
{countries.map((country) => (
<label
key={country.code}
className="flex items-center gap-3 border-b border-gray-100 px-3 py-2 text-sm text-gray-700 last:border-b-0 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-200 dark:hover:bg-gray-800"
>
<input
type="checkbox"
checked={filters.countryCodes.length === 0 || filters.countryCodes.includes(country.code)}
onChange={() => toggleCountry(country.code)}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
/>
<span className="min-w-0 flex-1 truncate">{country.name}</span>
<span className="flex-shrink-0 text-xs text-gray-400 dark:text-gray-500">
{country.code}
</span>
</label>
))}
</div>
</div>
</TimelineFilterDropdown>
<TimelineFilterDropdown
label={peopleLabel}
widthClassName="w-96"
@@ -781,7 +781,7 @@ function renderAllocBlocksFromData(
text: "text-white",
light: "",
};
const HANDLE_W = width >= 48 ? 10 : 0;
const HANDLE_W = width >= 48 ? 10 : 6;
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence;
const allocInfo: AllocMouseDownInfo = {
@@ -821,22 +821,22 @@ function renderAllocBlocksFromData(
}}
>
{/* Left resize handle */}
{HANDLE_W > 0 && (
<div
className="flex-shrink-0 flex items-center justify-center cursor-ew-resize hover:bg-black/20 transition-colors"
style={{ width: HANDLE_W }}
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
}}
>
<div
className="flex-shrink-0 flex items-center justify-center cursor-ew-resize hover:bg-black/20 transition-colors"
style={{ width: HANDLE_W }}
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
}}
>
{HANDLE_W >= 10 && (
<div className="flex flex-col gap-0.5 opacity-60 group-hover/block:opacity-100">
<div className="w-px h-2.5 bg-white rounded" />
<div className="w-px h-2.5 bg-white rounded" />
</div>
</div>
)}
)}
</div>
{/* Center -- move */}
<div
@@ -861,22 +861,22 @@ function renderAllocBlocksFromData(
</div>
{/* Right resize handle */}
{HANDLE_W > 0 && (
<div
className="flex-shrink-0 flex items-center justify-center cursor-ew-resize hover:bg-black/20 transition-colors"
style={{ width: HANDLE_W }}
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
}}
>
<div
className="flex-shrink-0 flex items-center justify-center cursor-ew-resize hover:bg-black/20 transition-colors"
style={{ width: HANDLE_W }}
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
}}
>
{HANDLE_W >= 10 && (
<div className="flex flex-col gap-0.5 opacity-60 group-hover/block:opacity-100">
<div className="w-px h-2.5 bg-white rounded" />
<div className="w-px h-2.5 bg-white rounded" />
</div>
</div>
)}
)}
</div>
</div>
);
});
@@ -46,7 +46,8 @@ export function TimelineToolbar({
filters.clientIds.length +
filters.chapters.length +
filters.eids.length +
filters.projectIds.length;
filters.projectIds.length +
filters.countryCodes.length;
const filterAnchorRef = useRef<HTMLDivElement | null>(null);
function clearQuickFilters() {