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:
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user