update on the user page regardinga the dispatch and order page and the deliveries page
This commit is contained in:
304
src/components/consoleUi.tsx
Normal file
304
src/components/consoleUi.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* @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}`,
|
||||
};
|
||||
Reference in New Issue
Block a user