update on the user page regardinga the dispatch and order page and the deliveries page
This commit is contained in:
469
docs/FIESTA_BACKEND_API.md
Normal file
469
docs/FIESTA_BACKEND_API.md
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
# Fiesta Backend API Reference
|
||||||
|
|
||||||
|
Go + Fiber backend (`fiesta-backend/backend_fiesta`). Server listens on `:1122`.
|
||||||
|
|
||||||
|
- **Base prefix:** `/live/api`
|
||||||
|
- **Web group:** `/live/api/v1/web/*` — used by the merchant web app (via the `/fiesta` proxy → `https://fiesta.nearle.app`).
|
||||||
|
- **Mobile group:** `/live/api/v1/mob/*` — used by the mobile app.
|
||||||
|
|
||||||
|
Paths below omit the `/live/api` prefix. Query-param defaults shown in `( )`. Request-body structs are defined in [§ Models](#models) at the end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# GET
|
||||||
|
|
||||||
|
### Users
|
||||||
|
| Path | Query params | Description |
|
||||||
|
| --------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------- |
|
||||||
|
| `/v1/web/users/getallusers` | `roleid`:int (0), `tenantid`:int (0), `pageno`:int (1), `pagesize`:int (10), `keyword`:string | List users, filterable by role/tenant |
|
||||||
|
| `/v1/web/users/getusers` · `/v1/mob/users/getusers` | `userid`:int | Single user profile |
|
||||||
|
|
||||||
|
### Orders
|
||||||
|
| Path | Query params | Description |
|
||||||
|
| ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
|
||||||
|
| `/v1/web/orders/getorders` (+ `tenant/`,`partner/`,`customer/`,`user/`,`admin/getorders`) · `/v1/mob/orders/tenant/getorders` | `tenantid`:int (0), `partnerid`:int (0), `customerid`:int (0), `moduleid`:int (0), `applocationid`:int (0), `appuserid`:int (0), `locationid`:int (0), `configid`:int (0), `status`:string, `fromdate`:string, `todate`:string, `keyword`:string, `pageno`:int (1), `pagesize`:int (10) | Orders board (role-scoped by path) |
|
||||||
|
| `/v1/web/orders/getordersummary` | `tenantid`:int (0), `partnerid`:int (0), `customerid`:int (0), `locationid`:int (0), `fromdate`:string, `todate`:string | Order counts by status |
|
||||||
|
| `/v1/web/orders/getlocationsummary` | `tenantid`:int | Per-location order rollup |
|
||||||
|
| `/v1/web/orders/getorderinsight` | `tenantid`:int | Monthly order insight per location |
|
||||||
|
| `/v1/web/orders/getorderdetails` · `/v1/mob/orders/getorderdetails` | `orderheaderid`:int | Order line items + price breakdown |
|
||||||
|
| `/v1/mob/orders/getcustomerorders` | `customerid`:string, `tenantid`:string, `moduleid`:string, `fromdate`:string, `todate`:string, `orderstatus`:string, `keyword`:string, `pageno`:int (1), `pagesize`:int (10) | A customer's order history |
|
||||||
|
|
||||||
|
### Deliveries
|
||||||
|
| Path | Query params | Description |
|
||||||
|
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------- |
|
||||||
|
| `/v1/web/deliveries/getdeliveries` · `/v1/mob/deliveries/getdeliveries` | `partnerid`:int (0), `tenantid`:int (0), `userid`:int (0), `customerid`:int (0), `applocationid`:int (0), `appuserid`:int (0), `locationid`:int (0), `pageno`:int (1), `pagesize`:int, `fromdate`:string, `todate`:string, `status`:string, `keyword`:string | Deliveries board |
|
||||||
|
| `/v1/web/deliveries/deliverysummary` · `/v1/mob/deliveries/deliverysummary` | `tenantid`:int (0), `partnerid`:int (0), `userid`:int (0), `applocationid`:int (0), `locationid`:int (0), `fromdate`:string, `todate`:string | Delivery counts by status |
|
||||||
|
| `/v1/web/deliveries/getdeliveryinsight` | `tenantid`:int | Delivery insight per location |
|
||||||
|
| `/v1/web/deliveries/getlocationsummary` | `tenantid`:int | Per-location delivery rollup |
|
||||||
|
| `/v1/web/deliveries/getreportsummary` | `tenantid`:int (0), `partnerid`:int (0), `userid`:int (0), `applocationid`:int (0), `fromdate`:string, `todate`:string | Delivery report summary |
|
||||||
|
| `/v1/web/deliveries/getridersummary` | `applocationid`:int (0), `partnerid`:int (0), `tenantid`:int (0), `fromdate`:string, `todate`:string | Per-rider performance summary |
|
||||||
|
| `/v1/mob/deliveries/getdeliveryqueues` | `userid`:int, `fromdate`:string, `todate`:string | Rider's delivery queue |
|
||||||
|
|
||||||
|
### Products / Catalog
|
||||||
|
| Path | Query params | Description |
|
||||||
|
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||||
|
| `/v1/web/products/getproductcategories` | none | All product categories |
|
||||||
|
| `/v1/web/products/getproductsubcategories` · `/v1/mob/...` | `categoryid`:int (0), `tenantid`:int (0) | Subcategories under a category |
|
||||||
|
| `/v1/web/products/getproductscount` | `tenantid`:int, `categoryid`:int, `subcategoryid`:int, `approve`:string | Total / available / out-of-stock counts |
|
||||||
|
| `/v1/web/products/getproductvariants` | `tenantid`:int, `subcategoryid`:int | Product variants |
|
||||||
|
| `/v1/web/products/getcatalougeproducts` | `tenantid`:int, `locationid`:int, `subcategoryid`:int, `keyword`:string, `pageno`:int, `pagesize`:int | **Catalogue products** (curated assortment) |
|
||||||
|
| `/v1/web/products/getproductstocks` | `tenantid`:string, `locationid`:string | Live stock levels |
|
||||||
|
| `/v1/web/products/getstockstatement` | `tenantid`:int, `locationid`:int, `subcategoryid`:int, `pageno`:int, `pagesize`:int, `keyword`:string | Stock ledger / statement |
|
||||||
|
| `/v1/web/products/getlocationproducts` · `/v1/mob/...` | `tenantid`:int, `locationid`:int, `subcategoryid`:int, `keyword`:string, `pageno`:int, `pagesize`:int | Products assigned to a location |
|
||||||
|
| `/v1/web/products/getlocationproductsummary` | `tenantid`:int, `locationid`:int | Location product summary (count by subcategory) |
|
||||||
|
| `/v1/web/products/getallproducts` · `/v1/mob/...` | `categoryid`:int, `subcategoryid`:int, `productid`:int, `applocationid`:int, `tenantid`:int, `locationid`:int, `keyword`:string, `productstatus`:string, `approve`:string, `pageno`:int, `pagesize`:int | All products, advanced filtering |
|
||||||
|
| `/v1/mob/products/getproductbyvariant` | `tenantid`:int, `variantid`:int | Products by variant |
|
||||||
|
| `/v1/mob/products/getproductsbysubcategory` | `categoryid`:int, `tenantid`:int, `applocationid`:int, `productid`:int, `keyword`:string, `locationid`:int | Products by subcategory (mobile) |
|
||||||
|
|
||||||
|
### Partners / Riders
|
||||||
|
| Path | Query params | Description |
|
||||||
|
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
|
||||||
|
| `/v1/web/partners/getriders` · `/v1/mob/...` | `partnerid`:int (0), `applocationid`:int (0), `userid`:int (0), `tenantid`:int (0) | Active riders |
|
||||||
|
| `/v1/web/partners/getpartners` · `/v1/mob/...` | `partnerid`:int (0), `applocationid`:int (0), `userid`:int (0) | Partners |
|
||||||
|
| `/v1/web/partners/getridershifts` | `applocationid`:int (0) | Rider shifts |
|
||||||
|
| `/v1/web/partners/getlocations` | `userid`:int (0), `configid`:int (0) | Location config for user |
|
||||||
|
| `/v1/web/partners/getriderlogs` · `/v1/mob/...` | `partnerid`:int (0), `applocationid`:int (0), `fromdate`:string, `todate`:string *(⚠ controller reads `todate` from `fromdate` — bug)* | Rider activity logs |
|
||||||
|
| `/v1/mob/partners/getriderinfo` | `userid`:int (0) | Rider profile + latest log |
|
||||||
|
|
||||||
|
### Tenants
|
||||||
|
| Path | Query params | Description |
|
||||||
|
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------- |
|
||||||
|
| `/v1/web/tenants/search` | `status`:string, `keyword`:string | Search tenants |
|
||||||
|
| `/v1/web/tenants/searchbykeyword` · `/v1/mob/...` | `keyword`:string | Search by keyword |
|
||||||
|
| `/v1/web/tenants/getalltenants` | `pageno`:int, `pagesize`:int, `status`:string, `applocationid`:int, `tenanttype`:string, `keyword`:string | All tenants |
|
||||||
|
| `/v1/web/tenants/gettenantlocations` · `/v1/mob/...` | `tenantid`:int | Tenant's outlet locations |
|
||||||
|
| `/v1/mob/tenants/gettenantslot` | none | Tenant slots |
|
||||||
|
| `/v1/mob/tenants/getcustomertenants` | `customerid`:int, `categoryid`:int, `tenant`:int (0=all,1=with orders) | Tenants for a customer |
|
||||||
|
| `/v1/mob/tenants/gettenantpricing` | `tenantid`:int, `applocationid`:int | Tenant pricing |
|
||||||
|
| `/v1/mob/tenants/getstaffs` | `tenantid`:int | Tenant staff |
|
||||||
|
| `/v1/mob/tenants/gettenantinfo` | `tenantid`:int, `locationid`:int | Tenant detail |
|
||||||
|
|
||||||
|
### Customers
|
||||||
|
| Path | Query params | Description |
|
||||||
|
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | --------------------------- |
|
||||||
|
| `/v1/web/customers/gettenantcustomers` · `/v1/mob/...` | `tenantid`:int (0), `locationid`:int (0), `pageno`:int (0), `pagesize`:int (0), `keyword`:string | Tenant customers, paginated |
|
||||||
|
| `/v1/mob/customers/getbyid` | `customerid`:int (0), `contactno`:string | Customer by id/contact |
|
||||||
|
| `/v1/mob/customers/getcustomerlocation` | `customerid`:int | Customer addresses |
|
||||||
|
| `/v1/mob/customers/getcustomerrequests` | `customerid`:string, `pageno`:string (1), `pagesize`:string (10) | Customer service requests |
|
||||||
|
| `/v1/mob/customers/search` | `keyword`:string, `tenantid`:int (0) | Search customers |
|
||||||
|
|
||||||
|
### Utils
|
||||||
|
| Path | Query params | Description |
|
||||||
|
| ------------------------------------------------ | ---------------------------------------- | ----------------------------------- |
|
||||||
|
| `/v1/web/utils/getapptypes` · `/v1/mob/...` | `tag`:string | App types by tag (e.g. paymentmode) |
|
||||||
|
| `/v1/web/utils/getsubcategories` · `/v1/mob/...` | `moduleid`:int (0), `categoryid`:int (0) | Subcategories |
|
||||||
|
| `/v1/web/utils/getapplocations` · `/v1/mob/...` | `applocationid`:int | App location detail |
|
||||||
|
| `/v1/web/utils/getappcategories` · `/v1/mob/...` | none | App categories |
|
||||||
|
| `/v1/mob/utils/getapplocationconfig` | `applocationid`:int | App location config |
|
||||||
|
| `/v1/mob/utils/getappconfig` | `configid`:int (0) | App config |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# POST
|
||||||
|
|
||||||
|
| Path | Request body | Description |
|
||||||
|
| ------------------------------------------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------- |
|
||||||
|
| `/v1/web/users/applogin` | [`User`](#user) (`authname`,`password`,`configid`,`userfcmtoken`…) | App/web login |
|
||||||
|
| `/v1/web/users/create` · `/v1/mob/...` | [`User`](#user) | Create user |
|
||||||
|
| `/v1/web/users/tenant/weblogin` | [`User`](#user) | Tenant web login |
|
||||||
|
| `/v1/mob/users/tenant/login` | [`User`](#user) | Tenant mobile login |
|
||||||
|
| `/v1/web/orders/createorder` · `/v1/mob/...` | [`Orders`](#orders) (accepts `{...}` or `{"orders":{...}}`; `tenantid` required) | Create order (+ `items[]`) |
|
||||||
|
| `/v1/web/deliveries/createdeliveries` · `/v1/mob/...` | [`[]Deliveries`](#deliveries) (array) | Batch-create deliveries |
|
||||||
|
| `/v1/web/products/create` | [`Products`](#products) | Create product |
|
||||||
|
| `/v1/web/products/createproductstock` | [`[]Productstock`](#productstock) (array) | **Stock entry** (batch) |
|
||||||
|
| `/v1/web/products/createproductlocation` | [`[]Productlocations`](#productlocations) (array) | **Assign product(s) to a store location** (price/min/max/qty) |
|
||||||
|
| `/v1/web/products/createproductvariant` | [`Productvariant`](#productvariant) | Create product variant |
|
||||||
|
| `/v1/web/tenants/createtenantcustomer` · `/v1/mob/...` | [`CreateTenantCustomerRequest`](#createtenantcustomerrequest) | Link customer to tenant |
|
||||||
|
| `/v1/web/tenants/createlocation` · `/v1/mob/...` | [`Tenantlocations`](#tenantlocations) | Create location |
|
||||||
|
| `/v1/web/tenants/createtenantlocation` | [`Tenantlocations`](#tenantlocations) | Create tenant-location |
|
||||||
|
| `/v1/mob/tenants/createstaff` | [`User`](#user) | Create staff |
|
||||||
|
| `/v1/mob/tenants/createtenantuser` | [`Tenants`](#tenants) (incl. nested `tenantlocations`) | Create tenant user |
|
||||||
|
| `/v1/mob/customers/createlocations` | [`Customerlocations`](#customerlocations) | Create customer address |
|
||||||
|
| `/v1/mob/customers/createcustomerrequest` | [`CustomerRequest`](#customerrequest) | Create customer request |
|
||||||
|
| `/v1/mob/customers/login` | `{ contactno:string }` | Customer login |
|
||||||
|
| `/v1/mob/customers/create` | [`Customers`](#customers) | Register customer |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# PUT
|
||||||
|
|
||||||
|
| Path | Request body | Description |
|
||||||
|
| -------------------------------------------------------- | ----------------------------------------------------------------------- | --------------------------------------------------------------- |
|
||||||
|
| `/v1/web/users/update` · `/v1/mob/...` | [`User`](#user) | Update staff/user |
|
||||||
|
| `/v1/web/orders/updateorder` · `/v1/mob/...` | [`Orders`](#orders) (`orderheaderid` required; + `items[]`) | Update order |
|
||||||
|
| `/v1/web/deliveries/updatedelivery` · `/v1/mob/...` | [`UpdateDeliveryStatus`](#updatedeliverystatus) (`deliveryid` required) | Update delivery (status/rider/coords/kms) |
|
||||||
|
| `/v1/web/products/update` · `/v1/mob/...` | [`Products`](#products) | Update product |
|
||||||
|
| `/v1/web/products/updateproductlocation` · `/v1/mob/...` | [`Productlocations`](#productlocations) | **Update a product's location settings** (price/min/max/status) |
|
||||||
|
| `/v1/web/tenants/updatelocation` · `/v1/mob/...` | [`Tenantlocations`](#tenantlocations) | Update location |
|
||||||
|
| `/v1/web/tenants/updatetenantlocation` | [`Tenantlocations`](#tenantlocations) | Update tenant-location |
|
||||||
|
| `/v1/mob/customers/update` | [`Customers`](#customers) | Update customer |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# DELETE
|
||||||
|
|
||||||
|
| Path | Query params | Description |
|
||||||
|
| ------------------------- | --------------- | -------------- |
|
||||||
|
| `/v1/web/products/delete` | `productid`:int | Delete product |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Models
|
||||||
|
|
||||||
|
### User
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :-------------- | :------- | :---- |
|
||||||
|
| `userid` | `int` | |
|
||||||
|
| `authname` | `string` | |
|
||||||
|
| `firstname` | `string` | |
|
||||||
|
| `lastname` | `string` | |
|
||||||
|
| `password` | `string` | |
|
||||||
|
| `email` | `string` | |
|
||||||
|
| `dialcode` | `string` | |
|
||||||
|
| `contactno` | `string` | |
|
||||||
|
| `configid` | `int` | |
|
||||||
|
| `authmode` | `int` | |
|
||||||
|
| `roleid` | `int` | |
|
||||||
|
| `pin` | `int` | |
|
||||||
|
| `deviceid` | `string` | |
|
||||||
|
| `devicetype` | `string` | |
|
||||||
|
| `userfcmtoken` | `string` | |
|
||||||
|
| `address` | `string` | |
|
||||||
|
| `suburb` | `string` | |
|
||||||
|
| `city` | `string` | |
|
||||||
|
| `state` | `string` | |
|
||||||
|
| `postcode` | `string` | |
|
||||||
|
| `partnerid` | `int` | |
|
||||||
|
| `tenantid` | `int` | |
|
||||||
|
| `locationid` | `int` | |
|
||||||
|
| `applocationid` | `int` | |
|
||||||
|
| `status` | `string` | |
|
||||||
|
| `shiftid` | `int` | |
|
||||||
|
|
||||||
|
### Orders
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :------------------------------------------------------------------------------------------------------ | :-------------- | :--------------------- |
|
||||||
|
| `orderheaderid` | `int` | |
|
||||||
|
| `tenantid` | `int` | *(required on create)* |
|
||||||
|
| `locationid` | `int` | |
|
||||||
|
| `applocationid` | `int` | |
|
||||||
|
| `moduleid` | `int` | |
|
||||||
|
| `partnerid` | `int` | |
|
||||||
|
| `configid` | `int` | |
|
||||||
|
| `categoryid` | `int` | |
|
||||||
|
| `subcategoryid` | `int` | |
|
||||||
|
| `orderid` | `string` | |
|
||||||
|
| `orderdate` | `string` | |
|
||||||
|
| `deliverytime` | `string` | |
|
||||||
|
| `deliverytype` | `string` | |
|
||||||
|
| `orderstatus` | `string` | |
|
||||||
|
| `pending`/`processing`/`ready`/`delivered`/`cancelled` | `string` | |
|
||||||
|
| `customerid` | `int` | |
|
||||||
|
| `pickupaddress`/`pickuplat`/`pickuplong`/`pickupcustomer`/`pickupcontactno`/`pickupsuburb`/`pickupcity` | `string` | |
|
||||||
|
| `deliverycustomer`/`deliverycontactno`/`deliveryaddress`/`deliverylocation`/`deliverycity` | `string` | |
|
||||||
|
| `deliverylocationid` | `int` | |
|
||||||
|
| `deliverylat`/`deliverylong` | `string` | |
|
||||||
|
| `promotionid` | `int` | |
|
||||||
|
| `promoname`/`promoterms` | `string` | |
|
||||||
|
| `promovalue` | `int` | |
|
||||||
|
| `promoamount`/`orderamount`/`taxamount`/`ordercharges`/`ordervalue` | `float32` | |
|
||||||
|
| `itemcount` | `int` | |
|
||||||
|
| `paymenttype` | `int` | |
|
||||||
|
| `paymentstatus` | `int` | |
|
||||||
|
| `deliverycharge` | `float32` | |
|
||||||
|
| `ordernotes`/`kms`/`remarks` | `string` | |
|
||||||
|
| `tenantuserid` | `int` | |
|
||||||
|
| `partneruserid` | `int` | |
|
||||||
|
| `smsdelivery` | `int` | |
|
||||||
|
| `items` | `[]OrderDetail` | |
|
||||||
|
|
||||||
|
### Deliveries
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--------------------------------------------------------------------------------------------------------------------------- | :-------- | :---- |
|
||||||
|
| `deliveryid` | `int` | |
|
||||||
|
| `orderheaderid` | `int` | |
|
||||||
|
| `applocationid` | `int` | |
|
||||||
|
| `configid` | `int` | |
|
||||||
|
| `partnerid` | `int` | |
|
||||||
|
| `tenantid` | `int` | |
|
||||||
|
| `moduleid` | `int` | |
|
||||||
|
| `locationid` | `int` | |
|
||||||
|
| `categoryid` | `int` | |
|
||||||
|
| `userid` | `int` | |
|
||||||
|
| `subcategoryid` | `int` | |
|
||||||
|
| `orderid` | `string` | |
|
||||||
|
| `deliverydate` | `string` | |
|
||||||
|
| `orderstatus` | `string` | |
|
||||||
|
| `assigntime`/`starttime`/`arrivaltime`/`pickuptime`/`deliverytime`/`canceltime` | `string` | |
|
||||||
|
| `itemcount` | `int` | |
|
||||||
|
| `orderamount` | `float32` | |
|
||||||
|
| `customerid` | `int` | |
|
||||||
|
| `pickupcustomer`/`pickupcontactno` | `string` | |
|
||||||
|
| `pickuplocationid` | `int` | |
|
||||||
|
| `pickupaddress`/`pickuplocation`/`pickuplat`/`pickuplon` | `string` | |
|
||||||
|
| `deliverycustomerid` | `int` | |
|
||||||
|
| `deliverylocationid` | `int` | |
|
||||||
|
| `deliverycustomer`/`deliverycontactno`/`deliveryaddress`/`deliverylocation`/`droplat`/`droplon`/`deliverylat`/`deliverylong` | `string` | |
|
||||||
|
| `deliverycharges`/`deliveryamt` | `float32` | |
|
||||||
|
| `deliverytype`/`notes`/`ordernotes`/`riderslat`/`riderslon` | `string` | |
|
||||||
|
| `firstmilekm`/`firstmilecharges`/`lastmilecharges`/`ridercharges` | `float32` | |
|
||||||
|
| `kms`/`actualkms` | `string` | |
|
||||||
|
| `smsdelivery` | `int` | |
|
||||||
|
| `paymenttype` | `int` | |
|
||||||
|
|
||||||
|
### UpdateDeliveryStatus
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :------------------------------------------------------------------------------ | :-------- | :----------- |
|
||||||
|
| `deliveryid` | `int` | *(required)* |
|
||||||
|
| `deliverytype` | `string` | |
|
||||||
|
| `pickuplocationid` | `int` | |
|
||||||
|
| `deliverylocationid` | `int` | |
|
||||||
|
| `orderheaderid` | `int` | |
|
||||||
|
| `userid` | `int` | |
|
||||||
|
| `orderstatus` | `string` | |
|
||||||
|
| `assigntime`/`starttime`/`arrivaltime`/`pickuptime`/`deliverytime`/`canceltime` | `string` | |
|
||||||
|
| `pickuplat`/`pickuplon`/`riderslat`/`riderslon`/`deliverylat`/`deliverylong` | `string` | |
|
||||||
|
| `address`/`suburb`/`city`/`state`/`postcode` | `string` | |
|
||||||
|
| `deliveryamt` | `float32` | |
|
||||||
|
| `kms`/`actualkms`/`riderkms`/`kmcal` | `string` | |
|
||||||
|
| `notes`/`feedback` | `string` | |
|
||||||
|
| `smsdelivery` | `int` | |
|
||||||
|
|
||||||
|
### Products
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :------------------ | :-------- | :---- |
|
||||||
|
| `productid` | `int` | |
|
||||||
|
| `applocationid` | `int` | |
|
||||||
|
| `productlocationid` | `int` | |
|
||||||
|
| `tenantid` | `int` | |
|
||||||
|
| `categoryid` | `int` | |
|
||||||
|
| `categoryname` | `string` | |
|
||||||
|
| `subcategoryid` | `int` | |
|
||||||
|
| `Subcategoryname` | `string` | |
|
||||||
|
| `catalogueid` | `int` | |
|
||||||
|
| `addonid` | `int` | |
|
||||||
|
| `discountid` | `int` | |
|
||||||
|
| `discountvalue` | `float64` | |
|
||||||
|
| `pricingid` | `int` | |
|
||||||
|
| `productname` | `string` | |
|
||||||
|
| `productimage` | `string` | |
|
||||||
|
| `productdesc` | `string` | |
|
||||||
|
| `productsku` | `string` | |
|
||||||
|
| `brandid` | `int` | |
|
||||||
|
| `productbrand` | `string` | |
|
||||||
|
| `productunit` | `string` | |
|
||||||
|
| `unitvalue` | `string` | |
|
||||||
|
| `toppicks` | `string` | |
|
||||||
|
| `productcost` | `float64` | |
|
||||||
|
| `taxamount` | `float64` | |
|
||||||
|
| `taxpercent` | `float64` | |
|
||||||
|
| `producttax` | `int` | |
|
||||||
|
| `productstock` | `int` | |
|
||||||
|
| `productcombo` | `int` | |
|
||||||
|
| `variants` | `int` | |
|
||||||
|
| `quantity` | `int` | |
|
||||||
|
| `retailprice` | `float64` | |
|
||||||
|
| `diffprice` | `float64` | |
|
||||||
|
| `diffpercent` | `float64` | |
|
||||||
|
| `othercost` | `float64` | |
|
||||||
|
| `approve` | `int` | |
|
||||||
|
| `productstatus` | `string` | |
|
||||||
|
|
||||||
|
### Productlocations
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :------------------ | :-------- | :------------ |
|
||||||
|
| `productlocationid` | `int` | |
|
||||||
|
| `tenantid` | `int` | |
|
||||||
|
| `locationid` | `int` | |
|
||||||
|
| `productid` | `int` | |
|
||||||
|
| `catlougeid` | `int` | |
|
||||||
|
| `minquantity` | `int` | (0) |
|
||||||
|
| `maxquantity` | `int` | (0) |
|
||||||
|
| `price` | `float32` | (0.0) |
|
||||||
|
| `quantity` | `int` | *(read-only)* |
|
||||||
|
| `stocktype` | `string` | *(read-only)* |
|
||||||
|
| `status` | `string` | |
|
||||||
|
|
||||||
|
### Productstock
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--------------- | :---------- | :---- |
|
||||||
|
| `productstockid` | `int` | |
|
||||||
|
| `tenantid` | `int` | |
|
||||||
|
| `stockdate` | `time.Time` | |
|
||||||
|
| `locationid` | `int` | |
|
||||||
|
| `productid` | `int` | |
|
||||||
|
| `quantity` | `int` | |
|
||||||
|
| `stocktype` | `string` | |
|
||||||
|
| `status` | `string` | |
|
||||||
|
|
||||||
|
### Productvariant
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :-------------- | :------- | :------- |
|
||||||
|
| `variantid` | `int` | |
|
||||||
|
| `tenantid` | `int` | |
|
||||||
|
| `variantname` | `string` | |
|
||||||
|
| `categoryid` | `int` | (0) |
|
||||||
|
| `categoryname` | `string` | |
|
||||||
|
| `subcategoryid` | `int` | |
|
||||||
|
| `status` | `string` | (active) |
|
||||||
|
|
||||||
|
### Tenantlocations
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--------------- | :------- | :---- |
|
||||||
|
| `locationid` | `int` | |
|
||||||
|
| `tenantid` | `int` | |
|
||||||
|
| `applocationid` | `int` | |
|
||||||
|
| `moduleid` | `int` | |
|
||||||
|
| `roleid` | `int` | |
|
||||||
|
| `locationname` | `string` | |
|
||||||
|
| `email` | `string` | |
|
||||||
|
| `contactno` | `string` | |
|
||||||
|
| `latitude` | `string` | |
|
||||||
|
| `longitude` | `string` | |
|
||||||
|
| `address` | `string` | |
|
||||||
|
| `suburb` | `string` | |
|
||||||
|
| `city` | `string` | |
|
||||||
|
| `state` | `string` | |
|
||||||
|
| `postcode` | `string` | |
|
||||||
|
| `opentime` | `string` | |
|
||||||
|
| `closetime` | `string` | |
|
||||||
|
| `partnerid` | `int` | |
|
||||||
|
| `deliveryradius` | `int` | |
|
||||||
|
| `deliverymins` | `int` | |
|
||||||
|
| `cancelsecs` | `int` | |
|
||||||
|
| `status` | `string` | |
|
||||||
|
|
||||||
|
### Tenants
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :------------------------------------------- | :---------------- | :---- |
|
||||||
|
| `tenantid` | `int` | |
|
||||||
|
| `tenantname` | `string` | |
|
||||||
|
| `configid` | `int` | |
|
||||||
|
| `partnerid` | `int` | |
|
||||||
|
| `moduleid` | `int` | |
|
||||||
|
| `tenanttype` | `string` | |
|
||||||
|
| `registrationno` | `string` | |
|
||||||
|
| `tenanttoken` | `string` | |
|
||||||
|
| `companyname` | `string` | |
|
||||||
|
| `devicetype` | `string` | |
|
||||||
|
| `deviceid` | `string` | |
|
||||||
|
| `firstname` | `string` | |
|
||||||
|
| `primaryemail` | `string` | |
|
||||||
|
| `primarycontact` | `string` | |
|
||||||
|
| `categoryid` | `int` | |
|
||||||
|
| `subcategoryid` | `int` | |
|
||||||
|
| `address`/`suburb`/`city`/`state`/`postcode` | `string` | |
|
||||||
|
| `latitude`/`longitude` | `string` | |
|
||||||
|
| `tenantimage` | `string` | |
|
||||||
|
| `tenantinfo` | `string` | |
|
||||||
|
| `paymode1` | `int` | |
|
||||||
|
| `paymode2` | `int` | |
|
||||||
|
| `promotion` | `int` | |
|
||||||
|
| `minorder` | `int` | |
|
||||||
|
| `applocationid` | `int` | |
|
||||||
|
| `approved` | `*int` | |
|
||||||
|
| `status` | `string` | |
|
||||||
|
| `partneruserid` | `int` | |
|
||||||
|
| `tenantlocations` | `Tenantlocations` | |
|
||||||
|
|
||||||
|
### CreateTenantCustomerRequest
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :------------------- | :---- | :---- |
|
||||||
|
| `moduleid` | `int` | |
|
||||||
|
| `tenantid` | `int` | |
|
||||||
|
| `locationid` | `int` | |
|
||||||
|
| `customerid` | `int` | |
|
||||||
|
| `customerlocationid` | `int` | |
|
||||||
|
| `status` | `int` | |
|
||||||
|
|
||||||
|
### Customers
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--------------------------------------------------------------- | :------- | :---- |
|
||||||
|
| `customerid` | `int` | |
|
||||||
|
| `firstname` | `string` | |
|
||||||
|
| `lastname` | `string` | |
|
||||||
|
| `profileimage` | `string` | |
|
||||||
|
| `gender` | `string` | |
|
||||||
|
| `dob` | `string` | |
|
||||||
|
| `dialcode` | `string` | |
|
||||||
|
| `contactno` | `string` | |
|
||||||
|
| `email` | `string` | |
|
||||||
|
| `deviceid` | `string` | |
|
||||||
|
| `devicetype` | `string` | |
|
||||||
|
| `authmode` | `int` | |
|
||||||
|
| `configid` | `int` | |
|
||||||
|
| `customertoken` | `string` | |
|
||||||
|
| `address`/`suburb`/`city`/`state`/`landmark`/`doorno`/`postcode` | `string` | |
|
||||||
|
| `latitude`/`longitude` | `string` | |
|
||||||
|
| `applocationid` | `int` | |
|
||||||
|
| `status` | `int` | |
|
||||||
|
| `intro` | `string` | |
|
||||||
|
|
||||||
|
### Customerlocations
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :--------------------------------------------------------------- | :------- | :---- |
|
||||||
|
| `locationid` | `int` | |
|
||||||
|
| `customerid` | `int` | |
|
||||||
|
| `applocationid` | `int` | |
|
||||||
|
| `address`/`suburb`/`city`/`state`/`landmark`/`doorno`/`postcode` | `string` | |
|
||||||
|
| `latitude`/`longitude` | `string` | |
|
||||||
|
| `primaryaddress` | `int` | |
|
||||||
|
| `status` | `int` | |
|
||||||
|
|
||||||
|
### CustomerRequest
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| :------------------ | :---------- | :---- |
|
||||||
|
| `customerrequestid` | `int` | |
|
||||||
|
| `referencedate` | `time.Time` | |
|
||||||
|
| `referencetype` | `string` | |
|
||||||
|
| `customerid` | `int` | |
|
||||||
|
| `tenantid` | `int` | |
|
||||||
|
| `apptypeid` | `int` | |
|
||||||
|
| `locationid` | `int` | |
|
||||||
|
| `subject` | `string` | |
|
||||||
|
| `remarks` | `string` | |
|
||||||
|
| `status` | `int` | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Notes:* `notifyuser` / `notifyadmin` (POST utils) exist in code but are commented out. `getriderlogs` reads `todate` from the `fromdate` query param (controller bug).
|
||||||
50
package-lock.json
generated
50
package-lock.json
generated
@@ -11,13 +11,16 @@
|
|||||||
"@google/genai": "^2.4.0",
|
"@google/genai": "^2.4.0",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"react": "^19.0.1",
|
"react": "^19.0.1",
|
||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.0.1",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"vite": "^6.2.3"
|
"vite": "^6.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -843,6 +846,17 @@
|
|||||||
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
|
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-leaflet/core": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.3",
|
"version": "1.0.0-rc.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||||
@@ -1551,6 +1565,12 @@
|
|||||||
"@types/send": "*"
|
"@types/send": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/http-errors": {
|
"node_modules/@types/http-errors": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||||
@@ -1558,6 +1578,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/leaflet": {
|
||||||
|
"version": "1.9.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||||
|
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/mime": {
|
"node_modules/@types/mime": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||||
@@ -2655,6 +2684,13 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -3333,6 +3369,20 @@
|
|||||||
"react": "^19.2.7"
|
"react": "^19.2.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-leaflet": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-leaflet/core": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
|
|||||||
@@ -14,13 +14,16 @@
|
|||||||
"@google/genai": "^2.4.0",
|
"@google/genai": "^2.4.0",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"react": "^19.0.1",
|
"react": "^19.0.1",
|
||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.0.1",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"vite": "^6.2.3"
|
"vite": "^6.2.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
323
src/components/DeliveriesView.tsx
Normal file
323
src/components/DeliveriesView.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliveries page — replicated from the operations console (nearle_console/
|
||||||
|
* deliveries), rebuilt against the shared console UI kit (`./consoleUi`) so it
|
||||||
|
* matches the source design (gradient header, KPI cards, batch + status pill
|
||||||
|
* tabs, STATUS_META colours, metric-pill table). The board loads the day's
|
||||||
|
* deliveries once and filters client-side by delivery wave, lifecycle status, and
|
||||||
|
* keyword. Rider write-actions (reassign/cancel/notify) need the dispatch + FCM
|
||||||
|
* backends this tenant doesn't expose, so they surface an "awaiting backend" note.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Truck, Clock, CheckCircle2, XCircle, Calendar, Sun, Sunset, Moon, Layers, UserCheck, MapPin, Phone, Package, Loader2, X, Bike,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useFiestaDeliverySummary, useFiestaDeliveries, useFiestaRiders, useFiestaOrderDetails } from '../services/fiestaQueries';
|
||||||
|
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||||
|
import { shortTime } from '../services/fiestaMappers';
|
||||||
|
import AwaitingApi from './AwaitingApi';
|
||||||
|
import {
|
||||||
|
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE,
|
||||||
|
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge,
|
||||||
|
} from './consoleUi';
|
||||||
|
|
||||||
|
interface DeliveriesViewProps { searchQuery?: string; locationid?: number; }
|
||||||
|
|
||||||
|
type DeliveryStatus = 'pending' | 'accepted' | 'arrived' | 'picked' | 'active' | 'skipped' | 'delivered' | 'cancelled';
|
||||||
|
const STATUS_TABS: Array<{ key: DeliveryStatus; label: string }> = [
|
||||||
|
{ key: 'pending', label: 'Pending' }, { key: 'accepted', label: 'Accepted' }, { key: 'arrived', label: 'Arrived' },
|
||||||
|
{ key: 'picked', label: 'Picked' }, { key: 'active', label: 'Active' }, { key: 'skipped', label: 'Skipped' },
|
||||||
|
{ key: 'delivered', label: 'Delivered' }, { key: 'cancelled', label: 'Cancelled' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Batch waves — canonical half-open hour ranges (match Dispatch).
|
||||||
|
type BatchId = 'all' | 'morning' | 'afternoon' | 'evening';
|
||||||
|
const BATCHES: Array<{ id: BatchId; label: string; range: string; color: string; icon: typeof Sun }> = [
|
||||||
|
{ id: 'all', label: 'All', range: 'Full day', color: '#7c3aed', icon: Layers },
|
||||||
|
{ id: 'morning', label: 'Morning', range: '12 AM – 8 AM', color: '#0ea5e9', icon: Sun },
|
||||||
|
{ id: 'afternoon', label: 'Afternoon', range: '9 AM – 12:30 PM', color: '#f59e0b', icon: Sunset },
|
||||||
|
{ id: 'evening', label: 'Evening', range: '4 PM – 7 PM', color: '#6366f1', icon: Moon },
|
||||||
|
];
|
||||||
|
function rowHourFrac(r: Row): number | null {
|
||||||
|
const m = (fstr(r.assigntime) || fstr(r.deliverytime) || fstr(r.deliverydate)).match(/[ T](\d{1,2}):(\d{2})/);
|
||||||
|
return m ? Number(m[1]) + Number(m[2]) / 60 : null;
|
||||||
|
}
|
||||||
|
function inBatch(r: Row, b: BatchId): boolean {
|
||||||
|
if (b === 'all') return true;
|
||||||
|
const h = rowHourFrac(r);
|
||||||
|
if (h == null) return false;
|
||||||
|
if (b === 'morning') return h >= 0 && h < 8;
|
||||||
|
if (b === 'afternoon') return h >= 9 && h < 12.5;
|
||||||
|
return h >= 16 && h < 19;
|
||||||
|
}
|
||||||
|
function initialBatch(): BatchId {
|
||||||
|
const h = new Date().getHours();
|
||||||
|
if (h >= 0 && h < 8) return 'morning';
|
||||||
|
if (h >= 9 && h < 12.5) return 'afternoon';
|
||||||
|
if (h >= 16 && h < 19) return 'evening';
|
||||||
|
return 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeliveriesView({ searchQuery = '', locationid }: DeliveriesViewProps) {
|
||||||
|
const today = new Date();
|
||||||
|
const [fromdate, setFromdate] = useState<string>(ymd(today));
|
||||||
|
const [todate, setTodate] = useState<string>(ymd(today));
|
||||||
|
const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); };
|
||||||
|
const presets = [
|
||||||
|
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
|
||||||
|
{ key: 'yesterday', label: 'Yesterday', from: dayOffset(1), to: dayOffset(1) },
|
||||||
|
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) },
|
||||||
|
];
|
||||||
|
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
|
||||||
|
|
||||||
|
const [batch, setBatch] = useState<BatchId>(initialBatch());
|
||||||
|
const [status, setStatus] = useState<DeliveryStatus>('pending');
|
||||||
|
const [localSearch, setLocalSearch] = useState('');
|
||||||
|
const [detailRow, setDetailRow] = useState<Row | null>(null);
|
||||||
|
|
||||||
|
const summaryQ = useFiestaDeliverySummary({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
||||||
|
const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
||||||
|
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID });
|
||||||
|
|
||||||
|
const allRows = deliveriesQ.data ?? [];
|
||||||
|
const summary = summaryQ.data;
|
||||||
|
|
||||||
|
const batchRows = useMemo(() => allRows.filter((r) => (!locationid || fnum(r.locationid) === locationid) && inBatch(r, batch)), [allRows, batch, locationid]);
|
||||||
|
const statusCounts = useMemo(() => {
|
||||||
|
const acc: Record<string, number> = {};
|
||||||
|
for (const r of batchRows) { const s = fstr(r.orderstatus).toLowerCase(); acc[s] = (acc[s] ?? 0) + 1; }
|
||||||
|
return acc;
|
||||||
|
}, [batchRows]);
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const term = (localSearch || searchQuery).toLowerCase();
|
||||||
|
return batchRows.filter((r) => {
|
||||||
|
if (fstr(r.orderstatus).toLowerCase() !== status) return false;
|
||||||
|
if (!term) return true;
|
||||||
|
return [r.orderid, r.deliverycustomer, r.deliveryaddress, r.deliverysuburb, r.pickupcustomer, r.ridername, r.username].some((f) => fstr(f).toLowerCase().includes(term));
|
||||||
|
});
|
||||||
|
}, [batchRows, status, localSearch, searchQuery]);
|
||||||
|
|
||||||
|
const activeFleet = (ridersQ.data ?? []).filter((r) => fstr(r.starttime)).length;
|
||||||
|
const total = summary?.total ?? 0;
|
||||||
|
const pct = (n: number) => (total > 0 ? `${Math.round((n / total) * 100)}% of total` : 'In range');
|
||||||
|
const kpis = [
|
||||||
|
{ label: 'Total Deliveries', value: total.toLocaleString('en-IN'), color: '#6366f1', icon: <Truck size={20} />, badge: undefined },
|
||||||
|
{ label: 'Pending', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#f59e0b', icon: <Clock size={20} />, badge: pct(summary?.pending ?? 0) },
|
||||||
|
{ label: 'Delivered', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#10b981', icon: <CheckCircle2 size={20} />, badge: pct(summary?.delivered ?? 0) },
|
||||||
|
{ label: 'Cancelled', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#ef4444', icon: <XCircle size={20} />, badge: pct(summary?.cancelled ?? 0) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-in fade-in duration-300">
|
||||||
|
<GradientHeader
|
||||||
|
title="Deliveries"
|
||||||
|
subtitle="Dispatch board for in-transit orders — tracked across the rider lifecycle and grouped into delivery waves."
|
||||||
|
status={
|
||||||
|
deliveriesQ.isLoading ? <LiveStatus state="loading" label="Loading live deliveries…" />
|
||||||
|
: deliveriesQ.isError ? <LiveStatus state="error" label="Live data unavailable" />
|
||||||
|
: <LiveStatus state="live" label={`Live · ${batchRows.length} in this wave · ${activeFleet} riders on duty`} />
|
||||||
|
}
|
||||||
|
right={
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full font-extrabold" style={{ padding: '6px 12px', fontSize: 12, background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}`, color: BRAND }}>
|
||||||
|
<MapPin size={13} /> Coimbatore
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-4"><KpiStrip items={kpis} loading={summaryQ.isLoading} /></div>
|
||||||
|
|
||||||
|
{/* Date + waves */}
|
||||||
|
<FilterBar className="mb-4">
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-extrabold uppercase tracking-widest pr-1" style={{ color: TEXT_2 }}><Calendar size={13} style={{ color: BRAND }} /> View</span>
|
||||||
|
{presets.map((p) => (
|
||||||
|
<React.Fragment key={p.key}><Pill active={activePreset === p.key} color={BRAND} onClick={() => { setFromdate(p.from); setTodate(p.to); }}>{p.label}</Pill></React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<input type="date" value={fromdate} max={todate} onChange={(e) => setFromdate(e.target.value)} className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
||||||
|
<span style={{ color: TEXT_3 }}>→</span>
|
||||||
|
<input type="date" value={todate} min={fromdate} max={ymd(today)} onChange={(e) => setTodate(e.target.value)} className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap pt-3 mt-3 border-t" style={{ borderColor: DIVIDER }}>
|
||||||
|
<span className="text-[10px] font-extrabold uppercase tracking-widest pr-1" style={{ color: TEXT_2 }}>Wave</span>
|
||||||
|
{BATCHES.map((b) => {
|
||||||
|
const Icon = b.icon;
|
||||||
|
const count = allRows.filter((r) => (!locationid || fnum(r.locationid) === locationid) && inBatch(r, b.id)).length;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={b.id}>
|
||||||
|
<Pill active={batch === b.id} color={b.color} onClick={() => setBatch(b.id)} title={b.range} count={count}><Icon size={13} /> {b.label}</Pill>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
{/* Status tabs + search */}
|
||||||
|
<FilterBar className="mb-4">
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto py-0.5 flex-1 min-w-0">
|
||||||
|
{STATUS_TABS.map((t) => {
|
||||||
|
const color = statusColor(DELIVERY_STATUS, t.key);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={t.key}>
|
||||||
|
<Pill active={status === t.key} color={color} onClick={() => setStatus(t.key)} count={statusCounts[t.key] ?? 0}>{t.label}</Pill>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="w-full lg:w-72 lg:shrink-0"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search by order, rider…" color="#6366f1" /></div>
|
||||||
|
</div>
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full" style={{ minWidth: 1040 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>{['#', 'Status', 'Order', 'Drop', 'Rider', 'ETA', 'KMs', 'Amount', ''].map((h, i) => (<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>))}</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{deliveriesQ.isLoading ? (
|
||||||
|
<tr><td colSpan={9} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}><span className="inline-flex items-center gap-2 text-xs font-semibold"><Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading deliveries…</span></td></tr>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<tr><td colSpan={9} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No {status} deliveries in this wave. Try another status, wave, or date.</td></tr>
|
||||||
|
) : (
|
||||||
|
rows.map((r, i) => {
|
||||||
|
const st = fstr(r.orderstatus).toLowerCase();
|
||||||
|
const rider = fstr(r.ridername) || fstr(r.username);
|
||||||
|
const kms = fnum(r.kms); const actualKms = fnum(r.cumulativekms);
|
||||||
|
const charge = fnum(r.deliverycharges); const amt = fnum(r.deliveryamt);
|
||||||
|
return (
|
||||||
|
<tr key={fstr(r.deliveryid) || fstr(r.orderid) || i} className="transition-colors align-top" style={{ borderBottom: `1px solid ${DIVIDER}` }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
|
||||||
|
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{i + 1}</td>
|
||||||
|
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(DELIVERY_STATUS, st)} /></td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`}</p>
|
||||||
|
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.assigntime || r.deliverydate)}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<p className="font-bold text-[12px] truncate max-w-[160px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
|
||||||
|
<p className="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
{rider ? (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span className="rounded-full flex items-center justify-center shrink-0" style={{ width: 26, height: 26, background: soft('#8b5cf6'), color: '#8b5cf6' }}><Bike size={13} /></span>
|
||||||
|
<span className="font-bold text-[12px] truncate max-w-[100px]" style={{ color: TEXT }}>{rider}</span>
|
||||||
|
</span>
|
||||||
|
) : <span className="text-[11px] italic" style={{ color: TEXT_3 }}>Unassigned</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5"><MetricPill color="#06b6d4">{shortTime(r.expecteddeliverytime) || '—'}</MetricPill></td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="flex flex-col items-start gap-1">
|
||||||
|
<MetricPill color="#ef4444" minWidth={64}>{kms ? kms.toFixed(1) : '—'}</MetricPill>
|
||||||
|
{actualKms > 0 && <MetricPill color="#10b981" minWidth={64}>{actualKms.toFixed(1)}</MetricPill>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="flex flex-col items-start gap-1">
|
||||||
|
{charge > 0 && <MetricPill color="#ef4444" minWidth={72}>₹{charge.toLocaleString('en-IN')}</MetricPill>}
|
||||||
|
{amt > 0 && <MetricPill color="#10b981" minWidth={72}>₹{amt.toLocaleString('en-IN')}</MetricPill>}
|
||||||
|
{charge === 0 && amt === 0 && <span style={{ color: TEXT_3 }}>—</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right">
|
||||||
|
<button onClick={() => setDetailRow(r)} className="rounded-full font-extrabold cursor-pointer" style={{ padding: '4px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}>View</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2.5 border-t text-[10px] font-bold uppercase tracking-wider" style={{ borderColor: BORDER, background: SURFACE_ALT, color: TEXT_2 }}>
|
||||||
|
{rows.length} {status} · {BATCHES.find((b) => b.id === batch)?.label} wave
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailRow && <DeliveryDetailModal row={detailRow} onClose={() => setDetailRow(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delivery details modal ──────────────────────────────────────────────────────
|
||||||
|
function DeliveryDetailModal({ row, onClose }: { row: Row; onClose: () => void }) {
|
||||||
|
const orderheaderid = row.orderheaderid ?? row.orderid;
|
||||||
|
const detailsQ = useFiestaOrderDetails(orderheaderid as number | string);
|
||||||
|
const lines = (detailsQ.data ?? []).map((d) => {
|
||||||
|
const quantity = fnum(d.quantity) || fnum(d.qty) || fnum(d.orderqty);
|
||||||
|
const price = fnum(d.price) || fnum(d.unitprice) || fnum(d.retailprice);
|
||||||
|
return { name: fstr(d.productname) || fstr(d.itemname) || 'Item', quantity, price, lineTotal: fnum(d.amount) || fnum(d.productsumprice) || price * quantity };
|
||||||
|
});
|
||||||
|
const st = fstr(row.orderstatus).toLowerCase();
|
||||||
|
const rider = fstr(row.ridername) || fstr(row.username);
|
||||||
|
const steps = [
|
||||||
|
{ label: 'Assigned', field: 'assigntime' }, { label: 'Accepted', field: 'acceptedtime' }, { label: 'Arrived', field: 'arrivaltime' },
|
||||||
|
{ label: 'Picked', field: 'pickuptime' }, { label: 'Delivered', field: 'deliverytime' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }} onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
|
<div className="bg-white w-full max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-2xl animate-in zoom-in-95 duration-200" style={{ border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
|
||||||
|
<div style={{ height: 4, background: `linear-gradient(90deg, #6366f1 0%, ${soft('#6366f1')} 100%)` }} />
|
||||||
|
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||||
|
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Truck size={16} style={{ color: '#6366f1' }} /> {fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}</h4>
|
||||||
|
<button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-4 overflow-y-auto flex-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<StatusChip label={st || '—'} color={statusColor(DELIVERY_STATUS, st)} />
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[11px] font-medium" style={{ color: TEXT_2 }}><UserCheck size={12} /> {rider || 'Unassigned'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-xl space-y-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
|
||||||
|
<div className="font-bold" style={{ color: TEXT }}>{fstr(row.deliverycustomer) || 'Customer'}</div>
|
||||||
|
{fstr(row.deliverycontactno) && <div className="flex items-center gap-2 font-mono text-xs" style={{ color: TEXT_2 }}><Phone size={12} /> {fstr(row.deliverycontactno)}</div>}
|
||||||
|
<div className="flex items-start gap-2 text-xs" style={{ color: TEXT_2 }}><MapPin size={12} className="mt-0.5 shrink-0" /> <span className="leading-relaxed">{fstr(row.deliveryaddress) || fstr(row.deliverysuburb) || 'Address unavailable'}</span></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-extrabold uppercase tracking-wide block mb-2" style={{ color: TEXT_2 }}>Delivery Timeline</span>
|
||||||
|
<div className="space-y-2.5 pl-1">
|
||||||
|
{steps.map((s) => {
|
||||||
|
const ts = fstr(row[s.field]); const done = Boolean(ts);
|
||||||
|
return (
|
||||||
|
<div key={s.field} className="flex items-center gap-2.5">
|
||||||
|
<CheckCircle2 size={13} style={{ color: done ? '#10b981' : '#cbd5e1' }} />
|
||||||
|
<span className="font-semibold text-xs" style={{ color: done ? TEXT : TEXT_3 }}>{s.label}</span>
|
||||||
|
<span className="ml-auto text-[10px] font-mono" style={{ color: TEXT_3 }}>{done ? shortTime(ts) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-extrabold uppercase tracking-wide mb-2 flex items-center gap-1.5" style={{ color: TEXT_2 }}><Package size={12} /> Items</span>
|
||||||
|
<div className="rounded-xl p-3" style={{ background: 'rgba(248,250,252,0.6)', border: `1px solid ${BORDER}` }}>
|
||||||
|
{detailsQ.isLoading && <div className="py-2 flex items-center gap-1.5 text-[11px] font-medium" style={{ color: TEXT_3 }}><Loader2 size={12} className="animate-spin" /> Loading items…</div>}
|
||||||
|
{!detailsQ.isLoading && lines.length === 0 && <div className="py-2 text-[11px] font-medium" style={{ color: TEXT_3 }}>No line items returned.</div>}
|
||||||
|
{lines.map((item, idx) => (
|
||||||
|
<div key={idx} className="py-2 flex justify-between items-center" style={{ borderTop: idx ? `1px solid ${DIVIDER}` : undefined }}>
|
||||||
|
<div><p className="font-bold text-xs" style={{ color: TEXT }}>{item.name}</p><p className="text-[10px]" style={{ color: TEXT_2 }}>Qty: {item.quantity} × ₹{item.price}</p></div>
|
||||||
|
<span className="font-extrabold font-mono text-xs" style={{ color: TEXT }}>₹{item.lineTotal.toLocaleString('en-IN')}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AwaitingApi label="Reassign · Cancel · Notify rider" api="dispatch backend" compact />
|
||||||
|
</div>
|
||||||
|
<div className="p-3 border-t flex justify-end shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||||
|
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})` }}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
305
src/components/DeliveryReportsView.tsx
Normal file
305
src/components/DeliveryReportsView.tsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delivery Reports — replicated from the operations console (nearle_console/
|
||||||
|
* reports), rebuilt against the shared console UI kit (`./consoleUi`) so it
|
||||||
|
* matches the source design (gradient header, KPI cards, pill tabs, status-chip /
|
||||||
|
* metric-pill tables, gradient total bars, gradient export button). Three report
|
||||||
|
* tabs map onto the live Fiesta endpoints: Orders Summary (getlocationsummary),
|
||||||
|
* Riders Summary (getfleetsummary), Orders Details (getdeliveries + CSV). The
|
||||||
|
* map-based reports need a mapping stack / GPS telemetry → "awaiting backend".
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { TrendingUp, Clock, CheckCircle2, IndianRupee, Bike, Truck, Calendar, Download, Store, ClipboardList, Route } from 'lucide-react';
|
||||||
|
import { useFiestaLocationSummary, useFiestaFleetSummary, useFiestaDeliveries } from '../services/fiestaQueries';
|
||||||
|
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||||
|
import { shortTime } from '../services/fiestaMappers';
|
||||||
|
import AwaitingApi from './AwaitingApi';
|
||||||
|
import {
|
||||||
|
GradientHeader, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE,
|
||||||
|
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge, ring,
|
||||||
|
} from './consoleUi';
|
||||||
|
|
||||||
|
type ReportTab = 'orders-summary' | 'riders-summary' | 'orders-details' | 'maps';
|
||||||
|
const TABS: Array<{ key: ReportTab; label: string; icon: typeof TrendingUp }> = [
|
||||||
|
{ key: 'orders-summary', label: 'Orders Summary', icon: Store },
|
||||||
|
{ key: 'riders-summary', label: 'Riders Summary', icon: Bike },
|
||||||
|
{ key: 'orders-details', label: 'Orders Details', icon: ClipboardList },
|
||||||
|
{ key: 'maps', label: 'Rider Routes', icon: Route },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface DeliveryReportsViewProps { searchQuery?: string; }
|
||||||
|
|
||||||
|
export default function DeliveryReportsView({ searchQuery = '' }: DeliveryReportsViewProps) {
|
||||||
|
const today = new Date();
|
||||||
|
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
const [fromdate, setFromdate] = useState<string>(ymd(monthStart));
|
||||||
|
const [todate, setTodate] = useState<string>(ymd(today));
|
||||||
|
const [tab, setTab] = useState<ReportTab>('orders-summary');
|
||||||
|
|
||||||
|
const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); };
|
||||||
|
const presets = [
|
||||||
|
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
|
||||||
|
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) },
|
||||||
|
{ key: '30d', label: 'Last 30 Days', from: dayOffset(29), to: ymd(today) },
|
||||||
|
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: ymd(today) },
|
||||||
|
];
|
||||||
|
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-in fade-in duration-300">
|
||||||
|
<GradientHeader title="Delivery Reports" subtitle="Operational analytics across outlets, riders, and the full order lifecycle." />
|
||||||
|
|
||||||
|
{/* Tab nav */}
|
||||||
|
<FilterBar className="mb-4">
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto">
|
||||||
|
{TABS.map((t) => {
|
||||||
|
const Icon = t.icon;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={t.key}>
|
||||||
|
<Pill active={tab === t.key} color={BRAND} onClick={() => setTab(t.key)}><Icon size={14} /> {t.label}</Pill>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
{/* Shared date range */}
|
||||||
|
<FilterBar className="mb-4">
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-extrabold uppercase tracking-widest pr-1" style={{ color: TEXT_2 }}><Calendar size={13} style={{ color: BRAND }} /> Period</span>
|
||||||
|
{presets.map((p) => (
|
||||||
|
<React.Fragment key={p.key}><Pill active={activePreset === p.key} color={BRAND} onClick={() => { setFromdate(p.from); setTodate(p.to); }}>{p.label}</Pill></React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<input type="date" value={fromdate} max={todate} onChange={(e) => setFromdate(e.target.value)} className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
||||||
|
<span style={{ color: TEXT_3 }}>→</span>
|
||||||
|
<input type="date" value={todate} min={fromdate} max={ymd(today)} onChange={(e) => setTodate(e.target.value)} className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
{tab === 'orders-summary' && <OrdersSummaryReport />}
|
||||||
|
{tab === 'riders-summary' && <RidersSummaryReport fromdate={fromdate} todate={todate} />}
|
||||||
|
{tab === 'orders-details' && <OrdersDetailsReport fromdate={fromdate} todate={todate} searchQuery={searchQuery} />}
|
||||||
|
{tab === 'maps' && (
|
||||||
|
<div className="bg-white border rounded-2xl p-4" style={{ borderColor: BORDER }}>
|
||||||
|
<span className="text-[10px] font-extrabold uppercase tracking-widest flex items-center gap-1.5 mb-2" style={{ color: TEXT_2 }}><Route size={12} /> Planned routes & live rider logs</span>
|
||||||
|
<AwaitingApi label="Rider route maps & live location logs" api="maps + rider telemetry" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Cnt = ({ n, color }: { n: number; color: string }) => (n > 0 ? <MetricPill color={color} minWidth={34}>{n.toLocaleString('en-IN')}</MetricPill> : <span style={{ color: TEXT_3, fontWeight: 700 }}>0</span>);
|
||||||
|
|
||||||
|
function TableShell({ minWidth, head, children, footer }: { minWidth: number; head: string[]; children: React.ReactNode; footer?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full" style={{ minWidth }}>
|
||||||
|
<thead><tr>{head.map((h, i) => (<th key={i} className={`px-3 py-2.5 ${i < 2 ? 'text-left' : 'text-right'}`} style={TH_STYLE}>{h}</th>))}</tr></thead>
|
||||||
|
<tbody>{children}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Orders Summary (per outlet) ──────────────────────────────────────────────────
|
||||||
|
function OrdersSummaryReport() {
|
||||||
|
const q = useFiestaLocationSummary(FIESTA_TENANT_ID);
|
||||||
|
const rows = q.data ?? [];
|
||||||
|
const totals = rows.reduce((a, r) => ({ total: a.total + r.total, pending: a.pending + r.pending, delivered: a.delivered + r.delivered, cancelled: a.cancelled + r.cancelled }), { total: 0, pending: 0, delivered: 0, cancelled: 0 });
|
||||||
|
const kpis = [
|
||||||
|
{ label: 'Total Orders', value: totals.total.toLocaleString('en-IN'), color: BRAND, icon: <TrendingUp size={20} /> },
|
||||||
|
{ label: 'Pending', value: totals.pending.toLocaleString('en-IN'), color: '#f59e0b', icon: <Clock size={20} /> },
|
||||||
|
{ label: 'Delivered', value: totals.delivered.toLocaleString('en-IN'), color: '#10b981', icon: <CheckCircle2 size={20} /> },
|
||||||
|
{ label: 'Outlets', value: rows.length.toLocaleString('en-IN'), color: '#0ea5e9', icon: <Store size={20} /> },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<KpiStrip items={kpis} loading={q.isLoading} />
|
||||||
|
<TableShell minWidth={820} head={['#', 'Outlet', 'All', 'Created', 'Pending', 'Processing', 'Delivered', 'Cancelled']}
|
||||||
|
footer={rows.length > 0 ? <TotalBar chips={[{ label: `${totals.total} orders`, color: BRAND }, { label: `${totals.delivered} delivered`, color: '#10b981' }, { label: `${totals.pending} pending`, color: '#f59e0b' }]} /> : undefined}>
|
||||||
|
{q.isLoading ? <tr><td colSpan={8} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>Loading outlet summary…</td></tr>
|
||||||
|
: rows.length === 0 ? <tr><td colSpan={8} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No outlet data available.</td></tr>
|
||||||
|
: rows.map((r, i) => (
|
||||||
|
<tr key={r.locationid || i} className="transition-colors" style={{ borderBottom: `1px solid ${DIVIDER}` }} onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
|
||||||
|
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{i + 1}</td>
|
||||||
|
<td className="px-3 py-2.5 font-extrabold text-[13px]" style={{ color: TEXT }}>{r.locationname || `Location ${r.locationid}`}</td>
|
||||||
|
<td className="px-3 py-2.5 text-right font-extrabold font-mono" style={{ color: TEXT }}>{r.total.toLocaleString('en-IN')}</td>
|
||||||
|
<td className="px-3 py-2.5 text-right"><Cnt n={r.created} color="#0ea5e9" /></td>
|
||||||
|
<td className="px-3 py-2.5 text-right"><Cnt n={r.pending} color="#f59e0b" /></td>
|
||||||
|
<td className="px-3 py-2.5 text-right"><Cnt n={r.processing} color="#6366f1" /></td>
|
||||||
|
<td className="px-3 py-2.5 text-right"><Cnt n={r.delivered} color="#10b981" /></td>
|
||||||
|
<td className="px-3 py-2.5 text-right"><Cnt n={r.cancelled} color="#ef4444" /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TableShell>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Riders Summary (per rider) ───────────────────────────────────────────────────
|
||||||
|
function RidersSummaryReport({ fromdate, todate }: { fromdate: string; todate: string }) {
|
||||||
|
const q = useFiestaFleetSummary({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
||||||
|
const rows = q.data ?? [];
|
||||||
|
const mapped = rows.map((r) => ({
|
||||||
|
name: fstr(r.fullname) || `${fstr(r.firstname)} ${fstr(r.lastname)}`.trim() || fstr(r.username) || `Rider ${fstr(r.userid)}`,
|
||||||
|
orders: fnum(r.totalorders) || fnum(r.orders), delivered: fnum(r.delivered) || fnum(r.deliveriescompleted) || fnum(r.completed),
|
||||||
|
pending: fnum(r.pending) || fnum(r.deliveriespending), cancelled: fnum(r.cancelled) || fnum(r.deliveriescancelled),
|
||||||
|
kms: fnum(r.kms), actualKms: fnum(r.cumulativekms), amount: fnum(r.deliveryamt) || fnum(r.charges) || fnum(r.deliverycharges),
|
||||||
|
}));
|
||||||
|
const totals = mapped.reduce((a, r) => ({ orders: a.orders + r.orders, delivered: a.delivered + r.delivered, amount: a.amount + r.amount }), { orders: 0, delivered: 0, amount: 0 });
|
||||||
|
const kpis = [
|
||||||
|
{ label: 'Active Riders', value: mapped.length.toLocaleString('en-IN'), color: BRAND, icon: <Bike size={20} /> },
|
||||||
|
{ label: 'Total Orders', value: totals.orders.toLocaleString('en-IN'), color: '#0ea5e9', icon: <Truck size={20} /> },
|
||||||
|
{ label: 'Delivered', value: totals.delivered.toLocaleString('en-IN'), color: '#10b981', icon: <CheckCircle2 size={20} /> },
|
||||||
|
{ label: 'Total Amount', value: `₹${totals.amount.toLocaleString('en-IN')}`, color: '#f59e0b', icon: <IndianRupee size={20} /> },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<KpiStrip items={kpis} loading={q.isLoading} />
|
||||||
|
<TableShell minWidth={820} head={['#', 'Rider', 'Orders', 'Pending', 'Cancelled', 'Delivered', 'KMs', 'Amount']}
|
||||||
|
footer={mapped.length > 0 ? <TotalBar chips={[{ label: `${totals.orders} orders`, color: BRAND }, { label: `${totals.delivered} delivered`, color: '#10b981' }]} grand={`₹${totals.amount.toLocaleString('en-IN')}`} /> : undefined}>
|
||||||
|
{q.isLoading ? <tr><td colSpan={8} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>Loading rider summary…</td></tr>
|
||||||
|
: q.isError ? <tr><td colSpan={8} className="px-3 py-12 text-center text-xs" style={{ color: '#ef4444' }}>Rider summary unavailable for this period.</td></tr>
|
||||||
|
: mapped.length === 0 ? <tr><td colSpan={8} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No rider activity in this period.</td></tr>
|
||||||
|
: mapped.map((r, i) => (
|
||||||
|
<tr key={i} className="transition-colors" style={{ borderBottom: `1px solid ${DIVIDER}` }} onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
|
||||||
|
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{i + 1}</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span className="rounded-full flex items-center justify-center shrink-0" style={{ width: 26, height: 26, background: soft('#8b5cf6'), color: '#8b5cf6' }}><Bike size={13} /></span>
|
||||||
|
<span className="font-extrabold text-[13px]" style={{ color: TEXT }}>{r.name}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right font-extrabold font-mono" style={{ color: TEXT }}>{r.orders.toLocaleString('en-IN')}</td>
|
||||||
|
<td className="px-3 py-2.5 text-right"><Cnt n={r.pending} color="#f59e0b" /></td>
|
||||||
|
<td className="px-3 py-2.5 text-right"><Cnt n={r.cancelled} color="#ef4444" /></td>
|
||||||
|
<td className="px-3 py-2.5 text-right"><Cnt n={r.delivered} color="#10b981" /></td>
|
||||||
|
<td className="px-3 py-2.5 text-right font-mono text-xs" style={{ color: TEXT_2 }}>{r.kms ? r.kms.toFixed(1) : '—'}{r.actualKms > 0 ? ` / ${r.actualKms.toFixed(1)}` : ''}</td>
|
||||||
|
<td className="px-3 py-2.5 text-right">{r.amount > 0 ? <MetricPill color="#10b981" minWidth={72}>₹{r.amount.toLocaleString('en-IN')}</MetricPill> : <span style={{ color: TEXT_3 }}>—</span>}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TableShell>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Orders Details (line-level + CSV) ────────────────────────────────────────────
|
||||||
|
const DETAIL_STATUSES = ['all', 'pending', 'accepted', 'arrived', 'picked', 'active', 'delivered', 'skipped', 'cancelled'] as const;
|
||||||
|
type DetailStatus = (typeof DETAIL_STATUSES)[number];
|
||||||
|
|
||||||
|
function OrdersDetailsReport({ fromdate, todate, searchQuery }: { fromdate: string; todate: string; searchQuery: string }) {
|
||||||
|
const q = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate, todate });
|
||||||
|
const allRows = q.data ?? [];
|
||||||
|
const [status, setStatus] = useState<DetailStatus>('all');
|
||||||
|
const [localSearch, setLocalSearch] = useState('');
|
||||||
|
|
||||||
|
const statusCounts = useMemo(() => {
|
||||||
|
const acc: Record<string, number> = {};
|
||||||
|
for (const r of allRows) { const s = fstr(r.orderstatus).toLowerCase(); acc[s] = (acc[s] ?? 0) + 1; }
|
||||||
|
return acc;
|
||||||
|
}, [allRows]);
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const term = (localSearch || searchQuery).toLowerCase();
|
||||||
|
return allRows.filter((r) => {
|
||||||
|
if (status !== 'all' && fstr(r.orderstatus).toLowerCase() !== status) return false;
|
||||||
|
if (!term) return true;
|
||||||
|
return [r.orderid, r.deliverycustomer, r.deliveryaddress, r.ridername].some((f) => fstr(f).toLowerCase().includes(term));
|
||||||
|
});
|
||||||
|
}, [allRows, status, localSearch, searchQuery]);
|
||||||
|
|
||||||
|
const exportCsv = () => {
|
||||||
|
const headers = ['Order ID', 'Status', 'Rider', 'Customer', 'Suburb', 'Address', 'Assigned', 'Delivered', 'KMs', 'Actual KMs', 'Charges', 'Amount'];
|
||||||
|
const esc = (v: unknown) => `"${fstr(v).replace(/"/g, '""')}"`;
|
||||||
|
const lines = rows.map((r) => [r.orderid, r.orderstatus, fstr(r.ridername) || fstr(r.username), r.deliverycustomer, r.deliverysuburb, r.deliveryaddress, shortTime(r.assigntime), shortTime(r.deliverytime), fnum(r.kms), fnum(r.cumulativekms), fnum(r.deliverycharges), fnum(r.deliveryamt)].map(esc).join(','));
|
||||||
|
const blob = new Blob([[headers.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a'); a.href = url; a.download = `Orders_Detail_${fromdate}_to_${todate}.csv`; a.click(); URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FilterBar>
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto py-0.5 flex-1 min-w-0">
|
||||||
|
{DETAIL_STATUSES.map((s) => {
|
||||||
|
const color = s === 'all' ? BRAND : statusColor(DELIVERY_STATUS, s);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={s}>
|
||||||
|
<Pill active={status === s} color={color} onClick={() => setStatus(s)} count={s === 'all' ? allRows.length : statusCounts[s] ?? 0}>
|
||||||
|
<span className="capitalize">{s}</span>
|
||||||
|
</Pill>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 lg:shrink-0">
|
||||||
|
<div className="w-full lg:w-56"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search…" /></div>
|
||||||
|
<button onClick={exportCsv} disabled={rows.length === 0} className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap"
|
||||||
|
style={{ padding: '7px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}>
|
||||||
|
<Download size={13} /> CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
<TableShell minWidth={1040} head={['#', 'Order', 'Drop', 'Rider', 'Assigned', 'Delivered', 'KMs', 'Charges', 'Status']}
|
||||||
|
footer={<div className="px-4 py-2.5 border-t text-[10px] font-bold uppercase tracking-wider" style={{ borderColor: BORDER, background: SURFACE_ALT, color: TEXT_2 }}>{rows.length} rows · {fromdate} → {todate}</div>}>
|
||||||
|
{q.isLoading ? <tr><td colSpan={9} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>Loading order details…</td></tr>
|
||||||
|
: rows.length === 0 ? <tr><td colSpan={9} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No deliveries match this filter.</td></tr>
|
||||||
|
: rows.map((r, i) => {
|
||||||
|
const st = fstr(r.orderstatus).toLowerCase();
|
||||||
|
const rider = fstr(r.ridername) || fstr(r.username);
|
||||||
|
const charge = fnum(r.deliverycharges) || fnum(r.deliveryamt);
|
||||||
|
return (
|
||||||
|
<tr key={fstr(r.deliveryid) || fstr(r.orderid) || i} className="transition-colors align-top" style={{ borderBottom: `1px solid ${DIVIDER}` }} onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
|
||||||
|
<td className="px-3 py-2.5 font-mono text-left" style={{ color: TEXT_3 }}>{i + 1}</td>
|
||||||
|
<td className="px-3 py-2.5 text-left">
|
||||||
|
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`}</p>
|
||||||
|
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.deliverydate || r.assigntime)}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-left">
|
||||||
|
<p className="font-bold text-[12px] truncate max-w-[160px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
|
||||||
|
<p className="text-[10px] truncate max-w-[160px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right font-medium text-xs truncate max-w-[110px]" style={{ color: TEXT_2 }}>{rider || '—'}</td>
|
||||||
|
<td className="px-3 py-2.5 text-right font-mono text-xs" style={{ color: TEXT_2 }}>{shortTime(r.assigntime) || '—'}</td>
|
||||||
|
<td className="px-3 py-2.5 text-right font-mono text-xs" style={{ color: TEXT_2 }}>{fstr(r.deliverytime) ? shortTime(r.deliverytime) : '—'}</td>
|
||||||
|
<td className="px-3 py-2.5 text-right">{fnum(r.kms) ? <MetricPill color="#ef4444" minWidth={52}>{fnum(r.kms).toFixed(1)}</MetricPill> : <span style={{ color: TEXT_3 }}>—</span>}</td>
|
||||||
|
<td className="px-3 py-2.5 text-right">{charge > 0 ? <MetricPill color="#10b981" minWidth={64}>₹{charge.toLocaleString('en-IN')}</MetricPill> : <span style={{ color: TEXT_3 }}>—</span>}</td>
|
||||||
|
<td className="px-3 py-2.5 text-right"><StatusChip label={st || '—'} color={statusColor(DELIVERY_STATUS, st)} /></td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableShell>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Total bar (gradient) ─────────────────────────────────────────────────────────
|
||||||
|
function TotalBar({ chips, grand }: { chips: Array<{ label: string; color: string }>; grand?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2 px-4 py-3 border-t" style={{ borderColor: BORDER, background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)` }}>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{chips.map((c, i) => (
|
||||||
|
<span key={i} className="inline-flex items-center rounded-full font-bold" style={{ padding: '3px 10px', fontSize: 11.5, background: soft(c.color), color: c.color, border: `1px solid ${edge(c.color)}` }}>{c.label}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{grand && (
|
||||||
|
<span className="inline-flex items-center rounded-full font-extrabold text-white" style={{ padding: '4px 12px', fontSize: 13, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 16px ${ring(BRAND)}` }}>{grand}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
313
src/components/DispatchMap.tsx
Normal file
313
src/components/DispatchMap.tsx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leaflet map for the Dispatch cockpit — the live route map that replaces the
|
||||||
|
* earlier gated placeholder. Plots each delivery's drop point as a numbered pin
|
||||||
|
* (coloured per rider) and, when a rider/zone is focused, draws the planned stop
|
||||||
|
* sequence as a dashed polyline. Uses OpenStreetMap tiles via react-leaflet.
|
||||||
|
*
|
||||||
|
* Coordinates come from the delivery rows (droplat/droplon, falling back to
|
||||||
|
* deliverylat/deliverylong). Rows without coordinates are simply not plotted.
|
||||||
|
* (Road-snapped routing + live rider GPS remain backend work — this draws the
|
||||||
|
* planned order with straight segments, not fabricated GPS traces.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { MapContainer, TileLayer, Marker, Polyline, useMap } from 'react-leaflet';
|
||||||
|
import { Bike, Mailbox, Utensils, MapPin, Map as MapIcon, Ruler, X } from 'lucide-react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
export interface MapPoint {
|
||||||
|
id: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
step: number;
|
||||||
|
color: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
status: string;
|
||||||
|
/** Full delivery row, for the rich click popup. */
|
||||||
|
raw: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Popup helpers (replicated from nearle_console renderOrderPopupContent) ────────
|
||||||
|
const STATUS_HEX: Record<string, string> = {
|
||||||
|
created: '#0ea5e9', processing: '#0ea5e9', pending: '#f59e0b', accepted: '#6366f1',
|
||||||
|
arrived: '#06b6d4', picked: '#8b5cf6', active: '#14b8a6', skipped: '#f97316',
|
||||||
|
delivered: '#22c55e', cancelled: '#ef4444',
|
||||||
|
};
|
||||||
|
function statusStyle(s: string) {
|
||||||
|
const k = String(s || '').toLowerCase();
|
||||||
|
const hex = STATUS_HEX[k] || '#64748b';
|
||||||
|
return { bg: `${hex}1f`, fg: hex, label: k ? k.charAt(0).toUpperCase() + k.slice(1) : '—' };
|
||||||
|
}
|
||||||
|
function fmtTime(raw: unknown): string {
|
||||||
|
const m = String(raw ?? '').match(/(\d{1,2}):(\d{2})/);
|
||||||
|
return m ? `${m[1]}:${m[2]}` : '';
|
||||||
|
}
|
||||||
|
const POPUP_TIMELINE: Array<{ key: string; label: string; final?: boolean }> = [
|
||||||
|
{ key: 'assigntime', label: 'Assigned' },
|
||||||
|
{ key: 'acceptedtime', label: 'Accepted' },
|
||||||
|
{ key: 'arrivaltime', label: 'Arrived' },
|
||||||
|
{ key: 'pickuptime', label: 'Pickup' },
|
||||||
|
{ key: 'starttime', label: 'Started' },
|
||||||
|
{ key: 'deliverytime', label: 'Delivered', final: true },
|
||||||
|
];
|
||||||
|
const S = (v: unknown) => (v == null ? '' : String(v));
|
||||||
|
|
||||||
|
function PuDetail({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="pu-detail">
|
||||||
|
<div className="pu-detail-icon">{icon}</div>
|
||||||
|
<div className="pu-detail-body">
|
||||||
|
<div className="pu-detail-label">{label}</div>
|
||||||
|
<div className="pu-detail-value" title={value}>{value}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The centered click popup — same structure/classes as the source console. */
|
||||||
|
function OrderPopup({ o, onClose }: { o: Record<string, unknown>; onClose: () => void }) {
|
||||||
|
const st = S(o.orderstatus).toLowerCase();
|
||||||
|
const ss = statusStyle(st);
|
||||||
|
const rider = S(o.rider_name) || S(o.ridername) || S(o.username) || 'Unassigned';
|
||||||
|
const customer = S(o.deliverycustomer) || S(o.customername);
|
||||||
|
const pickup = S(o.pickupcustomer) || S(o.locationname) || S(o.pickuplocation);
|
||||||
|
const drop = S(o.deliverysuburb) || S(o.deliveryaddress);
|
||||||
|
const zone = S(o.zone_name);
|
||||||
|
const riderId = o.rider_id || o.userid;
|
||||||
|
const kms = o.kms;
|
||||||
|
const actual = o.actualkms ?? o.cumulativekms;
|
||||||
|
const hasTimeline = POPUP_TIMELINE.some((t) => fmtTime(o[t.key]));
|
||||||
|
const hasDistance = (kms != null && kms !== '') || (actual != null && Number(actual) > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dispatch-popup-center" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
|
<div className="dispatch-popup-card dispatch-popup">
|
||||||
|
<button className="dispatch-popup-center-close" onClick={onClose} aria-label="Close">
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
<div style={{ height: '100%', width: '100%' }}>
|
||||||
|
<div className="pu-header">
|
||||||
|
<div className="pu-header-top">
|
||||||
|
<div className="pu-id">ORDER #{S(o.orderid) || S(o.deliveryid)}</div>
|
||||||
|
{st && <span className="pu-status-chip" style={{ background: ss.bg, color: ss.fg }}>{ss.label}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="pu-rider"><Bike size={13} /> <span>{rider}</span></div>
|
||||||
|
{customer && <div className="pu-customer" title={customer}><Mailbox size={13} /><span>{customer}</span></div>}
|
||||||
|
{o.deliveryid != null && <div className="pu-delivery-id">Delivery #{S(o.deliveryid)}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pu-body">
|
||||||
|
{hasTimeline && (
|
||||||
|
<div className="pu-section">
|
||||||
|
<div className="pu-section-label">Timeline</div>
|
||||||
|
<div className="pu-timeline">
|
||||||
|
{POPUP_TIMELINE.map((t) => {
|
||||||
|
const time = fmtTime(o[t.key]);
|
||||||
|
if (!time) return null;
|
||||||
|
return (
|
||||||
|
<div key={t.key} className={`pu-tl-row ${t.final ? 'delivered' : ''}`}>
|
||||||
|
<span className="pu-tl-dot" />
|
||||||
|
<span className="pu-tl-label">{t.label}</span>
|
||||||
|
<span className="pu-tl-time">{time}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pu-section">
|
||||||
|
<div className="pu-section-label">Details</div>
|
||||||
|
<div className="pu-details-grid">
|
||||||
|
{pickup && <PuDetail icon={<Utensils size={13} />} label="Pickup" value={pickup} />}
|
||||||
|
{drop && <PuDetail icon={<MapPin size={13} />} label="Drop" value={drop} />}
|
||||||
|
{zone && <PuDetail icon={<MapIcon size={13} />} label="Zone" value={zone} />}
|
||||||
|
{riderId ? <PuDetail icon={<Bike size={13} />} label="Rider ID" value={`#${S(riderId)}`} /> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasDistance && (
|
||||||
|
<div className="pu-distance-row">
|
||||||
|
{kms != null && kms !== '' && (
|
||||||
|
<div className="pu-distance-chip">
|
||||||
|
<span className="pu-distance-icon"><Ruler size={12} /></span>
|
||||||
|
<span className="pu-distance-label">Planned</span>
|
||||||
|
<span className="pu-distance-value">{S(kms)} km</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{actual != null && Number(actual) > 0 && (
|
||||||
|
<div className="pu-distance-chip">
|
||||||
|
<span className="pu-distance-icon"><Ruler size={12} /></span>
|
||||||
|
<span className="pu-distance-label">Actual</span>
|
||||||
|
<span className="pu-distance-value">{Number(actual).toFixed(2)} km</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const COIMBATORE: [number, number] = [11.0168, 76.9558];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Road-following route through the ordered waypoints via the public OSRM service.
|
||||||
|
* Returns the snapped geometry as [lat, lon][] (Leaflet order), or null on failure
|
||||||
|
* (caller then falls back to straight segments).
|
||||||
|
*/
|
||||||
|
async function fetchRoadRoute(waypoints: Array<[number, number]>, signal: AbortSignal): Promise<Array<[number, number]> | null> {
|
||||||
|
if (waypoints.length < 2) return null;
|
||||||
|
const path = waypoints.map(([la, lo]) => `${lo},${la}`).join(';'); // OSRM wants lon,lat
|
||||||
|
const url = `https://router.project-osrm.org/route/v1/driving/${path}?overview=full&geometries=geojson`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { signal });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
const coords = data?.routes?.[0]?.geometry?.coordinates;
|
||||||
|
if (!Array.isArray(coords)) return null;
|
||||||
|
return coords.map((c: [number, number]) => [c[1], c[0]] as [number, number]); // → lat,lon
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Square hub/pickup marker. */
|
||||||
|
function hubIcon(): L.DivIcon {
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'dispatch-hub',
|
||||||
|
html: `<div style="width:26px;height:26px;border-radius:7px;background:#0f172a;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:11px;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,0.35)">H</div>`,
|
||||||
|
iconSize: [26, 26],
|
||||||
|
iconAnchor: [13, 13],
|
||||||
|
popupAnchor: [0, -13],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Numbered circular pin coloured to the point's rider/route. */
|
||||||
|
function pinIcon(step: number, color: string, dim: boolean): L.DivIcon {
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'dispatch-pin',
|
||||||
|
html: `<div style="width:28px;height:28px;border-radius:50%;background:${color};opacity:${dim ? 0.6 : 1};color:#fff;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:11px;border:2px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,0.35)">${step}</div>`,
|
||||||
|
iconSize: [28, 28],
|
||||||
|
iconAnchor: [14, 14],
|
||||||
|
popupAnchor: [0, -14],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fit the view to the plotted points; invalidate size when the layout changes. */
|
||||||
|
function MapController({ points, resizeKey }: { points: MapPoint[]; resizeKey: unknown }) {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
if (points.length === 0) return;
|
||||||
|
if (points.length === 1) {
|
||||||
|
map.setView([points[0].lat, points[0].lon], 14);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bounds = L.latLngBounds(points.map((p) => [p.lat, p.lon] as [number, number]));
|
||||||
|
map.fitBounds(bounds, { padding: [48, 48], maxZoom: 15 });
|
||||||
|
}, [points, map]);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => map.invalidateSize(), 220);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [resizeKey, map]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DispatchMap({
|
||||||
|
points,
|
||||||
|
route,
|
||||||
|
routeColor = '#581c87',
|
||||||
|
start,
|
||||||
|
resizeKey,
|
||||||
|
animateNonce = 0,
|
||||||
|
}: {
|
||||||
|
points: MapPoint[];
|
||||||
|
route?: boolean;
|
||||||
|
routeColor?: string;
|
||||||
|
/** Optional pickup/hub the route starts from. */
|
||||||
|
start?: [number, number] | null;
|
||||||
|
resizeKey?: unknown;
|
||||||
|
/** Increment to (re)play the route-draw animation. */
|
||||||
|
animateNonce?: number;
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = useState<Record<string, unknown> | null>(null);
|
||||||
|
// Straight segments through the ordered stops (hub → drops), used as the
|
||||||
|
// fallback and shown instantly while the road route is fetched.
|
||||||
|
const straight: Array<[number, number]> = [
|
||||||
|
...(start ? [start] : []),
|
||||||
|
...points.map((p) => [p.lat, p.lon] as [number, number]),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Road-snapped geometry from OSRM (null until it resolves / on failure).
|
||||||
|
const [roadLine, setRoadLine] = useState<Array<[number, number]> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!route || straight.length < 2) { setRoadLine(null); return; }
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
fetchRoadRoute(straight, ctrl.signal).then((line) => setRoadLine(line));
|
||||||
|
return () => ctrl.abort();
|
||||||
|
// Re-fetch when the ordered coordinate set changes.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [route, JSON.stringify(straight)]);
|
||||||
|
|
||||||
|
const line = roadLine ?? straight;
|
||||||
|
|
||||||
|
// Route-draw animation: reveal the line progressively when animateNonce changes.
|
||||||
|
const [drawn, setDrawn] = useState<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!animateNonce || !route || line.length < 2) return;
|
||||||
|
let raf = 0;
|
||||||
|
const t0 = performance.now();
|
||||||
|
const dur = 2200;
|
||||||
|
const total = line.length;
|
||||||
|
const tick = (t: number) => {
|
||||||
|
const p = Math.min(1, (t - t0) / dur);
|
||||||
|
setDrawn(Math.max(2, Math.floor(p * total)));
|
||||||
|
if (p < 1) raf = requestAnimationFrame(tick);
|
||||||
|
else setDrawn(null);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
return () => { cancelAnimationFrame(raf); setDrawn(null); };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [animateNonce]);
|
||||||
|
const shownLine = drawn == null ? line : line.slice(0, drawn);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', height: '100%', width: '100%' }}>
|
||||||
|
<MapContainer center={COIMBATORE} zoom={12} scrollWheelZoom style={{ height: '100%', width: '100%' }} zoomControl>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
{route && shownLine.length > 1 && (
|
||||||
|
<Polyline
|
||||||
|
positions={shownLine}
|
||||||
|
pathOptions={{ color: routeColor, weight: 4, opacity: 0.85, dashArray: roadLine ? undefined : '6 8' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{route && start && (
|
||||||
|
<Marker position={start} icon={hubIcon()} eventHandlers={{ click: () => setSelected({ orderid: 'PICKUP', deliverycustomer: 'Pickup hub', pickupcustomer: 'Ragul Stores Hub' }) }} />
|
||||||
|
)}
|
||||||
|
{points.map((p) => (
|
||||||
|
<Marker
|
||||||
|
key={p.id}
|
||||||
|
position={[p.lat, p.lon]}
|
||||||
|
icon={pinIcon(p.step, p.color, p.status.toLowerCase() === 'cancelled')}
|
||||||
|
eventHandlers={{ click: () => setSelected(p.raw) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<MapController points={points} resizeKey={resizeKey} />
|
||||||
|
</MapContainer>
|
||||||
|
|
||||||
|
{selected && <OrderPopup o={selected} onClose={() => setSelected(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10923
src/components/DispatchView.css
Normal file
10923
src/components/DispatchView.css
Normal file
File diff suppressed because it is too large
Load Diff
727
src/components/DispatchView.tsx
Normal file
727
src/components/DispatchView.tsx
Normal file
@@ -0,0 +1,727 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch page — a faithful port of the operations console's dispatch cockpit
|
||||||
|
* (nearle_console/dispatch). It reuses that page's actual stylesheet verbatim
|
||||||
|
* (`./DispatchView.css`, copied from Dispatch.css) and reproduces the same DOM /
|
||||||
|
* class structure: the `#hdr` bar, `#strat-row` view tabs, `#batch-row` wave
|
||||||
|
* selector, the 400px `#sidebar` (RIDER DISPATCH header + KPI tiles + rider/zone
|
||||||
|
* cards + per-trip order cards), and the `#map-wrap` centrepiece.
|
||||||
|
*
|
||||||
|
* The source map is a Leaflet canvas of planned-vs-actual rider routes (OSRM
|
||||||
|
* road-snapping, Kalman-smoothed GPS) plus AI rider-assignment posting to
|
||||||
|
* external optimisation services. Those need a mapping stack + dispatch backends
|
||||||
|
* this tenant doesn't expose, so the `#map-wrap` plots the real planned stop
|
||||||
|
* order and marks the live-GPS / compare / AI-assign layers as awaiting backend —
|
||||||
|
* no fabricated telemetry. Everything else is driven by the live Fiesta feed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Map as MapIcon,
|
||||||
|
MapPin,
|
||||||
|
Bike,
|
||||||
|
Globe,
|
||||||
|
Info,
|
||||||
|
Package,
|
||||||
|
Ruler,
|
||||||
|
Wallet,
|
||||||
|
Crosshair,
|
||||||
|
Clock,
|
||||||
|
Utensils,
|
||||||
|
Mailbox,
|
||||||
|
StickyNote,
|
||||||
|
ArrowLeftRight,
|
||||||
|
Calendar,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
List,
|
||||||
|
Play,
|
||||||
|
PlugZap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useFiestaDeliveries, useFiestaRiders } from '../services/fiestaQueries';
|
||||||
|
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||||
|
import { MOCK_DELIVERIES, MOCK_RIDERS } from '../services/dispatchMockData';
|
||||||
|
import DispatchMap, { type MapPoint } from './DispatchMap';
|
||||||
|
import './DispatchView.css';
|
||||||
|
|
||||||
|
// ── Status colours (match the console palette) ───────────────────────────────────
|
||||||
|
const STATUS_HEX: Record<string, string> = {
|
||||||
|
pending: '#f59e0b',
|
||||||
|
accepted: '#6366f1',
|
||||||
|
arrived: '#06b6d4',
|
||||||
|
picked: '#8b5cf6',
|
||||||
|
active: '#14b8a6',
|
||||||
|
skipped: '#f97316',
|
||||||
|
delivered: '#22c55e',
|
||||||
|
cancelled: '#ef4444',
|
||||||
|
};
|
||||||
|
function statusStyle(s: string): React.CSSProperties {
|
||||||
|
const hex = STATUS_HEX[s.toLowerCase()] || '#64748b';
|
||||||
|
return { background: `${hex}1f`, color: hex };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable rider/zone colour.
|
||||||
|
const COLORS = ['#3b82f6', '#a855f7', '#10b981', '#f59e0b', '#ef4444', '#6366f1', '#14b8a6', '#ec4899', '#f97316', '#06b6d4'];
|
||||||
|
function colorFor(key: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < key.length; i++) hash = key.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
return COLORS[Math.abs(hash) % COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop coordinates from a delivery row (several field spellings), or null. */
|
||||||
|
function dropLatLon(r: Row): [number, number] | null {
|
||||||
|
const lat = fnum(r.droplat) || fnum(r.deliverylat) || fnum(r.deliverylatitude);
|
||||||
|
const lon = fnum(r.droplon) || fnum(r.deliverylong) || fnum(r.deliverylon) || fnum(r.deliverylongitude);
|
||||||
|
return lat && lon ? [lat, lon] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pickup/hub coordinates from a delivery row, or null. */
|
||||||
|
function pickupLatLon(r: Row): [number, number] | null {
|
||||||
|
const lat = fnum(r.pickuplat) || fnum(r.pickuplatitude);
|
||||||
|
const lon = fnum(r.pickuplong) || fnum(r.picklongitude) || fnum(r.pickuplon);
|
||||||
|
return lat && lon ? [lat, lon] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Batch / wave model (canonical half-open hour ranges, local time) ─────────────
|
||||||
|
// Mirrors Dispatch.js BATCH_OPTIONS: gaps (8–9, 12:30–16, after 19) are intentional.
|
||||||
|
type BatchId = 'all' | 'morning' | 'afternoon' | 'evening';
|
||||||
|
const BATCHES: Array<{ id: BatchId; label: string; range: string }> = [
|
||||||
|
{ id: 'all', label: 'All', range: 'Full day' },
|
||||||
|
{ id: 'morning', label: 'Morning', range: '12 AM – 8 AM' },
|
||||||
|
{ id: 'afternoon', label: 'Afternoon', range: '9 AM – 12:30 PM' },
|
||||||
|
{ id: 'evening', label: 'Evening', range: '4 PM – 7 PM' },
|
||||||
|
];
|
||||||
|
function rowHourFrac(r: Row): number | null {
|
||||||
|
const raw = fstr(r.assigntime) || fstr(r.deliverytime) || fstr(r.deliverydate);
|
||||||
|
const m = raw.match(/[ T](\d{1,2}):(\d{2})/);
|
||||||
|
if (!m) return null;
|
||||||
|
return Number(m[1]) + Number(m[2]) / 60;
|
||||||
|
}
|
||||||
|
function inBatch(r: Row, b: BatchId): boolean {
|
||||||
|
if (b === 'all') return true;
|
||||||
|
const h = rowHourFrac(r);
|
||||||
|
if (h == null) return false;
|
||||||
|
if (b === 'morning') return h >= 0 && h < 8;
|
||||||
|
if (b === 'afternoon') return h >= 9 && h < 12.5;
|
||||||
|
return h >= 16 && h < 19; // evening
|
||||||
|
}
|
||||||
|
function initialBatch(): BatchId {
|
||||||
|
const h = new Date().getHours();
|
||||||
|
if (h >= 0 && h < 8) return 'morning';
|
||||||
|
if (h >= 9 && h < 12.5) return 'afternoon';
|
||||||
|
if (h >= 16 && h < 19) return 'evening';
|
||||||
|
return 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View modes (match #strat-row tabs) ───────────────────────────────────────────
|
||||||
|
type ViewMode = 'kitchens' | 'zones' | 'riders' | 'all' | 'rider-info';
|
||||||
|
const VIEW_TABS: Array<{ id: ViewMode; label: string; icon: typeof MapIcon }> = [
|
||||||
|
{ id: 'kitchens', label: 'By Location', icon: MapPin },
|
||||||
|
{ id: 'zones', label: 'By Zone', icon: MapIcon },
|
||||||
|
{ id: 'riders', label: 'By Rider', icon: Bike },
|
||||||
|
{ id: 'all', label: 'All Routes', icon: Globe },
|
||||||
|
{ id: 'rider-info', label: 'Rider Info', icon: Info },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
orders: Row[];
|
||||||
|
delivered: number;
|
||||||
|
totalKm: number;
|
||||||
|
profit: number;
|
||||||
|
riders: Set<string>;
|
||||||
|
suburbs: Map<string, number>;
|
||||||
|
statusCounts: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DispatchViewProps {
|
||||||
|
locationid?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
|
export default function DispatchView({ locationid }: DispatchViewProps) {
|
||||||
|
const today = new Date();
|
||||||
|
const [date, setDate] = useState<string>(ymd(today));
|
||||||
|
const [batch, setBatch] = useState<BatchId>(initialBatch());
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('riders');
|
||||||
|
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
|
const [tripSort, setTripSort] = useState<'planned' | 'time'>('planned');
|
||||||
|
const [animateNonce, setAnimateNonce] = useState(0);
|
||||||
|
const [animating, setAnimating] = useState(false);
|
||||||
|
|
||||||
|
const deliveriesQ = useFiestaDeliveries({ tenantid: FIESTA_TENANT_ID, fromdate: date, todate: date });
|
||||||
|
const ridersQ = useFiestaRiders({ tenantid: FIESTA_TENANT_ID });
|
||||||
|
|
||||||
|
// Sample-data fallback: when the live feed returns nothing, render the demo set
|
||||||
|
// so the cockpit isn't blank. The header labels it "Sample data" so it's never
|
||||||
|
// mistaken for live (see services/dispatchMockData.ts).
|
||||||
|
const liveRows = deliveriesQ.data ?? [];
|
||||||
|
const usingMock = !deliveriesQ.isLoading && !deliveriesQ.isError && liveRows.length === 0;
|
||||||
|
const allRows = usingMock ? MOCK_DELIVERIES : liveRows;
|
||||||
|
// Sample rows aren't tied to the signed-in store, so skip the outlet filter for them.
|
||||||
|
const inScope = (r: Row) => usingMock || !locationid || fnum(r.locationid) === locationid;
|
||||||
|
|
||||||
|
const rows = useMemo(
|
||||||
|
() => allRows.filter((r) => inScope(r) && inBatch(r, batch)),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[allRows, batch, locationid, usingMock],
|
||||||
|
);
|
||||||
|
|
||||||
|
const batchCounts = useMemo(() => {
|
||||||
|
const acc: Record<string, number> = { all: 0, morning: 0, afternoon: 0, evening: 0 };
|
||||||
|
const scoped = allRows.filter(inScope);
|
||||||
|
for (const b of BATCHES) acc[b.id] = scoped.filter((r) => inBatch(r, b.id)).length;
|
||||||
|
return acc;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [allRows, locationid, usingMock]);
|
||||||
|
|
||||||
|
// ── Grouping ────────────────────────────────────────────────────────────────
|
||||||
|
const groups = useMemo<Group[]>(() => {
|
||||||
|
const map = new Map<string, Group>();
|
||||||
|
const keyOf = (r: Row): { id: string; name: string } => {
|
||||||
|
if (viewMode === 'riders' || viewMode === 'rider-info') {
|
||||||
|
const id = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
|
||||||
|
return { id, name: fstr(r.ridername) || fstr(r.username) || (id === 'unassigned' ? 'Unassigned' : `Rider ${id}`) };
|
||||||
|
}
|
||||||
|
if (viewMode === 'kitchens') {
|
||||||
|
const name = fstr(r.pickupcustomer) || fstr(r.pickuplocation) || 'Pickup';
|
||||||
|
return { id: name.toLowerCase(), name };
|
||||||
|
}
|
||||||
|
if (viewMode === 'all') return { id: 'all', name: 'All Routes' };
|
||||||
|
const name = fstr(r.deliverysuburb) || fstr(r.zone_name) || 'Unzoned';
|
||||||
|
return { id: name.toLowerCase(), name };
|
||||||
|
};
|
||||||
|
for (const r of rows) {
|
||||||
|
const { id, name } = keyOf(r);
|
||||||
|
let g = map.get(id);
|
||||||
|
if (!g) {
|
||||||
|
g = { id, name, color: colorFor(id), orders: [], delivered: 0, totalKm: 0, profit: 0, riders: new Set(), suburbs: new Map(), statusCounts: {} };
|
||||||
|
map.set(id, g);
|
||||||
|
}
|
||||||
|
g.orders.push(r);
|
||||||
|
const st = fstr(r.orderstatus).toLowerCase();
|
||||||
|
if (st === 'delivered') g.delivered += 1;
|
||||||
|
g.statusCounts[st] = (g.statusCounts[st] ?? 0) + 1;
|
||||||
|
g.totalKm += fnum(r.kms);
|
||||||
|
g.profit += fnum(r.profit);
|
||||||
|
const rid = fstr(r.userid) || fstr(r.ridername);
|
||||||
|
if (rid) g.riders.add(rid);
|
||||||
|
const sub = fstr(r.deliverysuburb);
|
||||||
|
if (sub) g.suburbs.set(sub, (g.suburbs.get(sub) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return Array.from(map.values()).sort((a, b) => b.orders.length - a.orders.length);
|
||||||
|
}, [rows, viewMode]);
|
||||||
|
|
||||||
|
const focused = groups.find((g) => g.id === focusedId) ?? null;
|
||||||
|
const groupedByRider = viewMode === 'zones' || viewMode === 'kitchens' || viewMode === 'all';
|
||||||
|
|
||||||
|
// Trip blocks for the focused group: by trip# (rider view) or by rider (zone/all view).
|
||||||
|
const tripBlocks = useMemo(() => {
|
||||||
|
if (!focused) return [];
|
||||||
|
const map = new Map<string, { label: string; color: string; orders: Row[] }>();
|
||||||
|
for (const r of focused.orders) {
|
||||||
|
let key: string;
|
||||||
|
let label: string;
|
||||||
|
let color: string;
|
||||||
|
if (groupedByRider) {
|
||||||
|
const rid = fstr(r.userid) || fstr(r.ridername) || 'unassigned';
|
||||||
|
key = rid;
|
||||||
|
label = fstr(r.ridername) || fstr(r.username) || (rid === 'unassigned' ? 'Unassigned' : `Rider ${rid}`);
|
||||||
|
color = colorFor(rid);
|
||||||
|
} else {
|
||||||
|
key = fstr(r.trip_number) || '1';
|
||||||
|
label = `Trip ${key}`;
|
||||||
|
color = focused.color;
|
||||||
|
}
|
||||||
|
let blk = map.get(key);
|
||||||
|
if (!blk) { blk = { label, color, orders: [] }; map.set(key, blk); }
|
||||||
|
blk.orders.push(r);
|
||||||
|
}
|
||||||
|
const blocks = Array.from(map.values());
|
||||||
|
for (const blk of blocks) {
|
||||||
|
blk.orders.sort((a, b) => {
|
||||||
|
if (tripSort === 'time') {
|
||||||
|
const ta = fstr(a.deliverytime) || fstr(a.expecteddeliverytime);
|
||||||
|
const tb = fstr(b.deliverytime) || fstr(b.expecteddeliverytime);
|
||||||
|
return ta.localeCompare(tb);
|
||||||
|
}
|
||||||
|
const sa = fnum(a.step);
|
||||||
|
const sb = fnum(b.step);
|
||||||
|
if (sa && sb && sa !== sb) return sa - sb;
|
||||||
|
return fstr(a.assigntime).localeCompare(fstr(b.assigntime));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}, [focused, groupedByRider, tripSort]);
|
||||||
|
|
||||||
|
// Map points: the focused group's ordered stops (with a route), else every stop
|
||||||
|
// in the wave (coloured per rider). Rows without coordinates are skipped.
|
||||||
|
const mapPoints = useMemo<MapPoint[]>(() => {
|
||||||
|
const src = focused ? tripBlocks.flatMap((b) => b.orders) : rows;
|
||||||
|
const out: MapPoint[] = [];
|
||||||
|
src.forEach((r, i) => {
|
||||||
|
const ll = dropLatLon(r);
|
||||||
|
if (!ll) return;
|
||||||
|
out.push({
|
||||||
|
id: fstr(r.deliveryid) || fstr(r.orderid) || String(i),
|
||||||
|
lat: ll[0],
|
||||||
|
lon: ll[1],
|
||||||
|
step: fnum(r.step) || i + 1,
|
||||||
|
color: focused ? focused.color : colorFor(fstr(r.userid) || fstr(r.ridername) || 'x'),
|
||||||
|
title: fstr(r.deliverycustomer) || `Order ${fstr(r.orderid)}`,
|
||||||
|
subtitle: fstr(r.deliverysuburb) || fstr(r.deliveryaddress),
|
||||||
|
status: fstr(r.orderstatus),
|
||||||
|
raw: r,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}, [focused, tripBlocks, rows]);
|
||||||
|
|
||||||
|
// Route start = the focused group's pickup/hub (so the road route originates there).
|
||||||
|
const firstOrder = tripBlocks[0]?.orders[0] ?? focused?.orders[0];
|
||||||
|
const routeStart = focused && firstOrder ? pickupLatLon(firstOrder) : null;
|
||||||
|
|
||||||
|
// KPI scope.
|
||||||
|
const totalOrders = rows.length;
|
||||||
|
const activeRiders = new Set(rows.map((r) => fstr(r.userid) || fstr(r.ridername)).filter(Boolean)).size;
|
||||||
|
const fleetSize = usingMock ? MOCK_RIDERS.length : (ridersQ.data ?? []).length;
|
||||||
|
const scopeLabel = BATCHES.find((b) => b.id === batch)?.label ?? 'All';
|
||||||
|
|
||||||
|
// Date chip helpers.
|
||||||
|
const isToday = date === ymd(today);
|
||||||
|
const dateObj = new Date(`${date}T00:00:00`);
|
||||||
|
const prettyDate = `${WEEKDAYS[dateObj.getDay()]}, ${dateObj.getDate()} ${MONTHS[dateObj.getMonth()]}`;
|
||||||
|
const shiftDate = (delta: number) => {
|
||||||
|
const d = new Date(`${date}T00:00:00`);
|
||||||
|
d.setDate(d.getDate() + delta);
|
||||||
|
if (d > today) return;
|
||||||
|
setDate(ymd(d));
|
||||||
|
setFocusedId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtTime = (raw: unknown): string => {
|
||||||
|
const m = fstr(raw).match(/(\d{1,2}):(\d{2})/);
|
||||||
|
return m ? `${m[1]}:${m[2]}` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', minHeight: 0 }}>
|
||||||
|
<div className="dispatch-container embedded">
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div id="hdr">
|
||||||
|
<div className="logo">
|
||||||
|
<div className="logo-badge">D</div>
|
||||||
|
<div className="logo-name">Dispatch</div>
|
||||||
|
<div className="logo-city-wrap">
|
||||||
|
<span className="logo-city" style={{ cursor: 'default' }}>
|
||||||
|
<MapPin size={13} />
|
||||||
|
<span className="logo-city-text">Coimbatore</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hdr-stats">
|
||||||
|
{deliveriesQ.isLoading ? (
|
||||||
|
<span className="live-status">
|
||||||
|
<span className="live-dot" /> Syncing
|
||||||
|
</span>
|
||||||
|
) : deliveriesQ.isError ? (
|
||||||
|
<span className="live-status live-status-error">
|
||||||
|
<span className="live-dot error" /> Offline
|
||||||
|
</span>
|
||||||
|
) : usingMock ? (
|
||||||
|
<span className="live-status" title="No live deliveries for this day — showing sample data">
|
||||||
|
<span className="live-dot" style={{ background: '#f59e0b' }} /> Sample data · {totalOrders} orders
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="live-status live-status-ready">
|
||||||
|
<span className="live-dot ready" /> Live · {totalOrders} orders
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`date-chip${isToday ? ' is-today' : ''}`}>
|
||||||
|
<button className="date-chip-nav" onClick={() => shiftDate(-1)} title="Previous day">
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<div className="date-chip-main" style={{ position: 'relative' }}>
|
||||||
|
<span className="date-chip-icon"><Calendar size={14} /></span>
|
||||||
|
<span className="date-chip-text">
|
||||||
|
<span className="date-chip-label">
|
||||||
|
Date {isToday && <span className="date-chip-today-pill">Today</span>}
|
||||||
|
</span>
|
||||||
|
<span className="date-chip-value">{prettyDate}</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
max={ymd(today)}
|
||||||
|
onChange={(e) => { setDate(e.target.value); setFocusedId(null); }}
|
||||||
|
style={{ position: 'absolute', inset: 0, opacity: 0, cursor: 'pointer', width: '100%', height: '100%' }}
|
||||||
|
aria-label="Pick date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="date-chip-nav" onClick={() => shiftDate(1)} disabled={isToday} title="Next day">
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── View-mode tabs ── */}
|
||||||
|
<div id="strat-row">
|
||||||
|
{VIEW_TABS.map((t) => {
|
||||||
|
const Icon = t.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
className={`sbt ${viewMode === t.id ? 'active' : ''}${t.id === 'rider-info' ? ' sbt-rider-info' : ''}`}
|
||||||
|
onClick={() => { setViewMode(t.id); setFocusedId(null); }}
|
||||||
|
>
|
||||||
|
<span className="sbt-icon"><Icon size={15} /></span>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Batch / wave bar ── */}
|
||||||
|
<div id="batch-row">
|
||||||
|
<span className="batch-label">Batch</span>
|
||||||
|
<div className="batch-scroll">
|
||||||
|
{BATCHES.map((b) => (
|
||||||
|
<button
|
||||||
|
key={b.id}
|
||||||
|
className={`batch-btn batch-slot ${batch === b.id ? 'active' : ''}`}
|
||||||
|
onClick={() => { setBatch(b.id); setFocusedId(null); }}
|
||||||
|
title={`${b.label} (${b.range})`}
|
||||||
|
>
|
||||||
|
<span className="batch-btn-label">{b.label}</span>
|
||||||
|
<span className="batch-btn-count">{batchCounts[b.id] ?? 0}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Body ── */}
|
||||||
|
<div id="body" className={sidebarCollapsed ? 'sidebar-collapsed' : ''}>
|
||||||
|
<button
|
||||||
|
className={`sidebar-toggle-tab${sidebarCollapsed ? ' is-collapsed' : ''}`}
|
||||||
|
onClick={() => setSidebarCollapsed((c) => !c)}
|
||||||
|
title={sidebarCollapsed ? 'Show panel' : 'Hide panel'}
|
||||||
|
>
|
||||||
|
{sidebarCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div id="sidebar">
|
||||||
|
<div className="sb-header">
|
||||||
|
<div className="sb-header-top">
|
||||||
|
<div className="sb-header-title">
|
||||||
|
<span className="sb-title-bar" aria-hidden="true" />
|
||||||
|
<span className="sb-title-text">RIDER DISPATCH</span>
|
||||||
|
</div>
|
||||||
|
<span className="sb-header-scope">
|
||||||
|
<span className="sb-scope-dot" />
|
||||||
|
{scopeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="sb-header-tiles">
|
||||||
|
<div className="sb-tile sb-tile-orders">
|
||||||
|
<span className="sb-tile-icon"><Package size={16} /></span>
|
||||||
|
<div className="sb-tile-body">
|
||||||
|
<div className="sb-tile-value">{totalOrders}</div>
|
||||||
|
<div className="sb-tile-label">Orders</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sb-tile sb-tile-riders">
|
||||||
|
<span className="sb-tile-icon"><Bike size={16} /></span>
|
||||||
|
<div className="sb-tile-body">
|
||||||
|
<div className="sb-tile-value">{activeRiders}{fleetSize ? `/${fleetSize}` : ''}</div>
|
||||||
|
<div className="sb-tile-label">Riders</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="riders-panel">
|
||||||
|
{deliveriesQ.isLoading ? (
|
||||||
|
<div className="ph">Loading dispatch feed…</div>
|
||||||
|
) : focused ? (
|
||||||
|
<FocusedDetail
|
||||||
|
focused={focused}
|
||||||
|
tripBlocks={tripBlocks}
|
||||||
|
groupedByRider={groupedByRider}
|
||||||
|
tripSort={tripSort}
|
||||||
|
setTripSort={setTripSort}
|
||||||
|
onBack={() => setFocusedId(null)}
|
||||||
|
fmtTime={fmtTime}
|
||||||
|
/>
|
||||||
|
) : groups.length === 0 ? (
|
||||||
|
<div className="ph">No deliveries in this wave</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="ph">
|
||||||
|
{viewMode === 'riders' || viewMode === 'rider-info' ? 'Riders' : viewMode === 'kitchens' ? 'Pickup points' : viewMode === 'all' ? 'All routes' : 'Zones'} ({groups.length})
|
||||||
|
</div>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<React.Fragment key={g.id}>
|
||||||
|
{viewMode === 'riders' || viewMode === 'rider-info'
|
||||||
|
? <RiderCard g={g} onClick={() => setFocusedId(g.id)} />
|
||||||
|
: <ZoneCard g={g} onClick={() => setFocusedId(g.id)} />}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map area */}
|
||||||
|
<div id="map-wrap">
|
||||||
|
{/* Live Leaflet route map */}
|
||||||
|
<DispatchMap
|
||||||
|
points={mapPoints}
|
||||||
|
route={Boolean(focused)}
|
||||||
|
routeColor={focused?.color || '#581c87'}
|
||||||
|
start={routeStart}
|
||||||
|
resizeKey={`${sidebarCollapsed}|${viewMode}|${focusedId}|${batch}`}
|
||||||
|
animateNonce={animateNonce}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Contextual note overlaid on the map */}
|
||||||
|
{viewMode === 'rider-info' ? (
|
||||||
|
<div className="dmp-overlay-note">
|
||||||
|
<PlugZap size={13} /> Live rider telemetry (battery · GPS · speed) awaiting backend — map shows planned drops.
|
||||||
|
</div>
|
||||||
|
) : mapPoints.length === 0 ? (
|
||||||
|
<div className="dmp-overlay-note">
|
||||||
|
<MapIcon size={13} /> No drop coordinates in this {focused ? 'route' : 'wave'} yet.
|
||||||
|
</div>
|
||||||
|
) : !focused ? (
|
||||||
|
<div className="dmp-overlay-note">
|
||||||
|
<MapIcon size={13} /> Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : 'rider'} to draw its route.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* bottom-right overlay controls (gated) */}
|
||||||
|
<div id="ov-br">
|
||||||
|
<button
|
||||||
|
className={`sbt ${animating ? 'active' : ''}`}
|
||||||
|
disabled={!focused || mapPoints.length < 2}
|
||||||
|
onClick={() => {
|
||||||
|
if (!focused || mapPoints.length < 2) return;
|
||||||
|
setAnimating(true);
|
||||||
|
setAnimateNonce((n) => n + 1);
|
||||||
|
window.setTimeout(() => setAnimating(false), 2300);
|
||||||
|
}}
|
||||||
|
title={focused ? 'Replay the route draw' : 'Select a rider to animate its route'}
|
||||||
|
>
|
||||||
|
<span className="sbt-icon"><Play size={14} /></span> {animating ? 'Animating…' : 'Animate Routes'}
|
||||||
|
</button>
|
||||||
|
<button className="sbt" disabled title="Planned-vs-actual compare needs rider GPS telemetry (awaiting backend)">
|
||||||
|
<span className="sbt-icon"><ArrowLeftRight size={14} /></span> Compare
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rider card ───────────────────────────────────────────────────────────────────
|
||||||
|
function RiderCard({ g, onClick }: { g: Group; onClick: () => void }) {
|
||||||
|
const total = g.orders.length;
|
||||||
|
const percent = total ? Math.round((g.delivered / total) * 100) : 0;
|
||||||
|
const isDone = total > 0 && g.delivered === total;
|
||||||
|
const zoneName = [...g.suburbs.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || 'Mixed';
|
||||||
|
const trips = new Set(g.orders.map((o) => fstr(o.trip_number) || '1')).size;
|
||||||
|
return (
|
||||||
|
<div className="rcard" onClick={onClick}>
|
||||||
|
<div className="rcard-top">
|
||||||
|
<div className="rcard-emo" style={{ background: `${g.color}18`, color: g.color }}>
|
||||||
|
<Bike size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="rcard-info">
|
||||||
|
<div className="rcard-name">{g.name}</div>
|
||||||
|
<div className="rcard-zone">{zoneName} · {trips} trip{trips > 1 ? 's' : ''}</div>
|
||||||
|
</div>
|
||||||
|
<div className={`rcard-badge ${isDone ? 'is-done' : ''}`}>{g.delivered}/{total}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bar-bg">
|
||||||
|
<div className="bar-fg" style={{ width: `${percent}%`, background: g.color }} />
|
||||||
|
</div>
|
||||||
|
<div className="rcard-meta">
|
||||||
|
<span><Ruler size={11} /> {g.totalKm.toFixed(1)} km</span>
|
||||||
|
{g.profit > 0 && <span><Wallet size={11} /> ₹{g.profit.toLocaleString('en-IN')}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="step-ids">
|
||||||
|
{g.orders.slice(0, 16).map((o, i) => (
|
||||||
|
<span key={fstr(o.orderid) || i} className="step-id">S{fnum(o.step) || i + 1}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zone card (also used for By Location / All Routes) ───────────────────────────
|
||||||
|
function ZoneCard({ g, onClick }: { g: Group; onClick: () => void }) {
|
||||||
|
const suburbs = [...g.suburbs.entries()].sort((a, b) => b[1] - a[1]).map(([s]) => s);
|
||||||
|
return (
|
||||||
|
<div className="rcard zone-card" onClick={onClick}>
|
||||||
|
<div className="zone-card-header">
|
||||||
|
<div className="zone-card-emoji" style={{ color: g.color }}><MapIcon size={16} /></div>
|
||||||
|
<div className="zone-card-titles">
|
||||||
|
<div className="zone-card-name">{g.name}</div>
|
||||||
|
<div className="zone-card-sub">{g.riders.size} rider{g.riders.size === 1 ? '' : 's'} · {g.orders.length} orders</div>
|
||||||
|
</div>
|
||||||
|
<span className="zone-card-arrow" aria-hidden="true">→</span>
|
||||||
|
</div>
|
||||||
|
{g.orders.length > 0 && (
|
||||||
|
<div className="zone-progress-row">
|
||||||
|
<div className="zone-status-bar">
|
||||||
|
{Object.entries(g.statusCounts).map(([s, c]) => (
|
||||||
|
<div key={s} className="zone-status-seg" style={{ flex: c, background: STATUS_HEX[s] || '#cbd5e1' }} title={`${s}: ${c}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="zone-progress-label">{g.delivered}/{g.orders.length}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="zone-stat-pills">
|
||||||
|
<span className="zone-stat-pill">
|
||||||
|
<span className="zone-stat-icon"><MapPin size={12} /></span>
|
||||||
|
<span className="zone-stat-value">{g.suburbs.size}</span>
|
||||||
|
<span className="zone-stat-label">areas</span>
|
||||||
|
</span>
|
||||||
|
<span className="zone-stat-pill">
|
||||||
|
<span className="zone-stat-icon"><Ruler size={12} /></span>
|
||||||
|
<span className="zone-stat-value">{g.totalKm.toFixed(0)}</span>
|
||||||
|
<span className="zone-stat-label">km</span>
|
||||||
|
</span>
|
||||||
|
{g.profit > 0 && (
|
||||||
|
<span className="zone-stat-pill">
|
||||||
|
<span className="zone-stat-icon"><Wallet size={12} /></span>
|
||||||
|
<span className="zone-stat-value">₹{g.profit.toLocaleString('en-IN')}</span>
|
||||||
|
<span className="zone-stat-label">profit</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{suburbs.length > 0 && (
|
||||||
|
<div className="zone-card-suburbs">
|
||||||
|
<span className="zone-card-suburbs-text">{suburbs.slice(0, 3).join(' · ')}</span>
|
||||||
|
{suburbs.length > 3 && <span className="zone-card-suburbs-more">+{suburbs.length - 3}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Focused detail (trip blocks + order cards) ───────────────────────────────────
|
||||||
|
function FocusedDetail({
|
||||||
|
focused,
|
||||||
|
tripBlocks,
|
||||||
|
groupedByRider,
|
||||||
|
tripSort,
|
||||||
|
setTripSort,
|
||||||
|
onBack,
|
||||||
|
fmtTime,
|
||||||
|
}: {
|
||||||
|
focused: Group;
|
||||||
|
tripBlocks: Array<{ label: string; color: string; orders: Row[] }>;
|
||||||
|
groupedByRider: boolean;
|
||||||
|
tripSort: 'planned' | 'time';
|
||||||
|
setTripSort: (v: 'planned' | 'time') => void;
|
||||||
|
onBack: () => void;
|
||||||
|
fmtTime: (raw: unknown) => string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className="sbt" onClick={onBack} style={{ marginBottom: 12 }}>
|
||||||
|
<span className="sbt-icon"><ChevronLeft size={15} /></span> Back to list
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{tripBlocks.map((blk, bi) => (
|
||||||
|
<div className="trip-block" key={bi}>
|
||||||
|
<div className="trip-header" style={{ background: `${blk.color}12`, borderColor: `${blk.color}40` }}>
|
||||||
|
<span className="th-badge" style={{ background: blk.color }}>{blk.label}</span>
|
||||||
|
<span className="trip-stats">
|
||||||
|
<span><MapPin size={11} /> {blk.orders.length} stops</span>
|
||||||
|
<span><Ruler size={11} /> {blk.orders.reduce((a, o) => a + fnum(o.kms), 0).toFixed(1)} km</span>
|
||||||
|
</span>
|
||||||
|
<div className="trip-sort-toggle" role="group">
|
||||||
|
<button className={`trip-sort-pill ${tripSort === 'planned' ? 'is-active' : ''}`} onClick={() => setTripSort('planned')}>
|
||||||
|
<List size={12} /> <span>Planned</span>
|
||||||
|
</button>
|
||||||
|
<button className={`trip-sort-pill ${tripSort === 'time' ? 'is-active' : ''}`} onClick={() => setTripSort('time')}>
|
||||||
|
<Clock size={12} /> <span>By time</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="zone-order-grid">
|
||||||
|
{blk.orders.map((o, i) => {
|
||||||
|
const st = fstr(o.orderstatus).toLowerCase();
|
||||||
|
const step = fnum(o.step) || i + 1;
|
||||||
|
const actual = fstr(o.deliverytime);
|
||||||
|
const expected = fstr(o.expecteddeliverytime);
|
||||||
|
const profit = fnum(o.profit);
|
||||||
|
const km = fnum(o.kms);
|
||||||
|
const charge = fnum(o.deliverycharge) || fnum(o.deliverycharges);
|
||||||
|
return (
|
||||||
|
<div className={`zone-order-card ${st === 'delivered' ? '' : 'is-pending-time'}`} key={fstr(o.deliveryid) || fstr(o.orderid) || i}>
|
||||||
|
<div className="zone-order-card-head">
|
||||||
|
<div className="zone-order-num" style={{ background: `${blk.color}15`, color: blk.color }}>{step}</div>
|
||||||
|
<div className="zone-order-id-block">
|
||||||
|
<div className="zone-order-id">Order #{fstr(o.orderid) || fstr(o.deliveryid)}</div>
|
||||||
|
{groupedByRider && fstr(o.ridername) && (
|
||||||
|
<div className="zone-order-rider"><Bike size={10} /> {fstr(o.ridername)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="zone-order-status-stack">
|
||||||
|
{st && <span className="zone-order-status" style={statusStyle(st)}>{st}</span>}
|
||||||
|
{(actual || expected) && (
|
||||||
|
<span className={`zone-order-time ${actual ? '' : 'is-expected'}`}>
|
||||||
|
<Clock size={10} /> {fmtTime(actual || expected)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="zone-order-customer"><Mailbox size={11} /> {fstr(o.deliverycustomer) || 'Customer'}</div>
|
||||||
|
{fstr(o.pickupcustomer) && (
|
||||||
|
<div className="zone-order-line"><Utensils size={11} /> {fstr(o.pickupcustomer)}</div>
|
||||||
|
)}
|
||||||
|
{(fstr(o.deliverysuburb) || fstr(o.deliveryaddress)) && (
|
||||||
|
<div className="zone-order-line"><MapPin size={11} /> {fstr(o.deliverysuburb) || fstr(o.deliveryaddress)}</div>
|
||||||
|
)}
|
||||||
|
{fstr(o.ordernotes) && (
|
||||||
|
<div className="zone-order-line zone-order-notes"><StickyNote size={11} /> {fstr(o.ordernotes)}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="zone-order-stats">
|
||||||
|
<span className="zone-order-chip"><Ruler size={10} /> {km.toFixed(1)} km</span>
|
||||||
|
{profit !== 0 && (
|
||||||
|
<span className={`zone-order-chip ${profit < 0 ? 'is-loss' : 'is-profit'}`}>
|
||||||
|
<Wallet size={10} /> ₹{Math.abs(profit).toLocaleString('en-IN')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{charge > 0 && <span className="zone-order-chip">₹{charge} chg</span>}
|
||||||
|
<span className="zone-order-chip zone-order-trip"><Crosshair size={10} /> S{step}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
301
src/components/OrdersView.tsx
Normal file
301
src/components/OrdersView.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orders page — replicated from the operations console (nearle_console/orders),
|
||||||
|
* rebuilt in the merchant stack against the shared console UI kit (`./consoleUi`)
|
||||||
|
* so it matches the source design: brand purple #662582, gradient header, KPI
|
||||||
|
* cards with gradient top-bars, pill status tabs, and a status-chip table. Wired
|
||||||
|
* to the live Fiesta order endpoints (status-scoped, date-ranged, paginated).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { ShoppingBag, Clock, CheckCircle2, XCircle, Calendar, ChevronLeft, ChevronRight, Package, MapPin, Phone, X, Loader2 } from 'lucide-react';
|
||||||
|
import { useFiestaOrderSummary, useFiestaOrders, useFiestaOrderDetails } from '../services/fiestaQueries';
|
||||||
|
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||||
|
import { shortTime } from '../services/fiestaMappers';
|
||||||
|
import {
|
||||||
|
GradientHeader, LiveStatus, KpiStrip, Pill, StatusChip, MetricPill, SearchPill, FilterBar, TH_STYLE,
|
||||||
|
ORDER_STATUS, statusColor, BRAND, TEXT, TEXT_2, TEXT_3, BORDER, SURFACE_ALT, tint, soft, edge,
|
||||||
|
} from './consoleUi';
|
||||||
|
|
||||||
|
interface OrdersViewProps {
|
||||||
|
searchQuery?: string;
|
||||||
|
locationid?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusKey = 'created' | 'pending' | 'processing' | 'delivered' | 'cancelled';
|
||||||
|
const STATUS_TABS: Array<{ key: StatusKey; label: string }> = [
|
||||||
|
{ key: 'created', label: 'Created' },
|
||||||
|
{ key: 'pending', label: 'Pending' },
|
||||||
|
{ key: 'processing', label: 'Processing' },
|
||||||
|
{ key: 'delivered', label: 'Delivered' },
|
||||||
|
{ key: 'cancelled', label: 'Cancelled' },
|
||||||
|
];
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
export default function OrdersView({ searchQuery = '', locationid }: OrdersViewProps) {
|
||||||
|
const today = new Date();
|
||||||
|
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
const [fromdate, setFromdate] = useState<string>(ymd(today));
|
||||||
|
const [todate, setTodate] = useState<string>(ymd(today));
|
||||||
|
|
||||||
|
const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); };
|
||||||
|
const presets = [
|
||||||
|
{ key: 'today', label: 'Today', from: ymd(today), to: ymd(today) },
|
||||||
|
{ key: 'yesterday', label: 'Yesterday', from: dayOffset(1), to: dayOffset(1) },
|
||||||
|
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: ymd(today) },
|
||||||
|
{ key: '30d', label: 'Last 30 Days', from: dayOffset(29), to: ymd(today) },
|
||||||
|
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: ymd(today) },
|
||||||
|
];
|
||||||
|
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<StatusKey>('created');
|
||||||
|
const [pageno, setPageno] = useState(1);
|
||||||
|
const [localSearch, setLocalSearch] = useState('');
|
||||||
|
const [detailOrder, setDetailOrder] = useState<Row | null>(null);
|
||||||
|
|
||||||
|
const summaryQ = useFiestaOrderSummary(FIESTA_TENANT_ID, fromdate, todate);
|
||||||
|
const ordersQ = useFiestaOrders({ tenantid: FIESTA_TENANT_ID, status, fromdate, todate, pageno, pagesize: PAGE_SIZE });
|
||||||
|
const summary = summaryQ.data;
|
||||||
|
const rawRows = ordersQ.data ?? [];
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const term = (localSearch || searchQuery).toLowerCase();
|
||||||
|
return rawRows.filter((r) => {
|
||||||
|
if (locationid && fnum(r.locationid) !== locationid) return false;
|
||||||
|
if (!term) return true;
|
||||||
|
return (
|
||||||
|
fstr(r.orderid).toLowerCase().includes(term) ||
|
||||||
|
fstr(r.deliverycustomer).toLowerCase().includes(term) ||
|
||||||
|
fstr(r.pickupcustomer).toLowerCase().includes(term) ||
|
||||||
|
fstr(r.deliveryaddress).toLowerCase().includes(term) ||
|
||||||
|
fstr(r.deliverysuburb).toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [rawRows, localSearch, searchQuery, locationid]);
|
||||||
|
|
||||||
|
const hasNext = rawRows.length === PAGE_SIZE;
|
||||||
|
const total = summary?.total ?? 0;
|
||||||
|
const pct = (n: number) => (total > 0 ? Math.round((n / total) * 100) : 0);
|
||||||
|
const countFor = (key: StatusKey): number => (summary ? (summary[key] ?? 0) : 0);
|
||||||
|
|
||||||
|
const kpis = [
|
||||||
|
{ label: 'Created Orders', value: (summary?.created ?? 0).toLocaleString('en-IN'), color: '#0ea5e9', icon: <ShoppingBag size={20} />, badge: `${pct(summary?.created ?? 0)}% of total` },
|
||||||
|
{ label: 'Pending Orders', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#f59e0b', icon: <Clock size={20} />, badge: `${pct(summary?.pending ?? 0)}% of total` },
|
||||||
|
{ label: 'Delivered Orders', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#10b981', icon: <CheckCircle2 size={20} />, badge: `${pct(summary?.delivered ?? 0)}% of total` },
|
||||||
|
{ label: 'Cancelled Orders', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#ef4444', icon: <XCircle size={20} />, badge: `${pct(summary?.cancelled ?? 0)}% of total` },
|
||||||
|
];
|
||||||
|
|
||||||
|
const setScope = (next: Partial<{ status: StatusKey; from: string; to: string }>) => {
|
||||||
|
if (next.status) setStatus(next.status);
|
||||||
|
if (next.from) setFromdate(next.from);
|
||||||
|
if (next.to) setTodate(next.to);
|
||||||
|
setPageno(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-in fade-in duration-300">
|
||||||
|
<GradientHeader
|
||||||
|
title="Orders"
|
||||||
|
subtitle="Live order board across the lifecycle — created, pending, processing, delivered, and cancelled."
|
||||||
|
status={
|
||||||
|
ordersQ.isLoading
|
||||||
|
? <LiveStatus state="loading" label="Loading live orders…" />
|
||||||
|
: ordersQ.isError
|
||||||
|
? <LiveStatus state="error" label="Live data unavailable" />
|
||||||
|
: <LiveStatus state="live" label={`Live · ${total.toLocaleString('en-IN')} orders in range`} />
|
||||||
|
}
|
||||||
|
right={
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full font-extrabold" style={{ padding: '6px 12px', fontSize: 12, background: tint(BRAND), border: `1.5px solid ${edge(BRAND)}`, color: BRAND }}>
|
||||||
|
<MapPin size={13} /> Coimbatore
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-4"><KpiStrip items={kpis} loading={summaryQ.isLoading} /></div>
|
||||||
|
|
||||||
|
{/* Date filter */}
|
||||||
|
<FilterBar className="mb-4">
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-extrabold uppercase tracking-widest pr-1" style={{ color: TEXT_2 }}>
|
||||||
|
<Calendar size={13} style={{ color: BRAND }} /> View
|
||||||
|
</span>
|
||||||
|
{presets.map((p) => (
|
||||||
|
<React.Fragment key={p.key}>
|
||||||
|
<Pill active={activePreset === p.key} color={BRAND} onClick={() => setScope({ from: p.from, to: p.to })}>{p.label}</Pill>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<input type="date" value={fromdate} max={todate} onChange={(e) => setScope({ from: e.target.value })}
|
||||||
|
className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
||||||
|
<span style={{ color: TEXT_3 }}>→</span>
|
||||||
|
<input type="date" value={todate} min={fromdate} max={ymd(today)} onChange={(e) => setScope({ to: e.target.value })}
|
||||||
|
className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
{/* Status tabs + search */}
|
||||||
|
<FilterBar className="mb-4">
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 overflow-x-auto py-0.5 flex-1 min-w-0">
|
||||||
|
{STATUS_TABS.map((t) => {
|
||||||
|
const color = statusColor(ORDER_STATUS, t.key);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={t.key}>
|
||||||
|
<Pill active={status === t.key} color={color} onClick={() => setScope({ status: t.key })} count={summaryQ.isLoading ? '·' : countFor(t.key).toLocaleString('en-IN')}>
|
||||||
|
{t.label}
|
||||||
|
</Pill>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="w-full lg:w-72 lg:shrink-0"><SearchPill value={localSearch} onChange={setLocalSearch} placeholder="Search orders…" /></div>
|
||||||
|
</div>
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full" style={{ minWidth: 960 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{['#', 'Order', 'Pickup', 'Drop', 'Qty', 'COD', 'KMs', 'Charges', 'Status', ''].map((h, i) => (
|
||||||
|
<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ordersQ.isLoading ? (
|
||||||
|
<tr><td colSpan={10} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}>
|
||||||
|
<span className="inline-flex items-center gap-2 text-xs font-semibold"><Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading orders…</span>
|
||||||
|
</td></tr>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<tr><td colSpan={10} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No orders found for this status, date range, or search.</td></tr>
|
||||||
|
) : (
|
||||||
|
rows.map((r, i) => {
|
||||||
|
const st = fstr(r.orderstatus).toLowerCase();
|
||||||
|
const cod = fnum(r.collectionamt);
|
||||||
|
const charges = fnum(r.deliverycharge) || fnum(r.deliverycharges);
|
||||||
|
return (
|
||||||
|
<tr key={fstr(r.orderid) || i} className="transition-colors" style={{ borderBottom: `1px solid ${DIVIDER_C}` }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
|
||||||
|
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
|
||||||
|
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
|
||||||
|
<p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<p className="font-bold text-[12px] truncate max-w-[150px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || '—'}</p>
|
||||||
|
<p className="text-[10px] truncate max-w-[150px]" style={{ color: TEXT_2 }}>{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td>
|
||||||
|
<td className="px-3 py-2.5">{cod > 0 ? <MetricPill color="#ef4444">₹{cod.toLocaleString('en-IN')}</MetricPill> : <span style={{ color: TEXT_3 }}>—</span>}</td>
|
||||||
|
<td className="px-3 py-2.5">{fnum(r.kms) ? <MetricPill color="#ef4444">{fnum(r.kms).toFixed(1)}</MetricPill> : <span style={{ color: TEXT_3 }}>—</span>}</td>
|
||||||
|
<td className="px-3 py-2.5">{charges > 0 ? <MetricPill color="#10b981">₹{charges.toLocaleString('en-IN')}</MetricPill> : <span style={{ color: TEXT_3 }}>—</span>}</td>
|
||||||
|
<td className="px-3 py-2.5"><StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} /></td>
|
||||||
|
<td className="px-3 py-2.5 text-right">
|
||||||
|
<button onClick={() => setDetailOrder(r)} className="rounded-full font-extrabold cursor-pointer transition-colors"
|
||||||
|
style={{ padding: '4px 12px', fontSize: 11, color: BRAND, background: tint(BRAND), border: `1px solid ${edge(BRAND)}` }}>View</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: TEXT_2 }}>Page {pageno} · {rows.length} shown</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PagerBtn disabled={pageno === 1} onClick={() => setPageno((p) => Math.max(1, p - 1))}><ChevronLeft size={13} /> Prev</PagerBtn>
|
||||||
|
<PagerBtn disabled={!hasNext} onClick={() => setPageno((p) => p + 1)}>Next <ChevronRight size={13} /></PagerBtn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailOrder && <OrderDetailModal order={detailOrder} onClose={() => setDetailOrder(null)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIVIDER_C = '#f1f5f9';
|
||||||
|
|
||||||
|
function PagerBtn({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} disabled={disabled}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full font-bold transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
style={{ padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Order details modal ─────────────────────────────────────────────────────────
|
||||||
|
function OrderDetailModal({ order, onClose }: { order: Row; onClose: () => void }) {
|
||||||
|
const orderheaderid = order.orderheaderid ?? order.orderid;
|
||||||
|
const detailsQ = useFiestaOrderDetails(orderheaderid as number | string);
|
||||||
|
const lines = (detailsQ.data ?? []).map((row) => {
|
||||||
|
const quantity = fnum(row.quantity) || fnum(row.qty) || fnum(row.orderqty);
|
||||||
|
const price = fnum(row.price) || fnum(row.unitprice) || fnum(row.retailprice);
|
||||||
|
return { name: fstr(row.productname) || fstr(row.itemname) || 'Item', quantity, price, lineTotal: fnum(row.amount) || fnum(row.productsumprice) || price * quantity };
|
||||||
|
});
|
||||||
|
const st = fstr(order.orderstatus).toLowerCase();
|
||||||
|
const total = fnum(order.deliveryamt) || fnum(order.orderamount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" style={{ background: 'rgba(15,23,42,0.4)', backdropFilter: 'blur(4px)' }}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||||
|
<div className="bg-white w-full max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-2xl animate-in zoom-in-95 duration-200" style={{ border: `1px solid ${BORDER}`, boxShadow: '0 18px 50px rgba(15,23,42,0.18)' }}>
|
||||||
|
<div style={{ height: 4, background: `linear-gradient(90deg, ${BRAND} 0%, ${soft(BRAND)} 100%)` }} />
|
||||||
|
<div className="p-4 border-b flex justify-between items-center shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||||
|
<h4 className="font-extrabold flex items-center gap-2" style={{ color: TEXT }}><Package size={16} style={{ color: BRAND }} /> Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}</h4>
|
||||||
|
<button onClick={onClose} className="p-1 rounded-full cursor-pointer" style={{ color: TEXT_3 }}><X size={16} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-4 overflow-y-auto flex-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<StatusChip label={st || '—'} color={statusColor(ORDER_STATUS, st)} />
|
||||||
|
<span className="text-[11px] font-medium" style={{ color: TEXT_2 }}>{shortTime(order.orderdate || order.deliverydate)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 rounded-xl space-y-1.5" style={{ background: SURFACE_ALT, border: `1px solid ${BORDER}` }}>
|
||||||
|
<div className="flex items-center gap-2 font-bold" style={{ color: TEXT }}>{fstr(order.deliverycustomer) || 'Customer'}</div>
|
||||||
|
{fstr(order.deliverycontactno) && <div className="flex items-center gap-2 font-mono text-xs" style={{ color: TEXT_2 }}><Phone size={12} /> {fstr(order.deliverycontactno)}</div>}
|
||||||
|
<div className="flex items-start gap-2 text-xs" style={{ color: TEXT_2 }}><MapPin size={12} className="mt-0.5 shrink-0" /> <span className="leading-relaxed">{fstr(order.deliveryaddress) || fstr(order.deliverysuburb) || 'Address unavailable'}</span></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-extrabold uppercase tracking-wide block mb-2" style={{ color: TEXT_2 }}>Order Items</span>
|
||||||
|
<div className="rounded-xl p-3" style={{ background: 'rgba(248,250,252,0.6)', border: `1px solid ${BORDER}` }}>
|
||||||
|
{detailsQ.isLoading && <div className="py-2 flex items-center gap-1.5 text-[11px] font-medium" style={{ color: TEXT_3 }}><Loader2 size={12} className="animate-spin" /> Loading line items…</div>}
|
||||||
|
{!detailsQ.isLoading && lines.length === 0 && <div className="py-2 text-[11px] font-medium" style={{ color: TEXT_3 }}>No line items returned for this order.</div>}
|
||||||
|
{lines.map((item, idx) => (
|
||||||
|
<div key={idx} className="py-2 flex justify-between items-center" style={{ borderTop: idx ? `1px solid ${DIVIDER_C}` : undefined }}>
|
||||||
|
<div><p className="font-bold text-xs" style={{ color: TEXT }}>{item.name}</p><p className="text-[10px]" style={{ color: TEXT_2 }}>Qty: {item.quantity} × ₹{item.price}</p></div>
|
||||||
|
<span className="font-extrabold font-mono text-xs" style={{ color: TEXT }}>₹{item.lineTotal.toLocaleString('en-IN')}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{total > 0 && (
|
||||||
|
<div className="pt-2 mt-1 flex justify-between items-center font-extrabold text-sm" style={{ color: BRAND, borderTop: `1px dashed ${BORDER}` }}>
|
||||||
|
<span>Order Total</span><span className="font-mono">₹{total.toLocaleString('en-IN')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 border-t flex justify-end shrink-0" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||||
|
<button onClick={onClose} className="rounded-full font-bold cursor-pointer text-white" style={{ padding: '8px 16px', background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT_LOCAL})` }}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BRAND_LIGHT_LOCAL = '#9255AB';
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Store,
|
Store,
|
||||||
Layers,
|
Layers,
|
||||||
ShoppingBag,
|
|
||||||
Settings,
|
Settings,
|
||||||
TrendingUp
|
TrendingUp
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|||||||
403
src/components/StoreCatalogView.tsx
Normal file
403
src/components/StoreCatalogView.tsx
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inventory & Catalog — the store user's page.
|
||||||
|
*
|
||||||
|
* Flow: the manager curates an assortment from the global catalog; the store user
|
||||||
|
* sees ONLY that manager-selected catalog (never the global one) and chooses which
|
||||||
|
* products to stock in their own store. Two tabs:
|
||||||
|
* • Browse Catalog — the manager-approved products, each addable to the store.
|
||||||
|
* • My Store Inventory — what's currently stocked at this outlet (live stock).
|
||||||
|
*
|
||||||
|
* The "manager-selected catalog" is sourced from the tenant master catalog
|
||||||
|
* (getMasterCatalog) for now — see CATALOG_SOURCE below; swap that one hook for
|
||||||
|
* the approved-products endpoint once it exists.
|
||||||
|
*
|
||||||
|
* Stocking a product at a location needs a write endpoint that isn't built yet,
|
||||||
|
* so selections are kept locally (persisted per store) and marked "pending sync".
|
||||||
|
* `commitSelectionToStore()` is the single integration point: replace its body
|
||||||
|
* with the real mutation when the backend is ready.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Search, Boxes, Layers, Plus, Check, CheckCircle2, X, Tag, Store, PackageSearch, AlertTriangle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useFiestaMasterCatalog,
|
||||||
|
useFiestaStockStatement,
|
||||||
|
useFiestaProductCategories,
|
||||||
|
useFiestaProductSubcategories,
|
||||||
|
FIESTA_TENANT_ID,
|
||||||
|
} from '../services/fiestaQueries';
|
||||||
|
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
||||||
|
import { categoryName } from '../services/fiestaMappers';
|
||||||
|
import AwaitingApi from './AwaitingApi';
|
||||||
|
|
||||||
|
const BRAND = '#581c87';
|
||||||
|
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
|
||||||
|
|
||||||
|
interface StoreCatalogViewProps {
|
||||||
|
locationid?: number;
|
||||||
|
storeName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatalogProduct {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
category: string;
|
||||||
|
categoryid: number;
|
||||||
|
subcategoryid: number;
|
||||||
|
subcategoryname: string;
|
||||||
|
price: number;
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stockStatus(closing: number): { label: string; color: string } {
|
||||||
|
if (closing <= 0) return { label: 'Out of stock', color: '#ef4444' };
|
||||||
|
if (closing < 25) return { label: 'Critical', color: '#ef4444' };
|
||||||
|
if (closing < 120) return { label: 'Low', color: '#f59e0b' };
|
||||||
|
return { label: 'Healthy', color: '#10b981' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StoreCatalogView({ locationid, storeName = 'your store' }: StoreCatalogViewProps) {
|
||||||
|
const tenantid = FIESTA_TENANT_ID;
|
||||||
|
const [view, setView] = useState<'catalog' | 'inventory'>('catalog');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [categoryid, setCategoryid] = useState(0);
|
||||||
|
const [subcategoryid, setSubcategoryid] = useState(0);
|
||||||
|
const [notice, setNotice] = useState(false);
|
||||||
|
|
||||||
|
// Selections "to stock at this store" — persisted per outlet so choices survive
|
||||||
|
// a refresh until the backend write exists.
|
||||||
|
const storageKey = `nearledaily.catalog.selected.${locationid ?? 'na'}`;
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(storageKey);
|
||||||
|
return new Set(raw ? (JSON.parse(raw) as string[]) : []);
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
try { localStorage.setItem(storageKey, JSON.stringify([...selected])); } catch { /* ignore */ }
|
||||||
|
}, [selected, storageKey]);
|
||||||
|
|
||||||
|
// ── Data ──────────────────────────────────────────────────────────────────────
|
||||||
|
// CATALOG_SOURCE: the manager-selected assortment. Swap this hook for the
|
||||||
|
// approved-products endpoint when it's available; the rest of the page is agnostic.
|
||||||
|
const catalogQ = useFiestaMasterCatalog({ tenantid, subcategoryid: subcategoryid || undefined, pagesize: 200 });
|
||||||
|
const stockQ = useFiestaStockStatement({ tenantid, locationid: locationid ?? 0, pagesize: 200 });
|
||||||
|
const categoriesQ = useFiestaProductCategories();
|
||||||
|
const subcategoriesQ = useFiestaProductSubcategories({ categoryid, tenantid });
|
||||||
|
|
||||||
|
const products = useMemo<CatalogProduct[]>(
|
||||||
|
() =>
|
||||||
|
(catalogQ.data ?? []).map((r: Row) => ({
|
||||||
|
id: fstr(r.productid) || fstr(r.productname),
|
||||||
|
name: fstr(r.productname) || 'Unnamed product',
|
||||||
|
image: fstr(r.productimage) || PLACEHOLDER,
|
||||||
|
category: categoryName(fnum(r.categoryid)),
|
||||||
|
categoryid: fnum(r.categoryid),
|
||||||
|
subcategoryid: fnum(r.subcategoryid),
|
||||||
|
subcategoryname: fstr(r.subcategoryname),
|
||||||
|
price: fnum(r.retailprice) || fnum(r.productcost),
|
||||||
|
unit: `${fstr(r.productunit) || 'unit'} · ${fstr(r.unitvalue) || '1'}`,
|
||||||
|
})),
|
||||||
|
[catalogQ.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Products already stocked at this store (by productid) — drives the "In Store" state.
|
||||||
|
const inStore = useMemo(() => new Set((stockQ.data ?? []).map((r) => fstr(r.productid))), [stockQ.data]);
|
||||||
|
|
||||||
|
const inventory = useMemo(
|
||||||
|
() =>
|
||||||
|
(stockQ.data ?? []).map((r: Row) => {
|
||||||
|
const closing = fnum(r.closing);
|
||||||
|
return {
|
||||||
|
id: fstr(r.productid),
|
||||||
|
name: fstr(r.productname) || 'Unnamed product',
|
||||||
|
category: categoryName(fnum(r.categoryid)),
|
||||||
|
closing,
|
||||||
|
...stockStatus(closing),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[stockQ.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const term = search.toLowerCase();
|
||||||
|
return products.filter((p) => {
|
||||||
|
if (categoryid && p.categoryid !== categoryid) return false;
|
||||||
|
if (!term) return true;
|
||||||
|
return p.name.toLowerCase().includes(term) || p.category.toLowerCase().includes(term) || p.id.toLowerCase().includes(term);
|
||||||
|
});
|
||||||
|
}, [products, search, categoryid]);
|
||||||
|
|
||||||
|
// Categories come from the Fiesta product-categories endpoint; if it returns
|
||||||
|
// nothing, fall back to the categories present in the loaded catalog so the
|
||||||
|
// filter is never empty.
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const fromApi = (categoriesQ.data ?? [])
|
||||||
|
.map((c) => ({ id: fnum(c.categoryid), name: fstr(c.categoryname) || categoryName(fnum(c.categoryid)) }))
|
||||||
|
.filter((c) => c.id);
|
||||||
|
if (fromApi.length) return fromApi;
|
||||||
|
const seen = new Map<number, string>();
|
||||||
|
for (const p of products) if (p.categoryid && !seen.has(p.categoryid)) seen.set(p.categoryid, p.category);
|
||||||
|
return [...seen.entries()].map(([id, name]) => ({ id, name }));
|
||||||
|
}, [categoriesQ.data, products]);
|
||||||
|
// Subcategories: Fiesta endpoint as source of truth; fall back to the
|
||||||
|
// subcategories present in the loaded catalog for the selected category.
|
||||||
|
const subcategories = useMemo(() => {
|
||||||
|
const fromApi = (subcategoriesQ.data ?? [])
|
||||||
|
.map((s) => ({ id: fnum(s.subcategoryid), name: fstr(s.subcategoryname) || `Subcategory ${fnum(s.subcategoryid)}` }))
|
||||||
|
.filter((s) => s.id);
|
||||||
|
if (fromApi.length) return fromApi;
|
||||||
|
const seen = new Map<number, string>();
|
||||||
|
for (const p of products) {
|
||||||
|
if (categoryid && p.categoryid !== categoryid) continue;
|
||||||
|
if (p.subcategoryid && !seen.has(p.subcategoryid)) seen.set(p.subcategoryid, p.subcategoryname || `Subcategory ${p.subcategoryid}`);
|
||||||
|
}
|
||||||
|
return [...seen.entries()].map(([id, name]) => ({ id, name }));
|
||||||
|
}, [subcategoriesQ.data, products, categoryid]);
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
setNotice(false);
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Integration point ──────────────────────────────────────────────────────────
|
||||||
|
// Replace this body with the real mutation: POST the selected product ids to the
|
||||||
|
// store/location assortment (stock-entry) endpoint, then invalidate stockQ.
|
||||||
|
const commitSelectionToStore = () => {
|
||||||
|
setNotice(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-lg animate-in fade-in duration-300 font-sans pb-24">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">Inventory & Catalog</h1>
|
||||||
|
<p className="text-zinc-500 text-xs mt-1">
|
||||||
|
Browse the products approved for your store and choose what to stock at <span className="font-semibold text-[#581c87]">{storeName}</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex items-center gap-1 bg-zinc-100/80 p-1 rounded-xl border border-zinc-200/60 w-full sm:w-auto sm:inline-flex">
|
||||||
|
<button
|
||||||
|
onClick={() => setView('catalog')}
|
||||||
|
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
view === 'catalog' ? 'bg-white text-[#581c87] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Boxes size={14} /> Browse Catalog ({products.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('inventory')}
|
||||||
|
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
view === 'inventory' ? 'bg-white text-[#581c87] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Store size={14} /> My Store Inventory ({inventory.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center gap-md bg-white border border-[#eceef2] p-md rounded-2xl shadow-sm">
|
||||||
|
<div className="relative w-full md:w-80 md:shrink-0">
|
||||||
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={view === 'catalog' ? 'Search catalog products…' : 'Search your stock…'}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-9 py-2.5 border border-[#e2e8f0] rounded-xl text-xs outline-none bg-[#f8fafc] focus:bg-white focus:ring-1 focus:ring-[#581c87] transition-all"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600">
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === 'catalog' && (
|
||||||
|
<div className="flex items-center gap-sm flex-wrap">
|
||||||
|
<span className="flex items-center gap-1.5 text-[10px] font-bold text-zinc-400 uppercase tracking-widest">
|
||||||
|
<Layers size={13} className="text-[#581c87]" /> Filter
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={categoryid}
|
||||||
|
onChange={(e) => { setCategoryid(Number(e.target.value)); setSubcategoryid(0); }}
|
||||||
|
className="border border-[#e2e8f0] rounded-lg px-3 py-2 text-xs font-semibold text-zinc-700 bg-[#f8fafc] outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value={0}>All categories</option>
|
||||||
|
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
{categoryid > 0 && subcategories.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={subcategoryid}
|
||||||
|
onChange={(e) => setSubcategoryid(Number(e.target.value))}
|
||||||
|
className="border border-[#e2e8f0] rounded-lg px-3 py-2 text-xs font-semibold text-zinc-700 bg-[#f8fafc] outline-none focus:ring-1 focus:ring-[#581c87] cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value={0}>All subcategories</option>
|
||||||
|
{subcategories.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="md:ml-auto text-[11px] font-semibold text-zinc-400">
|
||||||
|
{view === 'catalog' ? `${filtered.length} products` : `${inventory.length} stocked`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Browse Catalog ── */}
|
||||||
|
{view === 'catalog' && (
|
||||||
|
catalogQ.isLoading ? (
|
||||||
|
<CenterState icon={<PackageSearch size={26} />} title="Loading catalog…" />
|
||||||
|
) : catalogQ.isError ? (
|
||||||
|
<CenterState icon={<AlertTriangle size={26} />} title="Couldn't load the catalog" sub="Check your connection and try again." tone="error" />
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<CenterState icon={<Boxes size={26} />} title="No products found" sub="Your manager hasn't approved products matching this filter yet." />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-gutter">
|
||||||
|
{filtered.map((p) => {
|
||||||
|
const stocked = inStore.has(p.id);
|
||||||
|
const isSelected = selected.has(p.id);
|
||||||
|
return (
|
||||||
|
<div key={p.id} className="group bg-white border border-[#e2e8f0] rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition-all flex flex-col">
|
||||||
|
<div className="relative h-28 w-full overflow-hidden bg-zinc-50">
|
||||||
|
<img src={p.image} alt={p.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
|
||||||
|
{stocked && (
|
||||||
|
<span className="absolute top-2 right-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500 text-white text-[9px] font-bold uppercase tracking-wide shadow">
|
||||||
|
<CheckCircle2 size={10} /> In Store
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-3 flex-1 flex flex-col">
|
||||||
|
<span className="inline-flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-[#581c87] mb-1">
|
||||||
|
<Tag size={9} /> {p.category}
|
||||||
|
</span>
|
||||||
|
<p className="font-bold text-xs text-[#0f172a] leading-snug line-clamp-2 min-h-[2rem]">{p.name}</p>
|
||||||
|
<div className="flex items-center justify-between mt-1.5 mb-3">
|
||||||
|
<span className="font-mono font-extrabold text-sm text-zinc-800">{p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'}</span>
|
||||||
|
<span className="text-[9px] text-zinc-400 font-semibold">{p.unit}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stocked ? (
|
||||||
|
<button disabled className="mt-auto w-full py-2 rounded-xl text-[11px] font-bold bg-emerald-50 text-emerald-600 border border-emerald-100 cursor-default flex items-center justify-center gap-1.5">
|
||||||
|
<CheckCircle2 size={13} /> Stocked
|
||||||
|
</button>
|
||||||
|
) : isSelected ? (
|
||||||
|
<button onClick={() => toggle(p.id)} className="mt-auto w-full py-2 rounded-xl text-[11px] font-bold bg-[#581c87] text-white hover:bg-purple-800 transition flex items-center justify-center gap-1.5 cursor-pointer">
|
||||||
|
<Check size={13} /> Selected
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => toggle(p.id)} className="mt-auto w-full py-2 rounded-xl text-[11px] font-bold bg-white text-[#581c87] border border-purple-200 hover:bg-purple-50 transition flex items-center justify-center gap-1.5 cursor-pointer">
|
||||||
|
<Plus size={13} /> Add to Store
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── My Store Inventory ── */}
|
||||||
|
{view === 'inventory' && (
|
||||||
|
<div className="bg-white border border-[#e2e8f0] rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[#f8fafc] border-b border-[#e2e8f0] text-[10px] uppercase tracking-wider text-zinc-400 font-bold">
|
||||||
|
<th className="px-4 py-3 text-left">#</th>
|
||||||
|
<th className="px-4 py-3 text-left">Product</th>
|
||||||
|
<th className="px-4 py-3 text-left">Category</th>
|
||||||
|
<th className="px-4 py-3 text-right">In Stock</th>
|
||||||
|
<th className="px-4 py-3 text-center">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[#f1f5f9]">
|
||||||
|
{stockQ.isLoading ? (
|
||||||
|
<tr><td colSpan={5} className="px-4 py-12 text-center text-zinc-400">Loading your stock…</td></tr>
|
||||||
|
) : !locationid ? (
|
||||||
|
<tr><td colSpan={5} className="px-4 py-12 text-center text-zinc-400">No store linked to your account yet.</td></tr>
|
||||||
|
) : inventory.length === 0 ? (
|
||||||
|
<tr><td colSpan={5} className="px-4 py-12 text-center text-zinc-400">No products stocked yet — add some from the catalog.</td></tr>
|
||||||
|
) : (
|
||||||
|
inventory.map((it, i) => (
|
||||||
|
<tr key={it.id || i} className="hover:bg-zinc-50/70 transition-colors">
|
||||||
|
<td className="px-4 py-3 font-mono text-zinc-400">{i + 1}</td>
|
||||||
|
<td className="px-4 py-3 font-bold text-[#0f172a]">{it.name}</td>
|
||||||
|
<td className="px-4 py-3 text-zinc-500">{it.category}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono font-bold text-zinc-700">{it.closing.toLocaleString('en-IN')}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border" style={{ background: `${it.color}14`, color: it.color, borderColor: `${it.color}40` }}>
|
||||||
|
{it.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Selection action bar (sticky) ── */}
|
||||||
|
{view === 'catalog' && selected.size > 0 && (
|
||||||
|
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[120] w-[min(640px,calc(100vw-2rem))]">
|
||||||
|
<div className="bg-[#0f172a] text-white rounded-2xl shadow-2xl border border-white/10 px-4 py-3">
|
||||||
|
{notice ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold">{selected.size} product{selected.size > 1 ? 's' : ''} marked for {storeName}</span>
|
||||||
|
<button onClick={() => { setSelected(new Set()); setNotice(false); }} className="text-[11px] font-semibold text-purple-200 hover:text-white cursor-pointer">Clear</button>
|
||||||
|
</div>
|
||||||
|
<AwaitingApi label="Adding products to your store" api="stock-entry API" compact className="bg-white/5 border-white/15 text-purple-100" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center shrink-0"><Boxes size={15} /></span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-bold truncate">{selected.size} product{selected.size > 1 ? 's' : ''} selected</p>
|
||||||
|
<p className="text-[10px] text-purple-200">Ready to stock at {storeName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<button onClick={() => setSelected(new Set())} className="px-3 py-2 rounded-xl text-[11px] font-bold text-purple-200 hover:text-white hover:bg-white/10 transition cursor-pointer">Clear</button>
|
||||||
|
<button onClick={commitSelectionToStore} className="px-4 py-2 rounded-xl text-[11px] font-bold bg-white text-[#581c87] hover:bg-purple-50 transition cursor-pointer flex items-center gap-1.5">
|
||||||
|
<Plus size={13} /> Add to Store
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CenterState({ icon, title, sub, tone }: { icon: React.ReactNode; title: string; sub?: string; tone?: 'error' }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-dashed border-[#e2e8f0] rounded-2xl p-12 text-center">
|
||||||
|
<div className={`mx-auto mb-3 flex items-center justify-center w-14 h-14 rounded-2xl ${tone === 'error' ? 'bg-rose-50 text-rose-500' : 'bg-zinc-100 text-zinc-400'}`}>{icon}</div>
|
||||||
|
<p className="font-bold text-sm text-zinc-700">{title}</p>
|
||||||
|
{sub && <p className="text-xs text-zinc-400 mt-1">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,8 +30,7 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
History,
|
History,
|
||||||
Building,
|
Building,
|
||||||
Award,
|
Award
|
||||||
ShoppingBag
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useFiestaStockStatement,
|
useFiestaStockStatement,
|
||||||
@@ -44,7 +43,6 @@ import {
|
|||||||
import { str as fstr, num as fnum } from '../services/fiestaApi';
|
import { str as fstr, num as fnum } from '../services/fiestaApi';
|
||||||
import { mapOrderStatus, shortTime } from '../services/fiestaMappers';
|
import { mapOrderStatus, shortTime } from '../services/fiestaMappers';
|
||||||
import ragulStoreCover from '../assets/images/store_front_view_1780299351800.png';
|
import ragulStoreCover from '../assets/images/store_front_view_1780299351800.png';
|
||||||
import OrdersDeliveriesView from './OrdersDeliveriesView';
|
|
||||||
import AwaitingApi from './AwaitingApi';
|
import AwaitingApi from './AwaitingApi';
|
||||||
|
|
||||||
interface StoreDetailViewProps {
|
interface StoreDetailViewProps {
|
||||||
@@ -66,6 +64,10 @@ interface StoreDetailViewProps {
|
|||||||
* catalogue, promo/credit). Admins get true; plain store users get false so
|
* catalogue, promo/credit). Admins get true; plain store users get false so
|
||||||
* the console is view-only. */
|
* the console is view-only. */
|
||||||
canManage?: boolean;
|
canManage?: boolean;
|
||||||
|
/** Render a single section only (no tab bar). The user store console splits
|
||||||
|
* Overview, Inventory & Catalogue, and Customers into separate pages. When
|
||||||
|
* omitted, the full tabbed console renders (admin store detail). */
|
||||||
|
only?: 'overview' | 'inventory' | 'customers';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback cover images
|
// Fallback cover images
|
||||||
@@ -84,8 +86,13 @@ const DETAIL_STORE_COVERS = [
|
|||||||
'https://images.unsplash.com/photo-1579621970563-ebec7560ff3e?auto=format&fit=crop&w=800&q=80'
|
'https://images.unsplash.com/photo-1579621970563-ebec7560ff3e?auto=format&fit=crop&w=800&q=80'
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function StoreDetailView({ store, onBack, canManage = true }: StoreDetailViewProps) {
|
export default function StoreDetailView({ store, onBack, canManage = true, only }: StoreDetailViewProps) {
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'customers' | 'orders'>('overview');
|
const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'customers'>('overview');
|
||||||
|
// Which section to show: forced by `only` (separate-page mode) or the active tab.
|
||||||
|
const section = only ?? activeTab;
|
||||||
|
// The immersive store banner shows on Overview (and the admin tabbed console);
|
||||||
|
// the standalone Inventory & Customers pages omit it.
|
||||||
|
const showHero = !only || only === 'overview';
|
||||||
|
|
||||||
const isRagul = store.name.toLowerCase().includes('ragul');
|
const isRagul = store.name.toLowerCase().includes('ragul');
|
||||||
const getStoreCover = () => {
|
const getStoreCover = () => {
|
||||||
@@ -393,7 +400,8 @@ export default function StoreDetailView({ store, onBack, canManage = true }: Sto
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Immersive Analytics Banner (With Store Cover Image & Slate Gradient Overlay) ── */}
|
{/* ── Immersive Analytics Banner — hidden on the standalone Inventory & Customers pages ── */}
|
||||||
|
{showHero && (
|
||||||
<div className="relative overflow-hidden rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 mb-8 animate-in fade-in duration-300">
|
<div className="relative overflow-hidden rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 mb-8 animate-in fade-in duration-300">
|
||||||
{/* Cover Image Background */}
|
{/* Cover Image Background */}
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
@@ -488,8 +496,10 @@ export default function StoreDetailView({ store, onBack, canManage = true }: Sto
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Visual Glass-look Tab Controls ── */}
|
{/* ── Visual Glass-look Tab Controls (full tabbed console only) ── */}
|
||||||
|
{!only && (
|
||||||
<div className="flex gap-2 border-b border-[#e2e8f0] pb-[1px] select-none overflow-x-auto">
|
<div className="flex gap-2 border-b border-[#e2e8f0] pb-[1px] select-none overflow-x-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('overview')}
|
onClick={() => setActiveTab('overview')}
|
||||||
@@ -527,21 +537,11 @@ export default function StoreDetailView({ store, onBack, canManage = true }: Sto
|
|||||||
<Users size={14} />
|
<Users size={14} />
|
||||||
<span>Customer CRM Base ({customersList.length})</span>
|
<span>Customer CRM Base ({customersList.length})</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('orders')}
|
|
||||||
className={`flex items-center gap-xs px-md pb-sm text-xs font-bold uppercase tracking-wider cursor-pointer border-b-2 transition-all ${
|
|
||||||
activeTab === 'orders'
|
|
||||||
? 'border-b-[#581c87] text-[#581c87]'
|
|
||||||
: 'border-b-transparent text-zinc-400 hover:text-zinc-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<ShoppingBag size={14} />
|
|
||||||
<span>Orders & Deliveries</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── TAB PAYLOAD AREA ── */}
|
{/* ── TAB PAYLOAD AREA ── */}
|
||||||
{activeTab === 'overview' && (
|
{section === 'overview' && (
|
||||||
<div className="space-y-lg animate-in fade-in duration-300">
|
<div className="space-y-lg animate-in fade-in duration-300">
|
||||||
|
|
||||||
{/* Top Metric Cards */}
|
{/* Top Metric Cards */}
|
||||||
@@ -721,12 +721,12 @@ export default function StoreDetailView({ store, onBack, canManage = true }: Sto
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'inventory' && (
|
{section === 'inventory' && (
|
||||||
<div className="space-y-lg animate-in fade-in duration-300">
|
<div className="space-y-lg animate-in fade-in duration-300">
|
||||||
|
|
||||||
{/* Inventory search, metrics & catalogue tools */}
|
{/* Inventory search, metrics & catalogue tools */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-md bg-white border border-[#eceef2] p-md rounded-2xl shadow-sm">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-md bg-white border border-[#eceef2] p-md rounded-2xl shadow-sm">
|
||||||
<div className="relative w-full max-w-sm">
|
<div className="relative w-full md:w-80 md:shrink-0">
|
||||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-450" />
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-450" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -885,12 +885,12 @@ export default function StoreDetailView({ store, onBack, canManage = true }: Sto
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'customers' && (
|
{section === 'customers' && (
|
||||||
<div className="space-y-lg animate-in fade-in duration-300">
|
<div className="space-y-lg animate-in fade-in duration-300">
|
||||||
|
|
||||||
{/* Customer directory search and metrics */}
|
{/* Customer directory search and metrics */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-md bg-white border border-[#eceef2] p-md rounded-2xl shadow-sm">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-md bg-white border border-[#eceef2] p-md rounded-2xl shadow-sm">
|
||||||
<div className="relative w-full max-w-sm">
|
<div className="relative w-full sm:w-80 sm:shrink-0">
|
||||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -989,13 +989,7 @@ export default function StoreDetailView({ store, onBack, canManage = true }: Sto
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'orders' && (
|
{/* Orders & Deliveries moved out of the store console into their own pages. */}
|
||||||
<OrdersDeliveriesView
|
|
||||||
searchQuery=""
|
|
||||||
isCoimbatoreView={false}
|
|
||||||
locationid={locationid}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Replenishment Modal Dialog Overlay ── */}
|
{/* ── Replenishment Modal Dialog Overlay ── */}
|
||||||
{replenishModal.show && replenishModal.item && (
|
{replenishModal.show && replenishModal.item && (
|
||||||
|
|||||||
@@ -4,7 +4,21 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { AlertTriangle, LayoutDashboard, User, Mail, Phone, Store, ShieldCheck } from 'lucide-react';
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
LayoutDashboard,
|
||||||
|
User,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
Store,
|
||||||
|
ShieldCheck,
|
||||||
|
ShoppingBag,
|
||||||
|
Truck,
|
||||||
|
Route,
|
||||||
|
ClipboardList,
|
||||||
|
Layers,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useFiestaTenantLocations,
|
useFiestaTenantLocations,
|
||||||
useFiestaLocationSummary,
|
useFiestaLocationSummary,
|
||||||
@@ -14,6 +28,11 @@ import { str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
|||||||
import type { AuthUser } from '../services/auth';
|
import type { AuthUser } from '../services/auth';
|
||||||
import Header from './Header';
|
import Header from './Header';
|
||||||
import StoreDetailView from './StoreDetailView';
|
import StoreDetailView from './StoreDetailView';
|
||||||
|
import StoreCatalogView from './StoreCatalogView';
|
||||||
|
import OrdersView from './OrdersView';
|
||||||
|
import DeliveriesView from './DeliveriesView';
|
||||||
|
import DispatchView from './DispatchView';
|
||||||
|
import DeliveryReportsView from './DeliveryReportsView';
|
||||||
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
||||||
|
|
||||||
interface UserStorePageProps {
|
interface UserStorePageProps {
|
||||||
@@ -27,6 +46,12 @@ interface UserStorePageProps {
|
|||||||
// gets a matching branch in `renderSection` below.
|
// gets a matching branch in `renderSection` below.
|
||||||
const NAV_ITEMS: UserNavItem[] = [
|
const NAV_ITEMS: UserNavItem[] = [
|
||||||
{ id: 'console', label: 'Store Console', icon: LayoutDashboard },
|
{ id: 'console', label: 'Store Console', icon: LayoutDashboard },
|
||||||
|
{ id: 'inventory', label: 'Inventory & Catalog', icon: Layers },
|
||||||
|
{ id: 'customers', label: 'Customers', icon: Users },
|
||||||
|
{ id: 'orders', label: 'Orders', icon: ShoppingBag },
|
||||||
|
{ id: 'deliveries', label: 'Deliveries', icon: Truck },
|
||||||
|
{ id: 'dispatch', label: 'Dispatch', icon: Route },
|
||||||
|
{ id: 'reports', label: 'Delivery Reports', icon: ClipboardList },
|
||||||
{ id: 'account', label: 'My Account', icon: User },
|
{ id: 'account', label: 'My Account', icon: User },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -161,6 +186,17 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
const renderSection = () => {
|
const renderSection = () => {
|
||||||
if (activeSection === 'account') return renderAccount();
|
if (activeSection === 'account') return renderAccount();
|
||||||
|
|
||||||
|
// Logistics console — scoped to this user's store. These views own their
|
||||||
|
// loading/error states, so they don't need the store-console load gating below.
|
||||||
|
if (activeSection === 'orders') return <OrdersView locationid={resolvedLocationId || undefined} />;
|
||||||
|
if (activeSection === 'deliveries') return <DeliveriesView locationid={resolvedLocationId || undefined} />;
|
||||||
|
if (activeSection === 'dispatch') return <DispatchView locationid={resolvedLocationId || undefined} />;
|
||||||
|
if (activeSection === 'reports') return <DeliveryReportsView />;
|
||||||
|
// Inventory & Catalog is its own page: the manager-curated catalog the user
|
||||||
|
// stocks from (the catalog query is tenant-level, so it doesn't need the store
|
||||||
|
// gating below — only "My Store Inventory" uses the resolved location id).
|
||||||
|
if (activeSection === 'inventory') return <StoreCatalogView locationid={resolvedLocationId || undefined} storeName={storeName} />;
|
||||||
|
|
||||||
// The store console needs a resolved store, so gate it on the load state.
|
// The store console needs a resolved store, so gate it on the load state.
|
||||||
if (locationsQ.isLoading || locSummaryQ.isLoading) {
|
if (locationsQ.isLoading || locSummaryQ.isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -218,7 +254,12 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
// canManage=false hides write actions in the console. NOTE: this is a UI-only
|
// 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
|
// restriction — the backend must also enforce role-based authorization on the
|
||||||
// write endpoints, since a hidden button doesn't stop a direct API call.
|
// write endpoints, since a hidden button doesn't stop a direct API call.
|
||||||
return <StoreDetailView store={buildStore()} canManage={false} />;
|
//
|
||||||
|
// The store console is split into separate pages: the "console" nav shows only
|
||||||
|
// Overview & Performance; Customers is its own page (Inventory & Catalog is the
|
||||||
|
// dedicated StoreCatalogView, handled above).
|
||||||
|
const only = activeSection === 'customers' ? 'customers' : 'overview';
|
||||||
|
return <StoreDetailView store={buildStore()} canManage={false} only={only} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -240,13 +281,19 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
className={`flex-1 min-w-0 min-h-[calc(100vh-80px)] transition-all duration-300 ${
|
className={`flex-1 min-w-0 transition-all duration-300 ${
|
||||||
sidebarOpen ? 'md:pl-64' : 'md:pl-20'
|
// Dispatch is a full-bleed cockpit — fill the area exactly (no page
|
||||||
}`}
|
// padding) so it sits flush under the header. Other pages stay padded.
|
||||||
|
activeSection === 'dispatch' ? 'h-[calc(100vh-80px)] overflow-hidden' : 'min-h-[calc(100vh-80px)]'
|
||||||
|
} ${sidebarOpen ? 'md:pl-64' : 'md:pl-20'}`}
|
||||||
>
|
>
|
||||||
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
{activeSection === 'dispatch' ? (
|
||||||
{renderSection()}
|
<div className="w-full h-full">{renderSection()}</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
||||||
|
{renderSection()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
304
src/components/consoleUi.tsx
Normal file
304
src/components/consoleUi.tsx
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Console UI kit — the shared visual language ported from the operations console
|
||||||
|
* (nearle_console). Orders / Deliveries / Delivery-Reports all render against this
|
||||||
|
* so they match the source design exactly: brand purple #662582, the tint/soft/
|
||||||
|
* ring/edge alpha scale, pill filters + tabs, KPI cards with a gradient top-bar,
|
||||||
|
* status chips, metric pills, stamp cells, gradient headers and total bars.
|
||||||
|
*
|
||||||
|
* Per the design's own model, accent colours are data-driven (per status / per
|
||||||
|
* card), so colour-bearing bits use inline styles (the natural translation of the
|
||||||
|
* source's MUI `sx`) while layout/spacing use Tailwind.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// ── Design tokens ────────────────────────────────────────────────────────────────
|
||||||
|
export const BRAND = '#662582';
|
||||||
|
export const BRAND_LIGHT = '#9255AB';
|
||||||
|
export const TEXT = '#0f172a';
|
||||||
|
export const TEXT_2 = '#64748b';
|
||||||
|
export const TEXT_3 = '#94a3b8';
|
||||||
|
export const BORDER = '#e2e8f0';
|
||||||
|
export const DIVIDER = '#f1f5f9';
|
||||||
|
export const SURFACE_ALT = '#f8fafc';
|
||||||
|
export const SHADOW_MD = '0 8px 24px rgba(15, 23, 42, 0.08)';
|
||||||
|
export const SHADOW_SOFT = '0 14px 40px rgba(15, 23, 42, 0.10)';
|
||||||
|
export const SHADOW_POP = '0 18px 50px rgba(15, 23, 42, 0.18)';
|
||||||
|
|
||||||
|
/** Alpha helpers — append #RRGGBBAA suffixes (08≈3%, 18≈9%, 26≈15%, 55≈33%). */
|
||||||
|
export const tint = (c: string) => `${c}08`;
|
||||||
|
export const soft = (c: string) => `${c}18`;
|
||||||
|
export const ring = (c: string) => `${c}26`;
|
||||||
|
export const edge = (c: string) => `${c}55`;
|
||||||
|
|
||||||
|
// ── Status colour maps ───────────────────────────────────────────────────────────
|
||||||
|
/** Order lifecycle (orders board). */
|
||||||
|
export const ORDER_STATUS: Record<string, string> = {
|
||||||
|
created: '#0ea5e9',
|
||||||
|
pending: '#f59e0b',
|
||||||
|
processing: '#0ea5e9',
|
||||||
|
modified: '#06b6d4',
|
||||||
|
confirmed: '#10b981',
|
||||||
|
accepted: '#6366f1',
|
||||||
|
ready: '#6366f1',
|
||||||
|
delivered: '#10b981',
|
||||||
|
cancelled: '#ef4444',
|
||||||
|
};
|
||||||
|
/** Delivery lifecycle (STATUS_META). */
|
||||||
|
export const DELIVERY_STATUS: Record<string, string> = {
|
||||||
|
pending: '#f59e0b',
|
||||||
|
accepted: '#6366f1',
|
||||||
|
arrived: '#06b6d4',
|
||||||
|
picked: '#8b5cf6',
|
||||||
|
active: '#14b8a6',
|
||||||
|
skipped: '#f97316',
|
||||||
|
delivered: '#10b981',
|
||||||
|
cancelled: '#ef4444',
|
||||||
|
};
|
||||||
|
export const statusColor = (map: Record<string, string>, s: string) => map[s.toLowerCase()] || TEXT_2;
|
||||||
|
|
||||||
|
// ── Gradient header ──────────────────────────────────────────────────────────────
|
||||||
|
export function GradientHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
status,
|
||||||
|
right,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
status?: React.ReactNode;
|
||||||
|
right?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border p-4 sm:p-5 mb-4"
|
||||||
|
style={{ borderColor: BORDER, background: `linear-gradient(135deg, ${tint(BRAND)} 0%, ${tint(BRAND_LIGHT)} 100%)`, boxShadow: SHADOW_MD }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<span
|
||||||
|
className="hidden sm:flex shrink-0 items-center justify-center rounded-2xl text-white"
|
||||||
|
style={{ width: 46, height: 46, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
|
||||||
|
>
|
||||||
|
<BrandMark />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="font-extrabold tracking-tight leading-tight text-[1.4rem] md:text-[1.75rem]" style={{ color: TEXT }}>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{subtitle && <p className="text-xs mt-0.5" style={{ color: TEXT_2 }}>{subtitle}</p>}
|
||||||
|
{status && <div className="mt-1">{status}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{right && <div className="shrink-0">{right}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrandMark() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 7h13l5 5-5 5H3z" />
|
||||||
|
<circle cx="8" cy="12" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live / loading / error status line used under the header title. */
|
||||||
|
export function LiveStatus({ state, label }: { state: 'live' | 'loading' | 'error'; label: string }) {
|
||||||
|
const color = state === 'error' ? '#ef4444' : state === 'loading' ? '#94a3b8' : '#10b981';
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold" style={{ color: TEXT_2 }}>
|
||||||
|
<span
|
||||||
|
className={`rounded-full ${state === 'loading' ? 'animate-pulse' : ''}`}
|
||||||
|
style={{ width: 8, height: 8, background: color, boxShadow: state === 'live' ? `0 0 0 4px ${color}2e` : undefined }}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── KPI cards ────────────────────────────────────────────────────────────────────
|
||||||
|
export interface KpiItem {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
color: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
badge?: string;
|
||||||
|
}
|
||||||
|
export function KpiStrip({ items, loading }: { items: KpiItem[]; loading?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4">
|
||||||
|
{items.map((it) => (
|
||||||
|
<div
|
||||||
|
key={it.label}
|
||||||
|
className="relative overflow-hidden rounded-2xl border bg-white p-3.5 sm:p-5 transition-all duration-200 hover:-translate-y-0.5"
|
||||||
|
style={{ borderColor: BORDER }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = SHADOW_MD)}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
|
||||||
|
>
|
||||||
|
<div className="absolute top-0 left-0 right-0" style={{ height: 3, background: `linear-gradient(90deg, ${it.color} 0%, ${soft(it.color)} 100%)` }} />
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[10px] sm:text-[11px] font-bold uppercase tracking-wide truncate" style={{ color: TEXT_2, letterSpacing: 0.4 }}>
|
||||||
|
{it.label}
|
||||||
|
</p>
|
||||||
|
<p className="font-extrabold leading-none mt-1.5 text-[1.4rem] sm:text-[1.6rem]" style={{ color: TEXT }}>
|
||||||
|
{loading ? '—' : it.value}
|
||||||
|
</p>
|
||||||
|
{it.badge && (
|
||||||
|
<span className="inline-flex items-center mt-1.5 rounded-full font-extrabold" style={{ padding: '1px 8px', fontSize: 10.5, background: soft(it.color), color: it.color }}>
|
||||||
|
{it.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded-full flex items-center justify-center"
|
||||||
|
style={{ width: 46, height: 46, background: soft(it.color), color: it.color, boxShadow: `inset 0 0 0 1px ${edge(it.color)}` }}
|
||||||
|
>
|
||||||
|
{it.icon}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pill (filter chip / tab) ─────────────────────────────────────────────────────
|
||||||
|
interface PillProps {
|
||||||
|
active: boolean;
|
||||||
|
color: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
count?: number | string;
|
||||||
|
}
|
||||||
|
export function Pill({ active, color, onClick, title, children, count }: PillProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full font-bold whitespace-nowrap transition-all duration-150 cursor-pointer shrink-0"
|
||||||
|
style={
|
||||||
|
active
|
||||||
|
? { padding: '5px 12px', fontSize: 12.5, background: color, color: '#fff', border: `1.5px solid ${color}`, boxShadow: `0 6px 18px ${ring(color)}` }
|
||||||
|
: { padding: '5px 12px', fontSize: 12.5, background: tint(color), color, border: `1.5px solid ${edge(color)}` }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{count != null && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center justify-center rounded-full font-extrabold"
|
||||||
|
style={
|
||||||
|
active
|
||||||
|
? { minWidth: 22, height: 18, padding: '0 6px', fontSize: 10.5, background: 'rgba(255,255,255,0.22)', color: '#fff' }
|
||||||
|
: { minWidth: 22, height: 18, padding: '0 6px', fontSize: 10.5, background: '#fff', color, border: `1px solid ${edge(color)}` }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status chip (table cell) ─────────────────────────────────────────────────────
|
||||||
|
export function StatusChip({ label, color }: { label: string; color: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded-full font-extrabold uppercase whitespace-nowrap"
|
||||||
|
style={{ padding: '3px 9px', fontSize: 10.5, background: tint(color), border: `1px solid ${edge(color)}`, color, letterSpacing: 0.3 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Metric pill (km / amount / count cells) ──────────────────────────────────────
|
||||||
|
export function MetricPill({ color, children, minWidth }: { color: string; children: React.ReactNode; minWidth?: number }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center justify-center gap-1 rounded-full font-extrabold whitespace-nowrap"
|
||||||
|
style={{ padding: '2px 9px', fontSize: 11, background: tint(color), border: `1px solid ${edge(color)}`, color, minWidth }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stamp cell (date over time) ──────────────────────────────────────────────────
|
||||||
|
export function StampCell({ date, time }: { date?: string; time?: string }) {
|
||||||
|
if (!date && !time) return <span style={{ color: TEXT_3 }}>—</span>;
|
||||||
|
return (
|
||||||
|
<div className="leading-tight">
|
||||||
|
{date && <div className="text-[11px] font-semibold whitespace-nowrap" style={{ color: TEXT_2 }}>{date}</div>}
|
||||||
|
{time && <div className="text-[11px] font-extrabold whitespace-nowrap" style={{ color: TEXT }}>{time}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search pill ──────────────────────────────────────────────────────────────────
|
||||||
|
export function SearchPill({ value, onChange, placeholder, color = BRAND }: { value: string; onChange: (v: string) => void; placeholder?: string; color?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<svg className="absolute left-3 top-1/2 -translate-y-1/2" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2.5" strokeLinecap="round">
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="block w-full rounded-full outline-none font-medium transition-all box-border"
|
||||||
|
style={{ height: 38, paddingLeft: 32, paddingRight: value ? 30 : 14, fontSize: 12.5, background: tint(color), border: `1.5px solid ${edge(color)}`, color: TEXT }}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = color; e.currentTarget.style.boxShadow = `0 0 0 3px ${ring(color)}`; }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = edge(color); e.currentTarget.style.boxShadow = 'none'; }}
|
||||||
|
/>
|
||||||
|
{value && (
|
||||||
|
<button onClick={() => onChange('')} className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer" style={{ color: TEXT_3 }} title="Clear">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Card shells & table head cell ────────────────────────────────────────────────
|
||||||
|
export function Card({ children, className = '', flush }: { children: React.ReactNode; className?: string; flush?: 'top' }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`bg-white border ${flush === 'top' ? 'rounded-b-2xl' : 'rounded-2xl'} ${className}`}
|
||||||
|
style={{ borderColor: BORDER }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter/tab bar paper that visually joins the table below it (flat bottom). */
|
||||||
|
export function FilterBar({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`bg-white border rounded-2xl p-3 sm:p-4 ${className}`} style={{ borderColor: BORDER, boxShadow: SHADOW_MD }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TH_STYLE: React.CSSProperties = {
|
||||||
|
background: SURFACE_ALT,
|
||||||
|
color: TEXT_2,
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
|
};
|
||||||
124
src/services/dispatchMockData.ts
Normal file
124
src/services/dispatchMockData.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample dispatch data for the Dispatch page.
|
||||||
|
*
|
||||||
|
* The live Fiesta deliveries feed (getdeliveries) can come back empty for a given
|
||||||
|
* day/tenant, which leaves the Dispatch cockpit blank. This module provides a
|
||||||
|
* realistic sample set — shaped exactly like the rows `DispatchView` consumes —
|
||||||
|
* so the page demonstrates the rider/zone grouping, waves, and planned-route view.
|
||||||
|
*
|
||||||
|
* It is a DEV/DEMO fallback only: DispatchView uses it when the live query returns
|
||||||
|
* no rows, and clearly labels the header "Sample data" so it never masquerades as
|
||||||
|
* live. Delete this file (and the fallback in DispatchView) once the live feed is
|
||||||
|
* reliably populated.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Row } from './fiestaApi';
|
||||||
|
|
||||||
|
/** Approx zone centroids in Coimbatore — drop pins are jittered around these. */
|
||||||
|
const ZONE_COORDS: Record<string, [number, number]> = {
|
||||||
|
'RS Puram': [11.0069, 76.9498],
|
||||||
|
'Gandhipuram': [11.0183, 76.9725],
|
||||||
|
'Peelamedu': [11.0259, 77.001],
|
||||||
|
'Saibaba Colony': [11.0233, 76.943],
|
||||||
|
'Singanallur': [10.998, 77.029],
|
||||||
|
};
|
||||||
|
const HUB: [number, number] = [11.0045, 76.955]; // Ragul Stores hub (pickup)
|
||||||
|
|
||||||
|
/** Build one delivery row in the Fiesta shape DispatchView reads. */
|
||||||
|
function mk(
|
||||||
|
deliveryid: number,
|
||||||
|
userid: number,
|
||||||
|
ridername: string,
|
||||||
|
zone: string,
|
||||||
|
status: string,
|
||||||
|
assign: string,
|
||||||
|
kms: number,
|
||||||
|
profit: number,
|
||||||
|
step: number,
|
||||||
|
trip: number,
|
||||||
|
customer: string,
|
||||||
|
address: string,
|
||||||
|
expected: string,
|
||||||
|
delivered: string,
|
||||||
|
charge: number,
|
||||||
|
): Row {
|
||||||
|
const [zlat, zlon] = ZONE_COORDS[zone] ?? [11.0168, 76.9558];
|
||||||
|
const droplat = zlat + ((deliveryid % 7) - 3) * 0.0016;
|
||||||
|
const droplon = zlon + ((deliveryid % 5) - 2) * 0.0018;
|
||||||
|
return {
|
||||||
|
orderid: `RS-${deliveryid}`,
|
||||||
|
deliveryid,
|
||||||
|
orderheaderid: deliveryid,
|
||||||
|
tenantname: 'Ragul Stores',
|
||||||
|
orderstatus: status,
|
||||||
|
userid,
|
||||||
|
ridername,
|
||||||
|
username: ridername,
|
||||||
|
deliverysuburb: zone,
|
||||||
|
zone_name: zone,
|
||||||
|
deliverycustomer: customer,
|
||||||
|
deliverycontactno: '+91 98430 0' + String(1000 + deliveryid).slice(-4),
|
||||||
|
deliveryaddress: address,
|
||||||
|
pickupcustomer: 'Ragul Stores Hub',
|
||||||
|
kms,
|
||||||
|
cumulativekms: delivered ? kms : 0,
|
||||||
|
profit,
|
||||||
|
step,
|
||||||
|
trip_number: trip,
|
||||||
|
assigntime: `2026-06-09 ${assign}:00`,
|
||||||
|
expecteddeliverytime: expected ? `2026-06-09 ${expected}:00` : '',
|
||||||
|
deliverytime: delivered ? `2026-06-09 ${delivered}:00` : '',
|
||||||
|
deliverycharge: charge,
|
||||||
|
deliveryamt: charge,
|
||||||
|
droplat,
|
||||||
|
droplon,
|
||||||
|
deliverylat: droplat,
|
||||||
|
deliverylong: droplon,
|
||||||
|
pickuplat: HUB[0],
|
||||||
|
pickuplong: HUB[1],
|
||||||
|
locationid: 1097,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ~16 deliveries across 4 riders + 1 unassigned, 5 zones, and all 3 waves. */
|
||||||
|
export const MOCK_DELIVERIES: Row[] = [
|
||||||
|
// ── Suresh Kumar · RS Puram · Morning (trip 1) ──
|
||||||
|
mk(5001, 101, 'Suresh Kumar', 'RS Puram', 'delivered', '06:40', 2.4, 38, 1, 1, 'Arun Prasad', '12, Lawley Rd, RS Puram', '07:00', '06:58', 35),
|
||||||
|
mk(5002, 101, 'Suresh Kumar', 'RS Puram', 'delivered', '06:45', 3.1, 42, 2, 1, 'Deepa Lakshmi', '45, DB Rd, RS Puram', '07:20', '07:25', 40),
|
||||||
|
mk(5003, 101, 'Suresh Kumar', 'RS Puram', 'picked', '06:45', 1.8, 30, 3, 1, 'Mohammed Irfan', '8, Bashyakarlu Rd, RS Puram', '07:40', '', 30),
|
||||||
|
|
||||||
|
// ── Vignesh R · Gandhipuram / Peelamedu · Afternoon (trip 1) ──
|
||||||
|
mk(5004, 102, 'Vignesh R', 'Gandhipuram', 'delivered', '09:30', 2.0, 35, 1, 1, 'Sangeetha R', '23, 100 Feet Rd, Gandhipuram', '10:00', '09:58', 35),
|
||||||
|
mk(5005, 102, 'Vignesh R', 'Gandhipuram', 'delivered', '09:30', 2.6, 40, 2, 1, 'Bala Subramani', '5, Cross Cut Rd, Gandhipuram', '10:25', '10:30', 38),
|
||||||
|
mk(5006, 102, 'Vignesh R', 'Peelamedu', 'active', '09:35', 4.2, 55, 3, 1, 'Nithya K', '78, Sathy Rd, Peelamedu', '10:55', '', 50),
|
||||||
|
mk(5007, 102, 'Vignesh R', 'Peelamedu', 'accepted', '09:35', 3.3, 45, 4, 1, 'Ramesh Babu', '16, Avinashi Rd, Peelamedu', '11:20', '', 42),
|
||||||
|
|
||||||
|
// ── Karthik M · Saibaba Colony · Afternoon (trips 1 & 2) ──
|
||||||
|
mk(5008, 103, 'Karthik M', 'Saibaba Colony', 'delivered', '10:40', 2.9, 41, 1, 1, 'Kavya S', '34, NSR Rd, Saibaba Colony', '11:10', '11:05', 40),
|
||||||
|
mk(5009, 103, 'Karthik M', 'Saibaba Colony', 'arrived', '10:40', 3.6, 48, 2, 1, 'Vijay Anand', '9, Mettupalayam Rd, Saibaba Colony', '11:35', '', 45),
|
||||||
|
mk(5010, 103, 'Karthik M', 'Saibaba Colony', 'pending', '10:45', 2.2, 33, 3, 1, 'Meena G', '61, Thadagam Rd, Saibaba Colony', '12:00', '', 32),
|
||||||
|
mk(5011, 103, 'Karthik M', 'Saibaba Colony', 'cancelled', '11:50', 5.1, 0, 1, 2, 'Hariharan', '2, Ganapathy, Saibaba Colony', '12:20', '', 0),
|
||||||
|
|
||||||
|
// ── Priya S · Singanallur / Peelamedu · Evening (trip 1) ──
|
||||||
|
mk(5012, 104, 'Priya S', 'Singanallur', 'delivered', '16:20', 3.0, 44, 1, 1, 'Divya R', '19, Trichy Rd, Singanallur', '16:50', '16:47', 42),
|
||||||
|
mk(5013, 104, 'Priya S', 'Singanallur', 'active', '16:20', 4.5, 58, 2, 1, 'Senthil Kumar', '88, Singanallur Main Rd', '17:15', '', 55),
|
||||||
|
mk(5014, 104, 'Priya S', 'Singanallur', 'picked', '16:25', 2.7, 39, 3, 1, 'Anitha M', '7, Ondipudur, Singanallur', '17:40', '', 38),
|
||||||
|
mk(5015, 104, 'Priya S', 'Peelamedu', 'accepted', '16:25', 3.9, 50, 4, 1, 'Gokul Raj', '52, Hope College, Peelamedu', '18:05', '', 48),
|
||||||
|
|
||||||
|
// ── Unassigned · Peelamedu · Evening ──
|
||||||
|
mk(5016, 0, '', 'Peelamedu', 'pending', '17:25', 2.3, 34, 1, 1, 'Lakshmi Narayanan', '30, Lakshmi Mills, Peelamedu', '18:30', '', 33),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Sample active rider fleet (DispatchView only reads the count). */
|
||||||
|
export const MOCK_RIDERS: Row[] = [
|
||||||
|
{ userid: 101, firstname: 'Suresh', lastname: 'Kumar', contactno: '+91 98430 01101', starttime: '2026-06-09 06:30:00' },
|
||||||
|
{ userid: 102, firstname: 'Vignesh', lastname: 'R', contactno: '+91 98430 01102', starttime: '2026-06-09 09:15:00' },
|
||||||
|
{ userid: 103, firstname: 'Karthik', lastname: 'M', contactno: '+91 98430 01103', starttime: '2026-06-09 10:20:00' },
|
||||||
|
{ userid: 104, firstname: 'Priya', lastname: 'S', contactno: '+91 98430 01104', starttime: '2026-06-09 16:00:00' },
|
||||||
|
{ userid: 105, firstname: 'Mahesh', lastname: 'V', contactno: '+91 98430 01105', starttime: '' },
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user