update on the user page regardinga the dispatch and order page and the deliveries page

This commit is contained in:
2026-06-09 16:01:26 +05:30
parent 9f25c5f60a
commit d8c1517239
16 changed files with 14328 additions and 43 deletions

469
docs/FIESTA_BACKEND_API.md Normal file
View 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
View File

@@ -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",

View File

@@ -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": {

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

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

View 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='&copy; <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>
);
}

File diff suppressed because it is too large Load Diff

View 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 (89, 12:3016, 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>
))}
</>
);
}

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

View File

@@ -8,7 +8,6 @@ import {
LayoutDashboard, LayoutDashboard,
Store, Store,
Layers, Layers,
ShoppingBag,
Settings, Settings,
TrendingUp TrendingUp
} from 'lucide-react'; } from 'lucide-react';

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

View File

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

View File

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

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

View 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: '' },
];