Files
daily_merchant_web/src/components/consoleUi.tsx

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}`,
};