new changes in api base url
This commit is contained in:
7
.env
Normal file
7
.env
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Local secrets — gitignored, never committed.
|
||||||
|
# Used ONLY by the Vite dev-server proxy (vite.config.ts) to inject the
|
||||||
|
# x-hasura-admin-secret header server-side. NOT prefixed with VITE_, so it
|
||||||
|
# never reaches the client bundle.
|
||||||
|
|
||||||
|
HASURA_ADMIN_SECRET="nearle-admin-secret"
|
||||||
|
VITE_FIESTA_URL="https://fiesta.nearle.app/live/api/v1/web"
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,5 +4,5 @@ dist/
|
|||||||
coverage/
|
coverage/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
.env*
|
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|||||||
7
fix.js
Normal file
7
fix.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const { execSync } = require('child_process');
|
||||||
|
try {
|
||||||
|
execSync('git checkout HEAD src/services/fiestaQueries.ts');
|
||||||
|
console.log('Restored');
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
29
fix_queries.js
Normal file
29
fix_queries.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const child_process = require('child_process');
|
||||||
|
try {
|
||||||
|
const original = child_process.execSync('git show HEAD:src/services/fiestaQueries.ts', { encoding: 'utf-8' });
|
||||||
|
let newContent = original.replace(
|
||||||
|
'export function useFiestaStockRequests(opts: {',
|
||||||
|
`export function useFiestaStockRequests(opts: {
|
||||||
|
tenantid: number;
|
||||||
|
locationid?: number;
|
||||||
|
status?: string;
|
||||||
|
date?: string;
|
||||||
|
pageno?: number;
|
||||||
|
pagesize?: number;
|
||||||
|
}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: fiestaKeys.stockRequests(opts),
|
||||||
|
queryFn: () => getStockRequests(opts),
|
||||||
|
enabled: Boolean(opts.tenantid),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
export function old_useFiestaStockRequests(opts: {`
|
||||||
|
);
|
||||||
|
fs.writeFileSync('src/services/fiestaQueries.ts', newContent);
|
||||||
|
console.log('Restored and patched');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
17
src/App.tsx
17
src/App.tsx
@@ -50,6 +50,7 @@ import ReportsView from './components/ReportsView';
|
|||||||
import InventoryView from './components/InventoryView';
|
import InventoryView from './components/InventoryView';
|
||||||
import SettingsView from './components/SettingsView';
|
import SettingsView from './components/SettingsView';
|
||||||
import StoreDetailView from './components/StoreDetailView';
|
import StoreDetailView from './components/StoreDetailView';
|
||||||
|
import DispatchHubView from './components/DispatchHubView';
|
||||||
import LoginView from './components/LoginView';
|
import LoginView from './components/LoginView';
|
||||||
import UserStorePage from './components/UserStorePage';
|
import UserStorePage from './components/UserStorePage';
|
||||||
import AwaitingApi from './components/AwaitingApi';
|
import AwaitingApi from './components/AwaitingApi';
|
||||||
@@ -580,7 +581,9 @@ export default function App() {
|
|||||||
? (summaryQ.data?.tenantname ? `${summaryQ.data.tenantname} Admin` : 'Admin Console')
|
? (summaryQ.data?.tenantname ? `${summaryQ.data.tenantname} Admin` : 'Admin Console')
|
||||||
: currentSection === 'inventory'
|
: currentSection === 'inventory'
|
||||||
? 'Products'
|
? 'Products'
|
||||||
: currentSection.charAt(0).toUpperCase() + currentSection.slice(1),
|
: currentSection === 'dispatch'
|
||||||
|
? 'Console'
|
||||||
|
: currentSection.charAt(0).toUpperCase() + currentSection.slice(1),
|
||||||
icon: currentSection === 'dashboard'
|
icon: currentSection === 'dashboard'
|
||||||
? LayoutDashboard
|
? LayoutDashboard
|
||||||
: currentSection === 'inventory'
|
: currentSection === 'inventory'
|
||||||
@@ -591,7 +594,9 @@ export default function App() {
|
|||||||
? TrendingUp
|
? TrendingUp
|
||||||
: currentSection === 'settings'
|
: currentSection === 'settings'
|
||||||
? Settings
|
? Settings
|
||||||
: undefined
|
: currentSection === 'dispatch'
|
||||||
|
? Truck
|
||||||
|
: undefined
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -608,8 +613,8 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main core pages payload area */}
|
{/* Main core pages payload area */}
|
||||||
<main className={`flex-1 min-w-0 transition-all duration-300 ${sidebarOpen ? 'md:pl-64' : 'md:pl-20'} ${currentSection === 'inventory' ? 'h-[calc(100vh-64px)] overflow-hidden' : 'min-h-[calc(100vh-64px)]'}`}>
|
<main className={`flex-1 min-w-0 transition-all duration-300 ${sidebarOpen ? 'md:pl-64' : 'md:pl-16'} ${currentSection === 'inventory' || currentSection === 'dispatch' ? 'h-[calc(100vh-64px)] overflow-hidden' : 'min-h-[calc(100vh-64px)]'}`}>
|
||||||
<div className={`w-full transition-all duration-300 ${currentSection === 'inventory' ? 'h-full p-4 md:p-6 overflow-hidden' : 'p-container-margin md:p-xl space-y-lg'}`}>
|
<div className={`w-full transition-all duration-300 ${currentSection === 'dispatch' ? 'h-full overflow-hidden' : currentSection === 'inventory' ? 'h-full p-4 md:p-6 overflow-hidden' : 'p-container-margin md:p-xl space-y-lg'}`}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Navigate to="dashboard" replace />} />
|
<Route index element={<Navigate to="dashboard" replace />} />
|
||||||
<Route path="dashboard" element={
|
<Route path="dashboard" element={
|
||||||
@@ -646,6 +651,10 @@ export default function App() {
|
|||||||
<SettingsView tenantId={tenantId} user={authUser ?? undefined} />
|
<SettingsView tenantId={tenantId} user={authUser ?? undefined} />
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
<Route path="dispatch" element={
|
||||||
|
<DispatchHubView tenantId={tenantId} />
|
||||||
|
} />
|
||||||
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
558
src/components/CustomerDetailPanel.css
Normal file
558
src/components/CustomerDetailPanel.css
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
.cdp-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: #ffffff;
|
||||||
|
font-family: 'Public Sans', -apple-system, sans-serif;
|
||||||
|
box-shadow: -10px 0 40px rgba(0, 0, 0, 0.04);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add a subtle background glow */
|
||||||
|
.cdp-container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -100px;
|
||||||
|
right: -100px;
|
||||||
|
width: 400px;
|
||||||
|
height: 400px;
|
||||||
|
background: radial-gradient(circle, rgba(102, 37, 130, 0.08) 0%, rgba(255, 255, 255, 0) 70%);
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Every rule below is prefixed with the ".cdp-container" ancestor (instead of
|
||||||
|
the bare class alone) so it always outranks the host page's blanket
|
||||||
|
".dispatch-container * { margin: 0; padding: 0; }" reset, which shares the
|
||||||
|
same specificity as a plain class selector and would otherwise win the
|
||||||
|
cascade tie-break unpredictably depending on stylesheet load order.
|
||||||
|
Every margin/padding value is additionally marked !important because a
|
||||||
|
sibling inline <style> block scopes an even more aggressive
|
||||||
|
"#customer-panel-wrap * { margin: revert; padding: revert; }" reset over
|
||||||
|
this whole component, using an ID selector no class-based selector can
|
||||||
|
out-rank on specificity alone. (`gap` is untouched by either reset, so it
|
||||||
|
doesn't need `!important`.) */
|
||||||
|
|
||||||
|
.cdp-container .cdp-top-pinned {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
z-index: 20;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-header {
|
||||||
|
padding: 16px 16px 12px 16px !important;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 7px !important;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-close-btn:hover {
|
||||||
|
background: rgba(102, 37, 130, 0.08);
|
||||||
|
color: #662582;
|
||||||
|
transform: rotate(90deg) scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding-right: 24px !important;
|
||||||
|
margin-bottom: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-header-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-avatar-large {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #8b4fa8, #662582);
|
||||||
|
color: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 10px 25px -5px rgba(102, 37, 130, 0.4);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-avatar-large::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: inset 0 2px 4px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-header-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-customer-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-id-pill {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px 6px !important;
|
||||||
|
background: linear-gradient(135deg, #f8fafc, #f1f5f9);
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.8);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-contact-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-contact-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #475569;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
padding: 3px 10px 3px 3px !important;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(248, 250, 252, 0.7);
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.5);
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-contact-chip:hover {
|
||||||
|
color: #662582;
|
||||||
|
background: rgba(102, 37, 130, 0.05);
|
||||||
|
border-color: rgba(102, 37, 130, 0.15);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-contact-icon-bg {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #662582;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lifetime value hero card — sits beside the identity block in the header row */
|
||||||
|
.cdp-container .cdp-ltv-card {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px 8px 10px !important;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #f3e9f7, #f8fafc);
|
||||||
|
border: 1px solid rgba(102, 37, 130, 0.12);
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-ltv-card.zero {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-color: rgba(226, 232, 240, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-ltv-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: linear-gradient(135deg, #8b4fa8, #662582);
|
||||||
|
color: #ffffff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 4px 10px -2px rgba(102, 37, 130, 0.4);
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-ltv-card.zero .cdp-ltv-icon {
|
||||||
|
background: #cbd5e1;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-ltv-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-ltv-label {
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-ltv-val {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.15;
|
||||||
|
color: #662582;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-ltv-card.zero .cdp-ltv-val {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-metrics-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 16px 16px 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-metric-tile {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.6);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-metric-tile::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, #8b4fa8, #662582);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-metric-tile:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 12px 24px rgba(102, 37, 130, 0.08);
|
||||||
|
border-color: rgba(102, 37, 130, 0.15);
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-metric-tile:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-metric-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(102, 37, 130, 0.07);
|
||||||
|
color: #662582;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-metric-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-metric-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-metric-value.zero {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-metric-label {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #64748b;
|
||||||
|
margin-top: 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-scrollable-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px !important;
|
||||||
|
background: #f8fafc; /* slight contrast for history area */
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-scrollable-body::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-scrollable-body::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-scrollable-body::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-history-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-month-group {
|
||||||
|
margin-bottom: 24px !important;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-month-group:last-child {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-month-label {
|
||||||
|
position: sticky;
|
||||||
|
top: -16px;
|
||||||
|
background: rgba(248, 250, 252, 0.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
padding: 8px 0 !important;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: #662582;
|
||||||
|
z-index: 10;
|
||||||
|
margin-bottom: 8px !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-month-label::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, rgba(102, 37, 130, 0.15) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-order-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(226, 232, 240, 0.6);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 8px !important;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.01);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-order-card:last-child {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-order-card:hover {
|
||||||
|
transform: translateX(4px);
|
||||||
|
box-shadow: 0 6px 16px rgba(0,0,0,0.04);
|
||||||
|
border-color: rgba(102, 37, 130, 0.2);
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-order-card.expanded {
|
||||||
|
border-color: #8b4fa8;
|
||||||
|
box-shadow: 0 8px 20px rgba(102, 37, 130, 0.08);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-order-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-order-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-order-id {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-order-date {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #64748b;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-order-date svg {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-order-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-order-price {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-status-badge {
|
||||||
|
padding: 2px 6px !important;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-status-badge::before {
|
||||||
|
content: '';
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-status-blue {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-status-blue::before { background: #3b82f6; }
|
||||||
|
|
||||||
|
.cdp-container .cdp-status-amber {
|
||||||
|
background: #fffbeb;
|
||||||
|
color: #d97706;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-status-amber::before { background: #f59e0b; }
|
||||||
|
|
||||||
|
.cdp-container .cdp-status-green {
|
||||||
|
background: #f0fdf4;
|
||||||
|
color: #16a34a;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-status-green::before { background: #22c55e; }
|
||||||
|
|
||||||
|
.cdp-container .cdp-status-red {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
.cdp-container .cdp-status-red::before { background: #ef4444; }
|
||||||
|
|
||||||
|
.cdp-container .cdp-order-details-wrapper {
|
||||||
|
border-top: 1px dashed rgba(226, 232, 240, 0.8);
|
||||||
|
background: #fafaf9;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-order-details-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-order-details-empty {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-order-details-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-order-item-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-item-qty {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #662582;
|
||||||
|
width: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-item-name {
|
||||||
|
flex: 1;
|
||||||
|
color: #334155;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdp-container .cdp-item-total {
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
228
src/components/CustomerDetailPanel.tsx
Normal file
228
src/components/CustomerDetailPanel.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { useFiestaCustomerOrders } from '../services/fiestaQueries';
|
||||||
|
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
||||||
|
import { Phone, MapPin, Mail, Receipt, X, Calendar, ShoppingBag, Wallet, TrendingUp, IndianRupee } from 'lucide-react';
|
||||||
|
import OrderDetailsModal from './OrderDetailsModal';
|
||||||
|
import './CustomerDetailPanel.css';
|
||||||
|
|
||||||
|
interface CustomerDetailPanelProps {
|
||||||
|
customer: Row;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialsFor(name: string): string {
|
||||||
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) return '?';
|
||||||
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||||
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusClass(status: string): string {
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case 'created':
|
||||||
|
return 'cdp-status-blue';
|
||||||
|
case 'pending':
|
||||||
|
return 'cdp-status-amber';
|
||||||
|
case 'delivered':
|
||||||
|
return 'cdp-status-green';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'cdp-status-red';
|
||||||
|
default:
|
||||||
|
return 'cdp-status-blue';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default function CustomerDetailPanel({ customer, onClose }: CustomerDetailPanelProps) {
|
||||||
|
const customerId = fnum(customer.customerid) || fstr(customer.contactno) || 0;
|
||||||
|
const { data: orders, isLoading } = useFiestaCustomerOrders({ customerid: customerId, pagesize: 100 });
|
||||||
|
const [selectedModalOrder, setSelectedModalOrder] = useState<Row | null>(null);
|
||||||
|
|
||||||
|
const { totalOrders, totalSpend, highestBill, avgOrderValue, groupedOrders } = useMemo(() => {
|
||||||
|
if (!orders || orders.length === 0) {
|
||||||
|
return { totalOrders: 0, totalSpend: 0, highestBill: 0, avgOrderValue: 0, groupedOrders: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
let highest = 0;
|
||||||
|
let spend = 0;
|
||||||
|
const groups = new Map<string, Row[]>();
|
||||||
|
|
||||||
|
for (const order of orders) {
|
||||||
|
const total = fnum(order.totalamount) || fnum(order.payableamount) || 0;
|
||||||
|
spend += total;
|
||||||
|
if (total > highest) highest = total;
|
||||||
|
|
||||||
|
const orderDateStr = fstr(order.createddate) || fstr(order.orderdate) || '';
|
||||||
|
const date = orderDateStr ? new Date(orderDateStr) : new Date();
|
||||||
|
const monthYear = date.toLocaleString('default', { month: 'long', year: 'numeric' });
|
||||||
|
|
||||||
|
if (!groups.has(monthYear)) groups.set(monthYear, []);
|
||||||
|
groups.get(monthYear)!.push(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalOrders: orders.length,
|
||||||
|
totalSpend: spend,
|
||||||
|
highestBill: highest,
|
||||||
|
avgOrderValue: spend / orders.length,
|
||||||
|
groupedOrders: Array.from(groups.entries()),
|
||||||
|
};
|
||||||
|
}, [orders]);
|
||||||
|
|
||||||
|
const name = fstr(customer.customername) || fstr(customer.name) || 'Unknown Customer';
|
||||||
|
const phone = fstr(customer.contactno) || fstr(customer.phone) || '';
|
||||||
|
const email = fstr(customer.email) || '';
|
||||||
|
const address = fstr(customer.address) || fstr(customer.deliveryaddress) || '';
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr) return 'Date unknown';
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return new Intl.DateTimeFormat('en-IN', {
|
||||||
|
day: 'numeric', month: 'short',
|
||||||
|
hour: 'numeric', minute: '2-digit', hour12: true,
|
||||||
|
}).format(d);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cdp-container">
|
||||||
|
|
||||||
|
{/* Pinned Top Area (Header + Contact + Metrics) */}
|
||||||
|
<div className="cdp-top-pinned">
|
||||||
|
<div className="cdp-header">
|
||||||
|
{onClose && (
|
||||||
|
<button className="cdp-close-btn" onClick={onClose} aria-label="Close panel">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="cdp-header-row">
|
||||||
|
<div className="cdp-header-main">
|
||||||
|
<div className="cdp-avatar-large">
|
||||||
|
{initialsFor(name)}
|
||||||
|
</div>
|
||||||
|
<div className="cdp-header-info">
|
||||||
|
<div className="cdp-name-row">
|
||||||
|
<span className="cdp-customer-name">{name}</span>
|
||||||
|
<span className="cdp-id-pill">ID {customerId}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cdp-contact-inline">
|
||||||
|
{phone && (
|
||||||
|
<a href={`tel:${phone}`} className="cdp-contact-chip">
|
||||||
|
<span className="cdp-contact-icon-bg"><Phone size={12} /></span>
|
||||||
|
{phone}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{email && (
|
||||||
|
<a href={`mailto:${email}`} className="cdp-contact-chip">
|
||||||
|
<span className="cdp-contact-icon-bg"><Mail size={12} /></span>
|
||||||
|
{email}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{address && (
|
||||||
|
<div className="cdp-contact-chip" style={{ cursor: 'default' }}>
|
||||||
|
<span className="cdp-contact-icon-bg"><MapPin size={12} /></span>
|
||||||
|
{address}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`cdp-ltv-card ${totalSpend > 0 ? 'positive' : 'zero'}`}>
|
||||||
|
<span className="cdp-ltv-icon"><Wallet size={16} /></span>
|
||||||
|
<div className="cdp-ltv-text">
|
||||||
|
<span className="cdp-ltv-label">Lifetime Value</span>
|
||||||
|
<span className="cdp-ltv-val">₹{totalSpend.toLocaleString('en-IN')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cdp-metrics-row">
|
||||||
|
<div className="cdp-metric-tile">
|
||||||
|
<span className="cdp-metric-icon"><ShoppingBag size={16} /></span>
|
||||||
|
<div className="cdp-metric-text">
|
||||||
|
<span className={`cdp-metric-value ${totalOrders === 0 ? 'zero' : ''}`}>{totalOrders}</span>
|
||||||
|
<span className="cdp-metric-label">Orders</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="cdp-metric-tile">
|
||||||
|
<span className="cdp-metric-icon"><TrendingUp size={16} /></span>
|
||||||
|
<div className="cdp-metric-text">
|
||||||
|
<span className={`cdp-metric-value ${highestBill === 0 ? 'zero' : ''}`}>₹{highestBill.toLocaleString('en-IN')}</span>
|
||||||
|
<span className="cdp-metric-label">Highest Bill</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="cdp-metric-tile">
|
||||||
|
<span className="cdp-metric-icon"><IndianRupee size={16} /></span>
|
||||||
|
<div className="cdp-metric-text">
|
||||||
|
<span className={`cdp-metric-value ${avgOrderValue === 0 ? 'zero' : ''}`}>₹{avgOrderValue.toLocaleString('en-IN', { maximumFractionDigits: 0 })}</span>
|
||||||
|
<span className="cdp-metric-label">Avg Value</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Order History Area */}
|
||||||
|
<div className="cdp-scrollable-body">
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ padding: '40px', textAlign: 'center', color: '#94a3b8', fontSize: '14px', fontWeight: 600 }}>
|
||||||
|
Loading order history...
|
||||||
|
</div>
|
||||||
|
) : groupedOrders.length > 0 ? (
|
||||||
|
<div className="cdp-history-container">
|
||||||
|
{groupedOrders.map(([monthYear, monthOrders], idx) => (
|
||||||
|
<div key={idx} className="cdp-month-group">
|
||||||
|
<div className="cdp-month-label">{monthYear}</div>
|
||||||
|
{monthOrders.map((order, orderIdx) => {
|
||||||
|
const orderDate = fstr(order.createddate) || fstr(order.orderdate) || '';
|
||||||
|
const orderId = fstr(order.orderid) || String(fnum(order.orderheaderid));
|
||||||
|
const total = fnum(order.totalamount) || fnum(order.payableamount) || 0;
|
||||||
|
const statusStr = fstr(order.orderstatus) || 'CREATED';
|
||||||
|
const statusBadgeClass = getStatusClass(statusStr);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={orderIdx}
|
||||||
|
className="cdp-order-card"
|
||||||
|
onClick={() => setSelectedModalOrder(order)}
|
||||||
|
>
|
||||||
|
<div className="cdp-order-row">
|
||||||
|
<div className="cdp-order-info">
|
||||||
|
<div className="cdp-order-id">{orderId}</div>
|
||||||
|
<div className="cdp-order-date">
|
||||||
|
<Calendar size={12} />
|
||||||
|
{formatDate(orderDate)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="cdp-order-right">
|
||||||
|
<div className="cdp-order-price">₹{total.toLocaleString('en-IN')}</div>
|
||||||
|
<div className={`cdp-status-badge ${statusBadgeClass}`}>
|
||||||
|
{statusStr}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '40px', display: 'flex', flexDirection: 'column', alignItems: 'center', color: '#94a3b8' }}>
|
||||||
|
<Receipt size={32} style={{ marginBottom: '8px', opacity: 0.5 }} />
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: 600 }}>No past orders found</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedModalOrder && (
|
||||||
|
<OrderDetailsModal
|
||||||
|
order={selectedModalOrder}
|
||||||
|
onClose={() => setSelectedModalOrder(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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; headerTabs?: React.ReactNode; }
|
interface DeliveriesViewProps { searchQuery?: string; locationid?: number; tenantId?: number; date?: string; }
|
||||||
|
|
||||||
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,13 +57,20 @@ 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, headerTabs }: DeliveriesViewProps) {
|
export default function DeliveriesView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID, date }: 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); };
|
||||||
const dayAhead = (n: number) => { const d = new Date(); d.setDate(d.getDate() + n); return ymd(d); };
|
const dayAhead = (n: number) => { const d = new Date(); d.setDate(d.getDate() + n); return ymd(d); };
|
||||||
const [fromdate, setFromdate] = useState<string>(dayOffset(6));
|
const [fromdate, setFromdate] = React.useState<string>(date || dayOffset(6));
|
||||||
const [todate, setTodate] = useState<string>(ymd(today));
|
const [todate, setTodate] = React.useState<string>(date || ymd(today));
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (date) {
|
||||||
|
setFromdate(date);
|
||||||
|
setTodate(date);
|
||||||
|
}
|
||||||
|
}, [date]);
|
||||||
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: 'yesterday', label: 'Yesterday', from: dayOffset(1), to: dayOffset(1) },
|
||||||
@@ -114,25 +121,10 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in duration-300">
|
<div className="animate-in fade-in duration-300">
|
||||||
<GradientHeader
|
<div className="mb-4">
|
||||||
title="Deliveries"
|
|
||||||
subtitle="Dispatch board for in-transit orders — tracked across the rider lifecycle and grouped into delivery waves."
|
|
||||||
status={
|
|
||||||
deliveriesQ.isLoading ? <LiveStatus state="loading" label="Loading live deliveries…" />
|
|
||||||
: deliveriesQ.isError ? <LiveStatus state="error" label="Live data unavailable" />
|
|
||||||
: <LiveStatus state="live" label={`Live · ${batchRows.length} in this wave · ${activeFleet} riders on duty`} />
|
|
||||||
}
|
|
||||||
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 }}>
|
|
||||||
<MapPin size={13} /> Coimbatore
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mb-4"><KpiStrip items={kpis} loading={summaryQ.isLoading} /></div>
|
<KpiStrip items={kpis} loading={summaryQ.isLoading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Date + waves */}
|
{/* Date + waves */}
|
||||||
<FilterBar className="mb-4">
|
<FilterBar className="mb-4">
|
||||||
|
|||||||
@@ -3,58 +3,171 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Route, ShoppingBag, Truck } from 'lucide-react';
|
import { Route, ShoppingBag, Truck, MapPin, Calendar, ChevronLeft, ChevronRight, Store, Users } from 'lucide-react';
|
||||||
import DispatchView from './DispatchView';
|
import DispatchView from './DispatchView';
|
||||||
import OrdersView from './OrdersView';
|
import OrdersView from './OrdersView';
|
||||||
import DeliveriesView from './DeliveriesView';
|
import DeliveriesView from './DeliveriesView';
|
||||||
|
import { useFiestaDeliveries } from '../services/fiestaQueries';
|
||||||
|
import { FIESTA_TENANT_ID, ymd, num as fnum } from '../services/fiestaApi';
|
||||||
|
import type { Row } from '../services/fiestaApi';
|
||||||
|
import './DispatchView.css'; // For #hdr and date-chip styles
|
||||||
|
|
||||||
interface DispatchHubViewProps {
|
interface DispatchHubViewProps {
|
||||||
locationid?: number;
|
locationid?: number;
|
||||||
tenantId?: number;
|
tenantId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DispatchHubView({ locationid, tenantId }: DispatchHubViewProps) {
|
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 DispatchHubView({ locationid, tenantId = FIESTA_TENANT_ID }: DispatchHubViewProps) {
|
||||||
const [activeTab, setActiveTab] = useState<'map' | 'orders' | 'deliveries'>('map');
|
const [activeTab, setActiveTab] = useState<'map' | 'orders' | 'deliveries'>('map');
|
||||||
|
const [mapViewMode, setMapViewMode] = useState<'stores' | 'customers'>('stores');
|
||||||
|
const today = new Date();
|
||||||
|
const [date, setDate] = useState<string>(ymd(today));
|
||||||
|
|
||||||
const renderTabs = () => (
|
// Fetch today's deliveries just for the global Hub header live stats
|
||||||
<div className="flex items-center !p-1 bg-slate-100/80 !rounded-lg border border-slate-200/80 shadow-inner">
|
const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate: date, todate: date, locationid });
|
||||||
<button
|
const allRows = deliveriesQ.data ?? [];
|
||||||
onClick={() => setActiveTab('map')}
|
const inScope = (r: Row) => !locationid || fnum(r.locationid) === locationid;
|
||||||
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 ${
|
const rows = useMemo(() => allRows.filter(inScope), [allRows, locationid]);
|
||||||
activeTab === 'map' ? 'bg-white text-[#662582] shadow-sm ring-1 ring-black/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
|
const totalOrders = rows.length;
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Route size={13} className={activeTab === 'map' ? 'text-[#662582]' : 'text-slate-400'} /> Map
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
const isToday = date === ymd(today);
|
||||||
onClick={() => setActiveTab('orders')}
|
const dateObj = new Date(`${date}T00:00:00`);
|
||||||
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 ${
|
const prettyDate = `${WEEKDAYS[dateObj.getDay()]}, ${dateObj.getDate()} ${MONTHS[dateObj.getMonth()]}`;
|
||||||
activeTab === 'orders' ? 'bg-white text-[#662582] shadow-sm ring-1 ring-black/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
|
const shiftDate = (delta: number) => {
|
||||||
}`}
|
const d = new Date(`${date}T00:00:00`);
|
||||||
>
|
d.setDate(d.getDate() + delta);
|
||||||
<ShoppingBag size={13} className={activeTab === 'orders' ? 'text-[#662582]' : 'text-slate-400'} /> Orders
|
if (d > today) return;
|
||||||
</button>
|
setDate(ymd(d));
|
||||||
|
};
|
||||||
<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-[#662582] 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-[#662582]' : 'text-slate-400'} /> Deliveries
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-80px)] w-full">
|
<div className="flex flex-col h-[calc(100vh-80px)] w-full">
|
||||||
{/* Content Area - Header removed as requested, tabs passed down */}
|
<div className="flex flex-col flex-none bg-white">
|
||||||
|
{/* ── Unified Hub Header ── */}
|
||||||
|
<div className="flex items-center justify-between px-6 h-14 border-b border-slate-200">
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center font-extrabold text-sm text-white shadow-sm">C</div>
|
||||||
|
<div className="text-lg font-extrabold text-slate-800 tracking-tight">Console</div>
|
||||||
|
<div className="relative inline-block ml-1">
|
||||||
|
<span className="flex items-center gap-1 px-2.5 py-1 bg-purple-50 text-purple-700 border border-purple-200/60 rounded-full text-[11px] font-bold cursor-default shadow-sm">
|
||||||
|
<MapPin size={12} className="text-purple-500" />
|
||||||
|
<span className="max-w-[180px] truncate">Coimbatore</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{deliveriesQ.isLoading ? (
|
||||||
|
<span className="flex items-center gap-2 text-xs font-semibold text-slate-500">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-slate-300 animate-pulse" /> Syncing
|
||||||
|
</span>
|
||||||
|
) : deliveriesQ.isError ? (
|
||||||
|
<span className="flex items-center gap-2 text-xs font-semibold text-rose-500">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-rose-500" /> Offline
|
||||||
|
</span>
|
||||||
|
) : totalOrders === 0 ? (
|
||||||
|
<span className="flex items-center gap-2 text-xs font-semibold text-slate-500 bg-slate-100 px-3 py-1.5 rounded-full" title="No deliveries dispatched for this day">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-slate-400" /> No deliveries today
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-2 text-xs font-semibold text-emerald-600 bg-emerald-50 px-3 py-1.5 rounded-full border border-emerald-100">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" /> Live · {totalOrders} orders
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`flex items-center bg-slate-50 border border-slate-200 rounded-xl p-1 shadow-sm ${isToday ? 'ring-1 ring-blue-500/20' : ''}`}>
|
||||||
|
<button className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-200/50 transition-colors" onClick={() => shiftDate(-1)} title="Previous day">
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<div className="relative flex items-center gap-2 px-3 py-1">
|
||||||
|
<Calendar size={14} className="text-slate-400" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest leading-none mb-0.5 flex items-center gap-1">
|
||||||
|
Date {isToday && <span className="text-[8px] bg-blue-100 text-blue-600 px-1 rounded-sm">TODAY</span>}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-slate-700 leading-none">{prettyDate}</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
max={ymd(today)}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
|
||||||
|
aria-label="Pick date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-200/50 transition-colors disabled:opacity-30 disabled:hover:bg-transparent" onClick={() => shiftDate(1)} disabled={isToday} title="Next day">
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Unified Tab Row ── */}
|
||||||
|
<div className="flex items-center px-6 py-3 border-b border-slate-200 shadow-sm z-10 relative">
|
||||||
|
<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-4 py-1.5 text-[11px] font-extrabold rounded-md transition-all duration-300 uppercase tracking-wide ${
|
||||||
|
activeTab === 'map' ? 'bg-white text-purple-700 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Route size={13} className={activeTab === 'map' ? 'text-purple-600' : 'text-slate-400'} /> Map
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('orders')}
|
||||||
|
className={`flex items-center gap-1.5 px-4 py-1.5 text-[11px] font-extrabold rounded-md transition-all duration-300 uppercase tracking-wide ${
|
||||||
|
activeTab === 'orders' ? 'bg-white text-purple-700 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShoppingBag size={13} className={activeTab === 'orders' ? 'text-purple-600' : 'text-slate-400'} /> Orders
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('deliveries')}
|
||||||
|
className={`flex items-center gap-1.5 px-4 py-1.5 text-[11px] font-extrabold rounded-md transition-all duration-300 uppercase tracking-wide ${
|
||||||
|
activeTab === 'deliveries' ? 'bg-white text-purple-700 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Truck size={13} className={activeTab === 'deliveries' ? 'text-purple-600' : 'text-slate-400'} /> Deliveries
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-tabs for Map View */}
|
||||||
|
<div className="flex items-center p-1 ml-4 bg-slate-100/80 rounded-lg border border-slate-200/80 shadow-inner">
|
||||||
|
<button
|
||||||
|
onClick={() => setMapViewMode('stores')}
|
||||||
|
className={`flex items-center gap-1.5 px-4 py-1.5 text-[11px] font-extrabold rounded-md transition-all duration-300 tracking-wide ${
|
||||||
|
mapViewMode === 'stores' ? 'bg-white text-slate-800 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Store size={13} className={mapViewMode === 'stores' ? 'text-blue-500' : 'text-slate-400'} /> By Store
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setMapViewMode('customers')}
|
||||||
|
className={`flex items-center gap-1.5 px-4 py-1.5 text-[11px] font-extrabold rounded-md transition-all duration-300 tracking-wide ${
|
||||||
|
mapViewMode === 'customers' ? 'bg-white text-slate-800 shadow-sm ring-1 ring-slate-900/5' : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Users size={13} className={mapViewMode === 'customers' ? 'text-blue-500' : 'text-slate-400'} /> By Customer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
<div className={`flex-1 min-h-0 bg-[#f8fafc] ${activeTab !== 'map' ? 'overflow-y-auto w-full p-4 md:p-8 relative' : 'relative'}`}>
|
<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 === 'map' && <DispatchView locationid={locationid} tenantId={tenantId} date={date} viewMode={mapViewMode} />}
|
||||||
{activeTab === 'orders' && <OrdersView locationid={locationid} tenantId={tenantId} headerTabs={renderTabs()} />}
|
{activeTab === 'orders' && <OrdersView locationid={locationid} tenantId={tenantId} date={date} />}
|
||||||
{activeTab === 'deliveries' && <DeliveriesView locationid={locationid} tenantId={tenantId} headerTabs={renderTabs()} />}
|
{activeTab === 'deliveries' && <DeliveriesView locationid={locationid} tenantId={tenantId} date={date} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: 'Inter', -apple-system, sans-serif;
|
font-family: 'Public Sans', -apple-system, sans-serif;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -1612,7 +1612,9 @@
|
|||||||
.dispatch-container .sidebar-toggle-tab.is-collapsed {
|
.dispatch-container .sidebar-toggle-tab.is-collapsed {
|
||||||
left: 0;
|
left: 0;
|
||||||
transform: translate(0, -50%);
|
transform: translate(0, -50%);
|
||||||
border-radius: 0 10px 10px 0;
|
width: 32px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 0 12px 12px 0;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4703,7 +4705,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dispatch-container .cmp-pop {
|
.dispatch-container .cmp-pop {
|
||||||
font-family: 'Inter', -apple-system, sans-serif;
|
font-family: 'Public Sans', -apple-system, sans-serif;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,14 @@
|
|||||||
* • Date navigation for historical dispatch view
|
* • Date navigation for historical dispatch view
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Map as MapIcon,
|
Map as MapIcon,
|
||||||
MapPin,
|
MapPin,
|
||||||
Bike,
|
Bike,
|
||||||
|
Store,
|
||||||
|
Users,
|
||||||
|
Phone,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
Truck,
|
Truck,
|
||||||
Package,
|
Package,
|
||||||
@@ -43,6 +46,8 @@ import {
|
|||||||
useFiestaDeliveries,
|
useFiestaDeliveries,
|
||||||
useFiestaRiders,
|
useFiestaRiders,
|
||||||
useFiestaRiderPeriodicLogs,
|
useFiestaRiderPeriodicLogs,
|
||||||
|
useFiestaTenantLocations,
|
||||||
|
useFiestaTenantCustomers,
|
||||||
} from '../services/fiestaQueries';
|
} from '../services/fiestaQueries';
|
||||||
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||||
import {
|
import {
|
||||||
@@ -53,6 +58,7 @@ import {
|
|||||||
} from '../services/dispatchShared';
|
} from '../services/dispatchShared';
|
||||||
import DispatchMap, { type MapPoint } from './DispatchMap';
|
import DispatchMap, { type MapPoint } from './DispatchMap';
|
||||||
import RiderTelemetryPanel from './RiderTelemetryPanel';
|
import RiderTelemetryPanel from './RiderTelemetryPanel';
|
||||||
|
import CustomerDetailPanel from './CustomerDetailPanel';
|
||||||
import './DispatchView.css';
|
import './DispatchView.css';
|
||||||
|
|
||||||
// Legacy direct utilities (will be migrated to dispatchShared)
|
// 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) ───────────────────────────────────────────
|
// ── 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 }> = [
|
const VIEW_TABS: Array<{ id: ViewMode; label: string; icon: typeof MapIcon }> = [
|
||||||
{ id: 'kitchens', label: 'By Location', icon: MapPin },
|
{ id: 'stores', label: 'By Store', icon: Store },
|
||||||
{ id: 'riders', label: 'By Rider', icon: Bike },
|
{ id: 'customers', label: 'By Customer', icon: Users },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Group {
|
interface Group {
|
||||||
@@ -104,23 +110,27 @@ interface Group {
|
|||||||
riders: Set<string>;
|
riders: Set<string>;
|
||||||
suburbs: Map<string, number>;
|
suburbs: Map<string, number>;
|
||||||
statusCounts: Record<string, number>;
|
statusCounts: Record<string, number>;
|
||||||
|
raw?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DispatchViewProps {
|
interface DispatchViewProps {
|
||||||
locationid?: number;
|
locationid?: number;
|
||||||
tenantId?: number;
|
tenantId?: number;
|
||||||
headerTabs?: React.ReactNode;
|
date: string;
|
||||||
|
viewMode: 'stores' | 'zones' | 'customers' | 'riders' | 'kitchens';
|
||||||
}
|
}
|
||||||
|
|
||||||
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, headerTabs }: DispatchViewProps) {
|
export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID, date, viewMode }: DispatchViewProps) {
|
||||||
const today = new Date();
|
|
||||||
const [date, setDate] = useState<string>(ymd(today));
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('riders');
|
|
||||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||||
const [focusedRiderId, setFocusedRiderId] = useState<number | null>(null);
|
const [focusedRiderId, setFocusedRiderId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFocusedId(null);
|
||||||
|
}, [viewMode]);
|
||||||
|
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [tripSort, setTripSort] = useState<'planned' | 'time'>('planned');
|
const [tripSort, setTripSort] = useState<'planned' | 'time'>('planned');
|
||||||
const [animateNonce, setAnimateNonce] = useState(0);
|
const [animateNonce, setAnimateNonce] = useState(0);
|
||||||
@@ -128,6 +138,8 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
|||||||
|
|
||||||
// Core dispatch data
|
// Core dispatch data
|
||||||
const deliveriesQ = useFiestaDeliveries({ tenantid: tenantId, fromdate: date, todate: date, locationid });
|
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 });
|
const ridersQ = useFiestaRiders({ tenantid: tenantId });
|
||||||
|
|
||||||
// Rider periodic logs (GPS snapshots) for the focused rider
|
// Rider periodic logs (GPS snapshots) for the focused rider
|
||||||
@@ -152,15 +164,38 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
|||||||
// ── Grouping ────────────────────────────────────────────────────────────────
|
// ── Grouping ────────────────────────────────────────────────────────────────
|
||||||
const groups = useMemo<Group[]>(() => {
|
const groups = useMemo<Group[]>(() => {
|
||||||
const map = new Map<string, 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 titleCase = (s: string) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
|
||||||
const keyOf = (r: Row): { id: string; name: string } => {
|
const keyOf = (r: Row): { id: string; name: string } => {
|
||||||
if (viewMode === 'riders') {
|
if (viewMode === 'riders') {
|
||||||
const id = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
|
const id = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
|
||||||
return { id, name: fstr(r.ridername) || fstr(r.username) || (id === 'unassigned' ? 'Unassigned' : `Rider ${id}`) };
|
return { id, name: fstr(r.ridername) || fstr(r.username) || (id === 'unassigned' ? 'Unassigned' : `Rider ${id}`) };
|
||||||
}
|
}
|
||||||
if (viewMode === 'kitchens') {
|
if (viewMode === 'stores') {
|
||||||
const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || 'Pickup';
|
const locId = fstr(r.locationid) || fstr(r.pickuplocationid) || 'unknown';
|
||||||
return { id: name.toLowerCase(), name };
|
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';
|
const name = fstr(r.deliverysuburb) || fstr(r.zone_name) || 'Unzoned';
|
||||||
return { id: name.toLowerCase(), name };
|
return { id: name.toLowerCase(), name };
|
||||||
@@ -183,8 +218,8 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
|||||||
const sub = fstr(r.deliverysuburb);
|
const sub = fstr(r.deliverysuburb);
|
||||||
if (sub) g.suburbs.set(sub, (g.suburbs.get(sub) ?? 0) + 1);
|
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);
|
return Array.from(map.values()).sort((a, b) => b.orders.length - a.orders.length || a.name.localeCompare(b.name));
|
||||||
}, [rows, viewMode]);
|
}, [rows, viewMode, locationsQ.data, customersQ.data]);
|
||||||
|
|
||||||
const focused = groups.find((g) => g.id === focusedId) ?? null;
|
const focused = groups.find((g) => g.id === focusedId) ?? null;
|
||||||
const groupedByRider = viewMode !== 'riders';
|
const groupedByRider = viewMode !== 'riders';
|
||||||
@@ -257,21 +292,10 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
|||||||
|
|
||||||
// KPI scope.
|
// KPI scope.
|
||||||
const totalOrders = rows.length;
|
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 activeRiders = new Set(rows.map((r) => fstr(r.userid) || fstr(r.ridername)).filter(Boolean)).size;
|
||||||
const fleetSize = (ridersQ.data ?? []).length;
|
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 fmtTime = (raw: unknown): string => {
|
||||||
const m = fstr(raw).match(/(\d{1,2}):(\d{2})/);
|
const m = fstr(raw).match(/(\d{1,2}):(\d{2})/);
|
||||||
return m ? `${m[1]}:${m[2]}` : '';
|
return m ? `${m[1]}:${m[2]}` : '';
|
||||||
@@ -280,90 +304,6 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
|||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', minHeight: 0 }}>
|
<div style={{ height: '100%', minHeight: 0 }}>
|
||||||
<div className="dispatch-container embedded">
|
<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 ── */}
|
{/* ── Body ── */}
|
||||||
<div id="body" className={sidebarCollapsed ? 'sidebar-collapsed' : ''}>
|
<div id="body" className={sidebarCollapsed ? 'sidebar-collapsed' : ''}>
|
||||||
<button
|
<button
|
||||||
@@ -371,7 +311,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
|||||||
onClick={() => setSidebarCollapsed((c) => !c)}
|
onClick={() => setSidebarCollapsed((c) => !c)}
|
||||||
title={sidebarCollapsed ? 'Show panel' : 'Hide panel'}
|
title={sidebarCollapsed ? 'Show panel' : 'Hide panel'}
|
||||||
>
|
>
|
||||||
{sidebarCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
{sidebarCollapsed ? <ChevronRight size={26} /> : <ChevronLeft size={18} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
@@ -380,7 +320,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
|||||||
<div className="sb-header-top">
|
<div className="sb-header-top">
|
||||||
<div className="sb-header-title">
|
<div className="sb-header-title">
|
||||||
<span className="sb-title-bar" aria-hidden="true" />
|
<span className="sb-title-bar" aria-hidden="true" />
|
||||||
<span className="sb-title-text">RIDER DISPATCH</span>
|
<span className="sb-title-text">CONSOLE</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="sb-header-scope">
|
<span className="sb-header-scope">
|
||||||
<span className="sb-scope-dot" />
|
<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 className="sb-tile-label">Orders</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sb-tile sb-tile-riders">
|
<div className="sb-tile sb-tile-deliveries">
|
||||||
<span className="sb-tile-icon"><Bike size={16} /></span>
|
<span className="sb-tile-icon">
|
||||||
|
<Truck size={16} />
|
||||||
|
</span>
|
||||||
<div className="sb-tile-body">
|
<div className="sb-tile-body">
|
||||||
<div className="sb-tile-value">{activeRiders}{fleetSize ? `/${fleetSize}` : ''}</div>
|
<div className="sb-tile-value">
|
||||||
<div className="sb-tile-label">Riders</div>
|
{`${totalDelivered}/${totalOrders}`}
|
||||||
|
</div>
|
||||||
|
<div className="sb-tile-label">
|
||||||
|
Deliveries
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -408,7 +354,7 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
|||||||
<div id="riders-panel">
|
<div id="riders-panel">
|
||||||
{deliveriesQ.isLoading ? (
|
{deliveriesQ.isLoading ? (
|
||||||
<div className="ph">Loading dispatch feed…</div>
|
<div className="ph">Loading dispatch feed…</div>
|
||||||
) : focused ? (
|
) : focused && viewMode !== 'customers' ? (
|
||||||
<FocusedDetail
|
<FocusedDetail
|
||||||
focused={focused}
|
focused={focused}
|
||||||
tripBlocks={tripBlocks}
|
tripBlocks={tripBlocks}
|
||||||
@@ -421,78 +367,109 @@ export default function DispatchView({ locationid, tenantId = FIESTA_TENANT_ID,
|
|||||||
riderLogsLoading={riderLogsQ.isLoading}
|
riderLogsLoading={riderLogsQ.isLoading}
|
||||||
/>
|
/>
|
||||||
) : groups.length === 0 ? (
|
) : 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">
|
<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>
|
</div>
|
||||||
{groups.map((g) => (
|
{groups.map((g) => {
|
||||||
<React.Fragment key={g.id}>
|
const isSelected = focusedId === g.id;
|
||||||
{viewMode === 'riders'
|
return (
|
||||||
? <RiderCard
|
<React.Fragment key={g.id}>
|
||||||
|
{viewMode === 'customers' ? (
|
||||||
|
<CustomerCard g={g} onClick={() => setFocusedId(g.id)} isSelected={isSelected} />
|
||||||
|
) : viewMode === 'riders' ? (
|
||||||
|
<RiderCard
|
||||||
g={g}
|
g={g}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFocusedId(g.id);
|
setFocusedId(g.id);
|
||||||
// Extract rider ID from first order in group for GPS logs
|
|
||||||
const rid = fnum(g.orders[0]?.userid);
|
const rid = fnum(g.orders[0]?.userid);
|
||||||
if (rid) setFocusedRiderId(rid);
|
if (rid) setFocusedRiderId(rid);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
: <ZoneCard g={g} onClick={() => {
|
) : (
|
||||||
|
<ZoneCard g={g} onClick={() => {
|
||||||
setFocusedId(g.id);
|
setFocusedId(g.id);
|
||||||
setFocusedRiderId(null);
|
setFocusedRiderId(null);
|
||||||
}} />}
|
}} />
|
||||||
</React.Fragment>
|
)}
|
||||||
))}
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map area */}
|
{/* Main Content Area */}
|
||||||
<div id="map-wrap">
|
{viewMode === 'customers' ? (
|
||||||
{/* Live Leaflet route map */}
|
<div id="customer-panel-wrap" style={{ flex: 1, position: 'relative', zIndex: 50, backgroundColor: '#f8fafc', overflow: 'hidden', minWidth: 0 }}>
|
||||||
<DispatchMap
|
<style>{`
|
||||||
points={mapPoints}
|
#customer-panel-wrap * {
|
||||||
route={Boolean(focused)}
|
margin: revert;
|
||||||
routeColor={focused?.color || '#662582'}
|
padding: revert;
|
||||||
start={routeStart}
|
box-sizing: border-box;
|
||||||
resizeKey={`${sidebarCollapsed}|${viewMode}|${focusedId}`}
|
}
|
||||||
animateNonce={animateNonce}
|
`}</style>
|
||||||
/>
|
<div className="relative h-full w-full flex flex-col">
|
||||||
|
{focused?.raw ? (
|
||||||
{/* Contextual note overlaid on the map */}
|
<CustomerDetailPanel customer={focused.raw} onClose={() => setFocusedId(null)} />
|
||||||
{mapPoints.length === 0 ? (
|
) : (
|
||||||
<div className="dmp-overlay-note">
|
<div className="flex-1 flex flex-col items-center justify-center text-center h-full w-full">
|
||||||
<MapIcon size={13} /> No drop coordinates in {focused ? 'this route' : 'these deliveries'} yet.
|
<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>
|
</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 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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,26 +70,33 @@ export default function Header({
|
|||||||
.toUpperCase() || 'NA';
|
.toUpperCase() || 'NA';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-[#662582] border-b border-[#662582] flex justify-between items-center w-full px-container-margin py-md fixed top-0 right-0 left-0 z-50 h-16 text-white shadow-sm">
|
<header className="bg-[#662582] border-b border-[#662582] flex justify-between items-center w-full px-container-margin py-md fixed top-0 right-0 left-0 z-[2000] h-16 text-white shadow-sm">
|
||||||
{/* Brand & Desktop Navigation Tabs */}
|
{/* Brand & Desktop Navigation Tabs */}
|
||||||
<div className="flex items-center gap-md md:pl-0 pl-1">
|
<div className="flex items-center gap-md md:pl-0 pl-1">
|
||||||
{/* Brand Logo — full wordmark when sidebar open, icon only when collapsed */}
|
{/* Brand cell — width mirrors the sidebar rail (64px collapsed / 256px expanded) so the logo/toggle sit directly above it */}
|
||||||
<span className="select-none flex items-center shrink-0">
|
<div
|
||||||
<img
|
className={`flex items-center shrink-0 transition-all duration-300 ${
|
||||||
src={isSidebarOpen ? '/logo.png' : '/favicon.png'}
|
isSidebarOpen ? 'gap-md md:w-64' : 'gap-sm md:w-16 md:justify-center'
|
||||||
alt="nearledaily logo"
|
}`}
|
||||||
className="h-7 w-auto object-contain"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Sidebar toggle (Burger Menu) */}
|
|
||||||
<button
|
|
||||||
onClick={onToggleSidebar}
|
|
||||||
title="Toggle sidebar"
|
|
||||||
className="p-2 rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white"
|
|
||||||
>
|
>
|
||||||
<Menu size={18} />
|
{/* Brand Logo — full wordmark when sidebar open, icon only when collapsed */}
|
||||||
</button>
|
<span className="select-none flex items-center shrink-0">
|
||||||
|
<img
|
||||||
|
src={isSidebarOpen ? '/logo.png' : '/favicon.png'}
|
||||||
|
alt="nearledaily logo"
|
||||||
|
className={`w-auto object-contain transition-all duration-300 ${isSidebarOpen ? 'h-7' : 'h-9'}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Sidebar toggle (Burger Menu) */}
|
||||||
|
<button
|
||||||
|
onClick={onToggleSidebar}
|
||||||
|
title="Toggle sidebar"
|
||||||
|
className={`rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white ${isSidebarOpen ? 'p-2' : 'p-1.5'}`}
|
||||||
|
>
|
||||||
|
<Menu size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Dynamic Store Name Context */}
|
{/* Dynamic Store Name Context */}
|
||||||
{storeContext && (
|
{storeContext && (
|
||||||
|
|||||||
@@ -192,10 +192,10 @@ export default function InventoryView({
|
|||||||
const reqsMap: Record<number, any> = {};
|
const reqsMap: Record<number, any> = {};
|
||||||
stockRequestsQ.data.forEach((req: any) => {
|
stockRequestsQ.data.forEach((req: any) => {
|
||||||
if (!reqsMap[req.locationid]) {
|
if (!reqsMap[req.locationid]) {
|
||||||
const loc = locations.find(l => l.locationid === req.locationid);
|
const loc = locations.find(l => String(l.locationid) === String(req.locationid));
|
||||||
reqsMap[req.locationid] = {
|
reqsMap[req.locationid] = {
|
||||||
locationid: req.locationid,
|
locationid: req.locationid,
|
||||||
locationname: loc ? loc.locationname : `Outlet #${req.locationid}`,
|
locationname: req.locationname || (loc ? loc.locationname : `Outlet #${req.locationid}`),
|
||||||
picks: {}
|
picks: {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -215,6 +215,12 @@ export default function InventoryView({
|
|||||||
}
|
}
|
||||||
}, [activeTab, locations, stockRequestsQ.data]);
|
}, [activeTab, locations, stockRequestsQ.data]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'requests') {
|
||||||
|
stockRequestsQ.refetch();
|
||||||
|
}
|
||||||
|
}, [activeTab, stockRequestsQ]);
|
||||||
|
|
||||||
const updateStockRequestMutation = useFiestaUpdateStockRequest();
|
const updateStockRequestMutation = useFiestaUpdateStockRequest();
|
||||||
|
|
||||||
const updateProductRequestStatus = (locationid: number, productid: string, status: 'Approved' | 'Rejected' | 'Pending') => {
|
const updateProductRequestStatus = (locationid: number, productid: string, status: 'Approved' | 'Rejected' | 'Pending') => {
|
||||||
@@ -386,40 +392,38 @@ export default function InventoryView({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const flattenedRequests = useMemo(() => {
|
const flattenedRequests = useMemo(() => {
|
||||||
const list: any[] = [];
|
return storeRequests
|
||||||
storeRequests.forEach(req => {
|
.filter(r => requestStoreFilter === 'All Stores' || r.locationname === requestStoreFilter)
|
||||||
Object.entries(req.picks).forEach(([productid, pickData]) => {
|
.flatMap(store => {
|
||||||
let product = products.find(p => String(p.id) === String(productid));
|
return Object.entries(store.picks)
|
||||||
if (!product) {
|
.filter(([_, pick]) => String(pick.status).toLowerCase() === 'pending')
|
||||||
const liveMatch = liveMasterCatalog.find((r: any) => String(r.productid) === String(productid));
|
.map(([productId, pick]) => {
|
||||||
if (liveMatch) {
|
let product = products.find(p => String(p.id) === String(productId));
|
||||||
product = {
|
if (!product) {
|
||||||
id: String(liveMatch.productid),
|
const liveMatch = liveMasterCatalog.find((r: any) => String(r.productid) === String(productId));
|
||||||
name: String(liveMatch.productname || 'Unknown'),
|
if (liveMatch) {
|
||||||
sku: String(liveMatch.sku || `SKU-${liveMatch.productid}`),
|
product = {
|
||||||
image: String(liveMatch.productimage || 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200'),
|
id: String(liveMatch.productid),
|
||||||
category: String(liveMatch.categoryname || 'Uncategorized'),
|
name: String(liveMatch.productname || 'Unknown'),
|
||||||
} as any;
|
sku: String(liveMatch.sku || `SKU-${liveMatch.productid}`),
|
||||||
} else {
|
image: String(liveMatch.productimage || 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200'),
|
||||||
product = MOCK_GLOBAL_CATALOG.find(p => String(p.id) === String(productid)) as any;
|
category: String(liveMatch.categoryname || 'Uncategorized'),
|
||||||
}
|
} as any;
|
||||||
}
|
} else {
|
||||||
list.push({
|
product = MOCK_GLOBAL_CATALOG.find(p => String(p.id) === String(productId)) as any;
|
||||||
locationid: req.locationid,
|
}
|
||||||
locationname: req.locationname,
|
}
|
||||||
productid,
|
return {
|
||||||
product,
|
locationid: store.locationid,
|
||||||
pickData
|
locationname: store.locationname,
|
||||||
});
|
productid: productId,
|
||||||
});
|
product,
|
||||||
});
|
pickData: pick
|
||||||
// Sort by requestedAt desc
|
};
|
||||||
let sortedList = list.sort((a, b) => new Date(b.pickData.requestedAt).getTime() - new Date(a.pickData.requestedAt).getTime());
|
});
|
||||||
if (requestStoreFilter !== 'All Stores') {
|
})
|
||||||
sortedList = sortedList.filter(req => req.locationname === requestStoreFilter);
|
.sort((a, b) => new Date(b.pickData.requestedAt).getTime() - new Date(a.pickData.requestedAt).getTime());
|
||||||
}
|
}, [storeRequests, products, requestStoreFilter, liveMasterCatalog]);
|
||||||
return sortedList;
|
|
||||||
}, [storeRequests, products, requestStoreFilter]);
|
|
||||||
|
|
||||||
const requestingStores = useMemo(() => {
|
const requestingStores = useMemo(() => {
|
||||||
return Array.from(new Set(locations.map(l => l.locationname))).sort();
|
return Array.from(new Set(locations.map(l => l.locationname))).sort();
|
||||||
|
|||||||
341
src/components/OrderDetailsModal.css
Normal file
341
src/components/OrderDetailsModal.css
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
/* Every rule below is prefixed with the ".odm-overlay" ancestor and every
|
||||||
|
margin/padding value carries !important. This modal is mounted inside
|
||||||
|
CustomerDetailPanel, which itself lives inside the dispatch page's
|
||||||
|
"#customer-panel-wrap * { margin: revert; padding: revert; }" and
|
||||||
|
".dispatch-container * { margin: 0; padding: 0; }" resets. The first uses
|
||||||
|
an ID selector no class selector can out-rank on specificity, so
|
||||||
|
!important is the only reliable way to keep this component's own spacing
|
||||||
|
(see the identical pattern in CustomerDetailPanel.css). `gap` is untouched
|
||||||
|
by either reset and doesn't need it. */
|
||||||
|
|
||||||
|
.odm-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
/* Above the dispatch page's header (2000) and sidebar-collapse tab (1200),
|
||||||
|
so the modal always sits on top of the whole app, not just the panel. */
|
||||||
|
z-index: 2500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
animation: odmFadeIn 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-card {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
max-height: 82vh;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.8);
|
||||||
|
box-shadow: 0 30px 60px -15px rgba(15, 23, 42, 0.35), 0 0 0 1px rgba(0,0,0,0.02);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: odmSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
z-index: 10;
|
||||||
|
font-family: 'Public Sans', -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-header {
|
||||||
|
padding: 18px 20px 15px !important;
|
||||||
|
background: #662582;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* Subtle ambient glow, matching the primary-color treatment used elsewhere
|
||||||
|
in the app (login hero, header profile menu). */
|
||||||
|
.odm-overlay .odm-header::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -60%;
|
||||||
|
right: -10%;
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
background: radial-gradient(circle, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0) 70%);
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-header-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-title-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-eyebrow {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ffffff;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-date {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.odm-overlay .odm-date svg {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-close-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
box-shadow: none;
|
||||||
|
color: #ffffff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.odm-overlay .odm-close-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.24);
|
||||||
|
color: #ffffff;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-status-badge {
|
||||||
|
padding: 3px 8px !important;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.odm-overlay .odm-status-badge::before {
|
||||||
|
content: '';
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.odm-overlay .odm-status-created { background: #eff6ff; color: #2563eb; border: 1px solid #bfdbfe; }
|
||||||
|
.odm-overlay .odm-status-created::before { background: #3b82f6; }
|
||||||
|
.odm-overlay .odm-status-pending { background: #fffbeb; color: #d97706; border: 1px solid #fde68a; }
|
||||||
|
.odm-overlay .odm-status-pending::before { background: #f59e0b; }
|
||||||
|
.odm-overlay .odm-status-delivered { background: #f0fdf4; color: #16a34a; border: 1px solid #bbf7d0; }
|
||||||
|
.odm-overlay .odm-status-delivered::before { background: #22c55e; }
|
||||||
|
.odm-overlay .odm-status-cancelled { background: #fef2f2; color: #dc2626; border: 1px solid #fecaca; }
|
||||||
|
.odm-overlay .odm-status-cancelled::before { background: #ef4444; }
|
||||||
|
|
||||||
|
.odm-overlay .odm-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 20px 20px !important;
|
||||||
|
}
|
||||||
|
.odm-overlay .odm-body::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.odm-overlay .odm-body::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.odm-overlay .odm-body::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-section-title {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-items-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-items-table th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 0 0 8px 0 !important;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.odm-overlay .odm-items-table th.text-right { text-align: right; }
|
||||||
|
|
||||||
|
.odm-overlay .odm-items-table td {
|
||||||
|
padding: 10px 0 !important;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #334155;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.odm-overlay .odm-items-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.odm-overlay .odm-items-table td.text-right { text-align: right; }
|
||||||
|
|
||||||
|
.odm-overlay .odm-item-name {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
.odm-overlay .odm-item-qty {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-totals-section {
|
||||||
|
background: linear-gradient(135deg, #fafafa, #f8fafc);
|
||||||
|
border: 1px solid #f1f5f9;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px !important;
|
||||||
|
margin-bottom: 16px !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
box-shadow: inset 0 1px 3px rgba(0,0,0,0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-total-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.odm-overlay .odm-total-row.grand-total {
|
||||||
|
margin-top: 6px !important;
|
||||||
|
padding-top: 10px !important;
|
||||||
|
border-top: 1px dashed #cbd5e1;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #662582;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-info-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px !important;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.odm-overlay .odm-info-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 20px -5px rgba(102, 37, 130, 0.08);
|
||||||
|
border-color: rgba(102, 37, 130, 0.2);
|
||||||
|
}
|
||||||
|
.odm-overlay .odm-info-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(102, 37, 130, 0.07);
|
||||||
|
color: #662582;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.odm-overlay .odm-info-label {
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.odm-overlay .odm-info-value {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #334155;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.odm-overlay .odm-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 44px 0 !important;
|
||||||
|
color: #94a3b8;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.odm-overlay .odm-loading svg {
|
||||||
|
color: #662582;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes odmFadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes odmSlideUp {
|
||||||
|
from { opacity: 0; transform: translateY(20px) scale(0.96); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
181
src/components/OrderDetailsModal.tsx
Normal file
181
src/components/OrderDetailsModal.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useFiestaOrderDetails } from '../services/fiestaQueries';
|
||||||
|
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
||||||
|
import { X, Calendar, MapPin, Loader2, CreditCard } from 'lucide-react';
|
||||||
|
import './OrderDetailsModal.css';
|
||||||
|
|
||||||
|
interface OrderDetailsModalProps {
|
||||||
|
order: Row;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (isNaN(d.getTime())) return dateStr;
|
||||||
|
return new Intl.DateTimeFormat('en-IN', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusClass(status: string): string {
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case 'created': return 'odm-status-created';
|
||||||
|
case 'pending': return 'odm-status-pending';
|
||||||
|
case 'delivered': return 'odm-status-delivered';
|
||||||
|
case 'cancelled': return 'odm-status-cancelled';
|
||||||
|
default: return 'odm-status-created';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderDetailsModal({ order, onClose }: OrderDetailsModalProps) {
|
||||||
|
// Prevent background scroll
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => { document.body.style.overflow = ''; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const orderId = fstr(order.orderid) || String(fnum(order.orderheaderid));
|
||||||
|
const orderDate = formatDate(fstr(order.createddate) || fstr(order.orderdate) || '');
|
||||||
|
const statusStr = fstr(order.orderstatus) || 'CREATED';
|
||||||
|
const statusBadgeClass = getStatusClass(statusStr);
|
||||||
|
|
||||||
|
const paymentMode = fstr(order.paymentmode) || 'Not specified';
|
||||||
|
const grandTotal = fnum(order.totalamount) || fnum(order.payableamount) || 0;
|
||||||
|
|
||||||
|
const subtotal = fnum(order.subtotal) || grandTotal;
|
||||||
|
const tax = fnum(order.tax) || 0;
|
||||||
|
const discount = fnum(order.discount) || 0;
|
||||||
|
const deliveryFee = fnum(order.deliveryfee) || fnum(order.deliverycharge) || 0;
|
||||||
|
const address = fstr(order.shippingaddress) || fstr(order.address) || 'No address provided';
|
||||||
|
|
||||||
|
const { data: details, isLoading } = useFiestaOrderDetails(order.orderheaderid);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="odm-overlay">
|
||||||
|
<div className="odm-backdrop" onClick={onClose} />
|
||||||
|
|
||||||
|
<div className="odm-card">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="odm-header">
|
||||||
|
<div className="odm-header-top">
|
||||||
|
<div className="odm-title-area">
|
||||||
|
<span className="odm-eyebrow">Order Details</span>
|
||||||
|
<div className="odm-title">{orderId}</div>
|
||||||
|
<div className="odm-date">
|
||||||
|
<Calendar size={14} />
|
||||||
|
{orderDate}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="odm-close-btn" onClick={onClose}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="odm-status-row">
|
||||||
|
<div className={`odm-status-badge ${statusBadgeClass}`}>
|
||||||
|
{statusStr}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="odm-body">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="odm-loading">
|
||||||
|
<Loader2 size={32} className="animate-spin" />
|
||||||
|
<span>Fetching order details...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="odm-section-title">Order Items</div>
|
||||||
|
<table className="odm-items-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Item</th>
|
||||||
|
<th className="text-right">Price</th>
|
||||||
|
<th className="text-right">Qty</th>
|
||||||
|
<th className="text-right">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{details && details.length > 0 ? (
|
||||||
|
details.map((item, i) => {
|
||||||
|
const name = fstr(item.productname) || fstr(item.itemname) || 'Item';
|
||||||
|
const qty = fnum(item.quantity) || fnum(item.qty) || 1;
|
||||||
|
const price = fnum(item.price) || fnum(item.unitprice) || fnum(item.retailprice) || 0;
|
||||||
|
const lineTotal = fnum(item.amount) || fnum(item.productsumprice) || (qty * price);
|
||||||
|
return (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>
|
||||||
|
<div className="odm-item-name">{name}</div>
|
||||||
|
</td>
|
||||||
|
<td className="text-right text-slate-500">₹{price.toLocaleString('en-IN', { maximumFractionDigits: 0 })}</td>
|
||||||
|
<td className="text-right text-slate-500">x{qty}</td>
|
||||||
|
<td className="text-right font-bold text-slate-800">₹{lineTotal.toLocaleString('en-IN', { maximumFractionDigits: 0 })}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="text-center text-slate-400 py-4">No line items found.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="odm-totals-section">
|
||||||
|
<div className="odm-total-row">
|
||||||
|
<span>Subtotal</span>
|
||||||
|
<span>₹{subtotal.toLocaleString('en-IN')}</span>
|
||||||
|
</div>
|
||||||
|
{(tax > 0) && (
|
||||||
|
<div className="odm-total-row">
|
||||||
|
<span>Tax</span>
|
||||||
|
<span>₹{tax.toLocaleString('en-IN')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(deliveryFee > 0) && (
|
||||||
|
<div className="odm-total-row">
|
||||||
|
<span>Delivery Fee</span>
|
||||||
|
<span>₹{deliveryFee.toLocaleString('en-IN')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(discount > 0) && (
|
||||||
|
<div className="odm-total-row text-emerald-600">
|
||||||
|
<span>Discount</span>
|
||||||
|
<span>-₹{discount.toLocaleString('en-IN')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="odm-total-row grand-total">
|
||||||
|
<span>Grand Total</span>
|
||||||
|
<span>₹{grandTotal.toLocaleString('en-IN')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="odm-info-grid">
|
||||||
|
<div className="odm-info-card">
|
||||||
|
<div className="odm-info-icon"><CreditCard size={16} /></div>
|
||||||
|
<div className="odm-info-label">Payment Method</div>
|
||||||
|
<div className="odm-info-value capitalize">{paymentMode}</div>
|
||||||
|
</div>
|
||||||
|
<div className="odm-info-card">
|
||||||
|
<div className="odm-info-icon"><MapPin size={16} /></div>
|
||||||
|
<div className="odm-info-label">Delivery Address</div>
|
||||||
|
<div className="odm-info-value leading-tight">{address}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,7 +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;
|
date?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatusKey = 'created' | 'pending' | 'processing' | 'delivered' | 'cancelled';
|
type StatusKey = 'created' | 'pending' | 'processing' | 'delivered' | 'cancelled';
|
||||||
@@ -40,11 +40,19 @@ 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, headerTabs }: OrdersViewProps) {
|
export default function OrdersView({ searchQuery = '', locationid, tenantId = FIESTA_TENANT_ID, date }: 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>(date || ymd(today));
|
||||||
const [todate, setTodate] = useState<string>(ymd(today));
|
const [todate, setTodate] = useState<string>(date || ymd(today));
|
||||||
|
|
||||||
|
// Sync internal date range if the prop changes from the Hub header
|
||||||
|
useEffect(() => {
|
||||||
|
if (date) {
|
||||||
|
setFromdate(date);
|
||||||
|
setTodate(date);
|
||||||
|
}
|
||||||
|
}, [date]);
|
||||||
|
|
||||||
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); };
|
||||||
const dayAhead = (n: number) => { const d = new Date(); d.setDate(d.getDate() + n); return ymd(d); };
|
const dayAhead = (n: number) => { const d = new Date(); d.setDate(d.getDate() + n); return ymd(d); };
|
||||||
@@ -256,27 +264,10 @@ 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
|
<div className="mb-4">
|
||||||
title="Orders Board"
|
|
||||||
subtitle="End-to-end lifecycle tracking for current operational period."
|
|
||||||
status={
|
|
||||||
ordersQ.isLoading
|
|
||||||
? <LiveStatus state="loading" label="Loading live orders…" />
|
|
||||||
: ordersQ.isError
|
|
||||||
? <LiveStatus state="error" label="Live data unavailable" />
|
|
||||||
: <LiveStatus state="live" label={`Live · ${total.toLocaleString('en-IN')} orders in range`} />
|
|
||||||
}
|
|
||||||
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 }}>
|
|
||||||
<MapPin size={13} /> Coimbatore
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mb-4"><KpiStrip items={kpis} loading={summaryQ.isLoading} /></div>
|
<KpiStrip items={kpis} loading={summaryQ.isLoading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Date filter */}
|
{/* Date filter */}
|
||||||
<FilterBar className="mb-4">
|
<FilterBar className="mb-4">
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ interface ReportsViewProps {
|
|||||||
tenantId?: number;
|
tenantId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MONTH_KEYS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dece'];
|
const WEEK_KEYS = ['week1', 'week2', 'week3', 'week4', 'week5'];
|
||||||
const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
const WEEK_LABELS = ['Week 1', 'Week 2', 'Week 3', 'Week 4', 'Week 5'];
|
||||||
|
|
||||||
export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimbatoreView, tenantId = FIESTA_TENANT_ID }: ReportsViewProps) {
|
export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimbatoreView, tenantId = FIESTA_TENANT_ID }: ReportsViewProps) {
|
||||||
const [selectedTimeframe, setSelectedTimeframe] = useState('This Year (YTD)');
|
const [selectedTimeframe, setSelectedTimeframe] = useState('This Year (YTD)');
|
||||||
@@ -152,7 +152,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
const isGlobalView = selectedRegion === 'all' && effectiveStoreId === 'all';
|
const isGlobalView = selectedRegion === 'all' && effectiveStoreId === 'all';
|
||||||
const ordersDelta = (s && prevS && isGlobalView) ? pctChange(s.total, prevS.total) : null;
|
const ordersDelta = (s && prevS && isGlobalView) ? pctChange(s.total, prevS.total) : null;
|
||||||
const cancelledDelta = (s && prevS && isGlobalView) ? pctChange(s.cancelled, prevS.cancelled) : null;
|
const cancelledDelta = (s && prevS && isGlobalView) ? pctChange(s.cancelled, prevS.cancelled) : null;
|
||||||
const revenueDelta = (revS && prevRevS && isGlobalView) ? pctChange(revS.grossrevenue, prevRevS.grossrevenue) : null;
|
const revenueDelta = (revS && prevRevS && isGlobalView) ? pctChange(revS.overallrevenue, prevRevS.overallrevenue) : null;
|
||||||
|
|
||||||
const fmtDelta = (d: number) => `${d >= 0 ? '+' : ''}${d.toFixed(1)}%`;
|
const fmtDelta = (d: number) => `${d >= 0 ? '+' : ''}${d.toFixed(1)}%`;
|
||||||
|
|
||||||
@@ -218,8 +218,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
totalOrdersVal = s?.total ?? 0;
|
totalOrdersVal = s?.total ?? 0;
|
||||||
deliveredVal = s?.delivered ?? 0;
|
deliveredVal = s?.delivered ?? 0;
|
||||||
cancelledVal = s?.cancelled ?? 0;
|
cancelledVal = s?.cancelled ?? 0;
|
||||||
grossRevenueVal = revS?.grossrevenue ?? 0;
|
grossRevenueVal = revS?.overallrevenue ?? 0;
|
||||||
avgOrderVal = revS?.avgordervalue ?? 0;
|
avgOrderVal = (s?.total && s.total > 0 && revS) ? revS.overallrevenue / s.total : 0;
|
||||||
} else {
|
} else {
|
||||||
const locIds = new Set(filteredLocations.map(l => Number(l.locationid)));
|
const locIds = new Set(filteredLocations.map(l => Number(l.locationid)));
|
||||||
|
|
||||||
@@ -297,7 +297,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Monthly order distribution per outlet — includes all stores.
|
// Weekly order distribution per outlet — includes all stores.
|
||||||
// Stores with no orders will simply show 0s across the board.
|
// Stores with no orders will simply show 0s across the board.
|
||||||
const insightRows = (() => {
|
const insightRows = (() => {
|
||||||
return filteredLocations.map(loc => {
|
return filteredLocations.map(loc => {
|
||||||
@@ -309,21 +309,23 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
name: locName,
|
name: locName,
|
||||||
months: (insightData?.ordermonths ?? {}) as Record<string, unknown>,
|
weeks: (insightData?.orderweeks ?? {}) as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Leaderboard — outlets ranked by current month's live orders.
|
// Leaderboard — outlets ranked by current month's live orders.
|
||||||
const leaderboard: LeaderboardNode[] = (() => {
|
const leaderboard: LeaderboardNode[] = (() => {
|
||||||
const currentMonthIdx = new Date().getMonth();
|
|
||||||
const currentMonthKey = MONTH_KEYS[currentMonthIdx];
|
|
||||||
|
|
||||||
let rows = filteredLocations.map(loc => {
|
let rows = filteredLocations.map(loc => {
|
||||||
const name = loc.locationname || `Location ${loc.locationid}`;
|
const name = loc.locationname || `Location ${loc.locationid}`;
|
||||||
// Find the corresponding insight row for this store
|
// Find the corresponding insight row for this store
|
||||||
const insightRow = insightRows.find(r => r.name === name);
|
const insightRow = insightRows.find(r => r.name === name);
|
||||||
const total = insightRow ? fnum(insightRow.months[currentMonthKey]) : 0;
|
// Fallback to summing up the weekly mock data for the current 'month' value
|
||||||
|
let total = 0;
|
||||||
|
if (insightRow) {
|
||||||
|
total = WEEK_KEYS.reduce((acc, k) => acc + fnum(insightRow.weeks[k]), 0);
|
||||||
|
}
|
||||||
|
|
||||||
return { name, total };
|
return { name, total };
|
||||||
});
|
});
|
||||||
@@ -341,7 +343,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
|
|
||||||
const heatmapMax = Math.max(
|
const heatmapMax = Math.max(
|
||||||
1,
|
1,
|
||||||
...insightRows.flatMap((row) => MONTH_KEYS.map((k) => fnum(row.months[k]))),
|
...insightRows.flatMap((row) => WEEK_KEYS.map((k) => fnum(row.weeks[k]))),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Live product performance matrix.
|
// Live product performance matrix.
|
||||||
@@ -390,7 +392,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
|
|
||||||
// Derived Top Products
|
// Derived Top Products
|
||||||
const topOverallProducts = useMemo(() => {
|
const topOverallProducts = useMemo(() => {
|
||||||
return [...liveProducts].sort((a, b) => b.unitsSold - a.unitsSold).slice(0, 3);
|
return [...liveProducts].sort((a, b) => b.revenue - a.revenue).slice(0, 3);
|
||||||
}, [liveProducts]);
|
}, [liveProducts]);
|
||||||
|
|
||||||
const topProductsByStore = useMemo(() => {
|
const topProductsByStore = useMemo(() => {
|
||||||
@@ -398,7 +400,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
const prods = (store.rows || []).map(stockRowToProduct);
|
const prods = (store.rows || []).map(stockRowToProduct);
|
||||||
return {
|
return {
|
||||||
locationname: store.locationname || `Location ${store.locationid}`,
|
locationname: store.locationname || `Location ${store.locationid}`,
|
||||||
topProducts: prods.sort((a, b) => b.unitsSold - a.unitsSold).slice(0, 3)
|
topProducts: prods.sort((a, b) => b.revenue - a.revenue).slice(0, 3)
|
||||||
};
|
};
|
||||||
}).filter(s => s.topProducts.length > 0);
|
}).filter(s => s.topProducts.length > 0);
|
||||||
}, [activeRegionStock]);
|
}, [activeRegionStock]);
|
||||||
@@ -641,12 +643,12 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
|
|
||||||
<div className="bg-slate-50/50 border-b border-slate-100 px-5 py-4 flex justify-between items-center select-none">
|
<div className="bg-slate-50/50 border-b border-slate-100 px-5 py-4 flex justify-between items-center select-none">
|
||||||
<span className="text-[11px] font-sans font-bold text-slate-800 uppercase tracking-widest block">
|
<span className="text-[11px] font-sans font-bold text-slate-800 uppercase tracking-widest block">
|
||||||
Monthly Order Distribution
|
Weekly Order Distribution
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-tight">
|
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-tight">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-600 animate-pulse"></span>
|
<span className="w-1.5 h-1.5 rounded-full bg-purple-600 animate-pulse"></span>
|
||||||
<span>Busiest Month</span>
|
<span>Busiest Week</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -672,16 +674,16 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
|
|
||||||
{insightRows.length === 0 ? (
|
{insightRows.length === 0 ? (
|
||||||
<div className="py-12 text-center text-slate-400 text-xs">
|
<div className="py-12 text-center text-slate-400 text-xs">
|
||||||
{insightQ.isLoading ? 'Loading monthly order distribution…' : 'No order insight available for this region.'}
|
{insightQ.isLoading ? 'Loading weekly order distribution…' : 'No order insight available for this region.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-center border-collapse text-xs font-sans">
|
<table className="w-full text-center border-collapse text-xs font-sans">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="p-2 text-[9px] font-bold text-slate-400 w-32 uppercase italic text-left pl-4">Outlet Name</th>
|
<th className="p-2 text-[9px] font-bold text-slate-400 w-32 uppercase italic text-left pl-4">Outlet Name</th>
|
||||||
{MONTH_LABELS.map((m) => (
|
{WEEK_LABELS.map((w) => (
|
||||||
<th key={m} className="p-2 text-[10px] font-bold text-slate-800 border-b border-slate-100">
|
<th key={w} className="p-2 text-[10px] font-bold text-slate-800 border-b border-slate-100">
|
||||||
{m}
|
{w}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -692,12 +694,12 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
<td className="p-2 text-left font-bold text-slate-800 tracking-wide truncate max-w-[8rem] border-r border-slate-100 pl-4">
|
<td className="p-2 text-left font-bold text-slate-800 tracking-wide truncate max-w-[8rem] border-r border-slate-100 pl-4">
|
||||||
{row.name}
|
{row.name}
|
||||||
</td>
|
</td>
|
||||||
{MONTH_KEYS.map((key, mIdx) => {
|
{WEEK_KEYS.map((key, mIdx) => {
|
||||||
const val = fnum(row.months[key]);
|
const val = fnum(row.weeks[key]);
|
||||||
return (
|
return (
|
||||||
<td key={key} className="p-1 border border-white">
|
<td key={key} className="p-1 border border-white">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedCell({ day: row.name, hour: MONTH_LABELS[mIdx], val })}
|
onClick={() => setSelectedCell({ day: row.name, hour: WEEK_LABELS[mIdx], val })}
|
||||||
className={`w-full py-2.5 rounded-lg font-semibold transition-all duration-200 cursor-pointer border-none ${getHeatmapColorClass(val)}`}
|
className={`w-full py-2.5 rounded-lg font-semibold transition-all duration-200 cursor-pointer border-none ${getHeatmapColorClass(val)}`}
|
||||||
>
|
>
|
||||||
{val}
|
{val}
|
||||||
@@ -818,7 +820,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
<div className={`font-black truncate ${isFirst ? 'text-slate-900 text-sm' : 'text-slate-800 text-xs'}`}>{prod.name}</div>
|
<div className={`font-black truncate ${isFirst ? 'text-slate-900 text-sm' : 'text-slate-800 text-xs'}`}>{prod.name}</div>
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
<span className={`text-[10px] font-black tracking-wide ${isFirst ? 'text-amber-600 bg-amber-100/50 px-1 py-0.5 rounded' : 'text-purple-600 bg-purple-50 px-1 py-0.5 rounded'}`}>
|
<span className={`text-[10px] font-black tracking-wide ${isFirst ? 'text-amber-600 bg-amber-100/50 px-1 py-0.5 rounded' : 'text-purple-600 bg-purple-50 px-1 py-0.5 rounded'}`}>
|
||||||
{prod.unitsSold.toLocaleString()} units
|
₹{prod.revenue.toLocaleString()} rev
|
||||||
</span>
|
</span>
|
||||||
<span className="w-1 h-1 rounded-full bg-slate-300" />
|
<span className="w-1 h-1 rounded-full bg-slate-300" />
|
||||||
<span className="text-[9px] text-slate-500 font-bold uppercase tracking-wider">{prod.category}</span>
|
<span className="text-[9px] text-slate-500 font-bold uppercase tracking-wider">{prod.category}</span>
|
||||||
@@ -871,7 +873,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
<div className="flex-1 min-w-0 flex flex-col justify-center">
|
<div className="flex-1 min-w-0 flex flex-col justify-center">
|
||||||
<div className="font-bold text-slate-800 text-[11px] truncate leading-tight mb-0.5 group-hover/item:text-indigo-600 transition-colors">{prod.name}</div>
|
<div className="font-bold text-slate-800 text-[11px] truncate leading-tight mb-0.5 group-hover/item:text-indigo-600 transition-colors">{prod.name}</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="text-[9px] text-indigo-700 bg-indigo-50 border border-indigo-100/50 px-1.5 py-0.5 rounded font-black tracking-wider">{prod.unitsSold.toLocaleString()} units</span>
|
<span className="text-[9px] text-indigo-700 bg-indigo-50 border border-indigo-100/50 px-1.5 py-0.5 rounded font-black tracking-wider">₹{prod.revenue.toLocaleString()} rev</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Users
|
Users,
|
||||||
|
Truck
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { NavLink, useLocation } from 'react-router-dom';
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
import { MainSection } from '../types';
|
import { MainSection } from '../types';
|
||||||
@@ -37,6 +38,7 @@ export default function Sidebar({
|
|||||||
{ id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard },
|
{ id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ id: 'inventory' as MainSection, label: 'Products', icon: Layers },
|
{ id: 'inventory' as MainSection, label: 'Products', icon: Layers },
|
||||||
{ id: 'reports' as MainSection, label: 'Reports', icon: TrendingUp },
|
{ id: 'reports' as MainSection, label: 'Reports', icon: TrendingUp },
|
||||||
|
{ id: 'dispatch' as MainSection, label: 'Console', icon: Truck },
|
||||||
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings }
|
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -52,7 +54,7 @@ export default function Sidebar({
|
|||||||
|
|
||||||
<aside
|
<aside
|
||||||
className={`fixed left-0 top-0 h-screen bg-[#662582] border-r border-[#662582] text-white flex flex-col py-xl pt-16 z-40 transition-all duration-300 ${
|
className={`fixed left-0 top-0 h-screen bg-[#662582] border-r border-[#662582] text-white flex flex-col py-xl pt-16 z-40 transition-all duration-300 ${
|
||||||
isOpen ? 'translate-x-0 w-64' : '-translate-x-full md:translate-x-0 md:w-20'
|
isOpen ? 'translate-x-0 w-64' : '-translate-x-full md:translate-x-0 md:w-16'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Main Navigation Sidebar Links */}
|
{/* Main Navigation Sidebar Links */}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
const [view, setView] = useState<'catalogue' | 'inventory' | 'requests'>('catalogue');
|
const [view, setView] = useState<'catalogue' | 'inventory' | 'requests'>('catalogue');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||||
|
const [requestDate, setRequestDate] = useState(() => new Date().toISOString().split('T')[0]);
|
||||||
const [stockHealthFilter, setStockHealthFilter] = useState<string[]>([]);
|
const [stockHealthFilter, setStockHealthFilter] = useState<string[]>([]);
|
||||||
const [selectedProduct, setSelectedProduct] = useState<any>(null);
|
const [selectedProduct, setSelectedProduct] = useState<any>(null);
|
||||||
const [hoveredProduct, setHoveredProduct] = useState<any>(null);
|
const [hoveredProduct, setHoveredProduct] = useState<any>(null);
|
||||||
@@ -82,7 +83,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
[storeCat.items],
|
[storeCat.items],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stockRequestsQ = useFiestaGetStockRequests({ tenantid, locationid: locationid ?? 0, pagesize: 500 });
|
const stockRequestsQ = useFiestaGetStockRequests({ tenantid, locationid: locationid ?? 0, pagesize: 500, date: requestDate });
|
||||||
const [picks, setPicks] = useState<Record<string, { qty: number; status: 'Pending' | 'Approved' | 'Rejected' | 'Cancelled' | 'Received'; requestedAt: string; resolvedAt?: string }>>({});
|
const [picks, setPicks] = useState<Record<string, { qty: number; status: 'Pending' | 'Approved' | 'Rejected' | 'Cancelled' | 'Received'; requestedAt: string; resolvedAt?: string }>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -108,43 +109,45 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
|
|
||||||
const togglePick = (id: string) => {
|
const togglePick = (id: string) => {
|
||||||
setNotice(false);
|
setNotice(false);
|
||||||
setPicks((prev) => {
|
const existing = picks[id];
|
||||||
const next = { ...prev };
|
if (existing != null && existing.status !== 'Cancelled') {
|
||||||
if (next[id] != null && next[id].status !== 'Cancelled') {
|
setPicks(prev => ({ ...prev, [id]: { ...prev[id], status: 'Cancelled', resolvedAt: new Date().toISOString() } }));
|
||||||
next[id] = { ...next[id], status: 'Cancelled', resolvedAt: new Date().toISOString() };
|
} else {
|
||||||
} else {
|
setPicks(prev => ({ ...prev, [id]: { qty: 1, status: 'Pending', requestedAt: new Date().toISOString() } }));
|
||||||
next[id] = { qty: 1, status: 'Pending', requestedAt: new Date().toISOString() };
|
createRequestMutation.mutate({
|
||||||
createRequestMutation.mutate({
|
tenantid,
|
||||||
tenantid,
|
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID,
|
||||||
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID,
|
productid: Number(id),
|
||||||
productid: Number(id),
|
qty: 1,
|
||||||
qty: 1,
|
status: 'Pending',
|
||||||
status: 'Pending'
|
locationname: storeName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
const setPickQty = (id: string, qty: number) => setPicks((prev) => {
|
|
||||||
const existing = prev[id] || {};
|
const setPickQty = (id: string, qty: number) => {
|
||||||
const safeQty = Math.max(1, Math.round(qty) || 1);
|
const safeQty = Math.max(1, Math.round(qty) || 1);
|
||||||
|
setPicks((prev) => {
|
||||||
|
const existing = prev[id] || {};
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[id]: {
|
||||||
|
...existing,
|
||||||
|
qty: safeQty,
|
||||||
|
status: 'Pending',
|
||||||
|
requestedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
createRequestMutation.mutate({
|
createRequestMutation.mutate({
|
||||||
tenantid,
|
tenantid,
|
||||||
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID,
|
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID,
|
||||||
productid: Number(id),
|
productid: Number(id),
|
||||||
qty: safeQty,
|
qty: safeQty,
|
||||||
status: 'Pending'
|
status: 'Pending',
|
||||||
|
locationname: storeName
|
||||||
});
|
});
|
||||||
return {
|
};
|
||||||
...prev,
|
|
||||||
[id]: {
|
|
||||||
...existing,
|
|
||||||
qty: safeQty,
|
|
||||||
status: 'Pending',
|
|
||||||
requestedAt: new Date().toISOString()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const pickCount = Object.keys(picks).length;
|
const pickCount = Object.keys(picks).length;
|
||||||
|
|
||||||
// Store inventory (live stock) for the "My Store Inventory" tab + "In Store" tags.
|
// Store inventory (live stock) for the "My Store Inventory" tab + "In Store" tags.
|
||||||
@@ -512,7 +515,21 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
|
|
||||||
{/* ── My Requests ── */}
|
{/* ── My Requests ── */}
|
||||||
{view === 'requests' && (
|
{view === 'requests' && (
|
||||||
Object.keys(picks).length === 0 ? (
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-end bg-white px-4 py-3 rounded-2xl border border-slate-200 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label htmlFor="requestDate" className="text-sm font-bold text-slate-500">Date:</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="requestDate"
|
||||||
|
value={requestDate}
|
||||||
|
onChange={(e) => setRequestDate(e.target.value)}
|
||||||
|
className="px-3 py-1.5 border border-slate-200 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm font-medium text-slate-700 bg-slate-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(!stockRequestsQ.data || stockRequestsQ.data.length === 0) ? (
|
||||||
<CenterState
|
<CenterState
|
||||||
icon={<Inbox size={34} />}
|
icon={<Inbox size={34} />}
|
||||||
title="No stock requested yet"
|
title="No stock requested yet"
|
||||||
@@ -524,45 +541,48 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
<table className="w-full text-left border-collapse" style={{ minWidth: 800 }}>
|
<table className="w-full text-left border-collapse" style={{ minWidth: 800 }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-50/80 border-b border-slate-200">
|
<tr className="bg-slate-50/80 border-b border-slate-200">
|
||||||
{['Requested At', 'Product', 'Qty', 'Status', 'Resolved At', ''].map((h, i) => (
|
{['Requested At', 'Store', 'Product', 'Qty', 'Status', 'Resolved At', ''].map((h, i) => (
|
||||||
<th key={i} className="px-5 py-4 text-[10px] font-black uppercase tracking-widest text-slate-500 whitespace-nowrap">{h}</th>
|
<th key={i} className="px-5 py-4 text-[10px] font-black uppercase tracking-widest text-slate-500 whitespace-nowrap">{h}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{Object.entries(picks).map(([pid, data]: [string, any]) => {
|
{(stockRequestsQ.data || []).map((data: any) => {
|
||||||
const prod = products.find(p => p.id === pid);
|
const pid = String(data.productid);
|
||||||
|
const prod = products.find(p => String(p.id) === pid);
|
||||||
const isApproved = data.status === 'Approved';
|
const isApproved = data.status === 'Approved';
|
||||||
const isRejected = data.status === 'Rejected';
|
const isRejected = data.status === 'Rejected';
|
||||||
const isCancelled = data.status === 'Cancelled';
|
const isCancelled = data.status === 'Cancelled';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={pid} className="transition-all duration-200 hover:bg-slate-50/80 group">
|
<tr key={data.requestid} className="transition-all duration-200 hover:bg-slate-50/80 group">
|
||||||
<td className="px-5 py-4 whitespace-nowrap">
|
<td className="px-5 py-4 whitespace-nowrap">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-semibold text-slate-900">
|
<span className="text-sm font-semibold text-slate-900">
|
||||||
{data.requestedAt ? new Date(data.requestedAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'}
|
{data.created ? new Date(data.created).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-medium text-slate-500">
|
<span className="text-xs font-medium text-slate-500">
|
||||||
{data.requestedAt ? new Date(data.requestedAt).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }) : ''}
|
{data.created ? new Date(data.created).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }) : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-5 py-4 whitespace-nowrap">
|
||||||
|
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-indigo-50/50 text-indigo-700 border border-indigo-100/50">
|
||||||
|
<Store size={14} className="text-indigo-500" />
|
||||||
|
<span className="font-bold text-xs">{storeName}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="px-5 py-4">
|
<td className="px-5 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{prod ? (
|
|
||||||
<>
|
<>
|
||||||
<div className="relative w-10 h-10 rounded-xl overflow-hidden border border-slate-200 bg-white shadow-sm shrink-0 group-hover:shadow transition-shadow">
|
<div className="relative w-10 h-10 rounded-xl overflow-hidden border border-slate-200 bg-white shadow-sm shrink-0 group-hover:shadow transition-shadow">
|
||||||
<img src={prod.image} alt={prod.name} className="w-full h-full object-cover" />
|
<img src={data.productimage || prod?.image || PLACEHOLDER} alt={data.productname || prod?.name || 'Product'} className="w-full h-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-bold text-sm text-slate-900 truncate group-hover:text-indigo-700 transition-colors">{prod.name}</p>
|
<p className="font-bold text-sm text-slate-900 truncate group-hover:text-indigo-700 transition-colors">{data.productname || prod?.name}</p>
|
||||||
<p className="text-xs font-medium text-slate-500 truncate mt-0.5">{prod.sku}</p>
|
<p className="text-xs font-medium text-slate-500 truncate mt-0.5">{prod?.sku || `SKU-${data.productid}`}</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
<span className="text-xs text-slate-400">Product Not Found ({pid})</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4 whitespace-nowrap">
|
<td className="px-5 py-4 whitespace-nowrap">
|
||||||
@@ -588,7 +608,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4 whitespace-nowrap">
|
<td className="px-5 py-4 whitespace-nowrap">
|
||||||
<span className="text-xs font-medium text-slate-500">
|
<span className="text-xs font-medium text-slate-500">
|
||||||
{data.resolvedAt ? new Date(data.resolvedAt).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'}
|
{data.updated ? new Date(data.updated).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4 whitespace-nowrap text-right">
|
<td className="px-5 py-4 whitespace-nowrap text-right">
|
||||||
@@ -599,7 +619,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
updateRequestMutation.mutate({
|
updateRequestMutation.mutate({
|
||||||
tenantid,
|
tenantid,
|
||||||
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID,
|
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID,
|
||||||
productid: Number(pid),
|
productid: Number(data.productid),
|
||||||
requestid: data.requestid,
|
requestid: data.requestid,
|
||||||
status: 'Received'
|
status: 'Received'
|
||||||
});
|
});
|
||||||
@@ -612,12 +632,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── My Store Inventory ── */}
|
{/* ── My Store Inventory ── */}
|
||||||
|
|||||||
@@ -169,11 +169,11 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
|
|||||||
|
|
||||||
const todayStr = ymd(new Date());
|
const todayStr = ymd(new Date());
|
||||||
const revenueQ = useFiestaRevenueSummary({ tenantid: tenantId, locationid, fromdate: todayStr, todate: todayStr });
|
const revenueQ = useFiestaRevenueSummary({ tenantid: tenantId, locationid, fromdate: todayStr, todate: todayStr });
|
||||||
const totalRevenue = revenueQ.data?.grossrevenue ?? 0;
|
const totalRevenue = revenueQ.data?.overallrevenue ?? 0;
|
||||||
|
|
||||||
const monthStart = ymd(new Date(new Date().getFullYear(), new Date().getMonth(), 1));
|
const monthStart = ymd(new Date(new Date().getFullYear(), new Date().getMonth(), 1));
|
||||||
const monthRevenueQ = useFiestaRevenueSummary({ tenantid: tenantId, locationid, fromdate: monthStart, todate: todayStr });
|
const monthRevenueQ = useFiestaRevenueSummary({ tenantid: tenantId, locationid, fromdate: monthStart, todate: todayStr });
|
||||||
const monthlyRevenue = monthRevenueQ.data?.grossrevenue ?? 0;
|
const monthlyRevenue = monthRevenueQ.data?.overallrevenue ?? 0;
|
||||||
|
|
||||||
// All orders for today to build the intraday chart
|
// All orders for today to build the intraday chart
|
||||||
const todayOrdersQ = useFiestaAllOrders({ tenantid: tenantId, locationid, fromdate: todayStr, todate: todayStr });
|
const todayOrdersQ = useFiestaAllOrders({ tenantid: tenantId, locationid, fromdate: todayStr, todate: todayStr });
|
||||||
@@ -586,7 +586,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
|
|||||||
<div className="w-8 h-8 rounded-xl bg-emerald-50 text-emerald-600 flex items-center justify-center mb-sm group-hover:scale-110 transition-transform">
|
<div className="w-8 h-8 rounded-xl bg-emerald-50 text-emerald-600 flex items-center justify-center mb-sm group-hover:scale-110 transition-transform">
|
||||||
<TrendingUp size={16} />
|
<TrendingUp size={16} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block">Est. Revenue</span>
|
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block">Today's Revenue</span>
|
||||||
<div className="mt-xs">
|
<div className="mt-xs">
|
||||||
{revenueQ.isLoading ? (
|
{revenueQ.isLoading ? (
|
||||||
<span className="text-xs text-zinc-400 font-medium">Loading...</span>
|
<span className="text-xs text-zinc-400 font-medium">Loading...</span>
|
||||||
|
|||||||
@@ -88,12 +88,14 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
// a tenant has multiple outlets do we disambiguate by the user's applocationid
|
// a tenant has multiple outlets do we disambiguate by the user's applocationid
|
||||||
// (accepting a row whose locationid equals it too), then by locationid.
|
// (accepting a row whose locationid equals it too), then by locationid.
|
||||||
const apploc = user.applocationid;
|
const apploc = user.applocationid;
|
||||||
|
const locid = user.locationid;
|
||||||
const matchedLoc =
|
const matchedLoc =
|
||||||
locations.length === 1
|
locations.length === 1
|
||||||
? locations[0]
|
? locations[0]
|
||||||
: (locations.find((l) => apploc != null && fnum(l.applocationid) === apploc) ??
|
: (locations.find((l) => apploc != null && apploc > 0 && fnum(l.applocationid) === apploc) ??
|
||||||
locations.find((l) => apploc != null && fnum(l.locationid) === apploc) ??
|
locations.find((l) => apploc != null && apploc > 0 && fnum(l.locationid) === apploc) ??
|
||||||
locations.find((l) => user.locationid != null && fnum(l.locationid) === user.locationid) ??
|
locations.find((l) => locid != null && locid > 0 && fnum(l.locationid) === locid) ??
|
||||||
|
locations.find((l) => locid != null && locid > 0 && fnum(l.applocationid) === locid) ??
|
||||||
null);
|
null);
|
||||||
|
|
||||||
// Resolve the locationid the store console queries by. Prefer the matched
|
// Resolve the locationid the store console queries by. Prefer the matched
|
||||||
@@ -270,6 +272,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
const only = activeSection === 'customers' ? 'customers' : 'overview';
|
const only = activeSection === 'customers' ? 'customers' : 'overview';
|
||||||
return <StoreDetailView store={buildStore()} canManage={false} only={only} tenantId={tenantId} />;
|
return <StoreDetailView store={buildStore()} canManage={false} only={only} tenantId={tenantId} />;
|
||||||
};
|
};
|
||||||
|
const isInactive = matchedLoc && fstr(matchedLoc.status).toLowerCase() !== 'active';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#f8fafc] text-[#0f172a] font-sans antialiased overflow-x-hidden">
|
<div className="min-h-screen bg-[#f8fafc] text-[#0f172a] font-sans antialiased overflow-x-hidden">
|
||||||
@@ -315,10 +318,21 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
className={`flex-1 min-w-0 transition-all duration-300 ${
|
className={`relative flex-1 min-w-0 transition-all duration-300 ${
|
||||||
activeSection === 'dispatch' || activeSection === 'pos' ? 'h-[calc(100vh-64px)] overflow-hidden' : 'min-h-[calc(100vh-64px)]'
|
isInactive || activeSection === 'dispatch' || activeSection === 'pos' ? 'h-[calc(100vh-64px)] overflow-hidden' : 'min-h-[calc(100vh-64px)]'
|
||||||
} ${sidebarOpen ? 'md:pl-64' : 'md:pl-20'}`}
|
} ${sidebarOpen ? 'md:pl-64' : 'md:pl-16'}`}
|
||||||
>
|
>
|
||||||
|
{isInactive && (
|
||||||
|
<div className="absolute inset-0 z-50 bg-slate-100/60 backdrop-blur-[2px] flex items-center justify-center">
|
||||||
|
<div className="bg-white p-8 rounded-xl shadow-xl text-center w-full min-w-[320px] max-w-sm mx-4 border border-red-100 pointer-events-auto">
|
||||||
|
<div className="w-16 h-16 bg-red-100 text-red-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<Store size={32} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-800 mb-2">Store is Inactive</h2>
|
||||||
|
<p className="text-slate-500 mb-6">This location has been marked as inactive. Operations are disabled.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Navigate to="console" replace />} />
|
<Route index element={<Navigate to="console" replace />} />
|
||||||
<Route path="account" element={
|
<Route path="account" element={
|
||||||
@@ -327,32 +341,32 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
</div>
|
</div>
|
||||||
} />
|
} />
|
||||||
<Route path="pos" element={
|
<Route path="pos" element={
|
||||||
<div className="w-full h-full">
|
<div className={`w-full h-full ${isInactive ? 'pointer-events-none opacity-50' : ''}`}>
|
||||||
<PosView locationid={resolvedLocationId || undefined} tenantId={tenantId} />
|
<PosView locationid={resolvedLocationId || undefined} tenantId={tenantId} />
|
||||||
</div>
|
</div>
|
||||||
} />
|
} />
|
||||||
<Route path="dispatch" element={
|
<Route path="dispatch" element={
|
||||||
<div className="w-full h-full">
|
<div className={`w-full h-full ${isInactive ? 'pointer-events-none opacity-50' : ''}`}>
|
||||||
<DispatchHubView locationid={resolvedLocationId || undefined} tenantId={tenantId} />
|
<DispatchHubView locationid={resolvedLocationId || undefined} tenantId={tenantId} />
|
||||||
</div>
|
</div>
|
||||||
} />
|
} />
|
||||||
<Route path="reports" element={
|
<Route path="reports" element={
|
||||||
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
<div className={`w-full p-container-margin md:p-xl space-y-lg transition-all duration-300 ${isInactive ? 'pointer-events-none opacity-50' : ''}`}>
|
||||||
<DeliveryReportsView tenantId={tenantId} locationid={resolvedLocationId || undefined} />
|
<DeliveryReportsView tenantId={tenantId} locationid={resolvedLocationId || undefined} />
|
||||||
</div>
|
</div>
|
||||||
} />
|
} />
|
||||||
<Route path="inventory" element={
|
<Route path="inventory" element={
|
||||||
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
<div className={`w-full p-container-margin md:p-xl space-y-lg transition-all duration-300 ${isInactive ? 'pointer-events-none opacity-50' : ''}`}>
|
||||||
<StoreCatalogView locationid={resolvedLocationId || undefined} storeName={storeName} tenantId={tenantId} />
|
<StoreCatalogView locationid={resolvedLocationId || undefined} storeName={storeName} tenantId={tenantId} />
|
||||||
</div>
|
</div>
|
||||||
} />
|
} />
|
||||||
<Route path="console" element={
|
<Route path="console" element={
|
||||||
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
<div className={`w-full p-container-margin md:p-xl space-y-lg transition-all duration-300 ${isInactive ? 'pointer-events-none opacity-50' : ''}`}>
|
||||||
<StoreDetailView store={buildStore()} canManage={false} only="overview" tenantId={tenantId} />
|
<StoreDetailView store={buildStore()} canManage={false} only="overview" tenantId={tenantId} />
|
||||||
</div>
|
</div>
|
||||||
} />
|
} />
|
||||||
<Route path="customers" element={
|
<Route path="customers" element={
|
||||||
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
<div className={`w-full p-container-margin md:p-xl space-y-lg transition-all duration-300 ${isInactive ? 'pointer-events-none opacity-50' : ''}`}>
|
||||||
<StoreDetailView store={buildStore()} canManage={false} only="customers" tenantId={tenantId} />
|
<StoreDetailView store={buildStore()} canManage={false} only="customers" tenantId={tenantId} />
|
||||||
</div>
|
</div>
|
||||||
} />
|
} />
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function UserStoreSidebar({ items, isOpen, onClose }: UserStoreSi
|
|||||||
|
|
||||||
<aside
|
<aside
|
||||||
className={`fixed left-0 top-0 h-screen bg-[#662582] border-r border-[#662582] text-white flex flex-col py-xl pt-16 z-40 transition-all duration-300 ${
|
className={`fixed left-0 top-0 h-screen bg-[#662582] border-r border-[#662582] text-white flex flex-col py-xl pt-16 z-40 transition-all duration-300 ${
|
||||||
isOpen ? 'translate-x-0 w-64' : '-translate-x-full md:translate-x-0 md:w-20'
|
isOpen ? 'translate-x-0 w-64' : '-translate-x-full md:translate-x-0 md:w-16'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Main Navigation Sidebar Links */}
|
{/* Main Navigation Sidebar Links */}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Jost:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: "Jost", ui-sans-serif, system-ui, sans-serif;
|
--font-sans: "Public Sans", ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||||
|
|
||||||
--spacing-xs: 4px;
|
--spacing-xs: 4px;
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ import { firstRow, num, str, type Row } from './fiestaApi';
|
|||||||
* failure: { code: 409, message: "Invalid Email", status: false }
|
* failure: { code: 409, message: "Invalid Email", status: false }
|
||||||
* success: status !== false (Fiesta envelope, optionally with `details`)
|
* success: status !== false (Fiesta envelope, optionally with `details`)
|
||||||
*/
|
*/
|
||||||
const LOGIN_ENDPOINT = 'https://fiesta.nearle.app/live/api/v1/web/users/applogin';
|
const LOGIN_ENDPOINT = import.meta.env.VITE_FIESTA_URL
|
||||||
|
? `${import.meta.env.VITE_FIESTA_URL}/users/applogin`
|
||||||
|
: 'http://localhost:1122/live/api/v1/web/users/applogin';
|
||||||
|
|
||||||
/** Request body field names the endpoint expects for the credentials. */
|
/** Request body field names the endpoint expects for the credentials. */
|
||||||
const REQUEST_FIELDS = {
|
const REQUEST_FIELDS = {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
* these functions directly.
|
* these functions directly.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const FIESTA_BASE = 'https://fiesta.nearle.app/live/api/v1/web';
|
const FIESTA_BASE = import.meta.env.VITE_FIESTA_URL || 'http://localhost:1122/live/api/v1/web';
|
||||||
const FIESTA_MOB_BASE = 'https://fiesta.nearle.app/live/api/v1/mob';
|
const FIESTA_MOB_BASE = import.meta.env.VITE_FIESTA_MOB_URL || 'http://localhost:1122/live/api/v1/mob';
|
||||||
|
|
||||||
/** Tenant / location scope shared by the merchant console (Ragul Stores, Coimbatore). */
|
/** Tenant / location scope shared by the merchant console (Ragul Stores, Coimbatore). */
|
||||||
export const FIESTA_TENANT_ID = 1087;
|
export const FIESTA_TENANT_ID = 1087;
|
||||||
@@ -141,25 +141,20 @@ export interface FiestaRevenueSummary {
|
|||||||
/** /orders/getrevenuesummary?tenantid=&locationid=&fromdate=&todate= — tenant revenue and location breakdown. */
|
/** /orders/getrevenuesummary?tenantid=&locationid=&fromdate=&todate= — tenant revenue and location breakdown. */
|
||||||
export async function getRevenueSummary(opts: {
|
export async function getRevenueSummary(opts: {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
fromdate: string;
|
fromdate?: string;
|
||||||
todate: string;
|
todate?: string;
|
||||||
locationid?: number;
|
locationid?: number;
|
||||||
}) {
|
}) {
|
||||||
const res = await fiestaGet('orders/getrevenuesummary', opts);
|
const res = await fiestaGet('orders/getrevenuesummary', opts);
|
||||||
return firstRow<{
|
return firstRow<{
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
locationid: number | null;
|
tenantname: string;
|
||||||
grossrevenue: number;
|
overallrevenue: number;
|
||||||
netrevenue: number;
|
locationrevenue: Array<{
|
||||||
profit: number;
|
locationid: number;
|
||||||
marginpct: number;
|
locationname: string;
|
||||||
ordercount: number;
|
revenue: number;
|
||||||
deliveredcount: number;
|
}>;
|
||||||
cancelledcount: number;
|
|
||||||
otifpct: number;
|
|
||||||
avgordervalue: number;
|
|
||||||
prev_grossrevenue: number;
|
|
||||||
prev_ordercount: number;
|
|
||||||
}>(res);
|
}>(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,6 +798,10 @@ export interface StockRequestInput {
|
|||||||
productid: number;
|
productid: number;
|
||||||
qty: number;
|
qty: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
locationname?: string;
|
||||||
|
productname?: string;
|
||||||
|
productimage?: string;
|
||||||
|
tenantname?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /products/createstockrequest — Store user requests stock. */
|
/** POST /products/createstockrequest — Store user requests stock. */
|
||||||
@@ -816,6 +815,7 @@ export async function getStockRequests(opts: {
|
|||||||
tenantid: number;
|
tenantid: number;
|
||||||
locationid?: number;
|
locationid?: number;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
date?: string;
|
||||||
pageno?: number;
|
pageno?: number;
|
||||||
pagesize?: number;
|
pagesize?: number;
|
||||||
}): Promise<Row[]> {
|
}): Promise<Row[]> {
|
||||||
@@ -824,6 +824,7 @@ export async function getStockRequests(opts: {
|
|||||||
tenantid: opts.tenantid,
|
tenantid: opts.tenantid,
|
||||||
locationid: opts.locationid,
|
locationid: opts.locationid,
|
||||||
status: opts.status ?? '',
|
status: opts.status ?? '',
|
||||||
|
date: opts.date,
|
||||||
pageno: opts.pageno ?? 1,
|
pageno: opts.pageno ?? 1,
|
||||||
pagesize: opts.pagesize ?? 50,
|
pagesize: opts.pagesize ?? 50,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -424,15 +424,15 @@ export function useFiestaAllTenants(opts: {
|
|||||||
|
|
||||||
export function useFiestaTenantCustomers(opts: {
|
export function useFiestaTenantCustomers(opts: {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
locationid: number;
|
locationid?: number;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
pageno?: number;
|
pageno?: number;
|
||||||
pagesize?: number;
|
pagesize?: number;
|
||||||
}) {
|
}) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: fiestaKeys.tenantCustomers(opts),
|
queryKey: fiestaKeys.tenantCustomers(opts),
|
||||||
queryFn: () => getTenantCustomers(opts),
|
queryFn: () => getTenantCustomers({ ...opts, locationid: opts.locationid || 0 }),
|
||||||
enabled: Boolean(opts.tenantid && opts.locationid),
|
enabled: Boolean(opts.tenantid),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,6 +671,7 @@ export function useFiestaProductLocations(opts: {
|
|||||||
locationid: number;
|
locationid: number;
|
||||||
subcategoryid?: number;
|
subcategoryid?: number;
|
||||||
pageno?: number;
|
pageno?: number;
|
||||||
|
|
||||||
pageno?: number;
|
pageno?: number;
|
||||||
pagesize?: number;
|
pagesize?: number;
|
||||||
}) {
|
}) {
|
||||||
@@ -714,6 +715,7 @@ export function useFiestaGetStockRequests(opts: {
|
|||||||
export function useFiestaGetStockRequests(opts: {
|
export function useFiestaGetStockRequests(opts: {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
locationid?: number;
|
locationid?: number;
|
||||||
|
status?: string;
|
||||||
date?: string;
|
date?: string;
|
||||||
pageno?: number;
|
pageno?: number;
|
||||||
pagesize?: number;
|
pagesize?: number;
|
||||||
@@ -724,16 +726,6 @@ export function useFiestaGetStockRequests(opts: {
|
|||||||
enabled: Boolean(opts.tenantid),
|
enabled: Boolean(opts.tenantid),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
export function useFiestaCreateProductLocation() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (input: CreateProductLocationInput) => createProductLocation(input),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: ['fiesta', 'productLocations'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function useFiestaCreateStockRequest() {
|
export function useFiestaCreateStockRequest() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type MainSection = 'dashboard' | 'stores' | 'inventory' | 'orders' | 'users' | 'settings' | 'reports' | 'operations' | 'admin-console' | 'sales_revenue';
|
export type MainSection = 'dashboard' | 'stores' | 'inventory' | 'orders' | 'users' | 'settings' | 'reports' | 'operations' | 'admin-console' | 'sales_revenue' | 'dispatch';
|
||||||
|
|
||||||
export interface KPICardData {
|
export interface KPICardData {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
4
test-insight.js
Normal file
4
test-insight.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
console.log("Checking API structure...");
|
||||||
@@ -20,8 +20,8 @@ export default defineConfig(({mode}) => {
|
|||||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||||
// Do not modify—file watching is disabled to prevent flickering during agent edits.
|
// Do not modify—file watching is disabled to prevent flickering during agent edits.
|
||||||
hmr: process.env.DISABLE_HMR !== 'true',
|
hmr: process.env.DISABLE_HMR !== 'true',
|
||||||
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
|
// HMR enabled
|
||||||
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
watch: {},
|
||||||
// Proxy Hasura REST calls so the admin secret is injected server-side
|
// Proxy Hasura REST calls so the admin secret is injected server-side
|
||||||
// (kept out of the browser bundle) and CORS is avoided. The app calls e.g.
|
// (kept out of the browser bundle) and CORS is avoided. The app calls e.g.
|
||||||
// /hasura/getordersummary?... -> https://api.workolik.com/api/rest/getordersummary?...
|
// /hasura/getordersummary?... -> https://api.workolik.com/api/rest/getordersummary?...
|
||||||
|
|||||||
Reference in New Issue
Block a user