diff --git a/.env b/.env new file mode 100644 index 0000000..94eebbd --- /dev/null +++ b/.env @@ -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" diff --git a/.gitignore b/.gitignore index 5a86d2a..199f5b2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,5 @@ dist/ coverage/ .DS_Store *.log -.env* + !.env.example diff --git a/fix.js b/fix.js new file mode 100644 index 0000000..aeca837 --- /dev/null +++ b/fix.js @@ -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); +} diff --git a/fix_queries.js b/fix_queries.js new file mode 100644 index 0000000..a297a0f --- /dev/null +++ b/fix_queries.js @@ -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); +} diff --git a/src/App.tsx b/src/App.tsx index 1c1a860..3a2aeca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -50,6 +50,7 @@ import ReportsView from './components/ReportsView'; import InventoryView from './components/InventoryView'; import SettingsView from './components/SettingsView'; import StoreDetailView from './components/StoreDetailView'; +import DispatchHubView from './components/DispatchHubView'; import LoginView from './components/LoginView'; import UserStorePage from './components/UserStorePage'; import AwaitingApi from './components/AwaitingApi'; @@ -580,7 +581,9 @@ export default function App() { ? (summaryQ.data?.tenantname ? `${summaryQ.data.tenantname} Admin` : 'Admin Console') : currentSection === 'inventory' ? 'Products' - : currentSection.charAt(0).toUpperCase() + currentSection.slice(1), + : currentSection === 'dispatch' + ? 'Console' + : currentSection.charAt(0).toUpperCase() + currentSection.slice(1), icon: currentSection === 'dashboard' ? LayoutDashboard : currentSection === 'inventory' @@ -591,7 +594,9 @@ export default function App() { ? TrendingUp : currentSection === 'settings' ? Settings - : undefined + : currentSection === 'dispatch' + ? Truck + : undefined }} /> @@ -608,8 +613,8 @@ export default function App() { /> {/* Main core pages payload area */} -
-
+
+
} /> } /> + + } /> +
diff --git a/src/components/CustomerDetailPanel.css b/src/components/CustomerDetailPanel.css new file mode 100644 index 0000000..ee23031 --- /dev/null +++ b/src/components/CustomerDetailPanel.css @@ -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 +
+ {focused?.raw ? ( + setFocusedId(null)} /> + ) : ( +
+
+ +
+

Select a customer

+

Choose a customer from the list to view their billing history.

+
+ )}
- ) : !focused ? ( -
- Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : viewMode === 'riders' ? 'rider' : 'group'} to draw its route. -
- ) : null} - - {/* bottom-right overlay controls (gated) */} -
- -
-
+ ) : ( +
+ {/* Live Leaflet route map */} + + + {/* Contextual note overlaid on the map */} + {mapPoints.length === 0 ? ( +
+ No drop coordinates in {focused ? 'this route' : 'these deliveries'} yet. +
+ ) : !focused ? ( +
+ Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : viewMode === 'riders' ? 'rider' : 'group'} to draw its route. +
+ ) : null} + + {/* bottom-right overlay controls (gated) */} +
+ + +
+
+ )} @@ -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 ( +
+ {isSelected && ( +
+ )} +
+
+ +
+
+
+ {name || 'Unknown Customer'} +
+ {phone && ( +
+ {phone} +
+ )} +
+
+
+ ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 839d702..8d6876c 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -70,26 +70,33 @@ export default function Header({ .toUpperCase() || 'NA'; return ( -
+
{/* Brand & Desktop Navigation Tabs */}
- {/* Brand Logo — full wordmark when sidebar open, icon only when collapsed */} - - nearledaily logo - - - {/* Sidebar toggle (Burger Menu) */} - + {/* Brand Logo — full wordmark when sidebar open, icon only when collapsed */} + + nearledaily logo + + + {/* Sidebar toggle (Burger Menu) */} + +
{/* Dynamic Store Name Context */} {storeContext && ( diff --git a/src/components/InventoryView.tsx b/src/components/InventoryView.tsx index 1cbeef2..68b33f3 100644 --- a/src/components/InventoryView.tsx +++ b/src/components/InventoryView.tsx @@ -192,10 +192,10 @@ export default function InventoryView({ const reqsMap: Record = {}; stockRequestsQ.data.forEach((req: any) => { 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] = { locationid: req.locationid, - locationname: loc ? loc.locationname : `Outlet #${req.locationid}`, + locationname: req.locationname || (loc ? loc.locationname : `Outlet #${req.locationid}`), picks: {} }; } @@ -215,6 +215,12 @@ export default function InventoryView({ } }, [activeTab, locations, stockRequestsQ.data]); + useEffect(() => { + if (activeTab === 'requests') { + stockRequestsQ.refetch(); + } + }, [activeTab, stockRequestsQ]); + const updateStockRequestMutation = useFiestaUpdateStockRequest(); const updateProductRequestStatus = (locationid: number, productid: string, status: 'Approved' | 'Rejected' | 'Pending') => { @@ -386,40 +392,38 @@ export default function InventoryView({ } }; const flattenedRequests = useMemo(() => { - const list: any[] = []; - storeRequests.forEach(req => { - Object.entries(req.picks).forEach(([productid, pickData]) => { - let product = products.find(p => String(p.id) === String(productid)); - if (!product) { - const liveMatch = liveMasterCatalog.find((r: any) => String(r.productid) === String(productid)); - if (liveMatch) { - product = { - id: String(liveMatch.productid), - name: String(liveMatch.productname || 'Unknown'), - sku: String(liveMatch.sku || `SKU-${liveMatch.productid}`), - image: String(liveMatch.productimage || 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200'), - category: String(liveMatch.categoryname || 'Uncategorized'), - } as any; - } else { - product = MOCK_GLOBAL_CATALOG.find(p => String(p.id) === String(productid)) as any; - } - } - list.push({ - locationid: req.locationid, - locationname: req.locationname, - productid, - product, - pickData - }); - }); - }); - // 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); - } - return sortedList; - }, [storeRequests, products, requestStoreFilter]); + return storeRequests + .filter(r => requestStoreFilter === 'All Stores' || r.locationname === requestStoreFilter) + .flatMap(store => { + return Object.entries(store.picks) + .filter(([_, pick]) => String(pick.status).toLowerCase() === 'pending') + .map(([productId, pick]) => { + let product = products.find(p => String(p.id) === String(productId)); + if (!product) { + const liveMatch = liveMasterCatalog.find((r: any) => String(r.productid) === String(productId)); + if (liveMatch) { + product = { + id: String(liveMatch.productid), + name: String(liveMatch.productname || 'Unknown'), + sku: String(liveMatch.sku || `SKU-${liveMatch.productid}`), + image: String(liveMatch.productimage || 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200'), + category: String(liveMatch.categoryname || 'Uncategorized'), + } as any; + } else { + product = MOCK_GLOBAL_CATALOG.find(p => String(p.id) === String(productId)) as any; + } + } + return { + locationid: store.locationid, + locationname: store.locationname, + productid: productId, + product, + pickData: pick + }; + }); + }) + .sort((a, b) => new Date(b.pickData.requestedAt).getTime() - new Date(a.pickData.requestedAt).getTime()); + }, [storeRequests, products, requestStoreFilter, liveMasterCatalog]); const requestingStores = useMemo(() => { return Array.from(new Set(locations.map(l => l.locationname))).sort(); diff --git a/src/components/OrderDetailsModal.css b/src/components/OrderDetailsModal.css new file mode 100644 index 0000000..f099e49 --- /dev/null +++ b/src/components/OrderDetailsModal.css @@ -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); } +} diff --git a/src/components/OrderDetailsModal.tsx b/src/components/OrderDetailsModal.tsx new file mode 100644 index 0000000..7e7196e --- /dev/null +++ b/src/components/OrderDetailsModal.tsx @@ -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( +
+
+ +
+ {/* Header */} +
+
+
+ Order Details +
{orderId}
+
+ + {orderDate} +
+
+ +
+ +
+
+ {statusStr} +
+
+
+ + {/* Body */} +
+ {isLoading ? ( +
+ + Fetching order details... +
+ ) : ( + <> +
Order Items
+ + + + + + + + + + + {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 ( + + + + + + + ); + }) + ) : ( + + + + )} + +
ItemPriceQtyTotal
+
{name}
+
₹{price.toLocaleString('en-IN', { maximumFractionDigits: 0 })}x{qty}₹{lineTotal.toLocaleString('en-IN', { maximumFractionDigits: 0 })}
No line items found.
+ +
+
+ Subtotal + ₹{subtotal.toLocaleString('en-IN')} +
+ {(tax > 0) && ( +
+ Tax + ₹{tax.toLocaleString('en-IN')} +
+ )} + {(deliveryFee > 0) && ( +
+ Delivery Fee + ₹{deliveryFee.toLocaleString('en-IN')} +
+ )} + {(discount > 0) && ( +
+ Discount + -₹{discount.toLocaleString('en-IN')} +
+ )} +
+ Grand Total + ₹{grandTotal.toLocaleString('en-IN')} +
+
+ +
+
+
+
Payment Method
+
{paymentMode}
+
+
+
+
Delivery Address
+
{address}
+
+
+ + )} +
+
+
, + document.body + ); +} diff --git a/src/components/OrdersView.tsx b/src/components/OrdersView.tsx index 8d10a01..698b305 100644 --- a/src/components/OrdersView.tsx +++ b/src/components/OrdersView.tsx @@ -27,7 +27,7 @@ interface OrdersViewProps { locationid?: number; /** Merchant tenant to scope to; defaults to the shared constant. */ tenantId?: number; - headerTabs?: React.ReactNode; + date?: string; } type StatusKey = 'created' | 'pending' | 'processing' | 'delivered' | 'cancelled'; @@ -40,11 +40,19 @@ const STATUS_TABS: Array<{ key: StatusKey; label: string }> = [ ]; 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 monthStart = new Date(today.getFullYear(), today.getMonth(), 1); - const [fromdate, setFromdate] = useState(ymd(today)); - const [todate, setTodate] = useState(ymd(today)); + const [fromdate, setFromdate] = useState(date || ymd(today)); + const [todate, setTodate] = useState(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 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 (
- - : ordersQ.isError - ? - : - } - right={ -
- {headerTabs} - - Coimbatore - -
- } - /> +
-
+ +
{/* Date filter */} diff --git a/src/components/ReportsView.tsx b/src/components/ReportsView.tsx index a435800..c3a3121 100644 --- a/src/components/ReportsView.tsx +++ b/src/components/ReportsView.tsx @@ -48,8 +48,8 @@ interface ReportsViewProps { tenantId?: number; } -const MONTH_KEYS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dece']; -const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; +const WEEK_KEYS = ['week1', 'week2', 'week3', 'week4', 'week5']; +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) { 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 ordersDelta = (s && prevS && isGlobalView) ? pctChange(s.total, prevS.total) : 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)}%`; @@ -218,8 +218,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba totalOrdersVal = s?.total ?? 0; deliveredVal = s?.delivered ?? 0; cancelledVal = s?.cancelled ?? 0; - grossRevenueVal = revS?.grossrevenue ?? 0; - avgOrderVal = revS?.avgordervalue ?? 0; + grossRevenueVal = revS?.overallrevenue ?? 0; + avgOrderVal = (s?.total && s.total > 0 && revS) ? revS.overallrevenue / s.total : 0; } else { 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. const insightRows = (() => { return filteredLocations.map(loc => { @@ -309,21 +309,23 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba return { name: locName, - months: (insightData?.ordermonths ?? {}) as Record, + weeks: (insightData?.orderweeks ?? {}) as Record, }; }); })(); // Leaderboard — outlets ranked by current month's live orders. const leaderboard: LeaderboardNode[] = (() => { - const currentMonthIdx = new Date().getMonth(); - const currentMonthKey = MONTH_KEYS[currentMonthIdx]; let rows = filteredLocations.map(loc => { const name = loc.locationname || `Location ${loc.locationid}`; // Find the corresponding insight row for this store 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 }; }); @@ -341,7 +343,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba const heatmapMax = Math.max( 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. @@ -390,7 +392,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba // Derived Top Products 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]); const topProductsByStore = useMemo(() => { @@ -398,7 +400,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba const prods = (store.rows || []).map(stockRowToProduct); return { 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); }, [activeRegionStock]); @@ -641,12 +643,12 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
- Monthly Order Distribution + Weekly Order Distribution
- Busiest Month + Busiest Week
@@ -672,16 +674,16 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba {insightRows.length === 0 ? (
- {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.'}
) : ( - {MONTH_LABELS.map((m) => ( - ))} @@ -692,12 +694,12 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba - {MONTH_KEYS.map((key, mIdx) => { - const val = fnum(row.months[key]); + {WEEK_KEYS.map((key, mIdx) => { + const val = fnum(row.weeks[key]); return (
Outlet Name - {m} + {WEEK_LABELS.map((w) => ( + + {w}
{row.name}
+
- - ) + )} + )} {/* ── My Store Inventory ── */} diff --git a/src/components/StoreDetailView.tsx b/src/components/StoreDetailView.tsx index 6f8118d..9a5fc15 100644 --- a/src/components/StoreDetailView.tsx +++ b/src/components/StoreDetailView.tsx @@ -169,11 +169,11 @@ export default function StoreDetailView({ store, onBack, canManage = true, only, const todayStr = ymd(new Date()); 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 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 const todayOrdersQ = useFiestaAllOrders({ tenantid: tenantId, locationid, fromdate: todayStr, todate: todayStr }); @@ -586,7 +586,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
- Est. Revenue + Today's Revenue
{revenueQ.isLoading ? ( Loading... diff --git a/src/components/UserStorePage.tsx b/src/components/UserStorePage.tsx index 382d987..8fe7017 100644 --- a/src/components/UserStorePage.tsx +++ b/src/components/UserStorePage.tsx @@ -88,12 +88,14 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { // a tenant has multiple outlets do we disambiguate by the user's applocationid // (accepting a row whose locationid equals it too), then by locationid. const apploc = user.applocationid; + const locid = user.locationid; const matchedLoc = locations.length === 1 ? locations[0] - : (locations.find((l) => apploc != null && fnum(l.applocationid) === apploc) ?? - locations.find((l) => apploc != null && fnum(l.locationid) === apploc) ?? - locations.find((l) => user.locationid != null && fnum(l.locationid) === user.locationid) ?? + : (locations.find((l) => apploc != null && apploc > 0 && fnum(l.applocationid) === apploc) ?? + locations.find((l) => apploc != null && apploc > 0 && fnum(l.locationid) === apploc) ?? + locations.find((l) => locid != null && locid > 0 && fnum(l.locationid) === locid) ?? + locations.find((l) => locid != null && locid > 0 && fnum(l.applocationid) === locid) ?? null); // 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'; return ; }; + const isInactive = matchedLoc && fstr(matchedLoc.status).toLowerCase() !== 'active'; return (
@@ -315,10 +318,21 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { />
+ {isInactive && ( +
+
+
+ +
+

Store is Inactive

+

This location has been marked as inactive. Operations are disabled.

+
+
+ )} } /> } /> +
} /> +
} /> +
} /> +
} /> +
} /> +
} /> diff --git a/src/components/UserStoreSidebar.tsx b/src/components/UserStoreSidebar.tsx index 67b5e3a..1b90396 100644 --- a/src/components/UserStoreSidebar.tsx +++ b/src/components/UserStoreSidebar.tsx @@ -39,7 +39,7 @@ export default function UserStoreSidebar({ items, isOpen, onClose }: UserStoreSi