feat: timeline UI overhaul with project/resource panel redesign, quick filters, and API improvements
Redesigned timeline project and resource panels with expanded detail views, added quick filter toolbar, improved drag handling, and enhanced vacation/entitlement router logic. Includes e2e test updates and minor API fixes. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -39,6 +39,7 @@ export type TimelineProject = {
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
orderType: string;
|
||||
clientId?: string | null;
|
||||
budgetCents?: number;
|
||||
winProbability?: number;
|
||||
staffingReqs?: unknown;
|
||||
@@ -51,7 +52,10 @@ export type TimelineRole = {
|
||||
color: string | null;
|
||||
};
|
||||
|
||||
export type TimelineAllocation = Omit<AllocationLike, "resource" | "project" | "roleEntity" | "metadata"> & {
|
||||
export type TimelineAllocation = Omit<
|
||||
AllocationLike,
|
||||
"resource" | "project" | "roleEntity" | "metadata"
|
||||
> & {
|
||||
resource?: TimelineResource | null;
|
||||
project: TimelineProject;
|
||||
roleEntity?: TimelineRole | null;
|
||||
@@ -83,6 +87,7 @@ export type ProjectGroup = {
|
||||
name: string;
|
||||
shortCode: string;
|
||||
orderType: string;
|
||||
clientId: string | null;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
status: string;
|
||||
@@ -196,7 +201,14 @@ export function TimelineProvider({
|
||||
|
||||
// ─── Data queries ──────────────────────────────────────────────────────────
|
||||
const { data: entriesView, isLoading } = trpc.timeline.getEntriesView.useQuery(
|
||||
{ startDate: viewStart, endDate: viewEnd },
|
||||
{
|
||||
startDate: viewStart,
|
||||
endDate: viewEnd,
|
||||
...(filters.clientIds.length > 0 ? { clientIds: filters.clientIds } : {}),
|
||||
...(filters.projectIds.length > 0 ? { projectIds: filters.projectIds } : {}),
|
||||
...(filters.chapters.length > 0 ? { chapters: filters.chapters } : {}),
|
||||
...(filters.eids.length > 0 ? { eids: filters.eids } : {}),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ placeholderData: (prev: any) => prev },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -232,21 +244,23 @@ export function TimelineProvider({
|
||||
// ─── Filtered entries ──────────────────────────────────────────────────────
|
||||
|
||||
const visibleAssignments = useMemo(
|
||||
() => assignments.filter((entry) => {
|
||||
if (entry.project.status === "DRAFT" && !filters.showDrafts) return false;
|
||||
if (DONE_STATUSES.has(entry.project.status) && filters.hideCompletedProjects) return false;
|
||||
return true;
|
||||
}),
|
||||
() =>
|
||||
assignments.filter((entry) => {
|
||||
if (entry.project.status === "DRAFT" && !filters.showDrafts) return false;
|
||||
if (DONE_STATUSES.has(entry.project.status) && filters.hideCompletedProjects) return false;
|
||||
return true;
|
||||
}),
|
||||
[assignments, filters.hideCompletedProjects, filters.showDrafts],
|
||||
);
|
||||
|
||||
const visibleDemands = useMemo(
|
||||
() => demands.filter((entry) => {
|
||||
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;
|
||||
return true;
|
||||
}),
|
||||
() =>
|
||||
demands.filter((entry) => {
|
||||
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;
|
||||
return true;
|
||||
}),
|
||||
[demands, filters.hideCompletedProjects, filters.showDrafts, filters.showPlaceholders],
|
||||
);
|
||||
|
||||
@@ -266,9 +280,19 @@ export function TimelineProvider({
|
||||
const allocsByResource = new Map<string, TimelineAssignmentEntry[]>();
|
||||
|
||||
if (eidFilterData?.resources) {
|
||||
for (const r of eidFilterData.resources as { id: string; displayName: string; eid: string; chapter: string | null }[]) {
|
||||
for (const r of eidFilterData.resources as {
|
||||
id: string;
|
||||
displayName: string;
|
||||
eid: string;
|
||||
chapter: string | null;
|
||||
}[]) {
|
||||
if (!resourceMap.has(r.id)) {
|
||||
resourceMap.set(r.id, { id: r.id, displayName: r.displayName, eid: r.eid, chapter: r.chapter });
|
||||
resourceMap.set(r.id, {
|
||||
id: r.id,
|
||||
displayName: r.displayName,
|
||||
eid: r.eid,
|
||||
chapter: r.chapter,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,9 +344,32 @@ export function TimelineProvider({
|
||||
),
|
||||
);
|
||||
}
|
||||
if (filters.clientIds.length > 0) {
|
||||
resources = resources.filter((r) =>
|
||||
visibleAssignments.some(
|
||||
(entry) => {
|
||||
const clientId = entry.project.clientId;
|
||||
return (
|
||||
entry.resourceId === r.id &&
|
||||
typeof clientId === "string" &&
|
||||
filters.clientIds.includes(clientId)
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return { resourceMap, allocsByResource, resources };
|
||||
}, [visibleAssignments, eidFilterData, isDragging, contextAllocations, filters.chapters, filters.eids, filters.projectIds]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
visibleAssignments,
|
||||
eidFilterData,
|
||||
isDragging,
|
||||
contextAllocations,
|
||||
filters.chapters,
|
||||
filters.eids,
|
||||
filters.projectIds,
|
||||
filters.clientIds,
|
||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ─── Project groups (for project view) ────────────────────────────────────
|
||||
const projectGroups = useMemo(() => {
|
||||
@@ -336,6 +383,7 @@ export function TimelineProvider({
|
||||
name: entry.project.name,
|
||||
shortCode: entry.project.shortCode,
|
||||
orderType: entry.project.orderType,
|
||||
clientId: entry.project.clientId ?? null,
|
||||
startDate: new Date(entry.project.startDate as unknown as string),
|
||||
endDate: new Date(entry.project.endDate as unknown as string),
|
||||
status: entry.project.status,
|
||||
@@ -344,8 +392,11 @@ export function TimelineProvider({
|
||||
projectGroupMap.set(entry.projectId, group);
|
||||
}
|
||||
const currentGroup = group;
|
||||
if (!currentGroup) continue;
|
||||
if (entry.kind === "assignment" && entry.resourceId) {
|
||||
const existingRow = currentGroup.resourceRows.find((r) => r.resource.id === entry.resourceId);
|
||||
const existingRow = currentGroup.resourceRows.find(
|
||||
(r) => r.resource.id === entry.resourceId,
|
||||
);
|
||||
if (existingRow) {
|
||||
existingRow.allocs.push(entry);
|
||||
} else {
|
||||
@@ -357,23 +408,68 @@ export function TimelineProvider({
|
||||
}
|
||||
}
|
||||
for (const group of projectGroupMap.values()) {
|
||||
group.resourceRows.sort((a, b) => a.resource.displayName.localeCompare(b.resource.displayName));
|
||||
group.resourceRows = group.resourceRows.filter(({ resource, allocs }) => {
|
||||
if (filters.chapters.length > 0) {
|
||||
if (!resource.chapter || !filters.chapters.includes(resource.chapter)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (filters.eids.length > 0 && !filters.eids.includes(resource.eid)) {
|
||||
return false;
|
||||
}
|
||||
if (filters.clientIds.length > 0) {
|
||||
const matchesClient = allocs.some(
|
||||
(alloc) => {
|
||||
const clientId = alloc.project.clientId;
|
||||
return typeof clientId === "string" && filters.clientIds.includes(clientId);
|
||||
},
|
||||
);
|
||||
if (!matchesClient) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
group.resourceRows.sort((a, b) =>
|
||||
a.resource.displayName.localeCompare(b.resource.displayName),
|
||||
);
|
||||
}
|
||||
return [...projectGroupMap.values()]
|
||||
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
|
||||
.filter((pg) => {
|
||||
if (filters.projectIds.length > 0 && !filters.projectIds.includes(pg.id)) return false;
|
||||
if (filters.chapters.length > 0 && !pg.resourceRows.some((r) => r.resource.chapter && filters.chapters.includes(r.resource.chapter))) return false;
|
||||
if (filters.eids.length > 0 && !pg.resourceRows.some((r) => filters.eids.includes(r.resource.eid))) return false;
|
||||
if (
|
||||
filters.clientIds.length > 0 &&
|
||||
(!pg.clientId || !filters.clientIds.includes(pg.clientId))
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
filters.chapters.length > 0 &&
|
||||
pg.resourceRows.length === 0
|
||||
)
|
||||
return false;
|
||||
if (filters.eids.length > 0 && pg.resourceRows.length === 0)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}, [visibleAssignments, visibleDemands, resourceMap, filters.projectIds, filters.chapters, filters.eids]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
visibleAssignments,
|
||||
visibleDemands,
|
||||
resourceMap,
|
||||
filters.projectIds,
|
||||
filters.clientIds,
|
||||
filters.chapters,
|
||||
filters.eids,
|
||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ─── Derived counts ───────────────────────────────────────────────────────
|
||||
const isInitialLoading = isLoading && !entriesView;
|
||||
const totalAllocCount = entriesView?.allocations.length ?? 0;
|
||||
const activeFilterCount =
|
||||
filters.chapters.length + filters.eids.length + filters.projectIds.length;
|
||||
filters.clientIds.length +
|
||||
filters.chapters.length +
|
||||
filters.eids.length +
|
||||
filters.projectIds.length;
|
||||
|
||||
const value = useMemo<TimelineContextValue>(
|
||||
() => ({
|
||||
@@ -433,9 +529,5 @@ export function TimelineProvider({
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<TimelineContext.Provider value={value}>
|
||||
{children}
|
||||
</TimelineContext.Provider>
|
||||
);
|
||||
return <TimelineContext.Provider value={value}>{children}</TimelineContext.Provider>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user