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:
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "../widget-registry.js";
|
||||
|
||||
const STATUS_DOT: Record<string, string> = {
|
||||
ACTIVE: "bg-green-500",
|
||||
DRAFT: "bg-gray-400",
|
||||
ON_HOLD: "bg-yellow-500",
|
||||
COMPLETED: "bg-blue-500",
|
||||
CANCELLED: "bg-red-400",
|
||||
};
|
||||
|
||||
export function MyProjectsWidget({ config }: WidgetProps) {
|
||||
const showFavorites = (config as { showFavorites?: boolean }).showFavorites !== false;
|
||||
const showResponsible = (config as { showResponsible?: boolean }).showResponsible !== false;
|
||||
|
||||
const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, {
|
||||
staleTime: 30_000,
|
||||
enabled: showFavorites,
|
||||
});
|
||||
|
||||
const { data: projectsData } = trpc.project.listWithCosts.useQuery(
|
||||
{ limit: 200 },
|
||||
{ staleTime: 30_000 },
|
||||
);
|
||||
|
||||
const { data: session } = trpc.user.me.useQuery(undefined, { staleTime: 60_000 });
|
||||
|
||||
const favorites = favoriteIds ?? [];
|
||||
const allProjects = (projectsData?.projects ?? []) as Array<{
|
||||
id: string;
|
||||
shortCode: string;
|
||||
name: string;
|
||||
status: string;
|
||||
responsiblePerson?: string | null;
|
||||
budgetCents?: number;
|
||||
startDate?: Date | string;
|
||||
endDate?: Date | string;
|
||||
}>;
|
||||
|
||||
const userName = session?.name ?? "";
|
||||
|
||||
const { favoriteProjects, responsibleProjects } = useMemo(() => {
|
||||
const favSet = new Set(favorites);
|
||||
const favProjects = showFavorites
|
||||
? allProjects.filter((p) => favSet.has(p.id))
|
||||
: [];
|
||||
const respProjects = showResponsible && userName
|
||||
? allProjects.filter((p) => p.responsiblePerson?.toLowerCase().includes(userName.toLowerCase()) && !favSet.has(p.id))
|
||||
: [];
|
||||
return { favoriteProjects: favProjects, responsibleProjects: respProjects };
|
||||
}, [allProjects, favorites, showFavorites, showResponsible, userName]);
|
||||
|
||||
const toggleMutation = trpc.user.toggleFavoriteProject.useMutation({
|
||||
onSuccess: () => {
|
||||
void trpc.useUtils().user.getFavoriteProjectIds.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const isEmpty = favoriteProjects.length === 0 && responsibleProjects.length === 0;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{isEmpty && (
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-gray-400 dark:text-gray-500">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-1">⭐</div>
|
||||
<p>No favorite projects yet.</p>
|
||||
<p className="text-xs mt-1">Star projects from the project list to see them here.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEmpty && (
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{favoriteProjects.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400 bg-amber-50/50 dark:bg-amber-900/10">
|
||||
Favorites
|
||||
</div>
|
||||
{favoriteProjects.map((p) => (
|
||||
<ProjectRow key={p.id} project={p} isFavorite onToggleFavorite={() => toggleMutation.mutate({ projectId: p.id })} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{responsibleProjects.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-brand-600 dark:text-brand-400 bg-brand-50/50 dark:bg-brand-900/10">
|
||||
Responsible
|
||||
</div>
|
||||
{responsibleProjects.map((p) => (
|
||||
<ProjectRow key={p.id} project={p} isFavorite={false} onToggleFavorite={() => toggleMutation.mutate({ projectId: p.id })} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectRow({
|
||||
project,
|
||||
isFavorite,
|
||||
onToggleFavorite,
|
||||
}: {
|
||||
project: { id: string; shortCode: string; name: string; status: string };
|
||||
isFavorite: boolean;
|
||||
onToggleFavorite: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.preventDefault(); onToggleFavorite(); }}
|
||||
className={`flex-shrink-0 text-sm transition-colors ${isFavorite ? "text-amber-500" : "text-gray-300 dark:text-gray-600 opacity-0 group-hover:opacity-100"}`}
|
||||
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
{isFavorite ? "★" : "☆"}
|
||||
</button>
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${STATUS_DOT[project.status] ?? "bg-gray-400"}`} />
|
||||
<Link
|
||||
href={`/projects/${project.id}`}
|
||||
className="min-w-0 flex-1 truncate text-sm text-gray-900 dark:text-gray-100 hover:text-brand-600 dark:hover:text-brand-400"
|
||||
>
|
||||
<span className="font-mono text-xs text-gray-400 dark:text-gray-500 mr-1.5">{project.shortCode}</span>
|
||||
{project.name}
|
||||
</Link>
|
||||
<span className="flex-shrink-0 text-[10px] text-gray-400 dark:text-gray-500 uppercase">{project.status}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user