ui changes
This commit is contained in:
110
src/components/ds/InsightRow.jsx
Normal file
110
src/components/ds/InsightRow.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import * as React from 'react';
|
||||
import { AlertTriangle, ChevronRight, Info, ShieldAlert, TrendingDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
/**
|
||||
* InsightRow — one item that needs an operator's attention.
|
||||
*
|
||||
* A list of these replaces the usual grid of alert cards. Six cards is a wall;
|
||||
* six rows is a queue you can work through, which is what "Attention Required"
|
||||
* actually is. Severity sits in a left rail so the list can be triaged by
|
||||
* scanning one column.
|
||||
*
|
||||
* `severity: 'critical' | 'warning' | 'info'`
|
||||
*/
|
||||
|
||||
const SEVERITY = {
|
||||
critical: {
|
||||
icon: ShieldAlert,
|
||||
rail: 'bg-destructive',
|
||||
tile: 'bg-destructive-muted text-destructive',
|
||||
badge: 'destructive',
|
||||
label: 'Critical',
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
rail: 'bg-warning',
|
||||
tile: 'bg-warning-muted text-warning',
|
||||
badge: 'warning',
|
||||
label: 'Warning',
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
rail: 'bg-krow-blue',
|
||||
tile: 'bg-krow-blue-tint text-krow-blue',
|
||||
badge: 'info',
|
||||
label: 'Info',
|
||||
},
|
||||
trend: {
|
||||
icon: TrendingDown,
|
||||
rail: 'bg-ink-4',
|
||||
tile: 'bg-surface-sunken text-ink-3',
|
||||
badge: 'neutral',
|
||||
label: 'Trend',
|
||||
},
|
||||
};
|
||||
|
||||
export function InsightRow({
|
||||
severity = 'info',
|
||||
title,
|
||||
detail,
|
||||
/** Short right-aligned figure — a count, an age, a percentage. */
|
||||
metric,
|
||||
metricLabel,
|
||||
/** Entity the item concerns, e.g. a position or candidate name. */
|
||||
entity,
|
||||
onClick,
|
||||
actionLabel = 'Review',
|
||||
className,
|
||||
}) {
|
||||
const s = SEVERITY[severity] || SEVERITY.info;
|
||||
const Comp = onClick ? 'button' : 'div';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group relative flex w-full items-start gap-3 py-3 pl-4 pr-3 text-left transition-colors duration-fast',
|
||||
onClick && 'cursor-pointer hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Severity rail — one column to scan rather than five colours to decode. */}
|
||||
<span className={cn('absolute inset-y-2 left-0 w-0.5 rounded-full', s.rail)} aria-hidden="true" />
|
||||
|
||||
<span className={cn('mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-lg', s.tile)}>
|
||||
<s.icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-body-sm font-semibold text-ink-1">{title}</span>
|
||||
{entity && <Badge variant="neutral" size="sm">{entity}</Badge>}
|
||||
</span>
|
||||
{detail && <span className="mt-0.5 block text-caption leading-relaxed text-ink-3">{detail}</span>}
|
||||
</span>
|
||||
|
||||
{metric != null && (
|
||||
<span className="shrink-0 text-right">
|
||||
<span className="block font-heading text-body font-bold tabular-nums text-ink-1">{metric}</span>
|
||||
{metricLabel && <span className="block text-[10px] text-ink-4">{metricLabel}</span>}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{onClick && (
|
||||
<span className="mt-0.5 flex shrink-0 items-center gap-0.5 text-caption font-medium text-ink-4 transition-colors group-hover:text-krow-blue">
|
||||
<span className="hidden lg:inline">{actionLabel}</span>
|
||||
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="sr-only">{s.label}</span>
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
|
||||
/** The list container — dividers between rows, nothing else. */
|
||||
export function InsightList({ children, className }) {
|
||||
return <div className={cn('divide-y divide-border', className)}>{children}</div>;
|
||||
}
|
||||
113
src/components/ds/MetricStrip.jsx
Normal file
113
src/components/ds/MetricStrip.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import * as React from 'react';
|
||||
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* MetricStrip — a row of operational figures.
|
||||
*
|
||||
* The Admin surface reports six to eight numbers at once. As individual cards
|
||||
* that becomes a wall of boxes competing with the content below it, so this
|
||||
* presents them as one banded strip: hairline dividers instead of borders, one
|
||||
* shared surface, numbers aligned on a baseline. Dense enough for an operations
|
||||
* console, still readable.
|
||||
*
|
||||
* `items: [{ label, value, delta?, deltaLabel?, invertTrend?, sub?, icon?, tone? }]`
|
||||
*
|
||||
* `invertTrend` exists because up is not always good: time-to-hire rising is
|
||||
* bad, and colouring it green would misinform.
|
||||
*/
|
||||
|
||||
const TONE = {
|
||||
default: 'text-ink-1',
|
||||
brand: 'text-krow-blue',
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
risk: 'text-destructive',
|
||||
};
|
||||
|
||||
function Delta({ delta, label, invert }) {
|
||||
if (typeof delta !== 'number') return null;
|
||||
|
||||
const flat = delta === 0;
|
||||
const good = invert ? delta < 0 : delta > 0;
|
||||
const Icon = flat ? Minus : delta > 0 ? ArrowUpRight : ArrowDownRight;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-0.5 text-[11px] font-semibold tabular-nums',
|
||||
flat ? 'text-ink-4' : good ? 'text-success' : 'text-destructive'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" aria-hidden="true" />
|
||||
{flat ? '0%' : `${Math.abs(delta)}%`}
|
||||
{label && <span className="ml-1 font-normal text-ink-4">{label}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricStrip({ items = [], columns, className, loading = false }) {
|
||||
// Column count follows the data by default, so the strip divides evenly
|
||||
// instead of leaving an orphan cell.
|
||||
const count = columns ?? Math.min(items.length, 6);
|
||||
/**
|
||||
* Five and six columns wait for `xl` rather than `lg`.
|
||||
*
|
||||
* The strip lives in the Admin main column, which is narrower than the
|
||||
* viewport whenever the Owliver workspace is open — roughly 400px narrower. A
|
||||
* `lg` breakpoint measures the window and so cheerfully packs six cells into a
|
||||
* 580px column, where every label clips. Two rows of three is the better
|
||||
* answer at that width.
|
||||
*/
|
||||
const gridCols = {
|
||||
2: 'sm:grid-cols-2',
|
||||
3: 'sm:grid-cols-3',
|
||||
4: 'grid-cols-2 sm:grid-cols-4',
|
||||
5: 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-5',
|
||||
6: 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6',
|
||||
}[count] || 'sm:grid-cols-3';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid overflow-hidden rounded-xl border border-border bg-surface',
|
||||
// Hairline dividers rather than gaps: one object, not six.
|
||||
'divide-x divide-y divide-border sm:divide-y-0',
|
||||
gridCols,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="min-w-0 px-4 py-3.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.icon && <item.icon className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />}
|
||||
{/* Wraps rather than truncates. A clipped label is worse than a
|
||||
two-line one, and grid rows stretch together so the strip stays
|
||||
even either way. */}
|
||||
<p className="text-[11px] font-medium uppercase leading-tight tracking-wide text-ink-4">
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-1.5 h-7 w-16 animate-pulse rounded bg-surface-sunken" />
|
||||
) : (
|
||||
<div className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
'font-heading text-title-lg font-bold tabular-nums leading-none',
|
||||
TONE[item.tone] || TONE.default
|
||||
)}
|
||||
>
|
||||
{item.value}
|
||||
</span>
|
||||
<Delta delta={item.delta} label={item.deltaLabel} invert={item.invertTrend} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.sub && <p className="mt-1 truncate text-caption text-ink-3">{item.sub}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -101,6 +101,8 @@ export {
|
||||
|
||||
/* ── Data display ───────────────────────────────────────────────────────── */
|
||||
export { KpiCard, MetricCard } from './KpiCard';
|
||||
export { MetricStrip } from './MetricStrip';
|
||||
export { InsightList, InsightRow } from './InsightRow';
|
||||
export { DataTable } from './DataTable';
|
||||
export { FilterBar } from './FilterBar';
|
||||
export { Timeline } from './Timeline';
|
||||
|
||||
Reference in New Issue
Block a user