weekend update
This commit is contained in:
@@ -27,7 +27,7 @@ import {
|
|||||||
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge,
|
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge,
|
||||||
} from './consoleUi';
|
} from './consoleUi';
|
||||||
|
|
||||||
interface DeliveriesViewProps { searchQuery?: string; locationid?: number; tenantId?: number; }
|
interface DeliveriesViewProps { searchQuery?: string; locationid?: number; tenantId?: number; headerTabs?: React.ReactNode; }
|
||||||
|
|
||||||
type DeliveryStatus = 'pending' | 'accepted' | 'arrived' | 'picked' | 'active' | 'skipped' | 'delivered' | 'cancelled';
|
type DeliveryStatus = 'pending' | 'accepted' | 'arrived' | 'picked' | 'active' | 'skipped' | 'delivered' | 'cancelled';
|
||||||
const STATUS_TABS: Array<{ key: DeliveryStatus; label: string }> = [
|
const STATUS_TABS: Array<{ key: DeliveryStatus; label: string }> = [
|
||||||
@@ -57,7 +57,7 @@ function inBatch(r: Row, b: BatchId): boolean {
|
|||||||
return h >= 16 && h < 19;
|
return h >= 16 && h < 19;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DeliveriesView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID }: DeliveriesViewProps) {
|
export default function DeliveriesView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID, headerTabs }: DeliveriesViewProps) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); };
|
const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); };
|
||||||
@@ -66,6 +66,7 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId
|
|||||||
const [todate, setTodate] = useState<string>(ymd(today));
|
const [todate, setTodate] = useState<string>(ymd(today));
|
||||||
const presets = [
|
const presets = [
|
||||||
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
|
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
|
||||||
|
{ key: 'yesterday', label: 'Yesterday', from: dayOffset(1), to: dayOffset(1) },
|
||||||
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) },
|
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) },
|
||||||
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: dayAhead(7) },
|
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: dayAhead(7) },
|
||||||
];
|
];
|
||||||
@@ -122,9 +123,12 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId
|
|||||||
: <LiveStatus state="live" label={`Live · ${batchRows.length} in this wave · ${activeFleet} riders on duty`} />
|
: <LiveStatus state="live" label={`Live · ${batchRows.length} in this wave · ${activeFleet} riders on duty`} />
|
||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{headerTabs}
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full font-extrabold" style={{ padding: '6px 12px', fontSize: 12, background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}`, color: BRAND }}>
|
<span className="inline-flex items-center gap-1.5 rounded-full font-extrabold" style={{ padding: '6px 12px', fontSize: 12, background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}`, color: BRAND }}>
|
||||||
<MapPin size={13} /> Coimbatore
|
<MapPin size={13} /> Coimbatore
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
61
src/components/DispatchHubView.tsx
Normal file
61
src/components/DispatchHubView.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Route, ShoppingBag, Truck } from 'lucide-react';
|
||||||
|
import DispatchView from './DispatchView';
|
||||||
|
import OrdersView from './OrdersView';
|
||||||
|
import DeliveriesView from './DeliveriesView';
|
||||||
|
|
||||||
|
interface DispatchHubViewProps {
|
||||||
|
locationid?: number;
|
||||||
|
tenantId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DispatchHubView({ locationid, tenantId }: DispatchHubViewProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<'map' | 'orders' | 'deliveries'>('map');
|
||||||
|
|
||||||
|
const renderTabs = () => (
|
||||||
|
<div className="flex items-center !p-1 bg-slate-100/80 !rounded-lg border border-slate-200/80 shadow-inner">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('map')}
|
||||||
|
className={`flex items-center !gap-1.5 !px-3 !py-1.5 text-[11px] font-extrabold !rounded-md transition-all duration-300 uppercase tracking-wide ${
|
||||||
|
activeTab === 'map' ? 'bg-white text-[#581c87] shadow-sm ring-1 ring-black/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Route size={13} className={activeTab === 'map' ? 'text-[#581c87]' : 'text-slate-400'} /> Map
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('orders')}
|
||||||
|
className={`flex items-center !gap-1.5 !px-3 !py-1.5 text-[11px] font-extrabold !rounded-md transition-all duration-300 uppercase tracking-wide ${
|
||||||
|
activeTab === 'orders' ? 'bg-white text-[#581c87] shadow-sm ring-1 ring-black/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShoppingBag size={13} className={activeTab === 'orders' ? 'text-[#581c87]' : 'text-slate-400'} /> Orders
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('deliveries')}
|
||||||
|
className={`flex items-center !gap-1.5 !px-3 !py-1.5 text-[11px] font-extrabold !rounded-md transition-all duration-300 uppercase tracking-wide ${
|
||||||
|
activeTab === 'deliveries' ? 'bg-white text-[#581c87] shadow-sm ring-1 ring-black/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Truck size={13} className={activeTab === 'deliveries' ? 'text-[#581c87]' : 'text-slate-400'} /> Deliveries
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-[calc(100vh-80px)] w-full">
|
||||||
|
{/* Content Area - Header removed as requested, tabs passed down */}
|
||||||
|
<div className={`flex-1 min-h-0 bg-[#f8fafc] ${activeTab !== 'map' ? 'overflow-y-auto w-full p-4 md:p-8 relative' : 'relative'}`}>
|
||||||
|
{activeTab === 'map' && <DispatchView locationid={locationid} tenantId={tenantId} headerTabs={renderTabs()} />}
|
||||||
|
{activeTab === 'orders' && <OrdersView locationid={locationid} tenantId={tenantId} headerTabs={renderTabs()} />}
|
||||||
|
{activeTab === 'deliveries' && <DeliveriesView locationid={locationid} tenantId={tenantId} headerTabs={renderTabs()} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -87,13 +87,11 @@ function pickupLatLon(r: Row): [number, number] | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── View modes (match #strat-row tabs) ───────────────────────────────────────────
|
// ── View modes (match #strat-row tabs) ───────────────────────────────────────────
|
||||||
type ViewMode = 'kitchens' | 'zones' | 'riders' | 'orders' | 'deliveries';
|
type ViewMode = 'kitchens' | 'zones' | 'riders';
|
||||||
const VIEW_TABS: Array<{ id: ViewMode; label: string; icon: typeof MapIcon }> = [
|
const VIEW_TABS: Array<{ id: ViewMode; label: string; icon: typeof MapIcon }> = [
|
||||||
{ id: 'kitchens', label: 'By Location', icon: MapPin },
|
{ id: 'kitchens', label: 'By Location', icon: MapPin },
|
||||||
{ id: 'zones', label: 'By Zone', icon: MapIcon },
|
{ id: 'zones', label: 'By Zone', icon: MapIcon },
|
||||||
{ id: 'riders', label: 'By Rider', icon: Bike },
|
{ id: 'riders', label: 'By Rider', icon: Bike },
|
||||||
{ id: 'orders', label: 'By Orders', icon: ShoppingBag },
|
|
||||||
{ id: 'deliveries', label: 'By Deliveries', icon: Truck },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Group {
|
interface Group {
|
||||||
@@ -112,12 +110,13 @@ interface Group {
|
|||||||
interface DispatchViewProps {
|
interface DispatchViewProps {
|
||||||
locationid?: number;
|
locationid?: number;
|
||||||
tenantId?: number;
|
tenantId?: number;
|
||||||
|
headerTabs?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID }: DispatchViewProps) {
|
export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID, headerTabs }: DispatchViewProps) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const [date, setDate] = useState<string>(ymd(today));
|
const [date, setDate] = useState<string>(ymd(today));
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('riders');
|
const [viewMode, setViewMode] = useState<ViewMode>('riders');
|
||||||
@@ -164,16 +163,6 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID }
|
|||||||
const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || 'Pickup';
|
const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || 'Pickup';
|
||||||
return { id: name.toLowerCase(), name };
|
return { id: name.toLowerCase(), name };
|
||||||
}
|
}
|
||||||
if (viewMode === 'orders') {
|
|
||||||
// Bucket by ORDER status (created / pending / processing / delivered / cancelled).
|
|
||||||
const s = fstr(r.orderstatus).toLowerCase() || 'unknown';
|
|
||||||
return { id: `o:${s}`, name: titleCase(s) };
|
|
||||||
}
|
|
||||||
if (viewMode === 'deliveries') {
|
|
||||||
// Bucket by DELIVERY/dispatch status (falls back to order status, then unassigned).
|
|
||||||
const s = (fstr(r.deliverystatus) || fstr(r.orderstatus)).toLowerCase() || 'unassigned';
|
|
||||||
return { id: `d:${s}`, name: titleCase(s) };
|
|
||||||
}
|
|
||||||
const name = fstr(r.deliverysuburb) || fstr(r.zone_name) || 'Unzoned';
|
const name = fstr(r.deliverysuburb) || fstr(r.zone_name) || 'Unzoned';
|
||||||
return { id: name.toLowerCase(), name };
|
return { id: name.toLowerCase(), name };
|
||||||
};
|
};
|
||||||
@@ -294,16 +283,19 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID }
|
|||||||
<div className="dispatch-container embedded">
|
<div className="dispatch-container embedded">
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div id="hdr">
|
<div id="hdr">
|
||||||
<div className="logo">
|
<div className="logo flex items-center gap-5">
|
||||||
<div className="logo-badge">D</div>
|
<div className="flex items-center gap-3">
|
||||||
<div className="logo-name">Dispatch</div>
|
<div className="logo-badge shadow-sm">D</div>
|
||||||
<div className="logo-city-wrap">
|
<div className="logo-name tracking-tight">Dispatch</div>
|
||||||
|
<div className="logo-city-wrap ml-1">
|
||||||
<span className="logo-city" style={{ cursor: 'default' }}>
|
<span className="logo-city" style={{ cursor: 'default' }}>
|
||||||
<MapPin size={13} />
|
<MapPin size={13} className="text-purple-500" />
|
||||||
<span className="logo-city-text">Coimbatore</span>
|
<span className="logo-city-text font-semibold text-slate-700">Coimbatore</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{headerTabs}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="hdr-stats">
|
<div className="hdr-stats">
|
||||||
{deliveriesQ.isLoading ? (
|
{deliveriesQ.isLoading ? (
|
||||||
@@ -430,7 +422,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID }
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="ph">
|
<div className="ph">
|
||||||
{viewMode === 'riders' ? 'Riders' : viewMode === 'kitchens' ? 'Pickup points' : viewMode === 'orders' ? 'Order statuses' : viewMode === 'deliveries' ? 'Delivery statuses' : 'Zones'} ({groups.length})
|
{viewMode === 'riders' ? 'Riders' : viewMode === 'kitchens' ? 'Pickup points' : 'Zones'} ({groups.length})
|
||||||
</div>
|
</div>
|
||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
<React.Fragment key={g.id}>
|
<React.Fragment key={g.id}>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface OrdersViewProps {
|
|||||||
locationid?: number;
|
locationid?: number;
|
||||||
/** Merchant tenant to scope to; defaults to the shared constant. */
|
/** Merchant tenant to scope to; defaults to the shared constant. */
|
||||||
tenantId?: number;
|
tenantId?: number;
|
||||||
|
headerTabs?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatusKey = 'created' | 'pending' | 'processing' | 'delivered' | 'cancelled';
|
type StatusKey = 'created' | 'pending' | 'processing' | 'delivered' | 'cancelled';
|
||||||
@@ -39,7 +40,7 @@ const STATUS_TABS: Array<{ key: StatusKey; label: string }> = [
|
|||||||
];
|
];
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
export default function OrdersView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID }: OrdersViewProps) {
|
export default function OrdersView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID, headerTabs }: OrdersViewProps) {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
const [fromdate, setFromdate] = useState<string>(ymd(today));
|
const [fromdate, setFromdate] = useState<string>(ymd(today));
|
||||||
@@ -54,6 +55,7 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
|||||||
// a wide window — from the platform's earliest plausible data to a year ahead.
|
// a wide window — from the platform's earliest plausible data to a year ahead.
|
||||||
const presets = [
|
const presets = [
|
||||||
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
|
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
|
||||||
|
{ key: 'yesterday', label: 'Yesterday', from: dayOffset(1), to: dayOffset(1) },
|
||||||
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) },
|
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) },
|
||||||
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: dayAhead(7) },
|
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: dayAhead(7) },
|
||||||
];
|
];
|
||||||
@@ -252,8 +254,8 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
|||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in duration-300">
|
<div className="animate-in fade-in duration-300">
|
||||||
<GradientHeader
|
<GradientHeader
|
||||||
title="Orders"
|
title="Orders Board"
|
||||||
subtitle="Live order board across the lifecycle — created, pending, processing, delivered, and cancelled."
|
subtitle="End-to-end lifecycle tracking for current operational period."
|
||||||
status={
|
status={
|
||||||
ordersQ.isLoading
|
ordersQ.isLoading
|
||||||
? <LiveStatus state="loading" label="Loading live orders…" />
|
? <LiveStatus state="loading" label="Loading live orders…" />
|
||||||
@@ -262,9 +264,12 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
|||||||
: <LiveStatus state="live" label={`Live · ${total.toLocaleString('en-IN')} orders in range`} />
|
: <LiveStatus state="live" label={`Live · ${total.toLocaleString('en-IN')} orders in range`} />
|
||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{headerTabs}
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full font-extrabold" style={{ padding: '6px 12px', fontSize: 12, background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}`, color: BRAND }}>
|
<span className="inline-flex items-center gap-1.5 rounded-full font-extrabold" style={{ padding: '6px 12px', fontSize: 12, background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}`, color: BRAND }}>
|
||||||
<MapPin size={13} /> Coimbatore
|
<MapPin size={13} /> Coimbatore
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ function Row({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SettingsViewProps {
|
||||||
|
tenantId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsViewProps) {
|
export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsViewProps) {
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>('profile');
|
const [activeTab, setActiveTab] = useState<TabKey>('profile');
|
||||||
|
|
||||||
|
|||||||
@@ -344,8 +344,9 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Subheader Navigation Bar ── */}
|
{/* ── Subheader Navigation Bar ── */}
|
||||||
<div className={`flex items-center ${onBack ? 'justify-between' : 'justify-end'}`}>
|
{/* ── Subheader Navigation Bar ── */}
|
||||||
{onBack && (
|
{onBack && (
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
<button
|
<button
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
className="flex items-center gap-xs text-xs font-bold text-[#581c87] hover:text-[#4c1d95] bg-purple-50 hover:bg-purple-100/80 px-xl py-2 rounded-lg transition-all shadow-sm border border-purple-100 cursor-pointer"
|
className="flex items-center gap-xs text-xs font-bold text-[#581c87] hover:text-[#4c1d95] bg-purple-50 hover:bg-purple-100/80 px-xl py-2 rounded-lg transition-all shadow-sm border border-purple-100 cursor-pointer"
|
||||||
@@ -353,14 +354,9 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
|
|||||||
<ArrowLeft size={14} />
|
<ArrowLeft size={14} />
|
||||||
<span>Back to Registry</span>
|
<span>Back to Registry</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-xs">
|
|
||||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
|
||||||
<span className="text-[10px] font-bold tracking-widest text-emerald-600 uppercase">System Sync Active</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Immersive Analytics Banner — hidden on the standalone Inventory & Customers pages ── */}
|
{/* ── Immersive Analytics Banner — hidden on the standalone Inventory & Customers pages ── */}
|
||||||
{showHero && (
|
{showHero && (
|
||||||
<div className="relative overflow-hidden rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 mb-8 animate-in fade-in duration-300">
|
<div className="relative overflow-hidden rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 mb-8 animate-in fade-in duration-300">
|
||||||
@@ -378,6 +374,12 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
|
|||||||
<div className="absolute top-0 right-0 w-72 h-72 bg-purple-500/10 rounded-full blur-3xl -mr-20 -mt-20 pointer-events-none z-0" />
|
<div className="absolute top-0 right-0 w-72 h-72 bg-purple-500/10 rounded-full blur-3xl -mr-20 -mt-20 pointer-events-none z-0" />
|
||||||
<div className="absolute bottom-0 left-0 w-56 h-56 bg-slate-500/5 rounded-full blur-2xl -ml-20 -mb-20 pointer-events-none z-0" />
|
<div className="absolute bottom-0 left-0 w-56 h-56 bg-slate-500/5 rounded-full blur-2xl -ml-20 -mb-20 pointer-events-none z-0" />
|
||||||
|
|
||||||
|
{/* System Sync Active Indicator */}
|
||||||
|
<div className="absolute top-4 right-5 sm:top-6 sm:right-8 z-20 flex items-center gap-1.5 bg-slate-950/40 backdrop-blur-md px-3 py-1.5 rounded-full border border-white/5 shadow-sm">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
<span className="text-[9px] font-bold tracking-widest text-emerald-400 uppercase">System Sync Active</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative flex flex-col md:flex-row md:items-center justify-between gap-6 z-10">
|
<div className="relative flex flex-col md:flex-row md:items-center justify-between gap-6 z-10">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full bg-purple-500/20 border border-purple-400/30 text-purple-200 text-[10px] font-bold uppercase tracking-widest">
|
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full bg-purple-500/20 border border-purple-400/30 text-purple-200 text-[10px] font-bold uppercase tracking-widest">
|
||||||
@@ -888,124 +890,139 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
|
|||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in duration-300">
|
<div className="animate-in fade-in duration-300">
|
||||||
|
|
||||||
{/* Page heading */}
|
{/* Page heading - Immersive Banner (Light mode to match Reports) */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
<div className="relative overflow-hidden rounded-2xl p-4 md:p-5 border mb-5 animate-in fade-in duration-300 shadow-[0_8px_24px_rgba(15,23,42,0.08)]" style={{ borderColor: '#e2e8f0', background: `linear-gradient(135deg, #66258208 0%, #9255AB08 100%)` }}>
|
||||||
<div>
|
{/* Background glowing circles */}
|
||||||
<h2 className="text-[22px] font-semibold tracking-tight text-[#0f172a]">Customers</h2>
|
<div className="absolute top-0 right-0 w-80 h-80 bg-[#662582]/5 rounded-full blur-3xl -mr-20 -mt-20 pointer-events-none z-0" />
|
||||||
<p className="text-[13px] text-zinc-500 mt-1">
|
<div className="absolute bottom-0 left-0 w-64 h-64 bg-[#9255AB]/5 rounded-full blur-2xl -ml-20 -mb-20 pointer-events-none z-0" />
|
||||||
{customersList.length} {customersList.length === 1 ? 'person orders' : 'people order'} from{' '}
|
|
||||||
<span className="font-medium text-zinc-700">{store.name}</span>
|
<div className="relative flex flex-col md:flex-row md:items-center justify-between gap-4 z-10">
|
||||||
<span className="text-zinc-300"> · </span>
|
<div className="space-y-1">
|
||||||
{withPhone} with phone<span className="text-zinc-300"> · </span>{withEmail} with email
|
<div className="flex items-center gap-3 mb-1">
|
||||||
</p>
|
<div className="inline-flex items-center gap-2 px-2.5 py-0.5 rounded-full bg-[#662582]/10 border border-[#662582]/20 text-[#662582] text-[9px] font-bold uppercase tracking-widest">
|
||||||
|
Store Audience Registry
|
||||||
</div>
|
</div>
|
||||||
<div className="relative w-full sm:w-64 shrink-0">
|
<div className="flex items-center gap-1.5">
|
||||||
<Search size={15} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-zinc-400" />
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
<span className="text-[9px] font-bold tracking-widest text-emerald-600 uppercase">System Sync Active</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="font-sans font-extrabold text-2xl md:text-[28px] tracking-tight text-[#0f172a]">
|
||||||
|
Customer CRM
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-[12px] text-zinc-500 mt-1 font-medium">
|
||||||
|
<span>
|
||||||
|
{customersList.length} {customersList.length === 1 ? 'customer orders' : 'customers order'} from <strong className="text-[#0f172a] font-bold">{store.name}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-300 hidden sm:inline">·</span>
|
||||||
|
<span className="flex items-center gap-1.5"><Phone size={11} className="text-zinc-400" /> {withPhone} with phone</span>
|
||||||
|
<span className="text-zinc-300 hidden sm:inline">·</span>
|
||||||
|
<span className="flex items-center gap-1.5"><Mail size={11} className="text-zinc-400" /> {withEmail} with email</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full md:w-64 shrink-0">
|
||||||
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#662582]" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search customers"
|
placeholder="Search customers..."
|
||||||
value={customerSearch}
|
value={customerSearch}
|
||||||
onChange={(e) => setCustomerSearch(e.target.value)}
|
onChange={(e) => setCustomerSearch(e.target.value)}
|
||||||
className="w-full pl-9 pr-3 py-2.5 border border-[#e6e8ee] rounded-full text-[13px] text-[#0f172a] placeholder:text-zinc-400 outline-none bg-white focus:border-[#581c87] focus:ring-4 focus:ring-[#581c87]/8 transition-all"
|
className="w-full pl-8 pr-3 py-2 border border-[#662582]/20 rounded-xl text-[12px] text-[#0f172a] placeholder:text-zinc-400 outline-none bg-white/60 backdrop-blur-md focus:border-[#662582] focus:ring-4 focus:ring-[#662582]/10 transition-all shadow-[inset_0_2px_4px_rgba(0,0,0,0.02)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Profile cards */}
|
{/* Profile cards */}
|
||||||
{customersList.length === 0 ? (
|
{customersList.length === 0 ? (
|
||||||
<div className="bg-white border border-[#e8e9ee] rounded-3xl py-20 flex flex-col items-center gap-3 text-center px-6">
|
<div className="bg-white/60 backdrop-blur-md border border-[#e2e8f0] rounded-3xl py-24 flex flex-col items-center gap-4 text-center px-6 shadow-sm">
|
||||||
<span className="flex items-center justify-center w-14 h-14 rounded-full bg-[#f4f0fb] text-[#6d28d9]"><Users size={22} /></span>
|
<span className="flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-[#f3e8ff] to-[#e9d5ff] text-[#6d28d9] shadow-sm"><Users size={28} /></span>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[15px] font-semibold text-[#0f172a]">No customers yet</p>
|
<p className="text-[18px] font-bold text-[#0f172a]">No customers yet</p>
|
||||||
<p className="text-[13px] text-zinc-500 mt-1 max-w-xs leading-relaxed">
|
<p className="text-[14px] text-zinc-500 mt-1 max-w-sm leading-relaxed">
|
||||||
{customerSearch ? 'Nothing matches your search.' : 'Customers will appear here once they place their first order.'}
|
{customerSearch ? 'Nothing matches your search criteria.' : 'Customers will appear here once they place their first order.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{customerSearch && (
|
{customerSearch && (
|
||||||
<button onClick={() => setCustomerSearch('')} className="mt-1 text-[13px] font-medium text-[#581c87] hover:underline cursor-pointer">Clear search</button>
|
<button onClick={() => setCustomerSearch('')} className="mt-2 text-[13px] font-bold text-[#581c87] hover:text-[#4c1d95] bg-purple-50 px-4 py-2 rounded-lg transition-colors cursor-pointer">Clear Search</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white border border-[#e8e9ee] rounded-2xl shadow-[0_1px_3px_rgba(16,24,40,0.04)] overflow-hidden">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 pb-8">
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left border-collapse" style={{ minWidth: 860 }}>
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-[#fafbfc] border-b border-[#eceef2] text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
|
|
||||||
<th className="px-5 py-3.5 font-semibold">Customer</th>
|
|
||||||
<th className="px-5 py-3.5 font-semibold">Phone</th>
|
|
||||||
<th className="px-5 py-3.5 font-semibold">Email</th>
|
|
||||||
<th className="px-5 py-3.5 font-semibold">Delivery address</th>
|
|
||||||
<th className="px-5 py-3.5 font-semibold text-right">Profile</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-[#f1f2f5]">
|
|
||||||
{customersList.map((c: any, idx: number) => {
|
{customersList.map((c: any, idx: number) => {
|
||||||
const tone = toneFor(c.name || `c${idx}`);
|
const tone = toneFor(c.name || `c${idx}`);
|
||||||
const locality = localityOf(c.address);
|
const locality = localityOf(c.address);
|
||||||
return (
|
return (
|
||||||
<tr key={c.id ?? idx} className="group hover:bg-[#fbfaff] transition-colors">
|
<div key={c.id ?? idx} className="group relative bg-white/70 backdrop-blur-md border border-[#e2e8f0] rounded-2xl p-5 hover:shadow-[0_12px_40px_rgba(88,28,135,0.08)] hover:border-[#581c87]/30 transition-all duration-300 flex flex-col">
|
||||||
{/* Customer */}
|
<div className="flex justify-between items-start mb-4">
|
||||||
<td className="px-5 py-3.5">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span
|
<span
|
||||||
className="w-9 h-9 rounded-xl flex items-center justify-center font-bold text-[12px] shrink-0 ring-1 ring-black/[0.04]"
|
className="w-12 h-12 rounded-2xl flex items-center justify-center font-bold text-sm shrink-0 shadow-sm transition-transform group-hover:scale-105 border border-white/50"
|
||||||
style={{ background: tone.soft, color: tone.fg }}
|
style={{ background: tone.soft, color: tone.fg }}
|
||||||
>
|
>
|
||||||
{initialsOf(c.name)}
|
{initialsOf(c.name)}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-semibold text-[14px] text-[#0f172a] truncate leading-tight" title={c.name}>{c.name}</p>
|
<h3 className="font-extrabold text-[#0f172a] text-[15px] leading-tight group-hover:text-[#581c87] transition-colors truncate" title={c.name}>{c.name}</h3>
|
||||||
{locality && (
|
{locality && (
|
||||||
<p className="text-[12px] text-zinc-400 mt-0.5 inline-flex items-center gap-1 truncate max-w-[180px]">
|
<p className="text-[11px] text-zinc-500 mt-0.5 flex items-center gap-1 font-medium truncate" title={locality}>
|
||||||
<MapPin size={11} className="shrink-0" /> {locality}
|
<MapPin size={10} className="shrink-0 text-zinc-400" /> {locality}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
{c.ordersCount > 5 && (
|
||||||
{/* Phone */}
|
<span className="shrink-0 ml-2 px-2 py-1 bg-gradient-to-r from-amber-100 to-amber-50 text-amber-700 text-[9px] font-black uppercase tracking-widest rounded-lg border border-amber-200 flex items-center gap-1 shadow-sm">
|
||||||
<td className="px-5 py-3.5">
|
<Award size={10} /> Loyal
|
||||||
<span className="text-[13px] text-zinc-700 tabular-nums">{c.phone}</span>
|
</span>
|
||||||
</td>
|
)}
|
||||||
{/* Email */}
|
</div>
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
{c.email
|
<div className="grid grid-cols-2 gap-3 mb-5">
|
||||||
? <span className="text-[13px] text-zinc-600 truncate inline-block max-w-[220px] align-middle" title={c.email}>{c.email}</span>
|
<div className="bg-gradient-to-br from-[#f8fafc] to-white rounded-xl p-3 border border-[#f1f5f9] shadow-[inset_0_2px_4px_rgba(0,0,0,0.01)] group-hover:border-[#581c87]/10 transition-colors">
|
||||||
: <span className="text-[13px] text-zinc-300">—</span>}
|
<span className="text-[9px] font-bold text-zinc-400 uppercase tracking-widest block">Dispatches</span>
|
||||||
</td>
|
<span className="font-black text-[16px] text-[#0f172a] mt-0.5 block">{c.ordersCount}</span>
|
||||||
{/* Address */}
|
</div>
|
||||||
<td className="px-5 py-3.5">
|
<div className="bg-gradient-to-br from-[#f8fafc] to-white rounded-xl p-3 border border-[#f1f5f9] shadow-[inset_0_2px_4px_rgba(0,0,0,0.01)] group-hover:border-emerald-500/10 transition-colors">
|
||||||
<span className="text-[13px] text-zinc-500 truncate inline-block max-w-[280px] align-middle" title={c.address}>{c.address}</span>
|
<span className="text-[9px] font-bold text-zinc-400 uppercase tracking-widest block">Gross Spend</span>
|
||||||
</td>
|
<span className="font-black text-[16px] text-emerald-600 mt-0.5 block">{c.totalSpent}</span>
|
||||||
{/* Action */}
|
</div>
|
||||||
<td className="px-5 py-3.5 text-right whitespace-nowrap">
|
</div>
|
||||||
<div className="inline-flex items-center justify-end gap-2">
|
|
||||||
|
<div className="space-y-2.5 mb-6 flex-1">
|
||||||
|
<div className="flex items-center gap-2.5 text-[12px] text-zinc-600 font-medium">
|
||||||
|
<div className="w-6 h-6 rounded-lg bg-zinc-50 flex items-center justify-center shrink-0 border border-zinc-100"><Phone size={12} className="text-zinc-400" /></div>
|
||||||
|
<span className="tabular-nums">{c.phone}</span>
|
||||||
|
</div>
|
||||||
|
{c.email && c.email !== '—' && (
|
||||||
|
<div className="flex items-center gap-2.5 text-[12px] text-zinc-600 font-medium">
|
||||||
|
<div className="w-6 h-6 rounded-lg bg-zinc-50 flex items-center justify-center shrink-0 border border-zinc-100"><Mail size={12} className="text-zinc-400" /></div>
|
||||||
|
<span className="truncate" title={c.email}>{c.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-[#f1f5f9] flex items-center justify-between gap-3 mt-auto">
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<button
|
<button
|
||||||
onClick={() => showToast(`Promo code sent to ${c.phone}.`, 'success')}
|
onClick={(e) => { e.stopPropagation(); showToast(`Promo code sent to ${c.phone}.`, 'success'); }}
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-xl bg-purple-50 border border-purple-100 text-[#581c87] hover:bg-[#581c87] hover:border-[#581c87] hover:text-white transition-all cursor-pointer shrink-0 shadow-sm"
|
||||||
title="Send promo SMS"
|
title="Send promo SMS"
|
||||||
className="w-8 h-8 inline-flex items-center justify-center rounded-lg border border-[#e6e8ee] text-zinc-500 hover:text-[#581c87] hover:border-[#d6bcf0] hover:bg-[#faf5ff] transition-colors cursor-pointer"
|
|
||||||
>
|
>
|
||||||
<Send size={14} />
|
<Send size={15} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedCustomer(c)}
|
onClick={() => setSelectedCustomer(c)}
|
||||||
className="inline-flex items-center gap-1.5 rounded-lg border border-[#e6e8ee] px-3 py-1.5 text-[12.5px] font-semibold text-zinc-700 hover:bg-[#581c87] hover:border-[#581c87] hover:text-white transition-colors cursor-pointer"
|
className="flex-1 h-10 flex items-center justify-center gap-2 rounded-xl bg-white border border-[#e2e8f0] text-[12px] font-bold text-zinc-700 hover:border-[#581c87] hover:bg-purple-50/30 hover:text-[#581c87] transition-all cursor-pointer shadow-sm group/btn"
|
||||||
>
|
>
|
||||||
View <ChevronRight size={14} />
|
View Profile <ChevronRight size={14} className="group-hover/btn:translate-x-1 transition-transform" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="px-5 py-3 border-t border-[#eceef2] bg-[#fafbfc] text-[12px] text-zinc-400">
|
|
||||||
Showing {customersList.length} {customersList.length === 1 ? 'customer' : 'customers'}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,9 +30,7 @@ import type { AuthUser } from '../services/auth';
|
|||||||
import Header from './Header';
|
import Header from './Header';
|
||||||
import StoreDetailView from './StoreDetailView';
|
import StoreDetailView from './StoreDetailView';
|
||||||
import StoreCatalogView from './StoreCatalogView';
|
import StoreCatalogView from './StoreCatalogView';
|
||||||
import OrdersView from './OrdersView';
|
import DispatchHubView from './DispatchHubView';
|
||||||
import DeliveriesView from './DeliveriesView';
|
|
||||||
import DispatchView from './DispatchView';
|
|
||||||
import DeliveryReportsView from './DeliveryReportsView';
|
import DeliveryReportsView from './DeliveryReportsView';
|
||||||
import StoreQRView from './StoreQRView';
|
import StoreQRView from './StoreQRView';
|
||||||
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
||||||
@@ -50,8 +48,6 @@ const NAV_ITEMS: UserNavItem[] = [
|
|||||||
{ id: 'console', label: 'Store Console', icon: LayoutDashboard },
|
{ id: 'console', label: 'Store Console', icon: LayoutDashboard },
|
||||||
{ id: 'inventory', label: 'Product Catalogue', icon: Layers },
|
{ id: 'inventory', label: 'Product Catalogue', icon: Layers },
|
||||||
{ id: 'customers', label: 'Customers', icon: Users },
|
{ id: 'customers', label: 'Customers', icon: Users },
|
||||||
{ id: 'orders', label: 'Orders', icon: ShoppingBag },
|
|
||||||
{ id: 'deliveries', label: 'Deliveries', icon: Truck },
|
|
||||||
{ id: 'dispatch', label: 'Dispatch', icon: Route },
|
{ id: 'dispatch', label: 'Dispatch', icon: Route },
|
||||||
{ id: 'reports', label: 'Reports', icon: ClipboardList },
|
{ id: 'reports', label: 'Reports', icon: ClipboardList },
|
||||||
];
|
];
|
||||||
@@ -194,9 +190,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
|
|
||||||
// Logistics console — scoped to this user's store. These views own their
|
// Logistics console — scoped to this user's store. These views own their
|
||||||
// loading/error states, so they don't need the store-console load gating below.
|
// loading/error states, so they don't need the store-console load gating below.
|
||||||
if (activeSection === 'orders') return <OrdersView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
if (activeSection === 'dispatch') return <DispatchHubView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
||||||
if (activeSection === 'deliveries') return <DeliveriesView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
|
||||||
if (activeSection === 'dispatch') return <DispatchView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
|
||||||
if (activeSection === 'reports') return <DeliveryReportsView tenantId={tenantId} />;
|
if (activeSection === 'reports') return <DeliveryReportsView tenantId={tenantId} />;
|
||||||
// Inventory & Catalog is its own page: the manager-curated catalog the user
|
// Inventory & Catalog is its own page: the manager-curated catalog the user
|
||||||
// stocks from (the catalog query is tenant-level, so it doesn't need the store
|
// stocks from (the catalog query is tenant-level, so it doesn't need the store
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import {
|
|||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Coins,
|
Coins,
|
||||||
Store,
|
Store,
|
||||||
Bike
|
Bike,
|
||||||
|
Clock
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useFiestaUsers, useFiestaCreateUser, useFiestaRiderShifts, useFiestaTenantLocations } from '../services/fiestaQueries';
|
import { useFiestaUsers, useFiestaCreateUser, useFiestaRiderShifts, useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||||
import { useAppRoles } from '../services/queries';
|
import { useAppRoles } from '../services/queries';
|
||||||
|
|||||||
Reference in New Issue
Block a user