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, Truck,
Sliders, Sliders,
Calendar, Calendar,
AlertTriangle,
FileCheck, FileCheck,
Building, Building,
CheckCircle2, CheckCircle2,
@@ -42,9 +41,54 @@ import ReportsView from './components/ReportsView';
import InventoryView from './components/InventoryView'; import InventoryView from './components/InventoryView';
import SettingsView from './components/SettingsView'; import SettingsView from './components/SettingsView';
import StoreDetailView from './components/StoreDetailView'; import StoreDetailView from './components/StoreDetailView';
import 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'; 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() { 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 // Navigation indicators states
const [currentSection, setCurrentSection] = useState<MainSection>('dashboard'); 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); 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); const [showCalendarModal, setShowCalendarModal] = useState(false);
// Callback action triggers // Callback action triggers
const handleNewReport = () => {
setCurrentSection('reports');
alert('System routed back to reports dashboard interface. Select product item metadata matrices.');
};
const handleHelp = () => { const handleHelp = () => {
alert('nearledaily User Manual & Documentation Center linked successfully. Contact Coimbatore regional IT hub desk for urgent escalations.'); alert('nearledaily User Manual & Documentation Center linked successfully. Contact Coimbatore regional IT hub desk for urgent escalations.');
}; };
const handleLogout = () => { const handleLogout = () => setAuthUser(null);
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();
}
};
// Define secondary sections (Stores, Logistics, Staffing, Settings) within main body // Define secondary sections (Stores, Logistics, Staffing, Settings) within main body
const renderSecondarySection = () => { const renderSecondarySection = () => {
switch (currentSection) { switch (currentSection) {
case 'stores': case 'stores': {
if (selectedStore) { // 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 ( return (
<div className="space-y-md animate-in fade-in duration-300">
{isSoleStore && (
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-4 border-b border-zinc-205">
<div>
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">
Store Console
</h1>
<p className="text-zinc-500 font-sans text-xs mt-1">
This merchant operates a single store. Add a branch to manage multiple outlets.
</p>
</div>
<button
onClick={() => setShowAddStoreModal(true)}
className="bg-[#581c87] text-white px-5 py-2.5 rounded-lg text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-2 cursor-pointer hover:bg-purple-800 transition shadow-sm self-start md:self-auto"
>
<Plus className="w-4 h-4" />
Add Retail Outlet Node
</button>
</div>
)}
<StoreDetailView <StoreDetailView
store={selectedStore} store={activeStore}
onBack={() => setSelectedStore(null)} onBack={selectedStore ? () => setSelectedStore(null) : undefined}
/> />
</div>
); );
} }
return ( return (
<div className="space-y-lg animate-in fade-in duration-300"> <div className="space-y-lg animate-in fade-in duration-300">
{/* Simple and elegant premium header */} {/* Simple and elegant premium header */}
@@ -468,6 +530,7 @@ export default function App() {
</div> </div>
</div> </div>
); );
}
case 'settings': case 'settings':
return <SettingsView tenantId={FIESTA_TENANT_ID} />; 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 ( return (
<div className="min-h-screen bg-[#f8fafc] text-[#0f172a] font-sans antialiased"> <div className="min-h-screen bg-[#f8fafc] text-[#0f172a] font-sans antialiased">
{/* Navbar segment */} {/* Navbar segment */}
@@ -486,9 +560,9 @@ export default function App() {
isCoimbatoreView={isCoimbatoreView} isCoimbatoreView={isCoimbatoreView}
onToggleSidebar={() => setSidebarOpen((prev) => !prev)} onToggleSidebar={() => setSidebarOpen((prev) => !prev)}
isSidebarOpen={sidebarOpen} isSidebarOpen={sidebarOpen}
onNewReportClick={handleNewReport}
onHelpClick={handleHelp} onHelpClick={handleHelp}
onLogoutClick={handleLogout} onLogoutClick={handleLogout}
profile={currentUser}
/> />
{/* Main Container workspace layout splits */} {/* 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"> <div className="p-md space-y-md overflow-y-auto flex-1">
<p className="text-zinc-500 leading-relaxed font-semibold"> <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> </p>
<div className="divide-y divide-[#f1f5f9] select-none text-[11px]"> <AwaitingApi label="Scheduled reports" api="[R13]" />
<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>
</div> </div>
<div className="p-sm bg-[#f8fafc] border-t border-[#e2e8f0] flex justify-end shrink-0"> <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 React from 'react';
import { import {
ShoppingBag,
PackageCheck,
Wallet, Wallet,
TrendingUp, TrendingUp,
Store, Store,
MapPin, MapPin,
Phone, Phone,
Sparkles,
AlertTriangle, AlertTriangle,
Activity,
Clock,
ArrowUpRight,
} from 'lucide-react'; } from 'lucide-react';
import { useOrderSummary, useTenantInfo, useTenantLocations, useInvoiceInsight } from '../services/queries'; import { useOrderSummary, useTenantInfo, useTenantLocations, useInvoiceInsight } from '../services/queries';
import { DEFAULT_TENANT_ID, DEFAULT_CONFIG_ID } from '../services/api'; 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 dashOffset = circumference - (circumference * activePct) / 100;
const kpis = [ 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: 'ACTIVE OUTLETS',
{ title: 'MONTHLY REVENUE', display: money(monthlyRevenue), icon: Wallet, chip: 'bg-sky-50 text-sky-600', loading: insightQ.isLoading }, display: `${activeStoresCount} / ${totalStoresCount}`,
{ title: 'MONTHLY PROFIT', display: money(monthlyProfit), icon: TrendingUp, chip: 'bg-emerald-50 text-emerald-600', loading: insightQ.isLoading }, 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 = [ const statusRows = [
@@ -97,35 +129,57 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) {
return ( return (
<div className="space-y-lg animate-in fade-in duration-500 relative"> <div className="space-y-lg animate-in fade-in duration-500 relative">
{/* Scope banner */} {/* ── Immersive Executive Banner (cover image + slate→purple gradient overlay) ── */}
<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="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">
<div className="flex items-center gap-sm"> {/* Cover image background & decorative glow */}
<Sparkles size={16} className="text-[#581c87]" /> <div className="absolute inset-0 z-0 overflow-hidden rounded-2xl">
<span className="font-sans text-xs text-zinc-700 font-medium"> <img
Live operations data for <strong>{tenantName}</strong> · {fromdate} {todate} src="https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1400&q=80"
alt="Executive operations dashboard"
className="w-full h-full object-cover object-center opacity-40"
/>
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-900/90 to-purple-950/80" />
<div className="absolute top-0 right-0 w-72 h-72 bg-purple-500/10 rounded-full blur-3xl -mr-20 -mt-20 pointer-events-none" />
<div className="absolute bottom-0 left-0 w-56 h-56 bg-indigo-500/10 rounded-full blur-2xl -ml-20 -mb-20 pointer-events-none" />
</div>
{/* Content row */}
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-lg">
<div>
<h1 className="font-sans font-bold text-2xl md:text-3xl tracking-tight text-white flex items-center gap-2.5">
Executive Command Center
<span className="text-[10px] text-purple-200 font-bold bg-purple-900/60 border border-purple-500/30 px-2 py-0.5 rounded-full uppercase tracking-wider animate-pulse">
Live Core
</span> </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>
</div> </div>
{/* Header */} {/* Reporting scope panel */}
<div className="flex justify-between items-end"> <div className="flex flex-col items-start md:items-end gap-2 shrink-0">
<div> <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">
<h1 className="font-sans font-bold text-3xl tracking-tight text-[#0f172a]">Executive Command Center</h1> <Clock size={14} className="text-purple-300" />
<div className="flex items-center gap-2 mt-1"> <span className="text-xs font-bold font-mono text-white tracking-tight">{fromdate} {todate}</span>
<p className="text-zinc-500 font-sans text-sm">Month-to-date order operations, pulled live from the API.</p> </div>
{loading ? ( <span className="text-[10px] text-slate-400 uppercase tracking-widest font-bold">Month-to-date reporting scope</span>
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-zinc-400 uppercase tracking-wide">
<span className="w-1.5 h-1.5 rounded-full bg-zinc-300 animate-pulse" /> Loading
</span>
) : errored ? (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-rose-600 uppercase tracking-wide" title="Restart the dev server so the /hasura proxy is active.">
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" /> Live data unavailable
</span>
) : (
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 uppercase tracking-wide">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Live · {tenantName}
</span>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -144,23 +198,31 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) {
</div> </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"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-gutter">
{kpis.map((kpi) => { {kpis.map((kpi) => {
const Icon = kpi.icon; const Icon = kpi.icon;
return ( return (
<div <div
key={kpi.title} 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}`}> {/* Gradient accent bar */}
<Icon size={14} /> <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> </div>
<p className="text-[10px] font-semibold text-zinc-400 tracking-wider uppercase font-sans mt-3"> <ArrowUpRight size={16} className="text-slate-300 group-hover:text-purple-400 transition-colors" />
</div>
<p className="text-[10px] font-bold text-slate-400 tracking-widest uppercase font-sans mt-4">
{kpi.title} {kpi.title}
</p> </p>
<p className="font-sans font-bold text-2xl leading-tight text-[#0f172a] tracking-tight mt-0.5"> <p className="font-sans font-extrabold text-[28px] leading-tight text-slate-900 tracking-tight mt-1">
{kpi.loading ? <span className="text-zinc-300">…</span> : kpi.display} {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> </p>
</div> </div>
); );
@@ -170,115 +232,140 @@ export default function DashboardView({ searchQuery }: DashboardViewProps) {
{/* Order status + store locations */} {/* Order status + store locations */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter">
{/* Store Node Status donut (live) */} {/* Store Node Status donut (live) */}
<div className="bg-white border border-[#e2e8f0] rounded-xl p-md flex flex-col shadow-[0_1px_3px_rgba(0,0,0,0.05)]"> <div className="bg-white border border-slate-200/70 rounded-2xl p-lg flex flex-col shadow-[0_1px_3px_rgba(16,24,40,0.05)]">
<div className="flex items-start gap-2.5">
<div className="h-9 w-9 rounded-xl bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100 flex items-center justify-center shrink-0">
<Activity size={17} />
</div>
<div> <div>
<h3 className="font-sans font-bold text-base text-[#0f172a]">Store Outlet Status</h3> <h3 className="font-sans font-bold text-base text-slate-900 tracking-tight">Store Outlet Status</h3>
<p className="text-zinc-500 text-xs font-sans mt-0.5">Active share of all registered store nodes.</p> <p className="text-slate-500 text-xs font-sans mt-0.5">Active share of all registered nodes.</p>
</div>
</div> </div>
<div className="my-md flex justify-center items-center"> <div className="my-lg flex justify-center items-center">
<div className="relative w-40 h-40 flex items-center justify-center"> <div className="relative w-44 h-44 flex items-center justify-center">
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 100 100"> {/* soft glow behind the ring */}
<circle cx="50" cy="50" r="40" fill="transparent" stroke="#eceef0" strokeWidth="8" /> <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 <circle
cx="50" cx="50"
cy="50" cy="50"
r="40" r="40"
fill="transparent" fill="transparent"
stroke="#10b981" stroke="url(#dashGradient)"
strokeWidth="8" strokeWidth="9"
strokeDasharray={circumference} strokeDasharray={circumference}
strokeDashoffset={dashOffset} strokeDashoffset={dashOffset}
strokeLinecap="round" strokeLinecap="round"
className="transition-all duration-700" 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> </svg>
<div className="absolute inset-0 flex flex-col items-center justify-center"> <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="font-sans font-extrabold text-4xl text-slate-900 tracking-tight">{activePct}%</span>
<span className="text-[10px] text-emerald-600 uppercase tracking-widest font-semibold mt-1">Active</span> <span className="text-[10px] text-emerald-600 uppercase tracking-widest font-bold mt-1">Active</span>
</div> </div>
</div> </div>
</div> </div>
<div className="divide-y divide-[#f1f5f9] text-xs"> <div className="divide-y divide-slate-100 text-xs">
{statusRows.map((r) => ( {statusRows.map((r) => (
<div key={r.label} className="flex justify-between items-center py-2"> <div key={r.label} className="flex justify-between items-center py-2.5">
<span className="flex items-center gap-1.5 text-zinc-500"> <span className="flex items-center gap-2 text-slate-500 font-medium">
<span className={`w-2.5 h-2.5 rounded-full ${r.dot}`} /> <span className={`w-2.5 h-2.5 rounded-full ${r.dot}`} />
{r.label} {r.label}
</span> </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>
))} ))}
<div className="flex justify-between items-center py-2"> <div className="flex justify-between items-center pt-2.5">
<span className="text-zinc-500 font-semibold">Total Nodes</span> <span className="text-slate-600 font-bold">Total Nodes</span>
<span className="font-mono font-bold text-[#581c87]">{totalStoresCount.toLocaleString('en-IN')}</span> <span className="font-mono font-extrabold text-purple-650">{totalStoresCount.toLocaleString('en-IN')}</span>
</div> </div>
</div> </div>
</div> </div>
{/* Store locations (live) */} {/* 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="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-xs border-b border-[#f1f5f9]"> <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-[#0f172a] flex items-center gap-2"> <h3 className="font-sans font-bold text-base text-slate-900 flex items-center gap-2.5 tracking-tight">
<Store size={16} className="text-[#581c87]" /> Store Locations <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> </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'}`} {locationsQ.isLoading ? 'Loading' : `${locations.length} Outlet${locations.length === 1 ? '' : 's'}`}
</span> </span>
</div> </div>
{locationsQ.isLoading ? ( {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 ? ( ) : 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) => { {locations.map((loc, i) => {
const sum = summaries.find((s) => s.locationid === Number(loc.locationid)); const sum = summaries.find((s) => s.locationid === Number(loc.locationid));
const deliveries = sum?.delivered ?? 0; const deliveries = sum?.delivered ?? 0;
const orders = Math.max(sum?.delivered ?? 0, sum?.total ?? 0); const orders = Math.max(sum?.delivered ?? 0, sum?.total ?? 0);
const isActive = str(loc.status).toLowerCase() === 'active';
const name = str(loc.locationname);
return ( return (
<div <div
key={str(loc.locationid) || i} key={str(loc.locationid) || i}
className="p-sm border border-[#e2e8f0] rounded-lg bg-[#f8fafc]/40 flex justify-between items-start gap-md hover:border-purple-200 transition-colors animate-in fade-in" className="group p-3.5 border border-slate-200/70 rounded-xl bg-slate-50/40 flex justify-between items-start gap-md hover:border-purple-200 hover:bg-white hover:shadow-[0_6px_18px_rgba(16,24,40,0.06)] transition-all animate-in fade-in"
> >
<div className="flex items-start gap-3 min-w-0">
{/* Outlet initial badge */}
<div className="h-9 w-9 shrink-0 rounded-lg bg-gradient-to-br from-purple-500 to-indigo-500 text-white flex items-center justify-center font-bold text-xs uppercase shadow-sm">
{name.slice(0, 2) || ''}
</div>
<div className="min-w-0"> <div className="min-w-0">
<p className="font-sans font-semibold text-sm text-[#0f172a] truncate">{str(loc.locationname)}</p> <p className="font-sans font-semibold text-sm text-slate-900 truncate">{name}</p>
<p className="text-[11px] text-zinc-500 mt-0.5 flex items-center gap-1"> <p className="text-[11px] text-slate-500 mt-0.5 flex items-center gap-1">
<MapPin size={11} className="shrink-0 text-zinc-400" /> <MapPin size={11} className="shrink-0 text-slate-400" />
<span className="truncate">{str(loc.address) || `${str(loc.suburb)}, ${str(loc.city)}`}</span> <span className="truncate">{str(loc.address) || `${str(loc.suburb)}, ${str(loc.city)}`}</span>
</p> </p>
{str(loc.contactno) && ( {str(loc.contactno) && (
<p className="text-[11px] text-zinc-500 mt-0.5 flex items-center gap-1"> <p className="text-[11px] text-slate-500 mt-0.5 flex items-center gap-1">
<Phone size={11} className="shrink-0 text-zinc-400" /> <Phone size={11} className="shrink-0 text-slate-400" />
{str(loc.contactno)} {str(loc.contactno)}
</p> </p>
)} )}
{/* Node-specific Orders and Dispatches */} {/* Node-specific Orders and Dispatches */}
<div className="flex items-center gap-3 mt-2.5"> <div className="flex items-center gap-2 mt-2.5 flex-wrap">
<span className="text-[10px] bg-purple-50 text-[#581c87] font-semibold px-2 py-0.5 rounded border border-purple-100/50"> <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 {orders} Orders
</span> </span>
<span className="text-[10px] bg-emerald-50 text-emerald-700 font-semibold px-2 py-0.5 rounded border border-emerald-100/50"> <span className="text-[10px] bg-emerald-50 text-emerald-700 font-bold px-2 py-0.5 rounded-md border border-emerald-100/60">
{deliveries} Dispatched {deliveries} Dispatched
</span> </span>
{orders > 0 && ( {orders > 0 && (
<span className="text-[10px] text-zinc-400 font-medium"> <span className="text-[10px] text-slate-400 font-semibold">
{Math.round((deliveries / orders) * 100)}% Fulfilled {Math.round((deliveries / orders) * 100)}% Fulfilled
</span> </span>
)} )}
</div> </div>
</div> </div>
</div>
<span <span
className={`shrink-0 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase ${ className={`shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[9px] font-bold uppercase tracking-wide ${
str(loc.status).toLowerCase() === 'active' isActive
? 'text-emerald-600 bg-emerald-50 border border-emerald-100' ? '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) || ''} {str(loc.status) || ''}
</span> </span>
</div> </div>

View File

@@ -4,29 +4,29 @@
*/ */
import React, { useState, useRef, useEffect } from 'react'; 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'; import { MainSection } from '../types';
interface HeaderProps { interface HeaderProps {
currentSection: MainSection; // Admin nav context — unused by the bar itself, optional so the Header can be
setCurrentSection: (section: MainSection) => void; // reused by the user store page which has no MainSection routing.
isCoimbatoreView: boolean; currentSection?: MainSection;
setCurrentSection?: (section: MainSection) => void;
isCoimbatoreView?: boolean;
onToggleSidebar: () => void; onToggleSidebar: () => void;
isSidebarOpen: boolean; isSidebarOpen: boolean;
onNewReportClick: () => void;
onHelpClick: () => void; onHelpClick: () => void;
onLogoutClick: () => void; onLogoutClick: () => void;
/** Signed-in user shown in the profile dropdown. */
profile: { name: string; role: string; email: string };
} }
export default function Header({ export default function Header({
currentSection,
setCurrentSection,
isCoimbatoreView,
onToggleSidebar, onToggleSidebar,
isSidebarOpen, isSidebarOpen,
onNewReportClick,
onHelpClick, onHelpClick,
onLogoutClick onLogoutClick,
profile
}: HeaderProps) { }: HeaderProps) {
const [showProfileDropdown, setShowProfileDropdown] = useState(false); const [showProfileDropdown, setShowProfileDropdown] = useState(false);
const profileRef = useRef<HTMLDivElement>(null); const profileRef = useRef<HTMLDivElement>(null);
@@ -47,12 +47,14 @@ export default function Header({
}; };
}, [showProfileDropdown]); }, [showProfileDropdown]);
const profile = { const initials =
name: 'Suresh Kumar', profile.name
role: 'Operations Director', .split(' ')
email: 'suresh.k@nearledaily.com', .map((w) => w[0])
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=150&q=80' .filter(Boolean)
}; .slice(0, 2)
.join('')
.toUpperCase() || 'NA';
return ( 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"> <header className="bg-[#581c87] border-b border-[#4c1d95] flex justify-between items-center w-full px-container-margin py-md fixed top-0 right-0 left-0 z-50 h-20 text-white shadow-sm">
@@ -83,54 +85,76 @@ export default function Header({
<div className="relative" ref={profileRef}> <div className="relative" ref={profileRef}>
<button <button
onClick={() => setShowProfileDropdown(!showProfileDropdown)} 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 {/* Initials avatar with live status dot */}
src={profile.avatar} <span className="relative shrink-0">
alt="Executive Profile" <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">
referrerPolicy="no-referrer" {initials}
className="w-full h-full object-cover" </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> </button>
{showProfileDropdown && ( {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="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">
<div className="px-4 py-2 border-b border-[#f1f5f9] bg-[#f8fafc]"> {/* Gradient profile header */}
<p className="font-bold text-xs text-[#0f172a]">{profile.name}</p> <div className="relative px-4 py-4 bg-gradient-to-br from-[#581c87] via-purple-800 to-purple-950 text-white overflow-hidden">
<p className="text-[10px] text-zinc-400 font-medium">{profile.role}</p> <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> </div>
{/* Account actions (moved here from the sidebar) */} {/* Account actions (moved here from the sidebar) */}
<div className="py-1 pt-2 flex flex-col gap-0.5"> <div className="p-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 <button
onClick={() => { setShowProfileDropdown(false); onHelpClick(); }} onClick={() => { setShowProfileDropdown(false); onHelpClick(); }}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-semibold text-zinc-700 hover:bg-zinc-50 cursor-pointer transition-colors" className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item"
> >
<HelpCircle size={14} className="text-zinc-400" /> <span className="h-7 w-7 rounded-lg bg-slate-100 text-slate-500 ring-1 ring-slate-200 flex items-center justify-center group-hover/item:scale-110 transition-transform">
<HelpCircle size={14} />
</span>
Help Center Help Center
</button> </button>
<div className="my-1 h-px bg-slate-100" />
<button <button
onClick={() => { setShowProfileDropdown(false); onLogoutClick(); }} onClick={() => { setShowProfileDropdown(false); onLogoutClick(); }}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-xs font-semibold text-rose-600 hover:bg-rose-50 cursor-pointer transition-colors" className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-xl text-xs font-semibold text-rose-600 hover:bg-rose-50 cursor-pointer transition-colors group/item"
> >
<LogOut size={14} className="text-rose-500" /> <span className="h-7 w-7 rounded-lg bg-rose-50 text-rose-500 ring-1 ring-rose-100 flex items-center justify-center group-hover/item:scale-110 transition-transform">
<LogOut size={14} />
</span>
Log Out Log Out
</button> </button>
</div> </div>
</div> </div>
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -19,25 +19,26 @@ import {
Trash2, Trash2,
PackageCheck, PackageCheck,
ShieldCheck, ShieldCheck,
Zap,
Tag, Tag,
UploadCloud, UploadCloud,
FileSpreadsheet, FileSpreadsheet,
Palette, Palette,
ShoppingBag,
Info, Info,
X, X,
Server, Server,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
RotateCw,
CheckCircle CheckCircle
} from 'lucide-react'; } from 'lucide-react';
import { ProductMatrixItem, ImportLog } from '../types'; import { ProductMatrixItem } from '../types';
import { initialImportLogs } from '../data'; import {
import { useFiestaTenantLocations, useFiestaStoresStock } from '../services/fiestaQueries'; useFiestaTenantLocations,
useFiestaStoresStock,
useFiestaProductCategories,
} from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi'; import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi';
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers'; import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi';
type StockRow = Record<string, unknown>; type StockRow = Record<string, unknown>;
const rowId = (r: StockRow) => String(r.productid ?? '') || String(r.productname ?? ''); 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 // Global catalog = deduped union of every outlet's products, plus anything the
// admin adds/imports in-session. Seeded once from the live data. // admin adds/imports in-session. Seeded once from the live data.
const [products, setProducts] = useState<ProductMatrixItem[]>([]); const [products, setProducts] = useState<ProductMatrixItem[]>([]);
const [importLogs, setImportLogs] = useState<ImportLog[]>(initialImportLogs);
const [seeded, setSeeded] = useState(false); const [seeded, setSeeded] = useState(false);
const allStoreRows = storesStock.flatMap((s) => s.rows); const allStoreRows = storesStock.flatMap((s) => s.rows);
@@ -98,8 +98,8 @@ export default function InventoryView({
const [showAddProductModal, setShowAddProductModal] = useState(false); const [showAddProductModal, setShowAddProductModal] = useState(false);
const [outletFilter, setOutletFilter] = useState<'all' | 'alerts'>('all'); const [outletFilter, setOutletFilter] = useState<'all' | 'alerts'>('all');
const [outletSearch, setOutletSearch] = useState(''); const [outletSearch, setOutletSearch] = useState('');
const [restockedOverrides, setRestockedOverrides] = useState<Record<number, Record<string, number>>>({}); // Regional Hub Stocks is read-only for admins — overrides remain empty (no restock actions).
const [loadingOutlets, setLoadingOutlets] = useState<Record<number, boolean>>({}); const [restockedOverrides] = useState<Record<number, Record<string, number>>>({});
const [expandedHubs, setExpandedHubs] = useState<Record<number, boolean>>({}); const [expandedHubs, setExpandedHubs] = useState<Record<number, boolean>>({});
// Memoize storesStock query results merged with simulated restock overrides // Memoize storesStock query results merged with simulated restock overrides
@@ -155,89 +155,11 @@ export default function InventoryView({
}; };
}, [storesStockWithOverrides]); }, [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 // CSV Textarea input
const [csvText, setCsvText] = useState( 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" "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 // Form state for individual adding
const [newProduct, setNewProduct] = useState({ const [newProduct, setNewProduct] = useState({
name: '', 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' 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). // Categories derived from the live catalog (falls back to ALL only).
const categorySet = new Set<string>(); const categorySet = new Set<string>();
products.forEach((p) => categorySet.add(p.category)); products.forEach((p) => categorySet.add(p.category));
@@ -342,78 +274,12 @@ export default function InventoryView({
if (parsedCount > 0) { if (parsedCount > 0) {
setProducts(prev => [...newProds, ...prev]); 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!`); alert(`Synchronized ${parsedCount} regional products into Catalog database successfully!`);
} else { } else {
alert('All the specified SKU codes are already active in the catalog ledger.'); 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 ( return (
<div className="space-y-lg animate-in fade-in duration-500 font-sans relative"> <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' }} /> <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) ── */} {/* ── 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 */} {/* Cover Image Background & Decor */}
<div className="absolute inset-0 z-0 overflow-hidden rounded-2xl"> <div className="absolute inset-0 z-0 overflow-hidden rounded-2xl">
<img <img
@@ -438,11 +304,11 @@ export default function InventoryView({
</div> </div>
{/* Content Row */} {/* 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> <div>
<h1 className="font-sans font-bold text-2xl tracking-tight text-white flex items-center gap-2"> <h1 className="font-sans font-bold text-2xl tracking-tight text-white flex items-center gap-2">
<Layers size={24} className="text-purple-300" /> <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"> <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 Global Sync
</span> </span>
@@ -710,20 +576,23 @@ export default function InventoryView({
{/* Elegant Header Row */} {/* 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="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-start gap-3 font-sans">
<div className="flex items-center gap-2"> <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">
<h3 className="font-bold text-sm text-slate-900 flex items-center gap-1.5"> <Server size={18} />
<Server size={16} className="text-purple-650" /> Regional Hub Stocks </div>
</h3> <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="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" /> <span className="w-1 h-1 rounded-full bg-emerald-500 animate-pulse" />
Live Sync Live Sync
</span> </span>
</div> </div>
<p className="text-slate-400 text-[10px] font-medium"> <p className="text-slate-400 text-[11px] font-medium">
Real-time inventory levels and capacity balance across {locations.length} regional outlets. Real-time inventory levels and capacity balance across {locations.length} regional outlets.
</p> </p>
</div> </div>
</div>
{/* Controls: Search + Filters */} {/* Controls: Search + Filters */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
@@ -779,23 +648,29 @@ export default function InventoryView({
</div> </div>
{/* Quick Metrics Strip */} {/* 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="grid grid-cols-2 sm:grid-cols-4 gap-3 font-sans">
<div className="flex flex-col gap-0.5"> {[
<span className="text-[9px] text-slate-400 uppercase tracking-wider font-extrabold">Active Outlets</span> { label: 'Active Outlets', value: locations.length, icon: Server, chip: 'bg-purple-50 text-purple-650 ring-purple-100', value_cls: 'text-slate-900' },
<span className="font-bold text-slate-800 text-sm font-mono">{locations.length}</span> { 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>
<div className="flex flex-col gap-0.5"> <div className="min-w-0">
<span className="text-[9px] text-slate-400 uppercase tracking-wider font-extrabold">Optimal Hubs</span> <span className="block text-[9px] text-slate-400 uppercase tracking-wider font-extrabold truncate">{m.label}</span>
<span className="font-bold text-emerald-600 text-sm font-mono">{locations.length - storeAlertsData.alertOutletsCount}</span> <span className={`block font-extrabold text-2xl leading-tight font-sans tracking-tight ${m.value_cls}`}>{m.value}</span>
</div> </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>
);
})}
</div> </div>
{(() => { {(() => {
@@ -837,6 +712,10 @@ export default function InventoryView({
? criticalItemsCount > 0 ? 'bg-rose-500' : 'bg-amber-500' ? criticalItemsCount > 0 ? 'bg-rose-500' : 'bg-amber-500'
: 'bg-emerald-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 optimalPct = totalItems > 0 ? (optimalCount / totalItems) * 100 : 0;
const lowPct = totalItems > 0 ? (lowCount / totalItems) * 100 : 0; const lowPct = totalItems > 0 ? (lowCount / totalItems) * 100 : 0;
const criticalPct = totalItems > 0 ? (criticalItemsCount / totalItems) * 100 : 0; const criticalPct = totalItems > 0 ? (criticalItemsCount / totalItems) * 100 : 0;
@@ -848,51 +727,49 @@ export default function InventoryView({
}); });
return ( return (
<div key={store.locationid} className="bg-white border border-slate-100 rounded-2xl shadow-sm hover:shadow-md transition-all duration-300 relative flex flex-col justify-between overflow-hidden min-h-[300px]"> <div key={store.locationid} className="bg-white border border-slate-200/70 rounded-2xl shadow-[0_1px_3px_rgba(16,24,40,0.05)] hover:shadow-[0_16px_36px_rgba(16,24,40,0.10)] hover:border-purple-200 hover:-translate-y-0.5 transition-all duration-300 relative flex flex-col justify-between overflow-hidden min-h-[300px]">
{/* Loading Overlay */} {/* Card Header */}
{loadingOutlets[store.locationid] && ( <div className="p-4 pb-3 flex justify-between items-start gap-2">
<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="flex items-start gap-2.5 min-w-0">
<div className="space-y-3"> <div className={`h-9 w-9 shrink-0 rounded-lg flex items-center justify-center ring-1 ${statusChip}`}>
<div className="w-8 h-8 mx-auto relative"> <Server size={15} />
<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>
<div className="space-y-0.5 font-sans">
<h4 className="text-slate-850 font-bold text-[11px]">Replenishing Hub...</h4>
<p className="text-slate-400 text-[9px]">Dispatching supply batch to {store.locationname}</p>
</div>
</div>
</div>
)}
{/* Card Header (Clean & borderless) */}
<div className="p-4 pb-2 flex justify-between items-start gap-2">
<div className="min-w-0"> <div className="min-w-0">
<h4 className="font-bold text-slate-900 text-xs truncate flex items-center gap-1.5"> <h4 className="font-bold text-slate-900 text-[13px] truncate leading-tight">
<span className={`w-1.5 h-1.5 rounded-full ${statusDotColor}`} />
{store.locationname} {store.locationname}
</h4> </h4>
<p className="text-[9px] text-slate-400 mt-0.5 font-medium"> <p className="text-[10px] text-slate-400 mt-0.5 font-medium">
{totalItems} items · {totalUnits.toLocaleString('en-IN')} units {totalItems} items · {totalUnits.toLocaleString('en-IN')} units
</p> </p>
</div> </div>
<span className={`shrink-0 px-2 py-0.5 rounded-full text-[8px] font-black uppercase tracking-wider ${ </div>
<span className={`shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[8px] font-black uppercase tracking-wider ${
hasAlert hasAlert
? criticalItemsCount > 0 ? criticalItemsCount > 0
? 'text-rose-600 bg-rose-50' ? 'text-rose-600 bg-rose-50 border border-rose-100'
: 'text-amber-700 bg-amber-50' : 'text-amber-700 bg-amber-50 border border-amber-100'
: 'text-emerald-600 bg-emerald-50' : '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'} {hasAlert ? criticalItemsCount > 0 ? 'Critical' : 'Low Stock' : 'Optimal'}
</span> </span>
</div> </div>
{/* Card Body */} {/* 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 */} {/* Segmented Stock Health Distribution */}
<div className="w-full h-1 bg-slate-100 rounded-full overflow-hidden flex select-none"> <div className="space-y-1.5">
<div className="flex justify-between items-center text-[9px] font-bold text-slate-400 uppercase tracking-wider">
<span>Stock Health</span>
<span className="flex items-center gap-2 normal-case font-semibold">
{criticalItemsCount > 0 && <span className="text-rose-600">{criticalItemsCount} crit</span>}
{lowCount > 0 && <span className="text-amber-600">{lowCount} low</span>}
<span className="text-emerald-600">{optimalCount} ok</span>
</span>
</div>
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden flex select-none">
{criticalPct > 0 && ( {criticalPct > 0 && (
<div className="h-full bg-rose-500" style={{ width: `${criticalPct}%` }} /> <div className="h-full bg-rose-500" style={{ width: `${criticalPct}%` }} />
)} )}
@@ -903,17 +780,18 @@ export default function InventoryView({
<div className="h-full bg-emerald-500" style={{ width: `${optimalPct}%` }} /> <div className="h-full bg-emerald-500" style={{ width: `${optimalPct}%` }} />
)} )}
</div> </div>
</div>
{/* Capacity utilization indicator */} {/* Capacity utilization indicator */}
<div className="space-y-1"> <div className="space-y-1.5">
<div className="flex justify-between items-center text-[9px] font-medium text-slate-400"> <div className="flex justify-between items-center text-[9px] font-bold text-slate-400 uppercase tracking-wider">
<span>Capacity Utilised</span> <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>
<div className="w-full bg-slate-100 h-1 rounded-full overflow-hidden"> <div className="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
<div <div
className={`h-full rounded-full transition-all duration-500 ${ 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}%` }} 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"> <div className="space-y-2 max-h-40 overflow-y-auto pr-0.5 scrollbar-thin">
{sortedItems.map((it, idx) => { {sortedItems.map((it, idx) => {
const rawRow = store.rows.find(r => `SKU-${String(r.productid ?? '') || String(r.productname ?? '')}` === it.sku);
const isLow = it.status !== 'Optimal'; const isLow = it.status !== 'Optimal';
return ( 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"> <div className="min-w-0 flex items-center gap-2">
<span className={`w-1 h-1 rounded-full shrink-0 ${ <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' it.status === 'Critical' ? 'bg-rose-500 animate-pulse' : it.status === 'Low Stock' ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500'
@@ -947,22 +824,11 @@ export default function InventoryView({
</span> </span>
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <span className={`font-mono text-[10px] font-bold 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.status === 'Critical' ? 'text-rose-600' : it.status === 'Low Stock' ? 'text-amber-700' : 'text-slate-500'
}`}> }`}>
{it.stockLevel} {it.stockLevel}
</span> </span>
{isLow && (
<button
onClick={() => handleRestockSKU(store.locationid, rawRow, store.locationname)}
title="Replenish SKU"
className="p-0.5 rounded hover:bg-purple-50 text-slate-400 hover:text-purple-650 transition-colors border-none bg-transparent cursor-pointer opacity-0 group-hover/row:opacity-100 focus:opacity-100"
>
<RotateCw size={9} />
</button>
)}
</div>
</div> </div>
); );
})} })}
@@ -972,20 +838,18 @@ export default function InventoryView({
</div> </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"> <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"> <span className="text-[9px] font-mono text-slate-400 uppercase tracking-wide">
{hasAlert ? `${criticalItemsCount + lowCount} items need attention` : 'All items optimal'} {hasAlert ? `${criticalItemsCount + lowCount} items need attention` : 'All items optimal'}
</span> </span>
{hasAlert ? ( {hasAlert ? (
<button <span className={`text-[9px] font-black flex items-center gap-1 select-none pr-1 uppercase tracking-wide ${
onClick={() => handleRestockOutlet(store.locationid, store.rows, store.locationname)} criticalItemsCount > 0 ? 'text-rose-600' : 'text-amber-700'
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" }`}>
> <AlertTriangle size={11} /> {criticalItemsCount > 0 ? 'Critical' : 'Low Stock'}
<Zap size={10} /> </span>
<span>Restock Hub</span>
</button>
) : ( ) : (
<span className="text-[9px] font-black text-emerald-600 flex items-center gap-1 select-none pr-1"> <span className="text-[9px] font-black text-emerald-600 flex items-center gap-1 select-none pr-1">
<CheckCircle size={11} /> Stocked <CheckCircle size={11} /> Stocked
@@ -1013,45 +877,7 @@ export default function InventoryView({
<h3>Cooperative Catalog Presets</h3> <h3>Cooperative Catalog Presets</h3>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-sm"> <AwaitingApi label="Catalog presets" api="[R5]" compact />
{/* 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>
</div> </div>
{/* Custom CSV Parsing Box */} {/* 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> <span className="text-[9px] text-zinc-500 font-bold">COIMBATORE_ERP_V4</span>
</div> </div>
<div className="space-y-2 max-h-48 overflow-y-auto pr-1"> <AwaitingApi label="Import / sync audit log" api="[R4]" compact className="bg-zinc-900/40 border-zinc-800" />
{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>
</div> </div>
</div> </div>
@@ -1130,115 +937,7 @@ export default function InventoryView({
<h3>Packaging Branding Studio</h3> <h3>Packaging Branding Studio</h3>
</div> </div>
<div className="space-y-md text-xs"> <AwaitingApi label="Brand & packaging config" api="[R6]" />
{/* 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>
</div> </div>
</div> </div>
@@ -1302,10 +1001,13 @@ export default function InventoryView({
onChange={(e) => setNewProduct({ ...newProduct, category: e.target.value })} 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" className="w-full border border-[#e2e8f0] rounded-lg p-2 bg-[#f8fafc] focus:bg-white outline-none"
> >
<option value="Staples / Rice">Staples / Rice</option> {/* Keep the form's default selection valid even if it isn't in the live list. */}
<option value="Groceries / Oils">Groceries / Oils</option> {!productCategoryNames.includes(newProduct.category) && (
<option value="Beverages / Coffee">Beverages / Coffee</option> <option value={newProduct.category}>{newProduct.category}</option>
<option value="Fresh Produce / Veg">Fresh Produce / Veg</option> )}
{productCategoryNames.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select> </select>
</div> </div>
</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, XCircle,
FolderSync, FolderSync,
UploadCloud, UploadCloud,
FileCheck,
Download, Download,
AlertOctagon,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { initialImportLogs } from '../data';
import { InventoryItem } from '../types'; import { InventoryItem } from '../types';
import { import {
useFiestaStockStatement, useFiestaStockStatement,
@@ -32,6 +29,7 @@ import {
} from '../services/fiestaQueries'; } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr } from '../services/fiestaApi'; import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr } from '../services/fiestaApi';
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers'; import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi';
interface OperationsViewProps { interface OperationsViewProps {
searchQuery: string; 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). // Dynamic state arrays for interaction (seeded from live data once it loads).
const [inventoryList, setInventoryList] = useState<InventoryItem[]>([]); const [inventoryList, setInventoryList] = useState<InventoryItem[]>([]);
const [productList, setProductList] = useState<ReturnType<typeof stockRowToProduct>[]>([]); const [productList, setProductList] = useState<ReturnType<typeof stockRowToProduct>[]>([]);
const [importLogs, setImportLogs] = useState(initialImportLogs);
useEffect(() => { useEffect(() => {
if (stockQ.data) { 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"> <p className="text-[11px] font-sans font-bold text-zinc-500 uppercase tracking-widest">
Fulfillment Health Fulfillment Health
</p> </p>
<h3 className="font-sans font-bold text-[#0f172a] text-xl mt-xs">98.4%</h3> <AwaitingApi label="Fulfillment health" api="[R1]" compact className="mt-xs" />
<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>
</div> </div>
<div className="p-2.5 rounded-lg bg-emerald-50 text-emerald-600 animate-pulse"> <div className="p-2.5 rounded-lg bg-emerald-50 text-emerald-600 animate-pulse">
<PackageCheck size={18} /> <PackageCheck size={18} />
@@ -376,10 +370,12 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
<span className="text-[10px] tracking-wider font-bold opacity-60 uppercase"> <span className="text-[10px] tracking-wider font-bold opacity-60 uppercase">
Forecast Efficiency Forecast Efficiency
</span> </span>
<p className="font-sans font-bold text-3xl mt-sm">92%</p> <AwaitingApi
<p className="text-zinc-300 text-xs mt-sm leading-relaxed"> label="Forecast insights"
AI-Driven automated replenishment is saving an estimated 1.9L/week in system overstock costs. api="[R7]"
</p> compact
className="mt-sm bg-white/5 border-white/15 text-zinc-300"
/>
</div> </div>
{/* Embedded SVG graphic visual */} {/* Embedded SVG graphic visual */}
@@ -451,7 +447,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
Master Assortment Catalogue Master Assortment Catalogue
</h3> </h3>
<p className="text-zinc-500 text-xs font-sans mt-0.5"> <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> </p>
</div> </div>
@@ -553,15 +549,6 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
onClick={() => { onClick={() => {
const fileRef = prompt('Enter CSV filename representation path:'); const fileRef = prompt('Enter CSV filename representation path:');
if (fileRef) { 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.'); alert('Uploaded successfully. Metadata schema verification committed.');
} }
}} }}
@@ -602,50 +589,7 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
Interactive Schema Validator Interactive Schema Validator
</span> </span>
<div className="space-y-sm"> <AwaitingApi label="Import audit & validation" api="[R4]" />
<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>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -12,7 +12,6 @@ import {
UserCheck, UserCheck,
MapPin, MapPin,
TrendingUp, TrendingUp,
Plus,
ChevronRight, ChevronRight,
Package, Package,
ArrowRight, ArrowRight,
@@ -28,9 +27,11 @@ import {
useFiestaDeliveries, useFiestaDeliveries,
useFiestaDeliverySummary, useFiestaDeliverySummary,
useFiestaRiders, useFiestaRiders,
useFiestaOrderDetails,
} from '../services/fiestaQueries'; } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi'; import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
import { deliveryRowToOrder } from '../services/fiestaMappers'; import { deliveryRowToOrder } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi';
interface OrdersDeliveriesViewProps { interface OrdersDeliveriesViewProps {
searchQuery?: string; searchQuery?: string;
@@ -43,7 +44,6 @@ interface DeliveryExecutive {
name: string; name: string;
phone: string; phone: string;
status: 'Active Duty' | 'Idle' | 'Offline'; status: 'Active Duty' | 'Idle' | 'Offline';
rating: number;
completedToday: number; completedToday: number;
currentZone: string; currentZone: string;
avatar: 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', name: fstr(row.fullname) || `${fstr(row.firstname)} ${fstr(row.lastname)}`.trim() || 'Rider',
phone: fstr(row.contactno) || '—', phone: fstr(row.contactno) || '—',
status: fstr(row.starttime) ? 'Active Duty' : 'Idle', status: fstr(row.starttime) ? 'Active Duty' : 'Idle',
rating: 4.7,
completedToday: fnum(row.completed) || fnum(row.deliverycount), completedToday: fnum(row.completed) || fnum(row.deliverycount),
currentZone: fstr(row.city) || fstr(row.vehiclename) || fstr(row.vehicleno) || 'Coimbatore', currentZone: fstr(row.city) || fstr(row.vehiclename) || fstr(row.vehicleno) || 'Coimbatore',
avatar: RIDER_AVATARS[idx % RIDER_AVATARS.length], 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 activeDispatchCount = storeOrders.filter(o => o.status === 'OUT_FOR_DELIVERY').length;
const completedDeliveriesCount = storeOrders.filter(o => o.status === 'DELIVERED').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']; // Live line-item details for the currently selected order. The deliveries board
const MOCK_STREETS = ['Avarampalayam Rd', 'DB Road', 'Cross Cut Road', 'Avinashi Road', 'Trichy Road', 'NSR Road', 'Sathy Road', 'Marudhamalai Road']; // only carries an itemCount; the actual basket lines come from this endpoint.
const MOCK_ITEMS = [ const orderDetailsQ = useFiestaOrderDetails(selectedOrder?.id ?? null);
{ name: 'Tata Salt Premium Iodized 1kg', price: 28 }, const orderItems = (orderDetailsQ.data ?? []).map((row) => {
{ name: 'Gold Winner Sunflower Oil 1L', price: 145 }, const quantity = fnum(row.quantity) || fnum(row.qty);
{ name: 'Britannia Marie Gold Biscuit 250g', price: 35 }, const price = fnum(row.price) || fnum(row.unitprice);
{ name: 'MTR Sambar Powder 200g', price: 85 }, const lineTotal = fnum(row.amount) || price * quantity;
{ name: 'Aavin Salted Butter 500g', price: 260 }, return {
{ name: 'Ponni Boiled Rice 5kg', price: 380 }, name: fstr(row.productname) || fstr(row.itemname) || 'Item',
{ name: 'Fresh Ooty Carrots 500g', price: 45 }, quantity,
{ name: 'Nescafe Classic Coffee 100g', price: 185 }, price,
]; lineTotal,
const handleCreateMockOrder = () => {
const randomName = MOCK_NAMES[Math.floor(Math.random() * MOCK_NAMES.length)];
const randomStreet = MOCK_STREETS[Math.floor(Math.random() * MOCK_STREETS.length)];
const numItems = Math.floor(Math.random() * 3) + 1; // 1 to 3 items
const selectedItems = [];
let amount = 0;
for (let k = 0; k < numItems; k++) {
const it = MOCK_ITEMS[Math.floor(Math.random() * MOCK_ITEMS.length)];
const qty = Math.floor(Math.random() * 2) + 1;
selectedItems.push({ name: it.name, quantity: qty, price: it.price });
amount += it.price * qty;
}
const newId = `ORD-${Math.floor(100000 + Math.random() * 900000)}`;
const newOrder: CustomerOrder = {
id: newId,
customerName: randomName,
phone: `9${Math.floor(100000000 + Math.random() * 900000000)}`,
address: `${Math.floor(10 + Math.random() * 190)}, ${randomStreet}, Coimbatore`,
items: selectedItems,
amount,
time: new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
status: 'PROCESSING',
assignedRider: 'Pending Assignment',
hub: locationid ? `Outlet Node #${locationid}` : 'Coimbatore Hub',
locationid: locationid ?? 1097,
};
setOrders(prev => [newOrder, ...prev]);
setSelectedOrder(newOrder);
};
const handleUpdateStatus = (newStatus: CustomerOrder['status']) => {
if (!selectedOrder) return;
setOrders(prev => prev.map(o => {
if (o.id === selectedOrder.id) {
const updated = { ...o, status: newStatus };
setSelectedOrder(updated);
return updated;
}
return o;
}));
};
const handleAssignRider = (riderName: string) => {
if (!selectedOrder) return;
setOrders(prev => prev.map(o => {
if (o.id === selectedOrder.id) {
const updated = {
...o,
assignedRider: riderName,
status: o.status === 'PROCESSING' ? 'CONFIRMED' : o.status
};
setSelectedOrder(updated);
return updated;
}
return o;
}));
}; };
});
return ( return (
<div className="space-y-lg animate-in fade-in duration-500"> <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"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter text-xs font-sans">
{/* Left List of Customer App Orders */} {/* Left List of Customer App Orders */}
<div className="lg:col-span-2 space-y-md"> <div className="lg:col-span-2 flex">
<div className="bg-white border border-[#e2e8f0] rounded-xl shadow-sm overflow-hidden flex flex-col justify-between"> <div className="bg-white border border-[#e2e8f0] rounded-xl shadow-sm overflow-hidden flex flex-col h-full w-full min-h-[32rem]">
<div> <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"> <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 className="flex flex-col sm:flex-row justify-between sm:items-center gap-sm">
<div> <div>
<h4 className="font-sans font-bold text-sm text-[#0f172a]"> <h4 className="font-sans font-bold text-sm text-[#0f172a]">
@@ -359,14 +302,6 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
</h4> </h4>
<p className="text-[10px] text-zinc-400 font-medium mt-0.5">Interactive list of customer purchases made via client app</p> <p className="text-[10px] text-zinc-400 font-medium mt-0.5">Interactive list of customer purchases made via client app</p>
</div> </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>
<div className="flex flex-col sm:flex-row items-center gap-sm w-full"> <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>
</div> </div>
{/* Order item rows */} {/* Order item rows — flex-fills the column so the feed matches the Order Details card height */}
<div className="divide-y divide-[#f1f5f9] max-h-[480px] overflow-y-auto"> <div className="divide-y divide-[#f1f5f9] flex-1 min-h-0 overflow-y-auto">
{filteredOrdersList.length === 0 ? ( {filteredOrdersList.length === 0 ? (
<div className="p-xl text-center text-zinc-400 font-medium"> <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> </div>
) : ( ) : (
filteredOrdersList.map(order => ( filteredOrdersList.map(order => (
@@ -448,41 +383,6 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
</div> </div>
</div> </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> </div>
{/* Right column — Order Details, shown parallel to the orders feed */} {/* Right column — Order Details, shown parallel to the orders feed */}
@@ -513,19 +413,24 @@ export default function OrdersDeliveriesView({ searchQuery = '', isCoimbatoreVie
<div> <div>
<span className="text-[10px] text-zinc-400 font-bold uppercase tracking-wide block mb-sm">Ordered Grocery basket Items:</span> <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"> <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"> <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="font-medium">{selectedOrder.itemCount ?? 0} line item(s)</span>
<span className="text-[10px] text-zinc-400">Detail lines not loaded on board view</span> <span className="text-[10px] text-zinc-400">Detail lines not loaded on board view</span>
</div> </div>
)} )}
{selectedOrder.items.map((item, idx) => ( {orderItems.map((item, idx) => (
<div key={idx} className="py-2 flex justify-between items-center text-xs"> <div key={idx} className="py-2 flex justify-between items-center text-xs">
<div> <div>
<p className="font-bold text-[#0f172a]">{item.name}</p> <p className="font-bold text-[#0f172a]">{item.name}</p>
<p className="text-[10px] text-zinc-400">Qty: {item.quantity} x {item.price}</p> <p className="text-[10px] text-zinc-400">Qty: {item.quantity} x {item.price}</p>
</div> </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>
))} ))}
<div className="pt-2 flex justify-between items-center font-bold text-sm text-[#581c87] border-t border-dashed border-[#e2e8f0]"> <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>
</div> </div>
{/* Interactive Status advancement controls */} {/* Live GPS route tracker — no rider-telemetry/GPS API yet */}
<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 */}
{selectedOrder.status === 'OUT_FOR_DELIVERY' && ( {selectedOrder.status === 'OUT_FOR_DELIVERY' && (
<div className="space-y-xs pt-xs"> <div className="space-y-xs pt-xs">
<span className="text-[9px] font-sans font-bold text-zinc-400 uppercase tracking-widest block"> <span className="text-[9px] font-sans font-bold text-zinc-400 uppercase tracking-widest block">
LIVE GPS ROUTE TRACKER LIVE GPS ROUTE TRACKER
</span> </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"> <AwaitingApi label="Live rider GPS & ETA" api="[R9]" compact />
{/* 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>
</div> </div>
)} )}

View File

@@ -32,6 +32,7 @@ import {
} from '../services/fiestaQueries'; } from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi'; import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
import { stockRowToProduct } from '../services/fiestaMappers'; import { stockRowToProduct } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi';
interface ReportsViewProps { interface ReportsViewProps {
searchQuery: string; 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 [selectedCell, setSelectedCell] = useState<{ day: string; hour: string; val: number } | null>(null);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [chartMetric, setChartMetric] = useState<'orders' | 'revenue' | 'cancelled' | 'skus'>('orders'); const [chartMetric, setChartMetric] = useState<'orders' | 'revenue' | 'cancelled' | 'skus'>('orders');
const [hoveredPoint, setHoveredPoint] = useState<number | null>(null);
const [expandedProductId, setExpandedProductId] = useState<string | null>(null); const [expandedProductId, setExpandedProductId] = useState<string | null>(null);
const [exportingFormat, setExportingFormat] = useState<'PDF' | 'CSV' | null>(null); const [exportingFormat, setExportingFormat] = useState<'PDF' | 'CSV' | null>(null);
const [exportProgress, setExportProgress] = useState(0); const [exportProgress, setExportProgress] = useState(0);
@@ -78,11 +78,17 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
// ── Live analytics (Fiesta) ─────────────────────────────────────────────── // ── Live analytics (Fiesta) ───────────────────────────────────────────────
const today = new Date(); const today = new Date();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const yearStart = new Date(today.getFullYear(), 0, 1); const yearStart = new Date(today.getFullYear(), 0, 1);
const todate = ymd(today); 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 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 locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
const insightQ = useFiestaOrderInsight(FIESTA_TENANT_ID); const insightQ = useFiestaOrderInsight(FIESTA_TENANT_ID);
const stockQ = useFiestaStockStatement({ const stockQ = useFiestaStockStatement({
@@ -94,94 +100,17 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
}); });
const s = summaryQ.data; const s = summaryQ.data;
const prevS = prevSummaryQ.data;
const activeSkus = (stockQ.data ?? []).length; const activeSkus = (stockQ.data ?? []).length;
// Base YTD data pool // Real period-over-period % change (null when we can't compute it yet).
const CHART_DATA_YTD = [ const pctChange = (current: number, previous: number): number | null => {
{ label: 'Jan', orders: 240, revenue: 78000, cancelled: 15, skus: 120 }, if (previous <= 0) return null;
{ label: 'Feb', orders: 310, revenue: 98000, cancelled: 10, skus: 125 }, return ((current - previous) / previous) * 100;
{ 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;
}; };
const ordersDelta = s && prevS ? pctChange(s.total, prevS.total) : null;
const getRegionScale = () => { const cancelledDelta = s && prevS ? pctChange(s.cancelled, prevS.cancelled) : null;
if (selectedRegion === 'coimbatore') return 0.42; const fmtDelta = (d: number) => `${d >= 0 ? '+' : ''}${d.toFixed(1)}%`;
if (selectedRegion === 'chennai') return 0.60;
if (selectedRegion === 'bangalore') return 0.75;
return 1.0;
};
const currentChartData = getDynamicChartData();
// Dynamic sparkline generator helper // Dynamic sparkline generator helper
const getSparkPath = (values: number[], width: number, height: number) => { const getSparkPath = (values: number[], width: number, height: number) => {
@@ -195,46 +124,6 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
}).join(' '); }).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 // Tab thematic config
const getChartColors = () => { const getChartColors = () => {
switch (chartMetric) { switch (chartMetric) {
@@ -274,57 +163,61 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
}; };
const theme = getChartColors(); const theme = getChartColors();
// Region specific calculations for KPIs // Live KPI values (tenant-wide; region scaling removed — no per-region API).
const scale = getRegionScale(); const totalOrdersVal = s?.total ?? 0;
const scaleCancelled = selectedRegion === 'coimbatore' ? 0.35 : selectedRegion === 'chennai' ? 0.50 : selectedRegion === 'bangalore' ? 0.65 : 1.0; const deliveredVal = s?.delivered ?? 0;
const scaleSkus = selectedRegion === 'coimbatore' ? 0.85 : selectedRegion === 'chennai' ? 0.90 : selectedRegion === 'bangalore' ? 0.95 : 1.0; const cancelledVal = s?.cancelled ?? 0;
const activeSkusVal = activeSkus;
const totalOrdersVal = Math.round((s?.total ?? 0) * scale); // KPI Row Configuration. `awaiting` cards have no live value (rendered via
const deliveredVal = Math.round((s?.delivered ?? 0) * scale); // AwaitingApi). `trend` is only set where a REAL delta could be derived.
const cancelledVal = Math.round((s?.cancelled ?? 0) * scaleCancelled);
const activeSkusVal = Math.round(activeSkus * scaleSkus);
// KPI Row Configuration
const reportsKPIs = [ const reportsKPIs = [
{ {
id: 'orders' as const, id: 'orders' as const,
title: 'Orders', title: 'Orders',
value: totalOrdersVal.toLocaleString('en-IN'), value: totalOrdersVal.toLocaleString('en-IN'),
trend: `+12.5%`, trend: ordersDelta !== null ? fmtDelta(ordersDelta) : null,
status: `${deliveredVal.toLocaleString('en-IN')} filled`, status: `${deliveredVal.toLocaleString('en-IN')} filled`,
isPositive: true, isPositive: ordersDelta === null ? true : ordersDelta >= 0,
spark: [30, 45, 35, 60, 55, 70, 65, 80], 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, id: 'revenue' as const,
title: 'Revenue', title: 'Revenue',
value: `${(deliveredVal * 355).toLocaleString('en-IN')}`, value: '',
trend: `+14.8%`, trend: null,
status: `Growth steady`, status: '',
isPositive: true, isPositive: true,
spark: [20, 30, 25, 45, 40, 55, 50, 68], spark: [20, 30, 25, 45, 40, 55, 50, 68],
color: 'emerald' color: 'emerald',
awaiting: true,
}, },
{ {
id: 'cancelled' as const, id: 'cancelled' as const,
title: 'Cancelled', title: 'Cancelled',
value: cancelledVal.toLocaleString('en-IN'), value: cancelledVal.toLocaleString('en-IN'),
trend: `-1.2%`, // Lower cancellations is good, so a negative delta is "positive".
status: `${Math.round((s?.created ?? 0) * scaleCancelled)} active`, trend: cancelledDelta !== null ? fmtDelta(cancelledDelta) : null,
isPositive: false, status: `${(s?.created ?? 0).toLocaleString('en-IN')} active`,
isPositive: cancelledDelta === null ? false : cancelledDelta <= 0,
spark: [15, 10, 8, 12, 5, 9, 4, 3], spark: [15, 10, 8, 12, 5, 9, 4, 3],
color: 'rose' color: 'rose',
awaiting: false,
}, },
{ {
id: 'skus' as const, id: 'skus' as const,
title: 'Active SKUs', title: 'Active SKUs',
value: activeSkusVal.toLocaleString('en-IN'), value: activeSkusVal.toLocaleString('en-IN'),
trend: `+8.4%`, // SKU delta value itself was fabricated — show no trend chip.
trend: null,
status: `All verified`, status: `All verified`,
isPositive: true, isPositive: true,
spark: [50, 50, 55, 60, 60, 68, 70, 72], 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 getFilteredLocations = () => {
const rawLocations = [...(locSummaryQ.data ?? [])]; 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') { if (selectedRegion === 'coimbatore') {
return rawLocations.filter(r => isCoimbatoreNode(r.locationname || '')); return rawLocations.filter(r => isCoimbatoreNode(r.locationname || ''));
} }
if (selectedRegion === 'chennai') { if (selectedRegion === 'chennai' || selectedRegion === 'bangalore') {
return [ 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[];
} }
return rawLocations; return rawLocations;
@@ -372,32 +255,22 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
rank: String(i + 1).padStart(2, '0'), rank: String(i + 1).padStart(2, '0'),
name: r.locationname || `Location ${r.locationid}`, name: r.locationname || `Location ${r.locationid}`,
percentage: max > 0 ? Math.round((r.total / max) * 100) : 0, 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; 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 = (() => { 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 ?? []); let rows = (insightQ.data ?? []);
if (selectedRegion === 'coimbatore') { if (selectedRegion === 'coimbatore') {
rows = rows.filter(r => isCoimbatoreNode(fstr(r.locationname))); rows = rows.filter(r => isCoimbatoreNode(fstr(r.locationname)));
} else if (selectedRegion === 'chennai' || selectedRegion === 'bangalore') {
rows = [];
} }
return rows.map((r) => ({ return rows.map((r) => ({
name: fstr(r.locationname) || `Location ${fstr(r.locationid)}`, 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' }} /> <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) ── */} {/* ── 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) */} {/* 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"> <div className="absolute inset-0 z-0 overflow-hidden rounded-2xl">
<img <img
@@ -481,7 +354,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
</div> </div>
{/* Content Row */} {/* 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> <div>
<h1 className="font-sans font-bold text-2xl tracking-tight text-white flex items-center gap-2"> <h1 className="font-sans font-bold text-2xl tracking-tight text-white flex items-center gap-2">
Business Intelligence Center Business Intelligence Center
@@ -634,13 +507,13 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
</div> </div>
<div className="mt-2"> <div className="mt-2">
<h3 className="text-xl font-extrabold tracking-tight font-mono"> <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> </h3>
<p className="text-[10px] text-slate-400 font-semibold mt-1">Segment Volume</p> <p className="text-[10px] text-slate-400 font-semibold mt-1">Segment Volume</p>
</div> </div>
</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="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"> <div className="flex justify-between items-start">
<span className="text-[10px] text-slate-400 uppercase tracking-widest font-bold">Gross Revenue</span> <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> </div>
<div className="mt-2"> <div className="mt-2">
<h3 className="text-xl font-extrabold tracking-tight font-mono"> <AwaitingApi label="Gross Revenue" api="[R1]" compact />
{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>
</div> </div>
</div> </div>
</div> </div>
@@ -726,16 +596,24 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
</div> </div>
{/* Main Metric Value and Trend Badge */} {/* Main Metric Value and Trend Badge */}
{kpi.awaiting ? (
<div className="mt-3">
<AwaitingApi label="Revenue" api="[R1]" compact />
</div>
) : (
<div className="mt-3 flex items-baseline gap-2"> <div className="mt-3 flex items-baseline gap-2">
<div className="font-sans font-black text-slate-900 text-3xl tracking-tight leading-none"> <div className="font-sans font-black text-slate-900 text-3xl tracking-tight leading-none">
{kpi.value} {kpi.value}
</div> </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' <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.isPositive ? '▲' : '▼'}
{kpi.trend} {kpi.trend}
</span> </span>
)}
</div> </div>
)}
{/* Bottom Sparkline & Subtext segment */} {/* Bottom Sparkline & Subtext segment */}
<div className="flex items-center justify-between mt-auto pt-3 w-full border-t border-[#f1f5f9]"> <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>
</div> </div>
{/* SVG Custom Graph Area */} {/* Plotted Area — no time-series API ([R2]) for orders/revenue/skus.
<div className="relative h-64 select-none w-full"> The metric tabs (KPI cards above) still switch the card title; the
<svg className="w-full h-full overflow-visible" viewBox={`0 0 ${chartWidth} ${chartHeight}`}> chart body itself shows the awaiting-backend placeholder. */}
<defs> <div className="relative h-64 select-none w-full flex items-center justify-center">
{/* Indigo Gradients */} <AwaitingApi label="Orders & revenue time-series" api="[R2]" className="w-full h-full justify-center" />
<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>
))}
</div> </div>
</div> </div>
@@ -1204,89 +935,11 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
<tr className="bg-slate-50/20"> <tr className="bg-slate-50/20">
<td colSpan={7} className="p-0 border-b border-[#e2e8f0]"> <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="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"> {/* Per-product stock & location breakdown has no live
API ([R3]); the previously fabricated unit counts,
{/* Inventory Level Progress block */} hub split, bin code, audit date and barcode are
<div className="space-y-2"> replaced with the awaiting-backend placeholder. */}
<span className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider block"> <AwaitingApi label="Per-product stock & location detail" api="[R3]" compact />
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>
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -14,14 +14,13 @@ import {
MapPin, MapPin,
Phone, Phone,
Mail, Mail,
Check,
RotateCcw,
CheckCircle2,
Plus Plus
} from 'lucide-react'; } from 'lucide-react';
import { useFiestaAllTenants, useFiestaTenantLocations } from '../services/fiestaQueries'; 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 { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
import UsersPanel from './UsersPanel'; import UsersPanel from './UsersPanel';
import AwaitingApi from './AwaitingApi';
interface SettingsViewProps { interface SettingsViewProps {
tenantId?: number; tenantId?: number;
@@ -55,8 +54,6 @@ interface MerchantSettings {
sandboxMode: boolean; sandboxMode: boolean;
} }
const STORAGE_KEY = 'merchant-settings-v1';
const DEFAULTS: MerchantSettings = { const DEFAULTS: MerchantSettings = {
contactEmail: '', contactEmail: '',
contactPhone: '', contactPhone: '',
@@ -78,28 +75,6 @@ const DEFAULTS: MerchantSettings = {
sandboxMode: false, 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) => { const formatFriendlyTime = (timeStr: string) => {
try { try {
if (timeStr.includes('T')) { if (timeStr.includes('T')) {
@@ -127,15 +102,6 @@ const formatFriendlyTime = (timeStr: string) => {
}; };
/// ── Small presentational helpers ──────────────────────────────────────────── /// ── 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({ function Row({
title, title,
desc, desc,
@@ -165,73 +131,48 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
const locationsQ = useFiestaTenantLocations(tenantId); const locationsQ = useFiestaTenantLocations(tenantId);
const outlets = locationsQ.data ?? []; const outlets = locationsQ.data ?? [];
// Persisted preferences. // Application roles (Hasura) — drives the role dropdowns.
const initial = useRef(loadSettings()); const rolesQ = useAppRoles();
const [form, setForm] = useState<MerchantSettings>(initial.current.settings);
const [saved, setSaved] = useState<MerchantSettings>(initial.current.settings);
const [toast, setToast] = useState<string | null>(null);
// First-run seeding: if nothing was saved yet, fill contact/min-order/region // In-session workspace preferences. These have NO merchant-settings backend
// from the live tenant once it arrives. // (see [R6]) so they are not persisted; the operational controls that would
const seededRef = useRef(initial.current.hadSaved); // 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(() => { useEffect(() => {
if (seededRef.current || !tenant) return; if (seededRef.current || !tenant) return;
seededRef.current = true; seededRef.current = true;
const seed = (prev: MerchantSettings): MerchantSettings => ({ setForm((prev) => ({
...prev, ...prev,
contactEmail: prev.contactEmail || fstr(tenant.primaryemail), contactEmail: prev.contactEmail || fstr(tenant.primaryemail),
contactPhone: prev.contactPhone || fstr(tenant.primarycontact), contactPhone: prev.contactPhone || fstr(tenant.primarycontact),
minOrderValue: prev.minOrderValue || fnum(tenant.minorder), minOrderValue: prev.minOrderValue || fnum(tenant.minorder),
defaultRegion: prev.defaultRegion || fstr(tenant.city) || 'Coimbatore', defaultRegion: prev.defaultRegion || fstr(tenant.city) || 'Coimbatore',
}); }));
setForm(seed);
setSaved(seed);
}, [tenant]); }, [tenant]);
// Live outlets only — no fabricated fallback. Render whatever the API returns.
const cleanOutlets = useMemo(() => { const cleanOutlets = useMemo(() => {
return outlets.map((loc, idx) => { return outlets.map((loc, idx) => ({
// If the location name is a mock name (doesn't contain store context), replace with Coimbatore locations locationid: fstr(loc.locationid) || String(idx),
const nameStr = fstr(loc.locationname); locationname: fstr(loc.locationname) || '—',
const isMockTest = !nameStr.toLowerCase().includes('stores') && suburb: fstr(loc.suburb),
!nameStr.toLowerCase().includes('outlet') && city: fstr(loc.city),
!nameStr.toLowerCase().includes('hub') && postcode: fstr(loc.postcode),
!nameStr.toLowerCase().includes('ragul'); status: fstr(loc.status) || '—',
opentime: fstr(loc.opentime),
const localData = LOCAL_OUTLETS_DATA[idx % LOCAL_OUTLETS_DATA.length]; closetime: fstr(loc.closetime),
deliverymins: fnum(loc.deliverymins),
return { deliveryradius: fnum(loc.deliveryradius),
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),
};
});
}, [outlets]); }, [outlets]);
const dirty = useMemo(() => JSON.stringify(form) !== JSON.stringify(saved), [form, saved]);
const set = <K extends keyof MerchantSettings>(key: K, value: MerchantSettings[K]) => const set = <K extends keyof MerchantSettings>(key: K, value: MerchantSettings[K]) =>
setForm((f) => ({ ...f, [key]: value })); 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 }> = [ const tabs: Array<{ key: TabKey; label: string; icon: typeof Building2 }> = [
{ key: 'profile', label: 'Business Profile', icon: Building2 }, { key: 'profile', label: 'Business Profile', icon: Building2 },
{ key: 'outlets', label: 'Outlets', icon: Store }, { 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 }, { 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 ( return (
<div className="space-y-lg animate-in fade-in duration-300 relative font-sans text-slate-700"> <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>
</div> </div>
{/* Editable contact (persisted locally) */} {/* Customer support contacts — live (read-only) tenant values. */}
<div className="space-y-sm"> <div className="space-y-sm">
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider block">Customer Support & Contacts</span> <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"> <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> </label>
<input <input
type="email" type="email"
value={form.contactEmail} value={fstr(tenant?.primaryemail) || ''}
onChange={(e) => set('contactEmail', e.target.value)} readOnly
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" 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="store@example.com" placeholder=""
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -420,10 +377,10 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
</label> </label>
<input <input
type="text" type="text"
value={form.contactPhone} value={fstr(tenant?.primarycontact) || ''}
onChange={(e) => set('contactPhone', e.target.value)} readOnly
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" 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="9876543210" placeholder=""
/> />
</div> </div>
</div> </div>
@@ -446,7 +403,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
{locationsQ.isLoading ? ( {locationsQ.isLoading ? (
<div className="text-center py-lg text-slate-400 font-medium text-sm">Loading live outlets</div> <div className="text-center py-lg text-slate-400 font-medium text-sm">Loading live outlets</div>
) : cleanOutlets.length === 0 ? ( ) : 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"> <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) => ( {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="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"> <p className="text-xs text-slate-450 mt-1.5 flex items-center gap-1">
<MapPin size={12} className="shrink-0 text-slate-400" /> <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> </p>
</div> </div>
</div> </div>
@@ -480,20 +437,22 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
<div className="space-y-1"> <div className="space-y-1">
<span className="text-[10px] text-slate-450 uppercase font-bold block">Delivery Range</span> <span className="text-[10px] text-slate-450 uppercase font-bold block">Delivery Range</span>
<p className="font-bold text-slate-700 text-xs"> <p className="font-bold text-slate-700 text-xs">
Up to {loc.deliveryradius / 1000} km {loc.deliveryradius ? `Up to ${loc.deliveryradius / 1000} km` : '—'}
</p> </p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<span className="text-[10px] text-slate-455 uppercase font-bold block">Delivery Speed</span> <span className="text-[10px] text-slate-455 uppercase font-bold block">Delivery Speed</span>
<p className="font-bold text-slate-700 text-xs"> <p className="font-bold text-slate-700 text-xs">
{loc.deliverymins} mins avg {loc.deliverymins ? `${loc.deliverymins} mins avg` : '—'}
</p> </p>
</div> </div>
<div className="space-y-1 col-span-2 border-t border-slate-100 pt-2 mt-1"> <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> <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"> <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" /> <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> </p>
</div> </div>
</div> </div>
@@ -511,144 +470,47 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID }: SettingsVi
{activeTab === 'delivery' && ( {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"> <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>
<div className="space-y-sm"> <span className="text-xs font-bold text-slate-450 uppercase tracking-widest block">Delivery</span>
<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"> <h2 className="text-xl font-bold text-slate-900 mt-1">Order Prep, Timings & Dispatch</h2>
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> </div>
{/* No merchant-settings API yet — these operational controls cannot be persisted live. */}
<AwaitingApi label="Merchant settings persistence" api="[R6]" />
</div> </div>
)} )}
{activeTab === 'payment' && ( {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"> <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"> <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"> <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> </span>
<div className="divide-y divide-slate-100/70 mt-2"> <div className="divide-y divide-slate-100/70 mt-2">
<Row title="Cash on Delivery" desc="Allow customers to pay on delivery."> <Row title="Minimum Order Value" desc="Smallest order a customer can place (from store profile).">
<Toggle checked={form.codEnabled} onChange={() => set('codEnabled', !form.codEnabled)} /> <span className="font-bold text-slate-700 text-sm font-mono">
{tenant && fnum(tenant.minorder) ? `${fnum(tenant.minorder).toLocaleString('en-IN')}` : '—'}
</span>
</Row> </Row>
<Row title="Online Payments" desc="Accept UPI / card / wallet at checkout."> <Row title="Payment Gateway ID" desc="Configured payment type for this store.">
<Toggle checked={form.onlinePaymentEnabled} onChange={() => set('onlinePaymentEnabled', !form.onlinePaymentEnabled)} /> <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> </Row>
</div> </div>
</div> </div>
{/* Group 2: Taxation & Rules */} {/* Editable checkout gateways + tax rules have no persistence backend. */}
<div className="space-y-sm"> <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"> <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> </span>
<div className="divide-y divide-slate-100/70 mt-2"> <AwaitingApi className="mt-2" label="Merchant settings persistence" api="[R6]" />
<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>
</div> </div>
</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" 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) => ( {roleOptions.map((r) => (
<option key={r} value={r}>{roleName(r)}</option> <option key={r.id} value={r.id}>{r.name}</option>
))} ))}
</select> </select>
</Row> </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> </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> </div>
{/* Group 2: Notifications */} {/* Group 2: Notifications, sync interval & sandbox — no persistence backend. */}
<div className="space-y-sm"> <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"> <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> </span>
<div className="divide-y divide-slate-100/70 mt-2"> <AwaitingApi className="mt-2" label="Merchant settings persistence" api="[R6]" />
<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>
</div> </div>
</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>
</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> </div>
); );
} }

View File

@@ -22,13 +22,11 @@ import {
Send, Send,
Download, Download,
X, X,
Battery,
ShieldCheck, ShieldCheck,
Globe, Globe,
UploadCloud, UploadCloud,
FileText, FileText,
Mail, Mail,
UserCheck,
CreditCard, CreditCard,
History, History,
Building, Building,
@@ -38,16 +36,16 @@ import {
import { import {
useFiestaStockStatement, useFiestaStockStatement,
useFiestaTenantCustomers, useFiestaTenantCustomers,
useFiestaCustomerOrders,
useFiestaMasterCatalog,
useFiestaRiders,
FIESTA_TENANT_ID FIESTA_TENANT_ID
} from '../services/fiestaQueries'; } from '../services/fiestaQueries';
import { str as fstr } from '../services/fiestaApi'; import { str as fstr, num as fnum } from '../services/fiestaApi';
import { import { mapOrderStatus, shortTime } from '../services/fiestaMappers';
initialInventory,
initialCustomerOrders,
operationalAlerts
} from '../data';
import ragulStoreCover from '../assets/images/store_front_view_1780299351800.png'; import ragulStoreCover from '../assets/images/store_front_view_1780299351800.png';
import OrdersDeliveriesView from './OrdersDeliveriesView'; import OrdersDeliveriesView from './OrdersDeliveriesView';
import AwaitingApi from './AwaitingApi';
interface StoreDetailViewProps { interface StoreDetailViewProps {
store: { store: {
@@ -61,18 +59,15 @@ interface StoreDetailViewProps {
color: string; color: string;
status: string; status: string;
}; };
onBack: () => void; /** Returns to the stores registry. Omitted when the view stands alone (e.g. a
* single user is scoped to just this store), in which case no back button shows. */
onBack?: () => void;
/** Whether to show write/management actions (replenish, broadcast, import,
* catalogue, promo/credit). Admins get true; plain store users get false so
* the console is view-only. */
canManage?: boolean;
} }
// ── Master Global Catalogue Items ──
const GLOBAL_CATALOGUE_ITEMS = [
{ sku: 'SALT-TATA-1KG', name: 'Tata Salt Premium Iodized 1kg', category: 'Staples / Salt', price: 28, image: 'https://images.unsplash.com/photo-1626132647523-66f5bf380027?auto=format&fit=crop&w=150&q=80' },
{ sku: 'SUN-OIL-1LIT', name: 'Gold Winner Sunflower Oil 1L', category: 'Groceries / Oils', price: 145, image: 'https://images.unsplash.com/photo-1474979266404-7eaacbcd87c5?auto=format&fit=crop&w=150&q=80' },
{ sku: 'BISCUIT-MAR-GD', name: 'Britannia Marie Gold Biscuit 250g', category: 'Snacks / Biscuits', price: 35, image: 'https://images.unsplash.com/photo-1558961363-fa8fdf82db35?auto=format&fit=crop&w=150&q=80' },
{ sku: 'SPICE-SAMBAR-MTR', name: 'MTR Sambar Powder 200g', category: 'Groceries / Spices', price: 85, image: 'https://images.unsplash.com/photo-1596040033229-a9821ebd058d?auto=format&fit=crop&w=150&q=80' },
{ sku: 'AAVIN-BUTTER-500', name: 'Aavin Salted Butter 500g', category: 'Dairy / Butter', price: 260, image: 'https://images.unsplash.com/photo-1589985270826-4b7bb135bc9d?auto=format&fit=crop&w=150&q=80' }
];
// Fallback cover images // Fallback cover images
const DETAIL_STORE_COVERS = [ const DETAIL_STORE_COVERS = [
'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=800&q=80', 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=800&q=80',
@@ -89,7 +84,7 @@ const DETAIL_STORE_COVERS = [
'https://images.unsplash.com/photo-1579621970563-ebec7560ff3e?auto=format&fit=crop&w=800&q=80' 'https://images.unsplash.com/photo-1579621970563-ebec7560ff3e?auto=format&fit=crop&w=800&q=80'
]; ];
export default function StoreDetailView({ store, onBack }: StoreDetailViewProps) { export default function StoreDetailView({ store, onBack, canManage = true }: StoreDetailViewProps) {
const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'customers' | 'orders'>('overview'); const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'customers' | 'orders'>('overview');
const isRagul = store.name.toLowerCase().includes('ragul'); const isRagul = store.name.toLowerCase().includes('ragul');
@@ -105,7 +100,6 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
const storeCoverImage = getStoreCover(); const storeCoverImage = getStoreCover();
const [stockSearch, setStockSearch] = useState(''); const [stockSearch, setStockSearch] = useState('');
const [customerSearch, setCustomerSearch] = useState(''); const [customerSearch, setCustomerSearch] = useState('');
const [hoveredChartIndex, setHoveredChartIndex] = useState<number | null>(null);
// ── Toast Notification state ────────────────────────────────────────────── // ── Toast Notification state ──────────────────────────────────────────────
const [toast, setToast] = useState<{ show: boolean; message: string; type: 'success' | 'info' | 'warning' }>({ const [toast, setToast] = useState<{ show: boolean; message: string; type: 'success' | 'info' | 'warning' }>({
@@ -150,104 +144,63 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
locationid, locationid,
pagesize: 100 pagesize: 100
}); });
// Live active rider fleet for this tenant (powers KPI fleet count + fleet list)
// ── Seed / Fallback calculation helpers ──────────────────────────────────── const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID });
const parseOrdersCount = (salesStr: string): number => { // Master catalogue rows for the Global Catalogue modal
const num = parseInt(salesStr.replace(/[^0-9]/g, ''), 10); const masterCatalogQ = useFiestaMasterCatalog({
return isNaN(num) ? 45 : num; tenantid: FIESTA_TENANT_ID,
locationid,
pagesize: 100
});
// Past orders for the currently-open CRM drawer customer (disabled when no id)
const customerOrdersQ = useFiestaCustomerOrders({
customerid: selectedCustomer?.id ?? null,
pagesize: 20
});
const customerOrderHistory = (customerOrdersQ.data ?? []).map((row: any) => {
const amount = fnum(row.orderamount) || fnum(row.amount);
return {
id: fstr(row.orderid) || `ORD-${fstr(row.orderheaderid)}`,
date: shortTime(row.orderdate || row.createddate),
amount: amount > 0 ? `${amount.toLocaleString('en-IN')}` : '—',
status: mapOrderStatus(fstr(row.orderstatus))
}; };
});
const baseOrders = parseOrdersCount(store.sales); // Inventory mapping (live only)
const revenueToday = baseOrders > 100 ? Math.round(baseOrders * 320) : 48200;
// ── Interval slots with heights for chart representation ──────────────────
const intervalSlots = [
{ time: '06:00 AM - 10:00 AM', label: 'Morning Rush', orders: Math.round(store.deliveries * 0.35) || 14, sales: `${Math.round(revenueToday * 0.3).toLocaleString('en-IN')}`, height: '70%', status: 'PEAK' },
{ time: '10:00 AM - 02:00 PM', label: 'Mid-day Deliveries', orders: Math.round(store.deliveries * 0.25) || 10, sales: `${Math.round(revenueToday * 0.25).toLocaleString('en-IN')}`, height: '50%', status: 'NORMAL' },
{ time: '02:00 PM - 06:00 PM', label: 'Afternoon Dispatch', orders: Math.round(store.deliveries * 0.15) || 6, sales: `${Math.round(revenueToday * 0.15).toLocaleString('en-IN')}`, height: '30%', status: 'LOW' },
{ time: '06:00 PM - 10:00 PM', label: 'Evening Surge', orders: Math.round(store.deliveries * 0.2) || 8, sales: `${Math.round(revenueToday * 0.25).toLocaleString('en-IN')}`, height: '45%', status: 'HIGH' },
{ time: '10:00 PM - 06:00 AM', label: 'Night Prep', orders: Math.round(store.deliveries * 0.05) || 2, sales: `${Math.round(revenueToday * 0.05).toLocaleString('en-IN')}`, height: '15%', status: 'LOW' }
];
const activeSlotIndex = hoveredChartIndex !== null ? hoveredChartIndex : 0;
const activeSlot = intervalSlots[activeSlotIndex];
// Past 7 Days Log
const pastDaysLog = [
{ day: 'Wednesday (Today)', orders: store.deliveries || 40, sales: `${revenueToday.toLocaleString('en-IN')}`, rate: '98.2%', change: '+4.5%' },
{ day: 'Tuesday', orders: Math.round(store.deliveries * 0.9) || 36, sales: `${Math.round(revenueToday * 0.88).toLocaleString('en-IN')}`, rate: '100%', change: '+1.2%' },
{ day: 'Monday', orders: Math.round(store.deliveries * 0.85) || 34, sales: `${Math.round(revenueToday * 0.82).toLocaleString('en-IN')}`, rate: '96.8%', change: '-2.1%' },
{ day: 'Sunday', orders: Math.round(store.deliveries * 1.2) || 48, sales: `${Math.round(revenueToday * 1.15).toLocaleString('en-IN')}`, rate: '94.5%', change: '+12.4%' },
{ day: 'Saturday', orders: Math.round(store.deliveries * 1.15) || 46, sales: `${Math.round(revenueToday * 1.12).toLocaleString('en-IN')}`, rate: '99.1%', change: '+8.6%' },
{ day: 'Friday', orders: Math.round(store.deliveries * 0.95) || 38, sales: `${Math.round(revenueToday * 0.92).toLocaleString('en-IN')}`, rate: '97.4%', change: '+2.0%' },
{ day: 'Thursday', orders: Math.round(store.deliveries * 0.9) || 36, sales: `${Math.round(revenueToday * 0.89).toLocaleString('en-IN')}`, rate: '100%', change: '+0.5%' }
];
// Inventory mapping (derived + live merges)
const getMergedInventory = () => { const getMergedInventory = () => {
const rawStock = stockQ.data ?? []; const rawStock = stockQ.data ?? [];
const resolveMetadata = (name: string) => { // Cosmetic image fallback only — keyword → stock photo. This MUST NOT be
// used to fabricate prices; a missing live price renders as "—".
const resolveImage = (name: string) => {
const nameLower = name.toLowerCase(); const nameLower = name.toLowerCase();
let price = 60; if (nameLower.includes('rice')) return 'https://images.unsplash.com/photo-1586201375761-83865001e31c?auto=format&fit=crop&w=150&q=80';
let image = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=150&q=80'; if (nameLower.includes('oil')) return 'https://images.unsplash.com/photo-1474979266404-7eaacbcd87c5?auto=format&fit=crop&w=150&q=80';
if (nameLower.includes('coffee')) return 'https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?auto=format&fit=crop&w=150&q=80';
if (nameLower.includes('rice')) { if (nameLower.includes('carrot')) return 'https://images.unsplash.com/photo-1598170845058-32b9d6a5da37?auto=format&fit=crop&w=150&q=80';
price = 1400; if (nameLower.includes('ghee') || nameLower.includes('butter')) return 'https://images.unsplash.com/photo-1589985270826-4b7bb135bc9d?auto=format&fit=crop&w=150&q=80';
image = 'https://images.unsplash.com/photo-1586201375761-83865001e31c?auto=format&fit=crop&w=150&q=80'; if (nameLower.includes('salt')) return 'https://images.unsplash.com/photo-1626132647523-66f5bf380027?auto=format&fit=crop&w=150&q=80';
} else if (nameLower.includes('oil')) { if (nameLower.includes('atta') || nameLower.includes('flour')) return 'https://images.unsplash.com/photo-1574316071802-0d684efa7bf5?auto=format&fit=crop&w=150&q=80';
price = 340; return 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=150&q=80';
image = 'https://images.unsplash.com/photo-1474979266404-7eaacbcd87c5?auto=format&fit=crop&w=150&q=80';
} else if (nameLower.includes('coffee')) {
price = 195;
image = 'https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?auto=format&fit=crop&w=150&q=80';
} else if (nameLower.includes('carrot')) {
price = 60;
image = 'https://images.unsplash.com/photo-1598170845058-32b9d6a5da37?auto=format&fit=crop&w=150&q=80';
} else if (nameLower.includes('ghee')) {
price = 320;
image = 'https://images.unsplash.com/photo-1589985270826-4b7bb135bc9d?auto=format&fit=crop&w=150&q=80';
} else if (nameLower.includes('butter')) {
price = 260;
image = 'https://images.unsplash.com/photo-1589985270826-4b7bb135bc9d?auto=format&fit=crop&w=150&q=80';
} else if (nameLower.includes('salt')) {
price = 28;
image = 'https://images.unsplash.com/photo-1626132647523-66f5bf380027?auto=format&fit=crop&w=150&q=80';
} else if (nameLower.includes('atta') || nameLower.includes('flour')) {
price = 440;
image = 'https://images.unsplash.com/photo-1574316071802-0d684efa7bf5?auto=format&fit=crop&w=150&q=80';
}
return { price, image };
}; };
if (rawStock.length > 0) { // Live stock only — no hardcoded fallback. Empty live data → empty state.
return rawStock.map((item: any) => { return rawStock.map((item: any) => {
const name = fstr(item.productname) || fstr(item.name) || 'Product Item'; const name = fstr(item.productname) || fstr(item.name) || 'Product Item';
const meta = resolveMetadata(name); const livePrice = fnum(item.retailprice) || fnum(item.price) || fnum(item.productcost);
return { return {
sku: fstr(item.sku) || fstr(item.productsku) || 'SKU-UNKNOWN', sku: fstr(item.sku) || fstr(item.productsku) || `SKU-${fstr(item.productid)}` || 'SKU-UNKNOWN',
name, name,
category: fstr(item.subcategoryname) || fstr(item.categoryname) || 'Groceries / Staples', category: fstr(item.subcategoryname) || fstr(item.categoryname) || 'Groceries / Staples',
stockLevel: Number(item.physicalstock) || Number(item.stock) || 0, stockLevel: Number(item.physicalstock) || Number(item.stock) || fnum(item.closing) || 0,
maxCapacity: Number(item.maxcapacity) || 500, maxCapacity: Number(item.maxcapacity) || 500,
status: (Number(item.physicalstock) || 0) < 50 ? 'Critical' : (Number(item.physicalstock) || 0) < 150 ? 'Low Stock' : 'Optimal', status: (Number(item.physicalstock) || fnum(item.closing) || 0) < 50 ? 'Critical' : (Number(item.physicalstock) || fnum(item.closing) || 0) < 150 ? 'Low Stock' : 'Optimal',
price: meta.price, // Real price or null — never a guessed number.
image: meta.image price: livePrice > 0 ? livePrice : null,
image: fstr(item.productimage) || resolveImage(name)
}; };
}); });
}
// High fidelity fallback seeded catalog if live API results are empty
return [
{ sku: 'RICE-PN-50', name: 'Premium Ponni Rice Bag 25kg', category: 'Staples / Rice', stockLevel: Math.round(baseOrders * 1.2) || 450, maxCapacity: 1000, status: 'Optimal', price: 1400, image: 'https://images.unsplash.com/photo-1586201375761-83865001e31c?auto=format&fit=crop&w=150&q=80' },
{ sku: 'ATTA-ASH-10', name: 'Aashirvaad Chakki Atta 10kg', category: 'Staples / Flour', stockLevel: 12, maxCapacity: 120, status: 'Critical', price: 440, image: 'https://images.unsplash.com/photo-1574316071802-0d684efa7bf5?auto=format&fit=crop&w=150&q=80' },
{ sku: 'OIL-IDH-05', name: 'Idhayam Sesame Oil Can 5L', category: 'Groceries / Oils', stockLevel: 32, maxCapacity: 150, status: 'Low Stock', price: 340, image: 'https://images.unsplash.com/photo-1474979266404-7eaacbcd87c5?auto=format&fit=crop&w=150&q=80' },
{ sku: 'COF-NAR-01', name: 'Narasus Filter Coffee 1kg Pack', category: 'Beverages / Coffee', stockLevel: Math.round(baseOrders * 0.5) || 120, maxCapacity: 300, status: 'Optimal', price: 195, image: 'https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?auto=format&fit=crop&w=150&q=80' },
{ sku: 'MILK-AAV-50', name: 'Aavin Premium Pouch Milk 500ml', category: 'Dairy / Milk', stockLevel: 18, maxCapacity: 200, status: 'Critical', price: 28, image: 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=150&q=80' },
{ sku: 'OOTY-CARROT-1KG', name: 'Ooty Fresh Quality Carrots 1kg', category: 'Fresh Produce / Veg', stockLevel: 45, maxCapacity: 100, status: 'Low Stock', price: 60, image: 'https://images.unsplash.com/photo-1598170845058-32b9d6a5da37?auto=format&fit=crop&w=150&q=80' },
{ sku: 'TURMERIC-200G', name: 'Organic Turmeric Powder 200g', category: 'Groceries / Spices', stockLevel: 280, maxCapacity: 300, status: 'Optimal', price: 90, image: 'https://images.unsplash.com/photo-1596040033229-a9821ebd058d?auto=format&fit=crop&w=150&q=80' },
{ sku: 'GHEE-500ML', name: 'Pure Cow Ghee 500ml', category: 'Dairy / Ghee', stockLevel: 75, maxCapacity: 250, status: 'Low Stock', price: 320, image: 'https://images.unsplash.com/photo-1589985270826-4b7bb135bc9d?auto=format&fit=crop&w=150&q=80' }
];
}; };
// Sync loaded stock to state // Sync loaded stock to state
@@ -261,29 +214,27 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
item.category.toLowerCase().includes(stockSearch.toLowerCase()) item.category.toLowerCase().includes(stockSearch.toLowerCase())
); );
// Customer Directory mapping (derived + live merges) // Customer Directory mapping (live only — no hardcoded fallback)
const getMergedCustomers = () => { const getMergedCustomers = () => {
const rawCustomers = customersQ.data ?? []; const rawCustomers = customersQ.data ?? [];
if (rawCustomers.length > 0) { return rawCustomers.map((c: any) => {
return rawCustomers.map((c: any) => ({ const id =
fnum(c.customerid) ||
fnum(c.customerheaderid) ||
fnum(c.userid) ||
fnum(c.id) ||
null;
const spent = fnum(c.totalspent);
return {
id,
name: fstr(c.fullname) || `${fstr(c.firstname)} ${fstr(c.lastname)}`.trim() || 'Customer', name: fstr(c.fullname) || `${fstr(c.firstname)} ${fstr(c.lastname)}`.trim() || 'Customer',
phone: fstr(c.contactno) || '—', phone: fstr(c.contactno) || '—',
email: fstr(c.email),
address: fstr(c.address) || 'Coimbatore', address: fstr(c.address) || 'Coimbatore',
ordersCount: Number(c.orderscount) || Math.floor(Math.random() * 8) + 1, ordersCount: Number(c.orderscount) || 0,
totalSpent: `${(Number(c.totalspent) || Math.floor(Math.random() * 4000) + 500).toLocaleString('en-IN')}`, totalSpent: spent > 0 ? `${spent.toLocaleString('en-IN')}` : '—'
lastOrder: '2 Days ago' };
})); });
}
// High fidelity fallback customer base
return [
{ name: 'Meenakshi Sundaram', phone: '+91 94432 18942', address: 'Plot 4, Lakshmipuram Ext, RS Puram, Coimbatore - 641002', ordersCount: 42, totalSpent: '₹34,820.00', lastOrder: 'Today, 14:24 PM' },
{ name: 'Senthil Kumar VSD', phone: '+91 98421 00234', address: 'Flat 2C, Whispering Palms, Avinashi Road, Peelamedu - 641004', ordersCount: 28, totalSpent: '₹22,910.00', lastOrder: 'Yesterday, 14:10 PM' },
{ name: 'Kavitha Ramaswamy', phone: '+91 90035 88921', address: 'No 15, Cross Cut Road, Gandhipuram, Coimbatore - 641012', ordersCount: 19, totalSpent: '₹14,240.00', lastOrder: '2 Days ago' },
{ name: 'Dr. Anand Selvapandian', phone: '+91 97890 22104', address: 'Villa 12, Sobha Elanza, Sathy Road, Saravanampatti - 641035', ordersCount: 12, totalSpent: '₹9,800.00', lastOrder: '3 Days ago' },
{ name: 'Rajesh Subramaniam', phone: '+91 94421 88902', address: '45, West Club Road, Race Course, Coimbatore - 641018', ordersCount: 64, totalSpent: '₹84,900.00', lastOrder: 'Today, 10:15 AM' },
{ name: 'Priya Krishnan', phone: '+91 90432 11094', address: '8C, Royal Arcade, Trichy Road, Singanallur - 641005', ordersCount: 7, totalSpent: '₹4,120.00', lastOrder: '1 Week ago' }
];
}; };
const customersList = getMergedCustomers().filter(c => const customersList = getMergedCustomers().filter(c =>
@@ -292,19 +243,37 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
c.address.toLowerCase().includes(customerSearch.toLowerCase()) c.address.toLowerCase().includes(customerSearch.toLowerCase())
); );
// Store Alerts specific to the store // ── Active rider fleet (live) ──────────────────────────────────────────────
const storeAlerts = operationalAlerts.filter(alert => // Names + active/idle status come from getriders; battery % and last-ping
alert.title.toLowerCase().includes(store.name.split(' ')[0].toLowerCase()) || // have no API ([R9]) so those fields are intentionally not shown.
alert.details.toLowerCase().includes(store.name.split(' ')[0].toLowerCase()) const activeRiders = (ridersQ.data ?? []).map((row: any) => {
); const name =
fstr(row.fullname) ||
`${fstr(row.firstname)} ${fstr(row.lastname)}`.trim() ||
'Rider';
const parts = name.split(' ').filter(Boolean);
const initial = ((parts[0]?.[0] || '') + (parts.length > 1 ? parts[parts.length - 1][0] : '')).toUpperCase();
return {
name,
initial: initial || 'R',
status: fstr(row.starttime) ? 'Delivering' : 'Idle',
orders: fnum(row.completed) || fnum(row.deliverycount) || 0
};
});
// ── Riders fleet data list ──────────────────────────────────────────────── // ── Global Master Catalogue (live) for the "Add from Catalogue" modal ──────
const activeRiders = [ const globalCatalogueItems = (masterCatalogQ.data ?? []).map((row: any) => {
{ name: 'Karthikeyan Radhakrishnan', initial: 'KR', status: 'Delivering', orders: 3, battery: 92, lastPing: '2m ago' }, const price = fnum(row.retailprice) || fnum(row.price) || fnum(row.productcost);
{ name: 'Arun Kumar Chinnasamy', initial: 'AC', status: 'Delivering', orders: 2, battery: 48, lastPing: '10m ago' }, return {
{ name: 'Suresh Balasubramaniam', initial: 'SB', status: 'Idle', orders: 0, battery: 84, lastPing: 'Just now' }, sku: fstr(row.sku) || fstr(row.productsku) || `SKU-${fstr(row.productid)}` || 'SKU-UNKNOWN',
{ name: 'Manoj Kumar Gowda', initial: 'MG', status: 'Delivering', orders: 1, battery: 14, lastPing: '1m ago' } name: fstr(row.productname) || fstr(row.name) || 'Product Item',
]; category: fstr(row.subcategoryname) || fstr(row.categoryname) || 'Catalogue',
price: price > 0 ? price : null,
image:
fstr(row.productimage) ||
'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=150&q=80'
};
});
// Actions simulation handles // Actions simulation handles
const handleReplenishSubmit = (e: React.FormEvent) => { const handleReplenishSubmit = (e: React.FormEvent) => {
@@ -359,11 +328,11 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
return; return;
} }
const itemsToAdd = GLOBAL_CATALOGUE_ITEMS.filter(item => selectedGlobalSkus.includes(item.sku)).map(item => ({ const itemsToAdd = globalCatalogueItems.filter(item => selectedGlobalSkus.includes(item.sku)).map(item => ({
...item, ...item,
stockLevel: Math.floor(Math.random() * 80) + 20, stockLevel: 0,
maxCapacity: 200, maxCapacity: 200,
status: 'Optimal' status: 'Critical'
})); }));
setLocalInventory(prev => { setLocalInventory(prev => {
@@ -407,7 +376,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
)} )}
{/* ── Subheader Navigation Bar ── */} {/* ── Subheader Navigation Bar ── */}
<div className="flex items-center justify-between"> <div className={`flex items-center ${onBack ? 'justify-between' : 'justify-end'}`}>
{onBack && (
<button <button
onClick={onBack} onClick={onBack}
className="flex items-center gap-xs text-xs font-bold text-[#581c87] hover:text-[#4c1d95] bg-purple-50 hover:bg-purple-100/80 px-xl py-2 rounded-lg transition-all shadow-sm border border-purple-100 cursor-pointer" className="flex items-center gap-xs text-xs font-bold text-[#581c87] hover:text-[#4c1d95] bg-purple-50 hover:bg-purple-100/80 px-xl py-2 rounded-lg transition-all shadow-sm border border-purple-100 cursor-pointer"
@@ -415,6 +385,7 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
<ArrowLeft size={14} /> <ArrowLeft size={14} />
<span>Back to Registry</span> <span>Back to Registry</span>
</button> </button>
)}
<div className="flex items-center gap-xs"> <div className="flex items-center gap-xs">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" /> <span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
@@ -580,10 +551,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
<CheckCircle2 size={16} /> <CheckCircle2 size={16} />
</div> </div>
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block">OTIF Fulfillment</span> <span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block">OTIF Fulfillment</span>
<p className="text-2xl font-black text-[#0f172a] mt-xs">98.2%</p> <div className="mt-xs">
<div className="text-[10px] text-emerald-600 font-bold mt-1 flex items-center gap-xs"> <AwaitingApi label="OTIF fulfillment" api="[R1]" compact />
<span> 0.4%</span>
<span className="text-zinc-400 font-medium">vs past week</span>
</div> </div>
</div> </div>
@@ -592,10 +561,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
<TrendingUp size={16} /> <TrendingUp size={16} />
</div> </div>
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block">Est. Revenue</span> <span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block">Est. Revenue</span>
<p className="text-2xl font-black text-[#0f172a] mt-xs">{revenueToday.toLocaleString('en-IN')}</p> <div className="mt-xs">
<div className="text-[10px] text-emerald-600 font-bold mt-1 flex items-center gap-xs"> <AwaitingApi label="Outlet revenue" api="[R1]" compact />
<span> 12.4%</span>
<span className="text-zinc-400 font-medium">growth threshold</span>
</div> </div>
</div> </div>
@@ -615,10 +582,10 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
<Users size={16} /> <Users size={16} />
</div> </div>
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block">Active Fleet</span> <span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block">Active Fleet</span>
<p className="text-2xl font-black text-[#0f172a] mt-xs">4 Riders</p> <p className="text-2xl font-black text-[#0f172a] mt-xs">{activeRiders.length} Riders</p>
<div className="text-[10px] text-emerald-600 font-bold mt-1 flex items-center gap-xs"> <div className="text-[10px] text-emerald-600 font-bold mt-1 flex items-center gap-xs">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" /> <span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span>3 dispatches live</span> <span>{activeRiders.filter(r => r.status !== 'Idle').length} dispatches live</span>
</div> </div>
</div> </div>
</div> </div>
@@ -626,7 +593,7 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
<div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter">
{/* Interactive Timeline Pipeline Flow */} {/* Interactive Timeline Pipeline Flow */}
<div className="lg:col-span-2 bg-white border border-[#eceef2] rounded-2xl p-lg shadow-sm flex flex-col justify-between hover:shadow-md transition-shadow relative"> <div className={`${canManage ? 'lg:col-span-2' : 'lg:col-span-3'} bg-white border border-[#eceef2] rounded-2xl p-lg shadow-sm flex flex-col justify-between hover:shadow-md transition-shadow relative`}>
<div> <div>
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
@@ -639,139 +606,14 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
</div> </div>
</div> </div>
{/* The Pipeline Line and Nodes */} {/* Intraday dispatch breakdown has no backend yet ([R10]). */}
<div className="relative py-xl px-lg select-none my-xl"> <div className="my-xl">
<AwaitingApi label="Intraday dispatch breakdown" api="[R10]" />
{/* Flow track base line */}
<div className="w-full bg-[#f1f5f9] h-2 rounded-full relative shadow-inner">
{/* Glowing progress fill line */}
<div
className="absolute top-0 left-0 h-full bg-gradient-to-r from-purple-600 via-indigo-500 to-pink-500 rounded-full transition-all duration-700 shadow-[0_0_8px_rgba(99,102,241,0.5)]"
style={{ width: `${(activeSlotIndex / 4) * 100}%` }}
/>
{/* Operational Nodes */}
<div className="absolute inset-0 flex justify-between items-center overflow-visible">
{intervalSlots.map((slot, index) => {
const isActive = activeSlotIndex === index;
const isHovered = hoveredChartIndex === index;
// Status colors
let dotColor = 'bg-indigo-500';
let ringColor = 'border-indigo-100 hover:border-indigo-300';
let rippleColor = 'bg-indigo-400';
if (slot.status === 'PEAK') {
dotColor = 'bg-rose-500';
ringColor = isActive ? 'border-rose-300 bg-rose-50/50' : 'border-rose-100 hover:border-rose-300';
rippleColor = 'bg-rose-400';
} else if (slot.status === 'HIGH') {
dotColor = 'bg-amber-500';
ringColor = isActive ? 'border-amber-300 bg-amber-50/50' : 'border-amber-100 hover:border-amber-300';
rippleColor = 'bg-amber-400';
} else if (slot.status === 'LOW') {
dotColor = 'bg-emerald-500';
ringColor = isActive ? 'border-emerald-300 bg-emerald-50/50' : 'border-emerald-100 hover:border-emerald-300';
rippleColor = 'bg-emerald-400';
}
return (
<div
key={index}
onMouseEnter={() => setHoveredChartIndex(index)}
onMouseLeave={() => setHoveredChartIndex(null)}
onClick={() => setHoveredChartIndex(index)}
className="relative flex flex-col items-center cursor-pointer group z-10"
>
{/* Time label above node */}
<div className="absolute bottom-8 whitespace-nowrap text-center flex flex-col items-center">
<span className={`text-[10px] font-black tracking-tight transition-colors ${
isActive ? 'text-[#581c87]' : 'text-zinc-400 group-hover:text-zinc-600'
}`}>
{slot.time.split(' - ')[0]}
</span>
</div>
{/* Node Circle */}
<div className={`w-8 h-8 rounded-full border-2 bg-white flex items-center justify-center transition-all duration-300 shadow-sm ${
isActive ? 'scale-125 border-purple-600 shadow-md' : 'border-zinc-200 hover:border-purple-300'
}`}>
{/* Pulse ripples if active */}
{isActive && (
<span className={`absolute inline-flex h-8 w-8 rounded-full ${rippleColor} opacity-25 animate-ping z-0`} />
)}
{/* Inner dot */}
<div className={`w-3.5 h-3.5 rounded-full transition-transform duration-300 ${dotColor} ${
isActive ? 'scale-110' : 'group-hover:scale-110'
}`} />
</div>
{/* Label and dispatches below node */}
<div className="absolute top-10 whitespace-nowrap text-center flex flex-col items-center">
<span className={`text-[10px] font-bold tracking-tight transition-colors ${
isActive ? 'text-[#0f172a]' : 'text-zinc-500 group-hover:text-zinc-700'
}`}>
{slot.label.split(' ')[0]}
</span>
<span className="text-[8px] font-bold text-zinc-400 uppercase tracking-wider mt-[1px]">
{slot.orders} orders
</span>
</div>
</div>
);
})}
</div> </div>
</div> </div>
</div> {/* Quick Actions Console — management only */}
{canManage && (
{/* Interactive Glassmorphic Stats audit drawer below chart */}
<div className="mt-4 p-md bg-[#faf5ff] border border-[#f3e8ff] rounded-2xl flex flex-col sm:flex-row items-start sm:items-center justify-between gap-md transition-all duration-300 animate-in fade-in duration-200">
<div className="flex items-center gap-md">
<div className={`w-8 h-8 rounded-xl flex items-center justify-center shrink-0 shadow-sm border ${
activeSlot.status === 'PEAK' ? 'bg-rose-50 text-rose-600 border-rose-100' :
activeSlot.status === 'HIGH' ? 'bg-amber-50 text-amber-600 border-amber-100' :
activeSlot.status === 'NORMAL' ? 'bg-sky-50 text-sky-600 border-sky-100' :
'bg-emerald-50 text-emerald-600 border-emerald-100'
}`}>
<Clock size={16} />
</div>
<div>
<span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest block">Audit Interval</span>
<span className="font-extrabold text-sm text-[#0f172a]">{activeSlot.label}</span>
<span className="text-[10px] text-zinc-500 font-semibold block mt-0.5">{activeSlot.time}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-md sm:gap-xl select-none text-xs">
<div className="text-right">
<span className="text-[9px] font-bold text-zinc-400 uppercase tracking-widest block">Dispatches</span>
<span className="font-black text-sm text-[#0f172a]">{activeSlot.orders} dispatches</span>
</div>
<div className="text-right border-l border-purple-100 pl-md sm:pl-xl">
<span className="text-[9px] font-bold text-purple-400 uppercase tracking-widest block">Est. Revenue</span>
<span className="font-black text-sm text-[#581c87]">{activeSlot.sales}</span>
</div>
<div className="text-right border-l border-purple-100 pl-md sm:pl-xl">
<span className="text-[9px] font-bold text-zinc-400 uppercase tracking-widest block">Load level</span>
<span className={`px-2 py-0.5 rounded-lg text-[9px] font-black tracking-wider inline-block mt-0.5 ${
activeSlot.status === 'PEAK' ? 'bg-rose-100 text-rose-700' :
activeSlot.status === 'HIGH' ? 'bg-amber-100 text-amber-700' :
activeSlot.status === 'NORMAL' ? 'bg-sky-100 text-sky-700' :
'bg-emerald-100 text-emerald-700'
}`}>
{activeSlot.status}
</span>
</div>
</div>
</div>
</div>
{/* Quick Actions Console */}
<div className="bg-white border border-[#eceef2] rounded-2xl p-md shadow-sm space-y-md flex flex-col justify-between"> <div className="bg-white border border-[#eceef2] rounded-2xl p-md shadow-sm space-y-md flex flex-col justify-between">
<div> <div>
<h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-xs"> <h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-xs">
@@ -812,6 +654,7 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
</button> </button>
</div> </div>
</div> </div>
)}
</div> </div>
@@ -823,28 +666,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
<Calendar size={15} className="text-[#581c87]" /> Past 7 Days Ledger Log <Calendar size={15} className="text-[#581c87]" /> Past 7 Days Ledger Log
</h3> </h3>
<div className="overflow-x-auto text-xs font-sans"> {/* Daily ledger series has no backend yet ([R2]). */}
<table className="w-full text-left"> <AwaitingApi label="Daily ledger" api="[R2]" />
<thead className="bg-[#f8fafc] border-b border-[#e2e8f0] text-zinc-400 text-[10px] uppercase font-bold tracking-wider">
<tr>
<th className="px-md py-sm">Day Period</th>
<th className="px-md py-sm">Dispatches</th>
<th className="px-md py-sm">Revenue Volume</th>
<th className="px-md py-sm text-right">OTIF fulfillment</th>
</tr>
</thead>
<tbody className="divide-y divide-[#f1f5f9] font-medium text-zinc-700">
{pastDaysLog.map((dayLog, index) => (
<tr key={index} className="hover:bg-zinc-50/50 transition">
<td className="px-md py-md font-bold text-[#0f172a]">{dayLog.day}</td>
<td className="px-md py-md text-zinc-650">{dayLog.orders} dispatches</td>
<td className="px-md py-md text-[#581c87] font-black">{dayLog.sales}</td>
<td className="px-md py-md text-right font-mono text-emerald-600 font-bold">{dayLog.rate}</td>
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
{/* Live Rider fleet list */} {/* Live Rider fleet list */}
@@ -853,11 +676,16 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
<h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-xs"> <h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-xs">
<ShoppingCart size={15} className="text-[#581c87]" /> Active Rider Fleet <ShoppingCart size={15} className="text-[#581c87]" /> Active Rider Fleet
</h3> </h3>
<p className="text-zinc-405 text-[10px] font-sans mt-0.5">Live status and battery tracking of assigned riders.</p> <p className="text-zinc-405 text-[10px] font-sans mt-0.5">Live status of assigned riders for this tenant.</p>
</div> </div>
<div className="divide-y divide-[#f1f5f9] text-xs font-sans mt-md"> <div className="divide-y divide-[#f1f5f9] text-xs font-sans mt-md">
{activeRiders.map((rider, index) => ( {activeRiders.length === 0 ? (
<div className="py-8 text-center text-zinc-400 font-medium">
No active riders assigned yet.
</div>
) : (
activeRiders.map((rider, index) => (
<div key={index} className="py-2 flex justify-between items-center group"> <div key={index} className="py-2 flex justify-between items-center group">
<div className="flex items-center gap-sm min-w-0"> <div className="flex items-center gap-sm min-w-0">
<div className="w-7 h-7 rounded-full bg-purple-50 text-[#581c87] border border-purple-100 font-black text-[10px] flex items-center justify-center shrink-0"> <div className="w-7 h-7 rounded-full bg-purple-50 text-[#581c87] border border-purple-100 font-black text-[10px] flex items-center justify-center shrink-0">
@@ -872,12 +700,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
</div> </div>
</div> </div>
{canManage && (
<div className="flex items-center gap-sm text-right shrink-0"> <div className="flex items-center gap-sm text-right shrink-0">
<div className="flex items-center gap-[2px] font-mono text-[10px] text-zinc-400">
<Battery size={13} className={rider.battery < 20 ? 'text-rose-500' : 'text-zinc-400'} />
<span className={rider.battery < 20 ? 'text-rose-600 font-bold' : ''}>{rider.battery}%</span>
</div>
<button <button
onClick={() => showToast(`SMS alert broadcasted to rider ${rider.name.split(' ')[0]}.`, 'success')} onClick={() => showToast(`SMS alert broadcasted to rider ${rider.name.split(' ')[0]}.`, 'success')}
className="px-2 py-1 border border-zinc-200 rounded-lg hover:border-purple-300 text-[9px] font-bold hover:bg-purple-50/50 hover:text-[#581c87] cursor-pointer transition" className="px-2 py-1 border border-zinc-200 rounded-lg hover:border-purple-300 text-[9px] font-bold hover:bg-purple-50/50 hover:text-[#581c87] cursor-pointer transition"
@@ -885,8 +709,10 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
Ping Ping
</button> </button>
</div> </div>
)}
</div> </div>
))} ))
)}
</div> </div>
</div> </div>
@@ -913,6 +739,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
{/* Actions for Store Catalogue Management */} {/* Actions for Store Catalogue Management */}
<div className="flex flex-wrap items-center gap-sm text-xs shrink-0 select-none"> <div className="flex flex-wrap items-center gap-sm text-xs shrink-0 select-none">
{canManage && (
<>
<button <button
onClick={() => { setImportState('idle'); setShowImportModal(true); }} onClick={() => { setImportState('idle'); setShowImportModal(true); }}
className="px-3 py-2 bg-purple-50 text-[#581c87] hover:bg-purple-100/80 border border-purple-100 rounded-xl font-bold flex items-center gap-xs cursor-pointer transition shadow-sm" className="px-3 py-2 bg-purple-50 text-[#581c87] hover:bg-purple-100/80 border border-purple-100 rounded-xl font-bold flex items-center gap-xs cursor-pointer transition shadow-sm"
@@ -930,6 +758,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
</button> </button>
<span className="h-6 w-[1px] bg-zinc-200 mx-xs hidden md:block" /> <span className="h-6 w-[1px] bg-zinc-200 mx-xs hidden md:block" />
</>
)}
<span className="px-3 py-2 bg-rose-50 border border-rose-100 rounded-lg text-rose-700 font-bold"> <span className="px-3 py-2 bg-rose-50 border border-rose-100 rounded-lg text-rose-700 font-bold">
{inventoryList.filter(item => item.status === 'Critical').length} Critical {inventoryList.filter(item => item.status === 'Critical').length} Critical
@@ -959,13 +789,13 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
<th className="px-md py-sm">Est. Price</th> <th className="px-md py-sm">Est. Price</th>
<th className="px-md py-sm">Capacity Load</th> <th className="px-md py-sm">Capacity Load</th>
<th className="px-md py-sm">Status</th> <th className="px-md py-sm">Status</th>
<th className="px-md py-sm text-right">Replenish</th> {canManage && <th className="px-md py-sm text-right">Replenish</th>}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-[#f1f5f9] font-medium text-zinc-700"> <tbody className="divide-y divide-[#f1f5f9] font-medium text-zinc-700">
{inventoryList.length === 0 ? ( {inventoryList.length === 0 ? (
<tr> <tr>
<td colSpan={7} className="text-center py-10 text-zinc-400 font-medium"> <td colSpan={canManage ? 7 : 6} className="text-center py-10 text-zinc-400 font-medium">
No product stocks found matching the search keyword. No product stocks found matching the search keyword.
</td> </td>
</tr> </tr>
@@ -1004,7 +834,7 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
</span> </span>
</td> </td>
<td className="px-md py-md font-bold text-zinc-800"> <td className="px-md py-md font-bold text-zinc-800">
{item.price.toLocaleString('en-IN')} {item.price != null ? `${item.price.toLocaleString('en-IN')}` : '—'}
</td> </td>
<td className="px-md py-md w-44"> <td className="px-md py-md w-44">
<div className="flex items-center gap-sm"> <div className="flex items-center gap-sm">
@@ -1030,6 +860,7 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
{item.status} {item.status}
</span> </span>
</td> </td>
{canManage && (
<td className="px-md py-md text-right"> <td className="px-md py-md text-right">
<button <button
onClick={() => setReplenishModal({ show: true, item })} onClick={() => setReplenishModal({ show: true, item })}
@@ -1042,6 +873,7 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
Replenish Replenish
</button> </button>
</td> </td>
)}
</tr> </tr>
); );
}) })
@@ -1069,16 +901,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
/> />
</div> </div>
<div className="flex gap-sm text-xs shrink-0 select-none font-bold"> <div className="shrink-0 w-full sm:w-auto sm:min-w-[18rem]">
<span className="px-3 py-1.5 bg-purple-50 text-[#581c87] border border-purple-100 rounded-lg"> <AwaitingApi label="Customer analytics" api="[R11]" compact />
Retention Rate: 88.4%
</span>
<span className="px-3 py-1.5 bg-sky-50 text-sky-700 border border-sky-100 rounded-lg">
AOV: 1,580
</span>
<span className="px-3 py-1.5 bg-emerald-50 text-emerald-700 border border-emerald-100 rounded-lg">
CSAT Index: 4.9/5
</span>
</div> </div>
</div> </div>
@@ -1139,12 +963,14 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
<td className="px-md py-md text-zinc-700 font-bold">{c.ordersCount} orders</td> <td className="px-md py-md text-zinc-700 font-bold">{c.ordersCount} orders</td>
<td className="px-md py-md text-[#581c87] font-black">{c.totalSpent}</td> <td className="px-md py-md text-[#581c87] font-black">{c.totalSpent}</td>
<td className="px-md py-md text-right space-x-sm shrink-0"> <td className="px-md py-md text-right space-x-sm shrink-0">
{canManage && (
<button <button
onClick={() => showToast(`Voucher promo code successfully dispatched to ${c.phone}.`, 'success')} onClick={() => showToast(`Voucher promo code successfully dispatched to ${c.phone}.`, 'success')}
className="px-2.5 py-1 border border-zinc-200 hover:border-purple-300 rounded-lg font-bold text-[10px] text-zinc-650 hover:bg-purple-50/50 hover:text-[#581c87] cursor-pointer transition" className="px-2.5 py-1 border border-zinc-200 hover:border-purple-300 rounded-lg font-bold text-[10px] text-zinc-650 hover:bg-purple-50/50 hover:text-[#581c87] cursor-pointer transition"
> >
Promo SMS Promo SMS
</button> </button>
)}
<button <button
onClick={() => setSelectedCustomer(c)} onClick={() => setSelectedCustomer(c)}
className="px-2.5 py-1 bg-[#0f172a] hover:bg-zinc-800 text-white rounded-lg font-bold text-[10px] cursor-pointer transition shadow-sm" className="px-2.5 py-1 bg-[#0f172a] hover:bg-zinc-800 text-white rounded-lg font-bold text-[10px] cursor-pointer transition shadow-sm"
@@ -1350,8 +1176,13 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
Choose master items from the national database to stock and commission locally at <strong>{store.name}</strong>. Choose master items from the national database to stock and commission locally at <strong>{store.name}</strong>.
</p> </p>
{globalCatalogueItems.length === 0 ? (
<div className="py-8 text-center text-zinc-400 font-medium">
No catalogue products available yet.
</div>
) : (
<div className="space-y-sm divide-y divide-[#f1f5f9]"> <div className="space-y-sm divide-y divide-[#f1f5f9]">
{GLOBAL_CATALOGUE_ITEMS.map((item) => { {globalCatalogueItems.map((item) => {
const isChecked = selectedGlobalSkus.includes(item.sku); const isChecked = selectedGlobalSkus.includes(item.sku);
return ( return (
<div <div
@@ -1380,11 +1211,12 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
<p className="text-[9px] text-zinc-450 font-bold uppercase tracking-wider">{item.category} · SKU: {item.sku}</p> <p className="text-[9px] text-zinc-450 font-bold uppercase tracking-wider">{item.category} · SKU: {item.sku}</p>
</div> </div>
</div> </div>
<span className="font-bold text-zinc-800 shrink-0">{item.price}</span> <span className="font-bold text-zinc-800 shrink-0">{item.price != null ? `${item.price.toLocaleString('en-IN')}` : '—'}</span>
</div> </div>
); );
})} })}
</div> </div>
)}
</div> </div>
<div className="p-md border-t border-[#f1f5f9] flex justify-end gap-sm bg-[#f8fafc] shrink-0"> <div className="p-md border-t border-[#f1f5f9] flex justify-end gap-sm bg-[#f8fafc] shrink-0">
@@ -1423,8 +1255,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
</div> </div>
<div> <div>
<h4 className="font-sans font-black text-base text-[#0f172a]">{selectedCustomer.name}</h4> <h4 className="font-sans font-black text-base text-[#0f172a]">{selectedCustomer.name}</h4>
<span className="inline-flex items-center gap-xs px-2 py-0.5 rounded text-[8px] bg-purple-100 text-purple-700 font-black tracking-wider uppercase mt-1"> <span className="text-[10px] text-zinc-400 font-semibold">
<UserCheck size={9} /> High Value Account {selectedCustomer.ordersCount} orders · {selectedCustomer.totalSpent} spent
</span> </span>
</div> </div>
</div> </div>
@@ -1450,7 +1282,7 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
</div> </div>
<div className="flex items-center gap-sm py-1 border-b border-[#f1f5f9]"> <div className="flex items-center gap-sm py-1 border-b border-[#f1f5f9]">
<Mail size={13} className="text-zinc-400 shrink-0" /> <Mail size={13} className="text-zinc-400 shrink-0" />
<span>{selectedCustomer.name.toLowerCase().replace(/[^a-z]/g, '')}@gmail.com</span> <span>{selectedCustomer.email || ''}</span>
</div> </div>
<div className="flex items-start gap-sm py-1"> <div className="flex items-start gap-sm py-1">
<MapPin size={13} className="text-zinc-400 shrink-0 mt-0.5" /> <MapPin size={13} className="text-zinc-400 shrink-0 mt-0.5" />
@@ -1460,7 +1292,8 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
</div> </div>
{/* Metric grid info overlay */} {/* Metric grid info overlay */}
<div className="grid grid-cols-3 gap-sm select-none"> <div className="space-y-sm select-none">
<div className="grid grid-cols-2 gap-sm">
<div className="bg-[#faf5ff] border border-[#f3e8ff] p-sm rounded-xl text-center"> <div className="bg-[#faf5ff] border border-[#f3e8ff] p-sm rounded-xl text-center">
<span className="text-[8px] font-black text-purple-400 uppercase tracking-widest block">Dispatches</span> <span className="text-[8px] font-black text-purple-400 uppercase tracking-widest block">Dispatches</span>
<span className="font-black text-sm text-[#581c87] mt-xs block">{selectedCustomer.ordersCount} dispatches</span> <span className="font-black text-sm text-[#581c87] mt-xs block">{selectedCustomer.ordersCount} dispatches</span>
@@ -1469,56 +1302,42 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
<span className="text-[8px] font-black text-sky-500 uppercase tracking-widest block">Gross spend</span> <span className="text-[8px] font-black text-sky-500 uppercase tracking-widest block">Gross spend</span>
<span className="font-black text-sm text-sky-700 mt-xs block">{selectedCustomer.totalSpent}</span> <span className="font-black text-sm text-sky-700 mt-xs block">{selectedCustomer.totalSpent}</span>
</div> </div>
<div className="bg-emerald-50/50 border border-emerald-100 p-sm rounded-xl text-center">
<span className="text-[8px] font-black text-emerald-500 uppercase tracking-widest block">CSAT Score</span>
<span className="font-black text-sm text-emerald-700 mt-xs block">5.0 / 5.0</span>
</div> </div>
{/* CSAT / AOV / retention have no backend yet ([R11]). */}
<AwaitingApi label="Customer analytics" api="[R11]" compact />
</div> </div>
{/* Simulated Customer Order History ledger */} {/* Live Customer Order History ledger */}
<div className="space-y-sm"> <div className="space-y-sm">
<h5 className="font-sans font-bold text-xs text-[#0f172a] flex items-center gap-xs"> <h5 className="font-sans font-bold text-xs text-[#0f172a] flex items-center gap-xs">
<History size={14} className="text-[#581c87]" /> Past Interactions & Orders <History size={14} className="text-[#581c87]" /> Past Interactions & Orders
</h5> </h5>
{customerOrdersQ.isLoading ? (
<p className="py-4 text-center text-[10px] text-zinc-400 font-semibold uppercase tracking-wider">Loading order history</p>
) : customerOrderHistory.length === 0 ? (
<p className="py-4 text-center text-[10px] text-zinc-400 font-semibold uppercase tracking-wider">No past orders for this customer.</p>
) : (
<div className="divide-y divide-[#f1f5f9] select-none text-[11px] font-semibold text-zinc-650"> <div className="divide-y divide-[#f1f5f9] select-none text-[11px] font-semibold text-zinc-650">
<div className="py-2.5 flex justify-between items-center"> {customerOrderHistory.map((order, index) => (
<div key={order.id || index} className="py-2.5 flex justify-between items-center">
<div className="space-y-0.5"> <div className="space-y-0.5">
<span className="text-[#0f172a]">DM-ORD-2091</span> <span className="text-[#0f172a]">{order.id}</span>
<p className="text-[9px] text-zinc-400 font-medium">{selectedCustomer.lastOrder}</p> <p className="text-[9px] text-zinc-400 font-medium">{order.date}</p>
</div> </div>
<div className="text-right"> <div className="text-right">
<span className="font-mono text-[#581c87] font-bold">{selectedCustomer.totalSpent}</span> <span className="font-mono text-[#581c87] font-bold">{order.amount}</span>
<span className="px-1.5 py-0.2 bg-emerald-50 text-emerald-600 rounded text-[7px] font-black tracking-wider block mt-0.5 uppercase">Delivered</span> <span className="px-1.5 py-0.2 bg-emerald-50 text-emerald-600 rounded text-[7px] font-black tracking-wider block mt-0.5 uppercase">{order.status.replace(/_/g, ' ')}</span>
</div>
</div>
))}
</div>
)}
</div> </div>
</div> </div>
<div className="py-2.5 flex justify-between items-center opacity-70"> {/* Quick Actions in Side Drawer — management only */}
<div className="space-y-0.5"> {canManage && (
<span className="text-[#0f172a]">DM-ORD-1982</span>
<p className="text-[9px] text-zinc-400 font-medium">1 Month ago</p>
</div>
<div className="text-right">
<span className="font-mono text-zinc-700 font-bold">1,240.00</span>
<span className="px-1.5 py-0.2 bg-emerald-50 text-emerald-600 rounded text-[7px] font-black tracking-wider block mt-0.5 uppercase">Delivered</span>
</div>
</div>
<div className="py-2.5 flex justify-between items-center opacity-60">
<div className="space-y-0.5">
<span className="text-[#0f172a]">DM-ORD-1721</span>
<p className="text-[9px] text-zinc-400 font-medium">2 Months ago</p>
</div>
<div className="text-right">
<span className="font-mono text-zinc-700 font-bold">2,840.00</span>
<span className="px-1.5 py-0.2 bg-emerald-50 text-emerald-600 rounded text-[7px] font-black tracking-wider block mt-0.5 uppercase">Delivered</span>
</div>
</div>
</div>
</div>
</div>
{/* Quick Actions in Side Drawer */}
<div className="p-lg bg-[#f8fafc] border-t border-[#e2e8f0] grid grid-cols-2 gap-sm shrink-0"> <div className="p-lg bg-[#f8fafc] border-t border-[#e2e8f0] grid grid-cols-2 gap-sm shrink-0">
<button <button
onClick={() => { onClick={() => {
@@ -1540,6 +1359,7 @@ export default function StoreDetailView({ store, onBack }: StoreDetailViewProps)
Issue Store Credit Issue Store Credit
</button> </button>
</div> </div>
)}
</div> </div>
</div> </div>
)} )}

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 Coins
} from 'lucide-react'; } from 'lucide-react';
import { useFiestaUsers, useFiestaCreateUser } from '../services/fiestaQueries'; 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 { interface UsersPanelProps {
tenantId?: number; 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' }, 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) { export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUserRole = 4 }: UsersPanelProps) {
const usersQ = useFiestaUsers({ tenantid: tenantId, pagesize: 100 }); const usersQ = useFiestaUsers({ tenantid: tenantId, pagesize: 100 });
const createUserMut = useFiestaCreateUser(); 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 [search, setSearch] = useState('');
const [userRoleFilter, setUserRoleFilter] = useState<number | 'ALL'>('ALL'); 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"> <div className="space-y-2">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">SELECT ACCOUNT ROLE (*)</label> <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"> <div className="grid grid-cols-2 gap-2.5">
{[ {roleChoices.map((r) => {
{ 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) => {
const isSelected = newUser.roleid === r.id; const isSelected = newUser.roleid === r.id;
const Icon = r.icon; const Icon = r.icon;
return ( 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-xl: 32px;
--spacing-gutter: 20px; --spacing-gutter: 20px;
--spacing-container-margin: 24px; --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 // DELIVERIES
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
@@ -248,6 +270,48 @@ export async function getDeliveryInsight(tenantid: number): Promise<Row[]> {
return toRows(await fiestaGet('deliveries/getdeliveryinsight', { tenantid })); 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 // 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 // USERS
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════

View File

@@ -14,6 +14,7 @@
import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query';
import type { Row } from './fiestaApi'; import type { Row } from './fiestaApi';
import { loginRequest, matchTenantUser, buildAuthUser, type AuthUser } from './auth';
import { import {
FIESTA_TENANT_ID, FIESTA_TENANT_ID,
FIESTA_APPLOCATION_ID, FIESTA_APPLOCATION_ID,
@@ -25,6 +26,10 @@ import {
getDeliverySummary, getDeliverySummary,
getDeliveries, getDeliveries,
getDeliveryInsight, getDeliveryInsight,
getDeliveryReport,
getFleetSummary,
getOrderDetails,
getCustomerOrders,
getRiders, getRiders,
getRiderShifts, getRiderShifts,
getTenantLocations, getTenantLocations,
@@ -32,6 +37,11 @@ import {
getTenantCustomers, getTenantCustomers,
getStockStatement, getStockStatement,
getProductsCount, getProductsCount,
getProductStocks,
getProductLocations,
getMasterCatalog,
getProductCategories,
getProductSubcategories,
getAllUsers, getAllUsers,
getUserById, getUserById,
createUser, createUser,
@@ -55,6 +65,15 @@ export const fiestaKeys = {
tenantCustomers: (params: Record<string, unknown>) => ['fiesta', 'tenantCustomers', params] as const, tenantCustomers: (params: Record<string, unknown>) => ['fiesta', 'tenantCustomers', params] as const,
stockStatement: (params: Record<string, unknown>) => ['fiesta', 'stockStatement', params] as const, stockStatement: (params: Record<string, unknown>) => ['fiesta', 'stockStatement', params] as const,
productsCount: (params: Record<string, unknown>) => ['fiesta', 'productsCount', 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, users: (params: Record<string, unknown>) => ['fiesta', 'users', params] as const,
user: (userid: number) => ['fiesta', 'user', userid] 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 ───────────────────────────────────────────────────────────────────── // ── Users ─────────────────────────────────────────────────────────────────────
export function useFiestaUsers(opts: { export function useFiestaUsers(opts: {
tenantid: number; 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 }; export { FIESTA_TENANT_ID, FIESTA_APPLOCATION_ID, FIESTA_PRIMARY_LOCATION_ID };