feat(08-02): page shell + skeleton + period/fetch wiring (Task 1a)
- New app/mobile/engagement/[userId]/page.tsx with fetch + error states + retryNonce - New EngagementProfileSkeleton with header/metric/breakdown/list skeletons - 404 renders 'User not found' + back link; 500 renders sonner toast + Retry - D-04 comment: relies on App Router default scrollRestoration - D-01/D-22 guard rails: EngagementUserRow.tsx and data endpoint untouched
This commit is contained in:
parent
b1a6e6a3c3
commit
3247c92486
2 changed files with 275 additions and 0 deletions
198
app/mobile/engagement/[userId]/page.tsx
Normal file
198
app/mobile/engagement/[userId]/page.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
'use client';
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { toast } from 'sonner';
|
||||
import { EngagementPeriodChips, type EngagementPeriod } from '@/components/mobile/EngagementPeriodChips';
|
||||
import { EngagementProfileSkeleton } from '@/components/mobile/EngagementProfileSkeleton';
|
||||
|
||||
interface ApiResponse {
|
||||
user: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
jobTitle: string | null;
|
||||
department: string | null;
|
||||
accountEnabled: boolean | null;
|
||||
autotaskResourceId: number | null;
|
||||
};
|
||||
afterHours: { messages: number; meetings: number; messagesPct: number; meetingsPct: number };
|
||||
snapshots: Array<{
|
||||
period_type: string;
|
||||
period_end: string; // verified present — migration 041 line 18
|
||||
teams_chat_messages: number | null;
|
||||
teams_private_messages: number | null;
|
||||
emails_sent: number | null;
|
||||
teams_meetings_attended: number | null;
|
||||
teams_meetings_organized: number | null;
|
||||
meeting_duration_seconds: number | null;
|
||||
after_hours_messages: number | null;
|
||||
}>;
|
||||
hours: {
|
||||
d7: { total: number; billable: number };
|
||||
d30: { total: number; billable: number };
|
||||
d90: { total: number; billable: number };
|
||||
} | null;
|
||||
recentEntries: Array<{
|
||||
entry_date: string;
|
||||
hours_worked: number;
|
||||
billable: boolean | null;
|
||||
notes: string | null;
|
||||
title: string | null;
|
||||
start_date_time: string | null;
|
||||
end_date_time: string | null;
|
||||
company_name: string | null;
|
||||
}>;
|
||||
recentTeamsMeetings: Array<{
|
||||
subject: string | null;
|
||||
startTime: string;
|
||||
durationMinutes: number | null;
|
||||
attendeeCount: number;
|
||||
clientAttendeeCount: number;
|
||||
hasClientAttendees: boolean;
|
||||
clientCompanies: string[];
|
||||
participantNames: string[];
|
||||
matchedEntries: Array<{
|
||||
hours_worked: number;
|
||||
billable: boolean | null;
|
||||
notes: string | null;
|
||||
title: string | null;
|
||||
company_name: string | null;
|
||||
start_date_time: string | null;
|
||||
end_date_time: string | null;
|
||||
}>;
|
||||
}>;
|
||||
zoom: { calls: { d7: { total: number }; d30: { total: number }; d90: { total: number } } } | null;
|
||||
}
|
||||
|
||||
export default function MobileEngagementUserProfilePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ userId: string }>;
|
||||
}) {
|
||||
// D-04: rely on App Router default scrollRestoration
|
||||
const { userId } = use(params); // Next.js 16: params is a Promise — unwrap with React.use
|
||||
|
||||
const [period, setPeriod] = useState<EngagementPeriod>('D30'); // D-09
|
||||
const [data, setData] = useState<ApiResponse | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [errorState, setErrorState] = useState<'none' | 'not-found' | 'failed'>('none');
|
||||
// retryNonce: incrementing this re-runs the fetch effect without changing `period`.
|
||||
// Used by the Retry button in the failed-state branch (issue-7 fix).
|
||||
const [retryNonce, setRetryNonce] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setErrorState('none');
|
||||
fetch(`/api/engagement/user/${encodeURIComponent(userId)}?period=${period}`)
|
||||
.then(async (res) => {
|
||||
if (cancelled) return;
|
||||
if (res.status === 404) {
|
||||
setErrorState('not-found');
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const json = (await res.json()) as ApiResponse;
|
||||
setData(json);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
console.error('[mobile/engagement/profile] fetch failed', err);
|
||||
setErrorState('failed');
|
||||
toast.error('Failed to load profile — tap to retry');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [userId, period, retryNonce]);
|
||||
|
||||
// ── Error: 404 ──────────────────────────────────────────────────────
|
||||
if (errorState === 'not-found') {
|
||||
return (
|
||||
<main className="px-4 pb-safe">
|
||||
<div className="py-12 text-center space-y-3">
|
||||
<h1 className="text-xl font-semibold">User not found</h1>
|
||||
<p className="text-sm text-muted-foreground">This profile is no longer available.</p>
|
||||
<Link href="/mobile/engagement" className="text-sm text-primary underline-offset-4 hover:underline inline-block min-h-[44px] py-3">
|
||||
Back to Engagement
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error: 500/network — show skeleton + Retry ──────────────────────
|
||||
if (errorState === 'failed' && !data) {
|
||||
return (
|
||||
<main className="px-4 pb-safe">
|
||||
<EngagementPeriodChips period={period} onPeriodChange={setPeriod} />
|
||||
<EngagementProfileSkeleton />
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 inline-flex items-center justify-center min-h-[44px] px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-semibold"
|
||||
onClick={() => setRetryNonce((n) => n + 1)}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to derive period-scoped values once data is loaded.
|
||||
const periodKey = period === 'D7' ? 'd7' : period === 'D90' ? 'd90' : 'd30';
|
||||
const hoursForPeriod = data?.hours?.[periodKey]?.total ?? 0;
|
||||
const billableForPeriod = data?.hours?.[periodKey]?.billable ?? 0;
|
||||
const daysWorkedForPeriod = data
|
||||
? new Set(data.recentEntries.map((e) => String(e.entry_date).slice(0, 10))).size
|
||||
: 0;
|
||||
const snapshotForPeriod = data?.snapshots.find((s) => s.period_type === period) ?? null;
|
||||
const meetingsAttended = snapshotForPeriod?.teams_meetings_attended ?? 0;
|
||||
|
||||
// Last-active derivation for the header (D-06):
|
||||
// most recent of (recentEntries[0].entry_date, latest snapshot.period_end).
|
||||
// If `period_end` ever turns out to be missing from the API at runtime, fall
|
||||
// back to recentEntries[0].entry_date alone (executor can simplify this block
|
||||
// — see <discovery> above).
|
||||
let lastActiveAt: string | null = null;
|
||||
if (data) {
|
||||
const candidates: number[] = [];
|
||||
if (data.recentEntries[0]) candidates.push(new Date(data.recentEntries[0].entry_date).getTime());
|
||||
const latestSnapshot = data.snapshots
|
||||
.filter((s) => s.period_end)
|
||||
.map((s) => new Date(s.period_end).getTime())
|
||||
.filter((n) => Number.isFinite(n))
|
||||
.sort((a, b) => b - a)[0];
|
||||
if (latestSnapshot) candidates.push(latestSnapshot);
|
||||
if (candidates.length > 0) {
|
||||
lastActiveAt = new Date(Math.max(...candidates)).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="px-4 pb-safe">
|
||||
<h1 className="text-xl font-semibold pt-4 pb-2">
|
||||
{data?.user.displayName ?? ' '}
|
||||
</h1>
|
||||
<EngagementPeriodChips period={period} onPeriodChange={setPeriod} />
|
||||
|
||||
<div className="space-y-4">
|
||||
{loading || !data ? (
|
||||
<EngagementProfileSkeleton />
|
||||
) : (
|
||||
<>
|
||||
{/* Task 1b will mount EngagementProfileHeader + EngagementProfileMetricGrid here.
|
||||
Task 2 will add EngagementProfileBreakdown + EngagementRecentEntries + EngagementRecentMeetings. */}
|
||||
<div className="text-sm text-muted-foreground py-2">
|
||||
Loaded: {data.user.displayName}. Header and metric grid wired in Task 1b.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
77
components/mobile/EngagementProfileSkeleton.tsx
Normal file
77
components/mobile/EngagementProfileSkeleton.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
'use client';
|
||||
|
||||
/* EngagementProfileSkeleton — phase 08 (D-23).
|
||||
* Purpose: Full-page loading skeleton matching the final layout —
|
||||
* header skeleton + 4 metric-card skeletons (2×2) + breakdown card skeleton +
|
||||
* 2 list-section skeletons. Period chips render OUTSIDE this skeleton (they
|
||||
* drive the fetch). */
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export function EngagementProfileSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Identity header skeleton */}
|
||||
<Card className="py-0 shadow-none">
|
||||
<CardContent className="px-4 py-4 flex items-center gap-3">
|
||||
<Skeleton className="h-14 w-14 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2 min-w-0">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 2×2 metric grid skeleton */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Card key={i} className="py-0 shadow-none">
|
||||
<CardContent className="px-4 py-4">
|
||||
<Skeleton className="h-7 w-16" />
|
||||
<Skeleton className="h-3 w-24 mt-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Breakdown card skeleton */}
|
||||
<Card className="py-0 shadow-none">
|
||||
<CardContent className="px-4 py-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2 border-t border-border pt-3">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
<div className="space-y-2 border-t border-border pt-3">
|
||||
<Skeleton className="h-3 w-28" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Two list skeletons */}
|
||||
{[0, 1].map((i) => (
|
||||
<Card key={i} className="py-0 shadow-none">
|
||||
<CardContent className="px-4 py-4 space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
{[0, 1, 2].map((j) => (
|
||||
<div key={j} className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-40" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue