updates on the design on the kpi cards

This commit is contained in:
2026-08-20 16:53:34 +05:30
parent ce32465a6f
commit 1bf89adb6a
10 changed files with 973 additions and 274 deletions

View File

@@ -6,40 +6,83 @@ import { Heading } from '@astryxdesign/core/Heading';
import { Text } from '@astryxdesign/core/Text';
import { StatusDot } from '@astryxdesign/core/StatusDot';
// ==============================|| PAGE HEADER (Doormile-style clean title) ||============================== //
// A plain, un-boxed page title + optional live subtitle + an action slot
// (zone selector, primary button, etc.). Replaces the heavy boxed/gradient
// header so every operator page opens with a calm, corporate heading.
//
// Astryx: Heading level={1} resolves to --text-heading-1-size (1.5rem), the
// same size the old MUI `variant="h3"` rendered — the visual weight of every
// page title is unchanged by the migration. The old responsive
// `direction={{ xs: 'column', sm: 'row' }}` is now wrap="wrap" on a single
// HStack: the action slot drops to its own line once it can't fit, which is
// the behaviour that breakpoint was approximating.
import { DT } from 'themes/dt/tokens';
export default function PageHeader({ title, subtitle, live = false, action }) {
// ==============================|| PAGE HEADER ||============================== //
//
// A plain, un-boxed page title + optional live indicator + an action slot
// (zone selector, primary button, etc.), so every operator page opens with a
// calm, corporate heading.
//
// The STRUCTURE follows KROW's page frame; the colours and the type are the
// console's own and are unchanged — `Heading level={1}` and `Text
// type="supporting"` resolve to exactly the same tokens they did before.
//
// Title · meta · ● Live [ actions ]
// subtitle
// ──────────────────────────────────────────────────────────────
//
// Two things the KROW work changed:
//
// - The bottom hairline. KROW closes every page header with a rule, and it
// is what makes the header read as a REGION of the page rather than as
// the first item in the content stack — without it, a page header and a
// section heading look identical and the page has no top.
//
// - `live` moved from beside the subtitle up onto the title row. It was
// putting the page's only motion cue on its quietest line; a live
// indicator belongs where people look for it. It is still the same
// pulsing `StatusDot variant="success"` — nothing about its colour moved.
//
// `meta` is new and optional: a real count that belongs beside the name
// ("1,248 orders"), with tabular figures so it doesn't jitter as it updates.
//
// `action` keeps its name and still wraps to its own line once it can't fit,
// which is the behaviour the old responsive `direction={{ xs: 'column', sm:
// 'row' }}` was approximating.
export default function PageHeader({ title, subtitle, meta, live = false, action }) {
return (
<HStack justify="between" vAlign="center" wrap="wrap" gap={2} paddingBlock={1}>
<VStack gap={0.5} padding={0} minWidth={0}>
<Heading level={1} maxLines={1}>
{title}
</Heading>
{subtitle && (
<HStack gap={1} vAlign="center">
<VStack
gap={0}
padding={0}
style={{
paddingBottom: 16,
// The existing hairline at 50% alpha — a page header's rule should sit
// under the table borders it introduces, not match them.
borderBottom: `1px solid ${DT.borderSubtle}80`
}}
>
<HStack justify="between" vAlign="center" wrap="wrap" gap={2} paddingBlock={1}>
{/* minWidth in `style` — VStack has no such prop (see StatCard). */}
<VStack gap={0.5} padding={0} style={{ minWidth: 0 }}>
<HStack gap={2} vAlign="center" wrap="wrap">
<Heading level={1} maxLines={1}>
{title}
</Heading>
{meta != null && (
<Text type="supporting" hasTabularNumbers>
{meta}
</Text>
)}
{live && <StatusDot variant="success" label="Live" isPulsing />}
<Text type="supporting">{subtitle}</Text>
</HStack>
{subtitle && <Text type="supporting">{subtitle}</Text>}
</VStack>
{action && (
<HStack gap={1.5} vAlign="center" wrap="wrap" style={{ flexShrink: 0 }}>
{action}
</HStack>
)}
</VStack>
{action && <HStack gap={1.5}>{action}</HStack>}
</HStack>
</HStack>
</VStack>
);
}
PageHeader.propTypes = {
title: PropTypes.node,
subtitle: PropTypes.node,
meta: PropTypes.node,
live: PropTypes.bool,
action: PropTypes.node
};

View File

@@ -62,7 +62,7 @@ const FILL_CLASS = 'dt-page-fill';
// shell (e.g. Create Order's `.co-root` token block). It used to be dropped
// silently, which made every page-scoped rule fail to match with no error —
// the styles simply never applied.
export function PageShell({ children, gap = 4, fill = false, className }) {
export function PageShell({ children, gap = 3, fill = false, className }) {
return (
<>
{fill && (
@@ -106,8 +106,12 @@ PageShell.propTypes = {
//
// `max` caps the column count so 3 cards don't each become 400px wide on an
// ultrawide monitor; it defaults to the number of children.
//
// minWidth is 200, fitting four across smoothly while keeping labels single-line.
//
// gap is 2.5 (10px) — tight, cohesive summary strip.
// ---------------------------------------------------------------------------
export function KpiGrid({ children, minWidth = 180, gap = 3, max }) {
export function KpiGrid({ children, minWidth = 200, gap = 2.5, max }) {
const count = Array.isArray(children) ? children.filter(Boolean).length : 1;
return (
<Grid columns={{ minWidth, max: max || Math.max(count, 1), repeat: 'fit' }} gap={gap}>

View File

@@ -1,101 +1,208 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text';
import { Skeleton } from '@astryxdesign/core/Skeleton';
// ==============================|| STAT / KPI CARD (Doormile-style Modern) ||============================== //
// Modern, space-efficient KPI tile:
// - Left-aligned icon tile directly grouped with the metric stack to eliminate internal dead space.
// - Soft ambient accent gradient tint based on semantic status color.
// - Crisp typography and tight vertical footprint (~56px height) saving ~30% page height.
// - Micro-hover interaction (subtle lift + shadow depth).
//
// `icon` is a rendered node, e.g. icon={<MdLocalShipping size={20} />}.
// `color` is a hex accent (defaults to brand black); `caption` is optional supporting text.
import { DT, STATUS } from 'themes/dt/tokens';
// ==============================|| STAT / KPI CARD ||============================== //
//
// Ultra-compact, dense, space-efficient enterprise KPI component.
// Shared across Deliveries, Orders, Pricing, Tenants, Riders, Customers, Hubs,
// Vehicles, Tripsheets, Exceptions, App Users, Reports and Dispatch.
//
// ---------------------------------------------------------------------------
// ULTRA-COMPACT HORIZONTAL COMPOSITION (~48px target height)
// ---------------------------------------------------------------------------
// 1. Icon chip: 26px × 26px with 6px radius and subtle 7% tint (`${accent}12`).
// Glyph size normalized to 14px.
// 2. Value: 18px bold (`lineHeight: 1`, `fontVariantNumeric: 'tabular-nums'`).
// 3. Label: 12px / 500 in secondary ink (`#64748b`), aligned horizontally on
// the same baseline beside the value for maximum space efficiency and instant
// (<1s) visual scanning.
// 4. Tight elevation, flat white surface, 8px rounded corners, 7px 12px padding.
// 5. Interactive/clickable hover support with smooth 150ms transition. Static
// cards remain clean without hover distraction.
// ---------------------------------------------------------------------------
const MIN_HEIGHT = 48;
const VARIANTS = {
neutral: STATUS.muted,
brand: DT.brand,
blue: STATUS.info,
green: STATUS.delivered,
amber: STATUS.pending,
red: STATUS.cancelled
};
export default function StatCard({
title,
value,
icon,
color,
variant = 'brand',
caption,
loading = false,
onClick,
clickable,
className = '',
style = {}
}) {
const accent = color || VARIANTS[variant] || DT.brand;
const isClickable = Boolean(onClick || clickable);
export default function StatCard({ title, value, icon, color = '#000000', caption, loading = false }) {
return (
<Card
height="100%"
padding={1.5}
padding={0}
elevation="none"
className="dt-stat-card"
className={['dt-stat-card', isClickable ? 'dt-stat-card-clickable' : '', className].filter(Boolean).join(' ')}
data-clickable={isClickable ? 'true' : undefined}
onClick={onClick}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
onKeyDown={
isClickable && onClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick(e);
}
}
: undefined
}
style={{
position: 'relative',
background: `linear-gradient(135deg, ${color}0A 0%, rgba(255, 255, 255, 0.98) 45%, #ffffff 100%)`,
border: `1px solid ${color}24`,
borderRadius: 'var(--radius-container, 6px)',
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.04)',
cursor: 'default'
display: 'flex',
alignItems: 'center',
minHeight: MIN_HEIGHT,
maxHeight: 56,
padding: '7px 12px',
background: DT.surface,
border: `1px solid ${DT.borderSubtle}`,
borderRadius: 8,
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.03)',
cursor: isClickable ? 'pointer' : 'default',
userSelect: isClickable ? 'none' : 'auto',
boxSizing: 'border-box',
overflow: 'hidden',
width: '100%',
...style
}}
>
<HStack vAlign="center" gap={2} style={{ width: '100%', minHeight: 40 }}>
<div
className="dt-stat-content"
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
width: '100%',
minWidth: 0
}}
>
{icon && (
<Center
width={34}
height={34}
<div
className="dt-stat-icon"
style={{
flexShrink: 0,
borderRadius: 'var(--radius-element, 6px)',
backgroundColor: `${color}14`,
color,
border: `1px solid ${color}2A`
width: 26,
height: 26,
minWidth: 26,
minHeight: 26,
borderRadius: 6,
backgroundColor: `${accent}12`,
color: accent,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}
>
{icon}
</Center>
</div>
)}
<VStack gap={0} padding={0} minWidth={0} style={{ flex: 1 }}>
<Text
type="supporting"
size="xsm"
weight="semibold"
maxLines={1}
<div
className="dt-stat-body"
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
minWidth: 0,
flex: 1
}}
>
<div
className="dt-stat-primary"
style={{
fontSize: '11.5px',
color: 'var(--color-text-supporting, #64748b)',
letterSpacing: '0.01em',
lineHeight: 1.2
display: 'flex',
alignItems: 'baseline',
gap: '6px',
minWidth: 0,
flexWrap: 'wrap'
}}
>
{title}
</Text>
{loading ? (
<Skeleton width={48} height={20} radius={2} style={{ marginTop: 2 }} />
) : (
<Text
size="lg"
weight="bold"
display="block"
maxLines={1}
hasTabularNumbers
{loading ? (
<Skeleton width={36} height={18} radius={DT.radiusSm} />
) : (
<span
className="dt-stat-value"
style={{
fontFamily: 'var(--font-family-heading, Sora), -apple-system, BlinkMacSystemFont, sans-serif',
fontSize: '18px',
fontWeight: 700,
lineHeight: 1,
letterSpacing: '-0.02em',
color: 'var(--color-text-primary, #0f172a)',
fontVariantNumeric: 'tabular-nums',
whiteSpace: 'nowrap'
}}
>
{value}
</span>
)}
<span
className="dt-stat-label"
title={typeof title === 'string' ? title : undefined}
style={{
fontSize: '19px',
lineHeight: 1.2,
color: 'var(--color-text-primary, #0f172a)',
marginTop: 1
fontFamily: 'var(--font-family-body, Inter), -apple-system, BlinkMacSystemFont, sans-serif',
fontSize: '12px',
fontWeight: 500,
lineHeight: 1,
color: DT.textSecondary,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: '1 1 auto',
minWidth: 0
}}
>
{value}
</Text>
)}
{title}
</span>
</div>
{caption && (
<Text
type="supporting"
size="xsm"
display="block"
style={{ fontSize: '11px', marginTop: 1 }}
<span
className="dt-stat-caption"
title={typeof caption === 'string' ? caption : undefined}
style={{
fontFamily: 'var(--font-family-body, Inter), -apple-system, BlinkMacSystemFont, sans-serif',
fontSize: '10.5px',
lineHeight: '12px',
color: DT.textMuted,
marginTop: 1,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
>
{caption}
</Text>
</span>
)}
</VStack>
</HStack>
</div>
</div>
</Card>
);
}
@@ -104,7 +211,14 @@ StatCard.propTypes = {
title: PropTypes.node,
value: PropTypes.node,
icon: PropTypes.node,
/** Raw accent hex. Takes precedence over `variant`. */
color: PropTypes.string,
/** Semantic alias over the same palette, for call sites not importing STATUS. */
variant: PropTypes.oneOf(['neutral', 'brand', 'blue', 'green', 'amber', 'red']),
caption: PropTypes.node,
loading: PropTypes.bool
loading: PropTypes.bool,
onClick: PropTypes.func,
clickable: PropTypes.bool,
className: PropTypes.string,
style: PropTypes.object
};