udpates on the ui changesand api integration
This commit is contained in:
BIN
Fiesta_All_Endpoints_With_Tables_And_Params.xlsx
Normal file
BIN
Fiesta_All_Endpoints_With_Tables_And_Params.xlsx
Normal file
Binary file not shown.
112
docs/01_ENDPOINT_CATALOG.md
Normal file
112
docs/01_ENDPOINT_CATALOG.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Fiesta / Workolik Endpoint Catalog
|
||||||
|
|
||||||
|
Source: `Fiesta_All_Endpoints_With_Tables_And_Params.xlsx` (103 endpoints) cross-referenced
|
||||||
|
with the REST clients already in this repo (`src/services/api.ts` → Hasura `/hasura/*`,
|
||||||
|
`src/services/fiestaApi.ts` → Fiesta `/fiesta/*`).
|
||||||
|
|
||||||
|
Legend for **Client** column:
|
||||||
|
- ✅ **wired** — a function + TanStack hook already exists in this repo.
|
||||||
|
- 🟡 **available** — endpoint exists on the backend (in the sheet) but we have **no client yet**; safe to add.
|
||||||
|
- 🔴 **REVIEW_REQUIRED** — the sheet marks the table as `REVIEW_REQUIRED` (mostly POST/PUT auth & mutation routes); shape must be confirmed with backend before wiring.
|
||||||
|
|
||||||
|
> The sheet only documents GET read tables + a handful of mutations. It does **not**
|
||||||
|
> contain any analytics/aggregation, time-series, alerting, telemetry, or audit-log
|
||||||
|
> endpoints. Those gaps are listed in `03_REQUIRED_BACKEND_APIS.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App / Config
|
||||||
|
| API | Method | Table | Params | Client |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Utils | GET | app_types | tag | ✅ `getAppTypes` |
|
||||||
|
| Resolve subcategories | GET | app_subcategory | moduleid, categoryid | ✅ `getSubcategory` |
|
||||||
|
| System active geofence | GET | app_location | applocationid | ✅ `getAppLocations` |
|
||||||
|
| Global system categories | GET | app_category | None | ✅ `getProductCategories`* |
|
||||||
|
| Global payment & geofence configs | GET | app_config | configid | 🟡 available |
|
||||||
|
| App location config | GET | app_locationconfig | applocationid | ✅ `getAppLocationConfig` |
|
||||||
|
|
||||||
|
## Users / Auth
|
||||||
|
| API | Method | Table | Params | Client |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Users list | GET | users | roleid, tenantid, pageno, pagesize, keyword | ✅ `getAllUsers` |
|
||||||
|
| User profile by id | GET | users | userid | ✅ `getUserById` |
|
||||||
|
| Tenant Web Panel Login | POST | REVIEW_REQUIRED | — | ✅ `loginRequest` (weblogin) |
|
||||||
|
| General Application Login | POST | REVIEW_REQUIRED | authname, password, configid, userfcmtoken | ✅ `loginRequest` (applogin) |
|
||||||
|
| Register Web Staff | POST | REVIEW_REQUIRED | — | ✅ `createUser` |
|
||||||
|
| Update Web Staff | PUT | REVIEW_REQUIRED | — | ✅ `updateUser` |
|
||||||
|
| App roles | GET | (approles) | configid | ✅ `getAppRoles` |
|
||||||
|
|
||||||
|
## Orders
|
||||||
|
| API | Method | Table | Params | Client |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Orders board | GET | orders | tenantid, locationid, status, pageno, pagesize | ✅ `getOrders` |
|
||||||
|
| System Admin orders board | GET | orders | applocationid, status, pageno, pagesize | 🟡 available |
|
||||||
|
| Order dashboard stats summary | GET | orders | tenantid, fromdate, todate | ✅ `getOrderSummary` |
|
||||||
|
| Annual orders insights | GET | orders | tenantid | ✅ `getOrderInsight` |
|
||||||
|
| Location orders summary | GET | deliveries | tenantid | ✅ `getLocationSummary` |
|
||||||
|
| **Order detailed lines** | GET | orderdetails | orderheaderid | 🟡 **available — not wired (needed for line items)** |
|
||||||
|
| Customer order history | GET | orders | customerid, status, pageno, pagesize | 🟡 available |
|
||||||
|
| Create Web Order | POST | orders+orderdetails | — | 🔴 REVIEW_REQUIRED |
|
||||||
|
| Update Order Status | PUT | orders | — | 🔴 REVIEW_REQUIRED |
|
||||||
|
|
||||||
|
## Deliveries
|
||||||
|
| API | Method | Table | Params | Client |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Deliveries board (master) | GET | deliveries | tenantid, fromdate, todate | ✅ `getDeliveries` |
|
||||||
|
| Deliveries summary | GET | deliveries | tenantid, applocationid, fromdate, todate | ✅ `getDeliverySummary` |
|
||||||
|
| Daily delivery insights | GET | deliveries | tenantid | ✅ `getDeliveryInsight` |
|
||||||
|
| Location deliveries summary | GET | deliveries | tenantid | 🟡 available |
|
||||||
|
| **Deliveries financial report summary** | GET | deliveries | tenantid, partnerid, userid, applocationid, fromdate, todate | 🟡 **available — needed for Reports revenue** |
|
||||||
|
| **Fleet rider summary metrics** | GET | partneruser | applocationid, partnerid, tenantid, fromdate, todate | 🟡 **available — needed for fleet KPIs** |
|
||||||
|
| Assign Rider / dispatch | POST | deliveries+deliverydetails | — | 🔴 REVIEW_REQUIRED |
|
||||||
|
| Update pickup / delivered | PUT | deliveries | — | 🔴 REVIEW_REQUIRED |
|
||||||
|
|
||||||
|
## Partners / Riders
|
||||||
|
| API | Method | Table | Params | Client |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Active riders | GET | partneruser | partnerid, applocationid, userid, tenantid | ✅ `getRiders` |
|
||||||
|
| Partner profiles | GET | partneruser | partnerid, applocationid, userid | 🟡 available |
|
||||||
|
| Rider shifts | GET | partneruser | applocationid | ✅ `getRiderShifts` |
|
||||||
|
| Rider log sheet | GET | riderlogs | partnerid, applocationid, fromdate, todate | 🟡 available |
|
||||||
|
| Partners | GET | orders | partnerid, status, pageno, pagesize | ✅ `getPartners` |
|
||||||
|
|
||||||
|
## Tenants / Stores
|
||||||
|
| API | Method | Table | Params | Client |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| All active tenants | GET | tenants | applocationid, status, pageno, pagesize | ✅ `getAllTenants` |
|
||||||
|
| Tenant detail profile | GET | tenants | tenantid, locationid | ✅ `getTenantInfo` |
|
||||||
|
| Tenant outlet locations | GET | tenantlocations | tenantid | ✅ `getTenantLocations` |
|
||||||
|
| Tenant store staff | GET | tenantstaffs | tenantid | 🟡 available |
|
||||||
|
| Logistics pricing slabs | GET | tenantpricing | tenantid, applocationid | 🟡 available |
|
||||||
|
| Delivery time slots config | GET | tenantslot | None | 🟡 available |
|
||||||
|
| Onboard tenant / store / location | POST/PUT | REVIEW_REQUIRED | — | 🔴 REVIEW_REQUIRED |
|
||||||
|
|
||||||
|
## Customers
|
||||||
|
| API | Method | Table | Params | Client |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Customers | GET | customers | customerid, contactno | ✅ `getCustomersByTenant`* |
|
||||||
|
| Merchant customers list | GET | tenantcustomers | tenantid, locationid, pageno, pagesize, keyword | ✅ `getTenantCustomers` |
|
||||||
|
| Customer order history | GET | orders | customerid, status, pageno, pagesize | 🟡 available |
|
||||||
|
| Register / update customer | POST/PUT | REVIEW_REQUIRED | — | 🔴 REVIEW_REQUIRED |
|
||||||
|
|
||||||
|
## Products / Stock
|
||||||
|
| API | Method | Table | Params | Client |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Stock counts | GET | products | tenantid, categoryid, subcategoryid, approve | ✅ `getProductsCount` |
|
||||||
|
| Global categories | GET | productcategories | None | ✅ `getProductCategories` |
|
||||||
|
| Product subcategories | GET | productsubcategories | categoryid, tenantid | ✅ `getProductSubcategories` |
|
||||||
|
| Product variants | GET | productvariants | tenantid, subcategoryid | ✅ `getProductVariants` |
|
||||||
|
| Master catalog listings | GET | products | tenantid, locationid, subcategoryid, keyword, pageno, pagesize | 🟡 available |
|
||||||
|
| **Live stocks catalog** | GET | productstocks | tenantid, locationid | 🟡 **available — real stock levels** |
|
||||||
|
| Stock statement ledger | GET | product_stock_statement | tenantid, locationid, subcategoryid, pageno, pagesize, keyword | ✅ `getStockStatement` |
|
||||||
|
| **Outlet geofenced inventory** | GET | productlocations | tenantid, locationid, subcategoryid, pageno, pagesize | 🟡 **available — per-outlet inventory** |
|
||||||
|
| Create / update / delete product | POST/PUT/DELETE | REVIEW_REQUIRED | — | 🔴 REVIEW_REQUIRED |
|
||||||
|
| Add multi-product stock entry | POST | REVIEW_REQUIRED | — | 🔴 REVIEW_REQUIRED |
|
||||||
|
|
||||||
|
## Invoice / Payments
|
||||||
|
| API | Method | Table | Params | Client |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Invoice insight | GET | invoice_insight | tenantid | ✅ `getInvoiceInsight` |
|
||||||
|
| Payments | GET | paymentrequests | partnerid, status | 🟡 available |
|
||||||
|
|
||||||
|
\* Close match; verify exact route/shape against backend.
|
||||||
173
docs/02_MOCK_DATA_GAP_ANALYSIS.md
Normal file
173
docs/02_MOCK_DATA_GAP_ANALYSIS.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Mock-Data → Live-API Gap Analysis
|
||||||
|
|
||||||
|
Goal: remove every hardcoded / mock value in `src/` and back it with a live API before staging.
|
||||||
|
|
||||||
|
Each row is classified:
|
||||||
|
- **WIRE** — a live endpoint already exists (✅ or 🟡 in `01_ENDPOINT_CATALOG.md`); just connect it / remove the mock fallback.
|
||||||
|
- **DERIVE** — no dedicated endpoint, but the value is computable client-side from data we already fetch (e.g. period-over-period deltas by querying two date ranges). No backend work needed.
|
||||||
|
- **NEW API** — no endpoint exists and the value cannot be derived; backend must build it. Spec'd in `03_REQUIRED_BACKEND_APIS.md` under the referenced **[Rxx]** id.
|
||||||
|
|
||||||
|
Central mock source file to delete once empty: **`src/data.ts`**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. DashboardView.tsx *(mostly live already)*
|
||||||
|
| UI element | Current source | Class | Target |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Active Outlets / Total / % | live `useTenantLocations` + `useOrderSummary` | OK | — |
|
||||||
|
| Region fulfilment %, delivered/total | live `useOrderSummary` | OK | — |
|
||||||
|
| Monthly Revenue, Monthly Profit | live `useInvoiceInsight` | OK | — |
|
||||||
|
| Per-location orders / dispatched / fulfilled % | live `useFiestaLocationSummary` | OK | — |
|
||||||
|
| KPI card icons / gradients / static subtext labels | hardcoded | keep | cosmetic, not data |
|
||||||
|
| Banner title "Executive Command Center", "Live Core" | hardcoded | keep | cosmetic |
|
||||||
|
|
||||||
|
**Dashboard is ~clean.** Only cosmetic strings remain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ReportsView.tsx *(heaviest mock load)*
|
||||||
|
| UI element | Current source | Class | Target |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Orders KPI value / delivered / cancelled | live `useFiestaOrderSummary` | OK | — |
|
||||||
|
| KPI trend deltas `+12.5%`, `+14.8%`, `-1.2%`, `+8.4%` | hardcoded | **DERIVE** | query current + previous period summary, compute % change |
|
||||||
|
| Revenue value `= delivered × 355` | hardcoded multiplier | **NEW API** | **[R1] revenue summary** (real money, not a guess) |
|
||||||
|
| Active SKU count | live stock length | OK | — |
|
||||||
|
| `CHART_DATA_YTD` (12-mo orders/revenue/cancelled/skus) | hardcoded | **NEW API** | **[R2] order+revenue time-series** (orders partly via `getOrderInsight`; revenue & skus need [R1]/[R2]) |
|
||||||
|
| `getDynamicChartData` month/12M/all-time series | hardcoded | **NEW API** | **[R2]** with `granularity=day|month|year` |
|
||||||
|
| Region scaling multipliers (0.42 / 0.60 / 0.75…) | hardcoded fudge | **NEW API** | **[R2]** filtered by region/location — remove multipliers |
|
||||||
|
| Monthly order heatmap — Coimbatore rows | live `useFiestaOrderInsight` | OK | — |
|
||||||
|
| Monthly order heatmap — Chennai/Bangalore stub rows | hardcoded | **WIRE** | `getOrderInsight` returns all tenant locations; remove stubs |
|
||||||
|
| `MONTH_LABELS`, `MONTH_KEYS` | hardcoded | keep | static axis labels |
|
||||||
|
| Top-nodes leaderboard names / order counts | live (location summary) | OK | — |
|
||||||
|
| Leaderboard **revenue** column | derived from order count | **NEW API** | **[R1]** revenue-per-location |
|
||||||
|
| Product matrix rows | live `useFiestaStockStatement`→`stockRowToProduct` | OK | — |
|
||||||
|
| `unitsSold`/`revenue` per product (mapper uses `debit`×`retailprice`) | approximated | **NEW API** | **[R3] product sales analytics** (true units sold + revenue) |
|
||||||
|
| Expanded-row stock units (142/42/6), bar widths, hub split 60/40 | hardcoded | **WIRE/NEW** | per-outlet via `productlocations` 🟡; hub split = real `productstocks` |
|
||||||
|
| Warehouse Bin `BIN-C…`, "Last Audited" date, barcode bars | hardcoded | **NEW API** | **[R3]** product meta (bin, last-audit) — or drop if not tracked |
|
||||||
|
| Export progress modal | simulated | keep | client UX only |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. InventoryView.tsx
|
||||||
|
| UI element | Current source | Class | Target |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Banner active-outlets count | live `useFiestaTenantLocations` | OK | — |
|
||||||
|
| Banner total on-hand volume | live `useFiestaStoresStock` | OK | — |
|
||||||
|
| Global catalog grid | live stock → `stockRowToProduct` | OK | — |
|
||||||
|
| Optimal/Low/Critical hub counts | derived from live stock + thresholds | OK | thresholds are business rules, keep |
|
||||||
|
| Per-store SKU lists, capacity bars | live `useFiestaStoresStock` | OK | — but prefer real capacity ([R3]) |
|
||||||
|
| `initialImportLogs` (Live Sync Audit Stream) | mock `data.ts` | **NEW API** | **[R4] import/sync audit log** |
|
||||||
|
| CSV import default template string | hardcoded | keep | UX template |
|
||||||
|
| Nilgiris Dairy / Coimbatore Heritage presets | hardcoded | **WIRE** | catalog presets → `getAllTenants`/master catalog 🟡, or **NEW [R5]** |
|
||||||
|
| Brand Design Studio (theme, colors, bag label, eco-seal) | hardcoded local state | **NEW API** | **[R6] merchant branding config** (GET+PUT) |
|
||||||
|
| Add-Product modal category list | hardcoded | **WIRE** | `getProductCategories` / `getProductSubcategories` |
|
||||||
|
| Add-Product modal default price/stock | hardcoded | keep | form defaults |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. OperationsView.tsx
|
||||||
|
| UI element | Current source | Class | Target |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Inventory value, low-stock count, SKU count | live `useFiestaStockStatement` | OK | — |
|
||||||
|
| Fulfillment Health `98.4%` | hardcoded | **DERIVE** | from order/delivery summary |
|
||||||
|
| Forecast Efficiency `92%`, Cost savings `₹1.9L/week` | hardcoded | **NEW API** | **[R7] forecasting/insights** (or remove until modelled) |
|
||||||
|
| Catalogue table rows | live `stockRowToProduct` | OK | — |
|
||||||
|
| "Global Inventory Count 4,200 nodes" | hardcoded | **WIRE** | `getProductsCount` |
|
||||||
|
| Catalogue verification toggle | local state only | **NEW API** | product update PUT 🔴 (REVIEW_REQUIRED) |
|
||||||
|
| Import logs table | mock `data.ts` | **NEW API** | **[R4]** (same as Inventory) |
|
||||||
|
| Schema-validator messages ("14 duplicate SKUs") | hardcoded | **NEW API** | **[R4]** import validation result |
|
||||||
|
| Add-SKU / Transfer-Stock modals | local state mutation | **NEW API** | stock entry POST 🔴 + **[R8] stock transfer** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. OrdersDeliveriesView.tsx
|
||||||
|
| UI element | Current source | Class | Target |
|
||||||
|
|---|---|---|---|
|
||||||
|
| KPI tiles (deliveries in range, pending, completed, fleet) | live `useFiestaDeliveries` | OK | — |
|
||||||
|
| Date presets / range | client state | OK | — |
|
||||||
|
| Order list rows | live `deliveryRowToOrder` | OK | — |
|
||||||
|
| **Order line items** (`items: []` always empty) | empty / itemCount only | **WIRE** | **`orderdetails?orderheaderid=`** 🟡 (exists!) |
|
||||||
|
| "Create Simulated Order" + MOCK_NAMES/STREETS/ITEMS | hardcoded generator | **NEW API** | replace with real Create-Order POST 🔴, or remove button |
|
||||||
|
| Rider cards | live `useFiestaRiders` | OK | — |
|
||||||
|
| Rider `rating 4.7` | hardcoded in mapper | **NEW API** | **[R9] rider telemetry/rating** |
|
||||||
|
| Rider avatars | rotated stock photos | **WIRE** | user photo field if present, else keep placeholder |
|
||||||
|
| GPS route SVG, "9 MINS", "1.2 km left", "GPS ACTIVE" | hardcoded | **NEW API** | **[R9]** live rider GPS/ETA |
|
||||||
|
| Status-progression buttons (pack/dispatch/deliver) | local state | **NEW API** | order/delivery status PUT 🔴 (REVIEW_REQUIRED) |
|
||||||
|
| Assign-rider action | local state | **NEW API** | assign-rider POST 🔴 (REVIEW_REQUIRED) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. StoreDetailView.tsx
|
||||||
|
| UI element | Current source | Class | Target |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Inventory tab list | live `useFiestaStockStatement` + mock fallback | **WIRE** | remove hardcoded fallback array |
|
||||||
|
| Customers tab list | live `useFiestaTenantCustomers` + mock fallback | **WIRE** | remove hardcoded fallback array |
|
||||||
|
| `intervalSlots` dispatch pipeline (morning rush %, etc.) | computed from `store.deliveries`×% | **NEW API** | **[R10] intraday dispatch buckets** |
|
||||||
|
| `pastDaysLog` 7-day ledger | computed ×multipliers | **NEW API** | **[R2]** daily series per location |
|
||||||
|
| KPI cards (OTIF 98.2%, est revenue, dispatches, active fleet) | hardcoded/derived | **WIRE/NEW** | OTIF & revenue need [R1]; fleet via `getRiders` |
|
||||||
|
| `activeRiders` (battery, lastPing) | hardcoded | **NEW API** | **[R9]** rider telemetry |
|
||||||
|
| Customer CRM order history (3 orders) | hardcoded | **WIRE** | `orders?customerid=` 🟡 |
|
||||||
|
| Customer CRM metrics (CSAT 5.0) | hardcoded | **NEW API** | **[R11] customer analytics** (CSAT/AOV/retention) |
|
||||||
|
| Customer search metrics (retention 88.4%, AOV ₹1,580, CSAT 4.9) | hardcoded | **NEW API** | **[R11]** |
|
||||||
|
| `GLOBAL_CATALOGUE_ITEMS` modal | hardcoded | **WIRE** | master catalog listings 🟡 |
|
||||||
|
| `resolveMetadata` price/image keyword map | hardcoded | **NEW API** | **[R3]** product image/price from catalog |
|
||||||
|
| Store cover images | hardcoded array | keep | cosmetic asset set |
|
||||||
|
| `operationalAlerts` (filtered by store) | mock `data.ts` | **NEW API** | **[R12] operational alerts** |
|
||||||
|
| CSV import simulation, Global-catalogue add | local mutation | **NEW API** | stock entry POST 🔴 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. SettingsView.tsx
|
||||||
|
| UI element | Current source | Class | Target |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Merchant ID card / registration info | live `useFiestaAllTenants` | OK | — |
|
||||||
|
| Outlets tab cards | live `useFiestaTenantLocations` | OK | — |
|
||||||
|
| `LOCAL_OUTLETS_DATA` fallback | hardcoded | **WIRE** | remove fallback once locations confirmed live |
|
||||||
|
| `DEFAULTS` settings (min order, delivery charge, prep mins, tax, COD…) | hardcoded + localStorage | **NEW API** | **[R6] merchant settings** GET+PUT (some fields exist on tenant: minorder, paymenttype) |
|
||||||
|
| Role dropdown options | hardcoded `[1,2,3,4,6]` | **WIRE** | `getAppRoles` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. UsersPanel.tsx
|
||||||
|
| UI element | Current source | Class | Target |
|
||||||
|
|---|---|---|---|
|
||||||
|
| User directory | live `useFiestaUsers` | OK | — |
|
||||||
|
| Create user | live `useFiestaCreateUser` | OK | — |
|
||||||
|
| `USER_AVATARS` rotation | hardcoded | keep/WIRE | use user photo field if API has one |
|
||||||
|
| `ROLE_THEMES` styling | hardcoded | keep | cosmetic |
|
||||||
|
| Add-user modal role list | hardcoded | **WIRE** | `getAppRoles` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. App.tsx / Header / Sidebar / UserPage
|
||||||
|
| UI element | Current source | Class | Target |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Store registry cards | live `useFiestaTenantLocations` + `useFiestaLocationSummary` | OK | — |
|
||||||
|
| `STORE_COVERS` images | hardcoded (hash-picked) | keep | cosmetic |
|
||||||
|
| Add-store modal | local; needs onboard POST | **NEW API** | tenant/location onboard 🔴 (REVIEW_REQUIRED) |
|
||||||
|
| Scheduled-reports calendar (3 events) | hardcoded | **NEW API** | **[R13] scheduled reports** (or drop feature) |
|
||||||
|
| Header/UserPage profile | live `authUser` | OK | — |
|
||||||
|
| Sidebar nav items | hardcoded | keep | static nav config |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of backend work required
|
||||||
|
| Id | New API | Blocks |
|
||||||
|
|---|---|---|
|
||||||
|
| **[R1]** | Revenue summary (tenant + per-location, period) | Reports revenue KPI, leaderboard revenue, OTIF revenue, Dashboard cross-check |
|
||||||
|
| **[R2]** | Orders/revenue time-series (day/month/year, by region) | Reports main chart, 7-day ledger |
|
||||||
|
| **[R3]** | Product sales analytics + product meta (units sold, revenue, bin, image) | Product matrix, catalogue metadata |
|
||||||
|
| **[R4]** | Import / sync audit log + validation | Inventory & Operations import tabs |
|
||||||
|
| **[R5]** | Catalog presets (optional) | Inventory presets |
|
||||||
|
| **[R6]** | Merchant settings + branding config (GET/PUT) | Settings, Brand Studio |
|
||||||
|
| **[R7]** | Forecast / efficiency insights (optional) | Operations forecast panel |
|
||||||
|
| **[R8]** | Stock transfer (POST) | Operations transfer modal |
|
||||||
|
| **[R9]** | Rider telemetry (rating, battery, GPS, ETA) | Orders fleet, GPS tracker, StoreDetail riders |
|
||||||
|
| **[R10]** | Intraday dispatch buckets | StoreDetail dispatch pipeline |
|
||||||
|
| **[R11]** | Customer analytics (CSAT/AOV/retention) | StoreDetail CRM metrics |
|
||||||
|
| **[R12]** | Operational alerts feed | StoreDetail / dashboard alerts |
|
||||||
|
| **[R13]** | Scheduled reports (optional) | Reports calendar |
|
||||||
|
|
||||||
|
Plus mutation routes already in the sheet but marked 🔴 REVIEW_REQUIRED (order create/status,
|
||||||
|
delivery assign/status, product create/update, tenant onboard, stock entry) — backend must
|
||||||
|
confirm request/response before wiring.
|
||||||
275
docs/03_REQUIRED_BACKEND_APIS.md
Normal file
275
docs/03_REQUIRED_BACKEND_APIS.md
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
# Required Backend APIs (to retire all mock data)
|
||||||
|
|
||||||
|
These endpoints do **not** exist in `Fiesta_All_Endpoints_With_Tables_And_Params.xlsx` and the
|
||||||
|
values cannot be derived client-side. Each spec gives the **request** (params/body) and the
|
||||||
|
**exact response JSON** the frontend needs. All responses follow the existing Fiesta envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 200, "status": true, "message": "OK", "details": <payload> }
|
||||||
|
```
|
||||||
|
|
||||||
|
`details` is the `<payload>` documented per endpoint. Money is in INR (paise-free rupees,
|
||||||
|
2-dp numbers). Dates are `YYYY-MM-DD`; timestamps ISO-8601.
|
||||||
|
|
||||||
|
Grouped as the request asked: **Overall (executive) analysis**, **Store-wise analysis**,
|
||||||
|
**Reports**, then supporting operational feeds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. OVERALL / EXECUTIVE ANALYSIS
|
||||||
|
|
||||||
|
### [R1] Revenue summary — `GET /orders/getrevenuesummary`
|
||||||
|
Real money totals (today the app fakes revenue as `delivered × 355`).
|
||||||
|
|
||||||
|
**Request**
|
||||||
|
| param | type | required | notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| tenantid | int | ✓ | |
|
||||||
|
| locationid | int | | omit = all outlets (overall); set = store-wise |
|
||||||
|
| fromdate | date | ✓ | |
|
||||||
|
| todate | date | ✓ | |
|
||||||
|
|
||||||
|
**Response `details`** (single object)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tenantid": 1087,
|
||||||
|
"grossrevenue": 1248302.00,
|
||||||
|
"netrevenue": 1180500.00,
|
||||||
|
"profit": 354216.00,
|
||||||
|
"marginpct": 28.4,
|
||||||
|
"ordercount": 4921,
|
||||||
|
"deliveredcount": 4102,
|
||||||
|
"cancelledcount": 142,
|
||||||
|
"avgordervalue": 1580.00,
|
||||||
|
"prev_grossrevenue": 1090000.00,
|
||||||
|
"prev_ordercount": 4370
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`prev_*` = same metric for the immediately preceding equal-length window, so the UI can show
|
||||||
|
the `+14.2%` deltas without a second call. (If omitted, the frontend will issue a second
|
||||||
|
range query to derive deltas — see [R2].)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [R2] Orders/revenue time-series — `GET /orders/gettimeseries`
|
||||||
|
Powers the Reports main chart (Orders / Revenue / Cancelled / SKUs) and the StoreDetail 7-day ledger.
|
||||||
|
|
||||||
|
**Request**
|
||||||
|
| param | type | required | notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| tenantid | int | ✓ | |
|
||||||
|
| locationid | int | | omit = overall |
|
||||||
|
| granularity | enum | ✓ | `day` \| `month` \| `year` |
|
||||||
|
| fromdate | date | ✓ | |
|
||||||
|
| todate | date | ✓ | |
|
||||||
|
|
||||||
|
**Response `details`** (array, one bucket per period)
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "label": "2026-06-01", "orders": 312, "revenue": 442800.00,
|
||||||
|
"cancelled": 9, "delivered": 298, "activeskus": 72 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
`label` is the bucket key (`YYYY-MM-DD` for day, `YYYY-MM` for month, `YYYY` for year).
|
||||||
|
This removes all hardcoded `CHART_DATA_YTD` / `getDynamicChartData` arrays **and** the
|
||||||
|
region-scaling multipliers (filter by `locationid` instead).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [R12] Operational alerts — `GET /alerts/getalerts`
|
||||||
|
Replaces `operationalAlerts` in `data.ts` (stock-critical, latency, rebalance, etc.).
|
||||||
|
|
||||||
|
**Request**: `tenantid` (✓), `locationid` (optional), `status` (`open|all`), `pageno`, `pagesize`.
|
||||||
|
|
||||||
|
**Response `details`** (array)
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "alertid": 5012, "tenantid": 1087, "locationid": 1097,
|
||||||
|
"type": "critical", // critical | warning | info
|
||||||
|
"title": "Stock Critical: RS Puram",
|
||||||
|
"details": "Dairy inventory below 5%.",
|
||||||
|
"createdat": "2026-06-06T08:24:00",
|
||||||
|
"acknowledged": false }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. STORE-WISE ANALYSIS
|
||||||
|
|
||||||
|
### [R3] Product sales analytics — `GET /products/getproductanalytics`
|
||||||
|
The stock-statement mapper currently approximates `unitsSold = debit` and
|
||||||
|
`revenue = debit × retailprice`. This returns true sales + the product meta the matrix shows.
|
||||||
|
|
||||||
|
**Request**
|
||||||
|
| param | type | required | notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| tenantid | int | ✓ | |
|
||||||
|
| locationid | int | | omit = across all outlets |
|
||||||
|
| subcategoryid | int | | |
|
||||||
|
| fromdate / todate | date | | sales window (default month-to-date) |
|
||||||
|
| keyword | string | | |
|
||||||
|
| pageno / pagesize | int | | |
|
||||||
|
|
||||||
|
**Response `details`** (array)
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "productid": 88231, "productname": "Ponni Raw Rice 10kg",
|
||||||
|
"sku": "PONNI-RICE-10K", "categoryid": 2, "subcategoryname": "Rice",
|
||||||
|
"image": "https://…",
|
||||||
|
"unitssold": 12402, "revenue": 868140.00, "trend": "up", // up|flat|down
|
||||||
|
"closingstock": 1402, "maxcapacity": 2000, "binlocation": "BIN-C12",
|
||||||
|
"lastauditdate": "2026-06-05", "verified": true,
|
||||||
|
"outlets": [ { "locationid": 1097, "locationname": "RS Puram", "stock": 840 } ] }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
`outlets[]` gives the real per-hub split (replaces the hardcoded 60/40). `binlocation`,
|
||||||
|
`lastauditdate`, `image` replace the fabricated bin/barcode/audit fields — **drop them if the
|
||||||
|
catalog doesn't track them** rather than faking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [R10] Intraday dispatch buckets — `GET /deliveries/getdispatchbuckets`
|
||||||
|
Replaces StoreDetail `intervalSlots` (Morning Rush / Midday / Evening Peak, computed from `%`).
|
||||||
|
|
||||||
|
**Request**: `tenantid` (✓), `locationid` (✓), `date` (✓, default today).
|
||||||
|
|
||||||
|
**Response `details`** (array, fixed buckets)
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "bucket": "06:00-10:00", "label": "Morning Rush",
|
||||||
|
"orders": 64, "revenue": 88400.00, "status": "PEAK" } // PEAK|HIGH|NORMAL|LOW
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [R11] Customer analytics — `GET /customers/getcustomeranalytics`
|
||||||
|
Replaces hardcoded CSAT 5.0 / retention 88.4% / AOV ₹1,580 in StoreDetail.
|
||||||
|
|
||||||
|
**Request**: `tenantid` (✓), `locationid` (optional), `customerid` (optional — single-customer rollup).
|
||||||
|
|
||||||
|
**Response `details`** (object; per-customer when `customerid` set)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"retentionpct": 88.4,
|
||||||
|
"avgordervalue": 1580.00,
|
||||||
|
"csat": 4.9,
|
||||||
|
"totalcustomers": 1240,
|
||||||
|
"percustomer": { "customerid": 55012, "orderscount": 18,
|
||||||
|
"totalspent": 28450.00, "csat": 5.0, "lastorderdate": "2026-06-04" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> Customer order **history** itself is already available: `GET /orders/getorders?customerid=` (sheet row 56) — wire that, no new API needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [R9] Rider telemetry — `GET /partners/getridertelemetry`
|
||||||
|
Replaces hardcoded rider `rating 4.7`, `battery`, `lastPing`, and the fake GPS route / "9 MINS" / "1.2 km left".
|
||||||
|
|
||||||
|
**Request**: `applocationid` (✓), `tenantid` (optional), `userid` (optional — single rider).
|
||||||
|
|
||||||
|
**Response `details`** (array)
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "userid": 7781, "name": "Karthikeyan R", "status": "Delivering", // Delivering|Idle|Offline
|
||||||
|
"rating": 4.7, "completedtoday": 12, "battery": 84,
|
||||||
|
"lastping": "2026-06-06T14:21:00", "zone": "RS Puram",
|
||||||
|
"lat": 11.0041, "lng": 76.9612,
|
||||||
|
"activedelivery": { "deliveryid": 99213, "etamins": 9, "distancekm": 1.2,
|
||||||
|
"destlat": 11.0102, "destlng": 76.9550 } }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. REPORTS
|
||||||
|
|
||||||
|
Reports are powered primarily by **[R1]** (revenue + deltas), **[R2]** (time-series chart &
|
||||||
|
ledger), and **[R3]** (product matrix). Additional reports-only needs:
|
||||||
|
|
||||||
|
### [R4] Import / sync audit log — `GET /imports/getimportlogs` + validation
|
||||||
|
Replaces `initialImportLogs` and the hardcoded "14 duplicate SKUs" validator messages.
|
||||||
|
|
||||||
|
**Request (list)**: `tenantid` (✓), `pageno`, `pagesize`.
|
||||||
|
**Response `details`**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "importid": 921, "batchref": "#IMP_0921_A", "type": "Inventory Sync",
|
||||||
|
"source": "ERP Export", "rows": 421, "result": "SUCCESS", // SUCCESS|FAILED|PARTIAL
|
||||||
|
"message": "421 rows", "createdat": "2026-05-31T09:12:00" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
**Upload (optional)**: `POST /imports/upload` (multipart csv) → returns the same row plus a
|
||||||
|
`validation` block:
|
||||||
|
```json
|
||||||
|
{ "validation": { "passed": true, "duplicateskus": ["ST-SONA-25K"],
|
||||||
|
"badcolumns": [45, 82], "headerversion": "v2.8" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### [R13] Scheduled reports — `GET/POST /reports/getschedules` *(optional feature)*
|
||||||
|
Replaces the 3 hardcoded calendar events. Drop the feature if not on the roadmap.
|
||||||
|
```json
|
||||||
|
[ { "scheduleid": 11, "name": "Monthly Assortment Audit", "cron": "0 0 1 * *",
|
||||||
|
"nextrun": "2026-07-01T00:00:00", "format": "PDF", "recipients": ["ops@…"] } ]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. SETTINGS / CONFIG
|
||||||
|
|
||||||
|
### [R6] Merchant settings + branding — `GET /tenants/getsettings`, `PUT /tenants/updatesettings`
|
||||||
|
Replaces `DEFAULTS` (localStorage) and the Brand Design Studio local state. Some fields already
|
||||||
|
live on the tenant (`minorder`, `paymenttype`) — fold those in.
|
||||||
|
|
||||||
|
**GET response `details`**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tenantid": 1087,
|
||||||
|
"contactemail": "ops@ragul.com", "contactphone": "+91…",
|
||||||
|
"minordervalue": 199, "deliverycharge": 29, "prepmins": 20,
|
||||||
|
"deliverywindowmins": 45, "cancelwindowsecs": 120, "autoassignrider": true,
|
||||||
|
"defaulttaxpercent": 5, "codenabled": true, "onlinepaymentenabled": true,
|
||||||
|
"defaultregion": "Coimbatore", "defaultnewuserrole": 4,
|
||||||
|
"ordernotifications": true, "lowstockalerts": true, "dailysummaryemail": false,
|
||||||
|
"syncinterval": 15, "sandboxmode": false,
|
||||||
|
"branding": { "themename": "Kaveri Org", "primarycolor": "#16a34a",
|
||||||
|
"secondarycolor": "#f59e0b", "baglabel": "Freshly Harvested",
|
||||||
|
"ecoverified": true, "stickerpattern": "radial" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**PUT body**: same object (partial allowed) → returns updated object.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E. SUPPORTING OPERATIONAL ENDPOINTS (optional / phase-2)
|
||||||
|
|
||||||
|
### [R7] Forecast / efficiency insight — `GET /insights/getforecast`
|
||||||
|
Operations "Forecast Efficiency 92% / ₹1.9L savings". **Recommend removing** these tiles until a
|
||||||
|
forecasting model exists, rather than building a thin API. If kept:
|
||||||
|
```json
|
||||||
|
{ "forecastefficiencypct": 92, "estsavingsperweek": 190000.00, "horizondays": 7 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### [R8] Stock transfer — `POST /products/stocktransfer`
|
||||||
|
Operations transfer-stock modal.
|
||||||
|
**Body**: `{ tenantid, productid, fromlocationid, tolocationid, quantity }` →
|
||||||
|
**Response**: updated stock rows for both locations.
|
||||||
|
|
||||||
|
### [R5] Catalog presets — `GET /products/getcatalogpresets` *(optional)*
|
||||||
|
Inventory "Nilgiris Dairy / Coimbatore Heritage" quick-add packs. Could instead reuse master
|
||||||
|
catalog listings (sheet row 90) filtered by subcategory — **prefer that, skip the new API**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mutation routes already in the sheet (confirm shape, then wire)
|
||||||
|
These are marked `REVIEW_REQUIRED` in the sheet — backend must publish request/response:
|
||||||
|
| Action | Route (sheet) | Used by |
|
||||||
|
|---|---|---|
|
||||||
|
| Create web order | POST orders+orderdetails | Orders "create order" |
|
||||||
|
| Update order status | PUT orders | Orders status buttons |
|
||||||
|
| Assign rider / dispatch | POST deliveries+deliverydetails | Orders assign-rider |
|
||||||
|
| Update pickup / delivered | PUT deliveries | Orders status progression |
|
||||||
|
| Add multi-product stock | POST (products) | Inventory/Operations add stock |
|
||||||
|
| Create/Update/Delete product | POST/PUT/DELETE | Catalogue management |
|
||||||
|
| Onboard tenant / store / location | POST/PUT | App add-store modal |
|
||||||
322
docs/BACKEND_API_REQUIREMENTS.md
Normal file
322
docs/BACKEND_API_REQUIREMENTS.md
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
# Backend API Requirements — Merchant Web Console
|
||||||
|
|
||||||
|
**Audience:** Backend team.
|
||||||
|
**Purpose:** These are the endpoints the merchant web app needs before it can go to staging with
|
||||||
|
zero mock data. Every UI spot listed below currently renders an explicit
|
||||||
|
`Awaiting backend API [Rxx]` placeholder (or a known-wrong stand-in like the
|
||||||
|
`delivered × 355` revenue) until the matching endpoint ships.
|
||||||
|
|
||||||
|
## Conventions (match the existing live API)
|
||||||
|
- **Base / proxy:** Fiesta REST at `https://fiesta.nearle.app/live/api/v1/web/<group>/<action>`.
|
||||||
|
The frontend calls it through the dev proxy as `/fiesta/live/api/v1/web/...`.
|
||||||
|
- **Auth:** same as current read endpoints (no extra header required for GETs).
|
||||||
|
- **Envelope:** every response is
|
||||||
|
```json
|
||||||
|
{ "code": 200, "status": true, "message": "OK", "details": <payload> }
|
||||||
|
```
|
||||||
|
`details` is the documented payload (array or object). On failure return
|
||||||
|
`{ "code": 4xx, "status": false, "message": "<reason>" }`.
|
||||||
|
- **Types:** money = INR as a plain 2-dp number (e.g. `442800.00`), no currency symbol/string.
|
||||||
|
Dates = `YYYY-MM-DD`. Timestamps = ISO-8601 (`2026-06-06T14:21:00`). Ids = integers.
|
||||||
|
- **Tenant scope (current):** `tenantid=1087`, `applocationid=1`, primary `locationid=1097`.
|
||||||
|
- **Paging:** where listed, accept `pageno` (1-based) and `pagesize`.
|
||||||
|
|
||||||
|
## Index
|
||||||
|
| Id | Endpoint | Method | Powers (UI) | Priority |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| [R1] | `/orders/getrevenuesummary` | GET | Revenue KPIs, OTIF, leaderboard, fulfilment health | **P0** |
|
||||||
|
| [R2] | `/orders/gettimeseries` | GET | Reports main chart, 7-day ledger | **P0** |
|
||||||
|
| [R3] | `/products/getproductanalytics` | GET | Product matrix sales + per-product detail | **P0** |
|
||||||
|
| [R4] | `/imports/getimportlogs` (+ `/imports/upload`) | GET/POST | Import audit stream + validator | P1 |
|
||||||
|
| [R5] | `/products/getcatalogpresets` | GET | Inventory catalog presets (optional) | P2 |
|
||||||
|
| [R6] | `/tenants/getsettings` (+ `/tenants/updatesettings`) | GET/PUT | Settings + Brand studio | P1 |
|
||||||
|
| [R7] | `/insights/getforecast` | GET | Operations forecast tiles (optional) | P2 |
|
||||||
|
| [R8] | `/products/stocktransfer` | POST | Operations stock-transfer modal | P1 |
|
||||||
|
| [R9] | `/partners/getridertelemetry` | GET | Fleet rating, GPS, ETA | P1 |
|
||||||
|
| [R10] | `/deliveries/getdispatchbuckets` | GET | StoreDetail intraday dispatch | P1 |
|
||||||
|
| [R11] | `/customers/getcustomeranalytics` | GET | Customer CSAT/AOV/retention | P1 |
|
||||||
|
| [R12] | `/alerts/getalerts` | GET | Operational alerts feed | P1 |
|
||||||
|
| [R13] | `/reports/getschedules` (+ POST) | GET/POST | Scheduled reports (optional) | P2 |
|
||||||
|
|
||||||
|
Plus **mutation routes** already named in the endpoint sheet but not yet shape-documented — see
|
||||||
|
the last section.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [R1] Revenue summary · `GET /orders/getrevenuesummary`
|
||||||
|
Real money totals. Today the app fakes revenue as `delivered × 355` — replace that.
|
||||||
|
|
||||||
|
**Request (query params)**
|
||||||
|
| param | type | required | notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `tenantid` | int | ✓ | |
|
||||||
|
| `locationid` | int | | omit → all outlets (overall); set → store-wise |
|
||||||
|
| `fromdate` | date | ✓ | |
|
||||||
|
| `todate` | date | ✓ | |
|
||||||
|
|
||||||
|
**Response `details`** (single object)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tenantid": 1087,
|
||||||
|
"locationid": null,
|
||||||
|
"grossrevenue": 1248302.00,
|
||||||
|
"netrevenue": 1180500.00,
|
||||||
|
"profit": 354216.00,
|
||||||
|
"marginpct": 28.4,
|
||||||
|
"ordercount": 4921,
|
||||||
|
"deliveredcount": 4102,
|
||||||
|
"cancelledcount": 142,
|
||||||
|
"otifpct": 98.2,
|
||||||
|
"avgordervalue": 1580.00,
|
||||||
|
"prev_grossrevenue": 1090000.00,
|
||||||
|
"prev_ordercount": 4370
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`prev_*` = same metric for the immediately preceding equal-length window (lets the UI show
|
||||||
|
`+14.2%` deltas without a second call). `otifpct` powers the OTIF / fulfilment-health tiles.
|
||||||
|
Frontend hook to add: `useFiestaRevenueSummary`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [R2] Orders / revenue time-series · `GET /orders/gettimeseries`
|
||||||
|
Powers the Reports main chart (Orders / Revenue / Cancelled / SKUs) and StoreDetail 7-day ledger.
|
||||||
|
|
||||||
|
**Request**
|
||||||
|
| param | type | required | notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `tenantid` | int | ✓ | |
|
||||||
|
| `locationid` | int | | omit → overall |
|
||||||
|
| `granularity` | enum | ✓ | `day` \| `month` \| `year` |
|
||||||
|
| `fromdate` | date | ✓ | |
|
||||||
|
| `todate` | date | ✓ | |
|
||||||
|
|
||||||
|
**Response `details`** (array, one bucket per period)
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "label": "2026-06-01", "orders": 312, "revenue": 442800.00,
|
||||||
|
"delivered": 298, "cancelled": 9, "activeskus": 72 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
`label` = `YYYY-MM-DD` (day), `YYYY-MM` (month), or `YYYY` (year). Removes all hardcoded chart
|
||||||
|
arrays and the region-scaling multipliers (filter by `locationid` instead).
|
||||||
|
Frontend hook: `useFiestaTimeSeries`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [R3] Product sales analytics · `GET /products/getproductanalytics`
|
||||||
|
True units-sold + revenue per product, plus the per-product/per-outlet detail the matrix expands.
|
||||||
|
|
||||||
|
**Request**
|
||||||
|
| param | type | required | notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `tenantid` | int | ✓ | |
|
||||||
|
| `locationid` | int | | omit → across all outlets |
|
||||||
|
| `subcategoryid` | int | | |
|
||||||
|
| `fromdate` / `todate` | date | | sales window (default month-to-date) |
|
||||||
|
| `keyword` | string | | |
|
||||||
|
| `pageno` / `pagesize` | int | | |
|
||||||
|
|
||||||
|
**Response `details`** (array)
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "productid": 88231, "productname": "Ponni Raw Rice 10kg",
|
||||||
|
"sku": "PONNI-RICE-10K", "categoryid": 2, "subcategoryname": "Rice",
|
||||||
|
"image": "https://…",
|
||||||
|
"unitssold": 12402, "revenue": 868140.00, "trend": "up",
|
||||||
|
"closingstock": 1402, "maxcapacity": 2000,
|
||||||
|
"binlocation": "BIN-C12", "lastauditdate": "2026-06-05", "verified": true,
|
||||||
|
"outlets": [ { "locationid": 1097, "locationname": "RS Puram", "stock": 840 } ] }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
`trend` ∈ `up|flat|down`. `outlets[]` = real per-hub split (replaces hardcoded 60/40).
|
||||||
|
If the catalog doesn't track `binlocation`/`lastauditdate`, return `null` — the UI will hide them
|
||||||
|
rather than fake them. Frontend hook: `useFiestaProductAnalytics`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [R4] Import / sync audit log · `GET /imports/getimportlogs` + `POST /imports/upload`
|
||||||
|
Replaces the mock "Live Sync Audit Stream" and the hardcoded "14 duplicate SKUs" validator.
|
||||||
|
|
||||||
|
**GET request:** `tenantid` (✓), `pageno`, `pagesize`.
|
||||||
|
**GET `details`**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "importid": 921, "batchref": "#IMP_0921_A", "type": "Inventory Sync",
|
||||||
|
"source": "ERP Export", "rows": 421, "result": "SUCCESS",
|
||||||
|
"message": "421 rows imported", "createdat": "2026-05-31T09:12:00" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
`result` ∈ `SUCCESS|FAILED|PARTIAL`.
|
||||||
|
|
||||||
|
**POST `/imports/upload`** (multipart, field `file` = CSV; plus `tenantid`) → returns the new log row
|
||||||
|
plus a validation block:
|
||||||
|
```json
|
||||||
|
{ "importid": 922, "result": "PARTIAL",
|
||||||
|
"validation": { "passed": false, "duplicateskus": ["ST-SONA-25K"],
|
||||||
|
"badcolumns": [45, 82], "headerversion": "v2.8" } }
|
||||||
|
```
|
||||||
|
Frontend hooks: `useFiestaImportLogs`, `useFiestaImportUpload` (mutation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [R5] Catalog presets *(optional)* · `GET /products/getcatalogpresets`
|
||||||
|
Inventory "Nilgiris Dairy / Coimbatore Heritage" quick-add packs.
|
||||||
|
**Request:** `tenantid` (✓). **`details`:**
|
||||||
|
```json
|
||||||
|
[ { "presetid": 3, "name": "Nilgiris Dairy Fresh Pack",
|
||||||
|
"items": [ { "name": "Ooty Butter 500g", "sku": "DY-OOT-BTR",
|
||||||
|
"subcategoryname": "Dairy", "price": 340, "image": "https://…" } ] } ]
|
||||||
|
```
|
||||||
|
> Alternative: skip this and reuse master catalog (`/products/getproducts`) filtered by subcategory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [R6] Merchant settings + branding · `GET /tenants/getsettings` + `PUT /tenants/updatesettings`
|
||||||
|
Replaces the localStorage `DEFAULTS` and the Brand Design Studio local state.
|
||||||
|
|
||||||
|
**GET request:** `tenantid` (✓). **GET `details`:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tenantid": 1087,
|
||||||
|
"contactemail": "ops@ragul.com", "contactphone": "+91…",
|
||||||
|
"minordervalue": 199, "deliverycharge": 29, "prepmins": 20,
|
||||||
|
"deliverywindowmins": 45, "cancelwindowsecs": 120, "autoassignrider": true,
|
||||||
|
"defaulttaxpercent": 5, "codenabled": true, "onlinepaymentenabled": true,
|
||||||
|
"defaultregion": "Coimbatore", "defaultnewuserrole": 4,
|
||||||
|
"ordernotifications": true, "lowstockalerts": true, "dailysummaryemail": false,
|
||||||
|
"syncinterval": 15, "sandboxmode": false,
|
||||||
|
"branding": { "themename": "Kaveri Org", "primarycolor": "#16a34a",
|
||||||
|
"secondarycolor": "#f59e0b", "baglabel": "Freshly Harvested",
|
||||||
|
"ecoverified": true, "stickerpattern": "radial" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**PUT body:** same object (partial allowed) → returns the updated object.
|
||||||
|
Frontend hooks: `useFiestaSettings`, `useFiestaUpdateSettings` (mutation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [R7] Forecast insight *(optional)* · `GET /insights/getforecast`
|
||||||
|
Operations "Forecast Efficiency 92% / ₹1.9L savings". Recommend dropping the tiles until a model
|
||||||
|
exists; if kept: **request** `tenantid` (✓), `locationid` (opt). **`details`:**
|
||||||
|
```json
|
||||||
|
{ "forecastefficiencypct": 92, "estsavingsperweek": 190000.00, "horizondays": 7 }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [R8] Stock transfer · `POST /products/stocktransfer`
|
||||||
|
Operations transfer-stock modal.
|
||||||
|
**Body:**
|
||||||
|
```json
|
||||||
|
{ "tenantid": 1087, "productid": 88231,
|
||||||
|
"fromlocationid": 1097, "tolocationid": 1101, "quantity": 100 }
|
||||||
|
```
|
||||||
|
**`details`:** updated stock rows for both locations
|
||||||
|
```json
|
||||||
|
[ { "locationid": 1097, "productid": 88231, "closingstock": 740 },
|
||||||
|
{ "locationid": 1101, "productid": 88231, "closingstock": 360 } ]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [R9] Rider telemetry · `GET /partners/getridertelemetry`
|
||||||
|
Replaces hardcoded rider `rating`, `battery`, `lastPing` and the fake GPS route / "9 MINS" / "1.2 km".
|
||||||
|
|
||||||
|
**Request:** `applocationid` (✓), `tenantid` (opt), `userid` (opt → single rider).
|
||||||
|
**`details`** (array)
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "userid": 7781, "name": "Karthikeyan R", "status": "Delivering",
|
||||||
|
"rating": 4.7, "completedtoday": 12, "battery": 84,
|
||||||
|
"lastping": "2026-06-06T14:21:00", "zone": "RS Puram",
|
||||||
|
"lat": 11.0041, "lng": 76.9612,
|
||||||
|
"activedelivery": { "deliveryid": 99213, "etamins": 9, "distancekm": 1.2,
|
||||||
|
"destlat": 11.0102, "destlng": 76.9550 } }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
`status` ∈ `Delivering|Idle|Offline`. `activedelivery` = `null` when the rider is idle.
|
||||||
|
Frontend hook: `useFiestaRiderTelemetry`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [R10] Intraday dispatch buckets · `GET /deliveries/getdispatchbuckets`
|
||||||
|
Replaces StoreDetail `intervalSlots` (Morning Rush / Midday / Evening Peak computed from %).
|
||||||
|
|
||||||
|
**Request:** `tenantid` (✓), `locationid` (✓), `date` (✓, default today).
|
||||||
|
**`details`** (array, fixed buckets)
|
||||||
|
```json
|
||||||
|
[ { "bucket": "06:00-10:00", "label": "Morning Rush",
|
||||||
|
"orders": 64, "revenue": 88400.00, "status": "PEAK" } ]
|
||||||
|
```
|
||||||
|
`status` ∈ `PEAK|HIGH|NORMAL|LOW`. Frontend hook: `useFiestaDispatchBuckets`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [R11] Customer analytics · `GET /customers/getcustomeranalytics`
|
||||||
|
Replaces hardcoded CSAT 5.0 / retention 88.4% / AOV ₹1,580.
|
||||||
|
|
||||||
|
**Request:** `tenantid` (✓), `locationid` (opt), `customerid` (opt → single-customer rollup).
|
||||||
|
**`details`** (object)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"retentionpct": 88.4, "avgordervalue": 1580.00, "csat": 4.9,
|
||||||
|
"totalcustomers": 1240,
|
||||||
|
"percustomer": { "customerid": 55012, "orderscount": 18,
|
||||||
|
"totalspent": 28450.00, "csat": 5.0, "lastorderdate": "2026-06-04" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`percustomer` present only when `customerid` is supplied.
|
||||||
|
> Customer order **history** itself already exists: `GET /orders/getorders?customerid=` — wire that, no new API.
|
||||||
|
Frontend hook: `useFiestaCustomerAnalytics`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [R12] Operational alerts · `GET /alerts/getalerts`
|
||||||
|
Replaces the mock `operationalAlerts` (stock-critical, latency, rebalance…).
|
||||||
|
|
||||||
|
**Request:** `tenantid` (✓), `locationid` (opt), `status` (`open|all`), `pageno`, `pagesize`.
|
||||||
|
**`details`** (array)
|
||||||
|
```json
|
||||||
|
[ { "alertid": 5012, "tenantid": 1087, "locationid": 1097,
|
||||||
|
"type": "critical", "title": "Stock Critical: RS Puram",
|
||||||
|
"details": "Dairy inventory below 5%.",
|
||||||
|
"createdat": "2026-06-06T08:24:00", "acknowledged": false } ]
|
||||||
|
```
|
||||||
|
`type` ∈ `critical|warning|info`. Frontend hook: `useFiestaAlerts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [R13] Scheduled reports *(optional)* · `GET /reports/getschedules` + `POST /reports/createschedule`
|
||||||
|
Replaces 3 hardcoded calendar events. Drop if not on the roadmap.
|
||||||
|
**GET `details`:**
|
||||||
|
```json
|
||||||
|
[ { "scheduleid": 11, "name": "Monthly Assortment Audit", "cron": "0 0 1 * *",
|
||||||
|
"nextrun": "2026-07-01T00:00:00", "format": "PDF", "recipients": ["ops@ragul.com"] } ]
|
||||||
|
```
|
||||||
|
**POST body:** `{ tenantid, name, cron, format, recipients[] }` → returns the created schedule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mutation routes already named in the endpoint sheet (publish request/response, then we wire)
|
||||||
|
These are marked `REVIEW_REQUIRED` in `Fiesta_All_Endpoints_With_Tables_And_Params.xlsx`. We need
|
||||||
|
the exact request body + success response for each:
|
||||||
|
|
||||||
|
| Action | Suggested endpoint | Body (fields we'll send) |
|
||||||
|
|---|---|---|
|
||||||
|
| Create web order | `POST /orders/create` | tenantid, locationid, customerid, items[{productid,qty,price}], paymenttype |
|
||||||
|
| Update order status | `PUT /orders/updatestatus` | orderheaderid, status, financialflag |
|
||||||
|
| Assign rider / dispatch | `POST /deliveries/assign` | orderheaderid, tenantid, locationid, partnerid, userid |
|
||||||
|
| Update pickup / delivered | `PUT /deliveries/updatestatus` | deliveryid, status |
|
||||||
|
| Add multi-product stock | `POST /products/addstock` | tenantid, locationid, items[{productid,qty,cost}] |
|
||||||
|
| Create / update / delete product | `POST/PUT/DELETE /products/...` | product fields |
|
||||||
|
| Onboard tenant / store / location | `POST /tenants/onboard`, `POST /tenants/createlocation` | tenant + location fields |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend status (for reference)
|
||||||
|
- All read endpoints that **already exist** are wired (orders/deliveries/stock/customers/users/
|
||||||
|
locations/order-line-items/etc.).
|
||||||
|
- Each `[Rxx]` above corresponds to a visible `AwaitingApi` placeholder in the UI, so QA can see
|
||||||
|
exactly which screens are still blocked.
|
||||||
|
- The moment an endpoint here ships with the documented shape, wiring it is one hook + swapping the
|
||||||
|
placeholder — no redesign needed.
|
||||||
105
docs/_endpoints_raw.txt
Normal file
105
docs/_endpoints_raw.txt
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
API Name | Method | Exact Table | Query Parameters
|
||||||
|
Utils | GET | app_types | tag
|
||||||
|
Resolve subcategories (Web) | GET | app_subcategory | moduleid, categoryid
|
||||||
|
Fetch system active geofence details (Web) | GET | app_location | applocationid
|
||||||
|
Fetch global system categories configurations (Web) | GET | app_category | None
|
||||||
|
Get mobile geofence configuration details (Mobile) | GET | app_locationconfig | applocationid
|
||||||
|
Get mobile active geofence details (Mobile) | GET | app_location | applocationid
|
||||||
|
Fetch mobile app types by tag (Mobile) | GET | app_types | tag
|
||||||
|
Fetch global payment & geofence configs (Mobile) | GET | app_config | configid
|
||||||
|
Get mobile category subcategories list (Mobile) | GET | app_subcategory | moduleid, categoryid
|
||||||
|
Fetch mobile app categories configurations (Mobile) | GET | app_category | None
|
||||||
|
Users | GET | users | roleid, tenantid, pageno, pagesize, keyword
|
||||||
|
Get a specific user profile by ID (Web) | GET | users | userid
|
||||||
|
Get a specific user profile by ID (Mobile) | GET | users | userid
|
||||||
|
Operator/User orders board (Web) | GET | orders | appuserid, status, pageno, pagesize
|
||||||
|
Tenant Web Panel Login | POST | REVIEW_REQUIRED | None
|
||||||
|
General Application Login | POST | REVIEW_REQUIRED | None
|
||||||
|
Register New Web Staff Account | POST | REVIEW_REQUIRED | None
|
||||||
|
Update Web Staff User Details | PUT | REVIEW_REQUIRED | None
|
||||||
|
Rider/Merchant Mobile App Login | POST | REVIEW_REQUIRED | None
|
||||||
|
Create Mobile Staff User | POST | REVIEW_REQUIRED | None
|
||||||
|
Update Mobile Staff Details | PUT | REVIEW_REQUIRED | None
|
||||||
|
Partners | GET | orders | partnerid, status, pageno, pagesize
|
||||||
|
Get active riders (Web) | GET | partneruser | partnerid, applocationid, userid, tenantid
|
||||||
|
Get partner profiles (Web) | GET | partneruser | partnerid, applocationid, userid
|
||||||
|
Get rider shifts (Web) | GET | partneruser | applocationid
|
||||||
|
Get location configurations (Web) | GET | REVIEW_REQUIRED | userid, configid
|
||||||
|
Get rider log sheet (Web) | GET | riderlogs | partnerid, applocationid, fromdate, todate
|
||||||
|
Get partner profiles (Mobile) | GET | partneruser | partnerid, applocationid, userid
|
||||||
|
Get rider log sheet (Mobile) | GET | riderlogs | partnerid, applocationid, fromdate, todate
|
||||||
|
Get rider operational info (Mobile) | GET | partneruser | userid
|
||||||
|
Get active riders list (Mobile) | GET | partneruser | partnerid, applocationid, userid, tenantid
|
||||||
|
Tenants | GET | orders | tenantid, locationid, status, pageno, pagesize
|
||||||
|
Get specific tenant store orders (Mobile) | GET | orders | tenantid, locationid, pageno, pagesize
|
||||||
|
Search registered tenants (Web) | GET | REVIEW_REQUIRED | status, keyword
|
||||||
|
Search tenants by keyword (Web) | GET | REVIEW_REQUIRED | keyword
|
||||||
|
Get all active tenants catalog (Web) | GET | tenants | applocationid, status, pageno, pagesize
|
||||||
|
Get outlet locations assigned to a tenant (Web) | GET | tenantlocations | tenantid
|
||||||
|
Retrieve delivery time slots config (Mobile) | GET | tenantslot | None
|
||||||
|
Search tenants by keyword (Mobile) | GET | REVIEW_REQUIRED | keyword
|
||||||
|
Retrieve tenants associated with a customer (Mobile) | GET | tenantcustomers | customerid, tenant
|
||||||
|
Get outlet locations linked to a tenant (Mobile) | GET | tenantlocations | tenantid
|
||||||
|
Get logistics pricing slabs for a tenant (Mobile) | GET | tenantpricing | tenantid, applocationid
|
||||||
|
Get staff members under a tenant store (Mobile) | GET | tenantstaffs | tenantid
|
||||||
|
Get tenant detailed profile info (Mobile) | GET | tenants | tenantid, locationid
|
||||||
|
Link Customer Profile to Tenant Store | POST | REVIEW_REQUIRED | None
|
||||||
|
Create New Geofenced Store Location | POST | REVIEW_REQUIRED | None
|
||||||
|
Update Store Location Configurations | PUT | REVIEW_REQUIRED | None
|
||||||
|
Onboard New Tenant & Admin Profile | POST | REVIEW_REQUIRED | None
|
||||||
|
Customers | GET | customers | customerid, contactno
|
||||||
|
Get customer saved address locations (Mobile) | GET | customerlocations | customerid
|
||||||
|
Get customer logged support requests (Mobile) | GET | customerrequest | customerid, pageno, pagesize
|
||||||
|
Search customer names under a tenant (Mobile) | GET | REVIEW_REQUIRED | keyword, tenantid
|
||||||
|
Retrieve customers linked to a tenant location (Mobile) | GET | tenantcustomers | tenantid, locationid, pageno, pagesize
|
||||||
|
Retrieve merchant customers list (Web) | GET | tenantcustomers | tenantid, locationid, pageno, pagesize, keyword
|
||||||
|
Individual consumer invoice histories (Web) | GET | orders | customerid, status, pageno, pagesize
|
||||||
|
Passwordless OTP Login (via Phone) | POST | REVIEW_REQUIRED | None
|
||||||
|
Register Customer Account | POST | REVIEW_REQUIRED | None
|
||||||
|
Save New Geofenced Location Address | POST | REVIEW_REQUIRED | None
|
||||||
|
Log Customer Support Ticket Request | POST | REVIEW_REQUIRED | None
|
||||||
|
Update Customer Profile Details | PUT | REVIEW_REQUIRED | None
|
||||||
|
Deliveries | GET | deliveries | tenantid, partnerid, userid, applocationid, locationid, fromdate, todate
|
||||||
|
Get daily delivery insights (Web) | GET | deliveries | tenantid
|
||||||
|
Get location deliveries summary (Web) | GET | deliveries | tenantid
|
||||||
|
Get deliveries financial report summary (Web) | GET | deliveries | tenantid, partnerid, userid, applocationid, fromdate, todate
|
||||||
|
Get fleet rider summary metrics (Web) | GET | partneruser | applocationid, partnerid, tenantid, fromdate, todate
|
||||||
|
Get master deliveries board (Web) | GET | deliveries | tenantid, fromdate, todate
|
||||||
|
Get mobile dispatch summaries (Mobile) | GET | deliveries | userid, fromdate, todate
|
||||||
|
Get mobile deliveries board (Mobile) | GET | deliveries | userid, status
|
||||||
|
Fetch rider active shift deliveries queue (Mobile) | GET | deliveryqueues | userid, fromdate, todate
|
||||||
|
Initialize Logistics Delivery Dispatch (Assign Rider) | POST | deliveries + deliverydetails | None
|
||||||
|
Update Rider Pickup Status | PUT | deliveries | None
|
||||||
|
Rider Update Dispatch Status (Delivered) | PUT | deliveries | None
|
||||||
|
Orders | GET | orders | tenantid, locationid, status, pageno, pagesize
|
||||||
|
System Admin orders board (Web) | GET | orders | applocationid, status, pageno, pagesize
|
||||||
|
Get order dashboard stats summary (Web) | GET | orders | tenantid, fromdate, todate
|
||||||
|
Get location orders summary (Web) | GET | deliveries | tenantid
|
||||||
|
Get annual orders insights analytics (Web) | GET | orders | tenantid
|
||||||
|
Get order detailed lines (Web) | GET | orderdetails | orderheaderid
|
||||||
|
Get customer order history (Mobile) | GET | orders | customerid, pageno, pagesize
|
||||||
|
Get mobile order detailed lines (Mobile) | GET | orderdetails | orderheaderid
|
||||||
|
Create New Web Order | POST | orders + orderdetails | None
|
||||||
|
Update Order Status & Financial Flag | PUT | orders | None
|
||||||
|
Create Mobile Order | POST | orders + orderdetails | None
|
||||||
|
Mobile Update Order Status | PUT | orders | None
|
||||||
|
Products | GET | productsubcategories | categoryid, tenantid
|
||||||
|
Get products stock counts (Web) | GET | products | tenantid, categoryid, subcategoryid, approve
|
||||||
|
Get all global categories (Web) | GET | productcategories | None
|
||||||
|
Get specific product variants (Web) | GET | productvariants | tenantid, subcategoryid
|
||||||
|
Get master catalog listings (Web) | GET | products | tenantid, locationid, subcategoryid, keyword, pageno, pagesize
|
||||||
|
Get live stocks catalog (Web) | GET | productstocks | tenantid, locationid
|
||||||
|
Get dynamic stock statement ledger (Web) | GET | product_stock_statement | tenantid, locationid, subcategoryid, pageno, pagesize, keyword
|
||||||
|
Get outlet geofenced inventory (Web) | GET | productlocations | tenantid, locationid, subcategoryid, pageno, pagesize
|
||||||
|
Master products search board (Web) | GET | products | tenantid, locationid, keyword, pageno, pagesize
|
||||||
|
Get product details by variant ID (Mobile) | GET | productvariants | tenantid, variantid
|
||||||
|
Get product subcategories (Mobile) | GET | productsubcategories | categoryid, tenantid
|
||||||
|
Search product catalog (Mobile) | GET | products | keyword, pageno, pagesize
|
||||||
|
Get mobile geofenced outlet products (Mobile) | GET | productlocations | tenantid, locationid, pageno, pagesize
|
||||||
|
Add Multi-Product Stock Entry | POST | REVIEW_REQUIRED | None
|
||||||
|
Create Master Product Catalog Item | POST | REVIEW_REQUIRED | None
|
||||||
|
Update Master Product Details | PUT | REVIEW_REQUIRED | None
|
||||||
|
Purge Master Product Catalog Entry | DELETE | REVIEW_REQUIRED | productid
|
||||||
|
Invoice | GET | invoice_insight | tenantid
|
||||||
|
Payments | GET | paymentrequests | partnerid, status
|
||||||
|
TOTAL ROWS: 104
|
||||||
1009
spec_bundle.js
Normal file
1009
spec_bundle.js
Normal file
File diff suppressed because one or more lines are too long
136
src/App.tsx
136
src/App.tsx
@@ -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 (
|
||||||
<StoreDetailView
|
<div className="space-y-md animate-in fade-in duration-300">
|
||||||
store={selectedStore}
|
{isSoleStore && (
|
||||||
onBack={() => setSelectedStore(null)}
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-4 border-b border-zinc-205">
|
||||||
/>
|
<div>
|
||||||
|
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">
|
||||||
|
Store Console
|
||||||
|
</h1>
|
||||||
|
<p className="text-zinc-500 font-sans text-xs mt-1">
|
||||||
|
This merchant operates a single store. Add a branch to manage multiple outlets.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddStoreModal(true)}
|
||||||
|
className="bg-[#581c87] text-white px-5 py-2.5 rounded-lg text-xs font-bold uppercase tracking-wider flex items-center justify-center gap-2 cursor-pointer hover:bg-purple-800 transition shadow-sm self-start md:self-auto"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add Retail Outlet Node
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<StoreDetailView
|
||||||
|
store={activeStore}
|
||||||
|
onBack={selectedStore ? () => setSelectedStore(null) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
||||||
|
|||||||
40
src/components/AwaitingApi.tsx
Normal file
40
src/components/AwaitingApi.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { PlugZap } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit "this data has no backend yet" placeholder. Used for UI sections whose
|
||||||
|
* live API is not built (see docs/03_REQUIRED_BACKEND_APIS.md → [R*] ids). We show
|
||||||
|
* this instead of fabricated numbers so nothing fake reaches staging.
|
||||||
|
*/
|
||||||
|
export default function AwaitingApi({
|
||||||
|
label,
|
||||||
|
api,
|
||||||
|
className = '',
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
/** What the section will show once wired (e.g. "Operational alerts"). */
|
||||||
|
label: string;
|
||||||
|
/** The required-API id from the spec doc, e.g. "[R12]". */
|
||||||
|
api?: string;
|
||||||
|
className?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col items-center justify-center text-center rounded-xl border border-dashed border-slate-300 bg-slate-50/60 text-slate-400 ${
|
||||||
|
compact ? 'p-4 gap-1.5' : 'p-8 gap-2'
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
|
<PlugZap size={compact ? 16 : 22} className="text-slate-300" />
|
||||||
|
<p className={`font-semibold text-slate-500 ${compact ? 'text-xs' : 'text-sm'}`}>{label}</p>
|
||||||
|
<p className="text-[11px] leading-relaxed">
|
||||||
|
Awaiting backend API{api ? ` ${api}` : ''} — no live data source yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,15 +5,15 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import 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"
|
||||||
</span>
|
alt="Executive operations dashboard"
|
||||||
|
className="w-full h-full object-cover object-center opacity-40"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-900/90 to-purple-950/80" />
|
||||||
|
<div className="absolute top-0 right-0 w-72 h-72 bg-purple-500/10 rounded-full blur-3xl -mr-20 -mt-20 pointer-events-none" />
|
||||||
|
<div className="absolute bottom-0 left-0 w-56 h-56 bg-indigo-500/10 rounded-full blur-2xl -ml-20 -mb-20 pointer-events-none" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header */}
|
{/* Content row */}
|
||||||
<div className="flex justify-between items-end">
|
<div className="relative z-10 flex flex-col md:flex-row md:items-center justify-between gap-lg">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-sans font-bold text-3xl tracking-tight text-[#0f172a]">Executive Command Center</h1>
|
<h1 className="font-sans font-bold text-2xl md:text-3xl tracking-tight text-white flex items-center gap-2.5">
|
||||||
<div className="flex items-center gap-2 mt-1">
|
Executive Command Center
|
||||||
<p className="text-zinc-500 font-sans text-sm">Month-to-date order operations, pulled live from the API.</p>
|
<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">
|
||||||
{loading ? (
|
Live Core
|
||||||
<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>
|
</span>
|
||||||
) : errored ? (
|
</h1>
|
||||||
<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.">
|
<p className="text-slate-300 font-sans text-sm mt-2 leading-relaxed whitespace-nowrap">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-rose-500" /> Live data unavailable
|
Month-to-date order operations for <strong className="text-white font-semibold">{tenantName}</strong>, pulled live from the API.
|
||||||
</span>
|
</p>
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-600 uppercase tracking-wide">
|
<div className="mt-4">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" /> Live · {tenantName}
|
{loading ? (
|
||||||
</span>
|
<span className="inline-flex items-center gap-1.5 text-[11px] font-bold text-slate-300 uppercase tracking-wide">
|
||||||
)}
|
<span className="w-2 h-2 rounded-full bg-slate-400 animate-pulse" /> Syncing live data…
|
||||||
|
</span>
|
||||||
|
) : errored ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[11px] font-bold text-rose-300 uppercase tracking-wide" title="Restart the dev server so the /hasura proxy is active.">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-rose-400" /> Live data unavailable
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[11px] font-bold text-emerald-300 uppercase tracking-wide">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" /> Live · {tenantName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reporting scope panel */}
|
||||||
|
<div className="flex flex-col items-start md:items-end gap-2 shrink-0">
|
||||||
|
<div className="inline-flex items-center gap-2 bg-white/10 backdrop-blur-md border border-white/15 rounded-xl px-3.5 py-2.5 shadow-sm">
|
||||||
|
<Clock size={14} className="text-purple-300" />
|
||||||
|
<span className="text-xs font-bold font-mono text-white tracking-tight">{fromdate} → {todate}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-slate-400 uppercase tracking-widest font-bold">Month-to-date reporting scope</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
<ArrowUpRight size={16} className="text-slate-300 group-hover:text-purple-400 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] font-semibold text-zinc-400 tracking-wider uppercase font-sans mt-3">
|
<p className="text-[10px] font-bold text-slate-400 tracking-widest uppercase font-sans mt-4">
|
||||||
{kpi.title}
|
{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>
|
<div className="flex items-start gap-2.5">
|
||||||
<h3 className="font-sans font-bold text-base text-[#0f172a]">Store Outlet Status</h3>
|
<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">
|
||||||
<p className="text-zinc-500 text-xs font-sans mt-0.5">Active share of all registered store nodes.</p>
|
<Activity size={17} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-sans font-bold text-base text-slate-900 tracking-tight">Store Outlet Status</h3>
|
||||||
|
<p className="text-slate-500 text-xs font-sans mt-0.5">Active share of all registered nodes.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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="min-w-0">
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
<p className="font-sans font-semibold text-sm text-[#0f172a] truncate">{str(loc.locationname)}</p>
|
{/* Outlet initial badge */}
|
||||||
<p className="text-[11px] text-zinc-500 mt-0.5 flex items-center gap-1">
|
<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">
|
||||||
<MapPin size={11} className="shrink-0 text-zinc-400" />
|
{name.slice(0, 2) || '—'}
|
||||||
<span className="truncate">{str(loc.address) || `${str(loc.suburb)}, ${str(loc.city)}`}</span>
|
</div>
|
||||||
</p>
|
<div className="min-w-0">
|
||||||
{str(loc.contactno) && (
|
<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">
|
||||||
<Phone size={11} className="shrink-0 text-zinc-400" />
|
<MapPin size={11} className="shrink-0 text-slate-400" />
|
||||||
{str(loc.contactno)}
|
<span className="truncate">{str(loc.address) || `${str(loc.suburb)}, ${str(loc.city)}`}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
{str(loc.contactno) && (
|
||||||
|
<p className="text-[11px] text-slate-500 mt-0.5 flex items-center gap-1">
|
||||||
{/* Node-specific Orders and Dispatches */}
|
<Phone size={11} className="shrink-0 text-slate-400" />
|
||||||
<div className="flex items-center gap-3 mt-2.5">
|
{str(loc.contactno)}
|
||||||
<span className="text-[10px] bg-purple-50 text-[#581c87] font-semibold px-2 py-0.5 rounded border border-purple-100/50">
|
</p>
|
||||||
{orders} Orders
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] bg-emerald-50 text-emerald-700 font-semibold px-2 py-0.5 rounded border border-emerald-100/50">
|
|
||||||
{deliveries} Dispatched
|
|
||||||
</span>
|
|
||||||
{orders > 0 && (
|
|
||||||
<span className="text-[10px] text-zinc-400 font-medium">
|
|
||||||
{Math.round((deliveries / orders) * 100)}% Fulfilled
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Node-specific Orders and Dispatches */}
|
||||||
|
<div className="flex items-center gap-2 mt-2.5 flex-wrap">
|
||||||
|
<span className="text-[10px] bg-purple-50 text-purple-650 font-bold px-2 py-0.5 rounded-md border border-purple-100/60">
|
||||||
|
{orders} Orders
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] bg-emerald-50 text-emerald-700 font-bold px-2 py-0.5 rounded-md border border-emerald-100/60">
|
||||||
|
{deliveries} Dispatched
|
||||||
|
</span>
|
||||||
|
{orders > 0 && (
|
||||||
|
<span className="text-[10px] text-slate-400 font-semibold">
|
||||||
|
{Math.round((deliveries / orders) * 100)}% Fulfilled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|||||||
@@ -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,52 +85,74 @@ 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>
|
||||||
<div className="p-2 divide-y divide-[#f1f5f9]">
|
|
||||||
<div className="py-1">
|
|
||||||
<p className="px-2 text-[10px] text-zinc-400 font-semibold uppercase tracking-wider">Email</p>
|
|
||||||
<p className="px-2 py-0.5 text-xs text-purple-600 font-sans font-medium truncate">{profile.email}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Account actions (moved here from the sidebar) */}
|
{/* 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
|
<button
|
||||||
onClick={() => { setShowProfileDropdown(false); onNewReportClick(); }}
|
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"
|
||||||
>
|
>
|
||||||
<Plus size={14} className="text-[#581c87]" />
|
<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">
|
||||||
New Report
|
<HelpCircle size={14} />
|
||||||
</button>
|
</span>
|
||||||
<button
|
Help Center
|
||||||
onClick={() => { setShowProfileDropdown(false); onHelpClick(); }}
|
</button>
|
||||||
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"
|
<div className="my-1 h-px bg-slate-100" />
|
||||||
>
|
<button
|
||||||
<HelpCircle size={14} className="text-zinc-400" />
|
onClick={() => { setShowProfileDropdown(false); onLogoutClick(); }}
|
||||||
Help Center
|
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"
|
||||||
</button>
|
>
|
||||||
<button
|
<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">
|
||||||
onClick={() => { setShowProfileDropdown(false); onLogoutClick(); }}
|
<LogOut size={14} />
|
||||||
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"
|
</span>
|
||||||
>
|
Log Out
|
||||||
<LogOut size={14} className="text-rose-500" />
|
</button>
|
||||||
Log Out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,19 +576,22 @@ 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">
|
||||||
<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">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="w-1 h-1 rounded-full bg-emerald-500 animate-pulse" />
|
<h3 className="font-bold text-base text-slate-900 tracking-tight">Regional Hub Stocks</h3>
|
||||||
Live Sync
|
<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>
|
<span className="w-1 h-1 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
Live Sync
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-400 text-[11px] font-medium">
|
||||||
|
Real-time inventory levels and capacity balance across {locations.length} regional outlets.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-400 text-[10px] font-medium">
|
|
||||||
Real-time inventory levels and capacity balance across {locations.length} regional outlets.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls: Search + Filters */}
|
{/* Controls: Search + Filters */}
|
||||||
@@ -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' },
|
||||||
</div>
|
{ label: 'Low Stock Items', value: storeAlertsData.lowStockCount, icon: TrendingDown, chip: 'bg-amber-50 text-amber-600 ring-amber-100', value_cls: 'text-amber-600' },
|
||||||
<div className="flex flex-col gap-0.5">
|
{ label: 'Critical Alerts', value: storeAlertsData.criticalCount, icon: AlertTriangle, chip: 'bg-rose-50 text-rose-600 ring-rose-100', value_cls: 'text-rose-600' },
|
||||||
<span className="text-[9px] text-slate-400 uppercase tracking-wider font-extrabold">Optimal Hubs</span>
|
].map((m) => {
|
||||||
<span className="font-bold text-emerald-600 text-sm font-mono">{locations.length - storeAlertsData.alertOutletsCount}</span>
|
const MIcon = m.icon;
|
||||||
</div>
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div
|
||||||
<span className="text-[9px] text-slate-400 uppercase tracking-wider font-extrabold">Low Stock Items</span>
|
key={m.label}
|
||||||
<span className="font-bold text-amber-600 text-sm font-mono">{storeAlertsData.lowStockCount}</span>
|
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>
|
>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className={`h-10 w-10 shrink-0 rounded-xl flex items-center justify-center ring-1 ${m.chip}`}>
|
||||||
<span className="text-[9px] text-slate-400 uppercase tracking-wider font-extrabold">Critical Alerts</span>
|
<MIcon size={18} />
|
||||||
<span className="font-bold text-rose-600 text-sm font-mono">{storeAlertsData.criticalCount}</span>
|
</div>
|
||||||
</div>
|
<div className="min-w-0">
|
||||||
|
<span className="block text-[9px] text-slate-400 uppercase tracking-wider font-extrabold truncate">{m.label}</span>
|
||||||
|
<span className={`block font-extrabold text-2xl leading-tight font-sans tracking-tight ${m.value_cls}`}>{m.value}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -833,10 +708,14 @@ export default function InventoryView({
|
|||||||
const meta = locations.find((l) => l.locationid === store.locationid);
|
const meta = locations.find((l) => l.locationid === store.locationid);
|
||||||
const status = meta?.status ?? 'Active';
|
const status = meta?.status ?? 'Active';
|
||||||
|
|
||||||
const statusDotColor = hasAlert
|
const statusDotColor = hasAlert
|
||||||
? 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,72 +727,71 @@ 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>
|
||||||
<div className="absolute inset-0 rounded-full border-2 border-purple-600 border-t-transparent animate-spin" />
|
<div className="min-w-0">
|
||||||
</div>
|
<h4 className="font-bold text-slate-900 text-[13px] truncate leading-tight">
|
||||||
<div className="space-y-0.5 font-sans">
|
{store.locationname}
|
||||||
<h4 className="text-slate-850 font-bold text-[11px]">Replenishing Hub...</h4>
|
</h4>
|
||||||
<p className="text-slate-400 text-[9px]">Dispatching supply batch to {store.locationname}</p>
|
<p className="text-[10px] text-slate-400 mt-0.5 font-medium">
|
||||||
</div>
|
{totalItems} items · {totalUnits.toLocaleString('en-IN')} units
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 ${
|
||||||
|
|
||||||
{/* Card Header (Clean & borderless) */}
|
|
||||||
<div className="p-4 pb-2 flex justify-between items-start gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h4 className="font-bold text-slate-900 text-xs truncate flex items-center gap-1.5">
|
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${statusDotColor}`} />
|
|
||||||
{store.locationname}
|
|
||||||
</h4>
|
|
||||||
<p className="text-[9px] text-slate-400 mt-0.5 font-medium">
|
|
||||||
{totalItems} items · {totalUnits.toLocaleString('en-IN')} units
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className={`shrink-0 px-2 py-0.5 rounded-full text-[8px] font-black uppercase tracking-wider ${
|
|
||||||
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">
|
||||||
{criticalPct > 0 && (
|
<div className="flex justify-between items-center text-[9px] font-bold text-slate-400 uppercase tracking-wider">
|
||||||
<div className="h-full bg-rose-500" style={{ width: `${criticalPct}%` }} />
|
<span>Stock Health</span>
|
||||||
)}
|
<span className="flex items-center gap-2 normal-case font-semibold">
|
||||||
{lowPct > 0 && (
|
{criticalItemsCount > 0 && <span className="text-rose-600">{criticalItemsCount} crit</span>}
|
||||||
<div className="h-full bg-amber-500" style={{ width: `${lowPct}%` }} />
|
{lowCount > 0 && <span className="text-amber-600">{lowCount} low</span>}
|
||||||
)}
|
<span className="text-emerald-600">{optimalCount} ok</span>
|
||||||
{optimalPct > 0 && (
|
</span>
|
||||||
<div className="h-full bg-emerald-500" style={{ width: `${optimalPct}%` }} />
|
</div>
|
||||||
)}
|
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden flex select-none">
|
||||||
|
{criticalPct > 0 && (
|
||||||
|
<div className="h-full bg-rose-500" style={{ width: `${criticalPct}%` }} />
|
||||||
|
)}
|
||||||
|
{lowPct > 0 && (
|
||||||
|
<div className="h-full bg-amber-500" style={{ width: `${lowPct}%` }} />
|
||||||
|
)}
|
||||||
|
{optimalPct > 0 && (
|
||||||
|
<div className="h-full bg-emerald-500" style={{ width: `${optimalPct}%` }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</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'
|
||||||
@@ -946,23 +823,12 @@ export default function InventoryView({
|
|||||||
{it.name}
|
{it.name}
|
||||||
</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>
|
||||||
|
|||||||
296
src/components/LoginView.tsx
Normal file
296
src/components/LoginView.tsx
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ShieldCheck,
|
||||||
|
Mail,
|
||||||
|
Lock,
|
||||||
|
ArrowRight,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { checkEmailRequest } from '../services/auth';
|
||||||
|
import type { AuthUser } from '../services/auth';
|
||||||
|
import { useLogin } from '../services/fiestaQueries';
|
||||||
|
|
||||||
|
interface LoginViewProps {
|
||||||
|
/** Called with the authenticated user once credentials are verified. */
|
||||||
|
onLogin: (user: AuthUser) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginView({ onLogin }: LoginViewProps) {
|
||||||
|
// A single login form. The backend's role on the verified user decides the
|
||||||
|
// workspace (admin vs user) the account actually lands on.
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [step, setStep] = useState<'email' | 'password'>('email');
|
||||||
|
const [checkingEmail, setCheckingEmail] = useState(false);
|
||||||
|
|
||||||
|
const login = useLogin();
|
||||||
|
const loading = login.isPending;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (loading || checkingEmail) return;
|
||||||
|
|
||||||
|
if (step === 'email') {
|
||||||
|
if (!email.trim()) {
|
||||||
|
setError('Please enter your email.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
setCheckingEmail(true);
|
||||||
|
try {
|
||||||
|
const emailExists = await checkEmailRequest(email);
|
||||||
|
if (emailExists) {
|
||||||
|
setStep('password');
|
||||||
|
} else {
|
||||||
|
setError('Email not found. Please try again.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to verify email. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setCheckingEmail(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!password.trim()) {
|
||||||
|
setError('Please enter your password.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
// Verify credentials against the backend via the TanStack mutation. Only an
|
||||||
|
// exact match resolves; the returned role routes admin vs user workspace.
|
||||||
|
login.mutate(
|
||||||
|
{ email, password },
|
||||||
|
{
|
||||||
|
onSuccess: (user) => onLogin(user),
|
||||||
|
onError: (err) =>
|
||||||
|
setError(err instanceof Error ? err.message : 'Sign in failed. Please try again.'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-full flex bg-white font-sans text-slate-800 overflow-hidden">
|
||||||
|
{/* ── Left brand / hero panel (desktop only) ── */}
|
||||||
|
<div className="hidden lg:flex lg:w-1/2 shrink-0 relative overflow-hidden bg-gradient-to-br from-[#5b1d8c] via-purple-950 to-slate-950 text-white flex-col p-12 xl:p-16 justify-center">
|
||||||
|
{/* Layered ambient glows */}
|
||||||
|
<div className="absolute -top-24 -right-24 w-96 h-96 bg-purple-500/25 rounded-full blur-[120px] pointer-events-none" />
|
||||||
|
<div className="absolute -bottom-24 -left-24 w-[26rem] h-[26rem] bg-indigo-500/20 rounded-full blur-[140px] pointer-events-none animate-pulse" style={{ animationDuration: '9s' }} />
|
||||||
|
<div className="absolute top-1/3 left-1/2 w-72 h-72 bg-fuchsia-500/10 rounded-full blur-[120px] pointer-events-none" />
|
||||||
|
{/* Subtle dot-grid texture */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.07] pointer-events-none"
|
||||||
|
style={{ backgroundImage: 'radial-gradient(circle, #ffffff 1px, transparent 1px)', backgroundSize: '22px 22px' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col justify-center w-full max-w-[520px] mx-auto h-full">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="shrink-0 mb-10">
|
||||||
|
<img src="/logo.png" alt="nearledaily" className="h-10 w-auto object-contain" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<div className="min-h-0">
|
||||||
|
{/* Eyebrow */}
|
||||||
|
<span className="inline-flex items-center gap-1.5 self-start text-[11px] font-bold uppercase tracking-[0.15em] text-purple-100 bg-white/10 border border-white/15 rounded-full px-3 py-1 backdrop-blur-sm">
|
||||||
|
<ShieldCheck size={13} className="text-purple-200" />
|
||||||
|
Secure Merchant Console
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h1
|
||||||
|
className="text-5xl xl:text-6xl font-bold tracking-tight leading-[1.1] mt-6 max-w-[460px]"
|
||||||
|
style={{ textShadow: '0 2px 16px rgba(0,0,0,0.25)' }}
|
||||||
|
>
|
||||||
|
All Operations
|
||||||
|
<br />
|
||||||
|
In One Console
|
||||||
|
</h1>
|
||||||
|
<p className="text-purple-100/90 text-lg xl:text-xl leading-[1.6] mt-6 max-w-[460px]">
|
||||||
|
Monitor regional hubs, track live order operations, and manage your entire store network from a single command center.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Glassmorphic live-snapshot preview card */}
|
||||||
|
<div className="mt-10 max-w-[460px] rounded-2xl bg-white/10 backdrop-blur-md border border-white/15 p-6 shadow-2xl shadow-purple-950/50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-widest text-purple-100">Live Operations</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs font-bold text-emerald-300">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
|
LIVE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-3xl font-extrabold tracking-tight tabular-nums">2,480</p>
|
||||||
|
<p className="text-xs text-purple-200 mt-1">Orders today</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-3xl font-extrabold tracking-tight text-emerald-300 tabular-nums">98.6%</p>
|
||||||
|
<p className="text-xs text-purple-200 mt-1">Fulfillment</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mini bar chart */}
|
||||||
|
<div className="mt-5 flex items-end gap-1.5 h-14">
|
||||||
|
{[45, 62, 38, 70, 52, 80, 60, 90, 55, 74].map((h, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-1 rounded-t bg-gradient-to-t from-purple-500/40 to-purple-200/80"
|
||||||
|
style={{ height: `${h}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trust stats + copyright */}
|
||||||
|
<div className="shrink-0 mt-10 pt-6 border-t border-white/10 max-w-[460px]">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{[
|
||||||
|
{ v: '120+', l: 'Regional hubs' },
|
||||||
|
{ v: '99.9%', l: 'Platform uptime' },
|
||||||
|
{ v: '24/7', l: 'Live monitoring' },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.l}>
|
||||||
|
<p className="text-2xl font-bold tracking-tight">{s.v}</p>
|
||||||
|
<p className="text-xs text-purple-300 mt-0.5">{s.l}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-purple-300/70 font-medium mt-4">
|
||||||
|
© 2026 nearledaily · All rights reserved
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right form panel ── */}
|
||||||
|
<div className="w-full lg:w-1/2 flex items-center justify-center px-6 py-8 sm:px-10 overflow-y-auto">
|
||||||
|
<div className="w-full max-w-[400px]">
|
||||||
|
{/* Logo / brand (mobile) */}
|
||||||
|
<div className="lg:hidden mb-8">
|
||||||
|
<img src="/logo.png" alt="nearledaily" className="h-9 w-auto object-contain" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heading */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-[1.7rem] font-bold text-slate-900 tracking-tight leading-tight">Welcome back</h2>
|
||||||
|
<p className="text-slate-500 text-sm mt-2">Sign in to your nearledaily workspace to continue.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Email or username (the API's `authname` accepts either) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<label htmlFor="login-email" className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
|
||||||
|
Email or Username
|
||||||
|
</label>
|
||||||
|
{step === 'password' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setStep('email'); setError(''); setPassword(''); }}
|
||||||
|
className="text-xs font-semibold text-purple-600 hover:text-purple-800 bg-transparent border-none cursor-pointer p-0"
|
||||||
|
>
|
||||||
|
Change Email
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
id="login-email"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
disabled={step === 'password'}
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@nearledaily.com"
|
||||||
|
className="w-full h-12 pl-10 pr-4 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 transition-all disabled:opacity-75 disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
{step === 'password' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="login-password" className="block text-xs font-bold text-slate-600 uppercase tracking-wider">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
id="login-password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full h-12 pl-10 pr-11 bg-slate-50 border border-slate-200 rounded-xl text-sm text-slate-800 placeholder-slate-400 focus:outline-none focus:border-purple-500 focus:bg-white focus:ring-4 focus:ring-purple-500/10 transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((s) => !s)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 bg-transparent border-none cursor-pointer p-1"
|
||||||
|
title={showPassword ? 'Hide password' : 'Show password'}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs font-semibold text-rose-600 bg-rose-50 border border-rose-100 rounded-lg px-3 py-2.5">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Options row */}
|
||||||
|
{step === 'password' && (
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<label className="flex items-center gap-2 text-slate-500 font-medium cursor-pointer select-none">
|
||||||
|
<input type="checkbox" className="h-3.5 w-3.5 rounded border-slate-300 text-purple-600 accent-purple-600 focus:ring-purple-500/30" />
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
|
<button type="button" className="font-semibold text-purple-650 hover:text-purple-800 bg-transparent border-none cursor-pointer">
|
||||||
|
Forgot password?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || checkingEmail}
|
||||||
|
className="w-full h-12 flex items-center justify-center gap-2 bg-[#581c87] hover:bg-purple-800 text-white font-bold text-sm rounded-xl shadow-sm hover:shadow-lg transition-all active:scale-[0.98] cursor-pointer border-none disabled:opacity-70 disabled:cursor-not-allowed disabled:active:scale-100"
|
||||||
|
>
|
||||||
|
{loading || checkingEmail ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
{checkingEmail ? 'Checking Email…' : 'Verifying…'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{step === 'email' ? 'Continue' : 'Sign in'}
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Helper note */}
|
||||||
|
<p className="text-center text-xs text-slate-400 mt-6 leading-relaxed">
|
||||||
|
Your workspace is set automatically based on your account access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,12 +19,9 @@ import {
|
|||||||
XCircle,
|
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,12 +370,14 @@ 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 */}
|
||||||
<div className="absolute right-3 bottom-3 opacity-15">
|
<div className="absolute right-3 bottom-3 opacity-15">
|
||||||
<PackageCheck size={64} className="text-purple-300" />
|
<PackageCheck size={64} className="text-purple-300" />
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -549,19 +545,10 @@ export default function OperationsView({ searchQuery, isCoimbatoreView }: Operat
|
|||||||
{activeSubTab === 'import' && (
|
{activeSubTab === 'import' && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-gutter animate-in slide-in-from-right-5">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-gutter animate-in slide-in-from-right-5">
|
||||||
{/* Upload panel zone */}
|
{/* Upload panel zone */}
|
||||||
<div
|
<div
|
||||||
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 */}
|
||||||
<div className="mt-3 flex items-baseline gap-2">
|
{kpi.awaiting ? (
|
||||||
<div className="font-sans font-black text-slate-900 text-3xl tracking-tight leading-none">
|
<div className="mt-3">
|
||||||
{kpi.value}
|
<AwaitingApi label="Revenue" api="[R1]" compact />
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-[9px] font-bold px-2 py-0.5 rounded-full flex items-center gap-0.5 leading-none h-4 ${kpi.isPositive ? 'bg-emerald-50 text-emerald-600 border border-emerald-100/50' : 'bg-rose-50 text-rose-600 border border-rose-100/50'
|
) : (
|
||||||
}`}>
|
<div className="mt-3 flex items-baseline gap-2">
|
||||||
{kpi.isPositive ? '▲' : '▼'}
|
<div className="font-sans font-black text-slate-900 text-3xl tracking-tight leading-none">
|
||||||
{kpi.trend}
|
{kpi.value}
|
||||||
</span>
|
</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'
|
||||||
|
}`}>
|
||||||
|
{kpi.isPositive ? '▲' : '▼'}
|
||||||
|
{kpi.trend}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
254
src/components/UserStorePage.tsx
Normal file
254
src/components/UserStorePage.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { AlertTriangle, LayoutDashboard, User, Mail, Phone, Store, ShieldCheck } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useFiestaTenantLocations,
|
||||||
|
useFiestaLocationSummary,
|
||||||
|
FIESTA_TENANT_ID,
|
||||||
|
} from '../services/fiestaQueries';
|
||||||
|
import { str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||||
|
import type { AuthUser } from '../services/auth';
|
||||||
|
import Header from './Header';
|
||||||
|
import StoreDetailView from './StoreDetailView';
|
||||||
|
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
||||||
|
|
||||||
|
interface UserStorePageProps {
|
||||||
|
/** Returns to the login screen. */
|
||||||
|
onLogout: () => void;
|
||||||
|
/** The signed-in user, allocated to a single store via their applocationid. */
|
||||||
|
user: AuthUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sidebar navigation. Add entries here as new user sections are built — each id
|
||||||
|
// gets a matching branch in `renderSection` below.
|
||||||
|
const NAV_ITEMS: UserNavItem[] = [
|
||||||
|
{ id: 'console', label: 'Store Console', icon: LayoutDashboard },
|
||||||
|
{ id: 'account', label: 'My Account', icon: User },
|
||||||
|
];
|
||||||
|
|
||||||
|
type StoreShape = React.ComponentProps<typeof StoreDetailView>['store'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Landing workspace for a non-admin user. A user is allocated to exactly one
|
||||||
|
* store (their applocationid), so this resolves that store from the tenant's
|
||||||
|
* outlet list and renders the full store console scoped to it — no registry,
|
||||||
|
* no other stores. Layout mirrors the admin console (fixed Header + left rail).
|
||||||
|
*/
|
||||||
|
export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
const [activeSection, setActiveSection] = useState<string>('console');
|
||||||
|
|
||||||
|
const locationsQ = useFiestaTenantLocations(FIESTA_TENANT_ID);
|
||||||
|
const locSummaryQ = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
||||||
|
|
||||||
|
const locations = locationsQ.data ?? [];
|
||||||
|
const summaries = locSummaryQ.data ?? [];
|
||||||
|
|
||||||
|
// Resolve the user's store. Most tenants have exactly ONE store, so when the
|
||||||
|
// tenant has a single location we just use it — no id matching needed. Only if
|
||||||
|
// a tenant has multiple outlets do we disambiguate by the user's applocationid
|
||||||
|
// (accepting a row whose locationid equals it too), then by locationid.
|
||||||
|
const apploc = user.applocationid;
|
||||||
|
const matchedLoc =
|
||||||
|
locations.length === 1
|
||||||
|
? locations[0]
|
||||||
|
: (locations.find((l) => apploc != null && fnum(l.applocationid) === apploc) ??
|
||||||
|
locations.find((l) => apploc != null && fnum(l.locationid) === apploc) ??
|
||||||
|
locations.find((l) => user.locationid != null && fnum(l.locationid) === user.locationid) ??
|
||||||
|
null);
|
||||||
|
|
||||||
|
// Resolve the locationid the store console queries by. Prefer the matched
|
||||||
|
// outlet, then the user's own locationid, then the applocationid as a last
|
||||||
|
// resort so the console still scopes to a single store rather than all of them.
|
||||||
|
const resolvedLocationId =
|
||||||
|
(matchedLoc && fnum(matchedLoc.locationid)) || user.locationid || user.applocationid || 0;
|
||||||
|
|
||||||
|
const storeName =
|
||||||
|
(matchedLoc && fstr(matchedLoc.locationname)) ||
|
||||||
|
user.applocation ||
|
||||||
|
user.locationname ||
|
||||||
|
(resolvedLocationId ? `Store ${resolvedLocationId}` : 'Your Store');
|
||||||
|
|
||||||
|
const profile = {
|
||||||
|
name: user.name,
|
||||||
|
role: user.roleid ? roleName(user.roleid) : 'Team Member',
|
||||||
|
email: user.email,
|
||||||
|
};
|
||||||
|
|
||||||
|
const initials =
|
||||||
|
user.name
|
||||||
|
.split(' ')
|
||||||
|
.map((w) => w[0])
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.join('')
|
||||||
|
.toUpperCase() || 'NA';
|
||||||
|
|
||||||
|
const handleHelp = () =>
|
||||||
|
alert('nearledaily Store User Help — contact your store administrator for access or data questions.');
|
||||||
|
|
||||||
|
// Build the store shape StoreDetailView expects, mirroring the App registry
|
||||||
|
// mapping so the console renders identically to the admin's store view.
|
||||||
|
const buildStore = (): StoreShape => {
|
||||||
|
const sum = summaries.find((s) => s.locationid === resolvedLocationId);
|
||||||
|
const status = matchedLoc ? fstr(matchedLoc.status) || 'Active' : 'Active';
|
||||||
|
return {
|
||||||
|
locationid: resolvedLocationId,
|
||||||
|
name: storeName,
|
||||||
|
zone: matchedLoc
|
||||||
|
? [fstr(matchedLoc.suburb), fstr(matchedLoc.city)].filter(Boolean).join(', ') || 'Coimbatore'
|
||||||
|
: 'Coimbatore',
|
||||||
|
deliveries: sum?.delivered ?? 0,
|
||||||
|
sales: `${(sum?.total ?? 0).toLocaleString('en-IN')} orders`,
|
||||||
|
orders: Math.max(sum?.delivered ?? 0, sum?.total ?? 0),
|
||||||
|
staff: (matchedLoc && (fstr(matchedLoc.contactno) || fstr(matchedLoc.email))) || user.name || '—',
|
||||||
|
color: status.toLowerCase() === 'active' ? 'emerald' : 'amber',
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── My Account: read-only profile + store binding ──────────────────────────
|
||||||
|
const renderAccount = () => (
|
||||||
|
<div className="max-w-2xl animate-in fade-in duration-300">
|
||||||
|
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a] mb-1">My Account</h1>
|
||||||
|
<p className="text-zinc-500 font-sans text-xs mb-6">Your profile and the store you’re assigned to.</p>
|
||||||
|
|
||||||
|
<div className="bg-white border border-slate-200/70 rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
<div className="bg-gradient-to-br from-[#581c87] via-purple-800 to-purple-950 p-6 text-white flex items-center gap-4">
|
||||||
|
<span className="w-14 h-14 rounded-full bg-white/15 ring-2 ring-white/30 flex items-center justify-center text-lg font-bold tracking-wide">
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-bold text-lg truncate">{user.name}</p>
|
||||||
|
<span className="inline-flex items-center gap-1.5 mt-1 text-[10px] font-bold uppercase tracking-wider text-purple-100 bg-white/15 border border-white/20 px-2 py-0.5 rounded-full">
|
||||||
|
<ShieldCheck size={11} /> {profile.role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="p-6 divide-y divide-slate-100 text-sm">
|
||||||
|
<div className="flex items-center gap-3 py-3">
|
||||||
|
<Mail size={15} className="text-slate-400 shrink-0" />
|
||||||
|
<dt className="w-32 text-xs font-bold text-slate-400 uppercase tracking-wider">Email</dt>
|
||||||
|
<dd className="text-slate-700 font-medium break-all">{user.email || '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 py-3">
|
||||||
|
<Phone size={15} className="text-slate-400 shrink-0" />
|
||||||
|
<dt className="w-32 text-xs font-bold text-slate-400 uppercase tracking-wider">Phone</dt>
|
||||||
|
<dd className="text-slate-700 font-medium">{user.contactno || '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 py-3">
|
||||||
|
<Store size={15} className="text-slate-400 shrink-0" />
|
||||||
|
<dt className="w-32 text-xs font-bold text-slate-400 uppercase tracking-wider">App Location</dt>
|
||||||
|
<dd className="text-slate-700 font-medium">{user.applocation || storeName || '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 py-3">
|
||||||
|
<ShieldCheck size={15} className="text-slate-400 shrink-0" />
|
||||||
|
<dt className="w-32 text-xs font-bold text-slate-400 uppercase tracking-wider">App Location ID</dt>
|
||||||
|
<dd className="text-slate-700 font-medium font-mono">{user.applocationid ?? '—'}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render the active section. New sidebar items add a branch here; the default
|
||||||
|
// keeps anything not-yet-built from breaking the page.
|
||||||
|
const renderSection = () => {
|
||||||
|
if (activeSection === 'account') return renderAccount();
|
||||||
|
|
||||||
|
// The store console needs a resolved store, so gate it on the load state.
|
||||||
|
if (locationsQ.isLoading || locSummaryQ.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-24">
|
||||||
|
<div className="w-7 h-7 border-2 border-[#581c87] border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span className="text-xs font-semibold text-slate-500">Loading your store…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinguish a real load failure from "no store assigned" — otherwise an
|
||||||
|
// API outage would wrongly tell the user to contact their admin.
|
||||||
|
if (locationsQ.isError || locSummaryQ.isError) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<div className="bg-white border border-slate-200/70 rounded-3xl p-10 text-center max-w-md shadow-[0_10px_40px_rgba(0,0,0,0.08)]">
|
||||||
|
<div className="mx-auto h-16 w-16 rounded-2xl bg-rose-50 text-rose-600 ring-1 ring-rose-100 flex items-center justify-center mb-6">
|
||||||
|
<AlertTriangle size={30} />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 tracking-tight mb-3">Couldn’t load your store</h1>
|
||||||
|
<p className="text-[15px] text-slate-500 leading-relaxed mb-6">
|
||||||
|
We couldn’t reach the store service. Check your connection and try again.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
locationsQ.refetch();
|
||||||
|
locSummaryQ.refetch();
|
||||||
|
}}
|
||||||
|
className="px-5 py-2.5 bg-[#581c87] hover:bg-purple-800 text-white text-sm font-bold rounded-xl cursor-pointer transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedLocationId) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<div className="bg-white border border-slate-200/70 rounded-3xl p-10 text-center max-w-md shadow-[0_10px_40px_rgba(0,0,0,0.08)]">
|
||||||
|
<div className="mx-auto h-16 w-16 rounded-2xl bg-amber-50 text-amber-600 ring-1 ring-amber-100 flex items-center justify-center mb-6">
|
||||||
|
<AlertTriangle size={30} />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 tracking-tight mb-3">No store assigned</h1>
|
||||||
|
<p className="text-[15px] text-slate-500 leading-relaxed">
|
||||||
|
Your account isn’t linked to a store yet. Please contact your administrator to be
|
||||||
|
allocated to a store location.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// canManage=false hides write actions in the console. NOTE: this is a UI-only
|
||||||
|
// restriction — the backend must also enforce role-based authorization on the
|
||||||
|
// write endpoints, since a hidden button doesn't stop a direct API call.
|
||||||
|
return <StoreDetailView store={buildStore()} canManage={false} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#f8fafc] text-[#0f172a] font-sans antialiased">
|
||||||
|
<Header
|
||||||
|
isSidebarOpen={sidebarOpen}
|
||||||
|
onToggleSidebar={() => setSidebarOpen((s) => !s)}
|
||||||
|
onHelpClick={handleHelp}
|
||||||
|
onLogoutClick={onLogout}
|
||||||
|
profile={profile}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex pt-20">
|
||||||
|
<UserStoreSidebar
|
||||||
|
items={NAV_ITEMS}
|
||||||
|
activeId={activeSection}
|
||||||
|
onSelect={setActiveSection}
|
||||||
|
isOpen={sidebarOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main
|
||||||
|
className={`flex-1 min-w-0 min-h-[calc(100vh-80px)] transition-all duration-300 ${
|
||||||
|
sidebarOpen ? 'md:pl-64' : 'md:pl-20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
||||||
|
{renderSection()}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/UserStoreSidebar.tsx
Normal file
62
src/components/UserStoreSidebar.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { type LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
/** One entry in the user store sidebar. Add to `UserStorePage`'s `NAV_ITEMS`. */
|
||||||
|
export interface UserNavItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserStoreSidebarProps {
|
||||||
|
items: UserNavItem[];
|
||||||
|
activeId: string;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
/** Collapsed → icon-only rail; expanded → labels visible. */
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user store page's left rail. Visually identical to the admin `Sidebar`
|
||||||
|
* (fixed, sits under the fixed Header, collapses to an icon rail) but driven by
|
||||||
|
* a generic items list so new user sections can be added without touching this.
|
||||||
|
*/
|
||||||
|
export default function UserStoreSidebar({ items, activeId, onSelect, isOpen }: UserStoreSidebarProps) {
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={`fixed left-0 top-0 h-screen bg-[#581c87] border-r border-[#4c1d95] text-white flex-col py-xl pt-24 z-40 hidden md:flex transition-all duration-300 ${
|
||||||
|
isOpen ? 'w-64' : 'w-20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Main Navigation Sidebar Links */}
|
||||||
|
<nav className="flex-1 space-y-1 overflow-y-auto px-xs">
|
||||||
|
{items.map((item) => {
|
||||||
|
const IconComponent = item.icon;
|
||||||
|
const isActive = activeId === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onSelect(item.id)}
|
||||||
|
title={item.label}
|
||||||
|
className={`w-full flex items-center py-3 rounded-lg text-left transition-all duration-200 cursor-pointer ${
|
||||||
|
isOpen ? 'gap-md px-md' : 'justify-center px-0'
|
||||||
|
} ${
|
||||||
|
isActive
|
||||||
|
? 'bg-purple-800 text-white font-semibold' + (isOpen ? ' border-l-4 border-white' : '')
|
||||||
|
: 'text-purple-200 hover:bg-purple-800/60 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<IconComponent size={18} className={isActive ? 'text-white' : 'text-purple-300'} />
|
||||||
|
{isOpen && <span className="font-sans text-sm font-medium">{item.label}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,7 +28,8 @@ import {
|
|||||||
Coins
|
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 (
|
||||||
|
|||||||
433
src/data.ts
433
src/data.ts
@@ -1,433 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
KPICardData,
|
|
||||||
RevenueHeatmapDay,
|
|
||||||
LeaderboardNode,
|
|
||||||
ProductMatrixItem,
|
|
||||||
InventoryItem,
|
|
||||||
OrderItem,
|
|
||||||
ImportLog,
|
|
||||||
OperationalAlert,
|
|
||||||
RegionalPerformance,
|
|
||||||
CustomerOrder
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
// Dashboard Screen KPIs (Tamil Nadu Coimbatore Centric)
|
|
||||||
export const dashboardKPIs: KPICardData[] = [
|
|
||||||
{
|
|
||||||
title: 'TOTAL ORDERS',
|
|
||||||
value: '4,921',
|
|
||||||
subtext: 'Last 24 Hours',
|
|
||||||
sparkline: [50, 40, 60, 45, 80, 55, 65],
|
|
||||||
badge: '1.4k'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "TODAY'S REVENUE",
|
|
||||||
value: '₹4.2L',
|
|
||||||
subtext: 'Settled Funds (IST)',
|
|
||||||
trend: '+8.1%',
|
|
||||||
isPositive: true,
|
|
||||||
sparkline: [35, 55, 45, 70, 60, 90, 85]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'MONTHLY PROFIT',
|
|
||||||
value: '₹18.9L',
|
|
||||||
subtext: 'EBITDA Margin 22%',
|
|
||||||
trend: '-0.4%',
|
|
||||||
isPositive: false,
|
|
||||||
sparkline: [80, 75, 70, 65, 55, 60, 45]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'DELIVERED',
|
|
||||||
value: '4,102',
|
|
||||||
subtext: 'In-Full-On-Time (CBE)',
|
|
||||||
trend: '98.2%',
|
|
||||||
isPositive: true,
|
|
||||||
sparkline: [60, 70, 68, 85, 78, 92, 95]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Reports Screen KPIs (4 cards - Tamil Nadu Coimbatore Centric)
|
|
||||||
export const reportsKPIs: KPICardData[] = [
|
|
||||||
{
|
|
||||||
title: 'Gross Revenue',
|
|
||||||
value: '₹12,48,302.00',
|
|
||||||
subtext: 'Vs. Previous Quarter: ₹10.9L',
|
|
||||||
trend: '+14.2%',
|
|
||||||
isPositive: true,
|
|
||||||
sparkline: [40, 48, 52, 65, 72, 78, 90]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Net Margin %',
|
|
||||||
value: '28.4%',
|
|
||||||
subtext: 'Target threshold: 30.0%',
|
|
||||||
trend: '-0.8%',
|
|
||||||
isPositive: false,
|
|
||||||
sparkline: [75, 72, 68, 64, 60, 62, 58]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Active Inventory',
|
|
||||||
value: '48.2k',
|
|
||||||
subtext: 'Groceries units across TN hubs',
|
|
||||||
trend: '+5.1%',
|
|
||||||
isPositive: true,
|
|
||||||
sparkline: [55, 58, 61, 60, 64, 69, 73]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Staff Efficiency',
|
|
||||||
value: '94.1%',
|
|
||||||
subtext: 'Active utilization rate (CBE)',
|
|
||||||
trend: '+2.4%',
|
|
||||||
isPositive: true,
|
|
||||||
sparkline: [82, 85, 84, 88, 91, 93, 94]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Heatmap Data (Hourly Revenue Distribution)
|
|
||||||
export const heatmapData: RevenueHeatmapDay[] = [
|
|
||||||
{
|
|
||||||
day: 'Mon',
|
|
||||||
hours: {
|
|
||||||
'08:00': 1.2,
|
|
||||||
'10:00': 4.5,
|
|
||||||
'12:00': 8.9,
|
|
||||||
'14:00': 5.2,
|
|
||||||
'16:00': 4.8,
|
|
||||||
'18:00': 9.1,
|
|
||||||
'20:00': 3.2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
day: 'Tue',
|
|
||||||
hours: {
|
|
||||||
'08:00': 1.1,
|
|
||||||
'10:00': 3.8,
|
|
||||||
'12:00': 5.9,
|
|
||||||
'14:00': 7.2,
|
|
||||||
'16:00': 4.1,
|
|
||||||
'18:00': 8.4,
|
|
||||||
'20:00': 2.9
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
day: 'Wed',
|
|
||||||
hours: {
|
|
||||||
'08:00': 1.5,
|
|
||||||
'10:00': 4.2,
|
|
||||||
'12:00': 9.4,
|
|
||||||
'14:00': 10.1,
|
|
||||||
'16:00': 5.1,
|
|
||||||
'18:00': 4.2,
|
|
||||||
'20:00': 1.2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
day: 'Thu',
|
|
||||||
hours: {
|
|
||||||
'08:00': 0.9,
|
|
||||||
'10:00': 2.2,
|
|
||||||
'12:00': 4.8,
|
|
||||||
'14:00': 5.5,
|
|
||||||
'16:00': 8.2,
|
|
||||||
'18:00': 7.9,
|
|
||||||
'20:00': 3.4
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
day: 'Fri',
|
|
||||||
hours: {
|
|
||||||
'08:00': 2.2,
|
|
||||||
'10:00': 7.8,
|
|
||||||
'12:00': 11.4,
|
|
||||||
'14:00': 12.2,
|
|
||||||
'16:00': 9.8,
|
|
||||||
'18:00': 10.5,
|
|
||||||
'20:00': 5.2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Leaderboards (Top Performing State & Coimbatore Nodes)
|
|
||||||
export const globalLeaderboard: LeaderboardNode[] = [
|
|
||||||
{ rank: '01', name: 'Chennai - T-Nagar', percentage: 94, revenue: '₹2.4L' },
|
|
||||||
{ rank: '02', name: 'Coimbatore - RS Puram Main', percentage: 88, revenue: '₹2.18L' },
|
|
||||||
{ rank: '03', name: 'Madurai - KK Nagar', percentage: 82, revenue: '₹1.94L' },
|
|
||||||
{ rank: '04', name: 'Trichy - Thillai Nagar', percentage: 76, revenue: '₹1.81L' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export const regionalLeaderboard: LeaderboardNode[] = [
|
|
||||||
{ rank: '01', name: 'RS Puram - Main St', percentage: 94, revenue: '₹2.4L' },
|
|
||||||
{ rank: '02', name: 'Gandhipuram Cross Cut', percentage: 88, revenue: '₹2.1L' },
|
|
||||||
{ rank: '03', name: 'Peelamedu Avinashi Rd', percentage: 82, revenue: '₹1.9L' },
|
|
||||||
{ rank: '04', name: 'Town Hall Bazaar', percentage: 76, revenue: '₹1.8L' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Product Matrix (Detailed Groceries/Retail Items Matrix)
|
|
||||||
export const initialProducts: ProductMatrixItem[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
name: 'Ponni Raw Rice Premium 10kg',
|
|
||||||
sku: 'PONNI-RICE-10K',
|
|
||||||
unitsSold: 12402,
|
|
||||||
revenue: 868140,
|
|
||||||
stockStatus: 'Healthy',
|
|
||||||
trend: 'up',
|
|
||||||
image: 'https://images.unsplash.com/photo-1586201375761-83865001e31c?auto=format&fit=crop&w=150&q=80',
|
|
||||||
category: 'Staples / Rice',
|
|
||||||
exposure: '34/42 Stores',
|
|
||||||
verified: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
name: 'Idhayam Pure Sesame Oil 1L',
|
|
||||||
sku: 'IDH-SESAME-1L',
|
|
||||||
unitsSold: 8912,
|
|
||||||
revenue: 1480000,
|
|
||||||
stockStatus: 'Low Stock',
|
|
||||||
trend: 'flat',
|
|
||||||
image: 'https://images.unsplash.com/photo-1474979266404-7eaacbcd87c5?auto=format&fit=crop&w=150&q=80',
|
|
||||||
category: 'Groceries / Oils',
|
|
||||||
exposure: '40/42 Stores',
|
|
||||||
verified: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
name: 'Narasus Filter Coffee Powder 500g',
|
|
||||||
sku: 'NARASUS-COF-500',
|
|
||||||
unitsSold: 15221,
|
|
||||||
revenue: 456630,
|
|
||||||
stockStatus: 'Healthy',
|
|
||||||
trend: 'up',
|
|
||||||
image: 'https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?auto=format&fit=crop&w=150&q=80',
|
|
||||||
category: 'Beverages / Coffee',
|
|
||||||
exposure: '42/42 Stores',
|
|
||||||
verified: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
name: 'Ooty Fresh Quality Carrots 1kg',
|
|
||||||
sku: 'OOTY-CARROT-1KG',
|
|
||||||
unitsSold: 4120,
|
|
||||||
revenue: 123600,
|
|
||||||
stockStatus: 'Critical',
|
|
||||||
trend: 'down',
|
|
||||||
image: 'https://images.unsplash.com/photo-1598170845058-32b9d6a5da37?auto=format&fit=crop&w=150&q=80',
|
|
||||||
category: 'Fresh Produce / Veg',
|
|
||||||
exposure: '32/42 Stores',
|
|
||||||
verified: false
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Inventory lists (Coimbatore Warehouse Region Centric)
|
|
||||||
export const initialInventory: InventoryItem[] = [
|
|
||||||
{
|
|
||||||
sku: 'RICE-PN-50',
|
|
||||||
name: 'Premium Ponni Rice Bag 25kg',
|
|
||||||
warehouse: 'RS Puram Hub (CBE-01)',
|
|
||||||
stockLevel: 1402,
|
|
||||||
maxCapacity: 2000,
|
|
||||||
status: 'Optimal',
|
|
||||||
region: 'CBE-NORTH'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sku: 'ATTA-ASH-10',
|
|
||||||
name: 'Aashirvaad Chakki Atta 10kg',
|
|
||||||
warehouse: 'Gandhipuram Warehouse (CBE-04)',
|
|
||||||
stockLevel: 12,
|
|
||||||
maxCapacity: 100,
|
|
||||||
status: 'Critical',
|
|
||||||
region: 'CBE-SOUTH'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sku: 'OIL-IDH-05',
|
|
||||||
name: 'Idhayam Sesame Oil Can 5L',
|
|
||||||
warehouse: 'Peelamedu Sort Facility (CBE-09)',
|
|
||||||
stockLevel: 45,
|
|
||||||
maxCapacity: 120,
|
|
||||||
status: 'Low Stock',
|
|
||||||
region: 'CBE-EAST'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sku: 'COF-NAR-01',
|
|
||||||
name: 'Narasus Filter Coffee 1kg Pack',
|
|
||||||
warehouse: 'Pollachi Road Depot (CBE-15)',
|
|
||||||
stockLevel: 890,
|
|
||||||
maxCapacity: 1000,
|
|
||||||
status: 'Optimal',
|
|
||||||
region: 'CBE-WEST'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sku: 'MILK-AAV-50',
|
|
||||||
name: 'Aavin Premium Pouch Milk 500ml',
|
|
||||||
warehouse: 'Tiruppur Junction Depot (TPR-02)',
|
|
||||||
stockLevel: 18,
|
|
||||||
maxCapacity: 150,
|
|
||||||
status: 'Low Stock',
|
|
||||||
region: 'TIRUPPUR'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Order Items list
|
|
||||||
export const initialOrders: OrderItem[] = [
|
|
||||||
{
|
|
||||||
id: '#ORD-902341',
|
|
||||||
store: 'RS Puram Terminal',
|
|
||||||
amount: 12490.00,
|
|
||||||
time: '14:22 PM (IST)',
|
|
||||||
status: 'SHIPPED'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '#ORD-902339',
|
|
||||||
store: 'Gandhipuram Outlet 2',
|
|
||||||
amount: 840.50,
|
|
||||||
time: '13:05 PM (IST)',
|
|
||||||
status: 'FLAGGED'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '#ORD-902312',
|
|
||||||
store: 'Peelamedu Center',
|
|
||||||
amount: 24200.00,
|
|
||||||
time: '11:15 AM (IST)',
|
|
||||||
status: 'SHIPPED'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '#ORD-901995',
|
|
||||||
store: 'Saravanampatti Hub',
|
|
||||||
amount: 5120.00,
|
|
||||||
time: '09:40 AM (IST)',
|
|
||||||
status: 'PROCESSING'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Import log items list
|
|
||||||
export const initialImportLogs: ImportLog[] = [
|
|
||||||
{
|
|
||||||
timestamp: 'May 31, 2026 09:12',
|
|
||||||
batchRef: '#IMP_0921_A',
|
|
||||||
type: 'Inventory Sync',
|
|
||||||
source: 'ERP Export',
|
|
||||||
result: 'SUCCESS (421 Rows)',
|
|
||||||
status: 'SUCCESS'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
timestamp: 'May 30, 2026 17:45',
|
|
||||||
batchRef: '#IMP_0920_Z',
|
|
||||||
type: 'Catalogue Meta',
|
|
||||||
source: 'Marketing API',
|
|
||||||
result: 'FAILED (Invalid API Key)',
|
|
||||||
status: 'FAILED'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
timestamp: 'May 28, 2026 10:30',
|
|
||||||
batchRef: '#IMP_0899_C',
|
|
||||||
type: 'Logistics Matrix',
|
|
||||||
source: 'Manual Upload',
|
|
||||||
result: 'SUCCESS (1,500 Rows)',
|
|
||||||
status: 'SUCCESS'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Operational Alerts (Tamil Nadu Coimbatore Centric)
|
|
||||||
export const operationalAlerts: OperationalAlert[] = [
|
|
||||||
{
|
|
||||||
id: 'alert-1',
|
|
||||||
type: 'critical',
|
|
||||||
title: 'Stock Critical: RS Puram Store',
|
|
||||||
details: 'Fresh dairy and milk category inventory fell below 5% at 08:24 AM.',
|
|
||||||
time: '08:24 AM'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'alert-2',
|
|
||||||
type: 'warning',
|
|
||||||
title: 'High Latency Warning',
|
|
||||||
details: 'Coimbatore Hub Server API latency spiked to 640ms in grocery billing services.',
|
|
||||||
time: '07:15 AM'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'alert-3',
|
|
||||||
type: 'info',
|
|
||||||
title: 'Node Rebalance Completed',
|
|
||||||
details: 'Peelamedu sort facility rebalanced 4,500 units of Ponni Rice to Gandhipuram nodes.',
|
|
||||||
time: '05:30 AM'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Regional Performance (Coimbatore Zones)
|
|
||||||
export const regionalPerformances: RegionalPerformance[] = [
|
|
||||||
{ region: 'Coimbatore North', sales: '₹42.4L', revenueValue: 42.4, maxTarget: 50 },
|
|
||||||
{ region: 'Coimbatore South', sales: '₹38.2L', revenueValue: 38.2, maxTarget: 50 },
|
|
||||||
{ region: 'Coimbatore East & Peelamedu', sales: '₹29.1L', revenueValue: 29.1, maxTarget: 50 },
|
|
||||||
{ region: 'Tiruppur Regional Hub', sales: '₹14.5L', revenueValue: 14.5, maxTarget: 50 }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Initial Customer Orders Dataset
|
|
||||||
export const initialCustomerOrders: CustomerOrder[] = [
|
|
||||||
{
|
|
||||||
id: 'DM-ORD-2091',
|
|
||||||
customerName: 'Meenakshi Sundaram',
|
|
||||||
phone: '+91 94432 18942',
|
|
||||||
address: 'Plot 4, Lakshmipuram Ext, RS Puram, Coimbatore - 641002',
|
|
||||||
items: [
|
|
||||||
{ name: 'Ponni Raw Rice Premium 10kg', quantity: 1, price: 680 },
|
|
||||||
{ name: 'Idhayam Pure Sesame Oil 1L', quantity: 2, price: 340 },
|
|
||||||
{ name: 'Narasus Filter Coffee Powder 500g', quantity: 1, price: 195 }
|
|
||||||
],
|
|
||||||
amount: 1555,
|
|
||||||
time: '14:24 PM',
|
|
||||||
status: 'OUT_FOR_DELIVERY',
|
|
||||||
assignedRider: 'Karthikeyan Radhakrishnan',
|
|
||||||
hub: 'RS Puram Hub'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'DM-ORD-2092',
|
|
||||||
customerName: 'Senthil Kumar VSD',
|
|
||||||
phone: '+91 98421 00234',
|
|
||||||
address: 'Flat 2C, Whispering Palms, Avinashi Road, Peelamedu - 641004',
|
|
||||||
items: [
|
|
||||||
{ name: 'Premium Ponni Rice Bag 25kg', quantity: 1, price: 1600 },
|
|
||||||
{ name: 'Aavin Premium Milk 500ml', quantity: 4, price: 28 },
|
|
||||||
{ name: 'Ooty Fresh Quality Carrots 1kg', quantity: 2, price: 60 }
|
|
||||||
],
|
|
||||||
amount: 1832,
|
|
||||||
time: '14:10 PM',
|
|
||||||
status: 'DELIVERED',
|
|
||||||
assignedRider: 'Arun Kumar Chinnasamy',
|
|
||||||
hub: 'Peelamedu Sort Center'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'DM-ORD-2093',
|
|
||||||
customerName: 'Kavitha Ramaswamy',
|
|
||||||
phone: '+91 90035 88921',
|
|
||||||
address: 'No 15, Cross Cut Road, Gandhipuram, Coimbatore - 641012',
|
|
||||||
items: [
|
|
||||||
{ name: 'Aashirvaad Chakki Atta 10kg', quantity: 2, price: 440 },
|
|
||||||
{ name: 'Organic Turmeric Powder 200g', quantity: 1, price: 90 },
|
|
||||||
{ name: 'Pure Cow Ghee 500ml', quantity: 1, price: 320 }
|
|
||||||
],
|
|
||||||
amount: 1290,
|
|
||||||
time: '13:50 PM',
|
|
||||||
status: 'CONFIRMED',
|
|
||||||
assignedRider: 'Suresh Balasubramaniam',
|
|
||||||
hub: 'Gandhipuram Cross Rd'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'DM-ORD-2094',
|
|
||||||
customerName: 'Dr. Anand Selvapandian',
|
|
||||||
phone: '+91 97890 22104',
|
|
||||||
address: 'Villa 12, Sobha Elanza, Sathy Road, Saravanampatti - 641035',
|
|
||||||
items: [
|
|
||||||
{ name: 'Narasus Filter Coffee Powder 500g', quantity: 2, price: 195 },
|
|
||||||
{ name: 'Idhayam Pure Sesame Oil 1L', quantity: 1, price: 340 }
|
|
||||||
],
|
|
||||||
amount: 730,
|
|
||||||
time: '13:15 PM',
|
|
||||||
status: 'PROCESSING',
|
|
||||||
assignedRider: 'Pending Assignment',
|
|
||||||
hub: 'Saravanampatti Store CBE'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
@@ -12,4 +12,73 @@
|
|||||||
--spacing-xl: 32px;
|
--spacing-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
233
src/services/auth.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login / authentication client.
|
||||||
|
*
|
||||||
|
* The backend login endpoint is the ONE thing that still needs confirming — see
|
||||||
|
* the CONFIG block below. Everything else (request shape, response parsing,
|
||||||
|
* role mapping, error handling) is wired and ready. Once the four CONFIG values
|
||||||
|
* match the real API, the login form verifies credentials and gates entry.
|
||||||
|
*
|
||||||
|
* Requests are routed through the existing Vite dev proxy so no secret/CORS
|
||||||
|
* concern reaches the browser (see vite.config.ts: `/fiesta/*` → fiesta.nearle.app,
|
||||||
|
* `/hasura/*` → api.workolik.com). If the login route lives on a different host,
|
||||||
|
* add a proxy entry there and point LOGIN_ENDPOINT at it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { firstRow, num, str, type Row } from './fiestaApi';
|
||||||
|
|
||||||
|
// ── Backend login config ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fiesta application login. Routed through the Vite `/fiesta` proxy →
|
||||||
|
* https://fiesta.nearle.app/live/api/v1/web/users/applogin.
|
||||||
|
* Observed shape:
|
||||||
|
* request: { authname: <email>, password: <password>, configid: 1, userfcmtoken: null }
|
||||||
|
* failure: { code: 409, message: "Invalid Email", status: false }
|
||||||
|
* success: status !== false (Fiesta envelope, optionally with `details`)
|
||||||
|
*/
|
||||||
|
const LOGIN_ENDPOINT = '/fiesta/live/api/v1/web/users/applogin';
|
||||||
|
|
||||||
|
/** Request body field names the endpoint expects for the credentials. */
|
||||||
|
const REQUEST_FIELDS = {
|
||||||
|
email: 'authname',
|
||||||
|
password: 'password',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Extra fields the applogin endpoint expects alongside the credentials. */
|
||||||
|
const EXTRA_FIELDS = {
|
||||||
|
configid: 1,
|
||||||
|
userfcmtoken: null,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field names read from the user record (login response `details`, else the
|
||||||
|
* tenant user list). Verified against the live `users/getallusers` response.
|
||||||
|
*/
|
||||||
|
const RESPONSE_FIELDS = {
|
||||||
|
roleid: 'roleid',
|
||||||
|
firstname: 'firstname',
|
||||||
|
fullname: 'fullname',
|
||||||
|
email: 'email',
|
||||||
|
contactno: 'contactno',
|
||||||
|
userid: 'userid',
|
||||||
|
// Store binding: a non-admin user is allocated to an app-location via
|
||||||
|
// applocationid; `applocation` is its human-readable name (e.g. "Coimbatore").
|
||||||
|
// locationid/locationname are captured when present (often 0/absent on the
|
||||||
|
// user record — the outlet is resolved from the tenant locations list).
|
||||||
|
applocationid: 'applocationid',
|
||||||
|
applocation: 'applocation',
|
||||||
|
locationid: 'locationid',
|
||||||
|
locationname: 'locationname',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* roleids that land on the ADMIN dashboard; everyone else lands on the user page.
|
||||||
|
* From fiestaApi.roleName(): 1 = Owner, 2 = Manager, 3 = Admin, 4 = Staff,
|
||||||
|
* 5 = Rider, 6 = Cashier.
|
||||||
|
*/
|
||||||
|
const ADMIN_ROLE_IDS = new Set<number>([1, 3]);
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type LoginRole = 'admin' | 'user';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
role: LoginRole;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
userid?: number;
|
||||||
|
roleid?: number;
|
||||||
|
/** Phone number on the user record. */
|
||||||
|
contactno?: string;
|
||||||
|
/** The app-location this user is allocated to. */
|
||||||
|
applocationid?: number;
|
||||||
|
/** App-location / zone name on the user record (e.g. "Coimbatore"). */
|
||||||
|
applocation?: string;
|
||||||
|
/** Outlet/location id, when the record carries it directly (often 0). */
|
||||||
|
locationid?: number;
|
||||||
|
/** Outlet display name, when the record carries it directly. */
|
||||||
|
locationname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a numeric roleid to the workspace the user is allowed into. */
|
||||||
|
export function roleFromRoleId(roleid: number): LoginRole {
|
||||||
|
return ADMIN_ROLE_IDS.has(roleid) ? 'admin' : 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a friendly display name from the response, falling back to the email. */
|
||||||
|
function displayName(row: Record<string, unknown>, email: string): string {
|
||||||
|
const full = str(row[RESPONSE_FIELDS.fullname]).trim();
|
||||||
|
if (full) return full;
|
||||||
|
const first = str(row[RESPONSE_FIELDS.firstname]).trim();
|
||||||
|
if (first) return first;
|
||||||
|
// Fallback: derive from the email's local part.
|
||||||
|
return (
|
||||||
|
email
|
||||||
|
.split('@')[0]
|
||||||
|
.split(/[._-]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(' ') || 'Account'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The verified-login response: the user record (if any) + the resolved email. */
|
||||||
|
export interface LoginResult {
|
||||||
|
/** User record from the login response `details`, or null if it carried none. */
|
||||||
|
row: Row | null;
|
||||||
|
/** Whether `row` already includes the role (so no tenant-list lookup is needed). */
|
||||||
|
hasRole: boolean;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST the credentials to the Fiesta web-login endpoint. Resolves with the raw
|
||||||
|
* user record on success; throws an Error with a user-facing message on invalid
|
||||||
|
* credentials or any failure. Role resolution is left to the caller so the
|
||||||
|
* (optional) tenant-user lookup can go through React Query — see `useLogin`.
|
||||||
|
*/
|
||||||
|
export async function loginRequest(email: string, password: string): Promise<LoginResult> {
|
||||||
|
const trimmedEmail = email.trim();
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(LOGIN_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
[REQUEST_FIELDS.email]: trimmedEmail,
|
||||||
|
[REQUEST_FIELDS.password]: password,
|
||||||
|
...EXTRA_FIELDS,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new Error('Unable to reach the login service. Check your connection and try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON body (may be absent on some error responses).
|
||||||
|
const json = (await res.json().catch(() => null)) as
|
||||||
|
| { code?: number; status?: boolean; message?: string; details?: unknown }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
// Failure: HTTP error, or the Fiesta `status: false` envelope (e.g. wrong
|
||||||
|
// email/password → { code: 409, message: "Invalid Email", status: false }).
|
||||||
|
if (!res.ok || (json && json.status === false)) {
|
||||||
|
throw new Error(json?.message?.trim() || 'Incorrect email or password.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = firstRow<Row>(json);
|
||||||
|
const resolvedEmail = (row && str(row[RESPONSE_FIELDS.email]).trim()) || trimmedEmail;
|
||||||
|
return { row, hasRole: Boolean(row && row[RESPONSE_FIELDS.roleid] != null), email: resolvedEmail };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the email/authname exists and is registered by sending email and configid: 1.
|
||||||
|
* Returns true if the email exists, false if it is invalid.
|
||||||
|
*/
|
||||||
|
export async function checkEmailRequest(email: string): Promise<boolean> {
|
||||||
|
const trimmedEmail = email.trim();
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(LOGIN_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
[REQUEST_FIELDS.email]: trimmedEmail,
|
||||||
|
...EXTRA_FIELDS,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new Error('Unable to reach the login service. Check your connection and try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (await res.json().catch(() => null)) as
|
||||||
|
| { code?: number; status?: boolean; message?: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
// A 409 Invalid Email code means the email does not exist.
|
||||||
|
if (json && json.status === false && (json.code === 409 || json.message?.trim().toLowerCase() === 'invalid email')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any other result (such as 403 Unauthorized email) means the email is registered.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Find a user in the tenant user list by email or authname (case-insensitive). */
|
||||||
|
export function matchTenantUser(users: Row[], email: string): Row | null {
|
||||||
|
const target = email.toLowerCase();
|
||||||
|
return (
|
||||||
|
users.find(
|
||||||
|
(u) =>
|
||||||
|
str(u[RESPONSE_FIELDS.email]).toLowerCase() === target ||
|
||||||
|
str(u.authname).toLowerCase() === target,
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assemble the final AuthUser (role + identity) from a resolved user record. */
|
||||||
|
export function buildAuthUser(row: Row | null, email: string): AuthUser {
|
||||||
|
const roleid = row ? num(row[RESPONSE_FIELDS.roleid]) : 0;
|
||||||
|
const applocation = row ? str(row[RESPONSE_FIELDS.applocation]).trim() : '';
|
||||||
|
const locationname = row ? str(row[RESPONSE_FIELDS.locationname]).trim() : '';
|
||||||
|
const contactno = row ? str(row[RESPONSE_FIELDS.contactno]).trim() : '';
|
||||||
|
return {
|
||||||
|
role: roleFromRoleId(roleid),
|
||||||
|
name: displayName(row ?? {}, email),
|
||||||
|
email,
|
||||||
|
userid: row && row[RESPONSE_FIELDS.userid] != null ? num(row[RESPONSE_FIELDS.userid]) : undefined,
|
||||||
|
roleid,
|
||||||
|
contactno: contactno || undefined,
|
||||||
|
applocationid:
|
||||||
|
row && row[RESPONSE_FIELDS.applocationid] != null ? num(row[RESPONSE_FIELDS.applocationid]) : undefined,
|
||||||
|
applocation: applocation || undefined,
|
||||||
|
locationid: row && row[RESPONSE_FIELDS.locationid] != null ? num(row[RESPONSE_FIELDS.locationid]) : undefined,
|
||||||
|
locationname: locationname || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -183,6 +183,28 @@ export async function getOrders(opts: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** /orders/getorderdetails?orderheaderid= — line items for a single order. */
|
||||||
|
export async function getOrderDetails(orderheaderid: number | string): Promise<Row[]> {
|
||||||
|
return toRows(await fiestaGet('orders/getorderdetails', { orderheaderid }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /orders/getorders?customerid=&status=&pageno=&pagesize= — one customer's order history. */
|
||||||
|
export async function getCustomerOrders(opts: {
|
||||||
|
customerid: number | string;
|
||||||
|
status?: string;
|
||||||
|
pageno?: number;
|
||||||
|
pagesize?: number;
|
||||||
|
}): Promise<Row[]> {
|
||||||
|
return toRows(
|
||||||
|
await fiestaGet('orders/getorders', {
|
||||||
|
customerid: opts.customerid,
|
||||||
|
status: opts.status ?? '',
|
||||||
|
pageno: opts.pageno ?? 1,
|
||||||
|
pagesize: opts.pagesize ?? 20,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
// DELIVERIES
|
// 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
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user