305 lines
13 KiB
TypeScript
305 lines
13 KiB
TypeScript
/**
|
|
* @license
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* Console UI kit — the shared visual language ported from the operations console
|
|
* (nearle_console). Orders / Deliveries / Delivery-Reports all render against this
|
|
* so they match the source design exactly: brand purple #662582, the tint/soft/
|
|
* ring/edge alpha scale, pill filters + tabs, KPI cards with a gradient top-bar,
|
|
* status chips, metric pills, stamp cells, gradient headers and total bars.
|
|
*
|
|
* Per the design's own model, accent colours are data-driven (per status / per
|
|
* card), so colour-bearing bits use inline styles (the natural translation of the
|
|
* source's MUI `sx`) while layout/spacing use Tailwind.
|
|
*/
|
|
|
|
import React from 'react';
|
|
|
|
// ── Design tokens ────────────────────────────────────────────────────────────────
|
|
export const BRAND = '#662582';
|
|
export const BRAND_LIGHT = '#9255AB';
|
|
export const TEXT = '#0f172a';
|
|
export const TEXT_2 = '#64748b';
|
|
export const TEXT_3 = '#94a3b8';
|
|
export const BORDER = '#e2e8f0';
|
|
export const DIVIDER = '#f1f5f9';
|
|
export const SURFACE_ALT = '#f8fafc';
|
|
export const SHADOW_MD = '0 8px 24px rgba(15, 23, 42, 0.08)';
|
|
export const SHADOW_SOFT = '0 14px 40px rgba(15, 23, 42, 0.10)';
|
|
export const SHADOW_POP = '0 18px 50px rgba(15, 23, 42, 0.18)';
|
|
|
|
/** Alpha helpers — append #RRGGBBAA suffixes (08≈3%, 18≈9%, 26≈15%, 55≈33%). */
|
|
export const tint = (c: string) => `${c}08`;
|
|
export const soft = (c: string) => `${c}18`;
|
|
export const ring = (c: string) => `${c}26`;
|
|
export const edge = (c: string) => `${c}55`;
|
|
|
|
// ── Status colour maps ───────────────────────────────────────────────────────────
|
|
/** Order lifecycle (orders board). */
|
|
export const ORDER_STATUS: Record<string, string> = {
|
|
created: '#0ea5e9',
|
|
pending: '#f59e0b',
|
|
processing: '#0ea5e9',
|
|
modified: '#06b6d4',
|
|
confirmed: '#10b981',
|
|
accepted: '#6366f1',
|
|
ready: '#6366f1',
|
|
delivered: '#10b981',
|
|
cancelled: '#ef4444',
|
|
};
|
|
/** Delivery lifecycle (STATUS_META). */
|
|
export const DELIVERY_STATUS: Record<string, string> = {
|
|
pending: '#f59e0b',
|
|
accepted: '#6366f1',
|
|
arrived: '#06b6d4',
|
|
picked: '#8b5cf6',
|
|
active: '#14b8a6',
|
|
skipped: '#f97316',
|
|
delivered: '#10b981',
|
|
cancelled: '#ef4444',
|
|
};
|
|
export const statusColor = (map: Record<string, string>, s: string) => map[s.toLowerCase()] || TEXT_2;
|
|
|
|
// ── Gradient header ──────────────────────────────────────────────────────────────
|
|
export function GradientHeader({
|
|
title,
|
|
subtitle,
|
|
status,
|
|
right,
|
|
}: {
|
|
title: string;
|
|
subtitle?: string;
|
|
status?: React.ReactNode;
|
|
right?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div
|
|
className="rounded-2xl border p-4 sm:p-5 mb-4"
|
|
style={{ borderColor: BORDER, background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`, boxShadow: SHADOW_MD }}
|
|
>
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<span
|
|
className="hidden sm:flex shrink-0 items-center justify-center rounded-2xl text-white"
|
|
style={{ width: 46, height: 46, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
|
|
>
|
|
<BrandMark />
|
|
</span>
|
|
<div className="min-w-0">
|
|
<h1 className="font-extrabold tracking-tight leading-tight text-[1.4rem] md:text-[1.75rem]" style={{ color: TEXT }}>
|
|
{title}
|
|
</h1>
|
|
{subtitle && <p className="text-xs mt-0.5" style={{ color: TEXT_2 }}>{subtitle}</p>}
|
|
{status && <div className="mt-1">{status}</div>}
|
|
</div>
|
|
</div>
|
|
{right && <div className="shrink-0">{right}</div>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BrandMark() {
|
|
return (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M3 7h13l5 5-5 5H3z" />
|
|
<circle cx="8" cy="12" r="1.5" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/** Live / loading / error status line used under the header title. */
|
|
export function LiveStatus({ state, label }: { state: 'live' | 'loading' | 'error'; label: string }) {
|
|
const color = state === 'error' ? '#ef4444' : state === 'loading' ? '#94a3b8' : '#10b981';
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold" style={{ color: TEXT_2 }}>
|
|
<span
|
|
className={`rounded-full ${state === 'loading' ? 'animate-pulse' : ''}`}
|
|
style={{ width: 8, height: 8, background: color, boxShadow: state === 'live' ? `0 0 0 4px ${color}2e` : undefined }}
|
|
/>
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ── KPI cards ────────────────────────────────────────────────────────────────────
|
|
export interface KpiItem {
|
|
label: string;
|
|
value: string;
|
|
color: string;
|
|
icon: React.ReactNode;
|
|
badge?: string;
|
|
}
|
|
export function KpiStrip({ items, loading }: { items: KpiItem[]; loading?: boolean }) {
|
|
return (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4">
|
|
{items.map((it) => (
|
|
<div
|
|
key={it.label}
|
|
className="relative overflow-hidden rounded-2xl border bg-white p-3.5 sm:p-5 transition-all duration-200 hover:-translate-y-0.5"
|
|
style={{ borderColor: BORDER }}
|
|
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = SHADOW_MD)}
|
|
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
|
|
>
|
|
<div className="absolute top-0 left-0 right-0" style={{ height: 3, background: `linear-gradient(90deg, ${it.color} 0%, ${soft(it.color)} 100%)` }} />
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<p className="text-[10px] sm:text-[11px] font-bold uppercase tracking-wide truncate" style={{ color: TEXT_2, letterSpacing: 0.4 }}>
|
|
{it.label}
|
|
</p>
|
|
<p className="font-extrabold leading-none mt-1.5 text-[1.4rem] sm:text-[1.6rem]" style={{ color: TEXT }}>
|
|
{loading ? '—' : it.value}
|
|
</p>
|
|
{it.badge && (
|
|
<span className="inline-flex items-center mt-1.5 rounded-full font-extrabold" style={{ padding: '1px 8px', fontSize: 10.5, background: soft(it.color), color: it.color }}>
|
|
{it.badge}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span
|
|
className="shrink-0 rounded-full flex items-center justify-center"
|
|
style={{ width: 46, height: 46, background: soft(it.color), color: it.color, boxShadow: `inset 0 0 0 1px ${edge(it.color)}` }}
|
|
>
|
|
{it.icon}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Pill (filter chip / tab) ─────────────────────────────────────────────────────
|
|
interface PillProps {
|
|
active: boolean;
|
|
color: string;
|
|
onClick?: () => void;
|
|
title?: string;
|
|
children: React.ReactNode;
|
|
count?: number | string;
|
|
}
|
|
export function Pill({ active, color, onClick, title, children, count }: PillProps) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
title={title}
|
|
className="inline-flex items-center gap-1.5 rounded-full font-bold whitespace-nowrap transition-all duration-150 cursor-pointer shrink-0"
|
|
style={
|
|
active
|
|
? { padding: '5px 12px', fontSize: 12.5, background: color, color: '#fff', border: `1.5px solid ${color}`, boxShadow: `0 6px 18px ${ring(color)}` }
|
|
: { padding: '5px 12px', fontSize: 12.5, background: tint(color), color, border: `1.5px solid ${edge(color)}` }
|
|
}
|
|
>
|
|
{children}
|
|
{count != null && (
|
|
<span
|
|
className="inline-flex items-center justify-center rounded-full font-extrabold"
|
|
style={
|
|
active
|
|
? { minWidth: 22, height: 18, padding: '0 6px', fontSize: 10.5, background: 'rgba(255,255,255,0.22)', color: '#fff' }
|
|
: { minWidth: 22, height: 18, padding: '0 6px', fontSize: 10.5, background: '#fff', color, border: `1px solid ${edge(color)}` }
|
|
}
|
|
>
|
|
{count}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ── Status chip (table cell) ─────────────────────────────────────────────────────
|
|
export function StatusChip({ label, color }: { label: string; color: string }) {
|
|
return (
|
|
<span
|
|
className="inline-flex items-center gap-1 rounded-full font-extrabold uppercase whitespace-nowrap"
|
|
style={{ padding: '3px 9px', fontSize: 10.5, background: tint(color), border: `1px solid ${edge(color)}`, color, letterSpacing: 0.3 }}
|
|
>
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ── Metric pill (km / amount / count cells) ──────────────────────────────────────
|
|
export function MetricPill({ color, children, minWidth }: { color: string; children: React.ReactNode; minWidth?: number }) {
|
|
return (
|
|
<span
|
|
className="inline-flex items-center justify-center gap-1 rounded-full font-extrabold whitespace-nowrap"
|
|
style={{ padding: '2px 9px', fontSize: 11, background: tint(color), border: `1px solid ${edge(color)}`, color, minWidth }}
|
|
>
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ── Stamp cell (date over time) ──────────────────────────────────────────────────
|
|
export function StampCell({ date, time }: { date?: string; time?: string }) {
|
|
if (!date && !time) return <span style={{ color: TEXT_3 }}>—</span>;
|
|
return (
|
|
<div className="leading-tight">
|
|
{date && <div className="text-[11px] font-semibold whitespace-nowrap" style={{ color: TEXT_2 }}>{date}</div>}
|
|
{time && <div className="text-[11px] font-extrabold whitespace-nowrap" style={{ color: TEXT }}>{time}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Search pill ──────────────────────────────────────────────────────────────────
|
|
export function SearchPill({ value, onChange, placeholder, color = BRAND }: { value: string; onChange: (v: string) => void; placeholder?: string; color?: string }) {
|
|
return (
|
|
<div className="relative w-full">
|
|
<svg className="absolute left-3 top-1/2 -translate-y-1/2" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round">
|
|
<circle cx="11" cy="11" r="7" />
|
|
<path d="m21 21-4.3-4.3" />
|
|
</svg>
|
|
<input
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
className="block w-full rounded-full outline-none font-medium transition-all box-border"
|
|
style={{ height: 38, paddingLeft: 32, paddingRight: value ? 30 : 14, fontSize: 12.5, background: tint(color), border: `1.5px solid ${edge(color)}`, color: TEXT }}
|
|
onFocus={(e) => { e.currentTarget.style.borderColor = color; e.currentTarget.style.boxShadow = `0 0 0 3px ${ring(color)}`; }}
|
|
onBlur={(e) => { e.currentTarget.style.borderColor = edge(color); e.currentTarget.style.boxShadow = 'none'; }}
|
|
/>
|
|
{value && (
|
|
<button onClick={() => onChange('')} className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer" style={{ color: TEXT_3 }} title="Clear">
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Card shells & table head cell ────────────────────────────────────────────────
|
|
export function Card({ children, className = '', flush }: { children: React.ReactNode; className?: string; flush?: 'top' }) {
|
|
return (
|
|
<div
|
|
className={`bg-white border ${flush === 'top' ? 'rounded-b-2xl' : 'rounded-2xl'} ${className}`}
|
|
style={{ borderColor: BORDER }}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Filter/tab bar paper that visually joins the table below it (flat bottom). */
|
|
export function FilterBar({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
|
return (
|
|
<div className={`bg-white border rounded-2xl p-3 sm:p-4 ${className}`} style={{ borderColor: BORDER, boxShadow: SHADOW_MD }}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const TH_STYLE: React.CSSProperties = {
|
|
background: SURFACE_ALT,
|
|
color: TEXT_2,
|
|
fontSize: 10.5,
|
|
fontWeight: 800,
|
|
letterSpacing: 0.6,
|
|
textTransform: 'uppercase',
|
|
whiteSpace: 'nowrap',
|
|
borderBottom: `1px solid ${BORDER}`,
|
|
};
|