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:
@@ -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>(
|
||||
() => ({
|
||||
|
||||
Reference in New Issue
Block a user