199 lines
7.4 KiB
TypeScript
199 lines
7.4 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
import { ClubMetrics } from './types';
|
|
|
|
interface MetricCardProps {
|
|
label: string;
|
|
value: string | number;
|
|
unit?: string;
|
|
badge?: React.ReactNode;
|
|
subtextLeft?: React.ReactNode;
|
|
subtextRight?: React.ReactNode;
|
|
disclaimer?: string;
|
|
sparklineVariant?: 'secondary' | 'error' | 'primary';
|
|
showSparkline?: boolean;
|
|
isUnavailable?: boolean;
|
|
}
|
|
|
|
export function MetricCard({
|
|
label,
|
|
value,
|
|
unit,
|
|
badge,
|
|
subtextLeft,
|
|
subtextRight,
|
|
disclaimer,
|
|
sparklineVariant = 'secondary',
|
|
showSparkline = false,
|
|
isUnavailable = false,
|
|
}: MetricCardProps) {
|
|
const sparklineColors = {
|
|
secondary: { stroke: 'text-secondary', fill: '#38bdf8', path: 'M0 24 L25 18 L50 20 L75 10 L100 4' },
|
|
error: { stroke: 'text-error', fill: '#f87171', path: 'M0 15 L25 18 L50 10 L75 22 L100 8' },
|
|
primary: { stroke: 'text-primary', fill: '#0088ff', path: 'M0 26 L30 20 L60 14 L100 8' },
|
|
};
|
|
|
|
const selectedSpark = sparklineColors[sparklineVariant] || sparklineColors.secondary;
|
|
|
|
return (
|
|
<div className="relative rounded-2xl glass-card border border-brand-border/60 p-5 sm:p-6 flex flex-col justify-between transition-all hover:border-brand-cyan/50 hover:shadow-[0_0_20px_rgba(0,242,254,0.12)] overflow-hidden">
|
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gradient-to-r from-brand-cyan/50 via-transparent to-transparent" />
|
|
{/* Top Header Row */}
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="font-mono text-xs tracking-wider text-slate-400 font-medium uppercase">{label}</span>
|
|
{badge}
|
|
</div>
|
|
|
|
{/* Primary Numerical Value & Sparkline */}
|
|
<div className="my-3 flex items-baseline justify-between gap-2">
|
|
<div className="flex items-baseline gap-1.5">
|
|
<span className="font-headline text-3xl sm:text-4xl font-extrabold text-white tracking-tight">
|
|
{isUnavailable ? '--' : value}
|
|
</span>
|
|
{unit && !isUnavailable && (
|
|
<span className="font-mono text-xs text-brand-cyan font-bold">{unit}</span>
|
|
)}
|
|
</div>
|
|
|
|
{showSparkline && !isUnavailable && (
|
|
<div className="w-20 h-6 shrink-0" aria-hidden="true">
|
|
<svg className={`w-full h-full ${selectedSpark.stroke}`} fill="none" viewBox="0 0 100 30">
|
|
<path
|
|
d={selectedSpark.path}
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeWidth="2"
|
|
/>
|
|
<path
|
|
d={`${selectedSpark.path} L100 30 L0 30 Z`}
|
|
fill="currentColor"
|
|
fillOpacity="0.08"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer Subtext */}
|
|
<div className="flex flex-col gap-1 w-full pt-2 border-t border-brand-border/40">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 font-mono text-xs text-slate-400">
|
|
<div className="truncate">{subtextLeft}</div>
|
|
<div className="text-slate-400 truncate sm:text-right">{subtextRight}</div>
|
|
</div>
|
|
{disclaimer && (
|
|
<span className="text-[10px] font-mono text-slate-500 truncate" title={disclaimer}>
|
|
{disclaimer}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ExecutiveMetrics({ metrics }: { metrics: ClubMetrics }) {
|
|
return (
|
|
<section aria-label="Executive Metrics" className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 lg:gap-5">
|
|
{/* 1. MEMBERS */}
|
|
<MetricCard
|
|
label="Members"
|
|
value={metrics.members.toLocaleString()}
|
|
showSparkline={false}
|
|
badge={
|
|
metrics.membersActivePercent != null ? (
|
|
<span className="inline-flex items-center gap-1 font-mono text-[11px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan" />
|
|
{metrics.membersActivePercent}% ACTIVE
|
|
</span>
|
|
) : (
|
|
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60">ROSTER</span>
|
|
)
|
|
}
|
|
subtextLeft={
|
|
metrics.membersDeltaMonth != null ? (
|
|
<span className="text-brand-cyan font-medium">
|
|
+{metrics.membersDeltaMonth} this month
|
|
</span>
|
|
) : (
|
|
<span>Total Roster</span>
|
|
)
|
|
}
|
|
subtextRight={
|
|
metrics.roadMarshalsCount != null ? (
|
|
<span>{metrics.roadMarshalsCount} Road Marshals</span>
|
|
) : (
|
|
<span>Active members</span>
|
|
)
|
|
}
|
|
/>
|
|
|
|
{/* 2. PENDING APPLICATIONS */}
|
|
<MetricCard
|
|
label="Pending Applications"
|
|
value={metrics.pendingApplications}
|
|
showSparkline={false}
|
|
badge={
|
|
metrics.pendingApplications > 0 ? (
|
|
<span className="px-2 py-0.5 rounded-full bg-error-container text-on-error-container font-mono text-[10px] font-semibold border border-error/30">
|
|
REQUIRES REVIEW
|
|
</span>
|
|
) : (
|
|
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60">ALL CLEARED</span>
|
|
)
|
|
}
|
|
subtextLeft={
|
|
metrics.pendingApplications > 0 ? (
|
|
<span className="text-error font-medium">Requires review</span>
|
|
) : (
|
|
<span>No pending</span>
|
|
)
|
|
}
|
|
subtextRight={<span>Awaiting founder review</span>}
|
|
/>
|
|
|
|
{/* 3. UPCOMING RIDES */}
|
|
<MetricCard
|
|
label="Upcoming Rides"
|
|
value={metrics.upcomingRidesCount}
|
|
showSparkline={false}
|
|
badge={
|
|
<span className="font-mono text-[11px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 font-medium">
|
|
{metrics.upcomingRidesCount > 0 ? 'SCHEDULED' : 'NONE'}
|
|
</span>
|
|
}
|
|
subtextLeft={
|
|
<span className="text-on-surface font-medium">
|
|
{metrics.confirmedRidersTotal} riders confirmed
|
|
</span>
|
|
}
|
|
subtextRight={<span>Next: {metrics.nextRideDate ?? 'None'}</span>}
|
|
/>
|
|
|
|
{/* 4. CLUB MILES THIS MONTH */}
|
|
<MetricCard
|
|
label="Club Miles (This Month)"
|
|
value={metrics.clubMilesThisMonth ? metrics.clubMilesThisMonth.toLocaleString() : '--'}
|
|
unit="MI"
|
|
showSparkline={false}
|
|
isUnavailable={!metrics.clubMilesAvailable}
|
|
badge={
|
|
metrics.clubMilesAvailable ? (
|
|
<span className="font-mono text-[10px] text-brand-cyan bg-brand-cyan/10 px-2 py-0.5 rounded-full border border-brand-cyan/30 font-medium">
|
|
Completed Club Rides only
|
|
</span>
|
|
) : (
|
|
<span className="font-mono text-[10px] text-slate-400 bg-surface-container px-2 py-0.5 rounded-full border border-brand-border/60">UNAVAILABLE</span>
|
|
)
|
|
}
|
|
subtextLeft={
|
|
<span className="text-slate-300 font-medium">
|
|
{metrics.completedClubRidesCount} completed Club Rides
|
|
</span>
|
|
}
|
|
subtextRight={<span className="text-slate-400">Eligible rides only</span>}
|
|
disclaimer="Calculated from eligible completed Club Rides. Private rider mileage is not automatically included."
|
|
/>
|
|
</section>
|
|
);
|
|
}
|