746 lines
25 KiB
TypeScript
746 lines
25 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
VacationStatus,
|
|
type AllocationLike,
|
|
type AllocationReadModel,
|
|
type Assignment,
|
|
type DemandRequirement,
|
|
} from "@capakraken/shared";
|
|
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import { useSession } from "next-auth/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";
|
|
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
|
|
|
|
export type TimelineDisplayMode = "strip" | "bar" | "heatmap";
|
|
import { addDays } from "./utils.js";
|
|
import { DEFAULT_FILTERS, type TimelineFilters } from "./TimelineFilter.js";
|
|
import { DONE_STATUSES } from "./timelineConstants.js";
|
|
|
|
// ─── Local timeline types ─────────────────────────────────────────────────────
|
|
// These re-declare the shapes that the original TimelineView used internally.
|
|
// Kept here so every timeline sub-component can import them from one place.
|
|
|
|
export type TimelineResource = {
|
|
id: string;
|
|
displayName: string;
|
|
eid: string;
|
|
chapter?: string | null;
|
|
lcrCents: number;
|
|
availability?: unknown;
|
|
};
|
|
|
|
export type TimelineProject = {
|
|
id: string;
|
|
name: string;
|
|
shortCode: string;
|
|
status: string;
|
|
startDate: Date | string;
|
|
endDate: Date | string;
|
|
orderType: string;
|
|
clientId?: string | null;
|
|
budgetCents?: number;
|
|
winProbability?: number;
|
|
color?: string | null;
|
|
staffingReqs?: unknown;
|
|
responsiblePerson?: string | null;
|
|
};
|
|
|
|
export type TimelineRole = {
|
|
id: string;
|
|
name: string;
|
|
color: string | null;
|
|
};
|
|
|
|
export type TimelineAllocation = Omit<
|
|
AllocationLike,
|
|
"resource" | "project" | "roleEntity" | "metadata"
|
|
> & {
|
|
resource?: TimelineResource | null;
|
|
project: TimelineProject;
|
|
roleEntity?: TimelineRole | null;
|
|
metadata: Record<string, unknown> | null;
|
|
};
|
|
|
|
export type TimelineAssignmentEntry = Assignment<TimelineAllocation>;
|
|
export type TimelineDemandEntry = DemandRequirement<TimelineAllocation>;
|
|
|
|
export type TimelineEntriesView = AllocationReadModel<TimelineAllocation> & {
|
|
assignments: TimelineAssignmentEntry[];
|
|
demands: TimelineDemandEntry[];
|
|
};
|
|
export type TimelineProjectEntry = TimelineAssignmentEntry | TimelineDemandEntry;
|
|
|
|
export type ViewMode = "resource" | "project";
|
|
|
|
function buildTimelineFiltersFromSearchParams(searchParams: ReturnType<typeof useSearchParams>): TimelineFilters {
|
|
const savedPrefs = readAppPreferences();
|
|
const next: TimelineFilters = {
|
|
...DEFAULT_FILTERS,
|
|
hideCompletedProjects: savedPrefs.hideCompletedProjects,
|
|
showPlaceholders: savedPrefs.showDemandProjects,
|
|
};
|
|
|
|
const eids = searchParams.get("eids");
|
|
if (eids) next.eids = eids.split(",").filter(Boolean);
|
|
const projectIds = searchParams.get("projectIds");
|
|
if (projectIds) next.projectIds = projectIds.split(",").filter(Boolean);
|
|
const chapters = searchParams.get("chapters");
|
|
if (chapters) next.chapters = chapters.split(",").filter(Boolean);
|
|
const clientIds = searchParams.get("clientIds");
|
|
if (clientIds) next.clientIds = clientIds.split(",").filter(Boolean);
|
|
const countryCodes = searchParams.get("countryCodes");
|
|
if (countryCodes) next.countryCodes = countryCodes.split(",").filter(Boolean);
|
|
|
|
if (eids || projectIds) {
|
|
next.showDrafts = true;
|
|
next.hideCompletedProjects = false;
|
|
}
|
|
|
|
return next;
|
|
}
|
|
|
|
// ─── Derived resource type used throughout the timeline ─────────────────────
|
|
export type ResourceBrief = {
|
|
id: string;
|
|
displayName: string;
|
|
eid: string;
|
|
chapter: string | null | undefined;
|
|
};
|
|
|
|
// ─── Project group type (project view) ──────────────────────────────────────
|
|
export type ProjectGroup = {
|
|
id: string;
|
|
name: string;
|
|
shortCode: string;
|
|
orderType: string;
|
|
clientId: string | null;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
status: string;
|
|
color?: string | null;
|
|
resourceRows: { resource: ResourceBrief; allocs: TimelineAssignmentEntry[] }[];
|
|
};
|
|
|
|
// ─── Vacation entry type (inferred from tRPC) ──────────────────────────────
|
|
export type VacationEntry = {
|
|
id: string;
|
|
resourceId: string;
|
|
type: string;
|
|
startDate: Date | string;
|
|
endDate: Date | string;
|
|
note?: string | null;
|
|
status: string;
|
|
requestedBy?: { name?: string | null; email: string } | null;
|
|
approvedBy?: { name?: string | null; email: string } | null;
|
|
approvedAt?: Date | string | null;
|
|
isHalfDay?: boolean;
|
|
halfDayPart?: string | null;
|
|
};
|
|
|
|
export type HolidayOverlayEntry = {
|
|
id: string;
|
|
resourceId: string;
|
|
type: string;
|
|
status: string;
|
|
startDate: Date | string;
|
|
endDate: Date | string;
|
|
note?: string | null;
|
|
};
|
|
|
|
// ─── Context shape ──────────────────────────────────────────────────────────
|
|
|
|
export interface TimelineContextValue {
|
|
// ─ Data
|
|
assignments: TimelineAssignmentEntry[];
|
|
demands: TimelineDemandEntry[];
|
|
visibleAssignments: TimelineAssignmentEntry[];
|
|
visibleDemands: TimelineDemandEntry[];
|
|
vacationsByResource: Map<string, VacationEntry[]>;
|
|
resources: ResourceBrief[];
|
|
resourceMap: Map<string, ResourceBrief>;
|
|
allocsByResource: Map<string, TimelineAssignmentEntry[]>;
|
|
projectGroups: ProjectGroup[];
|
|
openDemandsByProject: Map<string, TimelineDemandEntry[]>;
|
|
|
|
// ─ View state
|
|
viewStart: Date;
|
|
viewEnd: Date;
|
|
viewDays: number;
|
|
setViewStart: React.Dispatch<React.SetStateAction<Date>>;
|
|
setViewDays: React.Dispatch<React.SetStateAction<number>>;
|
|
filters: TimelineFilters;
|
|
setFilters: React.Dispatch<React.SetStateAction<TimelineFilters>>;
|
|
filterOpen: boolean;
|
|
setFilterOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
viewMode: ViewMode;
|
|
setViewMode: React.Dispatch<React.SetStateAction<ViewMode>>;
|
|
today: Date;
|
|
|
|
// ─ Display preferences
|
|
displayMode: TimelineDisplayMode;
|
|
heatmapScheme: HeatmapColorScheme;
|
|
blinkOverbookedDays: boolean;
|
|
|
|
// ─ Loading
|
|
isLoading: boolean;
|
|
isInitialLoading: boolean;
|
|
totalAllocCount: number;
|
|
activeFilterCount: number;
|
|
|
|
// ─ SSE is initialized by the provider (no value exposed)
|
|
}
|
|
|
|
const TimelineContext = createContext<TimelineContextValue | null>(null);
|
|
|
|
export function useTimelineContext(): TimelineContextValue {
|
|
const ctx = useContext(TimelineContext);
|
|
if (!ctx) {
|
|
throw new Error("useTimelineContext must be used within a <TimelineProvider>");
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
// ─── Provider ───────────────────────────────────────────────────────────────
|
|
|
|
interface TimelineProviderProps {
|
|
/** Cross-project context resource IDs from drag — injected from the parent. */
|
|
contextAllocations: TimelineAssignmentEntry[];
|
|
isDragging: boolean;
|
|
children: ReactNode;
|
|
}
|
|
|
|
export function TimelineProvider({
|
|
contextAllocations,
|
|
isDragging,
|
|
children,
|
|
}: TimelineProviderProps) {
|
|
const { data: session, status: sessionStatus } = useSession();
|
|
const searchParams = useSearchParams();
|
|
const role = sessionStatus === "authenticated"
|
|
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
|
|
: null;
|
|
const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
|
|
const isRoleLoading = sessionStatus !== "authenticated";
|
|
|
|
const today = useMemo(() => {
|
|
const d = new Date();
|
|
d.setHours(0, 0, 0, 0);
|
|
return d;
|
|
}, []);
|
|
|
|
// 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);
|
|
|
|
// Support URL params: ?eids=EMP-001,EMP-002&projectIds=id1,id2&chapters=ch1
|
|
const [filters, setFilters] = useState<TimelineFilters>(() => buildTimelineFiltersFromSearchParams(searchParams));
|
|
|
|
// Sync filters/viewStart/viewDays from URL params on mount and after later changes
|
|
// (e.g. direct nav from another page or router.push("/timeline?eids=...") while already on /timeline)
|
|
useEffect(() => {
|
|
const spStart = searchParams.get("startDate");
|
|
setViewStart(() => {
|
|
if (spStart) {
|
|
const d = new Date(spStart);
|
|
if (!isNaN(d.getTime())) return d;
|
|
}
|
|
return addDays(today, -30);
|
|
});
|
|
|
|
const spDays = searchParams.get("days");
|
|
setViewDays(() => {
|
|
if (spDays) {
|
|
const n = parseInt(spDays, 10);
|
|
if (n > 0 && n <= 365) return n;
|
|
}
|
|
return 180;
|
|
});
|
|
|
|
setFilters(buildTimelineFiltersFromSearchParams(searchParams));
|
|
}, [searchParams, today]);
|
|
|
|
const [filterOpen, setFilterOpen] = useState(false);
|
|
const [viewMode, setViewMode] = useState<ViewMode>("resource");
|
|
|
|
useTimelineSSE();
|
|
|
|
const { prefs: appPrefs } = useAppPreferences();
|
|
const displayMode = appPrefs.timelineDisplayMode;
|
|
const heatmapScheme = appPrefs.heatmapColorScheme;
|
|
const blinkOverbookedDays = appPrefs.blinkOverbookedDays;
|
|
|
|
// ─── Data queries ──────────────────────────────────────────────────────────
|
|
const mountedRef = useRef(false);
|
|
const timelineQueryInput = {
|
|
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 } : {}),
|
|
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
|
|
};
|
|
|
|
const staffEntriesViewQuery = trpc.timeline.getEntriesView.useQuery(
|
|
timelineQueryInput,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
{
|
|
enabled: !isRoleLoading && !isSelfServiceTimeline,
|
|
placeholderData: (prev: any) => prev,
|
|
refetchOnWindowFocus: false,
|
|
staleTime: 90_000,
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
) as {
|
|
data: TimelineEntriesView | undefined;
|
|
isLoading: boolean;
|
|
refetch: () => Promise<unknown>;
|
|
};
|
|
|
|
const selfEntriesViewQuery = trpc.timeline.getMyEntriesView.useQuery(
|
|
timelineQueryInput,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
{
|
|
enabled: !isRoleLoading && isSelfServiceTimeline,
|
|
placeholderData: (prev: any) => prev,
|
|
refetchOnWindowFocus: false,
|
|
staleTime: 90_000,
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
) as {
|
|
data: TimelineEntriesView | undefined;
|
|
isLoading: boolean;
|
|
refetch: () => Promise<unknown>;
|
|
};
|
|
|
|
const entriesViewQuery = isSelfServiceTimeline ? selfEntriesViewQuery : staffEntriesViewQuery;
|
|
const { data: entriesView, isLoading, refetch: refetchEntriesView } = entriesViewQuery;
|
|
|
|
const assignments = entriesView?.assignments ?? [];
|
|
const demands = entriesView?.demands ?? [];
|
|
|
|
const {
|
|
data: vacationEntries = [],
|
|
refetch: refetchVacations,
|
|
} = trpc.vacation.list.useQuery(
|
|
{ startDate: viewStart, endDate: viewEnd, status: [VacationStatus.APPROVED, VacationStatus.PENDING], limit: 500 },
|
|
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
|
);
|
|
|
|
const staffHolidayOverlayQuery = trpc.timeline.getHolidayOverlays.useQuery(
|
|
timelineQueryInput,
|
|
{
|
|
enabled: !isRoleLoading && !isSelfServiceTimeline,
|
|
placeholderData: (prev) => prev,
|
|
refetchOnWindowFocus: false,
|
|
staleTime: 90_000,
|
|
},
|
|
);
|
|
const selfHolidayOverlayQuery = trpc.timeline.getMyHolidayOverlays.useQuery(
|
|
timelineQueryInput,
|
|
{
|
|
enabled: !isRoleLoading && isSelfServiceTimeline,
|
|
placeholderData: (prev) => prev,
|
|
refetchOnWindowFocus: false,
|
|
staleTime: 90_000,
|
|
},
|
|
);
|
|
const activeHolidayOverlayQuery = isSelfServiceTimeline ? selfHolidayOverlayQuery : staffHolidayOverlayQuery;
|
|
const {
|
|
data: holidayOverlayEntries = [],
|
|
refetch: refetchHolidayOverlays,
|
|
} = activeHolidayOverlayQuery;
|
|
|
|
useEffect(() => {
|
|
if (mountedRef.current) return;
|
|
if (isRoleLoading) return;
|
|
mountedRef.current = true;
|
|
|
|
// Harden client-side route transitions: the timeline must actively refresh
|
|
// its core read models once on mount instead of relying on a prefetched shell.
|
|
void refetchEntriesView();
|
|
void refetchVacations();
|
|
void refetchHolidayOverlays();
|
|
}, [isRoleLoading, refetchEntriesView, refetchHolidayOverlays, refetchVacations]);
|
|
|
|
const vacationsByResource = useMemo(() => {
|
|
const map = new Map<string, VacationEntry[]>();
|
|
const mergedEntries = [...(vacationEntries as VacationEntry[])];
|
|
const existingKeys = new Set(
|
|
mergedEntries.map((vacation) => {
|
|
const start = new Date(vacation.startDate).toISOString().slice(0, 10);
|
|
const end = new Date(vacation.endDate).toISOString().slice(0, 10);
|
|
return `${vacation.resourceId}:${vacation.type}:${start}:${end}`;
|
|
}),
|
|
);
|
|
|
|
for (const holiday of holidayOverlayEntries as HolidayOverlayEntry[]) {
|
|
const start = new Date(holiday.startDate).toISOString().slice(0, 10);
|
|
const end = new Date(holiday.endDate).toISOString().slice(0, 10);
|
|
const key = `${holiday.resourceId}:${holiday.type}:${start}:${end}`;
|
|
if (existingKeys.has(key)) {
|
|
continue;
|
|
}
|
|
|
|
existingKeys.add(key);
|
|
mergedEntries.push(holiday as VacationEntry);
|
|
}
|
|
|
|
for (const vacation of mergedEntries) {
|
|
const existing = map.get(vacation.resourceId);
|
|
if (existing) {
|
|
existing.push(vacation);
|
|
} else {
|
|
map.set(vacation.resourceId, [vacation]);
|
|
}
|
|
}
|
|
return map;
|
|
}, [holidayOverlayEntries, vacationEntries]);
|
|
|
|
// When EID filter is active, explicitly fetch those resources.
|
|
const { data: eidFilterData } = trpc.resource.directory.useQuery(
|
|
{ eids: filters.eids, limit: 100 },
|
|
{ enabled: !isSelfServiceTimeline && filters.eids.length > 0, staleTime: 30_000 },
|
|
);
|
|
|
|
// ─── 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, 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;
|
|
// 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],
|
|
);
|
|
|
|
const openDemandsByProject = useMemo(() => {
|
|
const map = new Map<string, TimelineDemandEntry[]>();
|
|
for (const demand of visibleDemands) {
|
|
const arr = map.get(demand.projectId) ?? [];
|
|
arr.push(demand);
|
|
map.set(demand.projectId, arr);
|
|
}
|
|
return map;
|
|
}, [visibleDemands]);
|
|
|
|
// ─── Resource map + allocsByResource ──────────────────────────────────────
|
|
const { resourceMap, allocsByResource, resources } = useMemo(() => {
|
|
const resourceMap = new Map<string, ResourceBrief>();
|
|
const allocsByResource = new Map<string, TimelineAssignmentEntry[]>();
|
|
const firstAssignmentByResource = new Map<string, TimelineAssignmentEntry>();
|
|
const projectIdsByResource = new Map<string, Set<string>>();
|
|
const clientIdsByResource = new Map<string, Set<string>>();
|
|
const chapterFilter = new Set(filters.chapters);
|
|
const eidFilter = new Set(filters.eids);
|
|
const projectFilter = new Set(filters.projectIds);
|
|
const clientFilter = new Set(filters.clientIds);
|
|
|
|
if (eidFilterData?.resources) {
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const entry of visibleAssignments) {
|
|
if (!entry.resourceId) continue;
|
|
firstAssignmentByResource.set(entry.resourceId, entry);
|
|
if (!resourceMap.has(entry.resourceId)) {
|
|
resourceMap.set(entry.resourceId, {
|
|
id: entry.resource!.id,
|
|
displayName: entry.resource!.displayName,
|
|
eid: entry.resource!.eid,
|
|
chapter: entry.resource!.chapter,
|
|
});
|
|
}
|
|
const arr = allocsByResource.get(entry.resourceId) ?? [];
|
|
arr.push(entry);
|
|
allocsByResource.set(entry.resourceId, arr);
|
|
|
|
const projectIds = projectIdsByResource.get(entry.resourceId) ?? new Set<string>();
|
|
projectIds.add(entry.projectId);
|
|
projectIdsByResource.set(entry.resourceId, projectIds);
|
|
|
|
if (typeof entry.project.clientId === "string") {
|
|
const clientIds = clientIdsByResource.get(entry.resourceId) ?? new Set<string>();
|
|
clientIds.add(entry.project.clientId);
|
|
clientIdsByResource.set(entry.resourceId, clientIds);
|
|
}
|
|
}
|
|
|
|
// Merge cross-project context allocations so they appear during drag
|
|
if (isDragging && contextAllocations.length > 0) {
|
|
for (const ca of contextAllocations) {
|
|
if (!ca.resourceId) continue;
|
|
const existing = firstAssignmentByResource.get(ca.resourceId);
|
|
if (existing && !resourceMap.has(ca.resourceId)) {
|
|
resourceMap.set(ca.resourceId, {
|
|
id: existing.resource!.id,
|
|
displayName: existing.resource!.displayName,
|
|
eid: existing.resource!.eid,
|
|
chapter: existing.resource!.chapter,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
let resources = [...resourceMap.values()].sort((a, b) =>
|
|
a.displayName.localeCompare(b.displayName),
|
|
);
|
|
if (chapterFilter.size > 0) {
|
|
resources = resources.filter((r) => r.chapter && chapterFilter.has(r.chapter));
|
|
}
|
|
if (eidFilter.size > 0) {
|
|
resources = resources.filter((r) => eidFilter.has(r.eid));
|
|
}
|
|
if (projectFilter.size > 0) {
|
|
resources = resources.filter((r) => {
|
|
const projectIds = projectIdsByResource.get(r.id);
|
|
if (!projectIds) return false;
|
|
for (const projectId of projectIds) {
|
|
if (projectFilter.has(projectId)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
if (clientFilter.size > 0) {
|
|
resources = resources.filter((r) => {
|
|
const clientIds = clientIdsByResource.get(r.id);
|
|
if (!clientIds) return false;
|
|
for (const clientId of clientIds) {
|
|
if (clientFilter.has(clientId)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
return { resourceMap, allocsByResource, resources };
|
|
}, [
|
|
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(() => {
|
|
const projectGroupMap = new Map<string, ProjectGroup>();
|
|
const resourceRowMapByProject = new Map<
|
|
string,
|
|
Map<string, ProjectGroup["resourceRows"][number]>
|
|
>();
|
|
const chapterFilter = new Set(filters.chapters);
|
|
const eidFilter = new Set(filters.eids);
|
|
const clientFilter = new Set(filters.clientIds);
|
|
const projectFilter = new Set(filters.projectIds);
|
|
const allGroupEntries: TimelineProjectEntry[] = [...visibleAssignments, ...visibleDemands];
|
|
for (const entry of allGroupEntries) {
|
|
let group = projectGroupMap.get(entry.projectId);
|
|
if (!group) {
|
|
group = {
|
|
id: entry.projectId,
|
|
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,
|
|
color: (entry.project as { color?: string | null }).color ?? null,
|
|
resourceRows: [],
|
|
};
|
|
projectGroupMap.set(entry.projectId, group);
|
|
resourceRowMapByProject.set(entry.projectId, new Map());
|
|
}
|
|
const currentGroup = group;
|
|
if (!currentGroup) continue;
|
|
if (entry.kind === "assignment" && entry.resourceId) {
|
|
const rowMap = resourceRowMapByProject.get(entry.projectId);
|
|
const existingRow = rowMap?.get(entry.resourceId);
|
|
if (existingRow) {
|
|
existingRow.allocs.push(entry);
|
|
} else {
|
|
const res = resourceMap.get(entry.resourceId);
|
|
if (res) {
|
|
const row = { resource: res, allocs: [entry] };
|
|
currentGroup.resourceRows.push(row);
|
|
rowMap?.set(entry.resourceId, row);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (const group of projectGroupMap.values()) {
|
|
group.resourceRows = group.resourceRows.filter(({ resource }) => {
|
|
if (chapterFilter.size > 0) {
|
|
if (!resource.chapter || !chapterFilter.has(resource.chapter)) {
|
|
return false;
|
|
}
|
|
}
|
|
if (eidFilter.size > 0 && !eidFilter.has(resource.eid)) {
|
|
return false;
|
|
}
|
|
if (clientFilter.size > 0 && (!group.clientId || !clientFilter.has(group.clientId))) {
|
|
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 (projectFilter.size > 0 && !projectFilter.has(pg.id)) return false;
|
|
if (
|
|
clientFilter.size > 0 &&
|
|
(!pg.clientId || !clientFilter.has(pg.clientId))
|
|
)
|
|
return false;
|
|
if (
|
|
chapterFilter.size > 0 &&
|
|
pg.resourceRows.length === 0
|
|
)
|
|
return false;
|
|
if (eidFilter.size > 0 && pg.resourceRows.length === 0)
|
|
return false;
|
|
return true;
|
|
});
|
|
}, [
|
|
visibleAssignments,
|
|
visibleDemands,
|
|
resourceMap,
|
|
filters.projectIds,
|
|
filters.clientIds,
|
|
filters.chapters,
|
|
filters.eids,
|
|
]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// ─── Derived counts ───────────────────────────────────────────────────────
|
|
const isInitialLoading = (isRoleLoading || isLoading) && !entriesView;
|
|
const totalAllocCount = entriesView?.allocations.length ?? 0;
|
|
const activeFilterCount =
|
|
filters.clientIds.length +
|
|
filters.chapters.length +
|
|
filters.eids.length +
|
|
filters.projectIds.length +
|
|
filters.countryCodes.length;
|
|
|
|
const value = useMemo<TimelineContextValue>(
|
|
() => ({
|
|
assignments,
|
|
demands,
|
|
visibleAssignments,
|
|
visibleDemands,
|
|
vacationsByResource,
|
|
resources,
|
|
resourceMap,
|
|
allocsByResource,
|
|
projectGroups,
|
|
openDemandsByProject,
|
|
viewStart,
|
|
viewEnd,
|
|
viewDays,
|
|
setViewStart,
|
|
setViewDays,
|
|
filters,
|
|
setFilters,
|
|
filterOpen,
|
|
setFilterOpen,
|
|
viewMode,
|
|
setViewMode,
|
|
today,
|
|
displayMode,
|
|
heatmapScheme,
|
|
blinkOverbookedDays,
|
|
isLoading,
|
|
isInitialLoading,
|
|
totalAllocCount,
|
|
activeFilterCount,
|
|
}),
|
|
[
|
|
assignments,
|
|
demands,
|
|
visibleAssignments,
|
|
visibleDemands,
|
|
vacationsByResource,
|
|
resources,
|
|
resourceMap,
|
|
allocsByResource,
|
|
projectGroups,
|
|
openDemandsByProject,
|
|
viewStart,
|
|
viewEnd,
|
|
viewDays,
|
|
filters,
|
|
filterOpen,
|
|
viewMode,
|
|
today,
|
|
displayMode,
|
|
heatmapScheme,
|
|
blinkOverbookedDays,
|
|
isLoading,
|
|
isInitialLoading,
|
|
totalAllocCount,
|
|
activeFilterCount,
|
|
],
|
|
);
|
|
|
|
return <TimelineContext.Provider value={value}>{children}</TimelineContext.Provider>;
|
|
}
|