new changes in api base url
This commit is contained in:
@@ -17,11 +17,14 @@
|
||||
* • Date navigation for historical dispatch view
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import {
|
||||
Map as MapIcon,
|
||||
MapPin,
|
||||
Bike,
|
||||
Store,
|
||||
Users,
|
||||
Phone,
|
||||
ShoppingBag,
|
||||
Truck,
|
||||
Package,
|
||||
@@ -43,6 +46,8 @@ import {
|
||||
useFiestaDeliveries,
|
||||
useFiestaRiders,
|
||||
useFiestaRiderPeriodicLogs,
|
||||
useFiestaTenantLocations,
|
||||
useFiestaTenantCustomers,
|
||||
} from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||
import {
|
||||
@@ -53,6 +58,7 @@ import {
|
||||
} from '../services/dispatchShared';
|
||||
import DispatchMap, { type MapPoint } from './DispatchMap';
|
||||
import RiderTelemetryPanel from './RiderTelemetryPanel';
|
||||
import CustomerDetailPanel from './CustomerDetailPanel';
|
||||
import './DispatchView.css';
|
||||
|
||||
// Legacy direct utilities (will be migrated to dispatchShared)
|
||||
@@ -87,10 +93,10 @@ function pickupLatLon(r: Row): [number, number] | null {
|
||||
}
|
||||
|
||||
// ── View modes (match #strat-row tabs) ───────────────────────────────────────────
|
||||
type ViewMode = 'kitchens' | 'zones' | 'riders';
|
||||
type ViewMode = 'stores' | 'zones' | 'customers' | 'riders' | 'kitchens';
|
||||
const VIEW_TABS: Array<{ id: ViewMode; label: string; icon: typeof MapIcon }> = [
|
||||
{ id: 'kitchens', label: 'By Location', icon: MapPin },
|
||||
{ id: 'riders', label: 'By Rider', icon: Bike },
|
||||
{ id: 'stores', label: 'By Store', icon: Store },
|
||||
{ id: 'customers', label: 'By Customer', icon: Users },
|
||||
];
|
||||
|
||||
interface Group {
|
||||
@@ -104,23 +110,27 @@ interface Group {
|
||||
riders: Set<string>;
|
||||
suburbs: Map<string, number>;
|
||||
statusCounts: Record<string, number>;
|
||||
raw?: any;
|
||||
}
|
||||
|
||||
interface DispatchViewProps {
|
||||
locationid?: number;
|
||||
tenantId?: number;
|
||||
headerTabs?: React.ReactNode;
|
||||
date: string;
|
||||
viewMode: 'stores' | 'zones' | 'customers' | 'riders' | 'kitchens';
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID, headerTabs }: DispatchViewProps) {
|
||||
const today = new Date();
|
||||
const [date, setDate] = useState<string>(ymd(today));
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('riders');
|
||||
export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID, date, viewMode }: DispatchViewProps) {
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
const [focusedRiderId, setFocusedRiderId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFocusedId(null);
|
||||
}, [viewMode]);
|
||||
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [tripSort, setTripSort] = useState<'planned' | 'time'>('planned');
|
||||
const [animateNonce, setAnimateNonce] = useState(0);
|
||||
@@ -128,6 +138,8 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
|
||||
// Core dispatch data
|
||||
const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate: date, todate: date, locationid });
|
||||
const locationsQ = useFiestaTenantLocations(tenantId);
|
||||
const customersQ = useFiestaTenantCustomers({ tenantid: tenantId, locationid: locationid || 0 });
|
||||
const ridersQ = useFiestaRiders({ tenantid: tenantId });
|
||||
|
||||
// Rider periodic logs (GPS snapshots) for the focused rider
|
||||
@@ -152,15 +164,38 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
// ── Grouping ────────────────────────────────────────────────────────────────
|
||||
const groups = useMemo<Group[]>(() => {
|
||||
const map = new Map<string, Group>();
|
||||
|
||||
if (viewMode === 'stores' && locationsQ.data) {
|
||||
for (const loc of locationsQ.data) {
|
||||
const id = String(fnum(loc.locationid)).toLowerCase();
|
||||
const name = fstr(loc.locationname) || `Store ${id}`;
|
||||
map.set(id, { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {} });
|
||||
}
|
||||
}
|
||||
|
||||
if (viewMode === 'customers' && customersQ.data) {
|
||||
for (const cust of customersQ.data) {
|
||||
const id = String(fnum(cust.customerid) || fstr(cust.contactno)).toLowerCase();
|
||||
const name = fstr(cust.customername) || fstr(cust.name) || `Customer ${id}`;
|
||||
map.set(id, { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {}, raw: cust });
|
||||
}
|
||||
}
|
||||
|
||||
const titleCase = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
||||
const keyOf = (r: Row): { id: string; name: string } => {
|
||||
if (viewMode === 'riders') {
|
||||
const id = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
|
||||
return { id, name: fstr(r.ridername) || fstr(r.username) || (id === 'unassigned' ? 'Unassigned' : `Rider ${id}`) };
|
||||
}
|
||||
if (viewMode === 'kitchens') {
|
||||
const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || 'Pickup';
|
||||
return { id: name.toLowerCase(), name };
|
||||
if (viewMode === 'stores') {
|
||||
const locId = fstr(r.locationid) || fstr(r.pickuplocationid) || 'unknown';
|
||||
const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || `Store ${locId}`;
|
||||
return { id: locId.toLowerCase(), name };
|
||||
}
|
||||
if (viewMode === 'customers') {
|
||||
const custId = fstr(r.customerid) || fstr(r.contactno) || fstr(r.deliverycustomerphone) || 'unknown';
|
||||
const name = fstr(r.deliverycustomer) || fstr(r.customername) || `Customer ${custId}`;
|
||||
return { id: custId.toLowerCase(), name };
|
||||
}
|
||||
const name = fstr(r.deliverysuburb) || fstr(r.zone_name) || 'Unzoned';
|
||||
return { id: name.toLowerCase(), name };
|
||||
@@ -183,8 +218,8 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
const sub = fstr(r.deliverysuburb);
|
||||
if (sub) g.suburbs.set(sub, (g.suburbs.get(sub) ?? 0) + 1);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => b.orders.length - a.orders.length);
|
||||
}, [rows, viewMode]);
|
||||
return Array.from(map.values()).sort((a, b) => b.orders.length - a.orders.length || a.name.localeCompare(b.name));
|
||||
}, [rows, viewMode, locationsQ.data, customersQ.data]);
|
||||
|
||||
const focused = groups.find((g) => g.id === focusedId) ?? null;
|
||||
const groupedByRider = viewMode !== 'riders';
|
||||
@@ -257,21 +292,10 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
|
||||
// KPI scope.
|
||||
const totalOrders = rows.length;
|
||||
const totalDelivered = rows.filter((r) => fstr(r.orderstatus).toLowerCase() === 'delivered').length;
|
||||
const activeRiders = new Set(rows.map((r) => fstr(r.userid) || fstr(r.ridername)).filter(Boolean)).size;
|
||||
const fleetSize = (ridersQ.data ?? []).length;
|
||||
|
||||
// Date chip helpers.
|
||||
const isToday = date === ymd(today);
|
||||
const dateObj = new Date(`${date}T00:00:00`);
|
||||
const prettyDate = `${WEEKDAYS[dateObj.getDay()]}, ${dateObj.getDate()} ${MONTHS[dateObj.getMonth()]}`;
|
||||
const shiftDate = (delta: number) => {
|
||||
const d = new Date(`${date}T00:00:00`);
|
||||
d.setDate(d.getDate() + delta);
|
||||
if (d > today) return;
|
||||
setDate(ymd(d));
|
||||
setFocusedId(null);
|
||||
};
|
||||
|
||||
const fmtTime = (raw: unknown): string => {
|
||||
const m = fstr(raw).match(/(\d{1,2}):(\d{2})/);
|
||||
return m ? `${m[1]}:${m[2]}` : '';
|
||||
@@ -280,90 +304,6 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
return (
|
||||
<div style={{ height: '100%', minHeight: 0 }}>
|
||||
<div className="dispatch-container embedded">
|
||||
{/* ── Header ── */}
|
||||
<div id="hdr">
|
||||
<div className="logo flex items-center gap-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="logo-badge shadow-sm">D</div>
|
||||
<div className="logo-name tracking-tight">Dispatch</div>
|
||||
<div className="logo-city-wrap ml-1">
|
||||
<span className="logo-city" style={{ cursor: 'default' }}>
|
||||
<MapPin size={13} className="text-purple-500" />
|
||||
<span className="logo-city-text font-semibold text-slate-700">Coimbatore</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hdr-stats">
|
||||
{deliveriesQ.isLoading ? (
|
||||
<span className="live-status">
|
||||
<span className="live-dot" /> Syncing
|
||||
</span>
|
||||
) : deliveriesQ.isError ? (
|
||||
<span className="live-status live-status-error">
|
||||
<span className="live-dot error" /> Offline
|
||||
</span>
|
||||
) : totalOrders === 0 ? (
|
||||
<span className="live-status" title="No deliveries dispatched for this day">
|
||||
<span className="live-dot" style={{ background: '#94a3b8' }} /> No deliveries today
|
||||
</span>
|
||||
) : (
|
||||
<span className="live-status live-status-ready">
|
||||
<span className="live-dot ready" /> Live · {totalOrders} orders
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className={`date-chip${isToday ? ' is-today' : ''}`}>
|
||||
<button className="date-chip-nav" onClick={() => shiftDate(-1)} title="Previous day">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<div className="date-chip-main" style={{ position: 'relative' }}>
|
||||
<span className="date-chip-icon"><Calendar size={14} /></span>
|
||||
<span className="date-chip-text">
|
||||
<span className="date-chip-label">
|
||||
Date {isToday && <span className="date-chip-today-pill">Today</span>}
|
||||
</span>
|
||||
<span className="date-chip-value">{prettyDate}</span>
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
max={ymd(today)}
|
||||
onChange={(e) => { setDate(e.target.value); setFocusedId(null); }}
|
||||
style={{ position: 'absolute', inset: 0, opacity: 0, cursor: 'pointer', width: '100%', height: '100%' }}
|
||||
aria-label="Pick date"
|
||||
/>
|
||||
</div>
|
||||
<button className="date-chip-nav" onClick={() => shiftDate(1)} disabled={isToday} title="Next day">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── View-mode tabs ── */}
|
||||
<div id="strat-row">
|
||||
{VIEW_TABS.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`sbt ${viewMode === t.id ? 'active' : ''}`}
|
||||
onClick={() => { setViewMode(t.id); setFocusedId(null); }}
|
||||
>
|
||||
<span className="sbt-icon"><Icon size={15} /></span>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{headerTabs && (
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
{headerTabs}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div id="body" className={sidebarCollapsed ? 'sidebar-collapsed' : ''}>
|
||||
<button
|
||||
@@ -371,7 +311,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
onClick={() => setSidebarCollapsed((c) => !c)}
|
||||
title={sidebarCollapsed ? 'Show panel' : 'Hide panel'}
|
||||
>
|
||||
{sidebarCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
{sidebarCollapsed ? <ChevronRight size={26} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
|
||||
{/* Sidebar */}
|
||||
@@ -380,7 +320,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
<div className="sb-header-top">
|
||||
<div className="sb-header-title">
|
||||
<span className="sb-title-bar" aria-hidden="true" />
|
||||
<span className="sb-title-text">RIDER DISPATCH</span>
|
||||
<span className="sb-title-text">CONSOLE</span>
|
||||
</div>
|
||||
<span className="sb-header-scope">
|
||||
<span className="sb-scope-dot" />
|
||||
@@ -395,11 +335,17 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
<div className="sb-tile-label">Orders</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sb-tile sb-tile-riders">
|
||||
<span className="sb-tile-icon"><Bike size={16} /></span>
|
||||
<div className="sb-tile sb-tile-deliveries">
|
||||
<span className="sb-tile-icon">
|
||||
<Truck size={16} />
|
||||
</span>
|
||||
<div className="sb-tile-body">
|
||||
<div className="sb-tile-value">{activeRiders}{fleetSize ? `/${fleetSize}` : ''}</div>
|
||||
<div className="sb-tile-label">Riders</div>
|
||||
<div className="sb-tile-value">
|
||||
{`${totalDelivered}/${totalOrders}`}
|
||||
</div>
|
||||
<div className="sb-tile-label">
|
||||
Deliveries
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -408,7 +354,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
<div id="riders-panel">
|
||||
{deliveriesQ.isLoading ? (
|
||||
<div className="ph">Loading dispatch feed…</div>
|
||||
) : focused ? (
|
||||
) : focused && viewMode !== 'customers' ? (
|
||||
<FocusedDetail
|
||||
focused={focused}
|
||||
tripBlocks={tripBlocks}
|
||||
@@ -421,78 +367,109 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
||||
riderLogsLoading={riderLogsQ.isLoading}
|
||||
/>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="ph">No deliveries for this day</div>
|
||||
<div className="ph">{viewMode === 'customers' ? 'No customers found' : 'No deliveries for this day'}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ph">
|
||||
{viewMode === 'riders' ? 'Riders' : viewMode === 'kitchens' ? 'Pickup points' : 'Zones'} ({groups.length})
|
||||
{viewMode === 'riders' ? 'Riders' : viewMode === 'customers' ? 'Customers' : viewMode === 'stores' ? 'Stores' : 'Zones'} ({groups.length})
|
||||
</div>
|
||||
{groups.map((g) => (
|
||||
<React.Fragment key={g.id}>
|
||||
{viewMode === 'riders'
|
||||
? <RiderCard
|
||||
{groups.map((g) => {
|
||||
const isSelected = focusedId === g.id;
|
||||
return (
|
||||
<React.Fragment key={g.id}>
|
||||
{viewMode === 'customers' ? (
|
||||
<CustomerCard g={g} onClick={() => setFocusedId(g.id)} isSelected={isSelected} />
|
||||
) : viewMode === 'riders' ? (
|
||||
<RiderCard
|
||||
g={g}
|
||||
onClick={() => {
|
||||
setFocusedId(g.id);
|
||||
// Extract rider ID from first order in group for GPS logs
|
||||
const rid = fnum(g.orders[0]?.userid);
|
||||
if (rid) setFocusedRiderId(rid);
|
||||
}}
|
||||
/>
|
||||
: <ZoneCard g={g} onClick={() => {
|
||||
) : (
|
||||
<ZoneCard g={g} onClick={() => {
|
||||
setFocusedId(g.id);
|
||||
setFocusedRiderId(null);
|
||||
}} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
}} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map area */}
|
||||
<div id="map-wrap">
|
||||
{/* Live Leaflet route map */}
|
||||
<DispatchMap
|
||||
points={mapPoints}
|
||||
route={Boolean(focused)}
|
||||
routeColor={focused?.color || '#662582'}
|
||||
start={routeStart}
|
||||
resizeKey={`${sidebarCollapsed}|${viewMode}|${focusedId}`}
|
||||
animateNonce={animateNonce}
|
||||
/>
|
||||
|
||||
{/* Contextual note overlaid on the map */}
|
||||
{mapPoints.length === 0 ? (
|
||||
<div className="dmp-overlay-note">
|
||||
<MapIcon size={13} /> No drop coordinates in {focused ? 'this route' : 'these deliveries'} yet.
|
||||
{/* Main Content Area */}
|
||||
{viewMode === 'customers' ? (
|
||||
<div id="customer-panel-wrap" style={{ flex: 1, position: 'relative', zIndex: 50, backgroundColor: '#f8fafc', overflow: 'hidden', minWidth: 0 }}>
|
||||
<style>{`
|
||||
#customer-panel-wrap * {
|
||||
margin: revert;
|
||||
padding: revert;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`}</style>
|
||||
<div className="relative h-full w-full flex flex-col">
|
||||
{focused?.raw ? (
|
||||
<CustomerDetailPanel customer={focused.raw} onClose={() => setFocusedId(null)} />
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center h-full w-full">
|
||||
<div className="w-14 h-14 rounded-2xl bg-purple-50 border border-purple-100 text-[#662582] flex items-center justify-center mb-md">
|
||||
<Users size={24} />
|
||||
</div>
|
||||
<p className="font-bold text-sm text-slate-900">Select a customer</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Choose a customer from the list to view their billing history.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : !focused ? (
|
||||
<div className="dmp-overlay-note">
|
||||
<MapIcon size={13} /> Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : viewMode === 'riders' ? 'rider' : 'group'} to draw its route.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* bottom-right overlay controls (gated) */}
|
||||
<div id="ov-br">
|
||||
<button
|
||||
className={`sbt ${animating ? 'active' : ''}`}
|
||||
disabled={!focused || mapPoints.length < 2}
|
||||
onClick={() => {
|
||||
if (!focused || mapPoints.length < 2) return;
|
||||
setAnimating(true);
|
||||
setAnimateNonce((n) => n + 1);
|
||||
window.setTimeout(() => setAnimating(false), 2300);
|
||||
}}
|
||||
title={focused ? 'Replay the route draw' : 'Select a rider to animate its route'}
|
||||
>
|
||||
<span className="sbt-icon"><Play size={14} /></span> {animating ? 'Animating…' : 'Animate Routes'}
|
||||
</button>
|
||||
<button className="sbt" disabled title="Planned-vs-actual compare needs rider GPS telemetry (awaiting backend)">
|
||||
<span className="sbt-icon"><ArrowLeftRight size={14} /></span> Compare
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div id="map-wrap">
|
||||
{/* Live Leaflet route map */}
|
||||
<DispatchMap
|
||||
points={mapPoints}
|
||||
route={Boolean(focused)}
|
||||
routeColor={focused?.color || '#662582'}
|
||||
start={routeStart}
|
||||
resizeKey={`${sidebarCollapsed}|${viewMode}|${focusedId}`}
|
||||
animateNonce={animateNonce}
|
||||
/>
|
||||
|
||||
{/* Contextual note overlaid on the map */}
|
||||
{mapPoints.length === 0 ? (
|
||||
<div className="dmp-overlay-note">
|
||||
<MapIcon size={13} /> No drop coordinates in {focused ? 'this route' : 'these deliveries'} yet.
|
||||
</div>
|
||||
) : !focused ? (
|
||||
<div className="dmp-overlay-note">
|
||||
<MapIcon size={13} /> Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : viewMode === 'riders' ? 'rider' : 'group'} to draw its route.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* bottom-right overlay controls (gated) */}
|
||||
<div id="ov-br">
|
||||
<button
|
||||
className={`sbt ${animating ? 'active' : ''}`}
|
||||
disabled={!focused || mapPoints.length < 2}
|
||||
onClick={() => {
|
||||
if (!focused || mapPoints.length < 2) return;
|
||||
setAnimating(true);
|
||||
setAnimateNonce((n) => n + 1);
|
||||
window.setTimeout(() => setAnimating(false), 2300);
|
||||
}}
|
||||
title={focused ? 'Replay the route draw' : 'Select a rider to animate its route'}
|
||||
>
|
||||
<span className="sbt-icon"><Play size={14} /></span> {animating ? 'Animating…' : 'Animate Routes'}
|
||||
</button>
|
||||
<button className="sbt" disabled title="Planned-vs-actual compare needs rider GPS telemetry (awaiting backend)">
|
||||
<span className="sbt-icon"><ArrowLeftRight size={14} /></span> Compare
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -699,3 +676,42 @@ function FocusedDetail({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Customer card ───────────────────────────────────────────────────────────────────
|
||||
function CustomerCard({ g, onClick, isSelected }: { g: Group; onClick: () => void; isSelected?: boolean }) {
|
||||
const customer = g.raw;
|
||||
const name = customer ? fstr(customer.customername) || fstr(customer.name) : g.name;
|
||||
const phone = customer ? fstr(customer.contactno) || fstr(customer.phone) : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rcard transition-all duration-300 cursor-pointer overflow-hidden relative group ${
|
||||
isSelected
|
||||
? 'border-purple-300 shadow-md bg-purple-50/50'
|
||||
: 'bg-white border-slate-200 hover:border-purple-200 hover:shadow-sm'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-[#662582] rounded-l-md"></div>
|
||||
)}
|
||||
<div className="flex items-center gap-4 p-3.5 pl-4 border-none">
|
||||
<div className={`w-11 h-11 rounded-xl flex items-center justify-center shrink-0 transition-colors ${
|
||||
isSelected ? 'bg-[#662582] text-white shadow-md' : 'bg-slate-100 text-slate-500 group-hover:bg-purple-100 group-hover:text-[#662582]'
|
||||
}`}>
|
||||
<Users size={20} strokeWidth={isSelected ? 2 : 1.5} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`font-bold text-sm truncate tracking-tight ${isSelected ? 'text-[#0f172a]' : 'text-slate-800'}`}>
|
||||
{name || 'Unknown Customer'}
|
||||
</div>
|
||||
{phone && (
|
||||
<div className={`text-xs truncate font-medium mt-0.5 flex items-center gap-1 ${isSelected ? 'text-[#662582]' : 'text-slate-500'}`}>
|
||||
<Phone size={10} /> {phone}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user