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,221 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { formatDate } from "~/lib/format.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationModal } from "~/components/allocations/AllocationModal.js";
import type { AllocationWithDetails } from "@planarchy/shared";
import type { OpenDemandAssignment } from "~/components/timeline/TimelineProjectPanel.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { trpc } from "~/lib/trpc/client.js";
const ALLOC_STATUS_COLORS: Record<string, string> = {
ACTIVE: "bg-green-100 text-green-700",
PROPOSED: "bg-yellow-100 text-yellow-700",
CONFIRMED: "bg-blue-100 text-blue-700",
CANCELLED: "bg-gray-100 text-gray-500",
COMPLETED: "bg-blue-100 text-blue-600",
};
interface DemandRow {
id: string;
projectId: string;
role: string | null;
roleId: string | null;
roleEntity?: { id: string; name: string; color: string | null } | null;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
headcount: number;
budgetCents?: number;
requestedHeadcount: number;
unfilledHeadcount: number;
status: string;
project?: { id: string; name: string; shortCode: string };
assignments?: Array<{ dailyCostCents: number; startDate: Date | string; endDate: Date | string; status: string }>;
}
interface ProjectDemandsTableProps {
demands: DemandRow[];
project: { id: string; name: string; shortCode: string };
}
export function ProjectDemandsTable({ demands, project }: ProjectDemandsTableProps) {
const [fillTarget, setFillTarget] = useState<OpenDemandAssignment | null>(null);
const [editTarget, setEditTarget] = useState<AllocationWithDetails | null>(null);
const { canEdit } = usePermissions();
const router = useRouter();
const utils = trpc.useUtils();
function handleMutationSuccess() {
// Invalidate budget status so BudgetStatusCard refetches
void utils.timeline.getBudgetStatus.invalidate();
void utils.allocation.listView.invalidate();
router.refresh();
}
const activeDemands = demands.filter((d) => d.status !== "CANCELLED" && d.status !== "COMPLETED");
const allDemands = demands;
return (
<>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Open Demands ({allDemands.length})
</h2>
{activeDemands.length > 0 && (
<span className="text-xs text-amber-600 dark:text-amber-400">
{activeDemands.reduce((sum, d) => sum + d.unfilledHeadcount, 0)} seats unfilled
</span>
)}
</div>
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Role
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Period
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Headcount <InfoTooltip content="Requested / Unfilled seats for this demand." />
</span>
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Hours/Day
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Budget <InfoTooltip content="Allocated role budget vs. booked cost from assignments." />
</span>
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
{canEdit && (
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{allDemands.map((demand) => {
const isFillable = demand.status !== "CANCELLED" && demand.status !== "COMPLETED" && demand.unfilledHeadcount > 0;
return (
<tr key={demand.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors">
<td className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
{demand.roleEntity?.name ?? demand.role ?? "Unassigned"}
</td>
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{formatDate(demand.startDate)} {formatDate(demand.endDate)}
</td>
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">
<span className="font-medium">{demand.unfilledHeadcount}</span>
<span className="text-gray-400"> / {demand.requestedHeadcount}</span>
</td>
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">{demand.hoursPerDay}h</td>
<td className="px-4 py-3 text-right text-sm">
{demand.budgetCents && demand.budgetCents > 0 ? (() => {
// Calculate booked cost from assignments
const bookedCents = (demand.assignments ?? [])
.filter((a) => a.status !== "CANCELLED")
.reduce((sum, a) => {
const s = new Date(a.startDate);
const e = new Date(a.endDate);
let days = 0;
const cur = new Date(s);
while (cur <= e) { if (cur.getDay() !== 0 && cur.getDay() !== 6) days++; cur.setDate(cur.getDate() + 1); }
return sum + a.dailyCostCents * days;
}, 0);
const remainCents = demand.budgetCents! - bookedCents;
return (
<div>
<div className="text-gray-900 dark:text-gray-100">
{(demand.budgetCents! / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR
</div>
<div className={`text-xs ${remainCents < 0 ? "text-red-500" : "text-gray-400"}`}>
{bookedCents > 0 ? `${(bookedCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} booked` : ""}
{remainCents < 0 ? ` (${(Math.abs(remainCents) / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} over)` : ""}
</div>
</div>
);
})() : (
<span className="text-gray-400 text-xs"></span>
)}
</td>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 text-xs rounded-full ${ALLOC_STATUS_COLORS[demand.status] ?? "bg-gray-100 text-gray-600"}`}>
{demand.status}
</span>
</td>
{canEdit && (
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => setEditTarget(demand as unknown as AllocationWithDetails)}
className="text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
>
Edit
</button>
{isFillable && (
<button
type="button"
onClick={() => setFillTarget({
id: demand.id,
projectId: demand.projectId,
roleId: demand.roleId,
role: demand.role,
headcount: demand.headcount,
...(demand.budgetCents ? { budgetCents: demand.budgetCents } : {}),
startDate: new Date(demand.startDate),
endDate: new Date(demand.endDate),
hoursPerDay: demand.hoursPerDay,
roleEntity: demand.roleEntity ?? null,
project,
})}
className="inline-flex items-center gap-1 text-xs font-medium text-brand-600 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-200"
>
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
Assign
</button>
)}
</div>
</td>
)}
</tr>
);
})}
</tbody>
</table>
{allDemands.length === 0 && (
<div className="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">No open demands for this project.</div>
)}
</div>
{fillTarget && (
<FillOpenDemandModal
allocation={fillTarget as never}
onClose={() => { setFillTarget(null); handleMutationSuccess(); }}
onSuccess={() => { setFillTarget(null); handleMutationSuccess(); }}
/>
)}
{editTarget && (
<AllocationModal
allocation={editTarget}
onClose={() => { setEditTarget(null); handleMutationSuccess(); }}
onSuccess={() => { setEditTarget(null); handleMutationSuccess(); }}
/>
)}
</>
);
}