feat(07-02): add 7 engagement components (chips, summary card, sparkline, sort, search, user row + skeleton)
This commit is contained in:
parent
ccd2177977
commit
d63789224e
7 changed files with 379 additions and 0 deletions
105
components/mobile/EngagementHoursSparkline.tsx
Normal file
105
components/mobile/EngagementHoursSparkline.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
'use client';
|
||||
|
||||
/* EngagementHoursSparkline — phase 07 (ENG-05).
|
||||
* Purpose: Custom inline SVG sparkline for daily hours trend over the selected period.
|
||||
* One series, no axes, no tooltips, no animation. DASH-04 (no recharts on mobile).
|
||||
* Renders the sparkline card per UI-SPEC: label row (left + right) + 48px-tall SVG.
|
||||
* Props: points (Plan 01 SparklinePoint[]), period (D7|D30|D90 — drives the period_label). */
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import type { SparklinePoint } from '@/app/api/mobile/engagement/trend/route';
|
||||
|
||||
export interface EngagementHoursSparklineProps {
|
||||
points: SparklinePoint[];
|
||||
period: 'D7' | 'D30' | 'D90';
|
||||
}
|
||||
|
||||
const PERIOD_LABEL: Record<EngagementHoursSparklineProps['period'], string> = {
|
||||
D7: '7 days',
|
||||
D30: '30 days',
|
||||
D90: '90 days',
|
||||
};
|
||||
|
||||
function formatLatestValue(points: SparklinePoint[]): string {
|
||||
// Find last point with hours > 0
|
||||
let latest: SparklinePoint | null = null;
|
||||
for (let i = points.length - 1; i >= 0; i--) {
|
||||
if (points[i]!.hours > 0) { latest = points[i]!; break; }
|
||||
}
|
||||
if (!latest) return '—';
|
||||
|
||||
const todayIso = new Date().toISOString().slice(0, 10); // "YYYY-MM-DD" UTC
|
||||
const isToday = latest.date === todayIso;
|
||||
const hoursLabel = `${latest.hours.toFixed(1)}h`;
|
||||
if (isToday) return `${hoursLabel} today`;
|
||||
|
||||
// shortDate: "May 2"
|
||||
const [y, m, d] = latest.date.split('-').map(Number);
|
||||
const dt = new Date(Date.UTC(y!, m! - 1, d!));
|
||||
const short = dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
|
||||
return `${hoursLabel} ${short}`;
|
||||
}
|
||||
|
||||
function buildPath(points: SparklinePoint[], svgWidth: number, svgHeight: number): string {
|
||||
if (points.length === 0) return '';
|
||||
const maxHours = Math.max(...points.map(p => p.hours), 0);
|
||||
// 4px top margin + 4px bottom margin per UI-SPEC
|
||||
const yScale = maxHours === 0 ? 0 : (svgHeight - 8) / maxHours;
|
||||
const xStep = points.length === 1 ? 0 : svgWidth / (points.length - 1);
|
||||
|
||||
return points.map((pt, i) => {
|
||||
const x = points.length === 1 ? svgWidth / 2 : i * xStep;
|
||||
const y = svgHeight - 4 - (pt.hours * yScale); // baseline 4px above bottom
|
||||
return `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
export function EngagementHoursSparkline({ points, period }: EngagementHoursSparklineProps) {
|
||||
const periodLabel = PERIOD_LABEL[period];
|
||||
const allZero = points.length === 0 || points.every(p => p.hours === 0);
|
||||
const SVG_W = 300;
|
||||
const SVG_H = 48;
|
||||
|
||||
return (
|
||||
<Card className="py-0 shadow-none">
|
||||
<CardContent className="px-4 py-3">
|
||||
<div className="flex justify-between items-center mb-2 gap-2">
|
||||
<p className="text-xs text-muted-foreground">{`Hours trend · last ${periodLabel}`}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatLatestValue(points)}</p>
|
||||
</div>
|
||||
|
||||
{allZero ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-3">No activity</p>
|
||||
) : (
|
||||
<svg
|
||||
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="h-12 w-full"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Baseline at y=46 (2px from bottom) per UI-SPEC */}
|
||||
<line
|
||||
x1="0"
|
||||
y1={SVG_H - 2}
|
||||
x2={SVG_W}
|
||||
y2={SVG_H - 2}
|
||||
className="text-muted-foreground/20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
/>
|
||||
{/* Series path — stroke-primary stroke-2 fill-none */}
|
||||
<path
|
||||
d={buildPath(points, SVG_W, SVG_H)}
|
||||
className="stroke-primary"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
45
components/mobile/EngagementPeriodChips.tsx
Normal file
45
components/mobile/EngagementPeriodChips.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
'use client';
|
||||
|
||||
/* EngagementPeriodChips — phase 07 (ENG-02).
|
||||
* Purpose: 3-chip period selector (7d/30d/90d) sticky below the page H1.
|
||||
* Maps 1:1 to data-layer period_type values D7/D30/D90 (D-04).
|
||||
* Props: period, onPeriodChange. Pure presentational — page owns refetch logic. */
|
||||
|
||||
export type EngagementPeriod = 'D7' | 'D30' | 'D90';
|
||||
|
||||
export interface EngagementPeriodChipsProps {
|
||||
period: EngagementPeriod;
|
||||
onPeriodChange: (next: EngagementPeriod) => void;
|
||||
}
|
||||
|
||||
const CHIPS: ReadonlyArray<{ value: EngagementPeriod; label: string }> = [
|
||||
{ value: 'D7', label: '7d' },
|
||||
{ value: 'D30', label: '30d' },
|
||||
{ value: 'D90', label: '90d' },
|
||||
];
|
||||
|
||||
export function EngagementPeriodChips({ period, onPeriodChange }: EngagementPeriodChipsProps) {
|
||||
return (
|
||||
<div className="sticky top-0 z-10 bg-background pt-2 pb-3 -mx-4 px-4 flex gap-2 min-h-[44px]">
|
||||
{CHIPS.map(chip => {
|
||||
const isActive = chip.value === period;
|
||||
return (
|
||||
<button
|
||||
key={chip.value}
|
||||
type="button"
|
||||
role="button"
|
||||
aria-pressed={isActive}
|
||||
onClick={() => { if (!isActive) onPeriodChange(chip.value); }}
|
||||
className={
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground rounded-full px-3 py-1.5 text-[10px] font-semibold'
|
||||
: 'bg-muted text-foreground hover:bg-muted/80 rounded-full px-3 py-1.5 text-[10px] font-semibold'
|
||||
}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
components/mobile/EngagementSearchInput.tsx
Normal file
49
components/mobile/EngagementSearchInput.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
'use client';
|
||||
|
||||
/* EngagementSearchInput — phase 07 (ENG-04).
|
||||
* Purpose: Search input with leading Search icon. Debounced 300ms before emitting onChange.
|
||||
* Page applies the filter client-side on loaded users (D-21).
|
||||
* Props: value (controlled string), onChange (debounced callback). */
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export interface EngagementSearchInputProps {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
}
|
||||
|
||||
export function EngagementSearchInput({ value, onChange }: EngagementSearchInputProps) {
|
||||
// Local immediate state for the input; debounce flushes to onChange
|
||||
const [local, setLocal] = useState<string>(value);
|
||||
|
||||
// Keep local state synced when the parent resets (e.g. "Clear search" CTA on no-matches state)
|
||||
useEffect(() => {
|
||||
setLocal(value);
|
||||
}, [value]);
|
||||
|
||||
// 300ms debounce per D-21
|
||||
useEffect(() => {
|
||||
if (local === value) return;
|
||||
const id = setTimeout(() => { onChange(local); }, 300);
|
||||
return () => clearTimeout(id);
|
||||
}, [local, value, onChange]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search by name or email"
|
||||
aria-label="Search team members by name or email"
|
||||
className="pl-9"
|
||||
value={local}
|
||||
onChange={(e) => setLocal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
components/mobile/EngagementSortChips.tsx
Normal file
45
components/mobile/EngagementSortChips.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
'use client';
|
||||
|
||||
/* EngagementSortChips — phase 07 (ENG-04).
|
||||
* Purpose: 3-chip sort selector (Hours/Name/Utilization). Same chip styling as period chips.
|
||||
* Maps to /api/engagement/users sort/order params per D-20.
|
||||
* Props: value, onChange. Pure presentational. */
|
||||
|
||||
export type EngagementSortKey = 'hours' | 'name' | 'utilization';
|
||||
|
||||
export interface EngagementSortChipsProps {
|
||||
value: EngagementSortKey;
|
||||
onChange: (next: EngagementSortKey) => void;
|
||||
}
|
||||
|
||||
const SORTS: ReadonlyArray<{ key: EngagementSortKey; label: string }> = [
|
||||
{ key: 'hours', label: 'Hours' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'utilization', label: 'Utilization' },
|
||||
];
|
||||
|
||||
export function EngagementSortChips({ value, onChange }: EngagementSortChipsProps) {
|
||||
return (
|
||||
<div className="flex gap-2 items-center min-h-[44px]">
|
||||
{SORTS.map(({ key, label }) => {
|
||||
const isActive = key === value;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="button"
|
||||
aria-pressed={isActive}
|
||||
onClick={() => { if (!isActive) onChange(key); }}
|
||||
className={
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground rounded-full px-3 py-1.5 text-[10px] font-semibold'
|
||||
: 'bg-muted text-foreground hover:bg-muted/80 rounded-full px-3 py-1.5 text-[10px] font-semibold'
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
components/mobile/EngagementSummaryCard.tsx
Normal file
25
components/mobile/EngagementSummaryCard.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
'use client';
|
||||
|
||||
/* EngagementSummaryCard — phase 07 (ENG-03).
|
||||
* Purpose: Single summary card with big number + label, stacked single-column.
|
||||
* Used 4× on the page: Active users, Total Graph hours, Total Autotask hours, Hours / active user (D-08).
|
||||
* Card has no shadow, only border (matches FinanceRow density per D-10).
|
||||
* Props: value (display string), label (display string). */
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
||||
export interface EngagementSummaryCardProps {
|
||||
value: string; // pre-formatted: "42", "128.5h", "—"
|
||||
label: string; // "Active users", "Total Graph hours", etc.
|
||||
}
|
||||
|
||||
export function EngagementSummaryCard({ value, label }: EngagementSummaryCardProps) {
|
||||
return (
|
||||
<Card className="py-0 shadow-none">
|
||||
<CardContent className="px-4 py-4">
|
||||
<p className="text-2xl font-semibold text-foreground leading-none">{value}</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">{label}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
87
components/mobile/EngagementUserRow.tsx
Normal file
87
components/mobile/EngagementUserRow.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
'use client';
|
||||
|
||||
/* EngagementUserRow — phase 07 (ENG-04).
|
||||
* Purpose: Per-employee row card — avatar (initials) + name + role + hours + hours bar.
|
||||
* Entire row is a Link to /mobile/engagement/[graphUserId] (D-19; Phase 8 owns destination).
|
||||
* Hours bar width = (billableHours / maxHours) * 100% — bounded at 100%.
|
||||
* Props: user (EngagementUserRowData shape), maxHours (largest billableHours in current page set; computed by parent). */
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
export interface EngagementUserRowData {
|
||||
graphUserId: string;
|
||||
displayName: string;
|
||||
userEmail: string;
|
||||
jobTitle: string | null;
|
||||
billableHours: number;
|
||||
hoursWorked: number;
|
||||
}
|
||||
|
||||
export interface EngagementUserRowProps {
|
||||
user: EngagementUserRowData;
|
||||
maxHours: number; // largest billableHours in the loaded set (parent computes)
|
||||
}
|
||||
|
||||
/**
|
||||
* getInitials — first letter of first word + first letter of last word of displayName,
|
||||
* uppercased. E.g. "Jordan Walsh" → "JW", "Alex" → "A". Exported for Phase 8 reuse
|
||||
* (the user profile header may share the avatar identity block per UI-SPEC).
|
||||
*/
|
||||
export function getInitials(displayName: string): string {
|
||||
const parts = displayName.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '??';
|
||||
if (parts.length === 1) return (parts[0]![0] ?? '?').toUpperCase();
|
||||
const first = parts[0]![0] ?? '';
|
||||
const last = parts[parts.length - 1]![0] ?? '';
|
||||
return (first + last).toUpperCase();
|
||||
}
|
||||
|
||||
export function EngagementUserRow({ user, maxHours }: EngagementUserRowProps) {
|
||||
const initials = getInitials(user.displayName);
|
||||
const hoursLabel = `${user.billableHours.toFixed(1)}h`;
|
||||
const barWidthPct = maxHours > 0
|
||||
? Math.min(100, (user.billableHours / maxHours) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/mobile/engagement/${user.graphUserId}`}
|
||||
className="block px-4 py-3 hover:bg-muted/50 transition-colors active:bg-muted/50"
|
||||
>
|
||||
{/* Top line: avatar + identity + hours value */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0 text-[10px] font-semibold text-foreground"
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0 flex items-baseline gap-2">
|
||||
<span className="text-sm font-semibold truncate flex-1">
|
||||
{user.displayName}
|
||||
</span>
|
||||
<span className="text-sm font-semibold shrink-0 text-right">
|
||||
{hoursLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role line — render only if jobTitle present (UI-SPEC: "render nothing (no empty line) if absent") */}
|
||||
{user.jobTitle && (
|
||||
<p className="text-xs text-muted-foreground truncate pl-11 mt-0.5">
|
||||
{user.jobTitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Hours bar */}
|
||||
<div className="mt-2">
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden" role="presentation" aria-hidden="true">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${barWidthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
23
components/mobile/EngagementUserRowSkeleton.tsx
Normal file
23
components/mobile/EngagementUserRowSkeleton.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
'use client';
|
||||
|
||||
/* EngagementUserRowSkeleton — phase 07 (D-23).
|
||||
* Purpose: Skeleton placeholder matching EngagementUserRow shape. Renders 5 instances on initial load.
|
||||
* Props: none — purely presentational. */
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export function EngagementUserRowSkeleton() {
|
||||
return (
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="flex-1 flex items-center justify-between gap-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-3 w-24 ml-11" />
|
||||
<Skeleton className="h-1.5 w-full mt-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue