The window scrolls (not <main>) on this layout, and the document content height isn't fully laid out by the first rAF after rows render — so window.scrollTo gets clamped to maxScroll, leaving the user near top. Retry up to 30 frames (~500ms) until the actual scroll position matches the target within 4px.
435 lines
19 KiB
TypeScript
435 lines
19 KiB
TypeScript
'use client';
|
|
|
|
/* MobileEngagementPage — phase 07 (ENG-01..05, ENG-09).
|
|
* Purpose: Phone-first refactor of /engagement — period chips + 4 stacked summary cards +
|
|
* compact sparkline + sortable/searchable per-employee list with infinite scroll.
|
|
* Real refactor, not a thin adaptation of the ~1300-line desktop page (ENG-01).
|
|
* Reachable from More drawer only — Engagement is NOT on the bottom nav (ENG-09).
|
|
* Per D-01..D-31. */
|
|
|
|
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
|
import { Loader2, Users } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
import type { MobileEngagementSummary } from '@/app/api/mobile/engagement/summary/route';
|
|
import type { SparklinePoint, EngagementTrendResponse } from '@/app/api/mobile/engagement/trend/route';
|
|
import { EngagementPeriodChips, type EngagementPeriod } from '@/components/mobile/EngagementPeriodChips';
|
|
import { EngagementSummaryCard } from '@/components/mobile/EngagementSummaryCard';
|
|
import { EngagementHoursSparkline } from '@/components/mobile/EngagementHoursSparkline';
|
|
import { EngagementSortChips, type EngagementSortKey } from '@/components/mobile/EngagementSortChips';
|
|
import { EngagementSearchInput } from '@/components/mobile/EngagementSearchInput';
|
|
import { EngagementUserRow } from '@/components/mobile/EngagementUserRow';
|
|
import { EngagementUserRowSkeleton } from '@/components/mobile/EngagementUserRowSkeleton';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
|
|
// ── Inline types for /api/engagement/users (existing endpoint, reused as-is per D-16) ──
|
|
|
|
interface EngagementUserApiRow {
|
|
graphUserId: string;
|
|
displayName: string;
|
|
email: string; // existing endpoint returns 'email', not 'userEmail'
|
|
jobTitle: string | null;
|
|
billableHours: number;
|
|
hoursWorked: number;
|
|
}
|
|
|
|
interface EngagementUsersResponse {
|
|
users: EngagementUserApiRow[];
|
|
pagination: {
|
|
page: number;
|
|
pageSize: number;
|
|
total: number;
|
|
totalPages?: number; // present when total > 0; existing endpoint returns this
|
|
};
|
|
}
|
|
|
|
// Sort key → API param mapping (D-20)
|
|
// NOTE: EngagementSortKey values are lowercase ('hours'|'name'|'utilization') per Wave 2 component
|
|
const SORT_TO_API: Record<EngagementSortKey, { sort: string; order: 'asc' | 'desc' }> = {
|
|
hours: { sort: 'billable_hours', order: 'desc' },
|
|
name: { sort: 'display_name', order: 'asc' },
|
|
utilization: { sort: 'billable_hours', order: 'desc' },
|
|
};
|
|
|
|
const SUMMARY_LABELS = {
|
|
activeUsers: 'Active users',
|
|
totalGraphHours: 'Total Graph hours',
|
|
totalAutotaskHours: 'Total Autotask hours',
|
|
hoursPerActiveUser: 'Hours / active user',
|
|
} as const;
|
|
|
|
export default function MobileEngagementPage() {
|
|
// ── State ─────────────────────────────────────────────────────────────
|
|
const [period, setPeriod] = useState<EngagementPeriod>('D30'); // D-04 default
|
|
const [sortKey, setSortKey] = useState<EngagementSortKey>('hours'); // D-20 default (lowercase)
|
|
const [searchQuery, setSearchQuery] = useState<string>(''); // D-21
|
|
|
|
const [summary, setSummary] = useState<MobileEngagementSummary | null>(null);
|
|
const [summaryLoading, setSummaryLoading] = useState<boolean>(true);
|
|
|
|
const [trendPoints, setTrendPoints] = useState<SparklinePoint[]>([]);
|
|
const [trendLoading, setTrendLoading] = useState<boolean>(true);
|
|
|
|
const [users, setUsers] = useState<EngagementUserApiRow[]>([]);
|
|
const [usersLoading, setUsersLoading] = useState<boolean>(true);
|
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
|
const [hasMore, setHasMore] = useState<boolean>(false);
|
|
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
|
const [loadMoreError, setLoadMoreError] = useState<boolean>(false);
|
|
|
|
// ── Fetchers ──────────────────────────────────────────────────────────
|
|
const loadSummary = useCallback(async (p: EngagementPeriod) => {
|
|
setSummaryLoading(true);
|
|
try {
|
|
const r = await fetch(`/api/mobile/engagement/summary?period=${p}`);
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
const data: MobileEngagementSummary = await r.json();
|
|
setSummary(data);
|
|
} catch (e) {
|
|
console.error('Engagement summary fetch failed:', e);
|
|
toast.error('Failed to load engagement summary'); // D-25
|
|
} finally {
|
|
setSummaryLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const loadTrend = useCallback(async (p: EngagementPeriod) => {
|
|
setTrendLoading(true);
|
|
try {
|
|
const r = await fetch(`/api/mobile/engagement/trend?period=${p}`);
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
const data: EngagementTrendResponse = await r.json();
|
|
setTrendPoints(data.points);
|
|
} catch (e) {
|
|
console.error('Engagement trend fetch failed:', e);
|
|
toast.error('Failed to load hours trend'); // D-25
|
|
} finally {
|
|
setTrendLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const loadUsersPage1 = useCallback(async (p: EngagementPeriod, sk: EngagementSortKey) => {
|
|
setUsersLoading(true);
|
|
setLoadMoreError(false);
|
|
try {
|
|
const { sort, order } = SORT_TO_API[sk];
|
|
const sp = new URLSearchParams({ period: p, sort, order, page: '1' });
|
|
const r = await fetch(`/api/engagement/users?${sp.toString()}`);
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
const data: EngagementUsersResponse = await r.json();
|
|
setUsers(data.users);
|
|
setCurrentPage(1);
|
|
const totalPages = data.pagination.totalPages
|
|
?? Math.ceil(data.pagination.total / data.pagination.pageSize);
|
|
setHasMore(1 < totalPages);
|
|
} catch (e) {
|
|
console.error('Engagement users fetch failed:', e);
|
|
toast.error('Failed to load engagement users'); // D-25
|
|
} finally {
|
|
setUsersLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const loadMoreUsers = useCallback(async () => {
|
|
if (loadingMore || !hasMore) return;
|
|
setLoadingMore(true);
|
|
setLoadMoreError(false);
|
|
try {
|
|
const next = currentPage + 1;
|
|
const { sort, order } = SORT_TO_API[sortKey];
|
|
const sp = new URLSearchParams({ period, sort, order, page: String(next) });
|
|
const r = await fetch(`/api/engagement/users?${sp.toString()}`);
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
const data: EngagementUsersResponse = await r.json();
|
|
setUsers(prev => [...prev, ...data.users]);
|
|
setCurrentPage(next);
|
|
const totalPages = data.pagination.totalPages
|
|
?? Math.ceil(data.pagination.total / data.pagination.pageSize);
|
|
setHasMore(next < totalPages);
|
|
} catch (e) {
|
|
console.error('Engagement users load-more fetch failed:', e);
|
|
toast.error('Failed to load more team members'); // D-25
|
|
setLoadMoreError(true);
|
|
} finally {
|
|
setLoadingMore(false);
|
|
}
|
|
}, [loadingMore, hasMore, currentPage, sortKey, period]);
|
|
|
|
// ── Effects ───────────────────────────────────────────────────────────
|
|
|
|
// Period change: refetch all three (summary + trend + users page 1) — D-04
|
|
useEffect(() => {
|
|
void loadSummary(period);
|
|
void loadTrend(period);
|
|
void loadUsersPage1(period, sortKey);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [period]);
|
|
|
|
// Sort change: refetch users only (D-20: summary/trend are period-scoped, not sort-scoped)
|
|
useEffect(() => {
|
|
void loadUsersPage1(period, sortKey);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [sortKey]);
|
|
|
|
// D-04 fallback: scroll restoration via sessionStorage. The actual scroll
|
|
// container differs by viewport — `<main>` has overflow-y-auto, but in some
|
|
// configurations the document scrolls instead. Listen on both, save the one
|
|
// that's non-zero, and restore both.
|
|
const restoredScrollRef = useRef(false);
|
|
useEffect(() => {
|
|
let raf = 0;
|
|
const save = () => {
|
|
const main = document.querySelector('main');
|
|
const mainTop = main?.scrollTop ?? 0;
|
|
const winTop = window.scrollY || document.documentElement.scrollTop || 0;
|
|
sessionStorage.setItem('mobile-engagement-scroll', JSON.stringify({ main: mainTop, win: winTop }));
|
|
};
|
|
const onScroll = () => {
|
|
if (raf) return;
|
|
raf = requestAnimationFrame(() => { raf = 0; save(); });
|
|
};
|
|
window.addEventListener('scroll', onScroll, { passive: true });
|
|
const main = document.querySelector('main');
|
|
main?.addEventListener('scroll', onScroll, { passive: true });
|
|
return () => {
|
|
window.removeEventListener('scroll', onScroll);
|
|
main?.removeEventListener('scroll', onScroll);
|
|
if (raf) cancelAnimationFrame(raf);
|
|
};
|
|
}, []);
|
|
useEffect(() => {
|
|
if (restoredScrollRef.current) return;
|
|
if (usersLoading || users.length === 0) return;
|
|
const raw = sessionStorage.getItem('mobile-engagement-scroll');
|
|
if (!raw) { restoredScrollRef.current = true; return; }
|
|
try {
|
|
const { main: mainTop, win: winTop } = JSON.parse(raw) as { main: number; win: number };
|
|
// Retry across frames until document height supports the target scroll;
|
|
// initial render may compute heights lazily and clamp scrollTo to a small
|
|
// maxScroll. Cap attempts so we never loop forever.
|
|
let attempts = 0;
|
|
const tryRestore = () => {
|
|
const main = document.querySelector('main');
|
|
if (main && mainTop) main.scrollTop = mainTop;
|
|
if (winTop) window.scrollTo(0, winTop);
|
|
const winNow = window.scrollY;
|
|
const mainNow = main?.scrollTop ?? 0;
|
|
const winOk = !winTop || Math.abs(winNow - winTop) <= 4;
|
|
const mainOk = !mainTop || Math.abs(mainNow - mainTop) <= 4;
|
|
if ((!winOk || !mainOk) && attempts < 30) {
|
|
attempts++;
|
|
requestAnimationFrame(tryRestore);
|
|
}
|
|
};
|
|
requestAnimationFrame(tryRestore);
|
|
} catch {
|
|
/* corrupt entry — ignore */
|
|
}
|
|
restoredScrollRef.current = true;
|
|
}, [usersLoading, users.length]);
|
|
|
|
// IntersectionObserver — D-18 (mirrors Phase 4/6 pattern, rootMargin '200px')
|
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
|
useEffect(() => {
|
|
const node = sentinelRef.current;
|
|
if (!node) return;
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0]?.isIntersecting && hasMore && !loadingMore && !usersLoading) {
|
|
void loadMoreUsers();
|
|
}
|
|
},
|
|
{ rootMargin: '200px' },
|
|
);
|
|
observer.observe(node);
|
|
return () => observer.disconnect();
|
|
}, [hasMore, loadingMore, usersLoading, loadMoreUsers]);
|
|
|
|
// ── Derived data ──────────────────────────────────────────────────────
|
|
|
|
// Client-side search filter (D-21): applies on displayName + email of loaded users
|
|
const filteredUsers = useMemo(() => {
|
|
const q = searchQuery.trim().toLowerCase();
|
|
if (!q) return users;
|
|
return users.filter(u =>
|
|
u.displayName.toLowerCase().includes(q) || u.email.toLowerCase().includes(q),
|
|
);
|
|
}, [users, searchQuery]);
|
|
|
|
// maxHours for hours bar normalization (UI-SPEC: largest billableHours in current page set)
|
|
const maxHours = useMemo(() => {
|
|
return filteredUsers.reduce((m, u) => Math.max(m, u.billableHours), 0);
|
|
}, [filteredUsers]);
|
|
|
|
// Empty-state predicate (D-26): both summary.activeUsers AND users.length === 0
|
|
const showEmptyState = !summaryLoading && !usersLoading
|
|
&& summary !== null && summary.activeUsers === 0 && users.length === 0;
|
|
|
|
// Not-configured banner predicate (D-27)
|
|
const showNotConfiguredBanner = !summaryLoading && summary !== null && summary.configured === false;
|
|
|
|
// Summary card values (formatted strings — Plan 02's SummaryCard accepts pre-formatted)
|
|
const summaryCard1 = summary ? String(summary.activeUsers) : '0';
|
|
const summaryCard2 = summary ? `${summary.totalGraphHours.toFixed(1)}h` : '0.0h';
|
|
const summaryCard3 = summary ? `${summary.totalAutotaskHours.toFixed(1)}h` : '0.0h';
|
|
const summaryCard4 = summary
|
|
? (summary.activeUsers === 0 ? '—' : `${summary.hoursPerActiveUser.toFixed(1)}h`) // D-08
|
|
: '—';
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div className="px-4 py-4 space-y-4"> {/* D-30 */}
|
|
{/* H1 — D-31, scrolls away under the sticky chips */}
|
|
<h1 className="text-sm font-semibold">Engagement</h1> {/* D-29 */}
|
|
|
|
{/* Sticky period chips — D-05 */}
|
|
<EngagementPeriodChips period={period} onPeriodChange={setPeriod} />
|
|
|
|
{showNotConfiguredBanner ? (
|
|
// Not-configured banner (D-27) — replaces all data sections
|
|
<div className="rounded-xl border bg-card px-4 py-4 space-y-1">
|
|
<p className="text-sm font-semibold">Engagement sync not configured</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Set MSGRAPH_* environment variables and restart.
|
|
<a
|
|
href="/admin"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
aria-label="Open Admin on desktop"
|
|
className="underline ml-1"
|
|
>
|
|
Open Admin
|
|
</a>
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Section 3 — Summary cards (D-08, D-23) */}
|
|
{summaryLoading ? (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Card key={i} className="py-0 shadow-none">
|
|
<CardContent className="px-4 py-4 space-y-2">
|
|
<Skeleton className="h-8 w-20" />
|
|
<Skeleton className="h-3 w-28" />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<EngagementSummaryCard value={summaryCard1} label={SUMMARY_LABELS.activeUsers} />
|
|
<EngagementSummaryCard value={summaryCard2} label={SUMMARY_LABELS.totalGraphHours} />
|
|
<EngagementSummaryCard value={summaryCard3} label={SUMMARY_LABELS.totalAutotaskHours} />
|
|
<EngagementSummaryCard value={summaryCard4} label={SUMMARY_LABELS.hoursPerActiveUser} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Section 4 — Sparkline (D-11..D-15, D-23) */}
|
|
{trendLoading ? (
|
|
<Card className="py-0 shadow-none">
|
|
<CardContent className="px-4 py-3 space-y-2">
|
|
<div className="flex justify-between gap-2">
|
|
<Skeleton className="h-3 w-32" />
|
|
<Skeleton className="h-3 w-16" />
|
|
</div>
|
|
<Skeleton className="h-12 w-full mt-2" />
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<EngagementHoursSparkline points={trendPoints} period={period} />
|
|
)}
|
|
|
|
{/* Section 5 — Sort + search (D-20, D-21) */}
|
|
<div className="space-y-2">
|
|
<EngagementSortChips value={sortKey} onChange={setSortKey} />
|
|
<EngagementSearchInput value={searchQuery} onChange={setSearchQuery} />
|
|
</div>
|
|
|
|
{/* Section 6 — User list (D-19, D-22, D-23, D-26) */}
|
|
{showEmptyState ? (
|
|
// Empty state (D-26)
|
|
<div className="flex flex-col items-center justify-center py-12 text-center space-y-3">
|
|
<Users className="h-8 w-8 text-muted-foreground/50" aria-hidden="true" />
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-semibold">No engagement data for this period</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Try a different period or trigger a sync from
|
|
<a
|
|
href="/admin"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
aria-label="Open Admin on desktop"
|
|
className="underline ml-1"
|
|
>Admin</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : usersLoading ? (
|
|
<div className="divide-y border rounded-xl overflow-hidden">
|
|
{Array.from({ length: 5 }).map((_, i) => <EngagementUserRowSkeleton key={i} />)}
|
|
</div>
|
|
) : filteredUsers.length === 0 && searchQuery.trim() !== '' ? (
|
|
// No-matches inline state (D-22) — preserves the list border + rounding
|
|
<div className="border rounded-xl overflow-hidden">
|
|
<div className="px-4 py-6 text-center space-y-2">
|
|
<p className="text-sm text-muted-foreground">{`No matches for "${searchQuery}"`}</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSearchQuery('')}
|
|
className="text-xs font-semibold text-primary underline"
|
|
>
|
|
Clear search
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="divide-y border rounded-xl overflow-hidden">
|
|
{filteredUsers.map(u => (
|
|
<EngagementUserRow
|
|
key={u.graphUserId}
|
|
user={{
|
|
graphUserId: u.graphUserId,
|
|
displayName: u.displayName,
|
|
userEmail: u.email, // existing endpoint uses 'email'; component expects 'userEmail'
|
|
jobTitle: u.jobTitle,
|
|
billableHours: u.billableHours,
|
|
hoursWorked: u.hoursWorked,
|
|
}}
|
|
maxHours={maxHours}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Sentinel — D-18 */}
|
|
<div ref={sentinelRef} aria-hidden="true" />
|
|
|
|
{/* Loading-more spinner — D-24 */}
|
|
{loadingMore && (
|
|
<div className="flex justify-center py-2">
|
|
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" aria-hidden="true" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Load more fallback button — D-18, D-25 */}
|
|
{hasMore && (
|
|
<button
|
|
type="button"
|
|
onClick={() => void loadMoreUsers()}
|
|
disabled={loadingMore}
|
|
aria-label="Load more team members"
|
|
className="w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50 transition-colors disabled:opacity-50 min-h-[44px]"
|
|
>
|
|
{loadMoreError ? 'Retry' : loadingMore ? 'Loading…' : 'Load more'}
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|