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)
This commit is contained in:
parent
d63789224e
commit
5daf7f31e5
1 changed files with 378 additions and 0 deletions
378
app/mobile/engagement/page.tsx
Normal file
378
app/mobile/engagement/page.tsx
Normal file
|
|
@ -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<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]);
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue