From 5daf7f31e51c5f5f15c5b3097732cf9f6b593c9f Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 3 May 2026 22:57:44 -0400 Subject: [PATCH] feat(07-03): create mobile engagement page (plan 01 endpoints + plan 02 components) - New app/mobile/engagement/page.tsx ('use client', 378 lines) - Period chips (D30 default), 3 independent fetches on mount/period change - Sort chips (hours default), refetch users only on sort change - Client-side search filter (useMemo, 300ms debounce via EngagementSearchInput) - IntersectionObserver infinite scroll (rootMargin 200px) + Load more fallback - 4 summary card skeletons + sparkline skeleton + 5 row skeletons on initial load - Empty state (activeUsers === 0 + users.length === 0), not-configured banner, no-matches inline - toast.error per failing fetch; Load more flips to Retry on error - BottomNav and MoreDrawer unchanged (ENG-09 / D-01 / D-02) --- app/mobile/engagement/page.tsx | 378 +++++++++++++++++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 app/mobile/engagement/page.tsx diff --git a/app/mobile/engagement/page.tsx b/app/mobile/engagement/page.tsx new file mode 100644 index 0000000..f682f75 --- /dev/null +++ b/app/mobile/engagement/page.tsx @@ -0,0 +1,378 @@ +'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 = { + 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('D30'); // D-04 default + const [sortKey, setSortKey] = useState('hours'); // D-20 default (lowercase) + const [searchQuery, setSearchQuery] = useState(''); // D-21 + + const [summary, setSummary] = useState(null); + const [summaryLoading, setSummaryLoading] = useState(true); + + const [trendPoints, setTrendPoints] = useState([]); + const [trendLoading, setTrendLoading] = useState(true); + + const [users, setUsers] = useState([]); + const [usersLoading, setUsersLoading] = useState(true); + const [currentPage, setCurrentPage] = useState(1); + const [hasMore, setHasMore] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [loadMoreError, setLoadMoreError] = useState(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]); + + // IntersectionObserver — D-18 (mirrors Phase 4/6 pattern, rootMargin '200px') + const sentinelRef = useRef(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 ( +
{/* D-30 */} + {/* H1 — D-31, scrolls away under the sticky chips */} +

Engagement

{/* D-29 */} + + {/* Sticky period chips — D-05 */} + + + {showNotConfiguredBanner ? ( + // Not-configured banner (D-27) — replaces all data sections +
+

Engagement sync not configured

+

+ Set MSGRAPH_* environment variables and restart. + + Open Admin + +

+
+ ) : ( + <> + {/* Section 3 — Summary cards (D-08, D-23) */} + {summaryLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + ))} +
+ ) : ( +
+ + + + +
+ )} + + {/* Section 4 — Sparkline (D-11..D-15, D-23) */} + {trendLoading ? ( + + +
+ + +
+ +
+
+ ) : ( + + )} + + {/* Section 5 — Sort + search (D-20, D-21) */} +
+ + +
+ + {/* Section 6 — User list (D-19, D-22, D-23, D-26) */} + {showEmptyState ? ( + // Empty state (D-26) +
+
+ ) : usersLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => )} +
+ ) : filteredUsers.length === 0 && searchQuery.trim() !== '' ? ( + // No-matches inline state (D-22) — preserves the list border + rounding +
+
+

{`No matches for "${searchQuery}"`}

+ +
+
+ ) : ( + <> +
+ {filteredUsers.map(u => ( + + ))} +
+ + {/* Sentinel — D-18 */} + + ); +}