udpates on the ui changesand api integration
This commit is contained in:
BIN
Fiesta_All_Endpoints_With_Tables_And_Params.xlsx
Normal file
BIN
Fiesta_All_Endpoints_With_Tables_And_Params.xlsx
Normal file
Binary file not shown.
112
docs/01_ENDPOINT_CATALOG.md
Normal file
112
docs/01_ENDPOINT_CATALOG.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Fiesta / Workolik Endpoint Catalog
|
||||
|
||||
Source: `Fiesta_All_Endpoints_With_Tables_And_Params.xlsx` (103 endpoints) cross-referenced
|
||||
with the REST clients already in this repo (`src/services/api.ts` → Hasura `/hasura/*`,
|
||||
`src/services/fiestaApi.ts` → Fiesta `/fiesta/*`).
|
||||
|
||||
Legend for **Client** column:
|
||||
- ✅ **wired** — a function + TanStack hook already exists in this repo.
|
||||
- 🟡 **available** — endpoint exists on the backend (in the sheet) but we have **no client yet**; safe to add.
|
||||
- 🔴 **REVIEW_REQUIRED** — the sheet marks the table as `REVIEW_REQUIRED` (mostly POST/PUT auth & mutation routes); shape must be confirmed with backend before wiring.
|
||||
|
||||
> The sheet only documents GET read tables + a handful of mutations. It does **not**
|
||||
> contain any analytics/aggregation, time-series, alerting, telemetry, or audit-log
|
||||
> endpoints. Those gaps are listed in `03_REQUIRED_BACKEND_APIS.md`.
|
||||
|
||||
---
|
||||
|
||||
## App / Config
|
||||
| API | Method | Table | Params | Client |
|
||||
|---|---|---|---|---|
|
||||
| Utils | GET | app_types | tag | ✅ `getAppTypes` |
|
||||
| Resolve subcategories | GET | app_subcategory | moduleid, categoryid | ✅ `getSubcategory` |
|
||||
| System active geofence | GET | app_location | applocationid | ✅ `getAppLocations` |
|
||||
| Global system categories | GET | app_category | None | ✅ `getProductCategories`* |
|
||||
| Global payment & geofence configs | GET | app_config | configid | 🟡 available |
|
||||
| App location config | GET | app_locationconfig | applocationid | ✅ `getAppLocationConfig` |
|
||||
|
||||
## Users / Auth
|
||||
| API | Method | Table | Params | Client |
|
||||
|---|---|---|---|---|
|
||||
| Users list | GET | users | roleid, tenantid, pageno, pagesize, keyword | ✅ `getAllUsers` |
|
||||
| User profile by id | GET | users | userid | ✅ `getUserById` |
|
||||
| Tenant Web Panel Login | POST | REVIEW_REQUIRED | — | ✅ `loginRequest` (weblogin) |
|
||||
| General Application Login | POST | REVIEW_REQUIRED | authname, password, configid, userfcmtoken | ✅ `loginRequest` (applogin) |
|
||||
| Register Web Staff | POST | REVIEW_REQUIRED | — | ✅ `createUser` |
|
||||
| Update Web Staff | PUT | REVIEW_REQUIRED | — | ✅ `updateUser` |
|
||||
| App roles | GET | (approles) | configid | ✅ `getAppRoles` |
|
||||
|
||||
## Orders
|
||||
| API | Method | Table | Params | Client |
|
||||
|---|---|---|---|---|
|
||||
| Orders board | GET | orders | tenantid, locationid, status, pageno, pagesize | ✅ `getOrders` |
|
||||
| System Admin orders board | GET | orders | applocationid, status, pageno, pagesize | 🟡 available |
|
||||
| Order dashboard stats summary | GET | orders | tenantid, fromdate, todate | ✅ `getOrderSummary` |
|
||||
| Annual orders insights | GET | orders | tenantid | ✅ `getOrderInsight` |
|
||||
| Location orders summary | GET | deliveries | tenantid | ✅ `getLocationSummary` |
|
||||
| **Order detailed lines** | GET | orderdetails | orderheaderid | 🟡 **available — not wired (needed for line items)** |
|
||||
| Customer order history | GET | orders | customerid, status, pageno, pagesize | 🟡 available |
|
||||
| Create Web Order | POST | orders+orderdetails | — | 🔴 REVIEW_REQUIRED |
|
||||
| Update Order Status | PUT | orders | — | 🔴 REVIEW_REQUIRED |
|
||||
|
||||
## Deliveries
|
||||
| API | Method | Table | Params | Client |
|
||||
|---|---|---|---|---|
|
||||
| Deliveries board (master) | GET | deliveries | tenantid, fromdate, todate | ✅ `getDeliveries` |
|
||||
| Deliveries summary | GET | deliveries | tenantid, applocationid, fromdate, todate | ✅ `getDeliverySummary` |
|
||||
| Daily delivery insights | GET | deliveries | tenantid | ✅ `getDeliveryInsight` |
|
||||
| Location deliveries summary | GET | deliveries | tenantid | 🟡 available |
|
||||
| **Deliveries financial report summary** | GET | deliveries | tenantid, partnerid, userid, applocationid, fromdate, todate | 🟡 **available — needed for Reports revenue** |
|
||||
| **Fleet rider summary metrics** | GET | partneruser | applocationid, partnerid, tenantid, fromdate, todate | 🟡 **available — needed for fleet KPIs** |
|
||||
| Assign Rider / dispatch | POST | deliveries+deliverydetails | — | 🔴 REVIEW_REQUIRED |
|
||||
| Update pickup / delivered | PUT | deliveries | — | 🔴 REVIEW_REQUIRED |
|
||||
|
||||
## Partners / Riders
|
||||
| API | Method | Table | Params | Client |
|
||||
|---|---|---|---|---|
|
||||
| Active riders | GET | partneruser | partnerid, applocationid, userid, tenantid | ✅ `getRiders` |
|
||||
| Partner profiles | GET | partneruser | partnerid, applocationid, userid | 🟡 available |
|
||||
| Rider shifts | GET | partneruser | applocationid | ✅ `getRiderShifts` |
|
||||
| Rider log sheet | GET | riderlogs | partnerid, applocationid, fromdate, todate | 🟡 available |
|
||||
| Partners | GET | orders | partnerid, status, pageno, pagesize | ✅ `getPartners` |
|
||||
|
||||
## Tenants / Stores
|
||||
| API | Method | Table | Params | Client |
|
||||
|---|---|---|---|---|
|
||||
| All active tenants | GET | tenants | applocationid, status, pageno, pagesize | ✅ `getAllTenants` |
|
||||
| Tenant detail profile | GET | tenants | tenantid, locationid | ✅ `getTenantInfo` |
|
||||
| Tenant outlet locations | GET | tenantlocations | tenantid | ✅ `getTenantLocations` |
|
||||
| Tenant store staff | GET | tenantstaffs | tenantid | 🟡 available |
|
||||
| Logistics pricing slabs | GET | tenantpricing | tenantid, applocationid | 🟡 available |
|
||||
| Delivery time slots config | GET | tenantslot | None | 🟡 available |
|
||||
| Onboard tenant / store / location | POST/PUT | REVIEW_REQUIRED | — | 🔴 REVIEW_REQUIRED |
|
||||
|
||||
## Customers
|
||||
| API | Method | Table | Params | Client |
|
||||
|---|---|---|---|---|
|
||||
| Customers | GET | customers | customerid, contactno | ✅ `getCustomersByTenant`* |
|
||||
| Merchant customers list | GET | tenantcustomers | tenantid, locationid, pageno, pagesize, keyword | ✅ `getTenantCustomers` |
|
||||
| Customer order history | GET | orders | customerid, status, pageno, pagesize | 🟡 available |
|
||||
| Register / update customer | POST/PUT | REVIEW_REQUIRED | — | 🔴 REVIEW_REQUIRED |
|
||||
|
||||
## Products / Stock
|
||||
| API | Method | Table | Params | Client |
|
||||
|---|---|---|---|---|
|
||||
| Stock counts | GET | products | tenantid, categoryid, subcategoryid, approve | ✅ `getProductsCount` |
|
||||
| Global categories | GET | productcategories | None | ✅ `getProductCategories` |
|
||||
| Product subcategories | GET | productsubcategories | categoryid, tenantid | ✅ `getProductSubcategories` |
|
||||
| Product variants | GET | productvariants | tenantid, subcategoryid | ✅ `getProductVariants` |
|
||||
| Master catalog listings | GET | products | tenantid, locationid, subcategoryid, keyword, pageno, pagesize | 🟡 available |
|
||||
| **Live stocks catalog** | GET | productstocks | tenantid, locationid | 🟡 **available — real stock levels** |
|
||||
| Stock statement ledger | GET | product_stock_statement | tenantid, locationid, subcategoryid, pageno, pagesize, keyword | ✅ `getStockStatement` |
|
||||
| **Outlet geofenced inventory** | GET | productlocations | tenantid, locationid, subcategoryid, pageno, pagesize | 🟡 **available — per-outlet inventory** |
|
||||
| Create / update / delete product | POST/PUT/DELETE | REVIEW_REQUIRED | — | 🔴 REVIEW_REQUIRED |
|
||||
| Add multi-product stock entry | POST | REVIEW_REQUIRED | — | 🔴 REVIEW_REQUIRED |
|
||||
|
||||
## Invoice / Payments
|
||||
| API | Method | Table | Params | Client |
|
||||
|---|---|---|---|---|
|
||||
| Invoice insight | GET | invoice_insight | tenantid | ✅ `getInvoiceInsight` |
|
||||
| Payments | GET | paymentrequests | partnerid, status | 🟡 available |
|
||||
|
||||
\* Close match; verify exact route/shape against backend.
|
||||
173
docs/02_MOCK_DATA_GAP_ANALYSIS.md
Normal file
173
docs/02_MOCK_DATA_GAP_ANALYSIS.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Mock-Data → Live-API Gap Analysis
|
||||
|
||||
Goal: remove every hardcoded / mock value in `src/` and back it with a live API before staging.
|
||||
|
||||
Each row is classified:
|
||||
- **WIRE** — a live endpoint already exists (✅ or 🟡 in `01_ENDPOINT_CATALOG.md`); just connect it / remove the mock fallback.
|
||||
- **DERIVE** — no dedicated endpoint, but the value is computable client-side from data we already fetch (e.g. period-over-period deltas by querying two date ranges). No backend work needed.
|
||||
- **NEW API** — no endpoint exists and the value cannot be derived; backend must build it. Spec'd in `03_REQUIRED_BACKEND_APIS.md` under the referenced **[Rxx]** id.
|
||||
|
||||
Central mock source file to delete once empty: **`src/data.ts`**.
|
||||
|
||||
---
|
||||
|
||||
## 1. DashboardView.tsx *(mostly live already)*
|
||||
| UI element | Current source | Class | Target |
|
||||
|---|---|---|---|
|
||||
| Active Outlets / Total / % | live `useTenantLocations` + `useOrderSummary` | OK | — |
|
||||
| Region fulfilment %, delivered/total | live `useOrderSummary` | OK | — |
|
||||
| Monthly Revenue, Monthly Profit | live `useInvoiceInsight` | OK | — |
|
||||
| Per-location orders / dispatched / fulfilled % | live `useFiestaLocationSummary` | OK | — |
|
||||
| KPI card icons / gradients / static subtext labels | hardcoded | keep | cosmetic, not data |
|
||||
| Banner title "Executive Command Center", "Live Core" | hardcoded | keep | cosmetic |
|
||||
|
||||
**Dashboard is ~clean.** Only cosmetic strings remain.
|
||||
|
||||
---
|
||||
|
||||
## 2. ReportsView.tsx *(heaviest mock load)*
|
||||
| UI element | Current source | Class | Target |
|
||||
|---|---|---|---|
|
||||
| Orders KPI value / delivered / cancelled | live `useFiestaOrderSummary` | OK | — |
|
||||
| KPI trend deltas `+12.5%`, `+14.8%`, `-1.2%`, `+8.4%` | hardcoded | **DERIVE** | query current + previous period summary, compute % change |
|
||||
| Revenue value `= delivered × 355` | hardcoded multiplier | **NEW API** | **[R1] revenue summary** (real money, not a guess) |
|
||||
| Active SKU count | live stock length | OK | — |
|
||||
| `CHART_DATA_YTD` (12-mo orders/revenue/cancelled/skus) | hardcoded | **NEW API** | **[R2] order+revenue time-series** (orders partly via `getOrderInsight`; revenue & skus need [R1]/[R2]) |
|
||||
| `getDynamicChartData` month/12M/all-time series | hardcoded | **NEW API** | **[R2]** with `granularity=day|month|year` |
|
||||
| Region scaling multipliers (0.42 / 0.60 / 0.75…) | hardcoded fudge | **NEW API** | **[R2]** filtered by region/location — remove multipliers |
|
||||
| Monthly order heatmap — Coimbatore rows | live `useFiestaOrderInsight` | OK | — |
|
||||
| Monthly order heatmap — Chennai/Bangalore stub rows | hardcoded | **WIRE** | `getOrderInsight` returns all tenant locations; remove stubs |
|
||||
| `MONTH_LABELS`, `MONTH_KEYS` | hardcoded | keep | static axis labels |
|
||||
| Top-nodes leaderboard names / order counts | live (location summary) | OK | — |
|
||||
| Leaderboard **revenue** column | derived from order count | **NEW API** | **[R1]** revenue-per-location |
|
||||
| Product matrix rows | live `useFiestaStockStatement`→`stockRowToProduct` | OK | — |
|
||||
| `unitsSold`/`revenue` per product (mapper uses `debit`×`retailprice`) | approximated | **NEW API** | **[R3] product sales analytics** (true units sold + revenue) |
|
||||
| Expanded-row stock units (142/42/6), bar widths, hub split 60/40 | hardcoded | **WIRE/NEW** | per-outlet via `productlocations` 🟡; hub split = real `productstocks` |
|
||||
| Warehouse Bin `BIN-C…`, "Last Audited" date, barcode bars | hardcoded | **NEW API** | **[R3]** product meta (bin, last-audit) — or drop if not tracked |
|
||||
| Export progress modal | simulated | keep | client UX only |
|
||||
|
||||
---
|
||||
|
||||
## 3. InventoryView.tsx
|
||||
| UI element | Current source | Class | Target |
|
||||
|---|---|---|---|
|
||||
| Banner active-outlets count | live `useFiestaTenantLocations` | OK | — |
|
||||
| Banner total on-hand volume | live `useFiestaStoresStock` | OK | — |
|
||||
| Global catalog grid | live stock → `stockRowToProduct` | OK | — |
|
||||
| Optimal/Low/Critical hub counts | derived from live stock + thresholds | OK | thresholds are business rules, keep |
|
||||
| Per-store SKU lists, capacity bars | live `useFiestaStoresStock` | OK | — but prefer real capacity ([R3]) |
|
||||
| `initialImportLogs` (Live Sync Audit Stream) | mock `data.ts` | **NEW API** | **[R4] import/sync audit log** |
|
||||
| CSV import default template string | hardcoded | keep | UX template |
|
||||
| Nilgiris Dairy / Coimbatore Heritage presets | hardcoded | **WIRE** | catalog presets → `getAllTenants`/master catalog 🟡, or **NEW [R5]** |
|
||||
| Brand Design Studio (theme, colors, bag label, eco-seal) | hardcoded local state | **NEW API** | **[R6] merchant branding config** (GET+PUT) |
|
||||
| Add-Product modal category list | hardcoded | **WIRE** | `getProductCategories` / `getProductSubcategories` |
|
||||
| Add-Product modal default price/stock | hardcoded | keep | form defaults |
|
||||
|
||||
---
|
||||
|
||||
## 4. OperationsView.tsx
|
||||
| UI element | Current source | Class | Target |
|
||||
|---|---|---|---|
|
||||
| Inventory value, low-stock count, SKU count | live `useFiestaStockStatement` | OK | — |
|
||||
| Fulfillment Health `98.4%` | hardcoded | **DERIVE** | from order/delivery summary |
|
||||
| Forecast Efficiency `92%`, Cost savings `₹1.9L/week` | hardcoded | **NEW API** | **[R7] forecasting/insights** (or remove until modelled) |
|
||||
| Catalogue table rows | live `stockRowToProduct` | OK | — |
|
||||
| "Global Inventory Count 4,200 nodes" | hardcoded | **WIRE** | `getProductsCount` |
|
||||
| Catalogue verification toggle | local state only | **NEW API** | product update PUT 🔴 (REVIEW_REQUIRED) |
|
||||
| Import logs table | mock `data.ts` | **NEW API** | **[R4]** (same as Inventory) |
|
||||
| Schema-validator messages ("14 duplicate SKUs") | hardcoded | **NEW API** | **[R4]** import validation result |
|
||||
| Add-SKU / Transfer-Stock modals | local state mutation | **NEW API** | stock entry POST 🔴 + **[R8] stock transfer** |
|
||||
|
||||
---
|
||||
|
||||
## 5. OrdersDeliveriesView.tsx
|
||||
| UI element | Current source | Class | Target |
|
||||
|---|---|---|---|
|
||||
| KPI tiles (deliveries in range, pending, completed, fleet) | live `useFiestaDeliveries` | OK | — |
|
||||
| Date presets / range | client state | OK | — |
|
||||
| Order list rows | live `deliveryRowToOrder` | OK | — |
|
||||
| **Order line items** (`items: []` always empty) | empty / itemCount only | **WIRE** | **`orderdetails?orderheaderid=`** 🟡 (exists!) |
|
||||
| "Create Simulated Order" + MOCK_NAMES/STREETS/ITEMS | hardcoded generator | **NEW API** | replace with real Create-Order POST 🔴, or remove button |
|
||||
| Rider cards | live `useFiestaRiders` | OK | — |
|
||||
| Rider `rating 4.7` | hardcoded in mapper | **NEW API** | **[R9] rider telemetry/rating** |
|
||||
| Rider avatars | rotated stock photos | **WIRE** | user photo field if present, else keep placeholder |
|
||||
| GPS route SVG, "9 MINS", "1.2 km left", "GPS ACTIVE" | hardcoded | **NEW API** | **[R9]** live rider GPS/ETA |
|
||||
| Status-progression buttons (pack/dispatch/deliver) | local state | **NEW API** | order/delivery status PUT 🔴 (REVIEW_REQUIRED) |
|
||||
| Assign-rider action | local state | **NEW API** | assign-rider POST 🔴 (REVIEW_REQUIRED) |
|
||||
|
||||
---
|
||||
|
||||
## 6. StoreDetailView.tsx
|
||||
| UI element | Current source | Class | Target |
|
||||
|---|---|---|---|
|
||||
| Inventory tab list | live `useFiestaStockStatement` + mock fallback | **WIRE** | remove hardcoded fallback array |
|
||||
| Customers tab list | live `useFiestaTenantCustomers` + mock fallback | **WIRE** | remove hardcoded fallback array |
|
||||
| `intervalSlots` dispatch pipeline (morning rush %, etc.) | computed from `store.deliveries`×% | **NEW API** | **[R10] intraday dispatch buckets** |
|
||||
| `pastDaysLog` 7-day ledger | computed ×multipliers | **NEW API** | **[R2]** daily series per location |
|
||||
| KPI cards (OTIF 98.2%, est revenue, dispatches, active fleet) | hardcoded/derived | **WIRE/NEW** | OTIF & revenue need [R1]; fleet via `getRiders` |
|
||||
| `activeRiders` (battery, lastPing) | hardcoded | **NEW API** | **[R9]** rider telemetry |
|
||||
| Customer CRM order history (3 orders) | hardcoded | **WIRE** | `orders?customerid=` 🟡 |
|
||||
| Customer CRM metrics (CSAT 5.0) | hardcoded | **NEW API** | **[R11] customer analytics** (CSAT/AOV/retention) |
|
||||
| Customer search metrics (retention 88.4%, AOV ₹1,580, CSAT 4.9) | hardcoded | **NEW API** | **[R11]** |
|
||||
| `GLOBAL_CATALOGUE_ITEMS` modal | hardcoded | **WIRE** | master catalog listings 🟡 |
|
||||
| `resolveMetadata` price/image keyword map | hardcoded | **NEW API** | **[R3]** product image/price from catalog |
|
||||
| Store cover images | hardcoded array | keep | cosmetic asset set |
|
||||
| `operationalAlerts` (filtered by store) | mock `data.ts` | **NEW API** | **[R12] operational alerts** |
|
||||
| CSV import simulation, Global-catalogue add | local mutation | **NEW API** | stock entry POST 🔴 |
|
||||
|
||||
---
|
||||
|
||||
## 7. SettingsView.tsx
|
||||
| UI element | Current source | Class | Target |
|
||||
|---|---|---|---|
|
||||
| Merchant ID card / registration info | live `useFiestaAllTenants` | OK | — |
|
||||
| Outlets tab cards | live `useFiestaTenantLocations` | OK | — |
|
||||
| `LOCAL_OUTLETS_DATA` fallback | hardcoded | **WIRE** | remove fallback once locations confirmed live |
|
||||
| `DEFAULTS` settings (min order, delivery charge, prep mins, tax, COD…) | hardcoded + localStorage | **NEW API** | **[R6] merchant settings** GET+PUT (some fields exist on tenant: minorder, paymenttype) |
|
||||
| Role dropdown options | hardcoded `[1,2,3,4,6]` | **WIRE** | `getAppRoles` |
|
||||
|
||||
---
|
||||
|
||||
## 8. UsersPanel.tsx
|
||||
| UI element | Current source | Class | Target |
|
||||
|---|---|---|---|
|
||||
| User directory | live `useFiestaUsers` | OK | — |
|
||||
| Create user | live `useFiestaCreateUser` | OK | — |
|
||||
| `USER_AVATARS` rotation | hardcoded | keep/WIRE | use user photo field if API has one |
|
||||
| `ROLE_THEMES` styling | hardcoded | keep | cosmetic |
|
||||
| Add-user modal role list | hardcoded | **WIRE** | `getAppRoles` |
|
||||
|
||||
---
|
||||
|
||||
## 9. App.tsx / Header / Sidebar / UserPage
|
||||
| UI element | Current source | Class | Target |
|
||||
|---|---|---|---|
|
||||
| Store registry cards | live `useFiestaTenantLocations` + `useFiestaLocationSummary` | OK | — |
|
||||
| `STORE_COVERS` images | hardcoded (hash-picked) | keep | cosmetic |
|
||||
| Add-store modal | local; needs onboard POST | **NEW API** | tenant/location onboard 🔴 (REVIEW_REQUIRED) |
|
||||
| Scheduled-reports calendar (3 events) | hardcoded | **NEW API** | **[R13] scheduled reports** (or drop feature) |
|
||||
| Header/UserPage profile | live `authUser` | OK | — |
|
||||
| Sidebar nav items | hardcoded | keep | static nav config |
|
||||
|
||||
---
|
||||
|
||||
## Summary of backend work required
|
||||
| Id | New API | Blocks |
|
||||
|---|---|---|
|
||||
| **[R1]** | Revenue summary (tenant + per-location, period) | Reports revenue KPI, leaderboard revenue, OTIF revenue, Dashboard cross-check |
|
||||
| **[R2]** | Orders/revenue time-series (day/month/year, by region) | Reports main chart, 7-day ledger |
|
||||
| **[R3]** | Product sales analytics + product meta (units sold, revenue, bin, image) | Product matrix, catalogue metadata |
|
||||
| **[R4]** | Import / sync audit log + validation | Inventory & Operations import tabs |
|
||||
| **[R5]** | Catalog presets (optional) | Inventory presets |
|
||||
| **[R6]** | Merchant settings + branding config (GET/PUT) | Settings, Brand Studio |
|
||||
| **[R7]** | Forecast / efficiency insights (optional) | Operations forecast panel |
|
||||
| **[R8]** | Stock transfer (POST) | Operations transfer modal |
|
||||
| **[R9]** | Rider telemetry (rating, battery, GPS, ETA) | Orders fleet, GPS tracker, StoreDetail riders |
|
||||
| **[R10]** | Intraday dispatch buckets | StoreDetail dispatch pipeline |
|
||||
| **[R11]** | Customer analytics (CSAT/AOV/retention) | StoreDetail CRM metrics |
|
||||
| **[R12]** | Operational alerts feed | StoreDetail / dashboard alerts |
|
||||
| **[R13]** | Scheduled reports (optional) | Reports calendar |
|
||||
|
||||
Plus mutation routes already in the sheet but marked 🔴 REVIEW_REQUIRED (order create/status,
|
||||
delivery assign/status, product create/update, tenant onboard, stock entry) — backend must
|
||||
confirm request/response before wiring.
|
||||
275
docs/03_REQUIRED_BACKEND_APIS.md
Normal file
275
docs/03_REQUIRED_BACKEND_APIS.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# Required Backend APIs (to retire all mock data)
|
||||
|
||||
These endpoints do **not** exist in `Fiesta_All_Endpoints_With_Tables_And_Params.xlsx` and the
|
||||
values cannot be derived client-side. Each spec gives the **request** (params/body) and the
|
||||
**exact response JSON** the frontend needs. All responses follow the existing Fiesta envelope:
|
||||
|
||||
```json
|
||||
{ "code": 200, "status": true, "message": "OK", "details": <payload> }
|
||||
```
|
||||
|
||||
`details` is the `<payload>` documented per endpoint. Money is in INR (paise-free rupees,
|
||||
2-dp numbers). Dates are `YYYY-MM-DD`; timestamps ISO-8601.
|
||||
|
||||
Grouped as the request asked: **Overall (executive) analysis**, **Store-wise analysis**,
|
||||
**Reports**, then supporting operational feeds.
|
||||
|
||||
---
|
||||
|
||||
## A. OVERALL / EXECUTIVE ANALYSIS
|
||||
|
||||
### [R1] Revenue summary — `GET /orders/getrevenuesummary`
|
||||
Real money totals (today the app fakes revenue as `delivered × 355`).
|
||||
|
||||
**Request**
|
||||
| param | type | required | notes |
|
||||
|---|---|---|---|
|
||||
| tenantid | int | ✓ | |
|
||||
| locationid | int | | omit = all outlets (overall); set = store-wise |
|
||||
| fromdate | date | ✓ | |
|
||||
| todate | date | ✓ | |
|
||||
|
||||
**Response `details`** (single object)
|
||||
```json
|
||||
{
|
||||
"tenantid": 1087,
|
||||
"grossrevenue": 1248302.00,
|
||||
"netrevenue": 1180500.00,
|
||||
"profit": 354216.00,
|
||||
"marginpct": 28.4,
|
||||
"ordercount": 4921,
|
||||
"deliveredcount": 4102,
|
||||
"cancelledcount": 142,
|
||||
"avgordervalue": 1580.00,
|
||||
"prev_grossrevenue": 1090000.00,
|
||||
"prev_ordercount": 4370
|
||||
}
|
||||
```
|
||||
`prev_*` = same metric for the immediately preceding equal-length window, so the UI can show
|
||||
the `+14.2%` deltas without a second call. (If omitted, the frontend will issue a second
|
||||
range query to derive deltas — see [R2].)
|
||||
|
||||
---
|
||||
|
||||
### [R2] Orders/revenue time-series — `GET /orders/gettimeseries`
|
||||
Powers the Reports main chart (Orders / Revenue / Cancelled / SKUs) and the StoreDetail 7-day ledger.
|
||||
|
||||
**Request**
|
||||
| param | type | required | notes |
|
||||
|---|---|---|---|
|
||||
| tenantid | int | ✓ | |
|
||||
| locationid | int | | omit = overall |
|
||||
| granularity | enum | ✓ | `day` \| `month` \| `year` |
|
||||
| fromdate | date | ✓ | |
|
||||
| todate | date | ✓ | |
|
||||
|
||||
**Response `details`** (array, one bucket per period)
|
||||
```json
|
||||
[
|
||||
{ "label": "2026-06-01", "orders": 312, "revenue": 442800.00,
|
||||
"cancelled": 9, "delivered": 298, "activeskus": 72 }
|
||||
]
|
||||
```
|
||||
`label` is the bucket key (`YYYY-MM-DD` for day, `YYYY-MM` for month, `YYYY` for year).
|
||||
This removes all hardcoded `CHART_DATA_YTD` / `getDynamicChartData` arrays **and** the
|
||||
region-scaling multipliers (filter by `locationid` instead).
|
||||
|
||||
---
|
||||
|
||||
### [R12] Operational alerts — `GET /alerts/getalerts`
|
||||
Replaces `operationalAlerts` in `data.ts` (stock-critical, latency, rebalance, etc.).
|
||||
|
||||
**Request**: `tenantid` (✓), `locationid` (optional), `status` (`open|all`), `pageno`, `pagesize`.
|
||||
|
||||
**Response `details`** (array)
|
||||
```json
|
||||
[
|
||||
{ "alertid": 5012, "tenantid": 1087, "locationid": 1097,
|
||||
"type": "critical", // critical | warning | info
|
||||
"title": "Stock Critical: RS Puram",
|
||||
"details": "Dairy inventory below 5%.",
|
||||
"createdat": "2026-06-06T08:24:00",
|
||||
"acknowledged": false }
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## B. STORE-WISE ANALYSIS
|
||||
|
||||
### [R3] Product sales analytics — `GET /products/getproductanalytics`
|
||||
The stock-statement mapper currently approximates `unitsSold = debit` and
|
||||
`revenue = debit × retailprice`. This returns true sales + the product meta the matrix shows.
|
||||
|
||||
**Request**
|
||||
| param | type | required | notes |
|
||||
|---|---|---|---|
|
||||
| tenantid | int | ✓ | |
|
||||
| locationid | int | | omit = across all outlets |
|
||||
| subcategoryid | int | | |
|
||||
| fromdate / todate | date | | sales window (default month-to-date) |
|
||||
| keyword | string | | |
|
||||
| pageno / pagesize | int | | |
|
||||
|
||||
**Response `details`** (array)
|
||||
```json
|
||||
[
|
||||
{ "productid": 88231, "productname": "Ponni Raw Rice 10kg",
|
||||
"sku": "PONNI-RICE-10K", "categoryid": 2, "subcategoryname": "Rice",
|
||||
"image": "https://…",
|
||||
"unitssold": 12402, "revenue": 868140.00, "trend": "up", // up|flat|down
|
||||
"closingstock": 1402, "maxcapacity": 2000, "binlocation": "BIN-C12",
|
||||
"lastauditdate": "2026-06-05", "verified": true,
|
||||
"outlets": [ { "locationid": 1097, "locationname": "RS Puram", "stock": 840 } ] }
|
||||
]
|
||||
```
|
||||
`outlets[]` gives the real per-hub split (replaces the hardcoded 60/40). `binlocation`,
|
||||
`lastauditdate`, `image` replace the fabricated bin/barcode/audit fields — **drop them if the
|
||||
catalog doesn't track them** rather than faking.
|
||||
|
||||
---
|
||||
|
||||
### [R10] Intraday dispatch buckets — `GET /deliveries/getdispatchbuckets`
|
||||
Replaces StoreDetail `intervalSlots` (Morning Rush / Midday / Evening Peak, computed from `%`).
|
||||
|
||||
**Request**: `tenantid` (✓), `locationid` (✓), `date` (✓, default today).
|
||||
|
||||
**Response `details`** (array, fixed buckets)
|
||||
```json
|
||||
[
|
||||
{ "bucket": "06:00-10:00", "label": "Morning Rush",
|
||||
"orders": 64, "revenue": 88400.00, "status": "PEAK" } // PEAK|HIGH|NORMAL|LOW
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### [R11] Customer analytics — `GET /customers/getcustomeranalytics`
|
||||
Replaces hardcoded CSAT 5.0 / retention 88.4% / AOV ₹1,580 in StoreDetail.
|
||||
|
||||
**Request**: `tenantid` (✓), `locationid` (optional), `customerid` (optional — single-customer rollup).
|
||||
|
||||
**Response `details`** (object; per-customer when `customerid` set)
|
||||
```json
|
||||
{
|
||||
"retentionpct": 88.4,
|
||||
"avgordervalue": 1580.00,
|
||||
"csat": 4.9,
|
||||
"totalcustomers": 1240,
|
||||
"percustomer": { "customerid": 55012, "orderscount": 18,
|
||||
"totalspent": 28450.00, "csat": 5.0, "lastorderdate": "2026-06-04" }
|
||||
}
|
||||
```
|
||||
> Customer order **history** itself is already available: `GET /orders/getorders?customerid=` (sheet row 56) — wire that, no new API needed.
|
||||
|
||||
---
|
||||
|
||||
### [R9] Rider telemetry — `GET /partners/getridertelemetry`
|
||||
Replaces hardcoded rider `rating 4.7`, `battery`, `lastPing`, and the fake GPS route / "9 MINS" / "1.2 km left".
|
||||
|
||||
**Request**: `applocationid` (✓), `tenantid` (optional), `userid` (optional — single rider).
|
||||
|
||||
**Response `details`** (array)
|
||||
```json
|
||||
[
|
||||
{ "userid": 7781, "name": "Karthikeyan R", "status": "Delivering", // Delivering|Idle|Offline
|
||||
"rating": 4.7, "completedtoday": 12, "battery": 84,
|
||||
"lastping": "2026-06-06T14:21:00", "zone": "RS Puram",
|
||||
"lat": 11.0041, "lng": 76.9612,
|
||||
"activedelivery": { "deliveryid": 99213, "etamins": 9, "distancekm": 1.2,
|
||||
"destlat": 11.0102, "destlng": 76.9550 } }
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## C. REPORTS
|
||||
|
||||
Reports are powered primarily by **[R1]** (revenue + deltas), **[R2]** (time-series chart &
|
||||
ledger), and **[R3]** (product matrix). Additional reports-only needs:
|
||||
|
||||
### [R4] Import / sync audit log — `GET /imports/getimportlogs` + validation
|
||||
Replaces `initialImportLogs` and the hardcoded "14 duplicate SKUs" validator messages.
|
||||
|
||||
**Request (list)**: `tenantid` (✓), `pageno`, `pagesize`.
|
||||
**Response `details`**
|
||||
```json
|
||||
[
|
||||
{ "importid": 921, "batchref": "#IMP_0921_A", "type": "Inventory Sync",
|
||||
"source": "ERP Export", "rows": 421, "result": "SUCCESS", // SUCCESS|FAILED|PARTIAL
|
||||
"message": "421 rows", "createdat": "2026-05-31T09:12:00" }
|
||||
]
|
||||
```
|
||||
**Upload (optional)**: `POST /imports/upload` (multipart csv) → returns the same row plus a
|
||||
`validation` block:
|
||||
```json
|
||||
{ "validation": { "passed": true, "duplicateskus": ["ST-SONA-25K"],
|
||||
"badcolumns": [45, 82], "headerversion": "v2.8" } }
|
||||
```
|
||||
|
||||
### [R13] Scheduled reports — `GET/POST /reports/getschedules` *(optional feature)*
|
||||
Replaces the 3 hardcoded calendar events. Drop the feature if not on the roadmap.
|
||||
```json
|
||||
[ { "scheduleid": 11, "name": "Monthly Assortment Audit", "cron": "0 0 1 * *",
|
||||
"nextrun": "2026-07-01T00:00:00", "format": "PDF", "recipients": ["ops@…"] } ]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## D. SETTINGS / CONFIG
|
||||
|
||||
### [R6] Merchant settings + branding — `GET /tenants/getsettings`, `PUT /tenants/updatesettings`
|
||||
Replaces `DEFAULTS` (localStorage) and the Brand Design Studio local state. Some fields already
|
||||
live on the tenant (`minorder`, `paymenttype`) — fold those in.
|
||||
|
||||
**GET response `details`**
|
||||
```json
|
||||
{
|
||||
"tenantid": 1087,
|
||||
"contactemail": "ops@ragul.com", "contactphone": "+91…",
|
||||
"minordervalue": 199, "deliverycharge": 29, "prepmins": 20,
|
||||
"deliverywindowmins": 45, "cancelwindowsecs": 120, "autoassignrider": true,
|
||||
"defaulttaxpercent": 5, "codenabled": true, "onlinepaymentenabled": true,
|
||||
"defaultregion": "Coimbatore", "defaultnewuserrole": 4,
|
||||
"ordernotifications": true, "lowstockalerts": true, "dailysummaryemail": false,
|
||||
"syncinterval": 15, "sandboxmode": false,
|
||||
"branding": { "themename": "Kaveri Org", "primarycolor": "#16a34a",
|
||||
"secondarycolor": "#f59e0b", "baglabel": "Freshly Harvested",
|
||||
"ecoverified": true, "stickerpattern": "radial" }
|
||||
}
|
||||
```
|
||||
**PUT body**: same object (partial allowed) → returns updated object.
|
||||
|
||||
---
|
||||
|
||||
## E. SUPPORTING OPERATIONAL ENDPOINTS (optional / phase-2)
|
||||
|
||||
### [R7] Forecast / efficiency insight — `GET /insights/getforecast`
|
||||
Operations "Forecast Efficiency 92% / ₹1.9L savings". **Recommend removing** these tiles until a
|
||||
forecasting model exists, rather than building a thin API. If kept:
|
||||
```json
|
||||
{ "forecastefficiencypct": 92, "estsavingsperweek": 190000.00, "horizondays": 7 }
|
||||
```
|
||||
|
||||
### [R8] Stock transfer — `POST /products/stocktransfer`
|
||||
Operations transfer-stock modal.
|
||||
**Body**: `{ tenantid, productid, fromlocationid, tolocationid, quantity }` →
|
||||
**Response**: updated stock rows for both locations.
|
||||
|
||||
### [R5] Catalog presets — `GET /products/getcatalogpresets` *(optional)*
|
||||
Inventory "Nilgiris Dairy / Coimbatore Heritage" quick-add packs. Could instead reuse master
|
||||
catalog listings (sheet row 90) filtered by subcategory — **prefer that, skip the new API**.
|
||||
|
||||
---
|
||||
|
||||
## Mutation routes already in the sheet (confirm shape, then wire)
|
||||
These are marked `REVIEW_REQUIRED` in the sheet — backend must publish request/response:
|
||||
| Action | Route (sheet) | Used by |
|
||||
|---|---|---|
|
||||
| Create web order | POST orders+orderdetails | Orders "create order" |
|
||||
| Update order status | PUT orders | Orders status buttons |
|
||||
| Assign rider / dispatch | POST deliveries+deliverydetails | Orders assign-rider |
|
||||
| Update pickup / delivered | PUT deliveries | Orders status progression |
|
||||
| Add multi-product stock | POST (products) | Inventory/Operations add stock |
|
||||
| Create/Update/Delete product | POST/PUT/DELETE | Catalogue management |
|
||||
| Onboard tenant / store / location | POST/PUT | App add-store modal |
|
||||
322
docs/BACKEND_API_REQUIREMENTS.md
Normal file
322
docs/BACKEND_API_REQUIREMENTS.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# Backend API Requirements — Merchant Web Console
|
||||
|
||||
**Audience:** Backend team.
|
||||
**Purpose:** These are the endpoints the merchant web app needs before it can go to staging with
|
||||
zero mock data. Every UI spot listed below currently renders an explicit
|
||||
`Awaiting backend API [Rxx]` placeholder (or a known-wrong stand-in like the
|
||||
`delivered × 355` revenue) until the matching endpoint ships.
|
||||
|
||||
## Conventions (match the existing live API)
|
||||
- **Base / proxy:** Fiesta REST at `https://fiesta.nearle.app/live/api/v1/web/<group>/<action>`.
|
||||
The frontend calls it through the dev proxy as `/fiesta/live/api/v1/web/...`.
|
||||
- **Auth:** same as current read endpoints (no extra header required for GETs).
|
||||
- **Envelope:** every response is
|
||||
```json
|
||||
{ "code": 200, "status": true, "message": "OK", "details": <payload> }
|
||||
```
|
||||
`details` is the documented payload (array or object). On failure return
|
||||
`{ "code": 4xx, "status": false, "message": "<reason>" }`.
|
||||
- **Types:** money = INR as a plain 2-dp number (e.g. `442800.00`), no currency symbol/string.
|
||||
Dates = `YYYY-MM-DD`. Timestamps = ISO-8601 (`2026-06-06T14:21:00`). Ids = integers.
|
||||
- **Tenant scope (current):** `tenantid=1087`, `applocationid=1`, primary `locationid=1097`.
|
||||
- **Paging:** where listed, accept `pageno` (1-based) and `pagesize`.
|
||||
|
||||
## Index
|
||||
| Id | Endpoint | Method | Powers (UI) | Priority |
|
||||
|---|---|---|---|---|
|
||||
| [R1] | `/orders/getrevenuesummary` | GET | Revenue KPIs, OTIF, leaderboard, fulfilment health | **P0** |
|
||||
| [R2] | `/orders/gettimeseries` | GET | Reports main chart, 7-day ledger | **P0** |
|
||||
| [R3] | `/products/getproductanalytics` | GET | Product matrix sales + per-product detail | **P0** |
|
||||
| [R4] | `/imports/getimportlogs` (+ `/imports/upload`) | GET/POST | Import audit stream + validator | P1 |
|
||||
| [R5] | `/products/getcatalogpresets` | GET | Inventory catalog presets (optional) | P2 |
|
||||
| [R6] | `/tenants/getsettings` (+ `/tenants/updatesettings`) | GET/PUT | Settings + Brand studio | P1 |
|
||||
| [R7] | `/insights/getforecast` | GET | Operations forecast tiles (optional) | P2 |
|
||||
| [R8] | `/products/stocktransfer` | POST | Operations stock-transfer modal | P1 |
|
||||
| [R9] | `/partners/getridertelemetry` | GET | Fleet rating, GPS, ETA | P1 |
|
||||
| [R10] | `/deliveries/getdispatchbuckets` | GET | StoreDetail intraday dispatch | P1 |
|
||||
| [R11] | `/customers/getcustomeranalytics` | GET | Customer CSAT/AOV/retention | P1 |
|
||||
| [R12] | `/alerts/getalerts` | GET | Operational alerts feed | P1 |
|
||||
| [R13] | `/reports/getschedules` (+ POST) | GET/POST | Scheduled reports (optional) | P2 |
|
||||
|
||||
Plus **mutation routes** already named in the endpoint sheet but not yet shape-documented — see
|
||||
the last section.
|
||||
|
||||
---
|
||||
|
||||
## [R1] Revenue summary · `GET /orders/getrevenuesummary`
|
||||
Real money totals. Today the app fakes revenue as `delivered × 355` — replace that.
|
||||
|
||||
**Request (query params)**
|
||||
| param | type | required | notes |
|
||||
|---|---|---|---|
|
||||
| `tenantid` | int | ✓ | |
|
||||
| `locationid` | int | | omit → all outlets (overall); set → store-wise |
|
||||
| `fromdate` | date | ✓ | |
|
||||
| `todate` | date | ✓ | |
|
||||
|
||||
**Response `details`** (single object)
|
||||
```json
|
||||
{
|
||||
"tenantid": 1087,
|
||||
"locationid": null,
|
||||
"grossrevenue": 1248302.00,
|
||||
"netrevenue": 1180500.00,
|
||||
"profit": 354216.00,
|
||||
"marginpct": 28.4,
|
||||
"ordercount": 4921,
|
||||
"deliveredcount": 4102,
|
||||
"cancelledcount": 142,
|
||||
"otifpct": 98.2,
|
||||
"avgordervalue": 1580.00,
|
||||
"prev_grossrevenue": 1090000.00,
|
||||
"prev_ordercount": 4370
|
||||
}
|
||||
```
|
||||
`prev_*` = same metric for the immediately preceding equal-length window (lets the UI show
|
||||
`+14.2%` deltas without a second call). `otifpct` powers the OTIF / fulfilment-health tiles.
|
||||
Frontend hook to add: `useFiestaRevenueSummary`.
|
||||
|
||||
---
|
||||
|
||||
## [R2] Orders / revenue time-series · `GET /orders/gettimeseries`
|
||||
Powers the Reports main chart (Orders / Revenue / Cancelled / SKUs) and StoreDetail 7-day ledger.
|
||||
|
||||
**Request**
|
||||
| param | type | required | notes |
|
||||
|---|---|---|---|
|
||||
| `tenantid` | int | ✓ | |
|
||||
| `locationid` | int | | omit → overall |
|
||||
| `granularity` | enum | ✓ | `day` \| `month` \| `year` |
|
||||
| `fromdate` | date | ✓ | |
|
||||
| `todate` | date | ✓ | |
|
||||
|
||||
**Response `details`** (array, one bucket per period)
|
||||
```json
|
||||
[
|
||||
{ "label": "2026-06-01", "orders": 312, "revenue": 442800.00,
|
||||
"delivered": 298, "cancelled": 9, "activeskus": 72 }
|
||||
]
|
||||
```
|
||||
`label` = `YYYY-MM-DD` (day), `YYYY-MM` (month), or `YYYY` (year). Removes all hardcoded chart
|
||||
arrays and the region-scaling multipliers (filter by `locationid` instead).
|
||||
Frontend hook: `useFiestaTimeSeries`.
|
||||
|
||||
---
|
||||
|
||||
## [R3] Product sales analytics · `GET /products/getproductanalytics`
|
||||
True units-sold + revenue per product, plus the per-product/per-outlet detail the matrix expands.
|
||||
|
||||
**Request**
|
||||
| param | type | required | notes |
|
||||
|---|---|---|---|
|
||||
| `tenantid` | int | ✓ | |
|
||||
| `locationid` | int | | omit → across all outlets |
|
||||
| `subcategoryid` | int | | |
|
||||
| `fromdate` / `todate` | date | | sales window (default month-to-date) |
|
||||
| `keyword` | string | | |
|
||||
| `pageno` / `pagesize` | int | | |
|
||||
|
||||
**Response `details`** (array)
|
||||
```json
|
||||
[
|
||||
{ "productid": 88231, "productname": "Ponni Raw Rice 10kg",
|
||||
"sku": "PONNI-RICE-10K", "categoryid": 2, "subcategoryname": "Rice",
|
||||
"image": "https://…",
|
||||
"unitssold": 12402, "revenue": 868140.00, "trend": "up",
|
||||
"closingstock": 1402, "maxcapacity": 2000,
|
||||
"binlocation": "BIN-C12", "lastauditdate": "2026-06-05", "verified": true,
|
||||
"outlets": [ { "locationid": 1097, "locationname": "RS Puram", "stock": 840 } ] }
|
||||
]
|
||||
```
|
||||
`trend` ∈ `up|flat|down`. `outlets[]` = real per-hub split (replaces hardcoded 60/40).
|
||||
If the catalog doesn't track `binlocation`/`lastauditdate`, return `null` — the UI will hide them
|
||||
rather than fake them. Frontend hook: `useFiestaProductAnalytics`.
|
||||
|
||||
---
|
||||
|
||||
## [R4] Import / sync audit log · `GET /imports/getimportlogs` + `POST /imports/upload`
|
||||
Replaces the mock "Live Sync Audit Stream" and the hardcoded "14 duplicate SKUs" validator.
|
||||
|
||||
**GET request:** `tenantid` (✓), `pageno`, `pagesize`.
|
||||
**GET `details`**
|
||||
```json
|
||||
[
|
||||
{ "importid": 921, "batchref": "#IMP_0921_A", "type": "Inventory Sync",
|
||||
"source": "ERP Export", "rows": 421, "result": "SUCCESS",
|
||||
"message": "421 rows imported", "createdat": "2026-05-31T09:12:00" }
|
||||
]
|
||||
```
|
||||
`result` ∈ `SUCCESS|FAILED|PARTIAL`.
|
||||
|
||||
**POST `/imports/upload`** (multipart, field `file` = CSV; plus `tenantid`) → returns the new log row
|
||||
plus a validation block:
|
||||
```json
|
||||
{ "importid": 922, "result": "PARTIAL",
|
||||
"validation": { "passed": false, "duplicateskus": ["ST-SONA-25K"],
|
||||
"badcolumns": [45, 82], "headerversion": "v2.8" } }
|
||||
```
|
||||
Frontend hooks: `useFiestaImportLogs`, `useFiestaImportUpload` (mutation).
|
||||
|
||||
---
|
||||
|
||||
## [R5] Catalog presets *(optional)* · `GET /products/getcatalogpresets`
|
||||
Inventory "Nilgiris Dairy / Coimbatore Heritage" quick-add packs.
|
||||
**Request:** `tenantid` (✓). **`details`:**
|
||||
```json
|
||||
[ { "presetid": 3, "name": "Nilgiris Dairy Fresh Pack",
|
||||
"items": [ { "name": "Ooty Butter 500g", "sku": "DY-OOT-BTR",
|
||||
"subcategoryname": "Dairy", "price": 340, "image": "https://…" } ] } ]
|
||||
```
|
||||
> Alternative: skip this and reuse master catalog (`/products/getproducts`) filtered by subcategory.
|
||||
|
||||
---
|
||||
|
||||
## [R6] Merchant settings + branding · `GET /tenants/getsettings` + `PUT /tenants/updatesettings`
|
||||
Replaces the localStorage `DEFAULTS` and the Brand Design Studio local state.
|
||||
|
||||
**GET request:** `tenantid` (✓). **GET `details`:**
|
||||
```json
|
||||
{
|
||||
"tenantid": 1087,
|
||||
"contactemail": "ops@ragul.com", "contactphone": "+91…",
|
||||
"minordervalue": 199, "deliverycharge": 29, "prepmins": 20,
|
||||
"deliverywindowmins": 45, "cancelwindowsecs": 120, "autoassignrider": true,
|
||||
"defaulttaxpercent": 5, "codenabled": true, "onlinepaymentenabled": true,
|
||||
"defaultregion": "Coimbatore", "defaultnewuserrole": 4,
|
||||
"ordernotifications": true, "lowstockalerts": true, "dailysummaryemail": false,
|
||||
"syncinterval": 15, "sandboxmode": false,
|
||||
"branding": { "themename": "Kaveri Org", "primarycolor": "#16a34a",
|
||||
"secondarycolor": "#f59e0b", "baglabel": "Freshly Harvested",
|
||||
"ecoverified": true, "stickerpattern": "radial" }
|
||||
}
|
||||
```
|
||||
**PUT body:** same object (partial allowed) → returns the updated object.
|
||||
Frontend hooks: `useFiestaSettings`, `useFiestaUpdateSettings` (mutation).
|
||||
|
||||
---
|
||||
|
||||
## [R7] Forecast insight *(optional)* · `GET /insights/getforecast`
|
||||
Operations "Forecast Efficiency 92% / ₹1.9L savings". Recommend dropping the tiles until a model
|
||||
exists; if kept: **request** `tenantid` (✓), `locationid` (opt). **`details`:**
|
||||
```json
|
||||
{ "forecastefficiencypct": 92, "estsavingsperweek": 190000.00, "horizondays": 7 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [R8] Stock transfer · `POST /products/stocktransfer`
|
||||
Operations transfer-stock modal.
|
||||
**Body:**
|
||||
```json
|
||||
{ "tenantid": 1087, "productid": 88231,
|
||||
"fromlocationid": 1097, "tolocationid": 1101, "quantity": 100 }
|
||||
```
|
||||
**`details`:** updated stock rows for both locations
|
||||
```json
|
||||
[ { "locationid": 1097, "productid": 88231, "closingstock": 740 },
|
||||
{ "locationid": 1101, "productid": 88231, "closingstock": 360 } ]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## [R9] Rider telemetry · `GET /partners/getridertelemetry`
|
||||
Replaces hardcoded rider `rating`, `battery`, `lastPing` and the fake GPS route / "9 MINS" / "1.2 km".
|
||||
|
||||
**Request:** `applocationid` (✓), `tenantid` (opt), `userid` (opt → single rider).
|
||||
**`details`** (array)
|
||||
```json
|
||||
[
|
||||
{ "userid": 7781, "name": "Karthikeyan R", "status": "Delivering",
|
||||
"rating": 4.7, "completedtoday": 12, "battery": 84,
|
||||
"lastping": "2026-06-06T14:21:00", "zone": "RS Puram",
|
||||
"lat": 11.0041, "lng": 76.9612,
|
||||
"activedelivery": { "deliveryid": 99213, "etamins": 9, "distancekm": 1.2,
|
||||
"destlat": 11.0102, "destlng": 76.9550 } }
|
||||
]
|
||||
```
|
||||
`status` ∈ `Delivering|Idle|Offline`. `activedelivery` = `null` when the rider is idle.
|
||||
Frontend hook: `useFiestaRiderTelemetry`.
|
||||
|
||||
---
|
||||
|
||||
## [R10] Intraday dispatch buckets · `GET /deliveries/getdispatchbuckets`
|
||||
Replaces StoreDetail `intervalSlots` (Morning Rush / Midday / Evening Peak computed from %).
|
||||
|
||||
**Request:** `tenantid` (✓), `locationid` (✓), `date` (✓, default today).
|
||||
**`details`** (array, fixed buckets)
|
||||
```json
|
||||
[ { "bucket": "06:00-10:00", "label": "Morning Rush",
|
||||
"orders": 64, "revenue": 88400.00, "status": "PEAK" } ]
|
||||
```
|
||||
`status` ∈ `PEAK|HIGH|NORMAL|LOW`. Frontend hook: `useFiestaDispatchBuckets`.
|
||||
|
||||
---
|
||||
|
||||
## [R11] Customer analytics · `GET /customers/getcustomeranalytics`
|
||||
Replaces hardcoded CSAT 5.0 / retention 88.4% / AOV ₹1,580.
|
||||
|
||||
**Request:** `tenantid` (✓), `locationid` (opt), `customerid` (opt → single-customer rollup).
|
||||
**`details`** (object)
|
||||
```json
|
||||
{
|
||||
"retentionpct": 88.4, "avgordervalue": 1580.00, "csat": 4.9,
|
||||
"totalcustomers": 1240,
|
||||
"percustomer": { "customerid": 55012, "orderscount": 18,
|
||||
"totalspent": 28450.00, "csat": 5.0, "lastorderdate": "2026-06-04" }
|
||||
}
|
||||
```
|
||||
`percustomer` present only when `customerid` is supplied.
|
||||
> Customer order **history** itself already exists: `GET /orders/getorders?customerid=` — wire that, no new API.
|
||||
Frontend hook: `useFiestaCustomerAnalytics`.
|
||||
|
||||
---
|
||||
|
||||
## [R12] Operational alerts · `GET /alerts/getalerts`
|
||||
Replaces the mock `operationalAlerts` (stock-critical, latency, rebalance…).
|
||||
|
||||
**Request:** `tenantid` (✓), `locationid` (opt), `status` (`open|all`), `pageno`, `pagesize`.
|
||||
**`details`** (array)
|
||||
```json
|
||||
[ { "alertid": 5012, "tenantid": 1087, "locationid": 1097,
|
||||
"type": "critical", "title": "Stock Critical: RS Puram",
|
||||
"details": "Dairy inventory below 5%.",
|
||||
"createdat": "2026-06-06T08:24:00", "acknowledged": false } ]
|
||||
```
|
||||
`type` ∈ `critical|warning|info`. Frontend hook: `useFiestaAlerts`.
|
||||
|
||||
---
|
||||
|
||||
## [R13] Scheduled reports *(optional)* · `GET /reports/getschedules` + `POST /reports/createschedule`
|
||||
Replaces 3 hardcoded calendar events. Drop if not on the roadmap.
|
||||
**GET `details`:**
|
||||
```json
|
||||
[ { "scheduleid": 11, "name": "Monthly Assortment Audit", "cron": "0 0 1 * *",
|
||||
"nextrun": "2026-07-01T00:00:00", "format": "PDF", "recipients": ["ops@ragul.com"] } ]
|
||||
```
|
||||
**POST body:** `{ tenantid, name, cron, format, recipients[] }` → returns the created schedule.
|
||||
|
||||
---
|
||||
|
||||
## Mutation routes already named in the endpoint sheet (publish request/response, then we wire)
|
||||
These are marked `REVIEW_REQUIRED` in `Fiesta_All_Endpoints_With_Tables_And_Params.xlsx`. We need
|
||||
the exact request body + success response for each:
|
||||
|
||||
| Action | Suggested endpoint | Body (fields we'll send) |
|
||||
|---|---|---|
|
||||
| Create web order | `POST /orders/create` | tenantid, locationid, customerid, items[{productid,qty,price}], paymenttype |
|
||||
| Update order status | `PUT /orders/updatestatus` | orderheaderid, status, financialflag |
|
||||
| Assign rider / dispatch | `POST /deliveries/assign` | orderheaderid, tenantid, locationid, partnerid, userid |
|
||||
| Update pickup / delivered | `PUT /deliveries/updatestatus` | deliveryid, status |
|
||||
| Add multi-product stock | `POST /products/addstock` | tenantid, locationid, items[{productid,qty,cost}] |
|
||||
| Create / update / delete product | `POST/PUT/DELETE /products/...` | product fields |
|
||||
| Onboard tenant / store / location | `POST /tenants/onboard`, `POST /tenants/createlocation` | tenant + location fields |
|
||||
|
||||
---
|
||||
|
||||
## Frontend status (for reference)
|
||||
- All read endpoints that **already exist** are wired (orders/deliveries/stock/customers/users/
|
||||
locations/order-line-items/etc.).
|
||||
- Each `[Rxx]` above corresponds to a visible `AwaitingApi` placeholder in the UI, so QA can see
|
||||
exactly which screens are still blocked.
|
||||
- The moment an endpoint here ships with the documented shape, wiring it is one hook + swapping the
|
||||
placeholder — no redesign needed.
|
||||
105
docs/_endpoints_raw.txt
Normal file
105
docs/_endpoints_raw.txt
Normal file
@@ -0,0 +1,105 @@
|
||||
API Name | Method | Exact Table | Query Parameters
|
||||
Utils | GET | app_types | tag
|
||||
Resolve subcategories (Web) | GET | app_subcategory | moduleid, categoryid
|
||||
Fetch system active geofence details (Web) | GET | app_location | applocationid
|
||||
Fetch global system categories configurations (Web) | GET | app_category | None
|
||||
Get mobile geofence configuration details (Mobile) | GET | app_locationconfig | applocationid
|
||||
Get mobile active geofence details (Mobile) | GET | app_location | applocationid
|
||||
Fetch mobile app types by tag (Mobile) | GET | app_types | tag
|
||||
Fetch global payment & geofence configs (Mobile) | GET | app_config | configid
|
||||
Get mobile category subcategories list (Mobile) | GET | app_subcategory | moduleid, categoryid
|
||||
Fetch mobile app categories configurations (Mobile) | GET | app_category | None
|
||||
Users | GET | users | roleid, tenantid, pageno, pagesize, keyword
|
||||
Get a specific user profile by ID (Web) | GET | users | userid
|
||||
Get a specific user profile by ID (Mobile) | GET | users | userid
|
||||
Operator/User orders board (Web) | GET | orders | appuserid, status, pageno, pagesize
|
||||
Tenant Web Panel Login | POST | REVIEW_REQUIRED | None
|
||||
General Application Login | POST | REVIEW_REQUIRED | None
|
||||
Register New Web Staff Account | POST | REVIEW_REQUIRED | None
|
||||
Update Web Staff User Details | PUT | REVIEW_REQUIRED | None
|
||||
Rider/Merchant Mobile App Login | POST | REVIEW_REQUIRED | None
|
||||
Create Mobile Staff User | POST | REVIEW_REQUIRED | None
|
||||
Update Mobile Staff Details | PUT | REVIEW_REQUIRED | None
|
||||
Partners | GET | orders | partnerid, status, pageno, pagesize
|
||||
Get active riders (Web) | GET | partneruser | partnerid, applocationid, userid, tenantid
|
||||
Get partner profiles (Web) | GET | partneruser | partnerid, applocationid, userid
|
||||
Get rider shifts (Web) | GET | partneruser | applocationid
|
||||
Get location configurations (Web) | GET | REVIEW_REQUIRED | userid, configid
|
||||
Get rider log sheet (Web) | GET | riderlogs | partnerid, applocationid, fromdate, todate
|
||||
Get partner profiles (Mobile) | GET | partneruser | partnerid, applocationid, userid
|
||||
Get rider log sheet (Mobile) | GET | riderlogs | partnerid, applocationid, fromdate, todate
|
||||
Get rider operational info (Mobile) | GET | partneruser | userid
|
||||
Get active riders list (Mobile) | GET | partneruser | partnerid, applocationid, userid, tenantid
|
||||
Tenants | GET | orders | tenantid, locationid, status, pageno, pagesize
|
||||
Get specific tenant store orders (Mobile) | GET | orders | tenantid, locationid, pageno, pagesize
|
||||
Search registered tenants (Web) | GET | REVIEW_REQUIRED | status, keyword
|
||||
Search tenants by keyword (Web) | GET | REVIEW_REQUIRED | keyword
|
||||
Get all active tenants catalog (Web) | GET | tenants | applocationid, status, pageno, pagesize
|
||||
Get outlet locations assigned to a tenant (Web) | GET | tenantlocations | tenantid
|
||||
Retrieve delivery time slots config (Mobile) | GET | tenantslot | None
|
||||
Search tenants by keyword (Mobile) | GET | REVIEW_REQUIRED | keyword
|
||||
Retrieve tenants associated with a customer (Mobile) | GET | tenantcustomers | customerid, tenant
|
||||
Get outlet locations linked to a tenant (Mobile) | GET | tenantlocations | tenantid
|
||||
Get logistics pricing slabs for a tenant (Mobile) | GET | tenantpricing | tenantid, applocationid
|
||||
Get staff members under a tenant store (Mobile) | GET | tenantstaffs | tenantid
|
||||
Get tenant detailed profile info (Mobile) | GET | tenants | tenantid, locationid
|
||||
Link Customer Profile to Tenant Store | POST | REVIEW_REQUIRED | None
|
||||
Create New Geofenced Store Location | POST | REVIEW_REQUIRED | None
|
||||
Update Store Location Configurations | PUT | REVIEW_REQUIRED | None
|
||||
Onboard New Tenant & Admin Profile | POST | REVIEW_REQUIRED | None
|
||||
Customers | GET | customers | customerid, contactno
|
||||
Get customer saved address locations (Mobile) | GET | customerlocations | customerid
|
||||
Get customer logged support requests (Mobile) | GET | customerrequest | customerid, pageno, pagesize
|
||||
Search customer names under a tenant (Mobile) | GET | REVIEW_REQUIRED | keyword, tenantid
|
||||
Retrieve customers linked to a tenant location (Mobile) | GET | tenantcustomers | tenantid, locationid, pageno, pagesize
|
||||
Retrieve merchant customers list (Web) | GET | tenantcustomers | tenantid, locationid, pageno, pagesize, keyword
|
||||
Individual consumer invoice histories (Web) | GET | orders | customerid, status, pageno, pagesize
|
||||
Passwordless OTP Login (via Phone) | POST | REVIEW_REQUIRED | None
|
||||
Register Customer Account | POST | REVIEW_REQUIRED | None
|
||||
Save New Geofenced Location Address | POST | REVIEW_REQUIRED | None
|
||||
Log Customer Support Ticket Request | POST | REVIEW_REQUIRED | None
|
||||
Update Customer Profile Details | PUT | REVIEW_REQUIRED | None
|
||||
Deliveries | GET | deliveries | tenantid, partnerid, userid, applocationid, locationid, fromdate, todate
|
||||
Get daily delivery insights (Web) | GET | deliveries | tenantid
|
||||
Get location deliveries summary (Web) | GET | deliveries | tenantid
|
||||
Get deliveries financial report summary (Web) | GET | deliveries | tenantid, partnerid, userid, applocationid, fromdate, todate
|
||||
Get fleet rider summary metrics (Web) | GET | partneruser | applocationid, partnerid, tenantid, fromdate, todate
|
||||
Get master deliveries board (Web) | GET | deliveries | tenantid, fromdate, todate
|
||||
Get mobile dispatch summaries (Mobile) | GET | deliveries | userid, fromdate, todate
|
||||
Get mobile deliveries board (Mobile) | GET | deliveries | userid, status
|
||||
Fetch rider active shift deliveries queue (Mobile) | GET | deliveryqueues | userid, fromdate, todate
|
||||
Initialize Logistics Delivery Dispatch (Assign Rider) | POST | deliveries + deliverydetails | None
|
||||
Update Rider Pickup Status | PUT | deliveries | None
|
||||
Rider Update Dispatch Status (Delivered) | PUT | deliveries | None
|
||||
Orders | GET | orders | tenantid, locationid, status, pageno, pagesize
|
||||
System Admin orders board (Web) | GET | orders | applocationid, status, pageno, pagesize
|
||||
Get order dashboard stats summary (Web) | GET | orders | tenantid, fromdate, todate
|
||||
Get location orders summary (Web) | GET | deliveries | tenantid
|
||||
Get annual orders insights analytics (Web) | GET | orders | tenantid
|
||||
Get order detailed lines (Web) | GET | orderdetails | orderheaderid
|
||||
Get customer order history (Mobile) | GET | orders | customerid, pageno, pagesize
|
||||
Get mobile order detailed lines (Mobile) | GET | orderdetails | orderheaderid
|
||||
Create New Web Order | POST | orders + orderdetails | None
|
||||
Update Order Status & Financial Flag | PUT | orders | None
|
||||
Create Mobile Order | POST | orders + orderdetails | None
|
||||
Mobile Update Order Status | PUT | orders | None
|
||||
Products | GET | productsubcategories | categoryid, tenantid
|
||||
Get products stock counts (Web) | GET | products | tenantid, categoryid, subcategoryid, approve
|
||||
Get all global categories (Web) | GET | productcategories | None
|
||||
Get specific product variants (Web) | GET | productvariants | tenantid, subcategoryid
|
||||
Get master catalog listings (Web) | GET | products | tenantid, locationid, subcategoryid, keyword, pageno, pagesize
|
||||
Get live stocks catalog (Web) | GET | productstocks | tenantid, locationid
|
||||
Get dynamic stock statement ledger (Web) | GET | product_stock_statement | tenantid, locationid, subcategoryid, pageno, pagesize, keyword
|
||||
Get outlet geofenced inventory (Web) | GET | productlocations | tenantid, locationid, subcategoryid, pageno, pagesize
|
||||
Master products search board (Web) | GET | products | tenantid, locationid, keyword, pageno, pagesize
|
||||
Get product details by variant ID (Mobile) | GET | productvariants | tenantid, variantid
|
||||
Get product subcategories (Mobile) | GET | productsubcategories | categoryid, tenantid
|
||||
Search product catalog (Mobile) | GET | products | keyword, pageno, pagesize
|
||||
Get mobile geofenced outlet products (Mobile) | GET | productlocations | tenantid, locationid, pageno, pagesize
|
||||
Add Multi-Product Stock Entry | POST | REVIEW_REQUIRED | None
|
||||
Create Master Product Catalog Item | POST | REVIEW_REQUIRED | None
|
||||
Update Master Product Details | PUT | REVIEW_REQUIRED | None
|
||||
Purge Master Product Catalog Entry | DELETE | REVIEW_REQUIRED | productid
|
||||
Invoice | GET | invoice_insight | tenantid
|
||||
Payments | GET | paymentrequests | partnerid, status
|
||||
TOTAL ROWS: 104
|
||||
1009
spec_bundle.js
Normal file
1009
spec_bundle.js
Normal file
File diff suppressed because one or more lines are too long
132
src/App.tsx
132
src/App.tsx
@@ -9,7 +9,6 @@ import {
|
||||
Truck,
|
||||
Sliders,
|
||||
Calendar,
|
||||
AlertTriangle,
|
||||
FileCheck,
|
||||
Building,
|
||||
CheckCircle2,
|
||||
@@ -42,9 +41,54 @@ import ReportsView from './components/ReportsView';
|
||||
import InventoryView from './components/InventoryView';
|
||||
import SettingsView from './components/SettingsView';
|
||||
import StoreDetailView from './components/StoreDetailView';
|
||||
import LoginView from './components/LoginView';
|
||||
import UserStorePage from './components/UserStorePage';
|
||||
import AwaitingApi from './components/AwaitingApi';
|
||||
import type { AuthUser } from './services/auth';
|
||||
import ragulStoreCover from './assets/images/store_front_view_1780299351800.png';
|
||||
|
||||
const AUTH_STORAGE_KEY = 'nearledaily.auth';
|
||||
|
||||
/** Rehydrate the signed-in user from localStorage (keeps the session across refreshes). */
|
||||
function loadStoredUser(): AuthUser | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(AUTH_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as AuthUser;
|
||||
return parsed && typeof parsed === 'object' && (parsed.role === 'admin' || parsed.role === 'user')
|
||||
? parsed
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// Auth gate — null = signed out (show login). Admin → full dashboard, user →
|
||||
// their single store console. The verified user is persisted to localStorage so
|
||||
// a refresh keeps the session (rehydrated via the lazy initializer); authRole
|
||||
// is derived from it so the two can't drift out of sync.
|
||||
const [authUser, setAuthUser] = useState<AuthUser | null>(() => loadStoredUser());
|
||||
const authRole = authUser?.role ?? null;
|
||||
|
||||
// Persist (or clear) the session whenever the signed-in user changes.
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (authUser) localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(authUser));
|
||||
else localStorage.removeItem(AUTH_STORAGE_KEY);
|
||||
} catch {
|
||||
/* storage unavailable (private mode / quota) — session just won't persist */
|
||||
}
|
||||
}, [authUser]);
|
||||
|
||||
const handleLogin = (user: AuthUser) => setAuthUser(user);
|
||||
|
||||
const currentUser = {
|
||||
name: authUser?.name || 'Account',
|
||||
role: authRole === 'admin' ? 'Operations Admin' : 'Team Member',
|
||||
email: authUser?.email || '—',
|
||||
};
|
||||
|
||||
// Navigation indicators states
|
||||
const [currentSection, setCurrentSection] = useState<MainSection>('dashboard');
|
||||
const [selectedStore, setSelectedStore] = useState<{ locationid?: number; name: string; zone: string; deliveries: number; sales: string; orders?: number; staff: string; color: string; status: string } | null>(null);
|
||||
@@ -176,35 +220,53 @@ export default function App() {
|
||||
const [showCalendarModal, setShowCalendarModal] = useState(false);
|
||||
|
||||
// Callback action triggers
|
||||
const handleNewReport = () => {
|
||||
setCurrentSection('reports');
|
||||
alert('System routed back to reports dashboard interface. Select product item metadata matrices.');
|
||||
};
|
||||
|
||||
const handleHelp = () => {
|
||||
alert('nearledaily User Manual & Documentation Center linked successfully. Contact Coimbatore regional IT hub desk for urgent escalations.');
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
const ok = window.confirm('Are you sure you want to terminate this active secure session?');
|
||||
if (ok) {
|
||||
alert('Secure session suspended. Page reloading and restarting database state simulation.');
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
const handleLogout = () => setAuthUser(null);
|
||||
|
||||
// Define secondary sections (Stores, Logistics, Staffing, Settings) within main body
|
||||
const renderSecondarySection = () => {
|
||||
switch (currentSection) {
|
||||
case 'stores':
|
||||
if (selectedStore) {
|
||||
case 'stores': {
|
||||
// A single-store merchant has no branches, so skip the one-card registry
|
||||
// and open that store directly. Add a branch (2+ stores) and the grid
|
||||
// returns automatically. Clicking a card in multi-store mode also opens
|
||||
// the console, with a Back-to-registry button.
|
||||
const isSoleStore = !selectedStore && storesList.length === 1;
|
||||
const activeStore = selectedStore ?? (isSoleStore ? storesList[0] : null);
|
||||
|
||||
if (activeStore) {
|
||||
return (
|
||||
<div className="space-y-md animate-in fade-in duration-300">
|
||||
{isSoleStore && (
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-4 border-b border-zinc-205">
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">
|
||||
Store Console
|
||||
</h1>
|
||||
<p className="text-zinc-500 font-sans text-xs mt-1">
|
||||
This merchant operates a single store. Add a branch to manage multiple outlets.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddStoreModal(true)}
|
||||
className="bg-[#581c87] text-white px-5 py-2.5 rounded-lg text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-2 cursor-pointer hover:bg-purple-800 transition shadow-sm self-start md:self-auto"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Retail Outlet Node
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<StoreDetailView
|
||||
store={selectedStore}
|
||||
onBack={() => setSelectedStore(null)}
|
||||
store={activeStore}
|
||||
onBack={selectedStore ? () => setSelectedStore(null) : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-300">
|
||||
{/* Simple and elegant premium header */}
|
||||
@@ -468,6 +530,7 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'settings':
|
||||
return <SettingsView tenantId={FIESTA_TENANT_ID} />;
|
||||
@@ -477,6 +540,17 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Auth gate ──────────────────────────────────────────────────────────────
|
||||
// Signed out → login screen. User role → their single allocated store console
|
||||
// (scoped by applocationid). Only the admin role reaches the full operations
|
||||
// dashboard with the all-stores registry below.
|
||||
if (authRole === null) {
|
||||
return <LoginView onLogin={handleLogin} />;
|
||||
}
|
||||
if (authRole === 'user' && authUser) {
|
||||
return <UserStorePage onLogout={handleLogout} user={authUser} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f8fafc] text-[#0f172a] font-sans antialiased">
|
||||
{/* Navbar segment */}
|
||||
@@ -486,9 +560,9 @@ export default function App() {
|
||||
isCoimbatoreView={isCoimbatoreView}
|
||||
onToggleSidebar={() => setSidebarOpen((prev) => !prev)}
|
||||
isSidebarOpen={sidebarOpen}
|
||||
onNewReportClick={handleNewReport}
|
||||
onHelpClick={handleHelp}
|
||||
onLogoutClick={handleLogout}
|
||||
profile={currentUser}
|
||||
/>
|
||||
|
||||
{/* Main Container workspace layout splits */}
|
||||
@@ -556,28 +630,10 @@ export default function App() {
|
||||
|
||||
<div className="p-md space-y-md overflow-y-auto flex-1">
|
||||
<p className="text-zinc-500 leading-relaxed font-semibold">
|
||||
Automated compliance summaries are scheduled to generate and export on the following dates:
|
||||
Automated compliance summaries will appear here once scheduled-report exports are available.
|
||||
</p>
|
||||
|
||||
<div className="divide-y divide-[#f1f5f9] select-none text-[11px]">
|
||||
<div className="py-2 flex justify-between items-center">
|
||||
<span className="font-semibold text-zinc-700">Monthly Assortment Audit Ledger</span>
|
||||
<span className="font-mono text-[#581c87] font-bold">Oct 31, 2023</span>
|
||||
</div>
|
||||
<div className="py-2 flex justify-between items-center">
|
||||
<span className="font-semibold text-zinc-700">Daily Regional Turnover Sheet</span>
|
||||
<span className="text-emerald-600 font-bold">Everyday 23:59 (GMT)</span>
|
||||
</div>
|
||||
<div className="py-2 flex justify-between items-center">
|
||||
<span className="font-semibold text-zinc-700">Q4 Outlook Forecast Draft</span>
|
||||
<span className="font-mono text-zinc-500 font-bold">Nov 15, 2023</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-sm bg-amber-50 border border-amber-100 rounded-lg flex gap-sm text-amber-900 font-medium">
|
||||
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
|
||||
<span>Next automated sync will occur at standard local closing hour thresholds.</span>
|
||||
</div>
|
||||
<AwaitingApi label="Scheduled reports" api="[R13]" />
|
||||
</div>
|
||||
|
||||
<div className="p-sm bg-[#f8fafc] border-t border-[#e2e8f0] flex justify-end shrink-0">
|
||||
|
||||
40
src/components/AwaitingApi.tsx
Normal file
40
src/components/AwaitingApi.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { PlugZap } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Explicit "this data has no backend yet" placeholder. Used for UI sections whose
|
||||
* live API is not built (see docs/03_REQUIRED_BACKEND_APIS.md → [R*] ids). We show
|
||||
* this instead of fabricated numbers so nothing fake reaches staging.
|
||||
*/
|
||||
export default function AwaitingApi({
|
||||
label,
|
||||
api,
|
||||
className = '',
|
||||
compact = false,
|
||||
}: {
|
||||
/** What the section will show once wired (e.g. "Operational alerts"). */
|
||||
label: string;
|
||||
/** The required-API id from the spec doc, e.g. "[R12]". */
|
||||
api?: string;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center text-center rounded-xl border border-dashed border-slate-300 bg-slate-50/60 text-slate-400 ${
|
||||
compact ? 'p-4 gap-1.5' : 'p-8 gap-2'
|
||||
} ${className}`}
|
||||
>
|
||||
<PlugZap size={compact ? 16 : 22} className="text-slate-300" />
|
||||
<p className={`font-semibold text-slate-500 ${compact ? 'text-xs' : 'text-sm'}`}>{label}</p>
|
||||
<p className="text-[11px] leading-relaxed">
|
||||
Awaiting backend API{api ? ` ${api}` : ''} — no live data source yet.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,15 +5,15 @@
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
ShoppingBag,
|
||||
PackageCheck,
|
||||
Wallet,
|
||||
TrendingUp,
|
||||
Store,
|
||||
MapPin,
|
||||
Phone,
|
||||
Sparkles,
|
||||
AlertTriangle,
|
||||
Activity,
|
||||
Clock,
|
||||
ArrowUpRight,
|
||||
} from 'lucide-react';
|
||||
import { useOrderSummary, useTenantInfo, useTenantLocations, useInvoiceInsight } from '../services/queries';
|
||||
import { DEFAULT_TENANT_ID, DEFAULT_CONFIG_ID } from '../services/api';
|
||||
@@ -81,10 +81,42 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) {
|
||||
const dashOffset = circumference - (circumference * activePct) / 100;
|
||||
|
||||
const kpis = [
|
||||
{ title: 'ACTIVE OUTLETS', display: `${activeStoresCount} / ${totalStoresCount}`, icon: Store, chip: 'bg-purple-50 text-[#581c87]', loading: locationsQ.isLoading },
|
||||
{ title: 'REGION FULFILLMENT', display: regionFulfillmentPct == null ? '—' : `${regionFulfillmentPct.toFixed(1)}%`, icon: Sparkles, chip: 'bg-emerald-50 text-emerald-600', loading: summaryQ.isLoading },
|
||||
{ title: 'MONTHLY REVENUE', display: money(monthlyRevenue), icon: Wallet, chip: 'bg-sky-50 text-sky-600', loading: insightQ.isLoading },
|
||||
{ title: 'MONTHLY PROFIT', display: money(monthlyProfit), icon: TrendingUp, chip: 'bg-emerald-50 text-emerald-600', loading: insightQ.isLoading },
|
||||
{
|
||||
title: 'ACTIVE OUTLETS',
|
||||
display: `${activeStoresCount} / ${totalStoresCount}`,
|
||||
sub: `${activePct}% of the network is live`,
|
||||
icon: Store,
|
||||
bar: 'from-purple-500 to-indigo-500',
|
||||
chip: 'bg-purple-50 text-purple-650 ring-purple-100',
|
||||
loading: locationsQ.isLoading,
|
||||
},
|
||||
{
|
||||
title: 'REGION FULFILLMENT',
|
||||
display: regionFulfillmentPct == null ? '—' : `${regionFulfillmentPct.toFixed(1)}%`,
|
||||
sub: `${ordersDelivered.toLocaleString('en-IN')} of ${ordersTotal.toLocaleString('en-IN')} orders delivered`,
|
||||
icon: Activity,
|
||||
bar: 'from-indigo-500 to-sky-500',
|
||||
chip: 'bg-indigo-50 text-indigo-600 ring-indigo-100',
|
||||
loading: summaryQ.isLoading,
|
||||
},
|
||||
{
|
||||
title: 'MONTHLY REVENUE',
|
||||
display: money(monthlyRevenue),
|
||||
sub: 'Gross billed · month-to-date',
|
||||
icon: Wallet,
|
||||
bar: 'from-sky-500 to-cyan-500',
|
||||
chip: 'bg-sky-50 text-sky-600 ring-sky-100',
|
||||
loading: insightQ.isLoading,
|
||||
},
|
||||
{
|
||||
title: 'MONTHLY PROFIT',
|
||||
display: money(monthlyProfit),
|
||||
sub: 'Net margin · month-to-date',
|
||||
icon: TrendingUp,
|
||||
bar: 'from-emerald-500 to-teal-500',
|
||||
chip: 'bg-emerald-50 text-emerald-600 ring-emerald-100',
|
||||
loading: insightQ.isLoading,
|
||||
},
|
||||
];
|
||||
|
||||
const statusRows = [
|
||||
@@ -97,35 +129,57 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-500 relative">
|
||||
{/* Scope banner */}
|
||||
<div className="bg-[#faf5ff] border border-purple-100 rounded-xl p-md flex items-center justify-between shadow-[0_1px_3px_rgba(0,0,0,0.05)]">
|
||||
<div className="flex items-center gap-sm">
|
||||
<Sparkles size={16} className="text-[#581c87]" />
|
||||
<span className="font-sans text-xs text-zinc-700 font-medium">
|
||||
Live operations data for <strong>{tenantName}</strong> · {fromdate} → {todate}
|
||||
{/* ── Immersive Executive Banner (cover image + slate→purple gradient overlay) ── */}
|
||||
<div className="relative rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 overflow-hidden animate-in fade-in duration-300">
|
||||
{/* Cover image background & decorative glow */}
|
||||
<div className="absolute inset-0 z-0 overflow-hidden rounded-2xl">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1400&q=80"
|
||||
alt="Executive operations dashboard"
|
||||
className="w-full h-full object-cover object-center opacity-40"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-900/90 to-purple-950/80" />
|
||||
<div className="absolute top-0 right-0 w-72 h-72 bg-purple-500/10 rounded-full blur-3xl -mr-20 -mt-20 pointer-events-none" />
|
||||
<div className="absolute bottom-0 left-0 w-56 h-56 bg-indigo-500/10 rounded-full blur-2xl -ml-20 -mb-20 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Content row */}
|
||||
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-lg">
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl md:text-3xl tracking-tight text-white flex items-center gap-2.5">
|
||||
Executive Command Center
|
||||
<span className="text-[10px] text-purple-200 font-bold bg-purple-900/60 border border-purple-500/30 px-2 py-0.5 rounded-full uppercase tracking-wider animate-pulse">
|
||||
Live Core
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-slate-300 font-sans text-sm mt-2 leading-relaxed whitespace-nowrap">
|
||||
Month-to-date order operations for <strong className="text-white font-semibold">{tenantName}</strong>, pulled live from the API.
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-bold text-slate-300 uppercase tracking-wide">
|
||||
<span className="w-2 h-2 rounded-full bg-slate-400 animate-pulse" /> Syncing live data…
|
||||
</span>
|
||||
) : errored ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-bold text-rose-300 uppercase tracking-wide" title="Restart the dev server so the /hasura proxy is active.">
|
||||
<span className="w-2 h-2 rounded-full bg-rose-400" /> Live data unavailable
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-bold text-emerald-300 uppercase tracking-wide">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" /> Live · {tenantName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-3xl tracking-tight text-[#0f172a]">Executive Command Center</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-zinc-500 font-sans text-sm">Month-to-date order operations, pulled live from the API.</p>
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading…
|
||||
</span>
|
||||
) : errored ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-rose-600 uppercase tracking-wide" title="Restart the dev server so the /hasura proxy is active.">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" /> Live data unavailable
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 uppercase tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Live · {tenantName}
|
||||
</span>
|
||||
)}
|
||||
{/* Reporting scope panel */}
|
||||
<div className="flex flex-col items-start md:items-end gap-2 shrink-0">
|
||||
<div className="inline-flex items-center gap-2 bg-white/10 backdrop-blur-md border border-white/15 rounded-xl px-3.5 py-2.5 shadow-sm">
|
||||
<Clock size={14} className="text-purple-300" />
|
||||
<span className="text-xs font-bold font-mono text-white tracking-tight">{fromdate} → {todate}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 uppercase tracking-widest font-bold">Month-to-date reporting scope</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,23 +198,31 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI cards — all live from getordersummary */}
|
||||
{/* KPI cards — all live from getordersummary / getinvoiceinsight */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-gutter">
|
||||
{kpis.map((kpi) => {
|
||||
const Icon = kpi.icon;
|
||||
return (
|
||||
<div
|
||||
key={kpi.title}
|
||||
className="group relative flex flex-col bg-white border border-[#eceef2] rounded-xl p-3 shadow-[0_1px_2px_rgba(16,24,40,0.04)] transition-all duration-200 hover:-translate-y-0.5 hover:border-purple-300 hover:shadow-[0_8px_22px_rgba(16,24,40,0.08)]"
|
||||
className="group relative flex flex-col overflow-hidden bg-white border border-slate-200/70 rounded-2xl p-5 shadow-[0_1px_2px_rgba(16,24,40,0.04)] transition-all duration-300 hover:-translate-y-1 hover:border-purple-200 hover:shadow-[0_16px_36px_rgba(16,24,40,0.10)]"
|
||||
>
|
||||
<div className={`h-7 w-7 rounded-lg flex items-center justify-center ${kpi.chip}`}>
|
||||
<Icon size={14} />
|
||||
{/* Gradient accent bar */}
|
||||
<span className={`absolute inset-x-0 top-0 h-1 bg-gradient-to-r ${kpi.bar}`} />
|
||||
<div className="flex items-start justify-between">
|
||||
<div className={`h-11 w-11 rounded-xl flex items-center justify-center ring-1 group-hover:scale-110 transition-transform duration-300 ${kpi.chip}`}>
|
||||
<Icon size={19} />
|
||||
</div>
|
||||
<p className="text-[10px] font-semibold text-zinc-400 tracking-wider uppercase font-sans mt-3">
|
||||
<ArrowUpRight size={16} className="text-slate-300 group-hover:text-purple-400 transition-colors" />
|
||||
</div>
|
||||
<p className="text-[10px] font-bold text-slate-400 tracking-widest uppercase font-sans mt-4">
|
||||
{kpi.title}
|
||||
</p>
|
||||
<p className="font-sans font-bold text-2xl leading-tight text-[#0f172a] tracking-tight mt-0.5">
|
||||
{kpi.loading ? <span className="text-zinc-300">…</span> : kpi.display}
|
||||
<p className="font-sans font-extrabold text-[28px] leading-tight text-slate-900 tracking-tight mt-1">
|
||||
{kpi.loading ? <span className="text-slate-300">…</span> : kpi.display}
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400 font-medium mt-1.5 leading-snug">
|
||||
{kpi.sub}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -170,115 +232,140 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) {
|
||||
{/* Order status + store locations */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter">
|
||||
{/* Store Node Status donut (live) */}
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md flex flex-col shadow-[0_1px_3px_rgba(0,0,0,0.05)]">
|
||||
<div className="bg-white border border-slate-200/70 rounded-2xl p-lg flex flex-col shadow-[0_1px_3px_rgba(16,24,40,0.05)]">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="h-9 w-9 rounded-xl bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100 flex items-center justify-center shrink-0">
|
||||
<Activity size={17} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-sans font-bold text-base text-[#0f172a]">Store Outlet Status</h3>
|
||||
<p className="text-zinc-500 text-xs font-sans mt-0.5">Active share of all registered store nodes.</p>
|
||||
<h3 className="font-sans font-bold text-base text-slate-900 tracking-tight">Store Outlet Status</h3>
|
||||
<p className="text-slate-500 text-xs font-sans mt-0.5">Active share of all registered nodes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="my-md flex justify-center items-center">
|
||||
<div className="relative w-40 h-40 flex items-center justify-center">
|
||||
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="40" fill="transparent" stroke="#eceef0" strokeWidth="8" />
|
||||
<div className="my-lg flex justify-center items-center">
|
||||
<div className="relative w-44 h-44 flex items-center justify-center">
|
||||
{/* soft glow behind the ring */}
|
||||
<div className="absolute w-32 h-32 rounded-full bg-emerald-400/10 blur-2xl" />
|
||||
<svg className="w-full h-full transform -rotate-90 relative" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="40" fill="transparent" stroke="#eef2f6" strokeWidth="9" />
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="transparent"
|
||||
stroke="#10b981"
|
||||
strokeWidth="8"
|
||||
stroke="url(#dashGradient)"
|
||||
strokeWidth="9"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-700"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="dashGradient" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#10b981" />
|
||||
<stop offset="100%" stopColor="#0d9488" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="font-sans font-bold text-3xl text-[#0f172a] tracking-tight">{activePct}%</span>
|
||||
<span className="text-[10px] text-emerald-600 uppercase tracking-widest font-semibold mt-1">Active</span>
|
||||
<span className="font-sans font-extrabold text-4xl text-slate-900 tracking-tight">{activePct}%</span>
|
||||
<span className="text-[10px] text-emerald-600 uppercase tracking-widest font-bold mt-1">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#f1f5f9] text-xs">
|
||||
<div className="divide-y divide-slate-100 text-xs">
|
||||
{statusRows.map((r) => (
|
||||
<div key={r.label} className="flex justify-between items-center py-2">
|
||||
<span className="flex items-center gap-1.5 text-zinc-500">
|
||||
<div key={r.label} className="flex justify-between items-center py-2.5">
|
||||
<span className="flex items-center gap-2 text-slate-500 font-medium">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${r.dot}`} />
|
||||
{r.label}
|
||||
</span>
|
||||
<span className="font-mono font-bold text-zinc-700">{r.value.toLocaleString('en-IN')}</span>
|
||||
<span className="font-mono font-bold text-slate-700">{r.value.toLocaleString('en-IN')}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-zinc-500 font-semibold">Total Nodes</span>
|
||||
<span className="font-mono font-bold text-[#581c87]">{totalStoresCount.toLocaleString('en-IN')}</span>
|
||||
<div className="flex justify-between items-center pt-2.5">
|
||||
<span className="text-slate-600 font-bold">Total Nodes</span>
|
||||
<span className="font-mono font-extrabold text-purple-650">{totalStoresCount.toLocaleString('en-IN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Store locations (live) */}
|
||||
<div className="lg:col-span-2 bg-white border border-[#e2e8f0] rounded-xl p-md shadow-[0_1px_3px_rgba(0,0,0,0.05)]">
|
||||
<div className="flex justify-between items-center mb-md pb-xs border-b border-[#f1f5f9]">
|
||||
<h3 className="font-sans font-bold text-base text-[#0f172a] flex items-center gap-2">
|
||||
<Store size={16} className="text-[#581c87]" /> Store Locations
|
||||
<div className="lg:col-span-2 bg-white border border-slate-200/70 rounded-2xl p-lg shadow-[0_1px_3px_rgba(16,24,40,0.05)]">
|
||||
<div className="flex justify-between items-center mb-md pb-sm border-b border-slate-100">
|
||||
<h3 className="font-sans font-bold text-base text-slate-900 flex items-center gap-2.5 tracking-tight">
|
||||
<span className="h-9 w-9 rounded-xl bg-purple-50 text-purple-650 ring-1 ring-purple-100 flex items-center justify-center">
|
||||
<Store size={17} />
|
||||
</span>
|
||||
Store Locations
|
||||
</h3>
|
||||
<span className="text-[10px] text-[#581c87] uppercase font-bold bg-purple-50 px-2 py-0.5 rounded tracking-wide border border-purple-100">
|
||||
<span className="text-[10px] text-purple-650 uppercase font-bold bg-purple-50 px-2.5 py-1 rounded-lg tracking-wide border border-purple-100">
|
||||
{locationsQ.isLoading ? 'Loading…' : `${locations.length} Outlet${locations.length === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{locationsQ.isLoading ? (
|
||||
<div className="text-center py-xl text-zinc-400 text-xs">Loading store locations…</div>
|
||||
<div className="text-center py-xl text-slate-400 text-xs">Loading store locations…</div>
|
||||
) : locations.length === 0 ? (
|
||||
<div className="text-center py-xl text-zinc-400 text-xs">No store locations found for this tenant.</div>
|
||||
<div className="text-center py-xl text-slate-400 text-xs">No store locations found for this tenant.</div>
|
||||
) : (
|
||||
<div className="space-y-sm max-h-80 overflow-y-auto">
|
||||
<div className="space-y-sm max-h-80 overflow-y-auto pr-1">
|
||||
{locations.map((loc, i) => {
|
||||
const sum = summaries.find((s) => s.locationid === Number(loc.locationid));
|
||||
const deliveries = sum?.delivered ?? 0;
|
||||
const orders = Math.max(sum?.delivered ?? 0, sum?.total ?? 0);
|
||||
const isActive = str(loc.status).toLowerCase() === 'active';
|
||||
const name = str(loc.locationname);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={str(loc.locationid) || i}
|
||||
className="p-sm border border-[#e2e8f0] rounded-lg bg-[#f8fafc]/40 flex justify-between items-start gap-md hover:border-purple-200 transition-colors animate-in fade-in"
|
||||
className="group p-3.5 border border-slate-200/70 rounded-xl bg-slate-50/40 flex justify-between items-start gap-md hover:border-purple-200 hover:bg-white hover:shadow-[0_6px_18px_rgba(16,24,40,0.06)] transition-all animate-in fade-in"
|
||||
>
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
{/* Outlet initial badge */}
|
||||
<div className="h-9 w-9 shrink-0 rounded-lg bg-gradient-to-br from-purple-500 to-indigo-500 text-white flex items-center justify-center font-bold text-xs uppercase shadow-sm">
|
||||
{name.slice(0, 2) || '—'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-sans font-semibold text-sm text-[#0f172a] truncate">{str(loc.locationname)}</p>
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5 flex items-center gap-1">
|
||||
<MapPin size={11} className="shrink-0 text-zinc-400" />
|
||||
<p className="font-sans font-semibold text-sm text-slate-900 truncate">{name}</p>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5 flex items-center gap-1">
|
||||
<MapPin size={11} className="shrink-0 text-slate-400" />
|
||||
<span className="truncate">{str(loc.address) || `${str(loc.suburb)}, ${str(loc.city)}`}</span>
|
||||
</p>
|
||||
{str(loc.contactno) && (
|
||||
<p className="text-[11px] text-zinc-500 mt-0.5 flex items-center gap-1">
|
||||
<Phone size={11} className="shrink-0 text-zinc-400" />
|
||||
<p className="text-[11px] text-slate-500 mt-0.5 flex items-center gap-1">
|
||||
<Phone size={11} className="shrink-0 text-slate-400" />
|
||||
{str(loc.contactno)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Node-specific Orders and Dispatches */}
|
||||
<div className="flex items-center gap-3 mt-2.5">
|
||||
<span className="text-[10px] bg-purple-50 text-[#581c87] font-semibold px-2 py-0.5 rounded border border-purple-100/50">
|
||||
<div className="flex items-center gap-2 mt-2.5 flex-wrap">
|
||||
<span className="text-[10px] bg-purple-50 text-purple-650 font-bold px-2 py-0.5 rounded-md border border-purple-100/60">
|
||||
{orders} Orders
|
||||
</span>
|
||||
<span className="text-[10px] bg-emerald-50 text-emerald-700 font-semibold px-2 py-0.5 rounded border border-emerald-100/50">
|
||||
<span className="text-[10px] bg-emerald-50 text-emerald-700 font-bold px-2 py-0.5 rounded-md border border-emerald-100/60">
|
||||
{deliveries} Dispatched
|
||||
</span>
|
||||
{orders > 0 && (
|
||||
<span className="text-[10px] text-zinc-400 font-medium">
|
||||
<span className="text-[10px] text-slate-400 font-semibold">
|
||||
{Math.round((deliveries / orders) * 100)}% Fulfilled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${
|
||||
str(loc.status).toLowerCase() === 'active'
|
||||
className={`shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[9px] font-bold uppercase tracking-wide ${
|
||||
isActive
|
||||
? 'text-emerald-600 bg-emerald-50 border border-emerald-100'
|
||||
: 'text-zinc-500 bg-zinc-100'
|
||||
: 'text-slate-500 bg-slate-100 border border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${isActive ? 'bg-emerald-500' : 'bg-slate-400'}`} />
|
||||
{str(loc.status) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -4,29 +4,29 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Menu, Plus, HelpCircle, LogOut } from 'lucide-react';
|
||||
import { Menu, HelpCircle, LogOut, ChevronDown, Mail } from 'lucide-react';
|
||||
import { MainSection } from '../types';
|
||||
|
||||
interface HeaderProps {
|
||||
currentSection: MainSection;
|
||||
setCurrentSection: (section: MainSection) => void;
|
||||
isCoimbatoreView: boolean;
|
||||
// Admin nav context — unused by the bar itself, optional so the Header can be
|
||||
// reused by the user store page which has no MainSection routing.
|
||||
currentSection?: MainSection;
|
||||
setCurrentSection?: (section: MainSection) => void;
|
||||
isCoimbatoreView?: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
isSidebarOpen: boolean;
|
||||
onNewReportClick: () => void;
|
||||
onHelpClick: () => void;
|
||||
onLogoutClick: () => void;
|
||||
/** Signed-in user shown in the profile dropdown. */
|
||||
profile: { name: string; role: string; email: string };
|
||||
}
|
||||
|
||||
export default function Header({
|
||||
currentSection,
|
||||
setCurrentSection,
|
||||
isCoimbatoreView,
|
||||
onToggleSidebar,
|
||||
isSidebarOpen,
|
||||
onNewReportClick,
|
||||
onHelpClick,
|
||||
onLogoutClick
|
||||
onLogoutClick,
|
||||
profile
|
||||
}: HeaderProps) {
|
||||
const [showProfileDropdown, setShowProfileDropdown] = useState(false);
|
||||
const profileRef = useRef<HTMLDivElement>(null);
|
||||
@@ -47,12 +47,14 @@ export default function Header({
|
||||
};
|
||||
}, [showProfileDropdown]);
|
||||
|
||||
const profile = {
|
||||
name: 'Suresh Kumar',
|
||||
role: 'Operations Director',
|
||||
email: 'suresh.k@nearledaily.com',
|
||||
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=150&q=80'
|
||||
};
|
||||
const initials =
|
||||
profile.name
|
||||
.split(' ')
|
||||
.map((w) => w[0])
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase() || 'NA';
|
||||
|
||||
return (
|
||||
<header className="bg-[#581c87] border-b border-[#4c1d95] flex justify-between items-center w-full px-container-margin py-md fixed top-0 right-0 left-0 z-50 h-20 text-white shadow-sm">
|
||||
@@ -83,54 +85,76 @@ export default function Header({
|
||||
<div className="relative" ref={profileRef}>
|
||||
<button
|
||||
onClick={() => setShowProfileDropdown(!showProfileDropdown)}
|
||||
className="w-10 h-10 rounded-full overflow-hidden border border-purple-400 focus:ring-2 focus:ring-purple-300 outline-none cursor-pointer transition-transform duration-100 active:scale-95 flex items-center justify-center"
|
||||
className="group flex items-center gap-2.5 pl-1 pr-1 sm:pr-2.5 py-1 rounded-full bg-white/10 hover:bg-white/15 border border-white/15 backdrop-blur-sm focus:ring-2 focus:ring-purple-300/60 outline-none cursor-pointer transition-all duration-150 active:scale-[0.98]"
|
||||
>
|
||||
<img
|
||||
src={profile.avatar}
|
||||
alt="Executive Profile"
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full h-full object-cover"
|
||||
{/* Initials avatar with live status dot */}
|
||||
<span className="relative shrink-0">
|
||||
<span className="w-9 h-9 rounded-full bg-white/15 ring-2 ring-white/30 flex items-center justify-center text-xs font-bold text-white tracking-wide">
|
||||
{initials}
|
||||
</span>
|
||||
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-emerald-400 ring-2 ring-[#581c87]" />
|
||||
</span>
|
||||
|
||||
{/* Identity (hidden on small screens) */}
|
||||
<span className="hidden sm:flex flex-col items-start leading-tight">
|
||||
<span className="text-xs font-bold text-white truncate max-w-[130px]">{profile.name}</span>
|
||||
<span className="text-[10px] text-purple-200 font-medium truncate max-w-[130px]">{profile.role}</span>
|
||||
</span>
|
||||
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className={`hidden sm:block text-purple-200 transition-transform duration-200 ${showProfileDropdown ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{showProfileDropdown && (
|
||||
<div className="absolute right-0 mt-2 w-56 bg-white border border-[#e2e8f0] rounded-lg shadow-xl py-2 z-50 text-zinc-700 animate-in fade-in duration-200">
|
||||
<div className="px-4 py-2 border-b border-[#f1f5f9] bg-[#f8fafc]">
|
||||
<p className="font-bold text-xs text-[#0f172a]">{profile.name}</p>
|
||||
<p className="text-[10px] text-zinc-400 font-medium">{profile.role}</p>
|
||||
<div className="absolute right-0 mt-2.5 w-72 bg-white border border-slate-200/80 rounded-2xl shadow-2xl shadow-purple-950/15 z-50 text-slate-700 animate-in fade-in slide-in-from-top-2 duration-200 overflow-hidden">
|
||||
{/* Gradient profile header */}
|
||||
<div className="relative px-4 py-4 bg-gradient-to-br from-[#581c87] via-purple-800 to-purple-950 text-white overflow-hidden">
|
||||
<div className="absolute -top-8 -right-8 w-28 h-28 bg-purple-400/20 rounded-full blur-2xl pointer-events-none" />
|
||||
<div className="relative flex items-center gap-3">
|
||||
<span className="relative shrink-0">
|
||||
<span className="w-12 h-12 rounded-full bg-white/15 ring-2 ring-white/40 shadow-md flex items-center justify-center text-base font-bold text-white tracking-wide">
|
||||
{initials}
|
||||
</span>
|
||||
<span className="absolute bottom-0 right-0 w-3 h-3 rounded-full bg-emerald-400 ring-2 ring-purple-900" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-sm text-white truncate">{profile.name}</p>
|
||||
<span className="inline-block mt-1 text-[9px] font-bold uppercase tracking-wider text-purple-100 bg-white/15 border border-white/20 px-2 py-0.5 rounded-full">
|
||||
{profile.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-3 flex items-center gap-1.5 text-[11px] text-purple-100 font-medium truncate">
|
||||
<Mail size={12} className="shrink-0 text-purple-200" />
|
||||
<span className="truncate">{profile.email}</span>
|
||||
</div>
|
||||
<div className="p-2 divide-y divide-[#f1f5f9]">
|
||||
<div className="py-1">
|
||||
<p className="px-2 text-[10px] text-zinc-400 font-semibold uppercase tracking-wider">Email</p>
|
||||
<p className="px-2 py-0.5 text-xs text-purple-600 font-sans font-medium truncate">{profile.email}</p>
|
||||
</div>
|
||||
|
||||
{/* Account actions (moved here from the sidebar) */}
|
||||
<div className="py-1 pt-2 flex flex-col gap-0.5">
|
||||
<button
|
||||
onClick={() => { setShowProfileDropdown(false); onNewReportClick(); }}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-semibold text-zinc-700 hover:bg-zinc-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<Plus size={14} className="text-[#581c87]" />
|
||||
New Report
|
||||
</button>
|
||||
<div className="p-2 flex flex-col gap-0.5">
|
||||
<button
|
||||
onClick={() => { setShowProfileDropdown(false); onHelpClick(); }}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-semibold text-zinc-700 hover:bg-zinc-50 cursor-pointer transition-colors"
|
||||
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item"
|
||||
>
|
||||
<HelpCircle size={14} className="text-zinc-400" />
|
||||
<span className="h-7 w-7 rounded-lg bg-slate-100 text-slate-500 ring-1 ring-slate-200 flex items-center justify-center group-hover/item:scale-110 transition-transform">
|
||||
<HelpCircle size={14} />
|
||||
</span>
|
||||
Help Center
|
||||
</button>
|
||||
<div className="my-1 h-px bg-slate-100" />
|
||||
<button
|
||||
onClick={() => { setShowProfileDropdown(false); onLogoutClick(); }}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-semibold text-rose-600 hover:bg-rose-50 cursor-pointer transition-colors"
|
||||
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-xs font-semibold text-rose-600 hover:bg-rose-50 cursor-pointer transition-colors group/item"
|
||||
>
|
||||
<LogOut size={14} className="text-rose-500" />
|
||||
<span className="h-7 w-7 rounded-lg bg-rose-50 text-rose-500 ring-1 ring-rose-100 flex items-center justify-center group-hover/item:scale-110 transition-transform">
|
||||
<LogOut size={14} />
|
||||
</span>
|
||||
Log Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,25 +19,26 @@ import {
|
||||
Trash2,
|
||||
PackageCheck,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
Tag,
|
||||
UploadCloud,
|
||||
FileSpreadsheet,
|
||||
Palette,
|
||||
ShoppingBag,
|
||||
Info,
|
||||
X,
|
||||
Server,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
RotateCw,
|
||||
CheckCircle
|
||||
} from 'lucide-react';
|
||||
import { ProductMatrixItem, ImportLog } from '../types';
|
||||
import { initialImportLogs } from '../data';
|
||||
import { useFiestaTenantLocations, useFiestaStoresStock } from '../services/fiestaQueries';
|
||||
import { ProductMatrixItem } from '../types';
|
||||
import {
|
||||
useFiestaTenantLocations,
|
||||
useFiestaStoresStock,
|
||||
useFiestaProductCategories,
|
||||
} from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi';
|
||||
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
|
||||
type StockRow = Record<string, unknown>;
|
||||
const rowId = (r: StockRow) => String(r.productid ?? '') || String(r.productname ?? '');
|
||||
@@ -78,7 +79,6 @@ export default function InventoryView({
|
||||
// Global catalog = deduped union of every outlet's products, plus anything the
|
||||
// admin adds/imports in-session. Seeded once from the live data.
|
||||
const [products, setProducts] = useState<ProductMatrixItem[]>([]);
|
||||
const [importLogs, setImportLogs] = useState<ImportLog[]>(initialImportLogs);
|
||||
const [seeded, setSeeded] = useState(false);
|
||||
|
||||
const allStoreRows = storesStock.flatMap((s) => s.rows);
|
||||
@@ -98,8 +98,8 @@ export default function InventoryView({
|
||||
const [showAddProductModal, setShowAddProductModal] = useState(false);
|
||||
const [outletFilter, setOutletFilter] = useState<'all' | 'alerts'>('all');
|
||||
const [outletSearch, setOutletSearch] = useState('');
|
||||
const [restockedOverrides, setRestockedOverrides] = useState<Record<number, Record<string, number>>>({});
|
||||
const [loadingOutlets, setLoadingOutlets] = useState<Record<number, boolean>>({});
|
||||
// Regional Hub Stocks is read-only for admins — overrides remain empty (no restock actions).
|
||||
const [restockedOverrides] = useState<Record<number, Record<string, number>>>({});
|
||||
const [expandedHubs, setExpandedHubs] = useState<Record<number, boolean>>({});
|
||||
|
||||
// Memoize storesStock query results merged with simulated restock overrides
|
||||
@@ -155,89 +155,11 @@ export default function InventoryView({
|
||||
};
|
||||
}, [storesStockWithOverrides]);
|
||||
|
||||
// Simulated restock dispatch handler
|
||||
const handleRestockOutlet = (locationId: number, storeRows: any[], locationName: string) => {
|
||||
setLoadingOutlets(prev => ({ ...prev, [locationId]: true }));
|
||||
|
||||
setTimeout(() => {
|
||||
setRestockedOverrides(prev => {
|
||||
const currentOverrides = prev[locationId] || {};
|
||||
const newOverrides = { ...currentOverrides };
|
||||
|
||||
storeRows.forEach(row => {
|
||||
const sku = `SKU-${String(row.productid ?? '') || String(row.productname ?? '')}`;
|
||||
newOverrides[sku] = 200; // Restock to safe optimal level (>= 120)
|
||||
});
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[locationId]: newOverrides
|
||||
};
|
||||
});
|
||||
|
||||
setLoadingOutlets(prev => ({ ...prev, [locationId]: false }));
|
||||
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
setImportLogs(prev => [
|
||||
{
|
||||
id: String(Date.now()),
|
||||
timestamp,
|
||||
file: 'SUPPLY_CHAIN_API',
|
||||
status: 'SUCCESS',
|
||||
count: storeRows.length,
|
||||
note: `AUTO-RESTOCK: Dispatched emergency shipment to ${locationName}. Synchronized ${storeRows.length} SKUs to 200 units.`
|
||||
},
|
||||
...prev
|
||||
]);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
// Simulated single SKU restock handler
|
||||
const handleRestockSKU = (locationId: number, row: any, locationName: string) => {
|
||||
if (!row) return;
|
||||
const sku = `SKU-${String(row.productid ?? '') || String(row.productname ?? '')}`;
|
||||
const prodName = String(row.productname || 'Unnamed product');
|
||||
|
||||
setRestockedOverrides(prev => {
|
||||
const currentOverrides = prev[locationId] || {};
|
||||
return {
|
||||
...prev,
|
||||
[locationId]: {
|
||||
...currentOverrides,
|
||||
[sku]: 200
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
setImportLogs(prev => [
|
||||
{
|
||||
id: String(Date.now()),
|
||||
timestamp,
|
||||
file: 'SUPPLY_CHAIN_API',
|
||||
status: 'SUCCESS',
|
||||
count: 1,
|
||||
note: `SKU-RESTOCK: Restocked ${prodName} (${sku}) at ${locationName} to 200 units.`
|
||||
},
|
||||
...prev
|
||||
]);
|
||||
};
|
||||
|
||||
// CSV Textarea input
|
||||
const [csvText, setCsvText] = useState(
|
||||
"Name, SKU, Category, Price, InitialStock\nAmma Ghee Pure Butter, GHEE-AMMA-1L, Groceries / Oils, 640, 200\nBhavani Ponni Sona Rice, ST-SONA-25K, Staples / Rice, 1350, 150"
|
||||
);
|
||||
|
||||
// Brand designs state
|
||||
const [brandStyle, setBrandStyle] = useState({
|
||||
themeName: 'Coimbatore Kaveri Org',
|
||||
primaryColor: '#16a34a', // Emerald
|
||||
secondaryColor: '#f59e0b', // Amber
|
||||
bagLabel: 'Freshly Harvested from Tamil Soil',
|
||||
isEcoVerified: true,
|
||||
stickerPattern: 'radial'
|
||||
});
|
||||
|
||||
// Form state for individual adding
|
||||
const [newProduct, setNewProduct] = useState({
|
||||
name: '',
|
||||
@@ -248,6 +170,16 @@ export default function InventoryView({
|
||||
image: 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200'
|
||||
});
|
||||
|
||||
// Live product categories (for the Add-Product modal dropdown).
|
||||
const productCategoriesQ = useFiestaProductCategories();
|
||||
const productCategoryNames = useMemo(
|
||||
() =>
|
||||
(productCategoriesQ.data ?? [])
|
||||
.map((c) => fstr(c.categoryname))
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
[productCategoriesQ.data],
|
||||
);
|
||||
|
||||
// Categories derived from the live catalog (falls back to ALL only).
|
||||
const categorySet = new Set<string>();
|
||||
products.forEach((p) => categorySet.add(p.category));
|
||||
@@ -342,78 +274,12 @@ export default function InventoryView({
|
||||
|
||||
if (parsedCount > 0) {
|
||||
setProducts(prev => [...newProds, ...prev]);
|
||||
|
||||
const logEntry: ImportLog = {
|
||||
timestamp: new Date().toLocaleTimeString() + ' (IST)',
|
||||
batchRef: `#IMP_CSV_${Math.floor(1000 + Math.random() * 9000)}`,
|
||||
type: 'CSV Catalogue Import',
|
||||
source: 'Console Upload',
|
||||
result: `SUCCESS (Parsed ${parsedCount} rows)`,
|
||||
status: 'SUCCESS'
|
||||
};
|
||||
setImportLogs([logEntry, ...importLogs]);
|
||||
alert(`Synchronized ${parsedCount} regional products into Catalog database successfully!`);
|
||||
} else {
|
||||
alert('All the specified SKU codes are already active in the catalog ledger.');
|
||||
}
|
||||
};
|
||||
|
||||
// Preset import trigger
|
||||
const handleImportPreset = (presetName: string, itemsList: Array<{name: string, sku: string, cat: string, price: number, stock: number, img: string}>) => {
|
||||
let imported = 0;
|
||||
const newProds: ProductMatrixItem[] = [];
|
||||
|
||||
itemsList.forEach((itm) => {
|
||||
if (!products.some(p => p.sku === itm.sku)) {
|
||||
newProds.push({
|
||||
id: String(products.length + newProds.length + 20),
|
||||
name: itm.name,
|
||||
sku: itm.sku,
|
||||
unitsSold: Math.floor(Math.random() * 45 + 15),
|
||||
revenue: Math.floor(Math.random() * 20000 + 4000),
|
||||
stockStatus: 'Healthy',
|
||||
trend: 'up',
|
||||
image: itm.img,
|
||||
category: itm.cat,
|
||||
exposure: 'All Outlets',
|
||||
verified: true
|
||||
});
|
||||
imported++;
|
||||
}
|
||||
});
|
||||
|
||||
if (imported > 0) {
|
||||
setProducts(prev => [...newProds, ...prev]);
|
||||
|
||||
const logEntry: ImportLog = {
|
||||
timestamp: new Date().toLocaleTimeString() + ' (IST)',
|
||||
batchRef: `#IMP_PST_${Math.floor(1000 + Math.random() * 9000)}`,
|
||||
type: `${presetName} Import`,
|
||||
source: 'Corporate Cloud Feed',
|
||||
result: `SUCCESS Onboarded (${imported} SKUs)`,
|
||||
status: 'SUCCESS'
|
||||
};
|
||||
setImportLogs([logEntry, ...importLogs]);
|
||||
alert(`Successfully mapped and onboarded ${imported} brand SKUs from "${presetName}"!`);
|
||||
} else {
|
||||
alert('All elements of this retail catalog preset are already assigned.');
|
||||
}
|
||||
};
|
||||
|
||||
// Nilgiris Presets
|
||||
const nilgirisDairy = [
|
||||
{ name: 'Ooty Hills Creamery Butter 500g', sku: 'DY-OOT-BTR', cat: 'Groceries / Oils', price: 340, stock: 210, img: 'https://images.unsplash.com/photo-1589985270826-4b7bb135bc9d?auto=format&fit=crop&q=80&w=200' },
|
||||
{ name: 'Nilgiris Mountain Farm Cheese 250g', sku: 'DY-NIL-CHS', cat: 'Groceries / Oils', price: 460, stock: 120, img: 'https://images.unsplash.com/photo-1486887396153-fa416525c108?auto=format&fit=crop&q=80&w=200' },
|
||||
{ name: 'Aavin Premium Ghee Tin 1L', sku: 'DY-AAV-GHEE', cat: 'Groceries / Oils', price: 680, stock: 180, img: 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200' }
|
||||
];
|
||||
|
||||
// Coimbatore Crops
|
||||
const cbeHeritage = [
|
||||
{ name: 'Bhavani Premium Boiled Rice 10kg', sku: 'ST-BHV-RICE', cat: 'Staples / Rice', price: 740, stock: 350, img: 'https://images.unsplash.com/photo-1586201375761-83865001e31c?auto=format&fit=crop&q=80&w=200' },
|
||||
{ name: 'Pollachi Clean Gram Dhal 2kg', sku: 'ST-POL-DHAL', cat: 'Staples / Rice', price: 185, stock: 240, img: 'https://images.unsplash.com/photo-1596040033229-a9821ebd058d?auto=format&fit=crop&q=80&w=200' },
|
||||
{ name: 'Pure Wood Pressed Gingelly Oil 1L', sku: 'ST-OIL-WOOD', cat: 'Groceries / Oils', price: 395, stock: 190, img: 'https://images.unsplash.com/photo-1474979266404-7eaacbcd87c5?auto=format&fit=crop&q=80&w=200' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-500 font-sans relative">
|
||||
|
||||
@@ -422,7 +288,7 @@ export default function InventoryView({
|
||||
<div className="absolute top-40 right-1/4 w-[28rem] h-[28rem] bg-indigo-400/5 rounded-full blur-[140px] pointer-events-none -z-10 animate-pulse" style={{ animationDuration: '8s' }} />
|
||||
|
||||
{/* ── Immersive Analytics Banner (With Catalog Cover Image & Slate Gradient Overlay) ── */}
|
||||
<div className="relative rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 mb-8 animate-in fade-in duration-300 z-35">
|
||||
<div className="relative rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 mb-8 animate-in fade-in duration-300 z-40">
|
||||
{/* Cover Image Background & Decor */}
|
||||
<div className="absolute inset-0 z-0 overflow-hidden rounded-2xl">
|
||||
<img
|
||||
@@ -438,11 +304,11 @@ export default function InventoryView({
|
||||
</div>
|
||||
|
||||
{/* Content Row */}
|
||||
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="relative z-20 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-white flex items-center gap-2">
|
||||
<Layers size={24} className="text-purple-300" />
|
||||
Product Catalog Command Center
|
||||
Product Catalog
|
||||
<span className="text-[10px] text-purple-200 font-bold bg-purple-900/60 border border-purple-500/30 px-2 py-0.5 rounded-full uppercase tracking-wider animate-pulse">
|
||||
Global Sync
|
||||
</span>
|
||||
@@ -710,20 +576,23 @@ export default function InventoryView({
|
||||
|
||||
{/* Elegant Header Row */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-slate-100 pb-4 mt-8 select-none">
|
||||
<div className="space-y-1 font-sans">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-bold text-sm text-slate-900 flex items-center gap-1.5">
|
||||
<Server size={16} className="text-purple-650" /> Regional Hub Stocks
|
||||
</h3>
|
||||
<div className="flex items-start gap-3 font-sans">
|
||||
<div className="h-10 w-10 shrink-0 rounded-xl bg-gradient-to-br from-purple-500 to-indigo-500 text-white flex items-center justify-center shadow-sm">
|
||||
<Server size={18} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-bold text-base text-slate-900 tracking-tight">Regional Hub Stocks</h3>
|
||||
<span className="inline-flex items-center gap-1 text-[9px] font-bold bg-emerald-50 text-emerald-600 border border-emerald-100/50 px-2 py-0.5 rounded-full">
|
||||
<span className="w-1 h-1 rounded-full bg-emerald-500 animate-pulse" />
|
||||
Live Sync
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-slate-400 text-[10px] font-medium">
|
||||
<p className="text-slate-400 text-[11px] font-medium">
|
||||
Real-time inventory levels and capacity balance across {locations.length} regional outlets.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls: Search + Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -779,23 +648,29 @@ export default function InventoryView({
|
||||
</div>
|
||||
|
||||
{/* Quick Metrics Strip */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 bg-slate-50/50 rounded-2xl border border-slate-100/80 font-sans text-xs">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[9px] text-slate-400 uppercase tracking-wider font-extrabold">Active Outlets</span>
|
||||
<span className="font-bold text-slate-800 text-sm font-mono">{locations.length}</span>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 font-sans">
|
||||
{[
|
||||
{ label: 'Active Outlets', value: locations.length, icon: Server, chip: 'bg-purple-50 text-purple-650 ring-purple-100', value_cls: 'text-slate-900' },
|
||||
{ label: 'Optimal Hubs', value: locations.length - storeAlertsData.alertOutletsCount, icon: CheckCircle, chip: 'bg-emerald-50 text-emerald-600 ring-emerald-100', value_cls: 'text-emerald-600' },
|
||||
{ label: 'Low Stock Items', value: storeAlertsData.lowStockCount, icon: TrendingDown, chip: 'bg-amber-50 text-amber-600 ring-amber-100', value_cls: 'text-amber-600' },
|
||||
{ label: 'Critical Alerts', value: storeAlertsData.criticalCount, icon: AlertTriangle, chip: 'bg-rose-50 text-rose-600 ring-rose-100', value_cls: 'text-rose-600' },
|
||||
].map((m) => {
|
||||
const MIcon = m.icon;
|
||||
return (
|
||||
<div
|
||||
key={m.label}
|
||||
className="bg-white border border-slate-200/70 rounded-2xl p-4 shadow-[0_1px_2px_rgba(16,24,40,0.04)] hover:shadow-[0_8px_22px_rgba(16,24,40,0.08)] hover:border-purple-200 transition-all duration-300 flex items-center gap-3"
|
||||
>
|
||||
<div className={`h-10 w-10 shrink-0 rounded-xl flex items-center justify-center ring-1 ${m.chip}`}>
|
||||
<MIcon size={18} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[9px] text-slate-400 uppercase tracking-wider font-extrabold">Optimal Hubs</span>
|
||||
<span className="font-bold text-emerald-600 text-sm font-mono">{locations.length - storeAlertsData.alertOutletsCount}</span>
|
||||
<div className="min-w-0">
|
||||
<span className="block text-[9px] text-slate-400 uppercase tracking-wider font-extrabold truncate">{m.label}</span>
|
||||
<span className={`block font-extrabold text-2xl leading-tight font-sans tracking-tight ${m.value_cls}`}>{m.value}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[9px] text-slate-400 uppercase tracking-wider font-extrabold">Low Stock Items</span>
|
||||
<span className="font-bold text-amber-600 text-sm font-mono">{storeAlertsData.lowStockCount}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[9px] text-slate-400 uppercase tracking-wider font-extrabold">Critical Alerts</span>
|
||||
<span className="font-bold text-rose-600 text-sm font-mono">{storeAlertsData.criticalCount}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
@@ -837,6 +712,10 @@ export default function InventoryView({
|
||||
? criticalItemsCount > 0 ? 'bg-rose-500' : 'bg-amber-500'
|
||||
: 'bg-emerald-500';
|
||||
|
||||
const statusChip = hasAlert
|
||||
? criticalItemsCount > 0 ? 'bg-rose-50 text-rose-600 ring-rose-100' : 'bg-amber-50 text-amber-600 ring-amber-100'
|
||||
: 'bg-emerald-50 text-emerald-600 ring-emerald-100';
|
||||
|
||||
const optimalPct = totalItems > 0 ? (optimalCount / totalItems) * 100 : 0;
|
||||
const lowPct = totalItems > 0 ? (lowCount / totalItems) * 100 : 0;
|
||||
const criticalPct = totalItems > 0 ? (criticalItemsCount / totalItems) * 100 : 0;
|
||||
@@ -848,51 +727,49 @@ export default function InventoryView({
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={store.locationid} className="bg-white border border-slate-100 rounded-2xl shadow-sm hover:shadow-md transition-all duration-300 relative flex flex-col justify-between overflow-hidden min-h-[300px]">
|
||||
<div key={store.locationid} className="bg-white border border-slate-200/70 rounded-2xl shadow-[0_1px_3px_rgba(16,24,40,0.05)] hover:shadow-[0_16px_36px_rgba(16,24,40,0.10)] hover:border-purple-200 hover:-translate-y-0.5 transition-all duration-300 relative flex flex-col justify-between overflow-hidden min-h-[300px]">
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{loadingOutlets[store.locationid] && (
|
||||
<div className="absolute inset-0 bg-white/95 backdrop-blur-sm z-40 flex flex-col items-center justify-center p-6 text-center animate-in fade-in duration-200">
|
||||
<div className="space-y-3">
|
||||
<div className="w-8 h-8 mx-auto relative">
|
||||
<div className="absolute inset-0 rounded-full border-2 border-purple-500/20" />
|
||||
<div className="absolute inset-0 rounded-full border-2 border-purple-600 border-t-transparent animate-spin" />
|
||||
{/* Card Header */}
|
||||
<div className="p-4 pb-3 flex justify-between items-start gap-2">
|
||||
<div className="flex items-start gap-2.5 min-w-0">
|
||||
<div className={`h-9 w-9 shrink-0 rounded-lg flex items-center justify-center ring-1 ${statusChip}`}>
|
||||
<Server size={15} />
|
||||
</div>
|
||||
<div className="space-y-0.5 font-sans">
|
||||
<h4 className="text-slate-850 font-bold text-[11px]">Replenishing Hub...</h4>
|
||||
<p className="text-slate-400 text-[9px]">Dispatching supply batch to {store.locationname}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card Header (Clean & borderless) */}
|
||||
<div className="p-4 pb-2 flex justify-between items-start gap-2">
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-bold text-slate-900 text-xs truncate flex items-center gap-1.5">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${statusDotColor}`} />
|
||||
<h4 className="font-bold text-slate-900 text-[13px] truncate leading-tight">
|
||||
{store.locationname}
|
||||
</h4>
|
||||
<p className="text-[9px] text-slate-400 mt-0.5 font-medium">
|
||||
<p className="text-[10px] text-slate-400 mt-0.5 font-medium">
|
||||
{totalItems} items · {totalUnits.toLocaleString('en-IN')} units
|
||||
</p>
|
||||
</div>
|
||||
<span className={`shrink-0 px-2 py-0.5 rounded-full text-[8px] font-black uppercase tracking-wider ${
|
||||
</div>
|
||||
<span className={`shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[8px] font-black uppercase tracking-wider ${
|
||||
hasAlert
|
||||
? criticalItemsCount > 0
|
||||
? 'text-rose-600 bg-rose-50'
|
||||
: 'text-amber-700 bg-amber-50'
|
||||
: 'text-emerald-600 bg-emerald-50'
|
||||
? 'text-rose-600 bg-rose-50 border border-rose-100'
|
||||
: 'text-amber-700 bg-amber-50 border border-amber-100'
|
||||
: 'text-emerald-600 bg-emerald-50 border border-emerald-100'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${statusDotColor}`} />
|
||||
{hasAlert ? criticalItemsCount > 0 ? 'Critical' : 'Low Stock' : 'Optimal'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Card Body */}
|
||||
<div className="px-4 py-2 space-y-3 flex-1">
|
||||
<div className="px-4 py-2 space-y-3.5 flex-1">
|
||||
|
||||
{/* Segmented Stock Health Distribution */}
|
||||
<div className="w-full h-1 bg-slate-100 rounded-full overflow-hidden flex select-none">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center text-[9px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
<span>Stock Health</span>
|
||||
<span className="flex items-center gap-2 normal-case font-semibold">
|
||||
{criticalItemsCount > 0 && <span className="text-rose-600">{criticalItemsCount} crit</span>}
|
||||
{lowCount > 0 && <span className="text-amber-600">{lowCount} low</span>}
|
||||
<span className="text-emerald-600">{optimalCount} ok</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden flex select-none">
|
||||
{criticalPct > 0 && (
|
||||
<div className="h-full bg-rose-500" style={{ width: `${criticalPct}%` }} />
|
||||
)}
|
||||
@@ -903,17 +780,18 @@ export default function InventoryView({
|
||||
<div className="h-full bg-emerald-500" style={{ width: `${optimalPct}%` }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capacity utilization indicator */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center text-[9px] font-medium text-slate-400">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center text-[9px] font-bold text-slate-400 uppercase tracking-wider">
|
||||
<span>Capacity Utilised</span>
|
||||
<span className="font-mono text-slate-600">{Math.round(capacityPct)}%</span>
|
||||
<span className="font-mono text-slate-600 normal-case font-bold">{Math.round(capacityPct)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 h-1 rounded-full overflow-hidden">
|
||||
<div className="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
capacityPct > 85 ? 'bg-rose-500' : 'bg-purple-650'
|
||||
capacityPct > 85 ? 'bg-rose-500' : 'bg-gradient-to-r from-purple-500 to-indigo-500'
|
||||
}`}
|
||||
style={{ width: `${capacityPct}%` }}
|
||||
/>
|
||||
@@ -931,11 +809,10 @@ export default function InventoryView({
|
||||
) : (
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto pr-0.5 scrollbar-thin">
|
||||
{sortedItems.map((it, idx) => {
|
||||
const rawRow = store.rows.find(r => `SKU-${String(r.productid ?? '') || String(r.productname ?? '')}` === it.sku);
|
||||
const isLow = it.status !== 'Optimal';
|
||||
|
||||
return (
|
||||
<div key={idx} className="flex justify-between items-center gap-3 py-0.5 group/row">
|
||||
<div key={idx} className="flex justify-between items-center gap-3 py-0.5">
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
<span className={`w-1 h-1 rounded-full shrink-0 ${
|
||||
it.status === 'Critical' ? 'bg-rose-500 animate-pulse' : it.status === 'Low Stock' ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500'
|
||||
@@ -947,22 +824,11 @@ export default function InventoryView({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`font-mono text-[10px] font-bold ${
|
||||
<span className={`font-mono text-[10px] font-bold shrink-0 ${
|
||||
it.status === 'Critical' ? 'text-rose-600' : it.status === 'Low Stock' ? 'text-amber-700' : 'text-slate-500'
|
||||
}`}>
|
||||
{it.stockLevel}
|
||||
</span>
|
||||
{isLow && (
|
||||
<button
|
||||
onClick={() => handleRestockSKU(store.locationid, rawRow, store.locationname)}
|
||||
title="Replenish SKU"
|
||||
className="p-0.5 rounded hover:bg-purple-50 text-slate-400 hover:text-purple-650 transition-colors border-none bg-transparent cursor-pointer opacity-0 group-hover/row:opacity-100 focus:opacity-100"
|
||||
>
|
||||
<RotateCw size={9} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -972,20 +838,18 @@ export default function InventoryView({
|
||||
|
||||
</div>
|
||||
|
||||
{/* Card Footer actions */}
|
||||
{/* Card Footer — read-only status (admins cannot edit hub stock) */}
|
||||
<div className="px-4 py-3 border-t border-slate-100 bg-slate-50/50 flex items-center justify-between gap-2 shrink-0">
|
||||
<span className="text-[9px] font-mono text-slate-400 uppercase tracking-wide">
|
||||
{hasAlert ? `${criticalItemsCount + lowCount} items need attention` : 'All items optimal'}
|
||||
</span>
|
||||
|
||||
{hasAlert ? (
|
||||
<button
|
||||
onClick={() => handleRestockOutlet(store.locationid, store.rows, store.locationname)}
|
||||
className="bg-purple-650 hover:bg-purple-755 text-white px-3 py-1.5 rounded-lg text-[9px] font-bold uppercase tracking-wider cursor-pointer border-none transition-all flex items-center gap-1 shadow-sm active:scale-95"
|
||||
>
|
||||
<Zap size={10} />
|
||||
<span>Restock Hub</span>
|
||||
</button>
|
||||
<span className={`text-[9px] font-black flex items-center gap-1 select-none pr-1 uppercase tracking-wide ${
|
||||
criticalItemsCount > 0 ? 'text-rose-600' : 'text-amber-700'
|
||||
}`}>
|
||||
<AlertTriangle size={11} /> {criticalItemsCount > 0 ? 'Critical' : 'Low Stock'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[9px] font-black text-emerald-600 flex items-center gap-1 select-none pr-1">
|
||||
<CheckCircle size={11} /> Stocked
|
||||
@@ -1013,45 +877,7 @@ export default function InventoryView({
|
||||
<h3>Cooperative Catalog Presets</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-sm">
|
||||
|
||||
{/* Preset 1 */}
|
||||
<div className="border border-[#e2e8f0] rounded-2xl p-md space-y-md hover:border-purple-300 transition-colors bg-gradient-to-br from-indigo-50/20 to-purple-50/10 group">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[8px] font-extrabold uppercase bg-purple-100 text-purple-700 px-2 py-0.5 rounded-md">Cooperative Dairy</span>
|
||||
<h4 className="font-bold text-zinc-900 leading-tight pt-1">Nilgiris Dairy Fresh Pack</h4>
|
||||
<p className="text-[10px] text-zinc-400 font-semibold">3 High-Margin Butter & Cheese SKUs</p>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<span className="text-[10px] font-mono font-bold text-[#581c87]">CBE-COOP-04</span>
|
||||
<button
|
||||
onClick={() => handleImportPreset('Nilgiris Dairy Coop', nilgirisDairy)}
|
||||
className="px-3 py-1.5 bg-[#581c87] hover:bg-purple-800 text-white font-bold rounded-lg text-[9px] uppercase cursor-pointer transition-transform group-hover:scale-105"
|
||||
>
|
||||
Import Batch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preset 2 */}
|
||||
<div className="border border-[#e2e8f0] rounded-2xl p-md space-y-md hover:border-purple-300 transition-colors bg-gradient-to-br from-emerald-50/20 to-teal-50/10 group">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[8px] font-extrabold uppercase bg-emerald-100 text-emerald-700 px-2 py-0.5 rounded-md">Agricultural Feed</span>
|
||||
<h4 className="font-bold text-zinc-900 leading-tight pt-1">Coimbatore Heritage Grains</h4>
|
||||
<p className="text-[10px] text-zinc-400 font-semibold">3 Premium Boiled Rice & Oils</p>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<span className="text-[10px] font-mono font-bold text-emerald-600">TAMIL-AGRI-09</span>
|
||||
<button
|
||||
onClick={() => handleImportPreset('Coimbatore Heritage', cbeHeritage)}
|
||||
className="px-3 py-1.5 bg-[#581c87] hover:bg-purple-800 text-white font-bold rounded-lg text-[9px] uppercase cursor-pointer transition-transform group-hover:scale-105"
|
||||
>
|
||||
Import Batch
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<AwaitingApi label="Catalog presets" api="[R5]" compact />
|
||||
</div>
|
||||
|
||||
{/* Custom CSV Parsing Box */}
|
||||
@@ -1097,26 +923,7 @@ export default function InventoryView({
|
||||
<span className="text-[9px] text-zinc-500 font-bold">COIMBATORE_ERP_V4</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||
{importLogs.map((log, idx) => (
|
||||
<div key={idx} className="flex justify-between items-start gap-4 text-[10px] border-b border-zinc-900/50 pb-1.5">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-purple-400 font-extrabold">{log.batchRef}</span>
|
||||
<span className="text-zinc-650 text-[8px] font-semibold">{log.timestamp}</span>
|
||||
</div>
|
||||
<p className="text-zinc-400">
|
||||
{log.type} via <span className="text-zinc-300 italic">{log.source}</span>
|
||||
</p>
|
||||
<p className="text-zinc-505 font-sans text-[9px] leading-tight">{log.result}</p>
|
||||
</div>
|
||||
|
||||
<span className="px-1.5 py-0.5 bg-emerald-950/50 border border-emerald-900 text-emerald-450 font-extrabold uppercase text-[8px] rounded">
|
||||
{log.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<AwaitingApi label="Import / sync audit log" api="[R4]" compact className="bg-zinc-900/40 border-zinc-800" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1130,115 +937,7 @@ export default function InventoryView({
|
||||
<h3>Packaging Branding Studio</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-md text-xs">
|
||||
|
||||
{/* Studio Control 1 */}
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">Brand Title Caption</label>
|
||||
<input
|
||||
type="text"
|
||||
value={brandStyle.themeName}
|
||||
onChange={(e) => setBrandStyle({ ...brandStyle, themeName: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87] font-semibold text-zinc-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Studio Control 2 */}
|
||||
<div className="grid grid-cols-2 gap-sm">
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">Primary color</label>
|
||||
<div className="flex gap-sm items-center">
|
||||
<input
|
||||
type="color"
|
||||
value={brandStyle.primaryColor}
|
||||
onChange={(e) => setBrandStyle({ ...brandStyle, primaryColor: e.target.value })}
|
||||
className="w-8 h-8 rounded border border-zinc-200 cursor-pointer bg-transparent"
|
||||
/>
|
||||
<span className="font-mono font-bold text-zinc-650">{brandStyle.primaryColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-500 uppercase tracking-widest text-[9px]">Accent text color</label>
|
||||
<div className="flex gap-sm items-center">
|
||||
<input
|
||||
type="color"
|
||||
value={brandStyle.secondaryColor}
|
||||
onChange={(e) => setBrandStyle({ ...brandStyle, secondaryColor: e.target.value })}
|
||||
className="w-8 h-8 rounded border border-zinc-200 cursor-pointer bg-transparent"
|
||||
/>
|
||||
<span className="font-mono font-bold text-zinc-650">{brandStyle.secondaryColor}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Studio Control 3 */}
|
||||
<div className="space-y-1">
|
||||
<label className="font-bold text-zinc-505 uppercase tracking-widest text-[9px]">Bag print footer tag</label>
|
||||
<input
|
||||
type="text"
|
||||
value={brandStyle.bagLabel}
|
||||
onChange={(e) => setBrandStyle({ ...brandStyle, bagLabel: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-sm bg-[#f8fafc] focus:bg-white outline-none focus:ring-1 focus:ring-[#581c87] font-semibold text-zinc-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Studio Control 4 */}
|
||||
<div className="flex items-center justify-between p-sm bg-slate-50/50 border border-slate-200 rounded-xl">
|
||||
<div>
|
||||
<h4 className="font-bold text-[#0f172a] text-xs">Sustainability Seal</h4>
|
||||
<p className="text-[9px] text-zinc-400 font-semibold mt-0.5">Include Coimbatore eco certification stamp</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={brandStyle.isEcoVerified}
|
||||
onChange={() => setBrandStyle({ ...brandStyle, isEcoVerified: !brandStyle.isEcoVerified })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-8 h-4.5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-350 after:border after:rounded-full after:h-3.5 after:w-3.5 after:transition-all peer-checked:bg-emerald-500 shadow-inner"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Interactive Dynamic Checkout Jute Bag Preview Canvas */}
|
||||
<div className="border border-[#e2e8f0] rounded-2xl p-md bg-[#faf8f5] space-y-sm shadow-inner flex flex-col items-center">
|
||||
<span className="text-[9px] font-sans font-bold text-zinc-405 uppercase tracking-widest block text-center border-b border-zinc-200/50 pb-1.5 w-full">
|
||||
Package Bag Design Preview
|
||||
</span>
|
||||
|
||||
<div className="relative mx-auto w-48 h-64 bg-[#eddcd2] border-2 border-[#d6b795] rounded-b-3xl rounded-t-xl shadow-2xl flex flex-col justify-between p-md mt-6 mb-2 overflow-hidden animate-in fade-in zoom-in-95 duration-500">
|
||||
{/* Realistic Jute Texture Overlay simulation */}
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-stone-900/5 to-amber-950/5 pointer-events-none" />
|
||||
|
||||
{/* Hanging handle simulation */}
|
||||
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2 w-20 h-6 border-3 border-b-0 border-[#c4a27d] rounded-t-full shadow-sm" />
|
||||
|
||||
<div className="text-center pt-sm space-y-1 relative z-10">
|
||||
<span className="text-[11px] font-extrabold block tracking-tight uppercase" style={{ color: brandStyle.primaryColor }}>
|
||||
{brandStyle.themeName || 'nearledaily Fresh'}
|
||||
</span>
|
||||
<div className="w-12 h-0.5 mx-auto rounded-full" style={{ backgroundColor: brandStyle.secondaryColor }} />
|
||||
</div>
|
||||
|
||||
<div className="my-auto flex flex-col items-center text-center p-1 space-y-1.5 relative z-10">
|
||||
<ShoppingBag className="w-12 h-12 stroke-1 drop-shadow-sm" style={{ color: brandStyle.primaryColor }} />
|
||||
<span className="text-[9px] font-extrabold max-w-[130px] leading-tight block text-stone-750">
|
||||
{brandStyle.bagLabel || 'Grown with Pride'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center text-[7px] border-t border-stone-300 pt-1.5 relative z-10 font-bold text-stone-500">
|
||||
<span>100% ORGANIC</span>
|
||||
{brandStyle.isEcoVerified && (
|
||||
<span className="text-emerald-800 font-extrabold bg-emerald-100/80 border border-emerald-200 px-1.5 py-0.5 rounded text-[6px]">
|
||||
CBE-ECO
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<AwaitingApi label="Brand & packaging config" api="[R6]" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1302,10 +1001,13 @@ export default function InventoryView({
|
||||
onChange={(e) => setNewProduct({ ...newProduct, category: e.target.value })}
|
||||
className="w-full border border-[#e2e8f0] rounded-lg p-2 bg-[#f8fafc] focus:bg-white outline-none"
|
||||
>
|
||||
<option value="Staples / Rice">Staples / Rice</option>
|
||||
<option value="Groceries / Oils">Groceries / Oils</option>
|
||||
<option value="Beverages / Coffee">Beverages / Coffee</option>
|
||||
<option value="Fresh Produce / Veg">Fresh Produce / Veg</option>
|
||||
{/* Keep the form's default selection valid even if it isn't in the live list. */}
|
||||
{!productCategoryNames.includes(newProduct.category) && (
|
||||
<option value={newProduct.category}>{newProduct.category}</option>
|
||||
)}
|
||||
{productCategoryNames.map((name) => (
|
||||
<option key={name} value={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
296
src/components/LoginView.tsx
Normal file
296
src/components/LoginView.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ShieldCheck,
|
||||
Mail,
|
||||
Lock,
|
||||
ArrowRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { checkEmailRequest } from '../services/auth';
|
||||
import type { AuthUser } from '../services/auth';
|
||||
import { useLogin } from '../services/fiestaQueries';
|
||||
|
||||
interface LoginViewProps {
|
||||
/** Called with the authenticated user once credentials are verified. */
|
||||
onLogin: (user: AuthUser) => void;
|
||||
}
|
||||
|
||||
export default function LoginView({ onLogin }: LoginViewProps) {
|
||||
// A single login form. The backend's role on the verified user decides the
|
||||
// workspace (admin vs user) the account actually lands on.
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [step, setStep] = useState<'email' | 'password'>('email');
|
||||
const [checkingEmail, setCheckingEmail] = useState(false);
|
||||
|
||||
const login = useLogin();
|
||||
const loading = login.isPending;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (loading || checkingEmail) return;
|
||||
|
||||
if (step === 'email') {
|
||||
if (!email.trim()) {
|
||||
setError('Please enter your email.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setCheckingEmail(true);
|
||||
try {
|
||||
const emailExists = await checkEmailRequest(email);
|
||||
if (emailExists) {
|
||||
setStep('password');
|
||||
} else {
|
||||
setError('Email not found. Please try again.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to verify email. Please try again.');
|
||||
} finally {
|
||||
setCheckingEmail(false);
|
||||
}
|
||||
} else {
|
||||
if (!password.trim()) {
|
||||
setError('Please enter your password.');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
// Verify credentials against the backend via the TanStack mutation. Only an
|
||||
// exact match resolves; the returned role routes admin vs user workspace.
|
||||
login.mutate(
|
||||
{ email, password },
|
||||
{
|
||||
onSuccess: (user) => onLogin(user),
|
||||
onError: (err) =>
|
||||
setError(err instanceof Error ? err.message : 'Sign in failed. Please try again.'),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full flex bg-white font-sans text-slate-800 overflow-hidden">
|
||||
{/* ── Left brand / hero panel (desktop only) ── */}
|
||||
<div className="hidden lg:flex lg:w-1/2 shrink-0 relative overflow-hidden bg-gradient-to-br from-[#5b1d8c] via-purple-950 to-slate-950 text-white flex-col p-12 xl:p-16 justify-center">
|
||||
{/* Layered ambient glows */}
|
||||
<div className="absolute -top-24 -right-24 w-96 h-96 bg-purple-500/25 rounded-full blur-[120px] pointer-events-none" />
|
||||
<div className="absolute -bottom-24 -left-24 w-[26rem] h-[26rem] bg-indigo-500/20 rounded-full blur-[140px] pointer-events-none animate-pulse" style={{ animationDuration: '9s' }} />
|
||||
<div className="absolute top-1/3 left-1/2 w-72 h-72 bg-fuchsia-500/10 rounded-full blur-[120px] pointer-events-none" />
|
||||
{/* Subtle dot-grid texture */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.07] pointer-events-none"
|
||||
style={{ backgroundImage: 'radial-gradient(circle, #ffffff 1px, transparent 1px)', backgroundSize: '22px 22px' }}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex flex-col justify-center w-full max-w-[520px] mx-auto h-full">
|
||||
{/* Logo */}
|
||||
<div className="shrink-0 mb-10">
|
||||
<img src="/logo.png" alt="nearledaily" className="h-10 w-auto object-contain" />
|
||||
</div>
|
||||
|
||||
{/* Hero */}
|
||||
<div className="min-h-0">
|
||||
{/* Eyebrow */}
|
||||
<span className="inline-flex items-center gap-1.5 self-start text-[11px] font-bold uppercase tracking-[0.15em] text-purple-100 bg-white/10 border border-white/15 rounded-full px-3 py-1 backdrop-blur-sm">
|
||||
<ShieldCheck size={13} className="text-purple-200" />
|
||||
Secure Merchant Console
|
||||
</span>
|
||||
|
||||
<h1
|
||||
className="text-5xl xl:text-6xl font-bold tracking-tight leading-[1.1] mt-6 max-w-[460px]"
|
||||
style={{ textShadow: '0 2px 16px rgba(0,0,0,0.25)' }}
|
||||
>
|
||||
All Operations
|
||||
<br />
|
||||
In One Console
|
||||
</h1>
|
||||
<p className="text-purple-100/90 text-lg xl:text-xl leading-[1.6] mt-6 max-w-[460px]">
|
||||
Monitor regional hubs, track live order operations, and manage your entire store network from a single command center.
|
||||
</p>
|
||||
|
||||
{/* Glassmorphic live-snapshot preview card */}
|
||||
<div className="mt-10 max-w-[460px] rounded-2xl bg-white/10 backdrop-blur-md border border-white/15 p-6 shadow-2xl shadow-purple-950/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-purple-100">Live Operations</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-bold text-emerald-300">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
LIVE
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-3xl font-extrabold tracking-tight tabular-nums">2,480</p>
|
||||
<p className="text-xs text-purple-200 mt-1">Orders today</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-extrabold tracking-tight text-emerald-300 tabular-nums">98.6%</p>
|
||||
<p className="text-xs text-purple-200 mt-1">Fulfillment</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini bar chart */}
|
||||
<div className="mt-5 flex items-end gap-1.5 h-14">
|
||||
{[45, 62, 38, 70, 52, 80, 60, 90, 55, 74].map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-t bg-gradient-to-t from-purple-500/40 to-purple-200/80"
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trust stats + copyright */}
|
||||
<div className="shrink-0 mt-10 pt-6 border-t border-white/10 max-w-[460px]">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ v: '120+', l: 'Regional hubs' },
|
||||
{ v: '99.9%', l: 'Platform uptime' },
|
||||
{ v: '24/7', l: 'Live monitoring' },
|
||||
].map((s) => (
|
||||
<div key={s.l}>
|
||||
<p className="text-2xl font-bold tracking-tight">{s.v}</p>
|
||||
<p className="text-xs text-purple-300 mt-0.5">{s.l}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-purple-300/70 font-medium mt-4">
|
||||
© 2026 nearledaily · All rights reserved
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right form panel ── */}
|
||||
<div className="w-full lg:w-1/2 flex items-center justify-center px-6 py-8 sm:px-10 overflow-y-auto">
|
||||
<div className="w-full max-w-[400px]">
|
||||
{/* Logo / brand (mobile) */}
|
||||
<div className="lg:hidden mb-8">
|
||||
<img src="/logo.png" alt="nearledaily" className="h-9 w-auto object-contain" />
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-[1.7rem] font-bold text-slate-900 tracking-tight leading-tight">Welcome back</h2>
|
||||
<p className="text-slate-500 text-sm mt-2">Sign in to your nearledaily workspace to continue.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Email or username (the API's `authname` accepts either) */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="login-email" className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
|
||||
Email or Username
|
||||
</label>
|
||||
{step === 'password' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep('email'); setError(''); setPassword(''); }}
|
||||
className="text-xs font-semibold text-purple-600 hover:text-purple-800 bg-transparent border-none cursor-pointer p-0"
|
||||
>
|
||||
Change Email
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Mail size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||
<input
|
||||
id="login-email"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
disabled={step === 'password'}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@nearledaily.com"
|
||||
className="w-full h-12 pl-10 pr-4 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 transition-all disabled:opacity-75 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
{step === 'password' && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="login-password" className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||
<input
|
||||
id="login-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full h-12 pl-10 pr-11 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 bg-transparent border-none cursor-pointer p-1"
|
||||
title={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs font-semibold text-rose-600 bg-rose-50 border border-rose-100 rounded-lg px-3 py-2.5">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Options row */}
|
||||
{step === 'password' && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<label className="flex items-center gap-2 text-slate-500 font-medium cursor-pointer select-none">
|
||||
<input type="checkbox" className="h-3.5 w-3.5 rounded border-slate-300 text-purple-600 accent-purple-600 focus:ring-purple-500/30" />
|
||||
Remember me
|
||||
</label>
|
||||
<button type="button" className="font-semibold text-purple-650 hover:text-purple-800 bg-transparent border-none cursor-pointer">
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || checkingEmail}
|
||||
className="w-full h-12 flex items-center justify-center gap-2 bg-[#581c87] hover:bg-purple-800 text-white font-bold text-sm rounded-xl shadow-sm hover:shadow-lg transition-all active:scale-[0.98] cursor-pointer border-none disabled:opacity-70 disabled:cursor-not-allowed disabled:active:scale-100"
|
||||
>
|
||||
{loading || checkingEmail ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{checkingEmail ? 'Checking Email…' : 'Verifying…'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{step === 'email' ? 'Continue' : 'Sign in'}
|
||||
<ArrowRight size={16} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Helper note */}
|
||||
<p className="text-center text-xs text-slate-400 mt-6 leading-relaxed">
|
||||
Your workspace is set automatically based on your account access.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,12 +19,9 @@ import {
|
||||
XCircle,
|
||||
FolderSync,
|
||||
UploadCloud,
|
||||
FileCheck,
|
||||
Download,
|
||||
AlertOctagon,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { initialImportLogs } from '../data';
|
||||
import { InventoryItem } from '../types';
|
||||
import {
|
||||
useFiestaStockStatement,
|
||||
@@ -32,6 +29,7 @@ import {
|
||||
} from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr } from '../services/fiestaApi';
|
||||
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
|
||||
interface OperationsViewProps {
|
||||
searchQuery: string;
|
||||
@@ -66,7 +64,6 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
// Dynamic state arrays for interaction (seeded from live data once it loads).
|
||||
const [inventoryList, setInventoryList] = useState<InventoryItem[]>([]);
|
||||
const [productList, setProductList] = useState<ReturnType<typeof stockRowToProduct>[]>([]);
|
||||
const [importLogs, setImportLogs] = useState(initialImportLogs);
|
||||
|
||||
useEffect(() => {
|
||||
if (stockQ.data) {
|
||||
@@ -253,10 +250,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
<p className="text-[11px] font-sans font-bold text-zinc-500 uppercase tracking-widest">
|
||||
Fulfillment Health
|
||||
</p>
|
||||
<h3 className="font-sans font-bold text-[#0f172a] text-xl mt-xs">98.4%</h3>
|
||||
<div className="w-40 bg-[#eceef0] h-1.5 rounded-full overflow-hidden mt-sm">
|
||||
<div className="bg-[#581c87] h-full rounded-full" style={{ width: '98.4%' }}></div>
|
||||
</div>
|
||||
<AwaitingApi label="Fulfillment health" api="[R1]" compact className="mt-xs" />
|
||||
</div>
|
||||
<div className="p-2.5 rounded-lg bg-emerald-50 text-emerald-600 animate-pulse">
|
||||
<PackageCheck size={18} />
|
||||
@@ -376,10 +370,12 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
<span className="text-[10px] tracking-wider font-bold opacity-60 uppercase">
|
||||
Forecast Efficiency
|
||||
</span>
|
||||
<p className="font-sans font-bold text-3xl mt-sm">92%</p>
|
||||
<p className="text-zinc-300 text-xs mt-sm leading-relaxed">
|
||||
AI-Driven automated replenishment is saving an estimated ₹1.9L/week in system overstock costs.
|
||||
</p>
|
||||
<AwaitingApi
|
||||
label="Forecast insights"
|
||||
api="[R7]"
|
||||
compact
|
||||
className="mt-sm bg-white/5 border-white/15 text-zinc-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Embedded SVG graphic visual */}
|
||||
@@ -451,7 +447,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
Master Assortment Catalogue
|
||||
</h3>
|
||||
<p className="text-zinc-500 text-xs font-sans mt-0.5">
|
||||
Global inventory master list and exposure levels across 4,200 nodes.
|
||||
Global inventory master list and exposure levels across {productList.length.toLocaleString('en-IN')} nodes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -553,15 +549,6 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
onClick={() => {
|
||||
const fileRef = prompt('Enter CSV filename representation path:');
|
||||
if (fileRef) {
|
||||
const logsToAdd = {
|
||||
timestamp: 'Just now',
|
||||
batchRef: `#IMP_0922_${String.fromCharCode(65 + Math.floor(Math.random() * 26))}`,
|
||||
type: 'Inventory Sync',
|
||||
source: fileRef,
|
||||
result: `SUCCESS (98 Rows verified)`,
|
||||
status: 'SUCCESS' as const
|
||||
};
|
||||
setImportLogs([logsToAdd, ...importLogs]);
|
||||
alert('Uploaded successfully. Metadata schema verification committed.');
|
||||
}
|
||||
}}
|
||||
@@ -602,50 +589,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
||||
Interactive Schema Validator
|
||||
</span>
|
||||
|
||||
<div className="space-y-sm">
|
||||
<div className="p-sm bg-emerald-50/50 border border-emerald-100 rounded-xl flex gap-sm items-start text-xs">
|
||||
<FileCheck size={16} className="text-emerald-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h5 className="font-bold text-[#0f172a]">Verification Rule Passed</h5>
|
||||
<p className="text-zinc-600 mt-0.5">Primary header nodes align perfectly with Master specification v2.8.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-sm bg-rose-50/50 border border-rose-100 rounded-xl flex gap-sm items-start text-xs">
|
||||
<AlertOctagon size={16} className="text-rose-500 shrink-0 mt-bar" />
|
||||
<div>
|
||||
<h5 className="font-bold text-[#0f172a]">14 Duplicate SKUs Detected</h5>
|
||||
<p className="text-zinc-600 mt-0.5">Duplicate item overlaps flagged inside columns 45, 82. Verify manual index configurations before finalizing commit.</p>
|
||||
<button
|
||||
onClick={() => alert('Downloading conflicts summary report...')}
|
||||
className="text-rose-600 font-bold hover:underline mt-sm block"
|
||||
>
|
||||
DOWNLOAD RESOLUTION LOG
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs table list */}
|
||||
<div className="mt-md pt-md border-t border-[#f1f5f9]">
|
||||
<span className="text-[10px] font-sans font-bold text-zinc-400 uppercase tracking-wider block mb-sm">Recent Import Logs</span>
|
||||
|
||||
<div className="space-y-1 max-h-36 overflow-y-auto text-xs">
|
||||
{importLogs.map((log, i) => (
|
||||
<div key={i} className="flex justify-between items-center p-2 bg-[#f8fafc] border border-[#e2e8f0]/40 rounded-lg hover:bg-[#faf5ff]/20 transition-colors">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] font-bold text-[#581c87]">{log.batchRef}</p>
|
||||
<p className="text-[9px] text-zinc-400 font-medium">{log.timestamp} • {log.source}</p>
|
||||
</div>
|
||||
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded ${
|
||||
log.status === 'SUCCESS' ? 'text-emerald-700 bg-emerald-100' : 'text-rose-700 bg-rose-100'
|
||||
}`}>
|
||||
{log.result}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<AwaitingApi label="Import audit & validation" api="[R4]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
UserCheck,
|
||||
MapPin,
|
||||
TrendingUp,
|
||||
Plus,
|
||||
ChevronRight,
|
||||
Package,
|
||||
ArrowRight,
|
||||
@@ -28,9 +27,11 @@ import {
|
||||
useFiestaDeliveries,
|
||||
useFiestaDeliverySummary,
|
||||
useFiestaRiders,
|
||||
useFiestaOrderDetails,
|
||||
} from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
|
||||
import { deliveryRowToOrder } from '../services/fiestaMappers';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
|
||||
interface OrdersDeliveriesViewProps {
|
||||
searchQuery?: string;
|
||||
@@ -43,7 +44,6 @@ interface DeliveryExecutive {
|
||||
name: string;
|
||||
phone: string;
|
||||
status: 'Active Duty' | 'Idle' | 'Offline';
|
||||
rating: number;
|
||||
completedToday: number;
|
||||
currentZone: string;
|
||||
avatar: string;
|
||||
@@ -61,7 +61,6 @@ function riderRowToExecutive(row: Record<string, unknown>, idx: number): Deliver
|
||||
name: fstr(row.fullname) || `${fstr(row.firstname)} ${fstr(row.lastname)}`.trim() || 'Rider',
|
||||
phone: fstr(row.contactno) || '—',
|
||||
status: fstr(row.starttime) ? 'Active Duty' : 'Idle',
|
||||
rating: 4.7,
|
||||
completedToday: fnum(row.completed) || fnum(row.deliverycount),
|
||||
currentZone: fstr(row.city) || fstr(row.vehiclename) || fstr(row.vehicleno) || 'Coimbatore',
|
||||
avatar: RIDER_AVATARS[idx % RIDER_AVATARS.length],
|
||||
@@ -141,76 +140,20 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
|
||||
const activeDispatchCount = storeOrders.filter(o => o.status === 'OUT_FOR_DELIVERY').length;
|
||||
const completedDeliveriesCount = storeOrders.filter(o => o.status === 'DELIVERED').length;
|
||||
|
||||
const MOCK_NAMES = ['Aravind Swamy', 'Karthik Raja', 'Priya Mani', 'Meera Jasmine', 'Sanjay Dutt', 'Divya Spandana', 'Vijay Sethupathi', 'Nayan Thara'];
|
||||
const MOCK_STREETS = ['Avarampalayam Rd', 'DB Road', 'Cross Cut Road', 'Avinashi Road', 'Trichy Road', 'NSR Road', 'Sathy Road', 'Marudhamalai Road'];
|
||||
const MOCK_ITEMS = [
|
||||
{ name: 'Tata Salt Premium Iodized 1kg', price: 28 },
|
||||
{ name: 'Gold Winner Sunflower Oil 1L', price: 145 },
|
||||
{ name: 'Britannia Marie Gold Biscuit 250g', price: 35 },
|
||||
{ name: 'MTR Sambar Powder 200g', price: 85 },
|
||||
{ name: 'Aavin Salted Butter 500g', price: 260 },
|
||||
{ name: 'Ponni Boiled Rice 5kg', price: 380 },
|
||||
{ name: 'Fresh Ooty Carrots 500g', price: 45 },
|
||||
{ name: 'Nescafe Classic Coffee 100g', price: 185 },
|
||||
];
|
||||
|
||||
const handleCreateMockOrder = () => {
|
||||
const randomName = MOCK_NAMES[Math.floor(Math.random() * MOCK_NAMES.length)];
|
||||
const randomStreet = MOCK_STREETS[Math.floor(Math.random() * MOCK_STREETS.length)];
|
||||
const numItems = Math.floor(Math.random() * 3) + 1; // 1 to 3 items
|
||||
const selectedItems = [];
|
||||
let amount = 0;
|
||||
for (let k = 0; k < numItems; k++) {
|
||||
const it = MOCK_ITEMS[Math.floor(Math.random() * MOCK_ITEMS.length)];
|
||||
const qty = Math.floor(Math.random() * 2) + 1;
|
||||
selectedItems.push({ name: it.name, quantity: qty, price: it.price });
|
||||
amount += it.price * qty;
|
||||
}
|
||||
const newId = `ORD-${Math.floor(100000 + Math.random() * 900000)}`;
|
||||
const newOrder: CustomerOrder = {
|
||||
id: newId,
|
||||
customerName: randomName,
|
||||
phone: `9${Math.floor(100000000 + Math.random() * 900000000)}`,
|
||||
address: `${Math.floor(10 + Math.random() * 190)}, ${randomStreet}, Coimbatore`,
|
||||
items: selectedItems,
|
||||
amount,
|
||||
time: new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
|
||||
status: 'PROCESSING',
|
||||
assignedRider: 'Pending Assignment',
|
||||
hub: locationid ? `Outlet Node #${locationid}` : 'Coimbatore Hub',
|
||||
locationid: locationid ?? 1097,
|
||||
};
|
||||
setOrders(prev => [newOrder, ...prev]);
|
||||
setSelectedOrder(newOrder);
|
||||
};
|
||||
|
||||
const handleUpdateStatus = (newStatus: CustomerOrder['status']) => {
|
||||
if (!selectedOrder) return;
|
||||
setOrders(prev => prev.map(o => {
|
||||
if (o.id === selectedOrder.id) {
|
||||
const updated = { ...o, status: newStatus };
|
||||
setSelectedOrder(updated);
|
||||
return updated;
|
||||
}
|
||||
return o;
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAssignRider = (riderName: string) => {
|
||||
if (!selectedOrder) return;
|
||||
setOrders(prev => prev.map(o => {
|
||||
if (o.id === selectedOrder.id) {
|
||||
const updated = {
|
||||
...o,
|
||||
assignedRider: riderName,
|
||||
status: o.status === 'PROCESSING' ? 'CONFIRMED' : o.status
|
||||
};
|
||||
setSelectedOrder(updated);
|
||||
return updated;
|
||||
}
|
||||
return o;
|
||||
}));
|
||||
// Live line-item details for the currently selected order. The deliveries board
|
||||
// only carries an itemCount; the actual basket lines come from this endpoint.
|
||||
const orderDetailsQ = useFiestaOrderDetails(selectedOrder?.id ?? null);
|
||||
const orderItems = (orderDetailsQ.data ?? []).map((row) => {
|
||||
const quantity = fnum(row.quantity) || fnum(row.qty);
|
||||
const price = fnum(row.price) || fnum(row.unitprice);
|
||||
const lineTotal = fnum(row.amount) || price * quantity;
|
||||
return {
|
||||
name: fstr(row.productname) || fstr(row.itemname) || 'Item',
|
||||
quantity,
|
||||
price,
|
||||
lineTotal,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-500">
|
||||
@@ -348,10 +291,10 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter text-xs font-sans">
|
||||
|
||||
{/* Left List of Customer App Orders */}
|
||||
<div className="lg:col-span-2 space-y-md">
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl shadow-sm overflow-hidden flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex flex-col gap-md">
|
||||
<div className="lg:col-span-2 flex">
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl shadow-sm overflow-hidden flex flex-col h-full w-full min-h-[32rem]">
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="p-md border-b border-[#e2e8f0] bg-[#f8fafc] flex flex-col gap-md shrink-0">
|
||||
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-sm">
|
||||
<div>
|
||||
<h4 className="font-sans font-bold text-sm text-[#0f172a]">
|
||||
@@ -359,14 +302,6 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
|
||||
</h4>
|
||||
<p className="text-[10px] text-zinc-400 font-medium mt-0.5">Interactive list of customer purchases made via client app</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCreateMockOrder}
|
||||
className="bg-[#581c87] text-white px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider flex items-center justify-center gap-1 cursor-pointer hover:bg-purple-800 transition shadow-sm"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Create Simulated Order
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-sm w-full">
|
||||
@@ -401,11 +336,11 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order item rows */}
|
||||
<div className="divide-y divide-[#f1f5f9] max-h-[480px] overflow-y-auto">
|
||||
{/* Order item rows — flex-fills the column so the feed matches the Order Details card height */}
|
||||
<div className="divide-y divide-[#f1f5f9] flex-1 min-h-0 overflow-y-auto">
|
||||
{filteredOrdersList.length === 0 ? (
|
||||
<div className="p-xl text-center text-zinc-400 font-medium">
|
||||
No orders matching status filter found. Try another query or place a mock delivery item.
|
||||
No orders matching status filter found. Try another query or adjust the date range.
|
||||
</div>
|
||||
) : (
|
||||
filteredOrdersList.map(order => (
|
||||
@@ -448,41 +383,6 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delivery Executives Fleet Section */}
|
||||
<div className="bg-white border border-[#e2e8f0] p-md rounded-xl shadow-sm">
|
||||
<span className="text-[10px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs mb-md border-b border-[#f1f5f9]">
|
||||
Coimbatore Delivery Executive Fleet status
|
||||
</span>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-sm">
|
||||
{executives.map((ex) => (
|
||||
<div key={ex.id} className="p-sm border border-[#e2e8f0]/80 rounded-xl bg-[#f8fafc]/40 flex justify-between items-center">
|
||||
<div className="flex items-center gap-sm">
|
||||
<img
|
||||
src={ex.avatar}
|
||||
alt={ex.name}
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-10 h-10 rounded-full object-cover border border-zinc-200 shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold text-zinc-800">{ex.name}</p>
|
||||
<p className="text-[10px] text-zinc-400 font-medium">Zone: <strong>{ex.currentZone}</strong> • Rated ★{ex.rating}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[9px] font-bold uppercase inline-block ${
|
||||
ex.status === 'Active Duty' ? 'bg-sky-50 text-sky-600 border border-sky-100' : ex.status === 'Idle' ? 'bg-emerald-50 text-emerald-600 border border-emerald-100' : 'bg-zinc-100 text-zinc-400'
|
||||
}`}>
|
||||
{ex.status}
|
||||
</span>
|
||||
<p className="text-[10px] text-zinc-500 font-semibold mt-1">Completed: {ex.completedToday}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column — Order Details, shown parallel to the orders feed */}
|
||||
@@ -513,19 +413,24 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
|
||||
<div>
|
||||
<span className="text-[10px] text-zinc-400 font-bold uppercase tracking-wide block mb-sm">Ordered Grocery basket Items:</span>
|
||||
<div className="divide-y divide-[#f1f5f9] bg-zinc-50/50 p-2.5 rounded-lg border border-[#e2e8f0]/40">
|
||||
{selectedOrder.items.length === 0 && (
|
||||
{orderDetailsQ.isLoading && (
|
||||
<div className="py-2 flex items-center gap-1.5 text-[10px] text-zinc-400 font-medium">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading order line items…
|
||||
</div>
|
||||
)}
|
||||
{!orderDetailsQ.isLoading && orderItems.length === 0 && (
|
||||
<div className="py-2 flex justify-between items-center text-xs text-zinc-500">
|
||||
<span className="font-medium">{selectedOrder.itemCount ?? 0} line item(s)</span>
|
||||
<span className="text-[10px] text-zinc-400">Detail lines not loaded on board view</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedOrder.items.map((item, idx) => (
|
||||
{orderItems.map((item, idx) => (
|
||||
<div key={idx} className="py-2 flex justify-between items-center text-xs">
|
||||
<div>
|
||||
<p className="font-bold text-[#0f172a]">{item.name}</p>
|
||||
<p className="text-[10px] text-zinc-400">Qty: {item.quantity} x ₹{item.price}</p>
|
||||
</div>
|
||||
<span className="font-bold font-mono text-zinc-700">₹{(item.price * Number(item.quantity))}</span>
|
||||
<span className="font-bold font-mono text-zinc-700">₹{item.lineTotal}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-2 flex justify-between items-center font-bold text-sm text-[#581c87] border-t border-dashed border-[#e2e8f0]">
|
||||
@@ -535,167 +440,13 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Status advancement controls */}
|
||||
<div className="pt-xs space-y-sm">
|
||||
<span className="text-[9px] font-sans font-bold text-zinc-400 uppercase tracking-widest block pb-xs border-b border-[#f1f5f9]">OPERATIONAL CONTROL</span>
|
||||
{selectedOrder.status === 'PROCESSING' && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus('CONFIRMED')}
|
||||
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-bold text-[11px] py-2 rounded-xl transition duration-150 shadow-sm flex items-center justify-center gap-1 cursor-pointer border-none"
|
||||
>
|
||||
<Check size={14} /> Pack & Bag Order
|
||||
</button>
|
||||
)}
|
||||
{selectedOrder.status === 'CONFIRMED' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedOrder.assignedRider === 'Pending Assignment') {
|
||||
alert('Please assign a delivery partner from the fleet roster first.');
|
||||
return;
|
||||
}
|
||||
handleUpdateStatus('OUT_FOR_DELIVERY');
|
||||
}}
|
||||
className="w-full bg-purple-600 hover:bg-purple-700 text-white font-bold text-[11px] py-2 rounded-xl transition duration-150 shadow-sm flex items-center justify-center gap-1 cursor-pointer border-none"
|
||||
>
|
||||
<Truck size={14} /> Dispatch Rider
|
||||
</button>
|
||||
)}
|
||||
{selectedOrder.status === 'OUT_FOR_DELIVERY' && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus('DELIVERED')}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-bold text-[11px] py-2 rounded-xl transition duration-150 shadow-sm flex items-center justify-center gap-1 cursor-pointer border-none"
|
||||
>
|
||||
<CheckCircle2 size={14} /> Verify Delivery Handover
|
||||
</button>
|
||||
)}
|
||||
{selectedOrder.status === 'DELIVERED' && (
|
||||
<div className="bg-emerald-50 border border-emerald-250 text-emerald-800 font-bold text-[10px] py-2.5 rounded-xl text-center flex items-center justify-center gap-1 select-none">
|
||||
<CheckCircle2 size={13} className="text-emerald-600" /> Order Completed Successfully
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active Rider Assignment (only if not delivered) */}
|
||||
{selectedOrder.status !== 'DELIVERED' && (
|
||||
<div className="space-y-sm pt-xs">
|
||||
<div className="flex justify-between items-center border-b border-[#f1f5f9] pb-xs">
|
||||
<span className="text-[9px] font-sans font-bold text-zinc-400 uppercase tracking-widest">ASSIGN DELIVERY EXECUTIVE</span>
|
||||
<span className="text-[9px] text-[#581c87] font-bold">Fleet Roster</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-[140px] overflow-y-auto pr-1">
|
||||
{executives.length === 0 ? (
|
||||
<p className="text-[10px] text-zinc-405">No riders currently available.</p>
|
||||
) : (
|
||||
executives.map(ex => {
|
||||
const isAssigned = selectedOrder.assignedRider === ex.name;
|
||||
return (
|
||||
<button
|
||||
key={ex.id}
|
||||
type="button"
|
||||
onClick={() => handleAssignRider(ex.name)}
|
||||
className={`w-full p-2 border rounded-xl flex items-center justify-between text-left transition-all cursor-pointer ${
|
||||
isAssigned
|
||||
? 'bg-purple-50 border-[#581c87] text-[#581c87] font-semibold'
|
||||
: 'bg-[#f8fafc]/50 hover:bg-zinc-55 border-[#e2e8f0] text-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img src={ex.avatar} alt={ex.name} referrerPolicy="no-referrer" className="w-6 h-6 rounded-full object-cover border border-zinc-200" />
|
||||
<div>
|
||||
<p className="text-[10px] font-bold leading-tight">{ex.name}</p>
|
||||
<p className="text-[9px] text-zinc-450 leading-none">{ex.currentZone} • ★{ex.rating}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-[8px] font-bold uppercase px-1.5 py-0.5 rounded ${
|
||||
isAssigned
|
||||
? 'bg-[#581c87] text-white'
|
||||
: 'bg-zinc-200 text-zinc-650'
|
||||
}`}>
|
||||
{isAssigned ? 'Assigned' : 'Assign'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Simulated GPS map tracking path */}
|
||||
{/* Live GPS route tracker — no rider-telemetry/GPS API yet */}
|
||||
{selectedOrder.status === 'OUT_FOR_DELIVERY' && (
|
||||
<div className="space-y-xs pt-xs">
|
||||
<span className="text-[9px] font-sans font-bold text-zinc-400 uppercase tracking-widest block">
|
||||
LIVE GPS ROUTE TRACKER
|
||||
</span>
|
||||
<div className="relative overflow-hidden rounded-xl border border-zinc-200 bg-zinc-950 p-4 h-40 text-white flex flex-col justify-between font-sans shadow-inner select-none">
|
||||
{/* Grid background lines */}
|
||||
<div className="absolute inset-0 opacity-10 bg-[linear-gradient(to_right,#808080_1px,transparent_1px),linear-gradient(to_bottom,#808080_1px,transparent_1px)] bg-[size:12px_18px]" />
|
||||
|
||||
<svg className="absolute inset-0 w-full h-full" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="route-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#c084fc" />
|
||||
<stop offset="100%" stopColor="#818cf8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Route path line */}
|
||||
<path
|
||||
d="M 30 110 C 60 70, 110 110, 160 40"
|
||||
fill="none"
|
||||
stroke="#1e293b"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M 30 110 C 60 70, 110 110, 160 40"
|
||||
fill="none"
|
||||
stroke="url(#route-grad)"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="200"
|
||||
strokeDashoffset="200"
|
||||
style={{
|
||||
animation: 'dash 6s linear infinite'
|
||||
}}
|
||||
/>
|
||||
{/* Hub Marker */}
|
||||
<circle cx="30" cy="110" r="5" fill="#c084fc" className="animate-pulse" />
|
||||
<circle cx="30" cy="110" r="3" fill="#a855f7" />
|
||||
{/* Destination Marker */}
|
||||
<circle cx="160" cy="40" r="5" fill="#f43f5e" className="animate-ping" />
|
||||
<circle cx="160" cy="40" r="3" fill="#e11d48" />
|
||||
</svg>
|
||||
|
||||
<style dangerouslySetInnerHTML={{__html: `
|
||||
@keyframes dash {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
`}} />
|
||||
|
||||
{/* Map overlays */}
|
||||
<div className="z-10 flex justify-between items-start">
|
||||
<div className="bg-zinc-900/90 backdrop-blur-md px-2 py-0.5 rounded border border-zinc-800 text-[8px] font-bold text-zinc-300">
|
||||
GPS ACTIVE: IN TRANSIT
|
||||
</div>
|
||||
<div className="bg-zinc-900/90 backdrop-blur-md px-2 py-0.5 rounded border border-zinc-800 text-[8px] font-bold text-[#c084fc] flex items-center gap-1">
|
||||
<span className="w-1 h-1 rounded-full bg-purple-500 animate-ping" />
|
||||
ETA 9 MINS
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="z-10 bg-zinc-900/95 backdrop-blur-md p-2 rounded-lg border border-zinc-800 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[8px] text-zinc-400 font-bold uppercase tracking-wider">Executive</p>
|
||||
<p className="text-[10px] font-bold text-white leading-tight">{selectedOrder.assignedRider}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[8px] text-zinc-400 font-bold uppercase tracking-wider">Distance</p>
|
||||
<p className="text-[10px] font-bold text-[#c084fc] font-mono leading-tight">1.2 km left</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AwaitingApi label="Live rider GPS & ETA" api="[R9]" compact />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
|
||||
import { stockRowToProduct } from '../services/fiestaMappers';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
|
||||
interface ReportsViewProps {
|
||||
searchQuery: string;
|
||||
@@ -49,7 +50,6 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
const [selectedCell, setSelectedCell] = useState<{ day: string; hour: string; val: number } | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [chartMetric, setChartMetric] = useState<'orders' | 'revenue' | 'cancelled' | 'skus'>('orders');
|
||||
const [hoveredPoint, setHoveredPoint] = useState<number | null>(null);
|
||||
const [expandedProductId, setExpandedProductId] = useState<string | null>(null);
|
||||
const [exportingFormat, setExportingFormat] = useState<'PDF' | 'CSV' | null>(null);
|
||||
const [exportProgress, setExportProgress] = useState(0);
|
||||
@@ -78,11 +78,17 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
|
||||
// ── Live analytics (Fiesta) ───────────────────────────────────────────────
|
||||
const today = new Date();
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const yearStart = new Date(today.getFullYear(), 0, 1);
|
||||
const todate = ymd(today);
|
||||
|
||||
// Previous equal-length window (same number of days immediately before the
|
||||
// current YTD window) so we can derive a REAL orders/cancelled delta.
|
||||
const periodDays = Math.round((today.getTime() - yearStart.getTime()) / 86400000);
|
||||
const prevEnd = new Date(yearStart.getTime() - 86400000);
|
||||
const prevStart = new Date(prevEnd.getTime() - periodDays * 86400000);
|
||||
|
||||
const summaryQ = useFiestaOrderSummary(FIESTA_TENANT_ID, ymd(yearStart), todate);
|
||||
const prevSummaryQ = useFiestaOrderSummary(FIESTA_TENANT_ID, ymd(prevStart), ymd(prevEnd));
|
||||
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
||||
const insightQ = useFiestaOrderInsight(FIESTA_TENANT_ID);
|
||||
const stockQ = useFiestaStockStatement({
|
||||
@@ -94,94 +100,17 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
});
|
||||
|
||||
const s = summaryQ.data;
|
||||
const prevS = prevSummaryQ.data;
|
||||
const activeSkus = (stockQ.data ?? []).length;
|
||||
|
||||
// Base YTD data pool
|
||||
const CHART_DATA_YTD = [
|
||||
{ label: 'Jan', orders: 240, revenue: 78000, cancelled: 15, skus: 120 },
|
||||
{ label: 'Feb', orders: 310, revenue: 98000, cancelled: 10, skus: 125 },
|
||||
{ label: 'Mar', orders: 290, revenue: 89000, cancelled: 8, skus: 128 },
|
||||
{ label: 'Apr', orders: 380, revenue: 120000, cancelled: 12, skus: 135 },
|
||||
{ label: 'May', orders: 420, revenue: 145000, cancelled: 5, skus: 138 },
|
||||
{ label: 'Jun', orders: 510, revenue: 175000, cancelled: 9, skus: 140 },
|
||||
{ label: 'Jul', orders: 480, revenue: 162000, cancelled: 4, skus: 142 },
|
||||
{ label: 'Aug', orders: 560, revenue: 189000, cancelled: 3, skus: 145 },
|
||||
{ label: 'Sep', orders: 630, revenue: 215000, cancelled: 6, skus: 150 },
|
||||
{ label: 'Oct', orders: 710, revenue: 248000, cancelled: 8, skus: 152 },
|
||||
{ label: 'Nov', orders: 790, revenue: 275000, cancelled: 5, skus: 155 },
|
||||
{ label: 'Dec', orders: 920, revenue: 320000, cancelled: 2, skus: 158 },
|
||||
];
|
||||
|
||||
// Dynamic coordinates builder based on selected region and timeframe
|
||||
const getDynamicChartData = () => {
|
||||
let rawData = [...CHART_DATA_YTD];
|
||||
|
||||
if (selectedTimeframe === 'This Month') {
|
||||
rawData = [
|
||||
{ label: '02 Jun', orders: 15, revenue: 5200, cancelled: 1, skus: 145 },
|
||||
{ label: '04 Jun', orders: 18, revenue: 6100, cancelled: 0, skus: 145 },
|
||||
{ label: '06 Jun', orders: 12, revenue: 4300, cancelled: 2, skus: 145 },
|
||||
{ label: '08 Jun', orders: 22, revenue: 7800, cancelled: 1, skus: 146 },
|
||||
{ label: '10 Jun', orders: 25, revenue: 8900, cancelled: 3, skus: 146 },
|
||||
{ label: '12 Jun', orders: 28, revenue: 9900, cancelled: 1, skus: 147 },
|
||||
{ label: '14 Jun', orders: 24, revenue: 8400, cancelled: 0, skus: 147 },
|
||||
{ label: '16 Jun', orders: 30, revenue: 10500, cancelled: 2, skus: 148 },
|
||||
{ label: '18 Jun', orders: 35, revenue: 12200, cancelled: 1, skus: 148 },
|
||||
{ label: '20 Jun', orders: 32, revenue: 11100, cancelled: 0, skus: 149 },
|
||||
{ label: '22 Jun', orders: 38, revenue: 13300, cancelled: 4, skus: 149 },
|
||||
{ label: '24 Jun', orders: 42, revenue: 14800, cancelled: 2, skus: 150 },
|
||||
{ label: '26 Jun', orders: 45, revenue: 15800, cancelled: 1, skus: 150 },
|
||||
{ label: '28 Jun', orders: 40, revenue: 13900, cancelled: 0, skus: 151 },
|
||||
{ label: '30 Jun', orders: 50, revenue: 17500, cancelled: 1, skus: 151 },
|
||||
];
|
||||
} else if (selectedTimeframe === 'Last 12 Months') {
|
||||
rawData = [
|
||||
{ label: 'Jul 25', orders: 480, revenue: 162000, cancelled: 4, skus: 142 },
|
||||
{ label: 'Aug 25', orders: 560, revenue: 189000, cancelled: 3, skus: 145 },
|
||||
{ label: 'Sep 25', orders: 630, revenue: 215000, cancelled: 6, skus: 150 },
|
||||
{ label: 'Oct 25', orders: 710, revenue: 248000, cancelled: 8, skus: 152 },
|
||||
{ label: 'Nov 25', orders: 790, revenue: 275000, cancelled: 5, skus: 155 },
|
||||
{ label: 'Dec 25', orders: 920, revenue: 320000, cancelled: 2, skus: 158 },
|
||||
{ label: 'Jan 26', orders: 840, revenue: 290000, cancelled: 12, skus: 160 },
|
||||
{ label: 'Feb 26', orders: 890, revenue: 310000, cancelled: 8, skus: 162 },
|
||||
{ label: 'Mar 26', orders: 950, revenue: 330000, cancelled: 14, skus: 165 },
|
||||
{ label: 'Apr 26', orders: 1020, revenue: 355000, cancelled: 10, skus: 168 },
|
||||
{ label: 'May 26', orders: 1100, revenue: 385000, cancelled: 7, skus: 170 },
|
||||
{ label: 'Jun 26', orders: 1250, revenue: 435000, cancelled: 5, skus: 172 },
|
||||
];
|
||||
} else if (selectedTimeframe === 'All Time') {
|
||||
rawData = [
|
||||
{ label: '2022', orders: 2500, revenue: 850000, cancelled: 85, skus: 90 },
|
||||
{ label: '2023', orders: 4800, revenue: 1650000, cancelled: 120, skus: 120 },
|
||||
{ label: '2024', orders: 7200, revenue: 2500000, cancelled: 190, skus: 140 },
|
||||
{ label: '2025', orders: 9800, revenue: 3400000, cancelled: 210, skus: 160 },
|
||||
{ label: '2026 (Est)', orders: 12500, revenue: 4350000, cancelled: 150, skus: 172 },
|
||||
];
|
||||
}
|
||||
|
||||
// Scale values depending on region selected
|
||||
if (selectedRegion !== 'all') {
|
||||
const rScale = getRegionScale();
|
||||
return rawData.map(d => ({
|
||||
...d,
|
||||
orders: Math.round(d.orders * rScale),
|
||||
revenue: Math.round(d.revenue * (rScale * 1.05)),
|
||||
cancelled: Math.round(d.cancelled * (selectedRegion === 'coimbatore' ? 0.35 : selectedRegion === 'chennai' ? 0.50 : 0.65)),
|
||||
skus: Math.round(d.skus * (selectedRegion === 'coimbatore' ? 0.85 : selectedRegion === 'chennai' ? 0.90 : 0.95))
|
||||
}));
|
||||
}
|
||||
|
||||
return rawData;
|
||||
// Real period-over-period % change (null when we can't compute it yet).
|
||||
const pctChange = (current: number, previous: number): number | null => {
|
||||
if (previous <= 0) return null;
|
||||
return ((current - previous) / previous) * 100;
|
||||
};
|
||||
|
||||
const getRegionScale = () => {
|
||||
if (selectedRegion === 'coimbatore') return 0.42;
|
||||
if (selectedRegion === 'chennai') return 0.60;
|
||||
if (selectedRegion === 'bangalore') return 0.75;
|
||||
return 1.0;
|
||||
};
|
||||
|
||||
const currentChartData = getDynamicChartData();
|
||||
const ordersDelta = s && prevS ? pctChange(s.total, prevS.total) : null;
|
||||
const cancelledDelta = s && prevS ? pctChange(s.cancelled, prevS.cancelled) : null;
|
||||
const fmtDelta = (d: number) => `${d >= 0 ? '+' : ''}${d.toFixed(1)}%`;
|
||||
|
||||
// Dynamic sparkline generator helper
|
||||
const getSparkPath = (values: number[], width: number, height: number) => {
|
||||
@@ -195,46 +124,6 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
}).join(' ');
|
||||
};
|
||||
|
||||
// Simple cubic bezier curve generator for SVG path
|
||||
const getBezierPath = (pts: Array<{ x: number; y: number }>) => {
|
||||
if (pts.length === 0) return '';
|
||||
let d = `M ${pts[0].x} ${pts[0].y}`;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[i];
|
||||
const p1 = pts[i + 1];
|
||||
const cpX1 = p0.x + (p1.x - p0.x) / 3;
|
||||
const cpY1 = p0.y;
|
||||
const cpX2 = p0.x + 2 * (p1.x - p0.x) / 3;
|
||||
const cpY2 = p1.y;
|
||||
d += ` C ${cpX1} ${cpY1}, ${cpX2} ${cpY2}, ${p1.x} ${p1.y}`;
|
||||
}
|
||||
return d;
|
||||
};
|
||||
|
||||
// Dynamic SVG path calculations for the primary trend chart
|
||||
const paddingX = 40;
|
||||
const paddingY = 20;
|
||||
const chartWidth = 920;
|
||||
const chartHeight = 220;
|
||||
|
||||
const chartMaxVal = chartMetric === 'orders'
|
||||
? Math.max(...currentChartData.map(d => d.orders)) * 1.1
|
||||
: chartMetric === 'revenue'
|
||||
? Math.max(...currentChartData.map(d => d.revenue)) * 1.1
|
||||
: chartMetric === 'cancelled'
|
||||
? Math.max(...currentChartData.map(d => d.cancelled)) * 1.1
|
||||
: Math.max(...currentChartData.map(d => d.skus)) * 1.1;
|
||||
|
||||
const points = currentChartData.map((d, index) => {
|
||||
const val = d[chartMetric] as number;
|
||||
const x = paddingX + (index / (currentChartData.length - 1)) * (chartWidth - 2 * paddingX);
|
||||
const y = chartHeight - paddingY - (val / chartMaxVal) * (chartHeight - 2 * paddingY);
|
||||
return { x, y, label: d.label, val };
|
||||
});
|
||||
|
||||
const linePath = getBezierPath(points);
|
||||
const areaPath = points.length ? `${linePath} L ${points[points.length - 1].x} ${chartHeight - paddingY} L ${points[0].x} ${chartHeight - paddingY} Z` : '';
|
||||
|
||||
// Tab thematic config
|
||||
const getChartColors = () => {
|
||||
switch (chartMetric) {
|
||||
@@ -274,57 +163,61 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
};
|
||||
const theme = getChartColors();
|
||||
|
||||
// Region specific calculations for KPIs
|
||||
const scale = getRegionScale();
|
||||
const scaleCancelled = selectedRegion === 'coimbatore' ? 0.35 : selectedRegion === 'chennai' ? 0.50 : selectedRegion === 'bangalore' ? 0.65 : 1.0;
|
||||
const scaleSkus = selectedRegion === 'coimbatore' ? 0.85 : selectedRegion === 'chennai' ? 0.90 : selectedRegion === 'bangalore' ? 0.95 : 1.0;
|
||||
// Live KPI values (tenant-wide; region scaling removed — no per-region API).
|
||||
const totalOrdersVal = s?.total ?? 0;
|
||||
const deliveredVal = s?.delivered ?? 0;
|
||||
const cancelledVal = s?.cancelled ?? 0;
|
||||
const activeSkusVal = activeSkus;
|
||||
|
||||
const totalOrdersVal = Math.round((s?.total ?? 0) * scale);
|
||||
const deliveredVal = Math.round((s?.delivered ?? 0) * scale);
|
||||
const cancelledVal = Math.round((s?.cancelled ?? 0) * scaleCancelled);
|
||||
const activeSkusVal = Math.round(activeSkus * scaleSkus);
|
||||
|
||||
// KPI Row Configuration
|
||||
// KPI Row Configuration. `awaiting` cards have no live value (rendered via
|
||||
// AwaitingApi). `trend` is only set where a REAL delta could be derived.
|
||||
const reportsKPIs = [
|
||||
{
|
||||
id: 'orders' as const,
|
||||
title: 'Orders',
|
||||
value: totalOrdersVal.toLocaleString('en-IN'),
|
||||
trend: `+12.5%`,
|
||||
trend: ordersDelta !== null ? fmtDelta(ordersDelta) : null,
|
||||
status: `${deliveredVal.toLocaleString('en-IN')} filled`,
|
||||
isPositive: true,
|
||||
isPositive: ordersDelta === null ? true : ordersDelta >= 0,
|
||||
spark: [30, 45, 35, 60, 55, 70, 65, 80],
|
||||
color: 'indigo'
|
||||
color: 'indigo',
|
||||
awaiting: false,
|
||||
},
|
||||
{
|
||||
// Revenue: no revenue API ([R1]) — render AwaitingApi instead of a value.
|
||||
id: 'revenue' as const,
|
||||
title: 'Revenue',
|
||||
value: `₹${(deliveredVal * 355).toLocaleString('en-IN')}`,
|
||||
trend: `+14.8%`,
|
||||
status: `Growth steady`,
|
||||
value: '',
|
||||
trend: null,
|
||||
status: '',
|
||||
isPositive: true,
|
||||
spark: [20, 30, 25, 45, 40, 55, 50, 68],
|
||||
color: 'emerald'
|
||||
color: 'emerald',
|
||||
awaiting: true,
|
||||
},
|
||||
{
|
||||
id: 'cancelled' as const,
|
||||
title: 'Cancelled',
|
||||
value: cancelledVal.toLocaleString('en-IN'),
|
||||
trend: `-1.2%`,
|
||||
status: `${Math.round((s?.created ?? 0) * scaleCancelled)} active`,
|
||||
isPositive: false,
|
||||
// Lower cancellations is good, so a negative delta is "positive".
|
||||
trend: cancelledDelta !== null ? fmtDelta(cancelledDelta) : null,
|
||||
status: `${(s?.created ?? 0).toLocaleString('en-IN')} active`,
|
||||
isPositive: cancelledDelta === null ? false : cancelledDelta <= 0,
|
||||
spark: [15, 10, 8, 12, 5, 9, 4, 3],
|
||||
color: 'rose'
|
||||
color: 'rose',
|
||||
awaiting: false,
|
||||
},
|
||||
{
|
||||
id: 'skus' as const,
|
||||
title: 'Active SKUs',
|
||||
value: activeSkusVal.toLocaleString('en-IN'),
|
||||
trend: `+8.4%`,
|
||||
// SKU delta value itself was fabricated — show no trend chip.
|
||||
trend: null,
|
||||
status: `All verified`,
|
||||
isPositive: true,
|
||||
spark: [50, 50, 55, 60, 60, 68, 70, 72],
|
||||
color: 'sky'
|
||||
color: 'sky',
|
||||
awaiting: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -337,25 +230,15 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
const getFilteredLocations = () => {
|
||||
const rawLocations = [...(locSummaryQ.data ?? [])];
|
||||
|
||||
// Only Coimbatore can be filtered from live data; Chennai/Bangalore have no
|
||||
// live tenant locations (their stub data was removed). Selecting them yields
|
||||
// an empty list rather than fabricated hubs.
|
||||
if (selectedRegion === 'coimbatore') {
|
||||
return rawLocations.filter(r => isCoimbatoreNode(r.locationname || ''));
|
||||
}
|
||||
|
||||
if (selectedRegion === 'chennai') {
|
||||
return [
|
||||
{ locationid: 2001, locationname: 'Chennai Adyar Hub', total: 420, delivered: 405, cancelled: 15 },
|
||||
{ locationid: 2002, locationname: 'Chennai T-Nagar Outlet', total: 310, delivered: 290, cancelled: 20 },
|
||||
{ locationid: 2003, locationname: 'Chennai Velachery Super', total: 290, delivered: 285, cancelled: 5 },
|
||||
{ locationid: 2004, locationname: 'Chennai OMR Express', total: 180, delivered: 172, cancelled: 8 },
|
||||
] as any[];
|
||||
}
|
||||
|
||||
if (selectedRegion === 'bangalore') {
|
||||
return [
|
||||
{ locationid: 3001, locationname: 'Bangalore Indiranagar Hub', total: 580, delivered: 560, cancelled: 20 },
|
||||
{ locationid: 3002, locationname: 'Bangalore Koramangala Store', total: 410, delivered: 395, cancelled: 15 },
|
||||
{ locationid: 3003, locationname: 'Bangalore HSR Layout Express', total: 320, delivered: 312, cancelled: 8 },
|
||||
] as any[];
|
||||
if (selectedRegion === 'chennai' || selectedRegion === 'bangalore') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rawLocations;
|
||||
@@ -372,32 +255,22 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
rank: String(i + 1).padStart(2, '0'),
|
||||
name: r.locationname || `Location ${r.locationid}`,
|
||||
percentage: max > 0 ? Math.round((r.total / max) * 100) : 0,
|
||||
revenue: `${r.total.toLocaleString('en-IN')} ord`,
|
||||
// Live order count drives the ranking/bar. No per-node revenue API yet, so the
|
||||
// label shows the real order count (not fabricated rupees) — revenue lands with [R1].
|
||||
revenue: `${r.total.toLocaleString()} ord`,
|
||||
}));
|
||||
})();
|
||||
const currentLeaderboard = leaderboard;
|
||||
|
||||
// Monthly order distribution per outlet
|
||||
// Monthly order distribution per outlet — live only (useFiestaOrderInsight
|
||||
// already covers all the tenant's locations). The Chennai/Bangalore stub rows
|
||||
// were removed; selecting those regions filters the live rows to none.
|
||||
const insightRows = (() => {
|
||||
if (selectedRegion === 'chennai') {
|
||||
return [
|
||||
{ name: 'Chennai Adyar Hub', months: { jan: 30, feb: 35, mar: 40, apr: 45, may: 50, jun: 55, jul: 60, Aug: 65, Sep: 70, Oct: 75, Nov: 80, Dece: 85 } },
|
||||
{ name: 'Chennai T-Nagar Outlet', months: { jan: 25, feb: 28, mar: 30, apr: 35, may: 38, jun: 42, jul: 45, Aug: 48, Sep: 52, Oct: 55, Nov: 60, Dece: 65 } },
|
||||
{ name: 'Chennai Velachery Super', months: { jan: 20, feb: 22, mar: 25, apr: 28, may: 30, jun: 35, jul: 38, Aug: 42, Sep: 45, Oct: 48, Nov: 52, Dece: 55 } },
|
||||
{ name: 'Chennai OMR Express', months: { jan: 15, feb: 18, mar: 20, apr: 22, may: 25, jun: 28, jul: 30, Aug: 32, Sep: 35, Oct: 38, Nov: 40, Dece: 45 } },
|
||||
];
|
||||
}
|
||||
if (selectedRegion === 'bangalore') {
|
||||
return [
|
||||
{ name: 'Bangalore Indiranagar Hub', months: { jan: 40, feb: 45, mar: 50, apr: 55, may: 60, jun: 65, jul: 70, Aug: 75, Sep: 80, Oct: 85, Nov: 90, Dece: 95 } },
|
||||
{ name: 'Bangalore Koramangala Store', months: { jan: 30, feb: 32, mar: 35, apr: 38, may: 42, jun: 45, jul: 48, Aug: 52, Sep: 55, Oct: 60, Nov: 65, Dece: 70 } },
|
||||
{ name: 'Bangalore HSR Layout Express', months: { jan: 20, feb: 24, mar: 26, apr: 28, may: 32, jun: 35, jul: 38, Aug: 40, Sep: 44, Oct: 48, Nov: 52, Dece: 55 } },
|
||||
];
|
||||
}
|
||||
|
||||
let rows = (insightQ.data ?? []);
|
||||
if (selectedRegion === 'coimbatore') {
|
||||
rows = rows.filter(r => isCoimbatoreNode(fstr(r.locationname)));
|
||||
} else if (selectedRegion === 'chennai' || selectedRegion === 'bangalore') {
|
||||
rows = [];
|
||||
}
|
||||
return rows.map((r) => ({
|
||||
name: fstr(r.locationname) || `Location ${fstr(r.locationid)}`,
|
||||
@@ -465,7 +338,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
<div className="absolute top-40 right-1/4 w-[28rem] h-[28rem] bg-indigo-400/5 rounded-full blur-[140px] pointer-events-none -z-10 animate-pulse" style={{ animationDuration: '8s' }} />
|
||||
|
||||
{/* ── Immersive Analytics Banner (With Data Cover Image & Slate Gradient Overlay) ── */}
|
||||
<div className="relative rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 mb-8 animate-in fade-in duration-300 z-35">
|
||||
<div className="relative rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 mb-8 animate-in fade-in duration-300 z-40">
|
||||
{/* Cover Image Background & Decor (wrapped in overflow-hidden to keep rounded corner clip, while allowing dropdown overflow) */}
|
||||
<div className="absolute inset-0 z-0 overflow-hidden rounded-2xl">
|
||||
<img
|
||||
@@ -481,7 +354,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
</div>
|
||||
|
||||
{/* Content Row */}
|
||||
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-lg">
|
||||
<div className="relative z-20 flex flex-col md:flex-row md:items-center justify-between gap-lg">
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-white flex items-center gap-2">
|
||||
Business Intelligence Center
|
||||
@@ -634,13 +507,13 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<h3 className="text-xl font-extrabold tracking-tight font-mono">
|
||||
{currentChartData.reduce((acc, curr) => acc + curr.orders, 0).toLocaleString('en-IN')}
|
||||
{totalOrdersVal.toLocaleString('en-IN')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-1">Segment Volume</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Total Segment Revenue */}
|
||||
{/* Card 4: Gross Revenue — no revenue API ([R1]) */}
|
||||
<div className="bg-slate-900/40 backdrop-blur-sm rounded-xl p-4 border border-slate-800/80 hover:border-purple-500/30 transition-all group">
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-[10px] text-slate-400 uppercase tracking-widest font-bold">Gross Revenue</span>
|
||||
@@ -649,10 +522,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<h3 className="text-xl font-extrabold tracking-tight font-mono">
|
||||
₹{currentChartData.reduce((acc, curr) => acc + curr.revenue, 0).toLocaleString('en-IN')}
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-1">Estimated Value</p>
|
||||
<AwaitingApi label="Gross Revenue" api="[R1]" compact />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -726,16 +596,24 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
</div>
|
||||
|
||||
{/* Main Metric Value and Trend Badge */}
|
||||
{kpi.awaiting ? (
|
||||
<div className="mt-3">
|
||||
<AwaitingApi label="Revenue" api="[R1]" compact />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 flex items-baseline gap-2">
|
||||
<div className="font-sans font-black text-slate-900 text-3xl tracking-tight leading-none">
|
||||
{kpi.value}
|
||||
</div>
|
||||
{kpi.trend && (
|
||||
<span className={`text-[9px] font-bold px-2 py-0.5 rounded-full flex items-center gap-0.5 leading-none h-4 ${kpi.isPositive ? 'bg-emerald-50 text-emerald-600 border border-emerald-100/50' : 'bg-rose-50 text-rose-600 border border-rose-100/50'
|
||||
}`}>
|
||||
{kpi.isPositive ? '▲' : '▼'}
|
||||
{kpi.trend}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom Sparkline & Subtext segment */}
|
||||
<div className="flex items-center justify-between mt-auto pt-3 w-full border-t border-[#f1f5f9]">
|
||||
@@ -786,158 +664,11 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SVG Custom Graph Area */}
|
||||
<div className="relative h-64 select-none w-full">
|
||||
<svg className="w-full h-full overflow-visible" viewBox={`0 0 ${chartWidth} ${chartHeight}`}>
|
||||
<defs>
|
||||
{/* Indigo Gradients */}
|
||||
<linearGradient id="chart-indigo-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#818cf8" />
|
||||
<stop offset="100%" stopColor="#4f46e5" />
|
||||
</linearGradient>
|
||||
<linearGradient id="chart-indigo-area" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#6366f1" stopOpacity="0.15" />
|
||||
<stop offset="100%" stopColor="#6366f1" stopOpacity="0.00" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Emerald Gradients */}
|
||||
<linearGradient id="chart-emerald-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#34d399" />
|
||||
<stop offset="100%" stopColor="#059669" />
|
||||
</linearGradient>
|
||||
<linearGradient id="chart-emerald-area" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#10b981" stopOpacity="0.15" />
|
||||
<stop offset="100%" stopColor="#10b981" stopOpacity="0.00" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Rose Gradients */}
|
||||
<linearGradient id="chart-rose-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#fb7185" />
|
||||
<stop offset="100%" stopColor="#e11d48" />
|
||||
</linearGradient>
|
||||
<linearGradient id="chart-rose-area" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#f43f5e" stopOpacity="0.15" />
|
||||
<stop offset="100%" stopColor="#f43f5e" stopOpacity="0.00" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Sky Gradients */}
|
||||
<linearGradient id="chart-sky-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#38bdf8" />
|
||||
<stop offset="100%" stopColor="#0284c7" />
|
||||
</linearGradient>
|
||||
<linearGradient id="chart-sky-area" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#0ea5e9" stopOpacity="0.15" />
|
||||
<stop offset="100%" stopColor="#0ea5e9" stopOpacity="0.00" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* Grid Lines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio, idx) => {
|
||||
const y = paddingY + ratio * (chartHeight - 2 * paddingY);
|
||||
return (
|
||||
<line
|
||||
key={idx}
|
||||
x1={paddingX}
|
||||
y1={y}
|
||||
x2={chartWidth - paddingX}
|
||||
y2={y}
|
||||
stroke="#f1f5f9"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Area Fill */}
|
||||
{areaPath && (
|
||||
<path d={areaPath} fill={theme.fill} className="transition-all duration-550 ease-out" />
|
||||
)}
|
||||
|
||||
{/* Line Path */}
|
||||
{linePath && (
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={theme.stroke}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-550 ease-out"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hover Indicator Vertical Line */}
|
||||
{hoveredPoint !== null && (
|
||||
<line
|
||||
x1={points[hoveredPoint].x}
|
||||
y1={paddingY}
|
||||
x2={points[hoveredPoint].x}
|
||||
y2={chartHeight - paddingY}
|
||||
stroke={theme.activeLine}
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="3 3"
|
||||
className="transition-all duration-150"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Chart Points & Interactive Hover Areas */}
|
||||
{points.map((p, idx) => {
|
||||
const isHovered = hoveredPoint === idx;
|
||||
return (
|
||||
<g key={idx}>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={isHovered ? 6 : 4}
|
||||
fill={isHovered ? theme.hoverCircle : theme.pointFill}
|
||||
stroke="#ffffff"
|
||||
strokeWidth="2"
|
||||
className="transition-all duration-150"
|
||||
/>
|
||||
<circle
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r="20"
|
||||
fill="transparent"
|
||||
className="cursor-pointer"
|
||||
onMouseEnter={() => setHoveredPoint(idx)}
|
||||
onMouseLeave={() => setHoveredPoint(null)}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Hover Tooltip Overlay */}
|
||||
{hoveredPoint !== null && (
|
||||
<div
|
||||
className="absolute bg-zinc-950/95 border border-zinc-800 text-white rounded-2xl p-3 shadow-2xl font-sans text-xs z-10 pointer-events-none transition-all animate-in zoom-in-95 duration-150 flex flex-col gap-1 w-44 backdrop-blur-md"
|
||||
style={{
|
||||
left: `${(points[hoveredPoint].x / chartWidth) * 100}%`,
|
||||
top: `${(points[hoveredPoint].y / chartHeight) * 100 - 36}%`,
|
||||
transform: 'translateX(-50%)'
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-center border-b border-zinc-800 pb-1 mb-1">
|
||||
<span className="font-bold text-zinc-400">{currentChartData[hoveredPoint].label}</span>
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: theme.activeLine }} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Metrics Focus</span>
|
||||
<span className="font-extrabold font-mono text-base" style={{ color: theme.activeLine }}>
|
||||
{chartMetric === 'orders' ? `${points[hoveredPoint].val} Orders` :
|
||||
chartMetric === 'revenue' ? `₹${points[hoveredPoint].val.toLocaleString('en-IN')}` :
|
||||
chartMetric === 'cancelled' ? `${points[hoveredPoint].val} Cancelled` :
|
||||
`${points[hoveredPoint].val} SKUs`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* X Axis Labels */}
|
||||
<div className="flex justify-between items-center text-[10px] font-bold text-zinc-400 uppercase font-mono px-xl border-t border-[#f1f5f9] pt-md mt-sm select-none">
|
||||
{currentChartData.map((d, index) => (
|
||||
<span key={index}>{d.label}</span>
|
||||
))}
|
||||
{/* Plotted Area — no time-series API ([R2]) for orders/revenue/skus.
|
||||
The metric tabs (KPI cards above) still switch the card title; the
|
||||
chart body itself shows the awaiting-backend placeholder. */}
|
||||
<div className="relative h-64 select-none w-full flex items-center justify-center">
|
||||
<AwaitingApi label="Orders & revenue time-series" api="[R2]" className="w-full h-full justify-center" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1204,89 +935,11 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
||||
<tr className="bg-slate-50/20">
|
||||
<td colSpan={7} className="p-0 border-b border-[#e2e8f0]">
|
||||
<div className="px-lg py-md bg-gradient-to-r from-slate-50/50 to-purple-50/10 border-t border-[#e2e8f0] animate-in slide-in-from-top-2 duration-300">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-lg text-xs">
|
||||
|
||||
{/* Inventory Level Progress block */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider block">
|
||||
Stock Capacity Index
|
||||
</span>
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm">
|
||||
<div className="flex justify-between items-center mb-xs font-semibold">
|
||||
<span className="text-zinc-655">Current Balance</span>
|
||||
<span className={
|
||||
prod.stockStatus === 'Healthy' ? 'text-emerald-600' :
|
||||
prod.stockStatus === 'Low Stock' ? 'text-amber-600' : 'text-rose-600'
|
||||
}>
|
||||
{prod.stockStatus === 'Healthy' ? '142 Units (Optimal)' :
|
||||
prod.stockStatus === 'Low Stock' ? '42 Units (Low)' : '6 Units (Critical)'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 h-2.5 rounded-full overflow-hidden mt-1.5">
|
||||
<div className={`h-full rounded-full transition-all duration-500 ${prod.stockStatus === 'Healthy' ? 'bg-emerald-500 w-[85%]' :
|
||||
prod.stockStatus === 'Low Stock' ? 'bg-amber-500 w-[35%]' : 'bg-rose-500 w-[8%]'
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Distribution Locations */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider block">
|
||||
Hub Distribution Allocations
|
||||
</span>
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm space-y-2">
|
||||
<div className="flex justify-between text-[10px] font-semibold text-zinc-600">
|
||||
<span>Saravanampatti Hub</span>
|
||||
<span className="font-mono">{prod.stockStatus === 'Healthy' ? '85 units' : prod.stockStatus === 'Low Stock' ? '25 units' : '4 units'}</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
|
||||
<div className="bg-purple-500 h-full rounded-full w-[60%]" />
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-semibold text-zinc-600 mt-1">
|
||||
<span>RS Puram Hub</span>
|
||||
<span className="font-mono">{prod.stockStatus === 'Healthy' ? '57 units' : prod.stockStatus === 'Low Stock' ? '17 units' : '2 units'}</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
|
||||
<div className="bg-indigo-500 h-full rounded-full w-[40%]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Audit */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider block">
|
||||
Metadata & Barcode Identification
|
||||
</span>
|
||||
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md shadow-sm flex items-center justify-between gap-md">
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<span className="text-[8px] text-zinc-400 font-bold uppercase block leading-none mb-0.5">Warehouse Bin</span>
|
||||
<span className="font-mono font-bold text-zinc-750">BIN-C{prod.sku.replace(/\D/g, '').slice(-3) || '042'}</span>
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<span className="text-[8px] text-zinc-400 font-bold uppercase block leading-none mb-0.5">Last Audited</span>
|
||||
<span className="text-zinc-650 font-medium">{new Date().toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monospace barcode simulation */}
|
||||
<div className="flex flex-col items-center shrink-0 select-none bg-zinc-50 p-2 rounded-lg border border-zinc-100">
|
||||
<div className="flex items-center gap-[1.5px] h-7 px-1">
|
||||
{[1, 3, 1, 2, 4, 1, 3, 2, 1, 2, 3, 1, 2, 4, 1, 2].map((w, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-zinc-805 h-full"
|
||||
style={{ width: `${w * 0.7}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[8px] font-mono text-zinc-400 mt-1 uppercase tracking-wider">{prod.sku}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/* Per-product stock & location breakdown has no live
|
||||
API ([R3]); the previously fabricated unit counts,
|
||||
hub split, bin code, audit date and barcode are
|
||||
replaced with the awaiting-backend placeholder. */}
|
||||
<AwaitingApi label="Per-product stock & location detail" api="[R3]" compact />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -14,14 +14,13 @@ import {
|
||||
MapPin,
|
||||
Phone,
|
||||
Mail,
|
||||
Check,
|
||||
RotateCcw,
|
||||
CheckCircle2,
|
||||
Plus
|
||||
} from 'lucide-react';
|
||||
import { useFiestaAllTenants, useFiestaTenantLocations } from '../services/fiestaQueries';
|
||||
import { useAppRoles } from '../services/queries';
|
||||
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||
import UsersPanel from './UsersPanel';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
|
||||
interface SettingsViewProps {
|
||||
tenantId?: number;
|
||||
@@ -55,8 +54,6 @@ interface MerchantSettings {
|
||||
sandboxMode: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'merchant-settings-v1';
|
||||
|
||||
const DEFAULTS: MerchantSettings = {
|
||||
contactEmail: '',
|
||||
contactPhone: '',
|
||||
@@ -78,28 +75,6 @@ const DEFAULTS: MerchantSettings = {
|
||||
sandboxMode: false,
|
||||
};
|
||||
|
||||
function loadSettings(): { settings: MerchantSettings; hadSaved: boolean } {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return { settings: { ...DEFAULTS, ...JSON.parse(raw) }, hadSaved: true };
|
||||
} catch {
|
||||
/* ignore corrupt storage */
|
||||
}
|
||||
return { settings: { ...DEFAULTS }, hadSaved: false };
|
||||
}
|
||||
|
||||
// Localized fallback dataset to replace generic Faker test data with realistic Coimbatore outlets
|
||||
const LOCAL_OUTLETS_DATA = [
|
||||
{ name: 'Ragul Stores - Gandhipuram Hub', suburb: 'Gandhipuram', city: 'Coimbatore', postcode: '641018', radius: 4500, mins: 30 },
|
||||
{ name: 'Ragul Stores - Peelamedu Hub', suburb: 'Peelamedu', city: 'Coimbatore', postcode: '641004', radius: 3500, mins: 25 },
|
||||
{ name: 'Ragul Stores - RS Puram Hub', suburb: 'RS Puram', city: 'Coimbatore', postcode: '641002', radius: 5000, mins: 35 },
|
||||
{ name: 'Ragul Stores - Saravanampatti Outlet', suburb: 'Saravanampatti', city: 'Coimbatore', postcode: '641035', radius: 6000, mins: 40 },
|
||||
{ name: 'Ragul Stores - Singanallur Outlet', suburb: 'Singanallur', city: 'Coimbatore', postcode: '641005', radius: 4000, mins: 30 },
|
||||
{ name: 'Ragul Stores - Vadavalli Hub', suburb: 'Vadavalli', city: 'Coimbatore', postcode: '641046', radius: 3000, mins: 20 },
|
||||
{ name: 'Ragul Stores - Ramanathapuram Hub', suburb: 'Ramanathapuram', city: 'Coimbatore', postcode: '641045', radius: 4500, mins: 30 },
|
||||
{ name: 'Ragul Stores - Town Hall Outlet', suburb: 'Town Hall', city: 'Coimbatore', postcode: '641001', radius: 3500, mins: 25 },
|
||||
];
|
||||
|
||||
const formatFriendlyTime = (timeStr: string) => {
|
||||
try {
|
||||
if (timeStr.includes('T')) {
|
||||
@@ -127,15 +102,6 @@ const formatFriendlyTime = (timeStr: string) => {
|
||||
};
|
||||
|
||||
/// ── Small presentational helpers ────────────────────────────────────────────
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: () => void }) {
|
||||
return (
|
||||
<label className="relative inline-flex items-center cursor-pointer shrink-0 select-none group">
|
||||
<input type="checkbox" checked={checked} onChange={onChange} className="sr-only peer" />
|
||||
<div className="w-11 h-6 bg-slate-200 peer-focus:outline-none rounded-full transition-all duration-300 peer-checked:bg-purple-650 after:content-[''] after:absolute after:top-[3.5px] after:left-[4px] after:bg-white after:rounded-full after:h-4.5 after:w-4.5 after:transition-all after:duration-300 peer-checked:after:translate-x-5 shadow-sm group-active:after:w-5.5 peer-checked:group-active:after:translate-x-4" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
title,
|
||||
desc,
|
||||
@@ -165,73 +131,48 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
const locationsQ = useFiestaTenantLocations(tenantId);
|
||||
const outlets = locationsQ.data ?? [];
|
||||
|
||||
// Persisted preferences.
|
||||
const initial = useRef(loadSettings());
|
||||
const [form, setForm] = useState<MerchantSettings>(initial.current.settings);
|
||||
const [saved, setSaved] = useState<MerchantSettings>(initial.current.settings);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
// Application roles (Hasura) — drives the role dropdowns.
|
||||
const rolesQ = useAppRoles();
|
||||
|
||||
// First-run seeding: if nothing was saved yet, fill contact/min-order/region
|
||||
// from the live tenant once it arrives.
|
||||
const seededRef = useRef(initial.current.hadSaved);
|
||||
// In-session workspace preferences. These have NO merchant-settings backend
|
||||
// (see [R6]) so they are not persisted; the operational controls that would
|
||||
// need persistence show an AwaitingApi notice instead of saving silently.
|
||||
const [form, setForm] = useState<MerchantSettings>({ ...DEFAULTS });
|
||||
|
||||
// First-run seeding: fill region/role defaults from the live tenant once it
|
||||
// arrives (used at runtime by the Add User dialog / region label).
|
||||
const seededRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (seededRef.current || !tenant) return;
|
||||
seededRef.current = true;
|
||||
const seed = (prev: MerchantSettings): MerchantSettings => ({
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
contactEmail: prev.contactEmail || fstr(tenant.primaryemail),
|
||||
contactPhone: prev.contactPhone || fstr(tenant.primarycontact),
|
||||
minOrderValue: prev.minOrderValue || fnum(tenant.minorder),
|
||||
defaultRegion: prev.defaultRegion || fstr(tenant.city) || 'Coimbatore',
|
||||
});
|
||||
setForm(seed);
|
||||
setSaved(seed);
|
||||
}));
|
||||
}, [tenant]);
|
||||
|
||||
// Live outlets only — no fabricated fallback. Render whatever the API returns.
|
||||
const cleanOutlets = useMemo(() => {
|
||||
return outlets.map((loc, idx) => {
|
||||
// If the location name is a mock name (doesn't contain store context), replace with Coimbatore locations
|
||||
const nameStr = fstr(loc.locationname);
|
||||
const isMockTest = !nameStr.toLowerCase().includes('stores') &&
|
||||
!nameStr.toLowerCase().includes('outlet') &&
|
||||
!nameStr.toLowerCase().includes('hub') &&
|
||||
!nameStr.toLowerCase().includes('ragul');
|
||||
|
||||
const localData = LOCAL_OUTLETS_DATA[idx % LOCAL_OUTLETS_DATA.length];
|
||||
|
||||
return {
|
||||
locationid: fstr(loc.locationid) || String(1090 + idx),
|
||||
locationname: isMockTest ? localData.name : nameStr,
|
||||
suburb: isMockTest ? localData.suburb : (fstr(loc.suburb) || localData.suburb),
|
||||
city: isMockTest ? localData.city : (fstr(loc.city) || localData.city),
|
||||
postcode: isMockTest ? localData.postcode : (fstr(loc.postcode) || localData.postcode),
|
||||
status: fstr(loc.status) || 'Active',
|
||||
opentime: fstr(loc.opentime) || '2026-06-04T09:00:00Z',
|
||||
closetime: fstr(loc.closetime) || '2026-06-04T22:00:00Z',
|
||||
deliverymins: isMockTest ? localData.mins : (fnum(loc.deliverymins) || localData.mins),
|
||||
deliveryradius: isMockTest ? localData.radius : (fnum(loc.deliveryradius) || localData.radius),
|
||||
};
|
||||
});
|
||||
return outlets.map((loc, idx) => ({
|
||||
locationid: fstr(loc.locationid) || String(idx),
|
||||
locationname: fstr(loc.locationname) || '—',
|
||||
suburb: fstr(loc.suburb),
|
||||
city: fstr(loc.city),
|
||||
postcode: fstr(loc.postcode),
|
||||
status: fstr(loc.status) || '—',
|
||||
opentime: fstr(loc.opentime),
|
||||
closetime: fstr(loc.closetime),
|
||||
deliverymins: fnum(loc.deliverymins),
|
||||
deliveryradius: fnum(loc.deliveryradius),
|
||||
}));
|
||||
}, [outlets]);
|
||||
|
||||
const dirty = useMemo(() => JSON.stringify(form) !== JSON.stringify(saved), [form, saved]);
|
||||
|
||||
const set = <K extends keyof MerchantSettings>(key: K, value: MerchantSettings[K]) =>
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
|
||||
const handleSave = () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||
} catch {
|
||||
/* ignore quota errors */
|
||||
}
|
||||
setSaved(form);
|
||||
setToast('Settings saved');
|
||||
window.setTimeout(() => setToast(null), 2200);
|
||||
};
|
||||
|
||||
const handleReset = () => setForm(saved);
|
||||
|
||||
const tabs: Array<{ key: TabKey; label: string; icon: typeof Building2 }> = [
|
||||
{ key: 'profile', label: 'Business Profile', icon: Building2 },
|
||||
{ key: 'outlets', label: 'Outlets', icon: Store },
|
||||
@@ -241,7 +182,23 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
{ key: 'preferences', label: 'Preferences', icon: SlidersHorizontal },
|
||||
];
|
||||
|
||||
const roleOptions = [1, 2, 3, 4, 6];
|
||||
// Build role options from the live app-roles API; fall back to the known
|
||||
// numeric roles + roleName() helper when the API has no rows/names.
|
||||
const roleOptions = useMemo<Array<{ id: number; name: string }>>(() => {
|
||||
const rows = rolesQ.data ?? [];
|
||||
const mapped = rows
|
||||
.map((r) => {
|
||||
const id = fnum((r as Record<string, unknown>).roleid);
|
||||
const name =
|
||||
fstr((r as Record<string, unknown>).rolename) ||
|
||||
fstr((r as Record<string, unknown>).name) ||
|
||||
roleName(id);
|
||||
return { id, name };
|
||||
})
|
||||
.filter((r) => r.id > 0);
|
||||
if (mapped.length) return mapped;
|
||||
return [1, 2, 3, 4, 6].map((id) => ({ id, name: roleName(id) }));
|
||||
}, [rolesQ.data]);
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-300 relative font-sans text-slate-700">
|
||||
@@ -398,7 +355,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editable contact (persisted locally) */}
|
||||
{/* Customer support contacts — live (read-only) tenant values. */}
|
||||
<div className="space-y-sm">
|
||||
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider block">Customer Support & Contacts</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-md mt-2">
|
||||
@@ -408,10 +365,10 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.contactEmail}
|
||||
onChange={(e) => set('contactEmail', e.target.value)}
|
||||
className="w-full border border-slate-200 rounded-xl py-3 px-4 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
|
||||
placeholder="store@example.com"
|
||||
value={fstr(tenant?.primaryemail) || ''}
|
||||
readOnly
|
||||
className="w-full border border-slate-200 rounded-xl py-3 px-4 bg-slate-50/40 outline-none transition-all text-slate-800 font-semibold text-sm shadow-sm"
|
||||
placeholder="—"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
@@ -420,10 +377,10 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.contactPhone}
|
||||
onChange={(e) => set('contactPhone', e.target.value)}
|
||||
className="w-full border border-slate-200 rounded-xl py-3 px-4 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm"
|
||||
placeholder="9876543210"
|
||||
value={fstr(tenant?.primarycontact) || ''}
|
||||
readOnly
|
||||
className="w-full border border-slate-200 rounded-xl py-3 px-4 bg-slate-50/40 outline-none transition-all text-slate-800 font-semibold text-sm shadow-sm"
|
||||
placeholder="—"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -446,7 +403,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
{locationsQ.isLoading ? (
|
||||
<div className="text-center py-lg text-slate-400 font-medium text-sm">Loading live outlets…</div>
|
||||
) : cleanOutlets.length === 0 ? (
|
||||
<div className="text-center py-lg text-slate-400 font-medium text-sm">No outlets found for this store.</div>
|
||||
<div className="text-center py-lg text-slate-400 font-medium text-sm">No outlets configured yet.</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-md max-h-[38rem] overflow-y-auto pr-1 scrollbar-thin">
|
||||
{cleanOutlets.map((loc, i) => (
|
||||
@@ -462,7 +419,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
<p className="font-bold text-slate-800 text-sm truncate leading-tight group-hover:text-purple-950 transition-colors">{loc.locationname}</p>
|
||||
<p className="text-xs text-slate-450 mt-1.5 flex items-center gap-1">
|
||||
<MapPin size={12} className="shrink-0 text-slate-400" />
|
||||
<span className="truncate">{loc.suburb}, {loc.city}</span>
|
||||
<span className="truncate">{[loc.suburb, loc.city].filter(Boolean).join(', ') || '—'}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -480,20 +437,22 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-slate-450 uppercase font-bold block">Delivery Range</span>
|
||||
<p className="font-bold text-slate-700 text-xs">
|
||||
Up to {loc.deliveryradius / 1000} km
|
||||
{loc.deliveryradius ? `Up to ${loc.deliveryradius / 1000} km` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-slate-455 uppercase font-bold block">Delivery Speed</span>
|
||||
<p className="font-bold text-slate-700 text-xs">
|
||||
{loc.deliverymins} mins avg
|
||||
{loc.deliverymins ? `${loc.deliverymins} mins avg` : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1 col-span-2 border-t border-slate-100 pt-2 mt-1">
|
||||
<span className="text-[10px] text-slate-455 uppercase font-bold block">Opening Hours</span>
|
||||
<p className="font-bold text-slate-750 text-xs flex items-center gap-1.5 mt-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
Open: {formatFriendlyTime(loc.opentime)} – {formatFriendlyTime(loc.closetime)}
|
||||
{loc.opentime && loc.closetime
|
||||
? `Open: ${formatFriendlyTime(loc.opentime)} – ${formatFriendlyTime(loc.closetime)}`
|
||||
: 'Hours not set'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -511,144 +470,47 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
|
||||
{activeTab === 'delivery' && (
|
||||
<div className="bg-white border border-slate-200/60 p-6 rounded-2xl shadow-sm space-y-lg animate-in fade-in duration-200">
|
||||
{/* Group 1: Order Prep & Timings */}
|
||||
<div className="space-y-sm">
|
||||
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
|
||||
Order Prep & Timings
|
||||
</span>
|
||||
<div className="divide-y divide-slate-100/70 mt-2">
|
||||
<Row title="Preparation Time" desc="Minutes a store needs before pickup.">
|
||||
<div className="relative rounded-xl shadow-sm">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.prepMins}
|
||||
onChange={(e) => set('prepMins', Number(e.target.value))}
|
||||
className="w-28 pr-9 pl-4 py-2 border border-slate-200 rounded-xl text-right font-bold text-slate-700 bg-slate-50/40 hover:bg-slate-50 focus:bg-white focus:outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/10 transition-all text-sm font-mono"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<span className="text-slate-400 font-semibold text-[10px] uppercase">min</span>
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
<Row title="Delivery Window" desc="Estimated delivery time from store to customer.">
|
||||
<div className="relative rounded-xl shadow-sm">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.deliveryWindowMins}
|
||||
onChange={(e) => set('deliveryWindowMins', Number(e.target.value))}
|
||||
className="w-28 pr-9 pl-4 py-2 border border-slate-200 rounded-xl text-right font-bold text-slate-700 bg-slate-50/40 hover:bg-slate-50 focus:bg-white focus:outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/10 transition-all text-sm font-mono"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<span className="text-slate-400 font-semibold text-[10px] uppercase">min</span>
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
<Row title="Cancellation Window" desc="Seconds a customer can cancel for free.">
|
||||
<div className="relative rounded-xl shadow-sm">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.cancelWindowSecs}
|
||||
onChange={(e) => set('cancelWindowSecs', Number(e.target.value))}
|
||||
className="w-28 pr-9 pl-4 py-2 border border-slate-200 rounded-xl text-right font-bold text-slate-700 bg-slate-50/40 hover:bg-slate-50 focus:bg-white focus:outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/10 transition-all text-sm font-mono"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<span className="text-slate-400 font-semibold text-[10px] uppercase">sec</span>
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group 2: Delivery Charges & Dispatch */}
|
||||
<div className="space-y-sm">
|
||||
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
|
||||
Delivery Charges & Dispatch
|
||||
</span>
|
||||
<div className="divide-y divide-slate-100/70 mt-2">
|
||||
<Row title="Default Delivery Charge" desc="Flat fee added to each delivery order.">
|
||||
<div className="relative rounded-xl shadow-sm">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span className="text-slate-400 font-bold text-sm">₹</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.deliveryCharge}
|
||||
onChange={(e) => set('deliveryCharge', Number(e.target.value))}
|
||||
className="w-28 pl-7 pr-4 py-2 border border-slate-200 rounded-xl text-right font-bold text-slate-700 bg-slate-50/40 hover:bg-slate-50 focus:bg-white focus:outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/10 transition-all text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
<Row title="Auto-assign Rider" desc="Automatically dispatch the nearest available rider.">
|
||||
<Toggle checked={form.autoAssignRider} onChange={() => set('autoAssignRider', !form.autoAssignRider)} />
|
||||
</Row>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-bold text-slate-450 uppercase tracking-widest block">Delivery</span>
|
||||
<h2 className="text-xl font-bold text-slate-900 mt-1">Order Prep, Timings & Dispatch</h2>
|
||||
</div>
|
||||
{/* No merchant-settings API yet — these operational controls cannot be persisted live. */}
|
||||
<AwaitingApi label="Merchant settings persistence" api="[R6]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'payment' && (
|
||||
<div className="bg-white border border-slate-200/60 p-6 rounded-2xl shadow-sm space-y-lg animate-in fade-in duration-200">
|
||||
{/* Group 1: Checkout Gateways */}
|
||||
<div>
|
||||
<span className="text-xs font-bold text-slate-450 uppercase tracking-widest block">Payment & Tax</span>
|
||||
<h2 className="text-xl font-bold text-slate-900 mt-1">Checkout & Taxation</h2>
|
||||
</div>
|
||||
|
||||
{/* Live (read-only) tenant payment details. */}
|
||||
<div className="space-y-sm">
|
||||
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
|
||||
Checkout Gateways
|
||||
Store Payment Details
|
||||
</span>
|
||||
<div className="divide-y divide-slate-100/70 mt-2">
|
||||
<Row title="Cash on Delivery" desc="Allow customers to pay on delivery.">
|
||||
<Toggle checked={form.codEnabled} onChange={() => set('codEnabled', !form.codEnabled)} />
|
||||
<Row title="Minimum Order Value" desc="Smallest order a customer can place (from store profile).">
|
||||
<span className="font-bold text-slate-700 text-sm font-mono">
|
||||
{tenant && fnum(tenant.minorder) ? `₹${fnum(tenant.minorder).toLocaleString('en-IN')}` : '—'}
|
||||
</span>
|
||||
</Row>
|
||||
<Row title="Online Payments" desc="Accept UPI / card / wallet at checkout.">
|
||||
<Toggle checked={form.onlinePaymentEnabled} onChange={() => set('onlinePaymentEnabled', !form.onlinePaymentEnabled)} />
|
||||
<Row title="Payment Gateway ID" desc="Configured payment type for this store.">
|
||||
<span className="font-mono font-black bg-purple-100 px-3 py-1.5 rounded-xl border border-purple-200/40 text-xs">
|
||||
{tenant && fnum(tenant.paymenttype) ? fnum(tenant.paymenttype) : '—'}
|
||||
</span>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group 2: Taxation & Rules */}
|
||||
{/* Editable checkout gateways + tax rules have no persistence backend. */}
|
||||
<div className="space-y-sm">
|
||||
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
|
||||
Taxation & Cart Limits
|
||||
Checkout Gateways & Taxation
|
||||
</span>
|
||||
<div className="divide-y divide-slate-100/70 mt-2">
|
||||
<Row title="Default Tax Rate" desc="Applied to taxable catalogue items.">
|
||||
<div className="relative rounded-xl shadow-sm">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={form.defaultTaxPercent}
|
||||
onChange={(e) => set('defaultTaxPercent', Number(e.target.value))}
|
||||
className="w-28 pr-7 pl-4 py-2 border border-slate-200 rounded-xl text-right font-bold text-slate-700 bg-slate-50/40 hover:bg-slate-50 focus:bg-white focus:outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/10 transition-all text-sm font-mono"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<span className="text-slate-400 font-bold text-sm">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
<Row title="Minimum Order Value" desc="Smallest order a customer can place.">
|
||||
<div className="relative rounded-xl shadow-sm">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<span className="text-slate-400 font-bold text-sm">₹</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.minOrderValue}
|
||||
onChange={(e) => set('minOrderValue', Number(e.target.value))}
|
||||
className="w-28 pl-7 pr-4 py-2 border border-slate-200 rounded-xl text-right font-bold text-slate-700 bg-slate-50/40 hover:bg-slate-50 focus:bg-white focus:outline-none focus:border-purple-500 focus:ring-2 focus:ring-purple-500/10 transition-all text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API synchronization details */}
|
||||
<div className="p-4 bg-purple-50/50 border border-purple-100/50 rounded-2xl text-purple-900 text-xs font-semibold flex items-center justify-between">
|
||||
<span className="text-slate-650 font-bold text-xs">Payment Gateway ID</span>
|
||||
<span className="font-mono font-black bg-purple-100 px-3 py-1.5 rounded-xl border border-purple-200/40 text-xs">{fnum(tenant?.paymenttype) || 'PAY-MOCK-99'}</span>
|
||||
<AwaitingApi className="mt-2" label="Merchant settings persistence" api="[R6]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -681,92 +543,28 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
|
||||
className="border border-slate-200 bg-slate-50/40 hover:bg-slate-50 focus:bg-white rounded-xl py-2 px-3 font-bold text-slate-700 outline-none cursor-pointer focus:border-purple-500 transition-all text-sm shadow-sm"
|
||||
>
|
||||
{roleOptions.map((r) => (
|
||||
<option key={r} value={r}>{roleName(r)}</option>
|
||||
<option key={r.id} value={r.id}>{r.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
<Row title="Data Sync Interval" desc="How often live data refreshes from the API.">
|
||||
<select
|
||||
value={form.syncInterval}
|
||||
onChange={(e) => set('syncInterval', Number(e.target.value))}
|
||||
className="border border-slate-200 bg-slate-50/40 hover:bg-slate-50 focus:bg-white rounded-xl py-2 px-3 font-bold text-slate-700 outline-none cursor-pointer focus:border-purple-500 transition-all text-sm shadow-sm"
|
||||
>
|
||||
<option value={1}>Every 1 min</option>
|
||||
<option value={5}>Every 5 mins</option>
|
||||
<option value={15}>Every 15 mins</option>
|
||||
<option value={30}>Every 30 mins</option>
|
||||
</select>
|
||||
</Row>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 font-medium mt-2 px-4">
|
||||
Region and default-role are in-session workspace preferences applied at runtime; they are not saved to a backend.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Group 2: Notifications */}
|
||||
{/* Group 2: Notifications, sync interval & sandbox — no persistence backend. */}
|
||||
<div className="space-y-sm">
|
||||
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
|
||||
Notifications
|
||||
Notifications, Sync & Test Mode
|
||||
</span>
|
||||
<div className="divide-y divide-slate-100/70 mt-2">
|
||||
<Row title="Order Notifications" desc="Alert on every new incoming order.">
|
||||
<Toggle checked={form.orderNotifications} onChange={() => set('orderNotifications', !form.orderNotifications)} />
|
||||
</Row>
|
||||
<Row title="Low-stock Alerts" desc="Notify when an SKU drops below threshold.">
|
||||
<Toggle checked={form.lowStockAlerts} onChange={() => set('lowStockAlerts', !form.lowStockAlerts)} />
|
||||
</Row>
|
||||
<Row title="Daily Summary Email" desc="Email a closing-hours performance digest.">
|
||||
<Toggle checked={form.dailySummaryEmail} onChange={() => set('dailySummaryEmail', !form.dailySummaryEmail)} />
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group 3: Test Mode (Sandbox) */}
|
||||
<div className="space-y-sm">
|
||||
<span className="text-xs font-bold text-purple-800 bg-purple-50/70 border border-purple-100/50 px-3 py-1 rounded-full uppercase tracking-wider">
|
||||
Test Mode (Sandbox)
|
||||
</span>
|
||||
<div className="divide-y divide-slate-100/70 mt-2">
|
||||
<Row title="Sandbox Mode" desc="Simulate warning states for testing without affecting live operations.">
|
||||
<Toggle checked={form.sandboxMode} onChange={() => set('sandboxMode', !form.sandboxMode)} />
|
||||
</Row>
|
||||
</div>
|
||||
<AwaitingApi className="mt-2" label="Merchant settings persistence" api="[R6]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Save Actions Bar (Frosted Glass) */}
|
||||
<div className={`fixed bottom-6 left-6 sm:left-[28%] right-6 bg-white/75 backdrop-blur-md border border-slate-200/80 rounded-2xl p-4 shadow-[0_20px_50px_rgba(0,0,0,0.12)] flex flex-col sm:flex-row sm:items-center justify-between gap-4 z-40 transition-all duration-500 ease-out transform select-none ${
|
||||
activeTab === 'users' ? 'hidden' :
|
||||
dirty ? 'translate-y-0 opacity-100' : 'translate-y-16 opacity-0 pointer-events-none'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-amber-500 animate-pulse shrink-0" />
|
||||
<span className="text-sm font-bold text-slate-800">You have unsaved configuration changes</span>
|
||||
</div>
|
||||
<div className="flex gap-2.5">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-4 py-2.5 border border-slate-200 bg-white/50 hover:bg-slate-100 rounded-xl text-xs font-bold text-slate-650 transition-all cursor-pointer flex items-center gap-1.5 active:scale-95 shadow-sm"
|
||||
>
|
||||
<RotateCcw size={14} /> Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-4 py-2.5 bg-purple-650 hover:bg-purple-755 text-white rounded-xl text-xs font-bold transition-all cursor-pointer shadow-sm flex items-center gap-1.5 active:scale-95 border-none"
|
||||
>
|
||||
<Check size={14} /> Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className="fixed bottom-md right-md z-[130] bg-[#0f172a] text-white px-4 py-2.5 rounded-lg shadow-2xl flex items-center gap-2 text-xs font-semibold animate-in slide-in-from-bottom-2 fade-in duration-200">
|
||||
<CheckCircle2 size={15} className="text-emerald-400" />
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,13 +22,11 @@ import {
|
||||
Send,
|
||||
Download,
|
||||
X,
|
||||
Battery,
|
||||
ShieldCheck,
|
||||
Globe,
|
||||
UploadCloud,
|
||||
FileText,
|
||||
Mail,
|
||||
UserCheck,
|
||||
CreditCard,
|
||||
History,
|
||||
Building,
|
||||
@@ -38,16 +36,16 @@ import {
|
||||
import {
|
||||
useFiestaStockStatement,
|
||||
useFiestaTenantCustomers,
|
||||
useFiestaCustomerOrders,
|
||||
useFiestaMasterCatalog,
|
||||
useFiestaRiders,
|
||||
FIESTA_TENANT_ID
|
||||
} from '../services/fiestaQueries';
|
||||
import { str as fstr } from '../services/fiestaApi';
|
||||
import {
|
||||
initialInventory,
|
||||
initialCustomerOrders,
|
||||
operationalAlerts
|
||||
} from '../data';
|
||||
import { str as fstr, num as fnum } from '../services/fiestaApi';
|
||||
import { mapOrderStatus, shortTime } from '../services/fiestaMappers';
|
||||
import ragulStoreCover from '../assets/images/store_front_view_1780299351800.png';
|
||||
import OrdersDeliveriesView from './OrdersDeliveriesView';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
|
||||
interface StoreDetailViewProps {
|
||||
store: {
|
||||
@@ -61,18 +59,15 @@ interface StoreDetailViewProps {
|
||||
color: string;
|
||||
status: string;
|
||||
};
|
||||
onBack: () => void;
|
||||
/** Returns to the stores registry. Omitted when the view stands alone (e.g. a
|
||||
* single user is scoped to just this store), in which case no back button shows. */
|
||||
onBack?: () => void;
|
||||
/** Whether to show write/management actions (replenish, broadcast, import,
|
||||
* catalogue, promo/credit). Admins get true; plain store users get false so
|
||||
* the console is view-only. */
|
||||
canManage?: boolean;
|
||||
}
|
||||
|
||||
// ── Master Global Catalogue Items ──
|
||||
const GLOBAL_CATALOGUE_ITEMS = [
|
||||
{ sku: 'SALT-TATA-1KG', name: 'Tata Salt Premium Iodized 1kg', category: 'Staples / Salt', price: 28, image: 'https://images.unsplash.com/photo-1626132647523-66f5bf380027?auto=format&fit=crop&w=150&q=80' },
|
||||
{ sku: 'SUN-OIL-1LIT', name: 'Gold Winner Sunflower Oil 1L', category: 'Groceries / Oils', price: 145, image: 'https://images.unsplash.com/photo-1474979266404-7eaacbcd87c5?auto=format&fit=crop&w=150&q=80' },
|
||||
{ sku: 'BISCUIT-MAR-GD', name: 'Britannia Marie Gold Biscuit 250g', category: 'Snacks / Biscuits', price: 35, image: 'https://images.unsplash.com/photo-1558961363-fa8fdf82db35?auto=format&fit=crop&w=150&q=80' },
|
||||
{ sku: 'SPICE-SAMBAR-MTR', name: 'MTR Sambar Powder 200g', category: 'Groceries / Spices', price: 85, image: 'https://images.unsplash.com/photo-1596040033229-a9821ebd058d?auto=format&fit=crop&w=150&q=80' },
|
||||
{ sku: 'AAVIN-BUTTER-500', name: 'Aavin Salted Butter 500g', category: 'Dairy / Butter', price: 260, image: 'https://images.unsplash.com/photo-1589985270826-4b7bb135bc9d?auto=format&fit=crop&w=150&q=80' }
|
||||
];
|
||||
|
||||
// Fallback cover images
|
||||
const DETAIL_STORE_COVERS = [
|
||||
'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=800&q=80',
|
||||
@@ -89,7 +84,7 @@ const DETAIL_STORE_COVERS = [
|
||||
'https://images.unsplash.com/photo-1579621970563-ebec7560ff3e?auto=format&fit=crop&w=800&q=80'
|
||||
];
|
||||
|
||||
export default function StoreDetailView({ store, onBack }: StoreDetailViewProps) {
|
||||
export default function StoreDetailView({ store, onBack, canManage = true }: StoreDetailViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'customers' | 'orders'>('overview');
|
||||
|
||||
const isRagul = store.name.toLowerCase().includes('ragul');
|
||||
@@ -105,7 +100,6 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
const storeCoverImage = getStoreCover();
|
||||
const [stockSearch, setStockSearch] = useState('');
|
||||
const [customerSearch, setCustomerSearch] = useState('');
|
||||
const [hoveredChartIndex, setHoveredChartIndex] = useState<number | null>(null);
|
||||
|
||||
// ── Toast Notification state ──────────────────────────────────────────────
|
||||
const [toast, setToast] = useState<{ show: boolean; message: string; type: 'success' | 'info' | 'warning' }>({
|
||||
@@ -150,104 +144,63 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
locationid,
|
||||
pagesize: 100
|
||||
});
|
||||
|
||||
// ── Seed / Fallback calculation helpers ────────────────────────────────────
|
||||
const parseOrdersCount = (salesStr: string): number => {
|
||||
const num = parseInt(salesStr.replace(/[^0-9]/g, ''), 10);
|
||||
return isNaN(num) ? 45 : num;
|
||||
// Live active rider fleet for this tenant (powers KPI fleet count + fleet list)
|
||||
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID });
|
||||
// Master catalogue rows for the Global Catalogue modal
|
||||
const masterCatalogQ = useFiestaMasterCatalog({
|
||||
tenantid: FIESTA_TENANT_ID,
|
||||
locationid,
|
||||
pagesize: 100
|
||||
});
|
||||
// Past orders for the currently-open CRM drawer customer (disabled when no id)
|
||||
const customerOrdersQ = useFiestaCustomerOrders({
|
||||
customerid: selectedCustomer?.id ?? null,
|
||||
pagesize: 20
|
||||
});
|
||||
const customerOrderHistory = (customerOrdersQ.data ?? []).map((row: any) => {
|
||||
const amount = fnum(row.orderamount) || fnum(row.amount);
|
||||
return {
|
||||
id: fstr(row.orderid) || `ORD-${fstr(row.orderheaderid)}`,
|
||||
date: shortTime(row.orderdate || row.createddate),
|
||||
amount: amount > 0 ? `₹${amount.toLocaleString('en-IN')}` : '—',
|
||||
status: mapOrderStatus(fstr(row.orderstatus))
|
||||
};
|
||||
});
|
||||
|
||||
const baseOrders = parseOrdersCount(store.sales);
|
||||
const revenueToday = baseOrders > 100 ? Math.round(baseOrders * 320) : 48200;
|
||||
|
||||
// ── Interval slots with heights for chart representation ──────────────────
|
||||
const intervalSlots = [
|
||||
{ time: '06:00 AM - 10:00 AM', label: 'Morning Rush', orders: Math.round(store.deliveries * 0.35) || 14, sales: `₹${Math.round(revenueToday * 0.3).toLocaleString('en-IN')}`, height: '70%', status: 'PEAK' },
|
||||
{ time: '10:00 AM - 02:00 PM', label: 'Mid-day Deliveries', orders: Math.round(store.deliveries * 0.25) || 10, sales: `₹${Math.round(revenueToday * 0.25).toLocaleString('en-IN')}`, height: '50%', status: 'NORMAL' },
|
||||
{ time: '02:00 PM - 06:00 PM', label: 'Afternoon Dispatch', orders: Math.round(store.deliveries * 0.15) || 6, sales: `₹${Math.round(revenueToday * 0.15).toLocaleString('en-IN')}`, height: '30%', status: 'LOW' },
|
||||
{ time: '06:00 PM - 10:00 PM', label: 'Evening Surge', orders: Math.round(store.deliveries * 0.2) || 8, sales: `₹${Math.round(revenueToday * 0.25).toLocaleString('en-IN')}`, height: '45%', status: 'HIGH' },
|
||||
{ time: '10:00 PM - 06:00 AM', label: 'Night Prep', orders: Math.round(store.deliveries * 0.05) || 2, sales: `₹${Math.round(revenueToday * 0.05).toLocaleString('en-IN')}`, height: '15%', status: 'LOW' }
|
||||
];
|
||||
|
||||
const activeSlotIndex = hoveredChartIndex !== null ? hoveredChartIndex : 0;
|
||||
const activeSlot = intervalSlots[activeSlotIndex];
|
||||
|
||||
// Past 7 Days Log
|
||||
const pastDaysLog = [
|
||||
{ day: 'Wednesday (Today)', orders: store.deliveries || 40, sales: `₹${revenueToday.toLocaleString('en-IN')}`, rate: '98.2%', change: '+4.5%' },
|
||||
{ day: 'Tuesday', orders: Math.round(store.deliveries * 0.9) || 36, sales: `₹${Math.round(revenueToday * 0.88).toLocaleString('en-IN')}`, rate: '100%', change: '+1.2%' },
|
||||
{ day: 'Monday', orders: Math.round(store.deliveries * 0.85) || 34, sales: `₹${Math.round(revenueToday * 0.82).toLocaleString('en-IN')}`, rate: '96.8%', change: '-2.1%' },
|
||||
{ day: 'Sunday', orders: Math.round(store.deliveries * 1.2) || 48, sales: `₹${Math.round(revenueToday * 1.15).toLocaleString('en-IN')}`, rate: '94.5%', change: '+12.4%' },
|
||||
{ day: 'Saturday', orders: Math.round(store.deliveries * 1.15) || 46, sales: `₹${Math.round(revenueToday * 1.12).toLocaleString('en-IN')}`, rate: '99.1%', change: '+8.6%' },
|
||||
{ day: 'Friday', orders: Math.round(store.deliveries * 0.95) || 38, sales: `₹${Math.round(revenueToday * 0.92).toLocaleString('en-IN')}`, rate: '97.4%', change: '+2.0%' },
|
||||
{ day: 'Thursday', orders: Math.round(store.deliveries * 0.9) || 36, sales: `₹${Math.round(revenueToday * 0.89).toLocaleString('en-IN')}`, rate: '100%', change: '+0.5%' }
|
||||
];
|
||||
|
||||
// Inventory mapping (derived + live merges)
|
||||
// Inventory mapping (live only)
|
||||
const getMergedInventory = () => {
|
||||
const rawStock = stockQ.data ?? [];
|
||||
|
||||
const resolveMetadata = (name: string) => {
|
||||
// Cosmetic image fallback only — keyword → stock photo. This MUST NOT be
|
||||
// used to fabricate prices; a missing live price renders as "—".
|
||||
const resolveImage = (name: string) => {
|
||||
const nameLower = name.toLowerCase();
|
||||
let price = 60;
|
||||
let image = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=150&q=80';
|
||||
|
||||
if (nameLower.includes('rice')) {
|
||||
price = 1400;
|
||||
image = 'https://images.unsplash.com/photo-1586201375761-83865001e31c?auto=format&fit=crop&w=150&q=80';
|
||||
} else if (nameLower.includes('oil')) {
|
||||
price = 340;
|
||||
image = 'https://images.unsplash.com/photo-1474979266404-7eaacbcd87c5?auto=format&fit=crop&w=150&q=80';
|
||||
} else if (nameLower.includes('coffee')) {
|
||||
price = 195;
|
||||
image = 'https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?auto=format&fit=crop&w=150&q=80';
|
||||
} else if (nameLower.includes('carrot')) {
|
||||
price = 60;
|
||||
image = 'https://images.unsplash.com/photo-1598170845058-32b9d6a5da37?auto=format&fit=crop&w=150&q=80';
|
||||
} else if (nameLower.includes('ghee')) {
|
||||
price = 320;
|
||||
image = 'https://images.unsplash.com/photo-1589985270826-4b7bb135bc9d?auto=format&fit=crop&w=150&q=80';
|
||||
} else if (nameLower.includes('butter')) {
|
||||
price = 260;
|
||||
image = 'https://images.unsplash.com/photo-1589985270826-4b7bb135bc9d?auto=format&fit=crop&w=150&q=80';
|
||||
} else if (nameLower.includes('salt')) {
|
||||
price = 28;
|
||||
image = 'https://images.unsplash.com/photo-1626132647523-66f5bf380027?auto=format&fit=crop&w=150&q=80';
|
||||
} else if (nameLower.includes('atta') || nameLower.includes('flour')) {
|
||||
price = 440;
|
||||
image = 'https://images.unsplash.com/photo-1574316071802-0d684efa7bf5?auto=format&fit=crop&w=150&q=80';
|
||||
}
|
||||
return { price, image };
|
||||
if (nameLower.includes('rice')) return 'https://images.unsplash.com/photo-1586201375761-83865001e31c?auto=format&fit=crop&w=150&q=80';
|
||||
if (nameLower.includes('oil')) return 'https://images.unsplash.com/photo-1474979266404-7eaacbcd87c5?auto=format&fit=crop&w=150&q=80';
|
||||
if (nameLower.includes('coffee')) return 'https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?auto=format&fit=crop&w=150&q=80';
|
||||
if (nameLower.includes('carrot')) return 'https://images.unsplash.com/photo-1598170845058-32b9d6a5da37?auto=format&fit=crop&w=150&q=80';
|
||||
if (nameLower.includes('ghee') || nameLower.includes('butter')) return 'https://images.unsplash.com/photo-1589985270826-4b7bb135bc9d?auto=format&fit=crop&w=150&q=80';
|
||||
if (nameLower.includes('salt')) return 'https://images.unsplash.com/photo-1626132647523-66f5bf380027?auto=format&fit=crop&w=150&q=80';
|
||||
if (nameLower.includes('atta') || nameLower.includes('flour')) return 'https://images.unsplash.com/photo-1574316071802-0d684efa7bf5?auto=format&fit=crop&w=150&q=80';
|
||||
return 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=150&q=80';
|
||||
};
|
||||
|
||||
if (rawStock.length > 0) {
|
||||
// Live stock only — no hardcoded fallback. Empty live data → empty state.
|
||||
return rawStock.map((item: any) => {
|
||||
const name = fstr(item.productname) || fstr(item.name) || 'Product Item';
|
||||
const meta = resolveMetadata(name);
|
||||
const livePrice = fnum(item.retailprice) || fnum(item.price) || fnum(item.productcost);
|
||||
return {
|
||||
sku: fstr(item.sku) || fstr(item.productsku) || 'SKU-UNKNOWN',
|
||||
sku: fstr(item.sku) || fstr(item.productsku) || `SKU-${fstr(item.productid)}` || 'SKU-UNKNOWN',
|
||||
name,
|
||||
category: fstr(item.subcategoryname) || fstr(item.categoryname) || 'Groceries / Staples',
|
||||
stockLevel: Number(item.physicalstock) || Number(item.stock) || 0,
|
||||
stockLevel: Number(item.physicalstock) || Number(item.stock) || fnum(item.closing) || 0,
|
||||
maxCapacity: Number(item.maxcapacity) || 500,
|
||||
status: (Number(item.physicalstock) || 0) < 50 ? 'Critical' : (Number(item.physicalstock) || 0) < 150 ? 'Low Stock' : 'Optimal',
|
||||
price: meta.price,
|
||||
image: meta.image
|
||||
status: (Number(item.physicalstock) || fnum(item.closing) || 0) < 50 ? 'Critical' : (Number(item.physicalstock) || fnum(item.closing) || 0) < 150 ? 'Low Stock' : 'Optimal',
|
||||
// Real price or null — never a guessed number.
|
||||
price: livePrice > 0 ? livePrice : null,
|
||||
image: fstr(item.productimage) || resolveImage(name)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// High fidelity fallback seeded catalog if live API results are empty
|
||||
return [
|
||||
{ sku: 'RICE-PN-50', name: 'Premium Ponni Rice Bag 25kg', category: 'Staples / Rice', stockLevel: Math.round(baseOrders * 1.2) || 450, maxCapacity: 1000, status: 'Optimal', price: 1400, image: 'https://images.unsplash.com/photo-1586201375761-83865001e31c?auto=format&fit=crop&w=150&q=80' },
|
||||
{ sku: 'ATTA-ASH-10', name: 'Aashirvaad Chakki Atta 10kg', category: 'Staples / Flour', stockLevel: 12, maxCapacity: 120, status: 'Critical', price: 440, image: 'https://images.unsplash.com/photo-1574316071802-0d684efa7bf5?auto=format&fit=crop&w=150&q=80' },
|
||||
{ sku: 'OIL-IDH-05', name: 'Idhayam Sesame Oil Can 5L', category: 'Groceries / Oils', stockLevel: 32, maxCapacity: 150, status: 'Low Stock', price: 340, image: 'https://images.unsplash.com/photo-1474979266404-7eaacbcd87c5?auto=format&fit=crop&w=150&q=80' },
|
||||
{ sku: 'COF-NAR-01', name: 'Narasus Filter Coffee 1kg Pack', category: 'Beverages / Coffee', stockLevel: Math.round(baseOrders * 0.5) || 120, maxCapacity: 300, status: 'Optimal', price: 195, image: 'https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?auto=format&fit=crop&w=150&q=80' },
|
||||
{ sku: 'MILK-AAV-50', name: 'Aavin Premium Pouch Milk 500ml', category: 'Dairy / Milk', stockLevel: 18, maxCapacity: 200, status: 'Critical', price: 28, image: 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=150&q=80' },
|
||||
{ sku: 'OOTY-CARROT-1KG', name: 'Ooty Fresh Quality Carrots 1kg', category: 'Fresh Produce / Veg', stockLevel: 45, maxCapacity: 100, status: 'Low Stock', price: 60, image: 'https://images.unsplash.com/photo-1598170845058-32b9d6a5da37?auto=format&fit=crop&w=150&q=80' },
|
||||
{ sku: 'TURMERIC-200G', name: 'Organic Turmeric Powder 200g', category: 'Groceries / Spices', stockLevel: 280, maxCapacity: 300, status: 'Optimal', price: 90, image: 'https://images.unsplash.com/photo-1596040033229-a9821ebd058d?auto=format&fit=crop&w=150&q=80' },
|
||||
{ sku: 'GHEE-500ML', name: 'Pure Cow Ghee 500ml', category: 'Dairy / Ghee', stockLevel: 75, maxCapacity: 250, status: 'Low Stock', price: 320, image: 'https://images.unsplash.com/photo-1589985270826-4b7bb135bc9d?auto=format&fit=crop&w=150&q=80' }
|
||||
];
|
||||
};
|
||||
|
||||
// Sync loaded stock to state
|
||||
@@ -261,29 +214,27 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
item.category.toLowerCase().includes(stockSearch.toLowerCase())
|
||||
);
|
||||
|
||||
// Customer Directory mapping (derived + live merges)
|
||||
// Customer Directory mapping (live only — no hardcoded fallback)
|
||||
const getMergedCustomers = () => {
|
||||
const rawCustomers = customersQ.data ?? [];
|
||||
if (rawCustomers.length > 0) {
|
||||
return rawCustomers.map((c: any) => ({
|
||||
return rawCustomers.map((c: any) => {
|
||||
const id =
|
||||
fnum(c.customerid) ||
|
||||
fnum(c.customerheaderid) ||
|
||||
fnum(c.userid) ||
|
||||
fnum(c.id) ||
|
||||
null;
|
||||
const spent = fnum(c.totalspent);
|
||||
return {
|
||||
id,
|
||||
name: fstr(c.fullname) || `${fstr(c.firstname)} ${fstr(c.lastname)}`.trim() || 'Customer',
|
||||
phone: fstr(c.contactno) || '—',
|
||||
email: fstr(c.email),
|
||||
address: fstr(c.address) || 'Coimbatore',
|
||||
ordersCount: Number(c.orderscount) || Math.floor(Math.random() * 8) + 1,
|
||||
totalSpent: `₹${(Number(c.totalspent) || Math.floor(Math.random() * 4000) + 500).toLocaleString('en-IN')}`,
|
||||
lastOrder: '2 Days ago'
|
||||
}));
|
||||
}
|
||||
|
||||
// High fidelity fallback customer base
|
||||
return [
|
||||
{ name: 'Meenakshi Sundaram', phone: '+91 94432 18942', address: 'Plot 4, Lakshmipuram Ext, RS Puram, Coimbatore - 641002', ordersCount: 42, totalSpent: '₹34,820.00', lastOrder: 'Today, 14:24 PM' },
|
||||
{ name: 'Senthil Kumar VSD', phone: '+91 98421 00234', address: 'Flat 2C, Whispering Palms, Avinashi Road, Peelamedu - 641004', ordersCount: 28, totalSpent: '₹22,910.00', lastOrder: 'Yesterday, 14:10 PM' },
|
||||
{ name: 'Kavitha Ramaswamy', phone: '+91 90035 88921', address: 'No 15, Cross Cut Road, Gandhipuram, Coimbatore - 641012', ordersCount: 19, totalSpent: '₹14,240.00', lastOrder: '2 Days ago' },
|
||||
{ name: 'Dr. Anand Selvapandian', phone: '+91 97890 22104', address: 'Villa 12, Sobha Elanza, Sathy Road, Saravanampatti - 641035', ordersCount: 12, totalSpent: '₹9,800.00', lastOrder: '3 Days ago' },
|
||||
{ name: 'Rajesh Subramaniam', phone: '+91 94421 88902', address: '45, West Club Road, Race Course, Coimbatore - 641018', ordersCount: 64, totalSpent: '₹84,900.00', lastOrder: 'Today, 10:15 AM' },
|
||||
{ name: 'Priya Krishnan', phone: '+91 90432 11094', address: '8C, Royal Arcade, Trichy Road, Singanallur - 641005', ordersCount: 7, totalSpent: '₹4,120.00', lastOrder: '1 Week ago' }
|
||||
];
|
||||
ordersCount: Number(c.orderscount) || 0,
|
||||
totalSpent: spent > 0 ? `₹${spent.toLocaleString('en-IN')}` : '—'
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const customersList = getMergedCustomers().filter(c =>
|
||||
@@ -292,19 +243,37 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
c.address.toLowerCase().includes(customerSearch.toLowerCase())
|
||||
);
|
||||
|
||||
// Store Alerts specific to the store
|
||||
const storeAlerts = operationalAlerts.filter(alert =>
|
||||
alert.title.toLowerCase().includes(store.name.split(' ')[0].toLowerCase()) ||
|
||||
alert.details.toLowerCase().includes(store.name.split(' ')[0].toLowerCase())
|
||||
);
|
||||
// ── Active rider fleet (live) ──────────────────────────────────────────────
|
||||
// Names + active/idle status come from getriders; battery % and last-ping
|
||||
// have no API ([R9]) so those fields are intentionally not shown.
|
||||
const activeRiders = (ridersQ.data ?? []).map((row: any) => {
|
||||
const name =
|
||||
fstr(row.fullname) ||
|
||||
`${fstr(row.firstname)} ${fstr(row.lastname)}`.trim() ||
|
||||
'Rider';
|
||||
const parts = name.split(' ').filter(Boolean);
|
||||
const initial = ((parts[0]?.[0] || '') + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase();
|
||||
return {
|
||||
name,
|
||||
initial: initial || 'R',
|
||||
status: fstr(row.starttime) ? 'Delivering' : 'Idle',
|
||||
orders: fnum(row.completed) || fnum(row.deliverycount) || 0
|
||||
};
|
||||
});
|
||||
|
||||
// ── Riders fleet data list ────────────────────────────────────────────────
|
||||
const activeRiders = [
|
||||
{ name: 'Karthikeyan Radhakrishnan', initial: 'KR', status: 'Delivering', orders: 3, battery: 92, lastPing: '2m ago' },
|
||||
{ name: 'Arun Kumar Chinnasamy', initial: 'AC', status: 'Delivering', orders: 2, battery: 48, lastPing: '10m ago' },
|
||||
{ name: 'Suresh Balasubramaniam', initial: 'SB', status: 'Idle', orders: 0, battery: 84, lastPing: 'Just now' },
|
||||
{ name: 'Manoj Kumar Gowda', initial: 'MG', status: 'Delivering', orders: 1, battery: 14, lastPing: '1m ago' }
|
||||
];
|
||||
// ── Global Master Catalogue (live) for the "Add from Catalogue" modal ──────
|
||||
const globalCatalogueItems = (masterCatalogQ.data ?? []).map((row: any) => {
|
||||
const price = fnum(row.retailprice) || fnum(row.price) || fnum(row.productcost);
|
||||
return {
|
||||
sku: fstr(row.sku) || fstr(row.productsku) || `SKU-${fstr(row.productid)}` || 'SKU-UNKNOWN',
|
||||
name: fstr(row.productname) || fstr(row.name) || 'Product Item',
|
||||
category: fstr(row.subcategoryname) || fstr(row.categoryname) || 'Catalogue',
|
||||
price: price > 0 ? price : null,
|
||||
image:
|
||||
fstr(row.productimage) ||
|
||||
'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=150&q=80'
|
||||
};
|
||||
});
|
||||
|
||||
// Actions simulation handles
|
||||
const handleReplenishSubmit = (e: React.FormEvent) => {
|
||||
@@ -359,11 +328,11 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
return;
|
||||
}
|
||||
|
||||
const itemsToAdd = GLOBAL_CATALOGUE_ITEMS.filter(item => selectedGlobalSkus.includes(item.sku)).map(item => ({
|
||||
const itemsToAdd = globalCatalogueItems.filter(item => selectedGlobalSkus.includes(item.sku)).map(item => ({
|
||||
...item,
|
||||
stockLevel: Math.floor(Math.random() * 80) + 20,
|
||||
stockLevel: 0,
|
||||
maxCapacity: 200,
|
||||
status: 'Optimal'
|
||||
status: 'Critical'
|
||||
}));
|
||||
|
||||
setLocalInventory(prev => {
|
||||
@@ -407,7 +376,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
)}
|
||||
|
||||
{/* ── Subheader Navigation Bar ── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`flex items-center ${onBack ? 'justify-between' : 'justify-end'}`}>
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-xs text-xs font-bold text-[#581c87] hover:text-[#4c1d95] bg-purple-50 hover:bg-purple-100/80 px-xl py-2 rounded-lg transition-all shadow-sm border border-purple-100 cursor-pointer"
|
||||
@@ -415,6 +385,7 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
<ArrowLeft size={14} />
|
||||
<span>Back to Registry</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-xs">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
@@ -580,10 +551,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
<CheckCircle2 size={16} />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block">OTIF Fulfillment</span>
|
||||
<p className="text-2xl font-black text-[#0f172a] mt-xs">98.2%</p>
|
||||
<div className="text-[10px] text-emerald-600 font-bold mt-1 flex items-center gap-xs">
|
||||
<span>▲ 0.4%</span>
|
||||
<span className="text-zinc-400 font-medium">vs past week</span>
|
||||
<div className="mt-xs">
|
||||
<AwaitingApi label="OTIF fulfillment" api="[R1]" compact />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -592,10 +561,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
<TrendingUp size={16} />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block">Est. Revenue</span>
|
||||
<p className="text-2xl font-black text-[#0f172a] mt-xs">₹{revenueToday.toLocaleString('en-IN')}</p>
|
||||
<div className="text-[10px] text-emerald-600 font-bold mt-1 flex items-center gap-xs">
|
||||
<span>▲ 12.4%</span>
|
||||
<span className="text-zinc-400 font-medium">growth threshold</span>
|
||||
<div className="mt-xs">
|
||||
<AwaitingApi label="Outlet revenue" api="[R1]" compact />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -615,10 +582,10 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
<Users size={16} />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block">Active Fleet</span>
|
||||
<p className="text-2xl font-black text-[#0f172a] mt-xs">4 Riders</p>
|
||||
<p className="text-2xl font-black text-[#0f172a] mt-xs">{activeRiders.length} Riders</p>
|
||||
<div className="text-[10px] text-emerald-600 font-bold mt-1 flex items-center gap-xs">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span>3 dispatches live</span>
|
||||
<span>{activeRiders.filter(r => r.status !== 'Idle').length} dispatches live</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -626,7 +593,7 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter">
|
||||
|
||||
{/* Interactive Timeline Pipeline Flow */}
|
||||
<div className="lg:col-span-2 bg-white border border-[#eceef2] rounded-2xl p-lg shadow-sm flex flex-col justify-between hover:shadow-md transition-shadow relative">
|
||||
<div className={`${canManage ? 'lg:col-span-2' : 'lg:col-span-3'} bg-white border border-[#eceef2] rounded-2xl p-lg shadow-sm flex flex-col justify-between hover:shadow-md transition-shadow relative`}>
|
||||
<div>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
@@ -639,139 +606,14 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The Pipeline Line and Nodes */}
|
||||
<div className="relative py-xl px-lg select-none my-xl">
|
||||
|
||||
{/* Flow track base line */}
|
||||
<div className="w-full bg-[#f1f5f9] h-2 rounded-full relative shadow-inner">
|
||||
{/* Glowing progress fill line */}
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-gradient-to-r from-purple-600 via-indigo-500 to-pink-500 rounded-full transition-all duration-700 shadow-[0_0_8px_rgba(99,102,241,0.5)]"
|
||||
style={{ width: `${(activeSlotIndex / 4) * 100}%` }}
|
||||
/>
|
||||
|
||||
{/* Operational Nodes */}
|
||||
<div className="absolute inset-0 flex justify-between items-center overflow-visible">
|
||||
{intervalSlots.map((slot, index) => {
|
||||
const isActive = activeSlotIndex === index;
|
||||
const isHovered = hoveredChartIndex === index;
|
||||
|
||||
// Status colors
|
||||
let dotColor = 'bg-indigo-500';
|
||||
let ringColor = 'border-indigo-100 hover:border-indigo-300';
|
||||
let rippleColor = 'bg-indigo-400';
|
||||
|
||||
if (slot.status === 'PEAK') {
|
||||
dotColor = 'bg-rose-500';
|
||||
ringColor = isActive ? 'border-rose-300 bg-rose-50/50' : 'border-rose-100 hover:border-rose-300';
|
||||
rippleColor = 'bg-rose-400';
|
||||
} else if (slot.status === 'HIGH') {
|
||||
dotColor = 'bg-amber-500';
|
||||
ringColor = isActive ? 'border-amber-300 bg-amber-50/50' : 'border-amber-100 hover:border-amber-300';
|
||||
rippleColor = 'bg-amber-400';
|
||||
} else if (slot.status === 'LOW') {
|
||||
dotColor = 'bg-emerald-500';
|
||||
ringColor = isActive ? 'border-emerald-300 bg-emerald-50/50' : 'border-emerald-100 hover:border-emerald-300';
|
||||
rippleColor = 'bg-emerald-400';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onMouseEnter={() => setHoveredChartIndex(index)}
|
||||
onMouseLeave={() => setHoveredChartIndex(null)}
|
||||
onClick={() => setHoveredChartIndex(index)}
|
||||
className="relative flex flex-col items-center cursor-pointer group z-10"
|
||||
>
|
||||
|
||||
{/* Time label above node */}
|
||||
<div className="absolute bottom-8 whitespace-nowrap text-center flex flex-col items-center">
|
||||
<span className={`text-[10px] font-black tracking-tight transition-colors ${
|
||||
isActive ? 'text-[#581c87]' : 'text-zinc-400 group-hover:text-zinc-600'
|
||||
}`}>
|
||||
{slot.time.split(' - ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Node Circle */}
|
||||
<div className={`w-8 h-8 rounded-full border-2 bg-white flex items-center justify-center transition-all duration-300 shadow-sm ${
|
||||
isActive ? 'scale-125 border-purple-600 shadow-md' : 'border-zinc-200 hover:border-purple-300'
|
||||
}`}>
|
||||
|
||||
{/* Pulse ripples if active */}
|
||||
{isActive && (
|
||||
<span className={`absolute inline-flex h-8 w-8 rounded-full ${rippleColor} opacity-25 animate-ping z-0`} />
|
||||
)}
|
||||
|
||||
{/* Inner dot */}
|
||||
<div className={`w-3.5 h-3.5 rounded-full transition-transform duration-300 ${dotColor} ${
|
||||
isActive ? 'scale-110' : 'group-hover:scale-110'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
{/* Label and dispatches below node */}
|
||||
<div className="absolute top-10 whitespace-nowrap text-center flex flex-col items-center">
|
||||
<span className={`text-[10px] font-bold tracking-tight transition-colors ${
|
||||
isActive ? 'text-[#0f172a]' : 'text-zinc-500 group-hover:text-zinc-700'
|
||||
}`}>
|
||||
{slot.label.split(' ')[0]}
|
||||
</span>
|
||||
<span className="text-[8px] font-bold text-zinc-400 uppercase tracking-wider mt-[1px]">
|
||||
{slot.orders} orders
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Intraday dispatch breakdown has no backend yet ([R10]). */}
|
||||
<div className="my-xl">
|
||||
<AwaitingApi label="Intraday dispatch breakdown" api="[R10]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Interactive Glassmorphic Stats audit drawer below chart */}
|
||||
<div className="mt-4 p-md bg-[#faf5ff] border border-[#f3e8ff] rounded-2xl flex flex-col sm:flex-row items-start sm:items-center justify-between gap-md transition-all duration-300 animate-in fade-in duration-200">
|
||||
<div className="flex items-center gap-md">
|
||||
<div className={`w-8 h-8 rounded-xl flex items-center justify-center shrink-0 shadow-sm border ${
|
||||
activeSlot.status === 'PEAK' ? 'bg-rose-50 text-rose-600 border-rose-100' :
|
||||
activeSlot.status === 'HIGH' ? 'bg-amber-50 text-amber-600 border-amber-100' :
|
||||
activeSlot.status === 'NORMAL' ? 'bg-sky-50 text-sky-600 border-sky-100' :
|
||||
'bg-emerald-50 text-emerald-600 border-emerald-100'
|
||||
}`}>
|
||||
<Clock size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block">Audit Interval</span>
|
||||
<span className="font-extrabold text-sm text-[#0f172a]">{activeSlot.label}</span>
|
||||
<span className="text-[10px] text-zinc-500 font-semibold block mt-0.5">{activeSlot.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-md sm:gap-xl select-none text-xs">
|
||||
<div className="text-right">
|
||||
<span className="text-[9px] font-bold text-zinc-400 uppercase tracking-widest block">Dispatches</span>
|
||||
<span className="font-black text-sm text-[#0f172a]">{activeSlot.orders} dispatches</span>
|
||||
</div>
|
||||
<div className="text-right border-l border-purple-100 pl-md sm:pl-xl">
|
||||
<span className="text-[9px] font-bold text-purple-400 uppercase tracking-widest block">Est. Revenue</span>
|
||||
<span className="font-black text-sm text-[#581c87]">{activeSlot.sales}</span>
|
||||
</div>
|
||||
<div className="text-right border-l border-purple-100 pl-md sm:pl-xl">
|
||||
<span className="text-[9px] font-bold text-zinc-400 uppercase tracking-widest block">Load level</span>
|
||||
<span className={`px-2 py-0.5 rounded-lg text-[9px] font-black tracking-wider inline-block mt-0.5 ${
|
||||
activeSlot.status === 'PEAK' ? 'bg-rose-100 text-rose-700' :
|
||||
activeSlot.status === 'HIGH' ? 'bg-amber-100 text-amber-700' :
|
||||
activeSlot.status === 'NORMAL' ? 'bg-sky-100 text-sky-700' :
|
||||
'bg-emerald-100 text-emerald-700'
|
||||
}`}>
|
||||
{activeSlot.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions Console */}
|
||||
{/* Quick Actions Console — management only */}
|
||||
{canManage && (
|
||||
<div className="bg-white border border-[#eceef2] rounded-2xl p-md shadow-sm space-y-md flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-xs">
|
||||
@@ -812,6 +654,7 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -823,28 +666,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
<Calendar size={15} className="text-[#581c87]" /> Past 7 Days Ledger Log
|
||||
</h3>
|
||||
|
||||
<div className="overflow-x-auto text-xs font-sans">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-[#f8fafc] border-b border-[#e2e8f0] text-zinc-400 text-[10px] uppercase font-bold tracking-wider">
|
||||
<tr>
|
||||
<th className="px-md py-sm">Day Period</th>
|
||||
<th className="px-md py-sm">Dispatches</th>
|
||||
<th className="px-md py-sm">Revenue Volume</th>
|
||||
<th className="px-md py-sm text-right">OTIF fulfillment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#f1f5f9] font-medium text-zinc-700">
|
||||
{pastDaysLog.map((dayLog, index) => (
|
||||
<tr key={index} className="hover:bg-zinc-50/50 transition">
|
||||
<td className="px-md py-md font-bold text-[#0f172a]">{dayLog.day}</td>
|
||||
<td className="px-md py-md text-zinc-650">{dayLog.orders} dispatches</td>
|
||||
<td className="px-md py-md text-[#581c87] font-black">{dayLog.sales}</td>
|
||||
<td className="px-md py-md text-right font-mono text-emerald-600 font-bold">{dayLog.rate}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Daily ledger series has no backend yet ([R2]). */}
|
||||
<AwaitingApi label="Daily ledger" api="[R2]" />
|
||||
</div>
|
||||
|
||||
{/* Live Rider fleet list */}
|
||||
@@ -853,11 +676,16 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
<h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-xs">
|
||||
<ShoppingCart size={15} className="text-[#581c87]" /> Active Rider Fleet
|
||||
</h3>
|
||||
<p className="text-zinc-405 text-[10px] font-sans mt-0.5">Live status and battery tracking of assigned riders.</p>
|
||||
<p className="text-zinc-405 text-[10px] font-sans mt-0.5">Live status of assigned riders for this tenant.</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[#f1f5f9] text-xs font-sans mt-md">
|
||||
{activeRiders.map((rider, index) => (
|
||||
{activeRiders.length === 0 ? (
|
||||
<div className="py-8 text-center text-zinc-400 font-medium">
|
||||
No active riders assigned yet.
|
||||
</div>
|
||||
) : (
|
||||
activeRiders.map((rider, index) => (
|
||||
<div key={index} className="py-2 flex justify-between items-center group">
|
||||
<div className="flex items-center gap-sm min-w-0">
|
||||
<div className="w-7 h-7 rounded-full bg-purple-50 text-[#581c87] border border-purple-100 font-black text-[10px] flex items-center justify-center shrink-0">
|
||||
@@ -872,12 +700,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManage && (
|
||||
<div className="flex items-center gap-sm text-right shrink-0">
|
||||
<div className="flex items-center gap-[2px] font-mono text-[10px] text-zinc-400">
|
||||
<Battery size={13} className={rider.battery < 20 ? 'text-rose-500' : 'text-zinc-400'} />
|
||||
<span className={rider.battery < 20 ? 'text-rose-600 font-bold' : ''}>{rider.battery}%</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => showToast(`SMS alert broadcasted to rider ${rider.name.split(' ')[0]}.`, 'success')}
|
||||
className="px-2 py-1 border border-zinc-200 rounded-lg hover:border-purple-300 text-[9px] font-bold hover:bg-purple-50/50 hover:text-[#581c87] cursor-pointer transition"
|
||||
@@ -885,8 +709,10 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
Ping
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -913,6 +739,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
|
||||
{/* Actions for Store Catalogue Management */}
|
||||
<div className="flex flex-wrap items-center gap-sm text-xs shrink-0 select-none">
|
||||
{canManage && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setImportState('idle'); setShowImportModal(true); }}
|
||||
className="px-3 py-2 bg-purple-50 text-[#581c87] hover:bg-purple-100/80 border border-purple-100 rounded-xl font-bold flex items-center gap-xs cursor-pointer transition shadow-sm"
|
||||
@@ -930,6 +758,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
</button>
|
||||
|
||||
<span className="h-6 w-[1px] bg-zinc-200 mx-xs hidden md:block" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="px-3 py-2 bg-rose-50 border border-rose-100 rounded-lg text-rose-700 font-bold">
|
||||
{inventoryList.filter(item => item.status === 'Critical').length} Critical
|
||||
@@ -959,13 +789,13 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
<th className="px-md py-sm">Est. Price</th>
|
||||
<th className="px-md py-sm">Capacity Load</th>
|
||||
<th className="px-md py-sm">Status</th>
|
||||
<th className="px-md py-sm text-right">Replenish</th>
|
||||
{canManage && <th className="px-md py-sm text-right">Replenish</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#f1f5f9] font-medium text-zinc-700">
|
||||
{inventoryList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-10 text-zinc-400 font-medium">
|
||||
<td colSpan={canManage ? 7 : 6} className="text-center py-10 text-zinc-400 font-medium">
|
||||
No product stocks found matching the search keyword.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1004,7 +834,7 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-md py-md font-bold text-zinc-800">
|
||||
₹{item.price.toLocaleString('en-IN')}
|
||||
{item.price != null ? `₹${item.price.toLocaleString('en-IN')}` : '—'}
|
||||
</td>
|
||||
<td className="px-md py-md w-44">
|
||||
<div className="flex items-center gap-sm">
|
||||
@@ -1030,6 +860,7 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
● {item.status}
|
||||
</span>
|
||||
</td>
|
||||
{canManage && (
|
||||
<td className="px-md py-md text-right">
|
||||
<button
|
||||
onClick={() => setReplenishModal({ show: true, item })}
|
||||
@@ -1042,6 +873,7 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
Replenish
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
@@ -1069,16 +901,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-sm text-xs shrink-0 select-none font-bold">
|
||||
<span className="px-3 py-1.5 bg-purple-50 text-[#581c87] border border-purple-100 rounded-lg">
|
||||
Retention Rate: 88.4%
|
||||
</span>
|
||||
<span className="px-3 py-1.5 bg-sky-50 text-sky-700 border border-sky-100 rounded-lg">
|
||||
AOV: ₹1,580
|
||||
</span>
|
||||
<span className="px-3 py-1.5 bg-emerald-50 text-emerald-700 border border-emerald-100 rounded-lg">
|
||||
CSAT Index: 4.9/5
|
||||
</span>
|
||||
<div className="shrink-0 w-full sm:w-auto sm:min-w-[18rem]">
|
||||
<AwaitingApi label="Customer analytics" api="[R11]" compact />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1139,12 +963,14 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
<td className="px-md py-md text-zinc-700 font-bold">{c.ordersCount} orders</td>
|
||||
<td className="px-md py-md text-[#581c87] font-black">{c.totalSpent}</td>
|
||||
<td className="px-md py-md text-right space-x-sm shrink-0">
|
||||
{canManage && (
|
||||
<button
|
||||
onClick={() => showToast(`Voucher promo code successfully dispatched to ${c.phone}.`, 'success')}
|
||||
className="px-2.5 py-1 border border-zinc-200 hover:border-purple-300 rounded-lg font-bold text-[10px] text-zinc-650 hover:bg-purple-50/50 hover:text-[#581c87] cursor-pointer transition"
|
||||
>
|
||||
Promo SMS
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSelectedCustomer(c)}
|
||||
className="px-2.5 py-1 bg-[#0f172a] hover:bg-zinc-800 text-white rounded-lg font-bold text-[10px] cursor-pointer transition shadow-sm"
|
||||
@@ -1350,8 +1176,13 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
Choose master items from the national database to stock and commission locally at <strong>{store.name}</strong>.
|
||||
</p>
|
||||
|
||||
{globalCatalogueItems.length === 0 ? (
|
||||
<div className="py-8 text-center text-zinc-400 font-medium">
|
||||
No catalogue products available yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-sm divide-y divide-[#f1f5f9]">
|
||||
{GLOBAL_CATALOGUE_ITEMS.map((item) => {
|
||||
{globalCatalogueItems.map((item) => {
|
||||
const isChecked = selectedGlobalSkus.includes(item.sku);
|
||||
return (
|
||||
<div
|
||||
@@ -1380,11 +1211,12 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
<p className="text-[9px] text-zinc-450 font-bold uppercase tracking-wider">{item.category} · SKU: {item.sku}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-bold text-zinc-800 shrink-0">₹{item.price}</span>
|
||||
<span className="font-bold text-zinc-800 shrink-0">{item.price != null ? `₹${item.price.toLocaleString('en-IN')}` : '—'}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-md border-t border-[#f1f5f9] flex justify-end gap-sm bg-[#f8fafc] shrink-0">
|
||||
@@ -1423,8 +1255,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-sans font-black text-base text-[#0f172a]">{selectedCustomer.name}</h4>
|
||||
<span className="inline-flex items-center gap-xs px-2 py-0.5 rounded text-[8px] bg-purple-100 text-purple-700 font-black tracking-wider uppercase mt-1">
|
||||
<UserCheck size={9} /> High Value Account
|
||||
<span className="text-[10px] text-zinc-400 font-semibold">
|
||||
{selectedCustomer.ordersCount} orders · {selectedCustomer.totalSpent} spent
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1450,7 +1282,7 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
</div>
|
||||
<div className="flex items-center gap-sm py-1 border-b border-[#f1f5f9]">
|
||||
<Mail size={13} className="text-zinc-400 shrink-0" />
|
||||
<span>{selectedCustomer.name.toLowerCase().replace(/[^a-z]/g, '')}@gmail.com</span>
|
||||
<span>{selectedCustomer.email || '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-sm py-1">
|
||||
<MapPin size={13} className="text-zinc-400 shrink-0 mt-0.5" />
|
||||
@@ -1460,7 +1292,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
</div>
|
||||
|
||||
{/* Metric grid info overlay */}
|
||||
<div className="grid grid-cols-3 gap-sm select-none">
|
||||
<div className="space-y-sm select-none">
|
||||
<div className="grid grid-cols-2 gap-sm">
|
||||
<div className="bg-[#faf5ff] border border-[#f3e8ff] p-sm rounded-xl text-center">
|
||||
<span className="text-[8px] font-black text-purple-400 uppercase tracking-widest block">Dispatches</span>
|
||||
<span className="font-black text-sm text-[#581c87] mt-xs block">{selectedCustomer.ordersCount} dispatches</span>
|
||||
@@ -1469,56 +1302,42 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
<span className="text-[8px] font-black text-sky-500 uppercase tracking-widest block">Gross spend</span>
|
||||
<span className="font-black text-sm text-sky-700 mt-xs block">{selectedCustomer.totalSpent}</span>
|
||||
</div>
|
||||
<div className="bg-emerald-50/50 border border-emerald-100 p-sm rounded-xl text-center">
|
||||
<span className="text-[8px] font-black text-emerald-500 uppercase tracking-widest block">CSAT Score</span>
|
||||
<span className="font-black text-sm text-emerald-700 mt-xs block">5.0 / 5.0</span>
|
||||
</div>
|
||||
{/* CSAT / AOV / retention have no backend yet ([R11]). */}
|
||||
<AwaitingApi label="Customer analytics" api="[R11]" compact />
|
||||
</div>
|
||||
|
||||
{/* Simulated Customer Order History ledger */}
|
||||
{/* Live Customer Order History ledger */}
|
||||
<div className="space-y-sm">
|
||||
<h5 className="font-sans font-bold text-xs text-[#0f172a] flex items-center gap-xs">
|
||||
<History size={14} className="text-[#581c87]" /> Past Interactions & Orders
|
||||
</h5>
|
||||
|
||||
{customerOrdersQ.isLoading ? (
|
||||
<p className="py-4 text-center text-[10px] text-zinc-400 font-semibold uppercase tracking-wider">Loading order history…</p>
|
||||
) : customerOrderHistory.length === 0 ? (
|
||||
<p className="py-4 text-center text-[10px] text-zinc-400 font-semibold uppercase tracking-wider">No past orders for this customer.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-[#f1f5f9] select-none text-[11px] font-semibold text-zinc-650">
|
||||
<div className="py-2.5 flex justify-between items-center">
|
||||
{customerOrderHistory.map((order, index) => (
|
||||
<div key={order.id || index} className="py-2.5 flex justify-between items-center">
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[#0f172a]">DM-ORD-2091</span>
|
||||
<p className="text-[9px] text-zinc-400 font-medium">{selectedCustomer.lastOrder}</p>
|
||||
<span className="text-[#0f172a]">{order.id}</span>
|
||||
<p className="text-[9px] text-zinc-400 font-medium">{order.date}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="font-mono text-[#581c87] font-bold">{selectedCustomer.totalSpent}</span>
|
||||
<span className="px-1.5 py-0.2 bg-emerald-50 text-emerald-600 rounded text-[7px] font-black tracking-wider block mt-0.5 uppercase">Delivered</span>
|
||||
<span className="font-mono text-[#581c87] font-bold">{order.amount}</span>
|
||||
<span className="px-1.5 py-0.2 bg-emerald-50 text-emerald-600 rounded text-[7px] font-black tracking-wider block mt-0.5 uppercase">{order.status.replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="py-2.5 flex justify-between items-center opacity-70">
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[#0f172a]">DM-ORD-1982</span>
|
||||
<p className="text-[9px] text-zinc-400 font-medium">1 Month ago</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="font-mono text-zinc-700 font-bold">₹1,240.00</span>
|
||||
<span className="px-1.5 py-0.2 bg-emerald-50 text-emerald-600 rounded text-[7px] font-black tracking-wider block mt-0.5 uppercase">Delivered</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="py-2.5 flex justify-between items-center opacity-60">
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[#0f172a]">DM-ORD-1721</span>
|
||||
<p className="text-[9px] text-zinc-400 font-medium">2 Months ago</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="font-mono text-zinc-700 font-bold">₹2,840.00</span>
|
||||
<span className="px-1.5 py-0.2 bg-emerald-50 text-emerald-600 rounded text-[7px] font-black tracking-wider block mt-0.5 uppercase">Delivered</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions in Side Drawer */}
|
||||
{/* Quick Actions in Side Drawer — management only */}
|
||||
{canManage && (
|
||||
<div className="p-lg bg-[#f8fafc] border-t border-[#e2e8f0] grid grid-cols-2 gap-sm shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -1540,6 +1359,7 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
|
||||
Issue Store Credit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
254
src/components/UserStorePage.tsx
Normal file
254
src/components/UserStorePage.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { AlertTriangle, LayoutDashboard, User, Mail, Phone, Store, ShieldCheck } from 'lucide-react';
|
||||
import {
|
||||
useFiestaTenantLocations,
|
||||
useFiestaLocationSummary,
|
||||
FIESTA_TENANT_ID,
|
||||
} from '../services/fiestaQueries';
|
||||
import { str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||
import type { AuthUser } from '../services/auth';
|
||||
import Header from './Header';
|
||||
import StoreDetailView from './StoreDetailView';
|
||||
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
||||
|
||||
interface UserStorePageProps {
|
||||
/** Returns to the login screen. */
|
||||
onLogout: () => void;
|
||||
/** The signed-in user, allocated to a single store via their applocationid. */
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
// Sidebar navigation. Add entries here as new user sections are built — each id
|
||||
// gets a matching branch in `renderSection` below.
|
||||
const NAV_ITEMS: UserNavItem[] = [
|
||||
{ id: 'console', label: 'Store Console', icon: LayoutDashboard },
|
||||
{ id: 'account', label: 'My Account', icon: User },
|
||||
];
|
||||
|
||||
type StoreShape = React.ComponentProps<typeof StoreDetailView>['store'];
|
||||
|
||||
/**
|
||||
* Landing workspace for a non-admin user. A user is allocated to exactly one
|
||||
* store (their applocationid), so this resolves that store from the tenant's
|
||||
* outlet list and renders the full store console scoped to it — no registry,
|
||||
* no other stores. Layout mirrors the admin console (fixed Header + left rail).
|
||||
*/
|
||||
export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [activeSection, setActiveSection] = useState<string>('console');
|
||||
|
||||
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
|
||||
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
||||
|
||||
const locations = locationsQ.data ?? [];
|
||||
const summaries = locSummaryQ.data ?? [];
|
||||
|
||||
// Resolve the user's store. Most tenants have exactly ONE store, so when the
|
||||
// tenant has a single location we just use it — no id matching needed. Only if
|
||||
// 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 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) ??
|
||||
null);
|
||||
|
||||
// Resolve the locationid the store console queries by. Prefer the matched
|
||||
// outlet, then the user's own locationid, then the applocationid as a last
|
||||
// resort so the console still scopes to a single store rather than all of them.
|
||||
const resolvedLocationId =
|
||||
(matchedLoc && fnum(matchedLoc.locationid)) || user.locationid || user.applocationid || 0;
|
||||
|
||||
const storeName =
|
||||
(matchedLoc && fstr(matchedLoc.locationname)) ||
|
||||
user.applocation ||
|
||||
user.locationname ||
|
||||
(resolvedLocationId ? `Store ${resolvedLocationId}` : 'Your Store');
|
||||
|
||||
const profile = {
|
||||
name: user.name,
|
||||
role: user.roleid ? roleName(user.roleid) : 'Team Member',
|
||||
email: user.email,
|
||||
};
|
||||
|
||||
const initials =
|
||||
user.name
|
||||
.split(' ')
|
||||
.map((w) => w[0])
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase() || 'NA';
|
||||
|
||||
const handleHelp = () =>
|
||||
alert('nearledaily Store User Help — contact your store administrator for access or data questions.');
|
||||
|
||||
// Build the store shape StoreDetailView expects, mirroring the App registry
|
||||
// mapping so the console renders identically to the admin's store view.
|
||||
const buildStore = (): StoreShape => {
|
||||
const sum = summaries.find((s) => s.locationid === resolvedLocationId);
|
||||
const status = matchedLoc ? fstr(matchedLoc.status) || 'Active' : 'Active';
|
||||
return {
|
||||
locationid: resolvedLocationId,
|
||||
name: storeName,
|
||||
zone: matchedLoc
|
||||
? [fstr(matchedLoc.suburb), fstr(matchedLoc.city)].filter(Boolean).join(', ') || 'Coimbatore'
|
||||
: 'Coimbatore',
|
||||
deliveries: sum?.delivered ?? 0,
|
||||
sales: `${(sum?.total ?? 0).toLocaleString('en-IN')} orders`,
|
||||
orders: Math.max(sum?.delivered ?? 0, sum?.total ?? 0),
|
||||
staff: (matchedLoc && (fstr(matchedLoc.contactno) || fstr(matchedLoc.email))) || user.name || '—',
|
||||
color: status.toLowerCase() === 'active' ? 'emerald' : 'amber',
|
||||
status,
|
||||
};
|
||||
};
|
||||
|
||||
// ── My Account: read-only profile + store binding ──────────────────────────
|
||||
const renderAccount = () => (
|
||||
<div className="max-w-2xl animate-in fade-in duration-300">
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a] mb-1">My Account</h1>
|
||||
<p className="text-zinc-500 font-sans text-xs mb-6">Your profile and the store you’re assigned to.</p>
|
||||
|
||||
<div className="bg-white border border-slate-200/70 rounded-2xl shadow-sm overflow-hidden">
|
||||
<div className="bg-gradient-to-br from-[#581c87] via-purple-800 to-purple-950 p-6 text-white flex items-center gap-4">
|
||||
<span className="w-14 h-14 rounded-full bg-white/15 ring-2 ring-white/30 flex items-center justify-center text-lg font-bold tracking-wide">
|
||||
{initials}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="font-bold text-lg truncate">{user.name}</p>
|
||||
<span className="inline-flex items-center gap-1.5 mt-1 text-[10px] font-bold uppercase tracking-wider text-purple-100 bg-white/15 border border-white/20 px-2 py-0.5 rounded-full">
|
||||
<ShieldCheck size={11} /> {profile.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="p-6 divide-y divide-slate-100 text-sm">
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<Mail size={15} className="text-slate-400 shrink-0" />
|
||||
<dt className="w-32 text-xs font-bold text-slate-400 uppercase tracking-wider">Email</dt>
|
||||
<dd className="text-slate-700 font-medium break-all">{user.email || '—'}</dd>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<Phone size={15} className="text-slate-400 shrink-0" />
|
||||
<dt className="w-32 text-xs font-bold text-slate-400 uppercase tracking-wider">Phone</dt>
|
||||
<dd className="text-slate-700 font-medium">{user.contactno || '—'}</dd>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<Store size={15} className="text-slate-400 shrink-0" />
|
||||
<dt className="w-32 text-xs font-bold text-slate-400 uppercase tracking-wider">App Location</dt>
|
||||
<dd className="text-slate-700 font-medium">{user.applocation || storeName || '—'}</dd>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<ShieldCheck size={15} className="text-slate-400 shrink-0" />
|
||||
<dt className="w-32 text-xs font-bold text-slate-400 uppercase tracking-wider">App Location ID</dt>
|
||||
<dd className="text-slate-700 font-medium font-mono">{user.applocationid ?? '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render the active section. New sidebar items add a branch here; the default
|
||||
// keeps anything not-yet-built from breaking the page.
|
||||
const renderSection = () => {
|
||||
if (activeSection === 'account') return renderAccount();
|
||||
|
||||
// The store console needs a resolved store, so gate it on the load state.
|
||||
if (locationsQ.isLoading || locSummaryQ.isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-24">
|
||||
<div className="w-7 h-7 border-2 border-[#581c87] border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-xs font-semibold text-slate-500">Loading your store…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Distinguish a real load failure from "no store assigned" — otherwise an
|
||||
// API outage would wrongly tell the user to contact their admin.
|
||||
if (locationsQ.isError || locSummaryQ.isError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="bg-white border border-slate-200/70 rounded-3xl p-10 text-center max-w-md shadow-[0_10px_40px_rgba(0,0,0,0.08)]">
|
||||
<div className="mx-auto h-16 w-16 rounded-2xl bg-rose-50 text-rose-600 ring-1 ring-rose-100 flex items-center justify-center mb-6">
|
||||
<AlertTriangle size={30} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 tracking-tight mb-3">Couldn’t load your store</h1>
|
||||
<p className="text-[15px] text-slate-500 leading-relaxed mb-6">
|
||||
We couldn’t reach the store service. Check your connection and try again.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
locationsQ.refetch();
|
||||
locSummaryQ.refetch();
|
||||
}}
|
||||
className="px-5 py-2.5 bg-[#581c87] hover:bg-purple-800 text-white text-sm font-bold rounded-xl cursor-pointer transition-colors shadow-sm"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!resolvedLocationId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="bg-white border border-slate-200/70 rounded-3xl p-10 text-center max-w-md shadow-[0_10px_40px_rgba(0,0,0,0.08)]">
|
||||
<div className="mx-auto h-16 w-16 rounded-2xl bg-amber-50 text-amber-600 ring-1 ring-amber-100 flex items-center justify-center mb-6">
|
||||
<AlertTriangle size={30} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 tracking-tight mb-3">No store assigned</h1>
|
||||
<p className="text-[15px] text-slate-500 leading-relaxed">
|
||||
Your account isn’t linked to a store yet. Please contact your administrator to be
|
||||
allocated to a store location.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// canManage=false hides write actions in the console. NOTE: this is a UI-only
|
||||
// restriction — the backend must also enforce role-based authorization on the
|
||||
// write endpoints, since a hidden button doesn't stop a direct API call.
|
||||
return <StoreDetailView store={buildStore()} canManage={false} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f8fafc] text-[#0f172a] font-sans antialiased">
|
||||
<Header
|
||||
isSidebarOpen={sidebarOpen}
|
||||
onToggleSidebar={() => setSidebarOpen((s) => !s)}
|
||||
onHelpClick={handleHelp}
|
||||
onLogoutClick={onLogout}
|
||||
profile={profile}
|
||||
/>
|
||||
|
||||
<div className="flex pt-20">
|
||||
<UserStoreSidebar
|
||||
items={NAV_ITEMS}
|
||||
activeId={activeSection}
|
||||
onSelect={setActiveSection}
|
||||
isOpen={sidebarOpen}
|
||||
/>
|
||||
|
||||
<main
|
||||
className={`flex-1 min-w-0 min-h-[calc(100vh-80px)] transition-all duration-300 ${
|
||||
sidebarOpen ? 'md:pl-64' : 'md:pl-20'
|
||||
}`}
|
||||
>
|
||||
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
||||
{renderSection()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/components/UserStoreSidebar.tsx
Normal file
62
src/components/UserStoreSidebar.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { type LucideIcon } from 'lucide-react';
|
||||
|
||||
/** One entry in the user store sidebar. Add to `UserStorePage`'s `NAV_ITEMS`. */
|
||||
export interface UserNavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
interface UserStoreSidebarProps {
|
||||
items: UserNavItem[];
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
/** Collapsed → icon-only rail; expanded → labels visible. */
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user store page's left rail. Visually identical to the admin `Sidebar`
|
||||
* (fixed, sits under the fixed Header, collapses to an icon rail) but driven by
|
||||
* a generic items list so new user sections can be added without touching this.
|
||||
*/
|
||||
export default function UserStoreSidebar({ items, activeId, onSelect, isOpen }: UserStoreSidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={`fixed left-0 top-0 h-screen bg-[#581c87] border-r border-[#4c1d95] text-white flex-col py-xl pt-24 z-40 hidden md:flex transition-all duration-300 ${
|
||||
isOpen ? 'w-64' : 'w-20'
|
||||
}`}
|
||||
>
|
||||
{/* Main Navigation Sidebar Links */}
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto px-xs">
|
||||
{items.map((item) => {
|
||||
const IconComponent = item.icon;
|
||||
const isActive = activeId === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onSelect(item.id)}
|
||||
title={item.label}
|
||||
className={`w-full flex items-center py-3 rounded-lg text-left transition-all duration-200 cursor-pointer ${
|
||||
isOpen ? 'gap-md px-md' : 'justify-center px-0'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-purple-800 text-white font-semibold' + (isOpen ? ' border-l-4 border-white' : '')
|
||||
: 'text-purple-200 hover:bg-purple-800/60 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<IconComponent size={18} className={isActive ? 'text-white' : 'text-purple-300'} />
|
||||
{isOpen && <span className="font-sans text-sm font-medium">{item.label}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
Coins
|
||||
} from 'lucide-react';
|
||||
import { useFiestaUsers, useFiestaCreateUser } from '../services/fiestaQueries';
|
||||
import { FIESTA_TENANT_ID, str as fstr, roleName } from '../services/fiestaApi';
|
||||
import { useAppRoles } from '../services/queries';
|
||||
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||
|
||||
interface UsersPanelProps {
|
||||
tenantId?: number;
|
||||
@@ -50,9 +51,46 @@ const ROLE_THEMES: Record<number, { bg: string; text: string; border: string; la
|
||||
6: { bg: 'bg-indigo-50/75', text: 'text-indigo-700', border: 'border-indigo-100', label: 'Cashier' },
|
||||
};
|
||||
|
||||
/** Cosmetic icon + blurb per role id, used to keep the add-user role cards styled. */
|
||||
const ROLE_META: Record<number, { icon: typeof ShieldAlert; desc: string }> = {
|
||||
1: { icon: ShieldAlert, desc: 'Full business access' },
|
||||
2: { icon: Shield, desc: 'Operations control' },
|
||||
3: { icon: SlidersHorizontal, desc: 'Manage store settings' },
|
||||
4: { icon: User, desc: 'Standard staff duties' },
|
||||
6: { icon: Coins, desc: 'Checkout & registers' },
|
||||
};
|
||||
|
||||
/** Fallback role choices when the app-roles API returns nothing. */
|
||||
const FALLBACK_ROLE_CHOICES = [
|
||||
{ id: 1, label: 'Owner', desc: 'Full business access', icon: ShieldAlert },
|
||||
{ id: 2, label: 'Manager', desc: 'Operations control', icon: Shield },
|
||||
{ id: 3, label: 'Admin', desc: 'Manage store settings', icon: SlidersHorizontal },
|
||||
{ id: 4, label: 'Staff', desc: 'Standard staff duties', icon: User },
|
||||
{ id: 6, label: 'Cashier', desc: 'Checkout & registers', icon: Coins },
|
||||
];
|
||||
|
||||
export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUserRole = 4 }: UsersPanelProps) {
|
||||
const usersQ = useFiestaUsers({ tenantid: tenantId, pagesize: 100 });
|
||||
const createUserMut = useFiestaCreateUser();
|
||||
const rolesQ = useAppRoles();
|
||||
|
||||
// Selectable roles for the Add User modal — driven by the live app-roles API,
|
||||
// matched to local icon/desc styling by roleid; falls back to the static list.
|
||||
const roleChoices = React.useMemo(() => {
|
||||
const rows = rolesQ.data ?? [];
|
||||
const mapped = rows
|
||||
.map((r) => {
|
||||
const id = fnum((r as Record<string, unknown>).roleid);
|
||||
const label =
|
||||
fstr((r as Record<string, unknown>).rolename) ||
|
||||
fstr((r as Record<string, unknown>).name) ||
|
||||
roleName(id);
|
||||
const meta = ROLE_META[id];
|
||||
return { id, label, desc: meta?.desc ?? '', icon: meta?.icon ?? User };
|
||||
})
|
||||
.filter((r) => r.id > 0);
|
||||
return mapped.length ? mapped : FALLBACK_ROLE_CHOICES;
|
||||
}, [rolesQ.data]);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [userRoleFilter, setUserRoleFilter] = useState<number | 'ALL'>('ALL');
|
||||
@@ -378,13 +416,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
||||
<div className="space-y-2">
|
||||
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">SELECT ACCOUNT ROLE (*)</label>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
{[
|
||||
{ id: 1, label: 'Owner', desc: 'Full business access', icon: ShieldAlert },
|
||||
{ id: 2, label: 'Manager', desc: 'Operations control', icon: Shield },
|
||||
{ id: 3, label: 'Admin', desc: 'Manage store settings', icon: SlidersHorizontal },
|
||||
{ id: 4, label: 'Staff', desc: 'Standard staff duties', icon: User },
|
||||
{ id: 6, label: 'Cashier', desc: 'Checkout & registers', icon: Coins },
|
||||
].map((r) => {
|
||||
{roleChoices.map((r) => {
|
||||
const isSelected = newUser.roleid === r.id;
|
||||
const Icon = r.icon;
|
||||
return (
|
||||
|
||||
433
src/data.ts
433
src/data.ts
@@ -1,433 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
KPICardData,
|
||||
RevenueHeatmapDay,
|
||||
LeaderboardNode,
|
||||
ProductMatrixItem,
|
||||
InventoryItem,
|
||||
OrderItem,
|
||||
ImportLog,
|
||||
OperationalAlert,
|
||||
RegionalPerformance,
|
||||
CustomerOrder
|
||||
} from './types';
|
||||
|
||||
// Dashboard Screen KPIs (Tamil Nadu Coimbatore Centric)
|
||||
export const dashboardKPIs: KPICardData[] = [
|
||||
{
|
||||
title: 'TOTAL ORDERS',
|
||||
value: '4,921',
|
||||
subtext: 'Last 24 Hours',
|
||||
sparkline: [50, 40, 60, 45, 80, 55, 65],
|
||||
badge: '1.4k'
|
||||
},
|
||||
{
|
||||
title: "TODAY'S REVENUE",
|
||||
value: '₹4.2L',
|
||||
subtext: 'Settled Funds (IST)',
|
||||
trend: '+8.1%',
|
||||
isPositive: true,
|
||||
sparkline: [35, 55, 45, 70, 60, 90, 85]
|
||||
},
|
||||
{
|
||||
title: 'MONTHLY PROFIT',
|
||||
value: '₹18.9L',
|
||||
subtext: 'EBITDA Margin 22%',
|
||||
trend: '-0.4%',
|
||||
isPositive: false,
|
||||
sparkline: [80, 75, 70, 65, 55, 60, 45]
|
||||
},
|
||||
{
|
||||
title: 'DELIVERED',
|
||||
value: '4,102',
|
||||
subtext: 'In-Full-On-Time (CBE)',
|
||||
trend: '98.2%',
|
||||
isPositive: true,
|
||||
sparkline: [60, 70, 68, 85, 78, 92, 95]
|
||||
}
|
||||
];
|
||||
|
||||
// Reports Screen KPIs (4 cards - Tamil Nadu Coimbatore Centric)
|
||||
export const reportsKPIs: KPICardData[] = [
|
||||
{
|
||||
title: 'Gross Revenue',
|
||||
value: '₹12,48,302.00',
|
||||
subtext: 'Vs. Previous Quarter: ₹10.9L',
|
||||
trend: '+14.2%',
|
||||
isPositive: true,
|
||||
sparkline: [40, 48, 52, 65, 72, 78, 90]
|
||||
},
|
||||
{
|
||||
title: 'Net Margin %',
|
||||
value: '28.4%',
|
||||
subtext: 'Target threshold: 30.0%',
|
||||
trend: '-0.8%',
|
||||
isPositive: false,
|
||||
sparkline: [75, 72, 68, 64, 60, 62, 58]
|
||||
},
|
||||
{
|
||||
title: 'Active Inventory',
|
||||
value: '48.2k',
|
||||
subtext: 'Groceries units across TN hubs',
|
||||
trend: '+5.1%',
|
||||
isPositive: true,
|
||||
sparkline: [55, 58, 61, 60, 64, 69, 73]
|
||||
},
|
||||
{
|
||||
title: 'Staff Efficiency',
|
||||
value: '94.1%',
|
||||
subtext: 'Active utilization rate (CBE)',
|
||||
trend: '+2.4%',
|
||||
isPositive: true,
|
||||
sparkline: [82, 85, 84, 88, 91, 93, 94]
|
||||
}
|
||||
];
|
||||
|
||||
// Heatmap Data (Hourly Revenue Distribution)
|
||||
export const heatmapData: RevenueHeatmapDay[] = [
|
||||
{
|
||||
day: 'Mon',
|
||||
hours: {
|
||||
'08:00': 1.2,
|
||||
'10:00': 4.5,
|
||||
'12:00': 8.9,
|
||||
'14:00': 5.2,
|
||||
'16:00': 4.8,
|
||||
'18:00': 9.1,
|
||||
'20:00': 3.2
|
||||
}
|
||||
},
|
||||
{
|
||||
day: 'Tue',
|
||||
hours: {
|
||||
'08:00': 1.1,
|
||||
'10:00': 3.8,
|
||||
'12:00': 5.9,
|
||||
'14:00': 7.2,
|
||||
'16:00': 4.1,
|
||||
'18:00': 8.4,
|
||||
'20:00': 2.9
|
||||
}
|
||||
},
|
||||
{
|
||||
day: 'Wed',
|
||||
hours: {
|
||||
'08:00': 1.5,
|
||||
'10:00': 4.2,
|
||||
'12:00': 9.4,
|
||||
'14:00': 10.1,
|
||||
'16:00': 5.1,
|
||||
'18:00': 4.2,
|
||||
'20:00': 1.2
|
||||
}
|
||||
},
|
||||
{
|
||||
day: 'Thu',
|
||||
hours: {
|
||||
'08:00': 0.9,
|
||||
'10:00': 2.2,
|
||||
'12:00': 4.8,
|
||||
'14:00': 5.5,
|
||||
'16:00': 8.2,
|
||||
'18:00': 7.9,
|
||||
'20:00': 3.4
|
||||
}
|
||||
},
|
||||
{
|
||||
day: 'Fri',
|
||||
hours: {
|
||||
'08:00': 2.2,
|
||||
'10:00': 7.8,
|
||||
'12:00': 11.4,
|
||||
'14:00': 12.2,
|
||||
'16:00': 9.8,
|
||||
'18:00': 10.5,
|
||||
'20:00': 5.2
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Leaderboards (Top Performing State & Coimbatore Nodes)
|
||||
export const globalLeaderboard: LeaderboardNode[] = [
|
||||
{ rank: '01', name: 'Chennai - T-Nagar', percentage: 94, revenue: '₹2.4L' },
|
||||
{ rank: '02', name: 'Coimbatore - RS Puram Main', percentage: 88, revenue: '₹2.18L' },
|
||||
{ rank: '03', name: 'Madurai - KK Nagar', percentage: 82, revenue: '₹1.94L' },
|
||||
{ rank: '04', name: 'Trichy - Thillai Nagar', percentage: 76, revenue: '₹1.81L' }
|
||||
];
|
||||
|
||||
export const regionalLeaderboard: LeaderboardNode[] = [
|
||||
{ rank: '01', name: 'RS Puram - Main St', percentage: 94, revenue: '₹2.4L' },
|
||||
{ rank: '02', name: 'Gandhipuram Cross Cut', percentage: 88, revenue: '₹2.1L' },
|
||||
{ rank: '03', name: 'Peelamedu Avinashi Rd', percentage: 82, revenue: '₹1.9L' },
|
||||
{ rank: '04', name: 'Town Hall Bazaar', percentage: 76, revenue: '₹1.8L' }
|
||||
];
|
||||
|
||||
// Product Matrix (Detailed Groceries/Retail Items Matrix)
|
||||
export const initialProducts: ProductMatrixItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Ponni Raw Rice Premium 10kg',
|
||||
sku: 'PONNI-RICE-10K',
|
||||
unitsSold: 12402,
|
||||
revenue: 868140,
|
||||
stockStatus: 'Healthy',
|
||||
trend: 'up',
|
||||
image: 'https://images.unsplash.com/photo-1586201375761-83865001e31c?auto=format&fit=crop&w=150&q=80',
|
||||
category: 'Staples / Rice',
|
||||
exposure: '34/42 Stores',
|
||||
verified: true
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Idhayam Pure Sesame Oil 1L',
|
||||
sku: 'IDH-SESAME-1L',
|
||||
unitsSold: 8912,
|
||||
revenue: 1480000,
|
||||
stockStatus: 'Low Stock',
|
||||
trend: 'flat',
|
||||
image: 'https://images.unsplash.com/photo-1474979266404-7eaacbcd87c5?auto=format&fit=crop&w=150&q=80',
|
||||
category: 'Groceries / Oils',
|
||||
exposure: '40/42 Stores',
|
||||
verified: true
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Narasus Filter Coffee Powder 500g',
|
||||
sku: 'NARASUS-COF-500',
|
||||
unitsSold: 15221,
|
||||
revenue: 456630,
|
||||
stockStatus: 'Healthy',
|
||||
trend: 'up',
|
||||
image: 'https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?auto=format&fit=crop&w=150&q=80',
|
||||
category: 'Beverages / Coffee',
|
||||
exposure: '42/42 Stores',
|
||||
verified: true
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Ooty Fresh Quality Carrots 1kg',
|
||||
sku: 'OOTY-CARROT-1KG',
|
||||
unitsSold: 4120,
|
||||
revenue: 123600,
|
||||
stockStatus: 'Critical',
|
||||
trend: 'down',
|
||||
image: 'https://images.unsplash.com/photo-1598170845058-32b9d6a5da37?auto=format&fit=crop&w=150&q=80',
|
||||
category: 'Fresh Produce / Veg',
|
||||
exposure: '32/42 Stores',
|
||||
verified: false
|
||||
}
|
||||
];
|
||||
|
||||
// Inventory lists (Coimbatore Warehouse Region Centric)
|
||||
export const initialInventory: InventoryItem[] = [
|
||||
{
|
||||
sku: 'RICE-PN-50',
|
||||
name: 'Premium Ponni Rice Bag 25kg',
|
||||
warehouse: 'RS Puram Hub (CBE-01)',
|
||||
stockLevel: 1402,
|
||||
maxCapacity: 2000,
|
||||
status: 'Optimal',
|
||||
region: 'CBE-NORTH'
|
||||
},
|
||||
{
|
||||
sku: 'ATTA-ASH-10',
|
||||
name: 'Aashirvaad Chakki Atta 10kg',
|
||||
warehouse: 'Gandhipuram Warehouse (CBE-04)',
|
||||
stockLevel: 12,
|
||||
maxCapacity: 100,
|
||||
status: 'Critical',
|
||||
region: 'CBE-SOUTH'
|
||||
},
|
||||
{
|
||||
sku: 'OIL-IDH-05',
|
||||
name: 'Idhayam Sesame Oil Can 5L',
|
||||
warehouse: 'Peelamedu Sort Facility (CBE-09)',
|
||||
stockLevel: 45,
|
||||
maxCapacity: 120,
|
||||
status: 'Low Stock',
|
||||
region: 'CBE-EAST'
|
||||
},
|
||||
{
|
||||
sku: 'COF-NAR-01',
|
||||
name: 'Narasus Filter Coffee 1kg Pack',
|
||||
warehouse: 'Pollachi Road Depot (CBE-15)',
|
||||
stockLevel: 890,
|
||||
maxCapacity: 1000,
|
||||
status: 'Optimal',
|
||||
region: 'CBE-WEST'
|
||||
},
|
||||
{
|
||||
sku: 'MILK-AAV-50',
|
||||
name: 'Aavin Premium Pouch Milk 500ml',
|
||||
warehouse: 'Tiruppur Junction Depot (TPR-02)',
|
||||
stockLevel: 18,
|
||||
maxCapacity: 150,
|
||||
status: 'Low Stock',
|
||||
region: 'TIRUPPUR'
|
||||
}
|
||||
];
|
||||
|
||||
// Order Items list
|
||||
export const initialOrders: OrderItem[] = [
|
||||
{
|
||||
id: '#ORD-902341',
|
||||
store: 'RS Puram Terminal',
|
||||
amount: 12490.00,
|
||||
time: '14:22 PM (IST)',
|
||||
status: 'SHIPPED'
|
||||
},
|
||||
{
|
||||
id: '#ORD-902339',
|
||||
store: 'Gandhipuram Outlet 2',
|
||||
amount: 840.50,
|
||||
time: '13:05 PM (IST)',
|
||||
status: 'FLAGGED'
|
||||
},
|
||||
{
|
||||
id: '#ORD-902312',
|
||||
store: 'Peelamedu Center',
|
||||
amount: 24200.00,
|
||||
time: '11:15 AM (IST)',
|
||||
status: 'SHIPPED'
|
||||
},
|
||||
{
|
||||
id: '#ORD-901995',
|
||||
store: 'Saravanampatti Hub',
|
||||
amount: 5120.00,
|
||||
time: '09:40 AM (IST)',
|
||||
status: 'PROCESSING'
|
||||
}
|
||||
];
|
||||
|
||||
// Import log items list
|
||||
export const initialImportLogs: ImportLog[] = [
|
||||
{
|
||||
timestamp: 'May 31, 2026 09:12',
|
||||
batchRef: '#IMP_0921_A',
|
||||
type: 'Inventory Sync',
|
||||
source: 'ERP Export',
|
||||
result: 'SUCCESS (421 Rows)',
|
||||
status: 'SUCCESS'
|
||||
},
|
||||
{
|
||||
timestamp: 'May 30, 2026 17:45',
|
||||
batchRef: '#IMP_0920_Z',
|
||||
type: 'Catalogue Meta',
|
||||
source: 'Marketing API',
|
||||
result: 'FAILED (Invalid API Key)',
|
||||
status: 'FAILED'
|
||||
},
|
||||
{
|
||||
timestamp: 'May 28, 2026 10:30',
|
||||
batchRef: '#IMP_0899_C',
|
||||
type: 'Logistics Matrix',
|
||||
source: 'Manual Upload',
|
||||
result: 'SUCCESS (1,500 Rows)',
|
||||
status: 'SUCCESS'
|
||||
}
|
||||
];
|
||||
|
||||
// Operational Alerts (Tamil Nadu Coimbatore Centric)
|
||||
export const operationalAlerts: OperationalAlert[] = [
|
||||
{
|
||||
id: 'alert-1',
|
||||
type: 'critical',
|
||||
title: 'Stock Critical: RS Puram Store',
|
||||
details: 'Fresh dairy and milk category inventory fell below 5% at 08:24 AM.',
|
||||
time: '08:24 AM'
|
||||
},
|
||||
{
|
||||
id: 'alert-2',
|
||||
type: 'warning',
|
||||
title: 'High Latency Warning',
|
||||
details: 'Coimbatore Hub Server API latency spiked to 640ms in grocery billing services.',
|
||||
time: '07:15 AM'
|
||||
},
|
||||
{
|
||||
id: 'alert-3',
|
||||
type: 'info',
|
||||
title: 'Node Rebalance Completed',
|
||||
details: 'Peelamedu sort facility rebalanced 4,500 units of Ponni Rice to Gandhipuram nodes.',
|
||||
time: '05:30 AM'
|
||||
}
|
||||
];
|
||||
|
||||
// Regional Performance (Coimbatore Zones)
|
||||
export const regionalPerformances: RegionalPerformance[] = [
|
||||
{ region: 'Coimbatore North', sales: '₹42.4L', revenueValue: 42.4, maxTarget: 50 },
|
||||
{ region: 'Coimbatore South', sales: '₹38.2L', revenueValue: 38.2, maxTarget: 50 },
|
||||
{ region: 'Coimbatore East & Peelamedu', sales: '₹29.1L', revenueValue: 29.1, maxTarget: 50 },
|
||||
{ region: 'Tiruppur Regional Hub', sales: '₹14.5L', revenueValue: 14.5, maxTarget: 50 }
|
||||
];
|
||||
|
||||
// Initial Customer Orders Dataset
|
||||
export const initialCustomerOrders: CustomerOrder[] = [
|
||||
{
|
||||
id: 'DM-ORD-2091',
|
||||
customerName: 'Meenakshi Sundaram',
|
||||
phone: '+91 94432 18942',
|
||||
address: 'Plot 4, Lakshmipuram Ext, RS Puram, Coimbatore - 641002',
|
||||
items: [
|
||||
{ name: 'Ponni Raw Rice Premium 10kg', quantity: 1, price: 680 },
|
||||
{ name: 'Idhayam Pure Sesame Oil 1L', quantity: 2, price: 340 },
|
||||
{ name: 'Narasus Filter Coffee Powder 500g', quantity: 1, price: 195 }
|
||||
],
|
||||
amount: 1555,
|
||||
time: '14:24 PM',
|
||||
status: 'OUT_FOR_DELIVERY',
|
||||
assignedRider: 'Karthikeyan Radhakrishnan',
|
||||
hub: 'RS Puram Hub'
|
||||
},
|
||||
{
|
||||
id: 'DM-ORD-2092',
|
||||
customerName: 'Senthil Kumar VSD',
|
||||
phone: '+91 98421 00234',
|
||||
address: 'Flat 2C, Whispering Palms, Avinashi Road, Peelamedu - 641004',
|
||||
items: [
|
||||
{ name: 'Premium Ponni Rice Bag 25kg', quantity: 1, price: 1600 },
|
||||
{ name: 'Aavin Premium Milk 500ml', quantity: 4, price: 28 },
|
||||
{ name: 'Ooty Fresh Quality Carrots 1kg', quantity: 2, price: 60 }
|
||||
],
|
||||
amount: 1832,
|
||||
time: '14:10 PM',
|
||||
status: 'DELIVERED',
|
||||
assignedRider: 'Arun Kumar Chinnasamy',
|
||||
hub: 'Peelamedu Sort Center'
|
||||
},
|
||||
{
|
||||
id: 'DM-ORD-2093',
|
||||
customerName: 'Kavitha Ramaswamy',
|
||||
phone: '+91 90035 88921',
|
||||
address: 'No 15, Cross Cut Road, Gandhipuram, Coimbatore - 641012',
|
||||
items: [
|
||||
{ name: 'Aashirvaad Chakki Atta 10kg', quantity: 2, price: 440 },
|
||||
{ name: 'Organic Turmeric Powder 200g', quantity: 1, price: 90 },
|
||||
{ name: 'Pure Cow Ghee 500ml', quantity: 1, price: 320 }
|
||||
],
|
||||
amount: 1290,
|
||||
time: '13:50 PM',
|
||||
status: 'CONFIRMED',
|
||||
assignedRider: 'Suresh Balasubramaniam',
|
||||
hub: 'Gandhipuram Cross Rd'
|
||||
},
|
||||
{
|
||||
id: 'DM-ORD-2094',
|
||||
customerName: 'Dr. Anand Selvapandian',
|
||||
phone: '+91 97890 22104',
|
||||
address: 'Villa 12, Sobha Elanza, Sathy Road, Saravanampatti - 641035',
|
||||
items: [
|
||||
{ name: 'Narasus Filter Coffee Powder 500g', quantity: 2, price: 195 },
|
||||
{ name: 'Idhayam Pure Sesame Oil 1L', quantity: 1, price: 340 }
|
||||
],
|
||||
amount: 730,
|
||||
time: '13:15 PM',
|
||||
status: 'PROCESSING',
|
||||
assignedRider: 'Pending Assignment',
|
||||
hub: 'Saravanampatti Store CBE'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -12,4 +12,73 @@
|
||||
--spacing-xl: 32px;
|
||||
--spacing-gutter: 20px;
|
||||
--spacing-container-margin: 24px;
|
||||
|
||||
/* Intermediate color shades used across the app. Tailwind's default palette
|
||||
only ships shades in steps of 100, but the UI references finer steps
|
||||
(e.g. -450, -650, -755, -505). Each is interpolated in OKLab from its
|
||||
adjacent standard shade(s) so utilities like `bg-purple-650` /
|
||||
`text-slate-450` resolve to a real color instead of generating nothing
|
||||
(which left e.g. the "Add Team Member" button's white text on a
|
||||
transparent background — invisible). */
|
||||
|
||||
/* slate */
|
||||
--color-slate-55: color-mix(in oklab, #f8fafc 90%, #f1f5f9);
|
||||
--color-slate-150: color-mix(in oklab, #f1f5f9, #e2e8f0);
|
||||
--color-slate-350: color-mix(in oklab, #cbd5e1, #94a3b8);
|
||||
--color-slate-405: color-mix(in oklab, #94a3b8 95%, #64748b);
|
||||
--color-slate-450: color-mix(in oklab, #94a3b8, #64748b);
|
||||
--color-slate-455: color-mix(in oklab, #94a3b8 45%, #64748b);
|
||||
--color-slate-505: color-mix(in oklab, #64748b 95%, #475569);
|
||||
--color-slate-650: color-mix(in oklab, #475569, #334155);
|
||||
--color-slate-750: color-mix(in oklab, #334155, #1e293b);
|
||||
--color-slate-850: color-mix(in oklab, #1e293b, #0f172a);
|
||||
|
||||
/* zinc */
|
||||
--color-zinc-55: color-mix(in oklab, #fafafa 90%, #f4f4f5);
|
||||
--color-zinc-150: color-mix(in oklab, #f4f4f5, #e4e4e7);
|
||||
--color-zinc-205: color-mix(in oklab, #e4e4e7 95%, #d4d4d8);
|
||||
--color-zinc-405: color-mix(in oklab, #a1a1aa 95%, #71717a);
|
||||
--color-zinc-450: color-mix(in oklab, #a1a1aa, #71717a);
|
||||
--color-zinc-455: color-mix(in oklab, #a1a1aa 45%, #71717a);
|
||||
--color-zinc-505: color-mix(in oklab, #71717a 95%, #52525b);
|
||||
--color-zinc-550: color-mix(in oklab, #71717a, #52525b);
|
||||
--color-zinc-555: color-mix(in oklab, #71717a 45%, #52525b);
|
||||
--color-zinc-650: color-mix(in oklab, #52525b, #3f3f46);
|
||||
--color-zinc-655: color-mix(in oklab, #52525b 45%, #3f3f46);
|
||||
--color-zinc-750: color-mix(in oklab, #3f3f46, #27272a);
|
||||
--color-zinc-755: color-mix(in oklab, #3f3f46 45%, #27272a);
|
||||
--color-zinc-805: color-mix(in oklab, #27272a 95%, #18181b);
|
||||
|
||||
/* stone */
|
||||
--color-stone-750: color-mix(in oklab, #44403c, #292524);
|
||||
|
||||
/* purple */
|
||||
--color-purple-250: color-mix(in oklab, #e9d5ff, #d8b4fe);
|
||||
--color-purple-650: color-mix(in oklab, #9333ea, #7e22ce);
|
||||
--color-purple-750: color-mix(in oklab, #7e22ce, #6b21a8);
|
||||
--color-purple-755: color-mix(in oklab, #7e22ce 45%, #6b21a8);
|
||||
--color-purple-955: color-mix(in oklab, #3b0764 90%, black);
|
||||
|
||||
/* emerald */
|
||||
--color-emerald-250: color-mix(in oklab, #a7f3d0, #6ee7b7);
|
||||
--color-emerald-450: color-mix(in oklab, #34d399, #10b981);
|
||||
--color-emerald-455: color-mix(in oklab, #34d399 45%, #10b981);
|
||||
--color-emerald-505: color-mix(in oklab, #10b981 95%, #059669);
|
||||
--color-emerald-650: color-mix(in oklab, #059669, #047857);
|
||||
--color-emerald-750: color-mix(in oklab, #047857, #065f46);
|
||||
|
||||
/* rose */
|
||||
--color-rose-505: color-mix(in oklab, #f43f5e 95%, #e11d48);
|
||||
--color-rose-650: color-mix(in oklab, #e11d48, #be123c);
|
||||
--color-rose-750: color-mix(in oklab, #be123c, #9f1239);
|
||||
|
||||
/* amber */
|
||||
--color-amber-750: color-mix(in oklab, #b45309, #92400e);
|
||||
--color-amber-955: color-mix(in oklab, #451a03 90%, black);
|
||||
|
||||
/* indigo */
|
||||
--color-indigo-650: color-mix(in oklab, #4f46e5, #4338ca);
|
||||
|
||||
/* orange */
|
||||
--color-orange-850: color-mix(in oklab, #9a3412, #7c2d12);
|
||||
}
|
||||
|
||||
233
src/services/auth.ts
Normal file
233
src/services/auth.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Login / authentication client.
|
||||
*
|
||||
* The backend login endpoint is the ONE thing that still needs confirming — see
|
||||
* the CONFIG block below. Everything else (request shape, response parsing,
|
||||
* role mapping, error handling) is wired and ready. Once the four CONFIG values
|
||||
* match the real API, the login form verifies credentials and gates entry.
|
||||
*
|
||||
* Requests are routed through the existing Vite dev proxy so no secret/CORS
|
||||
* concern reaches the browser (see vite.config.ts: `/fiesta/*` → fiesta.nearle.app,
|
||||
* `/hasura/*` → api.workolik.com). If the login route lives on a different host,
|
||||
* add a proxy entry there and point LOGIN_ENDPOINT at it.
|
||||
*/
|
||||
|
||||
import { firstRow, num, str, type Row } from './fiestaApi';
|
||||
|
||||
// ── Backend login config ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fiesta application login. Routed through the Vite `/fiesta` proxy →
|
||||
* https://fiesta.nearle.app/live/api/v1/web/users/applogin.
|
||||
* Observed shape:
|
||||
* request: { authname: <email>, password: <password>, configid: 1, userfcmtoken: null }
|
||||
* failure: { code: 409, message: "Invalid Email", status: false }
|
||||
* success: status !== false (Fiesta envelope, optionally with `details`)
|
||||
*/
|
||||
const LOGIN_ENDPOINT = '/fiesta/live/api/v1/web/users/applogin';
|
||||
|
||||
/** Request body field names the endpoint expects for the credentials. */
|
||||
const REQUEST_FIELDS = {
|
||||
email: 'authname',
|
||||
password: 'password',
|
||||
} as const;
|
||||
|
||||
/** Extra fields the applogin endpoint expects alongside the credentials. */
|
||||
const EXTRA_FIELDS = {
|
||||
configid: 1,
|
||||
userfcmtoken: null,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Field names read from the user record (login response `details`, else the
|
||||
* tenant user list). Verified against the live `users/getallusers` response.
|
||||
*/
|
||||
const RESPONSE_FIELDS = {
|
||||
roleid: 'roleid',
|
||||
firstname: 'firstname',
|
||||
fullname: 'fullname',
|
||||
email: 'email',
|
||||
contactno: 'contactno',
|
||||
userid: 'userid',
|
||||
// Store binding: a non-admin user is allocated to an app-location via
|
||||
// applocationid; `applocation` is its human-readable name (e.g. "Coimbatore").
|
||||
// locationid/locationname are captured when present (often 0/absent on the
|
||||
// user record — the outlet is resolved from the tenant locations list).
|
||||
applocationid: 'applocationid',
|
||||
applocation: 'applocation',
|
||||
locationid: 'locationid',
|
||||
locationname: 'locationname',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* roleids that land on the ADMIN dashboard; everyone else lands on the user page.
|
||||
* From fiestaApi.roleName(): 1 = Owner, 2 = Manager, 3 = Admin, 4 = Staff,
|
||||
* 5 = Rider, 6 = Cashier.
|
||||
*/
|
||||
const ADMIN_ROLE_IDS = new Set<number>([1, 3]);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type LoginRole = 'admin' | 'user';
|
||||
|
||||
export interface AuthUser {
|
||||
role: LoginRole;
|
||||
name: string;
|
||||
email: string;
|
||||
userid?: number;
|
||||
roleid?: number;
|
||||
/** Phone number on the user record. */
|
||||
contactno?: string;
|
||||
/** The app-location this user is allocated to. */
|
||||
applocationid?: number;
|
||||
/** App-location / zone name on the user record (e.g. "Coimbatore"). */
|
||||
applocation?: string;
|
||||
/** Outlet/location id, when the record carries it directly (often 0). */
|
||||
locationid?: number;
|
||||
/** Outlet display name, when the record carries it directly. */
|
||||
locationname?: string;
|
||||
}
|
||||
|
||||
/** Map a numeric roleid to the workspace the user is allowed into. */
|
||||
export function roleFromRoleId(roleid: number): LoginRole {
|
||||
return ADMIN_ROLE_IDS.has(roleid) ? 'admin' : 'user';
|
||||
}
|
||||
|
||||
/** Build a friendly display name from the response, falling back to the email. */
|
||||
function displayName(row: Record<string, unknown>, email: string): string {
|
||||
const full = str(row[RESPONSE_FIELDS.fullname]).trim();
|
||||
if (full) return full;
|
||||
const first = str(row[RESPONSE_FIELDS.firstname]).trim();
|
||||
if (first) return first;
|
||||
// Fallback: derive from the email's local part.
|
||||
return (
|
||||
email
|
||||
.split('@')[0]
|
||||
.split(/[._-]+/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ') || 'Account'
|
||||
);
|
||||
}
|
||||
|
||||
/** The verified-login response: the user record (if any) + the resolved email. */
|
||||
export interface LoginResult {
|
||||
/** User record from the login response `details`, or null if it carried none. */
|
||||
row: Row | null;
|
||||
/** Whether `row` already includes the role (so no tenant-list lookup is needed). */
|
||||
hasRole: boolean;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST the credentials to the Fiesta web-login endpoint. Resolves with the raw
|
||||
* user record on success; throws an Error with a user-facing message on invalid
|
||||
* credentials or any failure. Role resolution is left to the caller so the
|
||||
* (optional) tenant-user lookup can go through React Query — see `useLogin`.
|
||||
*/
|
||||
export async function loginRequest(email: string, password: string): Promise<LoginResult> {
|
||||
const trimmedEmail = email.trim();
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(LOGIN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
[REQUEST_FIELDS.email]: trimmedEmail,
|
||||
[REQUEST_FIELDS.password]: password,
|
||||
...EXTRA_FIELDS,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
throw new Error('Unable to reach the login service. Check your connection and try again.');
|
||||
}
|
||||
|
||||
// Parse the JSON body (may be absent on some error responses).
|
||||
const json = (await res.json().catch(() => null)) as
|
||||
| { code?: number; status?: boolean; message?: string; details?: unknown }
|
||||
| null;
|
||||
|
||||
// Failure: HTTP error, or the Fiesta `status: false` envelope (e.g. wrong
|
||||
// email/password → { code: 409, message: "Invalid Email", status: false }).
|
||||
if (!res.ok || (json && json.status === false)) {
|
||||
throw new Error(json?.message?.trim() || 'Incorrect email or password.');
|
||||
}
|
||||
|
||||
const row = firstRow<Row>(json);
|
||||
const resolvedEmail = (row && str(row[RESPONSE_FIELDS.email]).trim()) || trimmedEmail;
|
||||
return { row, hasRole: Boolean(row && row[RESPONSE_FIELDS.roleid] != null), email: resolvedEmail };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the email/authname exists and is registered by sending email and configid: 1.
|
||||
* Returns true if the email exists, false if it is invalid.
|
||||
*/
|
||||
export async function checkEmailRequest(email: string): Promise<boolean> {
|
||||
const trimmedEmail = email.trim();
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(LOGIN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
[REQUEST_FIELDS.email]: trimmedEmail,
|
||||
...EXTRA_FIELDS,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
throw new Error('Unable to reach the login service. Check your connection and try again.');
|
||||
}
|
||||
|
||||
const json = (await res.json().catch(() => null)) as
|
||||
| { code?: number; status?: boolean; message?: string }
|
||||
| null;
|
||||
|
||||
// A 409 Invalid Email code means the email does not exist.
|
||||
if (json && json.status === false && (json.code === 409 || json.message?.trim().toLowerCase() === 'invalid email')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Any other result (such as 403 Unauthorized email) means the email is registered.
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/** Find a user in the tenant user list by email or authname (case-insensitive). */
|
||||
export function matchTenantUser(users: Row[], email: string): Row | null {
|
||||
const target = email.toLowerCase();
|
||||
return (
|
||||
users.find(
|
||||
(u) =>
|
||||
str(u[RESPONSE_FIELDS.email]).toLowerCase() === target ||
|
||||
str(u.authname).toLowerCase() === target,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Assemble the final AuthUser (role + identity) from a resolved user record. */
|
||||
export function buildAuthUser(row: Row | null, email: string): AuthUser {
|
||||
const roleid = row ? num(row[RESPONSE_FIELDS.roleid]) : 0;
|
||||
const applocation = row ? str(row[RESPONSE_FIELDS.applocation]).trim() : '';
|
||||
const locationname = row ? str(row[RESPONSE_FIELDS.locationname]).trim() : '';
|
||||
const contactno = row ? str(row[RESPONSE_FIELDS.contactno]).trim() : '';
|
||||
return {
|
||||
role: roleFromRoleId(roleid),
|
||||
name: displayName(row ?? {}, email),
|
||||
email,
|
||||
userid: row && row[RESPONSE_FIELDS.userid] != null ? num(row[RESPONSE_FIELDS.userid]) : undefined,
|
||||
roleid,
|
||||
contactno: contactno || undefined,
|
||||
applocationid:
|
||||
row && row[RESPONSE_FIELDS.applocationid] != null ? num(row[RESPONSE_FIELDS.applocationid]) : undefined,
|
||||
applocation: applocation || undefined,
|
||||
locationid: row && row[RESPONSE_FIELDS.locationid] != null ? num(row[RESPONSE_FIELDS.locationid]) : undefined,
|
||||
locationname: locationname || undefined,
|
||||
};
|
||||
}
|
||||
@@ -183,6 +183,28 @@ export async function getOrders(opts: {
|
||||
);
|
||||
}
|
||||
|
||||
/** /orders/getorderdetails?orderheaderid= — line items for a single order. */
|
||||
export async function getOrderDetails(orderheaderid: number | string): Promise<Row[]> {
|
||||
return toRows(await fiestaGet('orders/getorderdetails', { orderheaderid }));
|
||||
}
|
||||
|
||||
/** /orders/getorders?customerid=&status=&pageno=&pagesize= — one customer's order history. */
|
||||
export async function getCustomerOrders(opts: {
|
||||
customerid: number | string;
|
||||
status?: string;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}): Promise<Row[]> {
|
||||
return toRows(
|
||||
await fiestaGet('orders/getorders', {
|
||||
customerid: opts.customerid,
|
||||
status: opts.status ?? '',
|
||||
pageno: opts.pageno ?? 1,
|
||||
pagesize: opts.pagesize ?? 20,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// DELIVERIES
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -248,6 +270,48 @@ export async function getDeliveryInsight(tenantid: number): Promise<Row[]> {
|
||||
return toRows(await fiestaGet('deliveries/getdeliveryinsight', { tenantid }));
|
||||
}
|
||||
|
||||
/** /deliveries/getdeliveryreport?tenantid=&applocationid=&partnerid=&userid=&fromdate=&todate= —
|
||||
* deliveries financial report summary (per the endpoint sheet). */
|
||||
export async function getDeliveryReport(opts: {
|
||||
tenantid: number;
|
||||
applocationid?: number;
|
||||
partnerid?: number;
|
||||
userid?: number;
|
||||
fromdate: string;
|
||||
todate: string;
|
||||
}): Promise<Row[]> {
|
||||
return toRows(
|
||||
await fiestaGet('deliveries/getdeliveryreport', {
|
||||
tenantid: opts.tenantid,
|
||||
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
|
||||
partnerid: opts.partnerid,
|
||||
userid: opts.userid,
|
||||
fromdate: opts.fromdate,
|
||||
todate: opts.todate,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** /partners/getfleetsummary?applocationid=&partnerid=&tenantid=&fromdate=&todate= —
|
||||
* fleet rider summary metrics (per the endpoint sheet). */
|
||||
export async function getFleetSummary(opts: {
|
||||
applocationid?: number;
|
||||
partnerid?: number;
|
||||
tenantid: number;
|
||||
fromdate: string;
|
||||
todate: string;
|
||||
}): Promise<Row[]> {
|
||||
return toRows(
|
||||
await fiestaGet('partners/getfleetsummary', {
|
||||
applocationid: opts.applocationid ?? FIESTA_APPLOCATION_ID,
|
||||
partnerid: opts.partnerid,
|
||||
tenantid: opts.tenantid,
|
||||
fromdate: opts.fromdate,
|
||||
todate: opts.todate,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// PARTNERS / RIDERS
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@@ -356,6 +420,79 @@ export async function getProductsCount(opts: {
|
||||
);
|
||||
}
|
||||
|
||||
/** /products/getproductstocks?tenantid=&locationid= — live stock levels for an outlet. */
|
||||
export async function getProductStocks(opts: {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
}): Promise<Row[]> {
|
||||
return toRows(
|
||||
await fiestaGet('products/getproductstocks', {
|
||||
tenantid: opts.tenantid,
|
||||
locationid: opts.locationid,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** /products/getproductlocations?tenantid=&locationid=&subcategoryid=&pageno=&pagesize= —
|
||||
* geofenced per-outlet inventory. */
|
||||
export async function getProductLocations(opts: {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
subcategoryid?: number;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}): Promise<Row[]> {
|
||||
return toRows(
|
||||
await fiestaGet('products/getproductlocations', {
|
||||
tenantid: opts.tenantid,
|
||||
locationid: opts.locationid,
|
||||
subcategoryid: opts.subcategoryid,
|
||||
pageno: opts.pageno ?? 1,
|
||||
pagesize: opts.pagesize ?? 50,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** /products/getproducts?tenantid=&locationid=&subcategoryid=&keyword=&pageno=&pagesize= —
|
||||
* master catalog listings (global assortment). */
|
||||
export async function getMasterCatalog(opts: {
|
||||
tenantid: number;
|
||||
locationid?: number;
|
||||
subcategoryid?: number;
|
||||
keyword?: string;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}): Promise<Row[]> {
|
||||
return toRows(
|
||||
await fiestaGet('products/getproducts', {
|
||||
tenantid: opts.tenantid,
|
||||
locationid: opts.locationid,
|
||||
subcategoryid: opts.subcategoryid,
|
||||
keyword: opts.keyword ?? '',
|
||||
pageno: opts.pageno ?? 1,
|
||||
pagesize: opts.pagesize ?? 50,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** /products/getproductcategories — global product categories. */
|
||||
export async function getProductCategories(): Promise<Row[]> {
|
||||
return toRows(await fiestaGet('products/getproductcategories', {}));
|
||||
}
|
||||
|
||||
/** /products/getproductsubcategories?categoryid=&tenantid= — subcategories under a category. */
|
||||
export async function getProductSubcategories(opts: {
|
||||
categoryid: number;
|
||||
tenantid?: number;
|
||||
}): Promise<Row[]> {
|
||||
return toRows(
|
||||
await fiestaGet('products/getproductsubcategories', {
|
||||
categoryid: opts.categoryid,
|
||||
tenantid: opts.tenantid,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// USERS
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query';
|
||||
import type { Row } from './fiestaApi';
|
||||
import { loginRequest, matchTenantUser, buildAuthUser, type AuthUser } from './auth';
|
||||
import {
|
||||
FIESTA_TENANT_ID,
|
||||
FIESTA_APPLOCATION_ID,
|
||||
@@ -25,6 +26,10 @@ import {
|
||||
getDeliverySummary,
|
||||
getDeliveries,
|
||||
getDeliveryInsight,
|
||||
getDeliveryReport,
|
||||
getFleetSummary,
|
||||
getOrderDetails,
|
||||
getCustomerOrders,
|
||||
getRiders,
|
||||
getRiderShifts,
|
||||
getTenantLocations,
|
||||
@@ -32,6 +37,11 @@ import {
|
||||
getTenantCustomers,
|
||||
getStockStatement,
|
||||
getProductsCount,
|
||||
getProductStocks,
|
||||
getProductLocations,
|
||||
getMasterCatalog,
|
||||
getProductCategories,
|
||||
getProductSubcategories,
|
||||
getAllUsers,
|
||||
getUserById,
|
||||
createUser,
|
||||
@@ -55,6 +65,15 @@ export const fiestaKeys = {
|
||||
tenantCustomers: (params: Record<string, unknown>) => ['fiesta', 'tenantCustomers', params] as const,
|
||||
stockStatement: (params: Record<string, unknown>) => ['fiesta', 'stockStatement', params] as const,
|
||||
productsCount: (params: Record<string, unknown>) => ['fiesta', 'productsCount', params] as const,
|
||||
productStocks: (params: Record<string, unknown>) => ['fiesta', 'productStocks', params] as const,
|
||||
productLocations: (params: Record<string, unknown>) => ['fiesta', 'productLocations', params] as const,
|
||||
masterCatalog: (params: Record<string, unknown>) => ['fiesta', 'masterCatalog', params] as const,
|
||||
productCategories: () => ['fiesta', 'productCategories'] as const,
|
||||
productSubcategories: (params: Record<string, unknown>) => ['fiesta', 'productSubcategories', params] as const,
|
||||
orderDetails: (orderheaderid: number | string) => ['fiesta', 'orderDetails', orderheaderid] as const,
|
||||
customerOrders: (params: Record<string, unknown>) => ['fiesta', 'customerOrders', params] as const,
|
||||
deliveryReport: (params: Record<string, unknown>) => ['fiesta', 'deliveryReport', params] as const,
|
||||
fleetSummary: (params: Record<string, unknown>) => ['fiesta', 'fleetSummary', params] as const,
|
||||
users: (params: Record<string, unknown>) => ['fiesta', 'users', params] as const,
|
||||
user: (userid: number) => ['fiesta', 'user', userid] as const,
|
||||
};
|
||||
@@ -238,6 +257,117 @@ export function useFiestaStoresStock(
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Order details / customer history ───────────────────────────────────────────
|
||||
export function useFiestaOrderDetails(orderheaderid: number | string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: fiestaKeys.orderDetails(orderheaderid ?? ''),
|
||||
queryFn: () => getOrderDetails(orderheaderid as number | string),
|
||||
enabled: Boolean(orderheaderid),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFiestaCustomerOrders(opts: {
|
||||
customerid: number | string | null | undefined;
|
||||
status?: string;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: fiestaKeys.customerOrders(opts as Record<string, unknown>),
|
||||
queryFn: () =>
|
||||
getCustomerOrders({
|
||||
customerid: opts.customerid as number | string,
|
||||
status: opts.status,
|
||||
pageno: opts.pageno,
|
||||
pagesize: opts.pagesize,
|
||||
}),
|
||||
enabled: Boolean(opts.customerid),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Deliveries report / fleet ───────────────────────────────────────────────────
|
||||
export function useFiestaDeliveryReport(opts: {
|
||||
tenantid: number;
|
||||
applocationid?: number;
|
||||
partnerid?: number;
|
||||
userid?: number;
|
||||
fromdate: string;
|
||||
todate: string;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: fiestaKeys.deliveryReport(opts),
|
||||
queryFn: () => getDeliveryReport(opts),
|
||||
enabled: Boolean(opts.tenantid && opts.fromdate && opts.todate),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFiestaFleetSummary(opts: {
|
||||
tenantid: number;
|
||||
applocationid?: number;
|
||||
partnerid?: number;
|
||||
fromdate: string;
|
||||
todate: string;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: fiestaKeys.fleetSummary(opts),
|
||||
queryFn: () => getFleetSummary(opts),
|
||||
enabled: Boolean(opts.tenantid && opts.fromdate && opts.todate),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Products: live stocks / catalog / categories ────────────────────────────────
|
||||
export function useFiestaProductStocks(opts: { tenantid: number; locationid: number }) {
|
||||
return useQuery({
|
||||
queryKey: fiestaKeys.productStocks(opts),
|
||||
queryFn: () => getProductStocks(opts),
|
||||
enabled: Boolean(opts.tenantid && opts.locationid),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFiestaProductLocations(opts: {
|
||||
tenantid: number;
|
||||
locationid: number;
|
||||
subcategoryid?: number;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: fiestaKeys.productLocations(opts),
|
||||
queryFn: () => getProductLocations(opts),
|
||||
enabled: Boolean(opts.tenantid && opts.locationid),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFiestaMasterCatalog(opts: {
|
||||
tenantid: number;
|
||||
locationid?: number;
|
||||
subcategoryid?: number;
|
||||
keyword?: string;
|
||||
pageno?: number;
|
||||
pagesize?: number;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: fiestaKeys.masterCatalog(opts),
|
||||
queryFn: () => getMasterCatalog(opts),
|
||||
enabled: Boolean(opts.tenantid),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFiestaProductCategories() {
|
||||
return useQuery({
|
||||
queryKey: fiestaKeys.productCategories(),
|
||||
queryFn: () => getProductCategories(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFiestaProductSubcategories(opts: { categoryid: number; tenantid?: number }) {
|
||||
return useQuery({
|
||||
queryKey: fiestaKeys.productSubcategories(opts),
|
||||
queryFn: () => getProductSubcategories(opts),
|
||||
enabled: Boolean(opts.categoryid),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────────
|
||||
export function useFiestaUsers(opts: {
|
||||
tenantid: number;
|
||||
@@ -279,4 +409,38 @@ export function useFiestaUpdateUser() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Verify login credentials against the Fiesta web-login endpoint. A mutation
|
||||
* (not a query) since it's a POST with side effects; the form drives it via
|
||||
* `mutate`/`mutateAsync` and reads `isPending`/`error` for loading + error UI.
|
||||
*
|
||||
* Both network calls go through React Query: the login POST is the mutation,
|
||||
* and the role-resolution fallback (when the login response omits the role) is
|
||||
* fetched via the query client — so it shares the Users-panel cache.
|
||||
*/
|
||||
export function useLogin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<AuthUser, Error, { email: string; password: string }>({
|
||||
mutationFn: async ({ email, password }) => {
|
||||
const result = await loginRequest(email, password);
|
||||
let row = result.row;
|
||||
|
||||
// The login response didn't carry a role — resolve it from the tenant user
|
||||
// list through the query cache (deduped with useFiestaUsers).
|
||||
if (!result.hasRole) {
|
||||
const params = { tenantid: FIESTA_TENANT_ID, keyword: result.email, pagesize: 50 };
|
||||
const users = await qc.fetchQuery({
|
||||
queryKey: fiestaKeys.users(params),
|
||||
queryFn: () => getAllUsers(params),
|
||||
});
|
||||
const match = matchTenantUser(users, result.email);
|
||||
if (match) row = { ...match, ...(row ?? {}) };
|
||||
}
|
||||
|
||||
return buildAuthUser(row, result.email);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, FIESTA_PRIMARY_LOCATION_ID };
|
||||
|
||||
Reference in New Issue
Block a user