diff --git a/docs/FIESTA_BACKEND_API.md b/docs/FIESTA_BACKEND_API.md new file mode 100644 index 0000000..0f09512 --- /dev/null +++ b/docs/FIESTA_BACKEND_API.md @@ -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). diff --git a/package-lock.json b/package-lock.json index d3b8b28..e73bc34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,13 +11,16 @@ "@google/genai": "^2.4.0", "@tailwindcss/vite": "^4.1.14", "@tanstack/react-query": "^5.101.0", + "@types/leaflet": "^1.9.21", "@vitejs/plugin-react": "^5.0.4", "dotenv": "^17.2.3", "express": "^4.21.2", + "leaflet": "^1.9.4", "lucide-react": "^0.546.0", "motion": "^12.23.24", "react": "^19.0.1", "react-dom": "^19.0.1", + "react-leaflet": "^5.0.0", "vite": "^6.2.3" }, "devDependencies": { @@ -843,6 +846,17 @@ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", "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": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1551,6 +1565,12 @@ "@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": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -1558,6 +1578,15 @@ "dev": true, "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": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -2655,6 +2684,13 @@ "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": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -3333,6 +3369,20 @@ "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": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", diff --git a/package.json b/package.json index bdaf6e4..0aa2e31 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,16 @@ "@google/genai": "^2.4.0", "@tailwindcss/vite": "^4.1.14", "@tanstack/react-query": "^5.101.0", + "@types/leaflet": "^1.9.21", "@vitejs/plugin-react": "^5.0.4", "dotenv": "^17.2.3", "express": "^4.21.2", + "leaflet": "^1.9.4", "lucide-react": "^0.546.0", "motion": "^12.23.24", "react": "^19.0.1", "react-dom": "^19.0.1", + "react-leaflet": "^5.0.0", "vite": "^6.2.3" }, "devDependencies": { diff --git a/src/App.tsx b/src/App.tsx index 4fd963c..a55c1c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -593,10 +593,10 @@ export default function App() { )} {currentSection === 'reports' && ( - )} diff --git a/src/components/DeliveriesView.tsx b/src/components/DeliveriesView.tsx new file mode 100644 index 0000000..7eb5f92 --- /dev/null +++ b/src/components/DeliveriesView.tsx @@ -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(ymd(today)); + const [todate, setTodate] = useState(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(initialBatch()); + const [status, setStatus] = useState('pending'); + const [localSearch, setLocalSearch] = useState(''); + const [detailRow, setDetailRow] = useState(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 = {}; + 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: , badge: undefined }, + { label: 'Pending', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#f59e0b', icon: , badge: pct(summary?.pending ?? 0) }, + { label: 'Delivered', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#10b981', icon: , badge: pct(summary?.delivered ?? 0) }, + { label: 'Cancelled', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#ef4444', icon: , badge: pct(summary?.cancelled ?? 0) }, + ]; + + return ( +
+ + : deliveriesQ.isError ? + : + } + right={ + + Coimbatore + + } + /> + +
+ + {/* Date + waves */} + +
+
+ View + {presets.map((p) => ( + { setFromdate(p.from); setTodate(p.to); }}>{p.label} + ))} +
+
+ 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' }} /> + + 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' }} /> +
+
+
+ Wave + {BATCHES.map((b) => { + const Icon = b.icon; + const count = allRows.filter((r) => (!locationid || fnum(r.locationid) === locationid) && inBatch(r, b.id)).length; + return ( + + setBatch(b.id)} title={b.range} count={count}> {b.label} + + ); + })} +
+
+ + {/* Status tabs + search */} + +
+
+ {STATUS_TABS.map((t) => { + const color = statusColor(DELIVERY_STATUS, t.key); + return ( + + setStatus(t.key)} count={statusCounts[t.key] ?? 0}>{t.label} + + ); + })} +
+
+
+
+ + {/* Table */} +
+
+ + + {['#', 'Status', 'Order', 'Drop', 'Rider', 'ETA', 'KMs', 'Amount', ''].map((h, i) => ())} + + + {deliveriesQ.isLoading ? ( + + ) : rows.length === 0 ? ( + + ) : ( + 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 ( + (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}> + + + + + + + + + + + ); + }) + )} + +
{h}
Loading deliveries…
No {status} deliveries in this wave. Try another status, wave, or date.
{i + 1} +

{fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`}

+

{shortTime(r.assigntime || r.deliverydate)}

+
+

{fstr(r.deliverycustomer) || '—'}

+

{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}

+
+ {rider ? ( + + + {rider} + + ) : Unassigned} + {shortTime(r.expecteddeliverytime) || '—'} +
+ {kms ? kms.toFixed(1) : '—'} + {actualKms > 0 && {actualKms.toFixed(1)}} +
+
+
+ {charge > 0 && ₹{charge.toLocaleString('en-IN')}} + {amt > 0 && ₹{amt.toLocaleString('en-IN')}} + {charge === 0 && amt === 0 && } +
+
+ +
+
+
+ {rows.length} {status} · {BATCHES.find((b) => b.id === batch)?.label} wave +
+
+ + {detailRow && setDetailRow(null)} />} +
+ ); +} + +// ── 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 ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+
+
+

{fstr(row.orderid) || `Delivery ${fstr(row.deliveryid)}`}

+ +
+
+
+ + {rider || 'Unassigned'} +
+
+
{fstr(row.deliverycustomer) || 'Customer'}
+ {fstr(row.deliverycontactno) &&
{fstr(row.deliverycontactno)}
} +
{fstr(row.deliveryaddress) || fstr(row.deliverysuburb) || 'Address unavailable'}
+
+
+ Delivery Timeline +
+ {steps.map((s) => { + const ts = fstr(row[s.field]); const done = Boolean(ts); + return ( +
+ + {s.label} + {done ? shortTime(ts) : '—'} +
+ ); + })} +
+
+
+ Items +
+ {detailsQ.isLoading &&
Loading items…
} + {!detailsQ.isLoading && lines.length === 0 &&
No line items returned.
} + {lines.map((item, idx) => ( +
+

{item.name}

Qty: {item.quantity} × ₹{item.price}

+ ₹{item.lineTotal.toLocaleString('en-IN')} +
+ ))} +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/src/components/DeliveryReportsView.tsx b/src/components/DeliveryReportsView.tsx new file mode 100644 index 0000000..5a5d097 --- /dev/null +++ b/src/components/DeliveryReportsView.tsx @@ -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(ymd(monthStart)); + const [todate, setTodate] = useState(ymd(today)); + const [tab, setTab] = useState('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 ( +
+ + + {/* Tab nav */} + +
+ {TABS.map((t) => { + const Icon = t.icon; + return ( + + setTab(t.key)}> {t.label} + + ); + })} +
+
+ + {/* Shared date range */} + +
+
+ Period + {presets.map((p) => ( + { setFromdate(p.from); setTodate(p.to); }}>{p.label} + ))} +
+
+ 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' }} /> + + 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' }} /> +
+
+
+ + {tab === 'orders-summary' && } + {tab === 'riders-summary' && } + {tab === 'orders-details' && } + {tab === 'maps' && ( +
+ Planned routes & live rider logs + +
+ )} +
+ ); +} + +const Cnt = ({ n, color }: { n: number; color: string }) => (n > 0 ? {n.toLocaleString('en-IN')} : 0); + +function TableShell({ minWidth, head, children, footer }: { minWidth: number; head: string[]; children: React.ReactNode; footer?: React.ReactNode }) { + return ( +
+
+ + {head.map((h, i) => ())} + {children} +
{h}
+
+ {footer} +
+ ); +} + +// ── 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: }, + { label: 'Pending', value: totals.pending.toLocaleString('en-IN'), color: '#f59e0b', icon: }, + { label: 'Delivered', value: totals.delivered.toLocaleString('en-IN'), color: '#10b981', icon: }, + { label: 'Outlets', value: rows.length.toLocaleString('en-IN'), color: '#0ea5e9', icon: }, + ]; + return ( +
+ + 0 ? : undefined}> + {q.isLoading ? Loading outlet summary… + : rows.length === 0 ? No outlet data available. + : rows.map((r, i) => ( + (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}> + {i + 1} + {r.locationname || `Location ${r.locationid}`} + {r.total.toLocaleString('en-IN')} + + + + + + + ))} + +
+ ); +} + +// ── 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: }, + { label: 'Total Orders', value: totals.orders.toLocaleString('en-IN'), color: '#0ea5e9', icon: }, + { label: 'Delivered', value: totals.delivered.toLocaleString('en-IN'), color: '#10b981', icon: }, + { label: 'Total Amount', value: `₹${totals.amount.toLocaleString('en-IN')}`, color: '#f59e0b', icon: }, + ]; + return ( +
+ + 0 ? : undefined}> + {q.isLoading ? Loading rider summary… + : q.isError ? Rider summary unavailable for this period. + : mapped.length === 0 ? No rider activity in this period. + : mapped.map((r, i) => ( + (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}> + {i + 1} + + + + {r.name} + + + {r.orders.toLocaleString('en-IN')} + + + + {r.kms ? r.kms.toFixed(1) : '—'}{r.actualKms > 0 ? ` / ${r.actualKms.toFixed(1)}` : ''} + {r.amount > 0 ? ₹{r.amount.toLocaleString('en-IN')} : } + + ))} + +
+ ); +} + +// ── 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('all'); + const [localSearch, setLocalSearch] = useState(''); + + const statusCounts = useMemo(() => { + const acc: Record = {}; + 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 ( +
+ +
+
+ {DETAIL_STATUSES.map((s) => { + const color = s === 'all' ? BRAND : statusColor(DELIVERY_STATUS, s); + return ( + + setStatus(s)} count={s === 'all' ? allRows.length : statusCounts[s] ?? 0}> + {s} + + + ); + })} +
+
+
+ +
+
+
+ + {rows.length} rows · {fromdate} → {todate}
}> + {q.isLoading ? Loading order details… + : rows.length === 0 ? No deliveries match this filter. + : 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 ( + (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}> + {i + 1} + +

{fstr(r.orderid) || `DLV-${fstr(r.deliveryid)}`}

+

{shortTime(r.deliverydate || r.assigntime)}

+ + +

{fstr(r.deliverycustomer) || '—'}

+

{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}

+ + {rider || '—'} + {shortTime(r.assigntime) || '—'} + {fstr(r.deliverytime) ? shortTime(r.deliverytime) : '—'} + {fnum(r.kms) ? {fnum(r.kms).toFixed(1)} : } + {charge > 0 ? ₹{charge.toLocaleString('en-IN')} : } + + + ); + })} + +
+ ); +} + +// ── Total bar (gradient) ───────────────────────────────────────────────────────── +function TotalBar({ chips, grand }: { chips: Array<{ label: string; color: string }>; grand?: string }) { + return ( +
+
+ {chips.map((c, i) => ( + {c.label} + ))} +
+ {grand && ( + {grand} + )} +
+ ); +} diff --git a/src/components/DispatchMap.tsx b/src/components/DispatchMap.tsx new file mode 100644 index 0000000..135e3df --- /dev/null +++ b/src/components/DispatchMap.tsx @@ -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; +} + +// ── Popup helpers (replicated from nearle_console renderOrderPopupContent) ──────── +const STATUS_HEX: Record = { + 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 ( +
+
{icon}
+
+
{label}
+
{value}
+
+
+ ); +} + +/** The centered click popup — same structure/classes as the source console. */ +function OrderPopup({ o, onClose }: { o: Record; 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 ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+ +
+
+
+
ORDER #{S(o.orderid) || S(o.deliveryid)}
+ {st && {ss.label}} +
+
{rider}
+ {customer &&
{customer}
} + {o.deliveryid != null &&
Delivery #{S(o.deliveryid)}
} +
+ +
+ {hasTimeline && ( +
+
Timeline
+
+ {POPUP_TIMELINE.map((t) => { + const time = fmtTime(o[t.key]); + if (!time) return null; + return ( +
+ + {t.label} + {time} +
+ ); + })} +
+
+ )} + +
+
Details
+
+ {pickup && } label="Pickup" value={pickup} />} + {drop && } label="Drop" value={drop} />} + {zone && } label="Zone" value={zone} />} + {riderId ? } label="Rider ID" value={`#${S(riderId)}`} /> : null} +
+ + {hasDistance && ( +
+ {kms != null && kms !== '' && ( +
+ + Planned + {S(kms)} km +
+ )} + {actual != null && Number(actual) > 0 && ( +
+ + Actual + {Number(actual).toFixed(2)} km +
+ )} +
+ )} +
+
+
+
+
+ ); +} + +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 | 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: `
H
`, + 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: `
${step}
`, + 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 | 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 | 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(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 ( +
+ + + {route && shownLine.length > 1 && ( + + )} + {route && start && ( + setSelected({ orderid: 'PICKUP', deliverycustomer: 'Pickup hub', pickupcustomer: 'Ragul Stores Hub' }) }} /> + )} + {points.map((p) => ( + setSelected(p.raw) }} + /> + ))} + + + + {selected && setSelected(null)} />} +
+ ); +} diff --git a/src/components/DispatchView.css b/src/components/DispatchView.css new file mode 100644 index 0000000..6c46e7d --- /dev/null +++ b/src/components/DispatchView.css @@ -0,0 +1,10923 @@ +:root { + --bg: #ffffff; + --bg-sub: #f8fafc; + --bg-card: #ffffff; + --border: #e2e8f0; + --border-active: #3b82f6; + --text: #1e293b; + --text-muted: #64748b; + --accent: #3b82f6; + --accent-soft: rgba(59, 130, 246, 0.08); + --kitchen: #f59e0b; + --kitchen-soft: rgba(245, 158, 11, 0.1); + --success: #22c55e; + --shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + --shadow-lg: 0 10px 25px -5px rgba(0, 0, 0, 0.08); +} + +.dispatch-container { + width: calc(100% + 48px); + height: calc(100vh - 88px); + margin: -24px; + display: flex; + flex-direction: column; + background: var(--bg); + color: var(--text); + font-family: 'Inter', -apple-system, sans-serif; + overflow: hidden; + position: relative; +} + +/* Embedded mode: rendered inside a parent container (e.g. a Dialog), + so drop the negative margin and viewport-based sizing that assumes + the standalone /dispatch page is wrapped in MainCard's 24px padding. */ +.dispatch-container.embedded { + width: 100%; + height: 100%; + margin: 0; + flex: 1; + min-height: 0; +} + +.dispatch-container * { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* Header */ +.dispatch-container #hdr { + height: 56px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + background: var(--bg); + border-bottom: 1px solid var(--border); + z-index: 1010; +} + +.dispatch-container .logo { + display: flex; + align-items: center; + gap: 12px; +} + +.dispatch-container .logo-badge { + width: 32px; + height: 32px; + border-radius: 8px; + background: linear-gradient(135deg, #3b82f6, #2563eb); + display: flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 14px; + color: #fff; +} + +.dispatch-container .logo-name { + font-size: 18px; + font-weight: 800; + color: var(--text); + letter-spacing: -0.02em; +} + +.dispatch-container .logo-name em { + color: var(--accent); + font-style: normal; + opacity: 0.8; +} + +/* Operating-city pill — sits to the RIGHT of the "Dispatch" heading inline. */ +/* The location pill is now an interactive dropdown trigger. Wrapped in + .logo-city-wrap so the absolute-positioned menu below anchors to it. */ +.dispatch-container .logo-city-wrap { + position: relative; + display: inline-block; +} + +.dispatch-container .logo-city { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px 4px 10px; + border-radius: 999px; + background: rgba(123, 31, 162, 0.08); + border: 1px solid rgba(123, 31, 162, 0.25); + color: #7b1fa2; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + line-height: 1; + cursor: pointer; + font-family: inherit; + transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} + +.dispatch-container .logo-city:hover { + background: rgba(123, 31, 162, 0.14); + border-color: rgba(123, 31, 162, 0.45); +} + +.dispatch-container .logo-city.open { + background: rgba(123, 31, 162, 0.18); + border-color: rgba(123, 31, 162, 0.55); + box-shadow: 0 4px 12px rgba(123, 31, 162, 0.18); +} + +.dispatch-container .logo-city svg { + font-size: 13px; + flex-shrink: 0; +} + +.dispatch-container .logo-city-caret { + font-size: 15px; + transition: transform 0.2s ease; +} + +.dispatch-container .logo-city.open .logo-city-caret { + transform: rotate(180deg); +} + +.dispatch-container .logo-city-text { + max-width: 180px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Dropdown menu — anchored under the trigger, scrolls if there are many hubs. */ +.dispatch-container .logo-city-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 200px; + max-height: 320px; + overflow-y: auto; + background: #fff; + border: 1px solid rgba(123, 31, 162, 0.18); + border-radius: 12px; + box-shadow: 0 16px 36px rgba(15, 23, 42, 0.16); + padding: 6px; + z-index: 1000; + animation: logo-city-menu-in 0.14s ease-out; +} + +@keyframes logo-city-menu-in { + from { + opacity: 0; + transform: translateY(-4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.dispatch-container .logo-city-menu::-webkit-scrollbar { + width: 6px; +} + +.dispatch-container .logo-city-menu::-webkit-scrollbar-thumb { + background: rgba(123, 31, 162, 0.3); + border-radius: 999px; +} + +.dispatch-container .logo-city-option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 10px; + border: 0; + background: transparent; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + color: #1e293b; + cursor: pointer; + font-family: inherit; + text-align: left; + transition: background 0.12s ease; +} + +.dispatch-container .logo-city-option:hover { + background: rgba(123, 31, 162, 0.06); +} + +.dispatch-container .logo-city-option.active { + background: rgba(123, 31, 162, 0.1); + color: #7b1fa2; +} + +.dispatch-container .logo-city-option-icon { + font-size: 14px; + color: #7b1fa2; + flex-shrink: 0; +} + +.dispatch-container .logo-city-option span:not(.logo-city-option-check) { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dispatch-container .logo-city-option-check { + color: #7b1fa2; + font-weight: 800; + flex-shrink: 0; +} + +.dispatch-container .hdr-sep { + width: 1px; + height: 20px; + background: var(--border); + margin: 0 4px; +} + +.dispatch-container .hdr-meta { + font-size: 12px; + color: var(--text-muted); + font-weight: 500; +} + +.dispatch-container #clock { + font-size: 13px; + color: var(--text); + font-weight: 600; + font-family: 'JetBrains Mono', monospace; + background: var(--bg-sub); + padding: 7px 16px; + border-radius: 10px; + border: 1px solid var(--border); +} + +/* Header right-cluster — profit/loss + orders pill + date picker, sits to the + LEFT of the running clock. Pushed against the clock with margin-left:auto so + the .logo on the left stays anchored and the cluster floats right. */ +.dispatch-container .hdr-stats { + display: flex; + align-items: center; + gap: 16px; + margin-left: auto; + margin-right: 16px; + min-width: 0; + flex-wrap: nowrap; +} + +/* Tabs */ +.dispatch-container #strat-row { + height: 54px; + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 0 24px; + background: var(--bg); + border-bottom: 1px solid var(--border); +} + +.dispatch-container .sbt { + padding: 8px 14px; + border-radius: 10px; + border: 1px solid rgba(15, 23, 42, 0.08); + background: var(--bg); + color: var(--text-muted); + font-size: 13px; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 8px; + line-height: 1; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + font-family: inherit; +} + +.dispatch-container .sbt:hover { + background: var(--bg-sub); + color: var(--text); + border-color: var(--text-muted); +} + +.dispatch-container .sbt.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.25); +} + +/* SVG icon slot inside each tab button — fixed square, color inherits from button + so active-state white propagates without per-tab overrides. */ +.dispatch-container .sbt .sbt-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + font-size: 18px; + line-height: 1; + flex-shrink: 0; + color: inherit; +} + +.dispatch-container .sbt .sbt-icon svg { + width: 1em; + height: 1em; + display: block; + /* react-icons SVGs fill with currentColor by default — this just ensures + consistent baseline alignment with the label next to them. */ + vertical-align: middle; +} + +/* Strat-row quick stats — total orders + profit/loss chips next to the view-mode buttons */ +.dispatch-container .strat-stats { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: 8px; + padding-left: 12px; + border-left: 1px solid var(--border); + height: 32px; +} + +/* Right-floating variant — used for the profit/loss chip when there's no + live-controls block to nest inside. */ +.dispatch-container .strat-stats.strat-stats-right { + margin-left: auto; + padding-left: 0; + border-left: none; +} + +.dispatch-container .strat-stat { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 15px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + line-height: 1; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + transition: all 0.15s ease; + white-space: nowrap; +} + +.dispatch-container .strat-stat-icon { + font-size: 13px; + line-height: 1; +} + +.dispatch-container .strat-stat-label { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +.dispatch-container .strat-stat-value { + font-size: 13px; + font-weight: 800; +} + +.dispatch-container .strat-stat-orders { + background: var(--accent-soft); + border-color: rgba(59, 130, 246, 0.25); +} + +.dispatch-container .strat-stat-orders .strat-stat-value { + color: var(--accent); +} + +.dispatch-container .strat-stat-profit { + background: rgba(34, 197, 94, 0.1); + border-color: rgba(34, 197, 94, 0.3); +} + +.dispatch-container .strat-stat-profit .strat-stat-value, +.dispatch-container .strat-stat-profit .strat-stat-label { + color: var(--success); +} + +.dispatch-container .strat-stat-loss { + background: rgba(239, 68, 68, 0.1); + border-color: rgba(239, 68, 68, 0.35); +} + +.dispatch-container .strat-stat-loss .strat-stat-value, +.dispatch-container .strat-stat-loss .strat-stat-label { + color: #dc2626; +} + +/* Live data controls (date picker + load status) */ +.dispatch-container .live-controls { + margin-left: auto; + display: flex; + align-items: center; + gap: 12px; +} + +.dispatch-container .live-status { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + padding: 7px 15px; + border-radius: 999px; + background: var(--bg-sub); + border: 1px solid var(--border); +} + +.dispatch-container .live-status-ready { + color: var(--success); +} + +.dispatch-container .live-status-error { + color: #ef4444; +} + +.dispatch-container .live-status-sub { + color: var(--text-muted); + font-weight: 500; + font-size: 11px; + opacity: 0.85; +} + +.dispatch-container .live-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + animation: live-pulse 1.2s ease-in-out infinite; +} + +.dispatch-container .live-dot.ready { + background: var(--success); + animation: none; +} + +.dispatch-container .live-dot.error { + background: #ef4444; + animation: none; +} + +@keyframes live-pulse { + + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + + 50% { + opacity: 0.4; + transform: scale(0.85); + } +} + +/* ── Date picker chip ───────────────────────────────────────────── + Three-part pill: prev-day arrow ◂ | formatted-date card | ▸ next-day + arrow. The center card overlays a transparent native + so clicking anywhere on the chip opens the OS date dialog while still + showing a glanceable formatted value (`Mon, May 25, 2026`). A small + "Today" badge appears when the picked date matches today, and the + next-day arrow disables itself there. + + Design language: matches the Compare-button family — soft white card, + indigo border + halo on hover/focus, subtle lift on interaction. + ──────────────────────────────────────────────────────────────── */ +.dispatch-container .date-chip { + position: relative; + /* anchors .date-cal-popover */ + display: inline-flex; + align-items: stretch; + gap: 0; + background: #ffffff; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 12px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), + 0 4px 12px rgba(15, 23, 42, 0.06); + transition: border-color 0.18s ease, box-shadow 0.18s ease, + transform 0.18s ease; +} + +.dispatch-container .date-chip.is-open { + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2), + 0 12px 30px rgba(99, 102, 241, 0.22); +} + +.dispatch-container .date-chip:hover { + border-color: rgba(99, 102, 241, 0.45); + box-shadow: 0 2px 4px rgba(15, 23, 42, 0.06), + 0 8px 22px rgba(99, 102, 241, 0.15); + transform: translateY(-1px); +} + +.dispatch-container .date-chip:focus-within { + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2), + 0 8px 22px rgba(99, 102, 241, 0.22); +} + +/* Center card — visible chrome the operator reads. Renders as a +
+ + + + Date {isToday && Today} + + {prettyDate} + + { setDate(e.target.value); setFocusedId(null); }} + style={{ position: 'absolute', inset: 0, opacity: 0, cursor: 'pointer', width: '100%', height: '100%' }} + aria-label="Pick date" + /> +
+ + + + + + {/* ── View-mode tabs ── */} +
+ {VIEW_TABS.map((t) => { + const Icon = t.icon; + return ( + + ); + })} +
+ + {/* ── Batch / wave bar ── */} +
+ Batch +
+ {BATCHES.map((b) => ( + + ))} +
+
+ + {/* ── Body ── */} +
+ + + {/* Sidebar */} + + + {/* Map area */} +
+ {/* Live Leaflet route map */} + + + {/* Contextual note overlaid on the map */} + {viewMode === 'rider-info' ? ( +
+ Live rider telemetry (battery · GPS · speed) awaiting backend — map shows planned drops. +
+ ) : mapPoints.length === 0 ? ( +
+ No drop coordinates in this {focused ? 'route' : 'wave'} yet. +
+ ) : !focused ? ( +
+ Select a {viewMode === 'kitchens' ? 'pickup point' : viewMode === 'zones' ? 'zone' : 'rider'} to draw its route. +
+ ) : null} + + {/* bottom-right overlay controls (gated) */} +
+ + +
+
+
+ + + ); +} + +// ── 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 ( +
+
+
+ +
+
+
{g.name}
+
{zoneName} · {trips} trip{trips > 1 ? 's' : ''}
+
+
{g.delivered}/{total}
+
+
+
+
+
+ {g.totalKm.toFixed(1)} km + {g.profit > 0 && ₹{g.profit.toLocaleString('en-IN')}} +
+
+ {g.orders.slice(0, 16).map((o, i) => ( + S{fnum(o.step) || i + 1} + ))} +
+
+ ); +} + +// ── 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 ( +
+
+
+
+
{g.name}
+
{g.riders.size} rider{g.riders.size === 1 ? '' : 's'} · {g.orders.length} orders
+
+ +
+ {g.orders.length > 0 && ( +
+
+ {Object.entries(g.statusCounts).map(([s, c]) => ( +
+ ))} +
+
{g.delivered}/{g.orders.length}
+
+ )} +
+ + + {g.suburbs.size} + areas + + + + {g.totalKm.toFixed(0)} + km + + {g.profit > 0 && ( + + + ₹{g.profit.toLocaleString('en-IN')} + profit + + )} +
+ {suburbs.length > 0 && ( +
+ {suburbs.slice(0, 3).join(' · ')} + {suburbs.length > 3 && +{suburbs.length - 3}} +
+ )} +
+ ); +} + +// ── 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 ( + <> + + + {tripBlocks.map((blk, bi) => ( +
+
+ {blk.label} + + {blk.orders.length} stops + {blk.orders.reduce((a, o) => a + fnum(o.kms), 0).toFixed(1)} km + +
+ + +
+
+ +
+ {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 ( +
+
+
{step}
+
+
Order #{fstr(o.orderid) || fstr(o.deliveryid)}
+ {groupedByRider && fstr(o.ridername) && ( +
{fstr(o.ridername)}
+ )} +
+
+ {st && {st}} + {(actual || expected) && ( + + {fmtTime(actual || expected)} + + )} +
+
+ +
{fstr(o.deliverycustomer) || 'Customer'}
+ {fstr(o.pickupcustomer) && ( +
{fstr(o.pickupcustomer)}
+ )} + {(fstr(o.deliverysuburb) || fstr(o.deliveryaddress)) && ( +
{fstr(o.deliverysuburb) || fstr(o.deliveryaddress)}
+ )} + {fstr(o.ordernotes) && ( +
{fstr(o.ordernotes)}
+ )} + +
+ {km.toFixed(1)} km + {profit !== 0 && ( + + ₹{Math.abs(profit).toLocaleString('en-IN')} + + )} + {charge > 0 && ₹{charge} chg} + S{step} +
+
+ ); + })} +
+
+ ))} + + ); +} diff --git a/src/components/OrdersView.tsx b/src/components/OrdersView.tsx new file mode 100644 index 0000000..2115a4f --- /dev/null +++ b/src/components/OrdersView.tsx @@ -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(ymd(today)); + const [todate, setTodate] = useState(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('created'); + const [pageno, setPageno] = useState(1); + const [localSearch, setLocalSearch] = useState(''); + const [detailOrder, setDetailOrder] = useState(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: , badge: `${pct(summary?.created ?? 0)}% of total` }, + { label: 'Pending Orders', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#f59e0b', icon: , badge: `${pct(summary?.pending ?? 0)}% of total` }, + { label: 'Delivered Orders', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#10b981', icon: , badge: `${pct(summary?.delivered ?? 0)}% of total` }, + { label: 'Cancelled Orders', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#ef4444', icon: , 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 ( +
+ + : ordersQ.isError + ? + : + } + right={ + + Coimbatore + + } + /> + +
+ + {/* Date filter */} + +
+
+ + View + + {presets.map((p) => ( + + setScope({ from: p.from, to: p.to })}>{p.label} + + ))} +
+
+ 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' }} /> + + 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' }} /> +
+
+
+ + {/* Status tabs + search */} + +
+
+ {STATUS_TABS.map((t) => { + const color = statusColor(ORDER_STATUS, t.key); + return ( + + setScope({ status: t.key })} count={summaryQ.isLoading ? '·' : countFor(t.key).toLocaleString('en-IN')}> + {t.label} + + + ); + })} +
+
+
+
+ + {/* Table */} +
+
+ + + + {['#', 'Order', 'Pickup', 'Drop', 'Qty', 'COD', 'KMs', 'Charges', 'Status', ''].map((h, i) => ( + + ))} + + + + {ordersQ.isLoading ? ( + + ) : rows.length === 0 ? ( + + ) : ( + rows.map((r, i) => { + const st = fstr(r.orderstatus).toLowerCase(); + const cod = fnum(r.collectionamt); + const charges = fnum(r.deliverycharge) || fnum(r.deliverycharges); + return ( + (e.currentTarget.style.background = SURFACE_ALT)} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}> + + + + + + + + + + + + ); + }) + )} + +
{h}
+ Loading orders… +
No orders found for this status, date range, or search.
{(pageno - 1) * PAGE_SIZE + i + 1} +

{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}

+

{shortTime(r.orderdate || r.deliverydate)}

+
+

{fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}

+

{fstr(r.pickupsuburb) || fstr(r.pickupaddress)}

+
+

{fstr(r.deliverycustomer) || '—'}

+

{fstr(r.deliverysuburb) || fstr(r.deliveryaddress)}

+
{fnum(r.quantity) || '—'}{cod > 0 ? ₹{cod.toLocaleString('en-IN')} : }{fnum(r.kms) ? {fnum(r.kms).toFixed(1)} : }{charges > 0 ? ₹{charges.toLocaleString('en-IN')} : } + +
+
+
+ Page {pageno} · {rows.length} shown +
+ setPageno((p) => Math.max(1, p - 1))}> Prev + setPageno((p) => p + 1)}>Next +
+
+
+ + {detailOrder && setDetailOrder(null)} />} +
+ ); +} + +const DIVIDER_C = '#f1f5f9'; + +function PagerBtn({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick: () => void }) { + return ( + + ); +} + +// ── 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 ( +
{ if (e.target === e.currentTarget) onClose(); }}> +
+
+
+

Order {fstr(order.orderid) || `#${fstr(order.orderheaderid)}`}

+ +
+
+
+ + {shortTime(order.orderdate || order.deliverydate)} +
+
+
{fstr(order.deliverycustomer) || 'Customer'}
+ {fstr(order.deliverycontactno) &&
{fstr(order.deliverycontactno)}
} +
{fstr(order.deliveryaddress) || fstr(order.deliverysuburb) || 'Address unavailable'}
+
+
+ Order Items +
+ {detailsQ.isLoading &&
Loading line items…
} + {!detailsQ.isLoading && lines.length === 0 &&
No line items returned for this order.
} + {lines.map((item, idx) => ( +
+

{item.name}

Qty: {item.quantity} × ₹{item.price}

+ ₹{item.lineTotal.toLocaleString('en-IN')} +
+ ))} + {total > 0 && ( +
+ Order Total₹{total.toLocaleString('en-IN')} +
+ )} +
+
+
+
+ +
+
+
+ ); +} + +const BRAND_LIGHT_LOCAL = '#9255AB'; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 97ca3d6..642d739 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -8,7 +8,6 @@ import { LayoutDashboard, Store, Layers, - ShoppingBag, Settings, TrendingUp } from 'lucide-react'; diff --git a/src/components/StoreCatalogView.tsx b/src/components/StoreCatalogView.tsx new file mode 100644 index 0000000..d261c85 --- /dev/null +++ b/src/components/StoreCatalogView.tsx @@ -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>(() => { + 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( + () => + (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(); + 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(); + 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 ( +
+ {/* Header */} +
+

Inventory & Catalog

+

+ Browse the products approved for your store and choose what to stock at {storeName}. +

+
+ + {/* Tabs */} +
+ + +
+ + {/* Filters */} +
+
+ + 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 && ( + + )} +
+ + {view === 'catalog' && ( +
+ + Filter + + + {categoryid > 0 && subcategories.length > 0 && ( + + )} +
+ )} + +
+ {view === 'catalog' ? `${filtered.length} products` : `${inventory.length} stocked`} +
+
+ + {/* ── Browse Catalog ── */} + {view === 'catalog' && ( + catalogQ.isLoading ? ( + } title="Loading catalog…" /> + ) : catalogQ.isError ? ( + } title="Couldn't load the catalog" sub="Check your connection and try again." tone="error" /> + ) : filtered.length === 0 ? ( + } title="No products found" sub="Your manager hasn't approved products matching this filter yet." /> + ) : ( +
+ {filtered.map((p) => { + const stocked = inStore.has(p.id); + const isSelected = selected.has(p.id); + return ( +
+
+ {p.name} + {stocked && ( + + In Store + + )} +
+
+ + {p.category} + +

{p.name}

+
+ {p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'} + {p.unit} +
+ + {stocked ? ( + + ) : isSelected ? ( + + ) : ( + + )} +
+
+ ); + })} +
+ ) + )} + + {/* ── My Store Inventory ── */} + {view === 'inventory' && ( +
+
+ + + + + + + + + + + + {stockQ.isLoading ? ( + + ) : !locationid ? ( + + ) : inventory.length === 0 ? ( + + ) : ( + inventory.map((it, i) => ( + + + + + + + + )) + )} + +
#ProductCategoryIn StockStatus
Loading your stock…
No store linked to your account yet.
No products stocked yet — add some from the catalog.
{i + 1}{it.name}{it.category}{it.closing.toLocaleString('en-IN')} + + {it.label} + +
+
+
+ )} + + {/* ── Selection action bar (sticky) ── */} + {view === 'catalog' && selected.size > 0 && ( +
+
+ {notice ? ( +
+
+ {selected.size} product{selected.size > 1 ? 's' : ''} marked for {storeName} + +
+ +
+ ) : ( +
+
+ +
+

{selected.size} product{selected.size > 1 ? 's' : ''} selected

+

Ready to stock at {storeName}

+
+
+
+ + +
+
+ )} +
+
+ )} +
+ ); +} + +function CenterState({ icon, title, sub, tone }: { icon: React.ReactNode; title: string; sub?: string; tone?: 'error' }) { + return ( +
+
{icon}
+

{title}

+ {sub &&

{sub}

} +
+ ); +} diff --git a/src/components/StoreDetailView.tsx b/src/components/StoreDetailView.tsx index 1548362..f248aab 100644 --- a/src/components/StoreDetailView.tsx +++ b/src/components/StoreDetailView.tsx @@ -30,8 +30,7 @@ import { CreditCard, History, Building, - Award, - ShoppingBag + Award } from 'lucide-react'; import { useFiestaStockStatement, @@ -44,7 +43,6 @@ import { import { str as fstr, num as fnum } from '../services/fiestaApi'; import { mapOrderStatus, shortTime } from '../services/fiestaMappers'; import ragulStoreCover from '../assets/images/store_front_view_1780299351800.png'; -import OrdersDeliveriesView from './OrdersDeliveriesView'; import AwaitingApi from './AwaitingApi'; interface StoreDetailViewProps { @@ -66,6 +64,10 @@ interface StoreDetailViewProps { * catalogue, promo/credit). Admins get true; plain store users get false so * the console is view-only. */ 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 @@ -84,8 +86,13 @@ const DETAIL_STORE_COVERS = [ 'https://images.unsplash.com/photo-1579621970563-ebec7560ff3e?auto=format&fit=crop&w=800&q=80' ]; -export default function StoreDetailView({ store, onBack, canManage = true }: StoreDetailViewProps) { - const [activeTab, setActiveTab] = useState<'overview' | 'inventory' | 'customers' | 'orders'>('overview'); +export default function StoreDetailView({ store, onBack, canManage = true, only }: StoreDetailViewProps) { + 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 getStoreCover = () => { @@ -393,7 +400,8 @@ export default function StoreDetailView({ store, onBack, canManage = true }: Sto
- {/* ── Immersive Analytics Banner (With Store Cover Image & Slate Gradient Overlay) ── */} + {/* ── Immersive Analytics Banner — hidden on the standalone Inventory & Customers pages ── */} + {showHero && (
{/* Cover Image Background */}
@@ -488,8 +496,10 @@ export default function StoreDetailView({ store, onBack, canManage = true }: Sto
+ )} - {/* ── Visual Glass-look Tab Controls ── */} + {/* ── Visual Glass-look Tab Controls (full tabbed console only) ── */} + {!only && (
-
+ )} {/* ── TAB PAYLOAD AREA ── */} - {activeTab === 'overview' && ( + {section === 'overview' && (
{/* Top Metric Cards */} @@ -721,12 +721,12 @@ export default function StoreDetailView({ store, onBack, canManage = true }: Sto
)} - {activeTab === 'inventory' && ( + {section === 'inventory' && (
{/* Inventory search, metrics & catalogue tools */}
-
+
)} - {activeTab === 'customers' && ( + {section === 'customers' && (
{/* Customer directory search and metrics */}
-
+
)} - {activeTab === 'orders' && ( - - )} + {/* Orders & Deliveries moved out of the store console into their own pages. */} {/* ── Replenishment Modal Dialog Overlay ── */} {replenishModal.show && replenishModal.item && ( diff --git a/src/components/UserStorePage.tsx b/src/components/UserStorePage.tsx index 075341c..d669d4a 100644 --- a/src/components/UserStorePage.tsx +++ b/src/components/UserStorePage.tsx @@ -4,7 +4,21 @@ */ 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 { useFiestaTenantLocations, useFiestaLocationSummary, @@ -14,6 +28,11 @@ import { str as fstr, num as fnum, roleName } from '../services/fiestaApi'; import type { AuthUser } from '../services/auth'; import Header from './Header'; import StoreDetailView from './StoreDetailView'; +import 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'; interface UserStorePageProps { @@ -27,6 +46,12 @@ interface UserStorePageProps { // gets a matching branch in `renderSection` below. const NAV_ITEMS: UserNavItem[] = [ { 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 }, ]; @@ -161,6 +186,17 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { const renderSection = () => { 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 ; + if (activeSection === 'deliveries') return ; + if (activeSection === 'dispatch') return ; + if (activeSection === 'reports') return ; + // 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 ; + // The store console needs a resolved store, so gate it on the load state. if (locationsQ.isLoading || locSummaryQ.isLoading) { 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 // restriction — the backend must also enforce role-based authorization on the // write endpoints, since a hidden button doesn't stop a direct API call. - return ; + // + // 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 ; }; return ( @@ -240,13 +281,19 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) { />
-
- {renderSection()} -
+ {activeSection === 'dispatch' ? ( +
{renderSection()}
+ ) : ( +
+ {renderSection()} +
+ )}
diff --git a/src/components/consoleUi.tsx b/src/components/consoleUi.tsx new file mode 100644 index 0000000..5b9b7c5 --- /dev/null +++ b/src/components/consoleUi.tsx @@ -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 = { + 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 = { + pending: '#f59e0b', + accepted: '#6366f1', + arrived: '#06b6d4', + picked: '#8b5cf6', + active: '#14b8a6', + skipped: '#f97316', + delivered: '#10b981', + cancelled: '#ef4444', +}; +export const statusColor = (map: Record, 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 ( +
+
+
+ + + +
+

+ {title} +

+ {subtitle &&

{subtitle}

} + {status &&
{status}
} +
+
+ {right &&
{right}
} +
+
+ ); +} + +function BrandMark() { + return ( + + + + + ); +} + +/** 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 ( + + + {label} + + ); +} + +// ── 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 ( +
+ {items.map((it) => ( +
(e.currentTarget.style.boxShadow = SHADOW_MD)} + onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')} + > +
+
+
+

+ {it.label} +

+

+ {loading ? '—' : it.value} +

+ {it.badge && ( + + {it.badge} + + )} +
+ + {it.icon} + +
+
+ ))} +
+ ); +} + +// ── 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 ( + + ); +} + +// ── Status chip (table cell) ───────────────────────────────────────────────────── +export function StatusChip({ label, color }: { label: string; color: string }) { + return ( + + {label} + + ); +} + +// ── Metric pill (km / amount / count cells) ────────────────────────────────────── +export function MetricPill({ color, children, minWidth }: { color: string; children: React.ReactNode; minWidth?: number }) { + return ( + + {children} + + ); +} + +// ── Stamp cell (date over time) ────────────────────────────────────────────────── +export function StampCell({ date, time }: { date?: string; time?: string }) { + if (!date && !time) return ; + return ( +
+ {date &&
{date}
} + {time &&
{time}
} +
+ ); +} + +// ── Search pill ────────────────────────────────────────────────────────────────── +export function SearchPill({ value, onChange, placeholder, color = BRAND }: { value: string; onChange: (v: string) => void; placeholder?: string; color?: string }) { + return ( +
+ + + + + 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 && ( + + )} +
+ ); +} + +// ── Card shells & table head cell ──────────────────────────────────────────────── +export function Card({ children, className = '', flush }: { children: React.ReactNode; className?: string; flush?: 'top' }) { + return ( +
+ {children} +
+ ); +} + +/** Filter/tab bar paper that visually joins the table below it (flat bottom). */ +export function FilterBar({ children, className = '' }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} + +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}`, +}; diff --git a/src/services/dispatchMockData.ts b/src/services/dispatchMockData.ts new file mode 100644 index 0000000..a41a796 --- /dev/null +++ b/src/services/dispatchMockData.ts @@ -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 = { + '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: '' }, +];