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
@@ -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>
);
});