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>(
() => ({