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
@@ -0,0 +1,142 @@
"use client";
import { useMemo } from "react";
interface ChatMessageProps {
role: "user" | "assistant";
content: string;
}
/**
* Lightweight inline markdown renderer — handles bold, italic, code,
* bullet lists, and numbered lists without a full markdown library.
*/
function renderMarkdown(text: string) {
const lines = text.split("\n");
const elements: React.ReactNode[] = [];
let listItems: React.ReactNode[] = [];
let listType: "ul" | "ol" | null = null;
const flushList = () => {
if (listItems.length > 0 && listType) {
const Tag = listType;
elements.push(
<Tag key={`list-${elements.length}`} className={listType === "ul" ? "list-disc pl-4 my-1 space-y-0.5" : "list-decimal pl-4 my-1 space-y-0.5"}>
{listItems}
</Tag>,
);
listItems = [];
listType = null;
}
};
for (const [i, line] of lines.entries()) {
// Bullet list: "- item" or "* item"
const bulletMatch = line.match(/^[\s]*[-*]\s+(.*)/);
if (bulletMatch?.[1]) {
if (listType !== "ul") flushList();
listType = "ul";
listItems.push(<li key={`li-${i}`}>{inlineFormat(bulletMatch[1])}</li>);
continue;
}
// Numbered list: "1. item"
const numMatch = line.match(/^[\s]*\d+\.\s+(.*)/);
if (numMatch?.[1]) {
if (listType !== "ol") flushList();
listType = "ol";
listItems.push(<li key={`li-${i}`}>{inlineFormat(numMatch[1])}</li>);
continue;
}
// Not a list item — flush any pending list
flushList();
// Empty line → spacing
if (line.trim() === "") {
elements.push(<div key={`br-${i}`} className="h-2" />);
continue;
}
// Regular paragraph
elements.push(<p key={`p-${i}`} className="my-0">{inlineFormat(line)}</p>);
}
flushList();
return elements;
}
/** Parse inline formatting: **bold**, *italic*, `code` */
function inlineFormat(text: string): React.ReactNode {
// Split by inline patterns, preserving delimiters
const parts: React.ReactNode[] = [];
// Regex: **bold**, *italic*, `code`
const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
// Text before this match
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
if (match[2]) {
// **bold**
parts.push(<strong key={`b-${match.index}`} className="font-semibold">{match[2]}</strong>);
} else if (match[3]) {
// *italic*
parts.push(<em key={`i-${match.index}`}>{match[3]}</em>);
} else if (match[4]) {
// `code`
parts.push(
<code key={`c-${match.index}`} className="rounded bg-black/10 px-1 py-0.5 text-xs font-mono dark:bg-white/10">
{match[4]}
</code>,
);
}
lastIndex = match.index + match[0].length;
}
// Remaining text
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length === 1 ? parts[0] : <>{parts}</>;
}
export function ChatMessage({ role, content }: ChatMessageProps) {
const isUser = role === "user";
const rendered = useMemo(() => (isUser ? null : renderMarkdown(content)), [isUser, content]);
return (
<div className={`flex ${isUser ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[85%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
isUser
? "bg-brand-600 text-white"
: "bg-gray-100 text-gray-800 dark:bg-slate-800 dark:text-gray-200"
}`}
>
{isUser ? (
<span className="whitespace-pre-wrap break-words">{content}</span>
) : (
<div className="space-y-0.5 break-words">{rendered}</div>
)}
</div>
</div>
);
}
export function TypingIndicator() {
return (
<div className="flex justify-start">
<div className="rounded-2xl bg-gray-100 px-4 py-3 dark:bg-slate-800">
<div className="flex gap-1.5">
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style={{ animationDelay: "0ms" }} />
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style={{ animationDelay: "150ms" }} />
<span className="h-2 w-2 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style={{ animationDelay: "300ms" }} />
</div>
</div>
</div>
);
}