feat(03-01): add KpiCardMobile, NeedsAttentionStrip, WorkerStatusRow components
- KpiCardMobile: phone-sized KPI card with label/value/caption; tone='attention' adds destructive left border - NeedsAttentionStrip: horizontal-scroll strip of compact attention cards; renders null when items=[] - WorkerStatusRow: 3-cell status row with emerald/amber/destructive status dots, each linking to desktop admin - All three are pure presentational client components; no fetch, no recharts, no new state libraries
This commit is contained in:
parent
24e20c7ae7
commit
bfe9549d02
3 changed files with 145 additions and 0 deletions
39
components/mobile/KpiCardMobile.tsx
Normal file
39
components/mobile/KpiCardMobile.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
/* KpiCardMobile — phase 03 (DASH-01).
|
||||||
|
*
|
||||||
|
* Phone-sized KPI card for the 2×2 dashboard grid. Renders a label,
|
||||||
|
* a large numeric value, and an optional caption. tone="attention"
|
||||||
|
* adds a left-edge destructive border for SLA breaches > 0.
|
||||||
|
*
|
||||||
|
* Pure presentational — no fetch, no state. Parent provides values. */
|
||||||
|
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type KpiTone = 'default' | 'attention';
|
||||||
|
|
||||||
|
interface KpiCardMobileProps {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
caption?: string;
|
||||||
|
tone?: KpiTone;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE_BORDER: Record<KpiTone, string> = {
|
||||||
|
default: 'border-l-transparent',
|
||||||
|
attention: 'border-l-destructive',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function KpiCardMobile({ label, value, caption, tone = 'default' }: KpiCardMobileProps) {
|
||||||
|
const display = typeof value === 'number' ? value.toLocaleString() : value;
|
||||||
|
return (
|
||||||
|
<Card className={cn('h-full border-l-2', TONE_BORDER[tone])}>
|
||||||
|
<CardContent className="p-4 flex flex-col gap-1">
|
||||||
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">{label}</p>
|
||||||
|
<p className="text-3xl font-bold tabular-nums">{display}</p>
|
||||||
|
{caption && <p className="text-xs text-muted-foreground">{caption}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
components/mobile/NeedsAttentionStrip.tsx
Normal file
51
components/mobile/NeedsAttentionStrip.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
/* NeedsAttentionStrip — phase 03 (DASH-02).
|
||||||
|
*
|
||||||
|
* Horizontal-scroll strip of compact attention cards. Each card shows a
|
||||||
|
* count + label and is a next/link to the destination view. The strip
|
||||||
|
* uses native horizontal overflow with snap-x for momentum scroll on
|
||||||
|
* iOS/Android. Renders nothing when items=[]. */
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { AlertTriangle, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface NeedsAttentionItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NeedsAttentionStripProps {
|
||||||
|
items: NeedsAttentionItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NeedsAttentionStrip({ items }: NeedsAttentionStripProps) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||||
|
Needs attention
|
||||||
|
</p>
|
||||||
|
<div className="-mx-4 px-4 flex gap-3 overflow-x-auto snap-x snap-mandatory pb-1">
|
||||||
|
{items.map(item => (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
href={item.href}
|
||||||
|
className="snap-start shrink-0 w-44 rounded-2xl border bg-card p-3 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<AlertTriangle className={`w-4 h-4 ${item.count > 0 ? 'text-destructive' : 'text-muted-foreground'}`} />
|
||||||
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className={`mt-2 text-2xl font-bold tabular-nums ${item.count > 0 ? 'text-destructive' : ''}`}>
|
||||||
|
{item.count}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{item.label}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
components/mobile/WorkerStatusRow.tsx
Normal file
55
components/mobile/WorkerStatusRow.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
/* WorkerStatusRow — phase 03 (DASH-03).
|
||||||
|
*
|
||||||
|
* Compact 3-cell read-only status row showing analyzer worker, RMM worker,
|
||||||
|
* and backup success rate. Each cell is a next/link to the corresponding
|
||||||
|
* desktop admin page. status='ok' = emerald dot, 'warn' = amber, 'down' =
|
||||||
|
* destructive. */
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
|
export type WorkerStatus = 'ok' | 'warn' | 'down';
|
||||||
|
|
||||||
|
export interface WorkerStatusEntry {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
status: WorkerStatus;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkerStatusRowProps {
|
||||||
|
entries: WorkerStatusEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOT_COLOR: Record<WorkerStatus, string> = {
|
||||||
|
ok: 'bg-emerald-500',
|
||||||
|
warn: 'bg-amber-500',
|
||||||
|
down: 'bg-destructive',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkerStatusRow({ entries }: WorkerStatusRowProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||||
|
Workers & backups
|
||||||
|
</p>
|
||||||
|
<div className="rounded-2xl border divide-y overflow-hidden">
|
||||||
|
{entries.map(e => (
|
||||||
|
<Link
|
||||||
|
key={e.id}
|
||||||
|
href={e.href}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`inline-block w-2 h-2 rounded-full shrink-0 ${DOT_COLOR[e.status]}`} aria-hidden="true" />
|
||||||
|
<span className="text-sm font-medium flex-1">{e.label}</span>
|
||||||
|
<span className="text-sm tabular-nums text-muted-foreground">{e.value}</span>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue