feat(assistant): add approval inbox and e2e hardening
This commit is contained in:
@@ -75,6 +75,33 @@ 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;
|
||||
@@ -218,77 +245,31 @@ export function TimelineProvider({
|
||||
const viewEnd = addDays(viewStart, viewDays);
|
||||
|
||||
// Support URL params: ?eids=EMP-001,EMP-002&projectIds=id1,id2&chapters=ch1
|
||||
const [filters, setFilters] = useState<TimelineFilters>(() => {
|
||||
const savedPrefs = readAppPreferences();
|
||||
const base: TimelineFilters = {
|
||||
...DEFAULT_FILTERS,
|
||||
hideCompletedProjects: savedPrefs.hideCompletedProjects,
|
||||
showPlaceholders: savedPrefs.showDemandProjects,
|
||||
};
|
||||
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);
|
||||
const [filters, setFilters] = useState<TimelineFilters>(() => buildTimelineFiltersFromSearchParams(searchParams));
|
||||
|
||||
// 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)
|
||||
// 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(() => {
|
||||
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);
|
||||
}
|
||||
setViewStart(() => {
|
||||
if (spStart) {
|
||||
const d = new Date(spStart);
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
return addDays(today, -30);
|
||||
});
|
||||
|
||||
// Update viewDays if param changed
|
||||
const spDays = searchParams.get("days");
|
||||
if (spDays) {
|
||||
const n = parseInt(spDays, 10);
|
||||
if (n > 0 && n <= 365) setViewDays(n);
|
||||
}
|
||||
setViewDays(() => {
|
||||
if (spDays) {
|
||||
const n = parseInt(spDays, 10);
|
||||
if (n > 0 && n <= 365) return n;
|
||||
}
|
||||
return 180;
|
||||
});
|
||||
|
||||
// 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]);
|
||||
setFilters(buildTimelineFiltersFromSearchParams(searchParams));
|
||||
}, [searchParams, today]);
|
||||
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("resource");
|
||||
@@ -301,7 +282,9 @@ export function TimelineProvider({
|
||||
const blinkOverbookedDays = appPrefs.blinkOverbookedDays;
|
||||
|
||||
// ─── Data queries ──────────────────────────────────────────────────────────
|
||||
const { data: entriesView, isLoading } = trpc.timeline.getEntriesView.useQuery(
|
||||
const mountedRef = useRef(false);
|
||||
|
||||
const entriesViewQuery = trpc.timeline.getEntriesView.useQuery(
|
||||
{
|
||||
startDate: viewStart,
|
||||
endDate: viewEnd,
|
||||
@@ -314,17 +297,29 @@ export function TimelineProvider({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ placeholderData: (prev: any) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as { data: TimelineEntriesView | undefined; isLoading: boolean };
|
||||
) as {
|
||||
data: TimelineEntriesView | undefined;
|
||||
isLoading: boolean;
|
||||
refetch: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
const { data: entriesView, isLoading, refetch: refetchEntriesView } = entriesViewQuery;
|
||||
|
||||
const assignments = entriesView?.assignments ?? [];
|
||||
const demands = entriesView?.demands ?? [];
|
||||
|
||||
const { data: vacationEntries = [] } = trpc.vacation.list.useQuery(
|
||||
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 { data: holidayOverlayEntries = [] } = trpc.timeline.getHolidayOverlays.useQuery(
|
||||
const {
|
||||
data: holidayOverlayEntries = [],
|
||||
refetch: refetchHolidayOverlays,
|
||||
} = trpc.timeline.getHolidayOverlays.useQuery(
|
||||
{
|
||||
startDate: viewStart,
|
||||
endDate: viewEnd,
|
||||
@@ -337,6 +332,17 @@ export function TimelineProvider({
|
||||
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (mountedRef.current) 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();
|
||||
}, [refetchEntriesView, refetchHolidayOverlays, refetchVacations]);
|
||||
|
||||
const vacationsByResource = useMemo(() => {
|
||||
const map = new Map<string, VacationEntry[]>();
|
||||
const mergedEntries = [...(vacationEntries as VacationEntry[])];
|
||||
|
||||
Reference in New Issue
Block a user