udpates on the ui changesand api integration

This commit is contained in:
2026-06-09 11:25:29 +05:30
parent 7dbae96b5f
commit 9f25c5f60a
26 changed files with 4324 additions and 2639 deletions

Binary file not shown.

112
docs/01_ENDPOINT_CATALOG.md Normal file
View 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.

View 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.

View 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 |

View 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
View 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

File diff suppressed because one or more lines are too long

View File

@@ -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 (
<StoreDetailView
store={selectedStore}
onBack={() => setSelectedStore(null)}
/>
<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={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">

View 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>
);
}

View File

@@ -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}
</span>
{/* ── 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>
</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
{/* 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>
) : 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>
)}
</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>
{/* 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>
<ArrowUpRight size={16} className="text-slate-300 group-hover:text-purple-400 transition-colors" />
</div>
<p className="text-[10px] font-semibold text-zinc-400 tracking-wider uppercase font-sans mt-3">
<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>
<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>
<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-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="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" />
<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" />
{str(loc.contactno)}
<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-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>
)}
{/* 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">
{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">
{deliveries} Dispatched
</span>
{orders > 0 && (
<span className="text-[10px] text-zinc-400 font-medium">
{Math.round((deliveries / orders) * 100)}% Fulfilled
</span>
{str(loc.contactno) && (
<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-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-bold px-2 py-0.5 rounded-md border border-emerald-100/60">
{deliveries} Dispatched
</span>
{orders > 0 && (
<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>

View File

@@ -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,52 +85,74 @@ 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>
<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>
<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"
>
<HelpCircle size={14} className="text-zinc-400" />
Help Center
</button>
<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"
>
<LogOut size={14} className="text-rose-500" />
Log Out
</button>
</div>
{/* Account actions (moved here from the sidebar) */}
<div className="p-2 flex flex-col gap-0.5">
<button
onClick={() => { setShowProfileDropdown(false); onHelpClick(); }}
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"
>
<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.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"
>
<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>
)}

View File

@@ -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,19 +576,22 @@ 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>
<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 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-[11px] font-medium">
Real-time inventory levels and capacity balance across {locations.length} regional outlets.
</p>
</div>
<p className="text-slate-400 text-[10px] font-medium">
Real-time inventory levels and capacity balance across {locations.length} regional outlets.
</p>
</div>
{/* Controls: Search + Filters */}
@@ -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>
<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>
<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 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="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>
);
})}
</div>
{(() => {
@@ -833,10 +708,14 @@ export default function InventoryView({
const meta = locations.find((l) => l.locationid === store.locationid);
const status = meta?.status ?? 'Active';
const statusDotColor = hasAlert
? criticalItemsCount > 0 ? 'bg-rose-500' : 'bg-amber-500'
const statusDotColor = hasAlert
? 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,72 +727,71 @@ 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]">
{/* 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" />
</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 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]">
{/* 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="min-w-0">
<h4 className="font-bold text-slate-900 text-[13px] truncate leading-tight">
{store.locationname}
</h4>
<p className="text-[10px] text-slate-400 mt-0.5 font-medium">
{totalItems} items · {totalUnits.toLocaleString('en-IN')} units
</p>
</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}`} />
{store.locationname}
</h4>
<p className="text-[9px] 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 ${
<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'
? criticalItemsCount > 0
? '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">
{criticalPct > 0 && (
<div className="h-full bg-rose-500" style={{ width: `${criticalPct}%` }} />
)}
{lowPct > 0 && (
<div className="h-full bg-amber-500" style={{ width: `${lowPct}%` }} />
)}
{optimalPct > 0 && (
<div className="h-full bg-emerald-500" style={{ width: `${optimalPct}%` }} />
)}
<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}%` }} />
)}
{lowPct > 0 && (
<div className="h-full bg-amber-500" style={{ width: `${lowPct}%` }} />
)}
{optimalPct > 0 && (
<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
<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'
@@ -946,23 +823,12 @@ export default function InventoryView({
{it.name}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`font-mono text-[10px] font-bold ${
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>
<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>
</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>

View 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>
);
}

View File

@@ -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,12 +370,14 @@ 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 */}
<div className="absolute right-3 bottom-3 opacity-15">
<PackageCheck size={64} className="text-purple-300" />
@@ -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>
@@ -549,19 +545,10 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
{activeSubTab === 'import' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-gutter animate-in slide-in-from-right-5">
{/* Upload panel zone */}
<div
<div
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>

View File

@@ -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,
// 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,
};
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;
}));
};
});
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>
)}

View File

@@ -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 */}
<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}
{kpi.awaiting ? (
<div className="mt-3">
<AwaitingApi label="Revenue" api="[R1]" compact />
</div>
<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>
) : (
<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>

View File

@@ -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>
);
}

File diff suppressed because it is too large Load Diff

View 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 youre 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">Couldnt load your store</h1>
<p className="text-[15px] text-slate-500 leading-relaxed mb-6">
We couldnt 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 isnt 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>
);
}

View 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>
);
}

View File

@@ -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 (

View File

@@ -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'
}
];

View File

@@ -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
View 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,
};
}

View File

@@ -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
// ════════════════════════════════════════════════════════════════════════════

View File

@@ -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 };