@fleetbase/fleetops-engine 0.6.38 → 0.6.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (429) hide show
  1. package/addon/components/activity/form.hbs +2 -12
  2. package/addon/components/activity/logic-builder.hbs +1 -1
  3. package/addon/components/custom-entity/form.hbs +4 -20
  4. package/addon/components/device/form.hbs +3 -15
  5. package/addon/components/device/panel-header.hbs +13 -2
  6. package/addon/components/driver/details.hbs +33 -2
  7. package/addon/components/driver/form.hbs +42 -0
  8. package/addon/components/driver/panel-header.hbs +8 -1
  9. package/addon/components/driver/schedule.hbs +115 -76
  10. package/addon/components/driver/schedule.js +349 -157
  11. package/addon/components/driver-onboard-settings.hbs +2 -8
  12. package/addon/components/entity-field-editing-settings.hbs +2 -5
  13. package/addon/components/equipment/card.hbs +49 -0
  14. package/addon/components/equipment/card.js +6 -0
  15. package/addon/components/equipment/details.hbs +83 -44
  16. package/addon/components/equipment/form.hbs +111 -41
  17. package/addon/components/equipment/form.js +78 -10
  18. package/addon/components/equipment/panel-header.hbs +36 -0
  19. package/addon/components/equipment/panel-header.js +2 -0
  20. package/addon/components/fleet/driver-listing.hbs +3 -1
  21. package/addon/components/fleet/vehicle-listing.hbs +4 -7
  22. package/addon/components/fleet-panel/vehicle-listing.hbs +1 -6
  23. package/addon/components/fuel-report/form.hbs +1 -5
  24. package/addon/components/layout/fleet-ops-sidebar.hbs +40 -36
  25. package/addon/components/layout/fleet-ops-sidebar.js +61 -10
  26. package/addon/components/maintenance/cost-panel.hbs +176 -0
  27. package/addon/components/maintenance/cost-panel.js +241 -0
  28. package/addon/components/maintenance/details.hbs +123 -60
  29. package/addon/components/maintenance/form.hbs +138 -78
  30. package/addon/components/maintenance/form.js +131 -6
  31. package/addon/components/maintenance/panel-header.hbs +31 -0
  32. package/addon/components/maintenance/panel-header.js +2 -0
  33. package/addon/components/maintenance-schedule/details.hbs +260 -0
  34. package/addon/components/maintenance-schedule/details.js +158 -0
  35. package/addon/components/maintenance-schedule/form.hbs +287 -0
  36. package/addon/components/maintenance-schedule/form.js +199 -0
  37. package/addon/components/map/container.hbs +1 -1
  38. package/addon/components/map/drawer/device-event-listing.hbs +1 -1
  39. package/addon/components/map/drawer/driver-listing.hbs +1 -6
  40. package/addon/components/map/drawer/place-listing.hbs +1 -6
  41. package/addon/components/map/drawer/vehicle-listing.hbs +1 -6
  42. package/addon/components/map/drawer.hbs +8 -1
  43. package/addon/components/map/leaflet-live-map.hbs +2 -1
  44. package/addon/components/map/toolbar/visibility-control-panel.hbs +1 -1
  45. package/addon/components/map/toolbar.hbs +4 -29
  46. package/addon/components/modals/add-driver-shift.hbs +155 -0
  47. package/addon/components/modals/add-driver-shift.js +210 -0
  48. package/addon/components/modals/bulk-assign-orders.hbs +67 -0
  49. package/addon/components/modals/driver-shift.hbs +43 -0
  50. package/addon/components/modals/driver-shift.js +56 -0
  51. package/addon/components/modals/entity-form.hbs +5 -28
  52. package/addon/components/modals/orchestrator-import.hbs +351 -0
  53. package/addon/components/modals/orchestrator-import.js +807 -0
  54. package/addon/components/modals/order-config-new-status.hbs +1 -5
  55. package/addon/components/modals/scheduling-conflict.hbs +47 -0
  56. package/addon/components/modals/scheduling-conflict.js +53 -0
  57. package/addon/components/modals/send-work-order.hbs +91 -0
  58. package/addon/components/modals/send-work-order.js +3 -0
  59. package/addon/components/modals/service-area-form.hbs +1 -6
  60. package/addon/components/modals/set-driver-availability.hbs +50 -0
  61. package/addon/components/modals/set-driver-availability.js +57 -0
  62. package/addon/components/modals/update-order-activity.hbs +13 -9
  63. package/addon/components/modals/user-form.hbs +1 -5
  64. package/addon/components/modals/vehicle-form.hbs +17 -102
  65. package/addon/components/modals/vendor-form.hbs +15 -82
  66. package/addon/components/orchestrator/card-fields-settings.hbs +76 -0
  67. package/addon/components/orchestrator/card-fields-settings.js +134 -0
  68. package/addon/components/orchestrator/order-pool.hbs +264 -0
  69. package/addon/components/orchestrator/order-pool.js +394 -0
  70. package/addon/components/orchestrator/phase-builder.hbs +162 -0
  71. package/addon/components/orchestrator/phase-builder.js +162 -0
  72. package/addon/components/orchestrator/plan-viewer.hbs +278 -0
  73. package/addon/components/orchestrator/plan-viewer.js +342 -0
  74. package/addon/components/orchestrator/resource-panel.hbs +301 -0
  75. package/addon/components/orchestrator/resource-panel.js +106 -0
  76. package/addon/components/orchestrator-workbench.hbs +318 -0
  77. package/addon/components/orchestrator-workbench.js +1087 -0
  78. package/addon/components/order/details/custom-fields.hbs +10 -1
  79. package/addon/components/order/details/detail.hbs +37 -2
  80. package/addon/components/order/details/detail.js +0 -1
  81. package/addon/components/order/details/integrated-vendor-details.hbs +4 -4
  82. package/addon/components/order/details/notes.hbs +1 -7
  83. package/addon/components/order/details/route.hbs +1 -5
  84. package/addon/components/order/form/details.hbs +44 -10
  85. package/addon/components/order/form/details.js +57 -0
  86. package/addon/components/order/form/notes.hbs +1 -7
  87. package/addon/components/order/form/payload.hbs +1 -7
  88. package/addon/components/order/form/route.hbs +3 -15
  89. package/addon/components/order/header.hbs +1 -7
  90. package/addon/components/order/route-editor.hbs +4 -25
  91. package/addon/components/order/schedule-card.hbs +102 -95
  92. package/addon/components/order/schedule-card.js +8 -3
  93. package/addon/components/order-config-manager/details.hbs +2 -10
  94. package/addon/components/order-config-manager/entities.hbs +1 -7
  95. package/addon/components/order-config-manager.hbs +1 -6
  96. package/addon/components/part/card.hbs +49 -0
  97. package/addon/components/part/card.js +6 -0
  98. package/addon/components/part/details.hbs +102 -56
  99. package/addon/components/part/form.hbs +131 -56
  100. package/addon/components/part/form.js +78 -11
  101. package/addon/components/part/panel-header.hbs +36 -0
  102. package/addon/components/part/panel-header.js +2 -0
  103. package/addon/components/place/form.hbs +1 -7
  104. package/addon/components/sensor/details.hbs +1 -1
  105. package/addon/components/sensor/form.hbs +5 -3
  106. package/addon/components/sensor/panel-header.hbs +8 -1
  107. package/addon/components/service-area/form.hbs +1 -6
  108. package/addon/components/service-rate/form.hbs +12 -60
  109. package/addon/components/telematic/form.hbs +6 -2
  110. package/addon/components/vehicle/details/maintenance-history.hbs +42 -0
  111. package/addon/components/vehicle/details/maintenance-history.js +32 -0
  112. package/addon/components/vehicle/details/schedules.hbs +40 -0
  113. package/addon/components/vehicle/details/schedules.js +32 -0
  114. package/addon/components/vehicle/details/work-orders.hbs +42 -0
  115. package/addon/components/vehicle/details/work-orders.js +32 -0
  116. package/addon/components/vehicle/details.hbs +30 -0
  117. package/addon/components/vehicle/form.hbs +39 -0
  118. package/addon/components/vehicle/panel-header.hbs +19 -49
  119. package/addon/components/warranty/details.hbs +3 -2
  120. package/addon/components/warranty/form.hbs +3 -17
  121. package/addon/components/work-order/details.hbs +135 -40
  122. package/addon/components/work-order/form.hbs +178 -45
  123. package/addon/components/work-order/form.js +197 -4
  124. package/addon/components/work-order/panel-header.hbs +31 -0
  125. package/addon/components/work-order/panel-header.js +2 -0
  126. package/addon/controllers/connectivity/devices/index/details.js +1 -1
  127. package/addon/controllers/maintenance/equipment/index/details/index.js +0 -1
  128. package/addon/controllers/maintenance/equipment/index/details.js +36 -1
  129. package/addon/controllers/maintenance/equipment/index/edit.js +56 -1
  130. package/addon/controllers/maintenance/equipment/index/new.js +32 -1
  131. package/addon/controllers/maintenance/equipment/index.js +127 -113
  132. package/addon/controllers/maintenance/maintenances/index/details/index.js +3 -0
  133. package/addon/controllers/maintenance/maintenances/index/details.js +54 -0
  134. package/addon/controllers/maintenance/maintenances/index/edit.js +68 -0
  135. package/addon/controllers/maintenance/maintenances/index/new.js +34 -0
  136. package/addon/controllers/maintenance/maintenances/index.js +191 -0
  137. package/addon/controllers/maintenance/parts/index/details/index.js +0 -1
  138. package/addon/controllers/maintenance/parts/index/details.js +36 -1
  139. package/addon/controllers/maintenance/parts/index/edit.js +56 -1
  140. package/addon/controllers/maintenance/parts/index/new.js +32 -1
  141. package/addon/controllers/maintenance/parts/index.js +135 -113
  142. package/addon/controllers/maintenance/schedules/index/details.js +115 -0
  143. package/addon/controllers/maintenance/schedules/index/edit.js +41 -0
  144. package/addon/controllers/maintenance/schedules/index/new.js +33 -0
  145. package/addon/controllers/maintenance/schedules/index.js +280 -0
  146. package/addon/controllers/maintenance/work-orders/index/details.js +41 -1
  147. package/addon/controllers/maintenance/work-orders/index/edit.js +67 -1
  148. package/addon/controllers/maintenance/work-orders/index/new.js +43 -1
  149. package/addon/controllers/maintenance/work-orders/index.js +105 -113
  150. package/addon/controllers/management/drivers/index/details.js +6 -1
  151. package/addon/controllers/management/vehicles/index/details.js +65 -0
  152. package/addon/controllers/management/vehicles/index.js +18 -0
  153. package/addon/controllers/operations/orchestrator.js +10 -0
  154. package/addon/controllers/operations/orders/index.js +6 -0
  155. package/addon/controllers/operations/scheduler/fleet-schedule.js +341 -0
  156. package/addon/controllers/operations/scheduler/index.js +799 -275
  157. package/addon/controllers/operations/scheduler.js +21 -0
  158. package/addon/controllers/settings/orchestrator.js +70 -0
  159. package/addon/controllers/settings/scheduling.js +155 -0
  160. package/addon/extension.js +19 -0
  161. package/addon/instance-initializers/register-vroom-allocation.js +27 -0
  162. package/addon/models/maintenance-schedule.js +61 -0
  163. package/addon/routes/maintenance/equipment/index/details.js +27 -1
  164. package/addon/routes/maintenance/equipment/index/edit.js +27 -1
  165. package/addon/routes/maintenance/maintenances/index/details/index.js +3 -0
  166. package/addon/routes/maintenance/maintenances/index/details.js +29 -0
  167. package/addon/routes/maintenance/maintenances/index/edit.js +29 -0
  168. package/addon/routes/maintenance/maintenances/index/new.js +3 -0
  169. package/addon/routes/maintenance/maintenances/index.js +23 -0
  170. package/addon/routes/maintenance/maintenances.js +3 -0
  171. package/addon/routes/maintenance/parts/index/details.js +27 -1
  172. package/addon/routes/maintenance/parts/index/edit.js +27 -1
  173. package/addon/routes/maintenance/schedules/index/details/index.js +2 -0
  174. package/addon/routes/maintenance/schedules/index/details/work-orders.js +11 -0
  175. package/addon/routes/maintenance/schedules/index/details.js +25 -0
  176. package/addon/routes/maintenance/schedules/index/edit.js +25 -0
  177. package/addon/routes/maintenance/schedules/index/new.js +2 -0
  178. package/addon/routes/maintenance/schedules/index.js +21 -0
  179. package/addon/routes/maintenance/schedules.js +2 -0
  180. package/addon/routes/maintenance/work-orders/index/details.js +27 -1
  181. package/addon/routes/maintenance/work-orders/index/edit.js +27 -1
  182. package/addon/routes/management/vehicles/index/details/maintenance-history.js +3 -0
  183. package/addon/routes/management/vehicles/index/details/schedules.js +3 -0
  184. package/addon/routes/management/vehicles/index/details/work-orders.js +3 -0
  185. package/addon/routes/operations/orchestrator.js +23 -0
  186. package/addon/routes/operations/scheduler/fleet-schedule.js +28 -0
  187. package/addon/routes/operations/scheduler/index.js +48 -26
  188. package/addon/routes/operations/scheduler.js +14 -1
  189. package/addon/routes/settings/orchestrator.js +27 -0
  190. package/addon/routes/settings/scheduling.js +3 -0
  191. package/addon/routes.js +31 -1
  192. package/addon/services/driver-actions.js +40 -7
  193. package/addon/services/driver-scheduling.js +4 -1
  194. package/addon/services/equipment-actions.js +15 -5
  195. package/addon/services/leaflet-map-manager.js +14 -6
  196. package/addon/services/maintenance-actions.js +17 -14
  197. package/addon/services/maintenance-schedule-actions.js +118 -0
  198. package/addon/services/orchestration-engine-interface.js +49 -0
  199. package/addon/services/orchestration-engine.js +74 -0
  200. package/addon/services/order-actions.js +15 -0
  201. package/addon/services/order-allocation.js +116 -0
  202. package/addon/services/part-actions.js +12 -2
  203. package/addon/services/scheduling.js +316 -0
  204. package/addon/services/vehicle-actions.js +70 -7
  205. package/addon/services/vroom-allocation-engine.js +45 -0
  206. package/addon/services/work-order-actions.js +80 -0
  207. package/addon/styles/fleetops-engine.css +1658 -0
  208. package/addon/templates/analytics/reports/index/edit.hbs +1 -1
  209. package/addon/templates/analytics/reports/index/new.hbs +1 -1
  210. package/addon/templates/application.hbs +6 -1
  211. package/addon/templates/connectivity/devices/index/details/events.hbs +0 -1
  212. package/addon/templates/connectivity/devices.hbs +0 -1
  213. package/addon/templates/connectivity/events/index/details.hbs +0 -1
  214. package/addon/templates/connectivity/events.hbs +0 -1
  215. package/addon/templates/connectivity/sensors.hbs +0 -1
  216. package/addon/templates/connectivity/telematics/index/details/devices.hbs +0 -1
  217. package/addon/templates/connectivity/telematics/index/details/events.hbs +0 -1
  218. package/addon/templates/connectivity/telematics/index/details/sensors.hbs +0 -1
  219. package/addon/templates/connectivity/telematics.hbs +0 -1
  220. package/addon/templates/connectivity/tracking.hbs +0 -1
  221. package/addon/templates/connectivity.hbs +0 -1
  222. package/addon/templates/maintenance/equipment/index/details/index.hbs +1 -2
  223. package/addon/templates/maintenance/equipment/index/details.hbs +15 -2
  224. package/addon/templates/maintenance/equipment/index/edit.hbs +12 -2
  225. package/addon/templates/maintenance/equipment/index/new.hbs +1 -2
  226. package/addon/templates/maintenance/equipment/index.hbs +48 -13
  227. package/addon/templates/maintenance/equipment.hbs +0 -1
  228. package/addon/templates/maintenance/maintenances/index/details/index.hbs +1 -0
  229. package/addon/templates/maintenance/maintenances/index/details.hbs +15 -0
  230. package/addon/templates/maintenance/maintenances/index/edit.hbs +12 -0
  231. package/addon/templates/maintenance/maintenances/index/new.hbs +11 -0
  232. package/addon/templates/maintenance/maintenances/index.hbs +14 -0
  233. package/addon/templates/maintenance/maintenances.hbs +1 -0
  234. package/addon/templates/maintenance/parts/index/details/index.hbs +1 -2
  235. package/addon/templates/maintenance/parts/index/details.hbs +15 -2
  236. package/addon/templates/maintenance/parts/index/edit.hbs +12 -2
  237. package/addon/templates/maintenance/parts/index/new.hbs +1 -2
  238. package/addon/templates/maintenance/parts/index.hbs +48 -13
  239. package/addon/templates/maintenance/parts.hbs +0 -1
  240. package/addon/templates/maintenance/schedules/index/details/index.hbs +1 -0
  241. package/addon/templates/maintenance/schedules/index/details/work-orders.hbs +39 -0
  242. package/addon/templates/maintenance/schedules/index/details.hbs +14 -0
  243. package/addon/templates/maintenance/schedules/index/edit.hbs +12 -0
  244. package/addon/templates/maintenance/schedules/index/new.hbs +11 -0
  245. package/addon/templates/maintenance/schedules/index.hbs +40 -0
  246. package/addon/templates/maintenance/schedules.hbs +1 -0
  247. package/addon/templates/maintenance/work-orders/index/details.hbs +2 -1
  248. package/addon/templates/maintenance/work-orders/index/edit.hbs +2 -4
  249. package/addon/templates/maintenance/work-orders/index/new.hbs +1 -2
  250. package/addon/templates/maintenance/work-orders.hbs +0 -1
  251. package/addon/templates/management/contacts/customers/edit.hbs +1 -2
  252. package/addon/templates/management/contacts/customers/new.hbs +1 -2
  253. package/addon/templates/management/contacts/customers.hbs +1 -1
  254. package/addon/templates/management/contacts/index/edit.hbs +1 -2
  255. package/addon/templates/management/contacts/index/new.hbs +1 -2
  256. package/addon/templates/management/drivers/index/details/orders.hbs +0 -1
  257. package/addon/templates/management/drivers/index/edit.hbs +1 -2
  258. package/addon/templates/management/drivers/index/new.hbs +1 -2
  259. package/addon/templates/management/fleets/index/edit.hbs +1 -2
  260. package/addon/templates/management/fleets/index/new.hbs +1 -2
  261. package/addon/templates/management/fleets/index.hbs +1 -2
  262. package/addon/templates/management/fuel-reports/index/edit.hbs +1 -2
  263. package/addon/templates/management/fuel-reports/index/new.hbs +1 -2
  264. package/addon/templates/management/fuel-reports/index.hbs +1 -2
  265. package/addon/templates/management/issues/index/edit.hbs +1 -2
  266. package/addon/templates/management/issues/index/new.hbs +1 -2
  267. package/addon/templates/management/issues/index.hbs +1 -2
  268. package/addon/templates/management/places/index/details/activity.hbs +0 -1
  269. package/addon/templates/management/places/index/details/comments.hbs +0 -1
  270. package/addon/templates/management/places/index/details/documents.hbs +0 -1
  271. package/addon/templates/management/places/index/details/map.hbs +0 -1
  272. package/addon/templates/management/places/index/details/operations.hbs +0 -1
  273. package/addon/templates/management/places/index/details/performance.hbs +0 -1
  274. package/addon/templates/management/places/index/details/rules.hbs +0 -1
  275. package/addon/templates/management/vehicles/index/details/equipment.hbs +0 -1
  276. package/addon/templates/management/vehicles/index/details/maintenance-history.hbs +2 -0
  277. package/addon/templates/management/vehicles/index/details/schedules.hbs +2 -0
  278. package/addon/templates/management/vehicles/index/details/work-orders.hbs +2 -0
  279. package/addon/templates/management/vehicles/index/details.hbs +1 -1
  280. package/addon/templates/management/vehicles/index/edit.hbs +1 -2
  281. package/addon/templates/management/vehicles/index/new.hbs +1 -2
  282. package/addon/templates/management/vendors/index/edit.hbs +1 -2
  283. package/addon/templates/management/vendors/index/new.hbs +1 -2
  284. package/addon/templates/management/vendors/index.hbs +1 -2
  285. package/addon/templates/management/vendors/integrated.hbs +1 -2
  286. package/addon/templates/operations/orchestrator.hbs +1 -0
  287. package/addon/templates/operations/orders/index.hbs +6 -1
  288. package/addon/templates/operations/scheduler/fleet-schedule.hbs +41 -0
  289. package/addon/templates/operations/scheduler/index.hbs +147 -88
  290. package/addon/templates/operations/scheduler.hbs +7 -1
  291. package/addon/templates/settings/avatars.hbs +1 -1
  292. package/addon/templates/settings/orchestrator.hbs +65 -0
  293. package/addon/templates/settings/payments/index.hbs +1 -5
  294. package/addon/templates/settings/scheduling.hbs +82 -0
  295. package/addon/utils/create-full-calendar-event-from-order.js +52 -14
  296. package/addon/utils/create-full-calendar-event-from-schedule-item.js +50 -0
  297. package/addon/utils/fleet-ops-options.js +254 -0
  298. package/addon/utils/route-colors.js +99 -0
  299. package/addon/utils/to-calendar-date.js +70 -0
  300. package/app/components/driver/schedule.js +1 -0
  301. package/app/components/maintenance/cost-panel.js +1 -0
  302. package/app/components/maintenance/panel-header.js +1 -0
  303. package/app/components/maintenance-schedule/details.js +1 -0
  304. package/app/components/maintenance-schedule/form.js +1 -0
  305. package/app/components/modals/add-driver-shift.js +1 -0
  306. package/app/components/modals/bulk-assign-orders.js +1 -0
  307. package/app/components/modals/driver-shift.js +1 -0
  308. package/app/components/modals/orchestrator-import.js +1 -0
  309. package/app/components/modals/scheduling-conflict.js +1 -0
  310. package/app/components/modals/send-work-order.js +1 -0
  311. package/app/components/modals/set-driver-availability.js +1 -0
  312. package/app/components/orchestrator/card-fields-settings.js +1 -0
  313. package/app/components/orchestrator/order-pool.js +1 -0
  314. package/app/components/orchestrator/phase-builder.js +1 -0
  315. package/app/components/orchestrator/plan-viewer.js +1 -0
  316. package/app/components/orchestrator/resource-panel.js +1 -0
  317. package/app/components/orchestrator-workbench.js +1 -0
  318. package/app/components/vehicle/details/maintenance-history.js +1 -0
  319. package/app/components/vehicle/details/schedules.js +1 -0
  320. package/app/components/vehicle/details/work-orders.js +1 -0
  321. package/app/controllers/operations/orchestrator.js +1 -0
  322. package/app/controllers/settings/orchestrator.js +1 -0
  323. package/app/controllers/settings/scheduling.js +1 -0
  324. package/app/routes/operations/orchestrator.js +1 -0
  325. package/app/routes/settings/orchestrator.js +1 -0
  326. package/app/routes/settings/scheduling.js +1 -0
  327. package/app/services/maintenance-schedule-actions.js +1 -0
  328. package/app/services/orchestration-engine-interface.js +1 -0
  329. package/app/services/orchestration-engine.js +1 -0
  330. package/app/services/order-allocation.js +1 -0
  331. package/app/services/scheduling.js +1 -0
  332. package/app/services/vroom-allocation-engine.js +1 -0
  333. package/app/templates/settings/scheduling.js +1 -0
  334. package/app/utils/create-full-calendar-event-from-schedule-item.js +1 -0
  335. package/app/utils/route-colors.js +1 -0
  336. package/composer.json +5 -3
  337. package/extension.json +1 -1
  338. package/package.json +6 -5
  339. package/server/config/fleetops.php +20 -1
  340. package/server/migrations/2025_08_28_054927_create_parts_table.php +2 -2
  341. package/server/migrations/2025_08_28_054932_add_public_id_to_maintenance_tables.php +45 -0
  342. package/server/migrations/2025_09_01_000001_create_maintenance_schedules_table.php +88 -0
  343. package/server/migrations/2026_04_01_000001_fix_monetary_columns_in_parts_table.php +48 -0
  344. package/server/migrations/2026_04_01_000003_add_photo_uuid_to_equipment_and_parts_tables.php +61 -0
  345. package/server/migrations/2026_04_01_000004_add_public_id_to_equipments_table.php +38 -0
  346. package/server/migrations/2026_04_01_000005_add_missing_columns_to_parts_table.php +67 -0
  347. package/server/migrations/2026_04_04_000001_add_reminder_offsets_to_maintenance_schedules.php +44 -0
  348. package/server/migrations/2026_04_08_000001_add_orchestrator_columns_to_vehicles_table.php +58 -0
  349. package/server/migrations/2026_04_08_000002_add_orchestrator_columns_to_drivers_table.php +41 -0
  350. package/server/migrations/2026_04_08_000003_add_orchestrator_columns_to_orders_table.php +38 -0
  351. package/server/migrations/2026_04_08_000004_add_orchestrator_columns_to_payloads_table.php +41 -0
  352. package/server/migrations/2026_04_08_000005_add_orchestrator_columns_to_waypoints_table.php +38 -0
  353. package/server/migrations/2026_04_09_000001_create_manifests_table.php +48 -0
  354. package/server/migrations/2026_04_09_000002_create_manifest_stops_table.php +48 -0
  355. package/server/migrations/2026_04_09_000003_add_manifest_uuid_to_orders_table.php +28 -0
  356. package/server/migrations/2026_04_13_000001_add_pod_notes_columns_to_waypoints_table.php +39 -0
  357. package/server/resources/views/mail/maintenance-schedule-reminder.blade.php +59 -0
  358. package/server/resources/views/mail/work-order-dispatched.blade.php +67 -0
  359. package/server/src/Auth/Schemas/FleetOps.php +44 -0
  360. package/server/src/Console/Commands/ProcessMaintenanceTriggers.php +150 -0
  361. package/server/src/Console/Commands/SendMaintenanceReminders.php +128 -0
  362. package/server/src/Http/Controllers/Internal/v1/DriverController.php +1 -0
  363. package/server/src/Http/Controllers/Internal/v1/EquipmentController.php +27 -0
  364. package/server/src/Http/Controllers/Internal/v1/LiveController.php +9 -2
  365. package/server/src/Http/Controllers/Internal/v1/MaintenanceController.php +165 -0
  366. package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +304 -0
  367. package/server/src/Http/Controllers/Internal/v1/ManifestController.php +138 -0
  368. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +975 -0
  369. package/server/src/Http/Controllers/Internal/v1/OrderController.php +42 -0
  370. package/server/src/Http/Controllers/Internal/v1/PartController.php +27 -0
  371. package/server/src/Http/Controllers/Internal/v1/SettingController.php +118 -0
  372. package/server/src/Http/Controllers/Internal/v1/Traits/DriverSchedulingTrait.php +214 -0
  373. package/server/src/Http/Controllers/Internal/v1/WorkOrderController.php +68 -0
  374. package/server/src/Http/Resources/v1/Driver.php +1 -0
  375. package/server/src/Http/Resources/v1/Maintenance.php +138 -0
  376. package/server/src/Http/Resources/v1/MaintenanceSchedule.php +137 -0
  377. package/server/src/Http/Resources/v1/Orchestrator/Order.php +116 -0
  378. package/server/src/Http/Resources/v1/Order.php +7 -4
  379. package/server/src/Http/Resources/v1/Waypoint.php +7 -0
  380. package/server/src/Http/Resources/v1/WorkOrder.php +136 -0
  381. package/server/src/Imports/EquipmentImport.php +32 -0
  382. package/server/src/Imports/MaintenanceImport.php +32 -0
  383. package/server/src/Imports/MaintenanceScheduleImport.php +32 -0
  384. package/server/src/Imports/PartImport.php +32 -0
  385. package/server/src/Imports/WorkOrderImport.php +32 -0
  386. package/server/src/Jobs/ProcessAllocationJob.php +119 -0
  387. package/server/src/Listeners/HandleDeliveryCompletion.php +47 -0
  388. package/server/src/Listeners/NotifyDriverOnShiftChange.php +63 -0
  389. package/server/src/Mail/MaintenanceScheduleReminder.php +68 -0
  390. package/server/src/Mail/WorkOrderDispatched.php +58 -0
  391. package/server/src/Models/Asset.php +2 -2
  392. package/server/src/Models/Device.php +1 -1
  393. package/server/src/Models/Driver.php +82 -4
  394. package/server/src/Models/Equipment.php +62 -2
  395. package/server/src/Models/Maintenance.php +127 -9
  396. package/server/src/Models/MaintenanceSchedule.php +353 -0
  397. package/server/src/Models/Manifest.php +214 -0
  398. package/server/src/Models/ManifestStop.php +162 -0
  399. package/server/src/Models/Order.php +70 -0
  400. package/server/src/Models/OrderConfig.php +5 -2
  401. package/server/src/Models/Part.php +69 -3
  402. package/server/src/Models/Payload.php +7 -2
  403. package/server/src/Models/Place.php +1 -1
  404. package/server/src/Models/Sensor.php +1 -1
  405. package/server/src/Models/ServiceQuote.php +1 -1
  406. package/server/src/Models/Vehicle.php +20 -1
  407. package/server/src/Models/Warranty.php +1 -1
  408. package/server/src/Models/Waypoint.php +7 -1
  409. package/server/src/Models/WorkOrder.php +122 -12
  410. package/server/src/Notifications/DriverShiftChanged.php +110 -0
  411. package/server/src/Observers/WorkOrderObserver.php +107 -0
  412. package/server/src/Orchestration/Contracts/OrchestrationEngineInterface.php +63 -0
  413. package/server/src/Orchestration/Engines/DriverAssignmentEngine.php +265 -0
  414. package/server/src/Orchestration/Engines/GreedyOrchestrationEngine.php +155 -0
  415. package/server/src/Orchestration/Engines/RouteSequencingEngine.php +272 -0
  416. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +192 -0
  417. package/server/src/Orchestration/OrchestrationEngineRegistry.php +83 -0
  418. package/server/src/Orchestration/Support/OrchestrationPayloadBuilder.php +290 -0
  419. package/server/src/Providers/EventServiceProvider.php +7 -1
  420. package/server/src/Providers/FleetOpsServiceProvider.php +42 -15
  421. package/server/src/routes.php +65 -4
  422. package/translations/ar-ae.yml +44 -12
  423. package/translations/bg-bg.yaml +51 -10
  424. package/translations/en-us.yaml +444 -1
  425. package/translations/fr-fr.yaml +51 -10
  426. package/translations/mn-mn.yaml +51 -10
  427. package/translations/pt-br.yaml +51 -10
  428. package/translations/ru-ru.yaml +51 -10
  429. package/translations/vi-vn.yaml +48 -12
@@ -0,0 +1,1087 @@
1
+ import Component from '@glimmer/component';
2
+ import { inject as service } from '@ember/service';
3
+ import { tracked } from '@glimmer/tracking';
4
+ import { action } from '@ember/object';
5
+ import { later } from '@ember/runloop';
6
+ import { task } from 'ember-concurrency';
7
+ import { colorForId, waypointIconHtml } from '../utils/route-colors';
8
+ import polyline from '@fleetbase/ember-core/utils/polyline';
9
+ import getRoutingHost from '@fleetbase/ember-core/utils/get-routing-host';
10
+
11
+ /**
12
+ * OrchestratorWorkbenchComponent
13
+ *
14
+ * The Dispatcher Workbench — primary UI for the Orchestrator module.
15
+ *
16
+ * Layout:
17
+ * Left panel — Orchestrator::OrderPool (filterable order list)
18
+ * Centre — Leaflet map + optional phase builder panel
19
+ * Right panel — Orchestrator::ResourcePanel (pre-run) or
20
+ * Orchestrator::PlanViewer (post-run)
21
+ *
22
+ * Modes (via PhaseBuilder):
23
+ * assign_vehicles — allocate orders to vehicles using VROOM
24
+ * assign_drivers — match drivers to vehicles (greedy shift-aware)
25
+ * optimize_routes — re-sequence stops for minimum distance/time
26
+ * allocate — legacy single-pass assign driver+vehicle (default)
27
+ *
28
+ * The workbench delegates rendering of each panel to dedicated sub-components
29
+ * and only owns cross-cutting state: data, plan, selections, map, and phases.
30
+ */
31
+ export default class OrchestratorWorkbenchComponent extends Component {
32
+ @service store;
33
+ @service fetch;
34
+ @service notifications;
35
+ @service intl;
36
+ @service modalsManager;
37
+ @service location;
38
+ @service leafletMapManager;
39
+ @service('order-allocation') allocationService;
40
+
41
+ /** Routing controls added to the map for the current proposed plan. */
42
+ _routingControls = [];
43
+
44
+ // ── Data ──────────────────────────────────────────────────────────────────
45
+
46
+ @tracked unassignedOrders = [];
47
+ @tracked availableVehicles = [];
48
+ @tracked availableDrivers = [];
49
+ @tracked availableEngines = [];
50
+
51
+ // ── Plan state ────────────────────────────────────────────────────────────
52
+
53
+ @tracked proposedPlan = null;
54
+ @tracked routeSummaries = {};
55
+ @tracked unassignedAfterRun = [];
56
+ @tracked orchestratorRunMessage = null;
57
+ @tracked runError = null;
58
+ @tracked isCommitted = false;
59
+ @tracked manualOverrides = {};
60
+ /**
61
+ * Set of phase mode strings that have been executed in the current run.
62
+ * Used to determine grouping/display mode (e.g. show driver when assign_drivers ran).
63
+ */
64
+ @tracked ranPhaseTypes = new Set();
65
+
66
+ // ── Phase builder ─────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * User-composed list of phases to execute in sequence.
70
+ * Each phase: { id, mode, label, engine, orderStatuses, balanceWorkload,
71
+ * respectSkills, respectCapacity, returnToDepot, autoCommit }
72
+ */
73
+ @tracked phases = [];
74
+
75
+ /** Whether the phase builder panel is visible (replaces old options panel). */
76
+ @tracked showPhaseBuilder = false;
77
+
78
+ /** Whether the card fields settings panel is visible. */
79
+ @tracked showCardFieldsSettings = false;
80
+
81
+ // ── Configurable card fields ──────────────────────────────────────────────
82
+
83
+ /**
84
+ * Loaded from company settings. Shape:
85
+ * { standard: string[], byConfig: { [configUuid]: string[] }, meta: string[] }
86
+ */
87
+ @tracked cardFields = null;
88
+
89
+ // ── UI state ──────────────────────────────────────────────────────────────
90
+
91
+ @tracked leftPanelCollapsed = false;
92
+ @tracked rightPanelCollapsed = false;
93
+ @tracked leftPanelWidth = 290;
94
+ @tracked rightPanelWidth = 330;
95
+
96
+ // ── Resize state (not tracked — only used during drag) ────────────────────
97
+ _resizing = null;
98
+
99
+ @tracked selectedOrderIds = new Set();
100
+ @tracked selectedVehicleIds = new Set();
101
+ @tracked selectedDriverIds = new Set();
102
+
103
+ // ── Map ───────────────────────────────────────────────────────────────────
104
+
105
+ @tracked mapCenter = { lat: 1.369, lng: 103.8864 };
106
+ @tracked mapZoom = 11;
107
+ @tracked leafletMap = null;
108
+
109
+ // ── Drag ──────────────────────────────────────────────────────────────────
110
+
111
+ @tracked _draggingOrder = null;
112
+
113
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
114
+
115
+ constructor() {
116
+ super(...arguments);
117
+ // ── Map center diagnostic ──────────────────────────────────────────────
118
+ // Track whether _centerMapOnOrders() has run so getUserLocation() cannot
119
+ // override it even if it resolves before loadOrders completes.
120
+ this._mapCenteredOnOrders = false;
121
+
122
+ const lat = this.location.getLatitude();
123
+ const lng = this.location.getLongitude();
124
+ // eslint-disable-next-line no-console
125
+ console.log('[Orchestrator] constructor: location service initial coords =>', { lat, lng });
126
+ if (lat != null && lng != null) {
127
+ this.mapCenter = { lat, lng };
128
+ }
129
+ // getUserLocation resolves to browser/IP geolocation — only use it as a
130
+ // fallback when _centerMapOnOrders() has not yet run (i.e. no orders loaded).
131
+ this.location
132
+ .getUserLocation()
133
+ .then(({ latitude, longitude }) => {
134
+ // eslint-disable-next-line no-console
135
+ console.log('[Orchestrator] getUserLocation resolved =>', { latitude, longitude }, '| _mapCenteredOnOrders =', this._mapCenteredOnOrders);
136
+ if (!this._mapCenteredOnOrders) {
137
+ // eslint-disable-next-line no-console
138
+ console.log('[Orchestrator] applying geolocation as map center (no orders centered yet)');
139
+ this.mapCenter = { lat: latitude, lng: longitude };
140
+ if (this.leafletMap?.setView) {
141
+ this.leafletMap.setView([latitude, longitude], this.mapZoom);
142
+ }
143
+ } else {
144
+ // eslint-disable-next-line no-console
145
+ console.log('[Orchestrator] geolocation ignored — map already centered on orders');
146
+ }
147
+ })
148
+ .catch(() => {});
149
+ this.loadData.perform();
150
+ this.loadEngines.perform();
151
+ this.loadCardFields.perform();
152
+ }
153
+
154
+ // ── Data loading ──────────────────────────────────────────────────────────
155
+
156
+ @task *loadData() {
157
+ yield Promise.all([this.loadOrders.perform(), this.loadAvailableVehicles.perform(), this.loadAvailableDrivers.perform()]);
158
+ }
159
+
160
+ @task *loadOrders() {
161
+ try {
162
+ // Use the dedicated orchestrator/orders endpoint which returns the
163
+ // OrchestratorOrderResource — a richer payload that includes
164
+ // custom_field_values without impacting the tabular orders view.
165
+ const result = yield this.fetch.get('fleet-ops/orchestrator/orders', {
166
+ unassigned: true,
167
+ limit: 500,
168
+ });
169
+ this.unassignedOrders = result?.orders ?? [];
170
+ this._centerMapOnOrders();
171
+ } catch (error) {
172
+ this.notifications.serverError(error);
173
+ }
174
+ }
175
+
176
+ @task *loadAvailableVehicles() {
177
+ try {
178
+ const vehicles = yield this.store.query('vehicle', { limit: 300 });
179
+ this.availableVehicles = vehicles.toArray();
180
+ } catch (error) {
181
+ this.notifications.serverError(error);
182
+ }
183
+ }
184
+
185
+ @task *loadAvailableDrivers() {
186
+ try {
187
+ const drivers = yield this.store.query('driver', { limit: 300 });
188
+ this.availableDrivers = drivers.toArray();
189
+ } catch (error) {
190
+ this.notifications.serverError(error);
191
+ }
192
+ }
193
+
194
+ @task *loadEngines() {
195
+ try {
196
+ const result = yield this.fetch.get('fleet-ops/orchestrator/engines');
197
+ this.availableEngines = result?.engines ?? [{ id: 'greedy', name: 'Greedy (built-in)' }];
198
+ } catch {
199
+ this.availableEngines = [{ id: 'greedy', name: 'Greedy (built-in)' }];
200
+ }
201
+ }
202
+
203
+ @task *loadCardFields() {
204
+ try {
205
+ const result = yield this.fetch.get('fleet-ops/settings/orchestrator-card-fields').catch(() => null);
206
+ this.cardFields = result?.settings ?? null;
207
+ } catch {
208
+ this.cardFields = null;
209
+ }
210
+ }
211
+
212
+ // ── Orchestration ─────────────────────────────────────────────────────────
213
+
214
+ /**
215
+ * Run all configured phases in sequence. If no phases are configured,
216
+ * falls back to a single legacy 'allocate' run.
217
+ */
218
+ @task *runOrchestration() {
219
+ // Clear any routing controls from a previous run before starting a new one
220
+ this._clearRoutingControls();
221
+ this.proposedPlan = null;
222
+ this.isCommitted = false;
223
+ this.manualOverrides = {};
224
+ this.routeSummaries = {};
225
+ this.unassignedAfterRun = [];
226
+ this.orchestratorRunMessage = null;
227
+ this.runError = null;
228
+ const phasesToRun = this.phases.length > 0 ? this.phases : [this._legacyPhase()];
229
+
230
+ yield this._executePhases.perform(phasesToRun);
231
+ }
232
+
233
+ @task *_executePhases(phases) {
234
+ for (const phase of phases) {
235
+ yield this._runSinglePhase.perform(phase);
236
+ // If phase has autoCommit, commit immediately before next phase
237
+ if (phase.autoCommit && this.proposedPlan?.length) {
238
+ yield this.commitPlan.perform();
239
+ }
240
+ }
241
+ }
242
+
243
+ @task *_runSinglePhase(phase) {
244
+ const orderIds = this.selectedOrderIds.size > 0 ? [...this.selectedOrderIds] : this.unassignedOrders.map((o) => o.public_id);
245
+
246
+ const vehicleIdsFromVehicleTab = [...this.selectedVehicleIds];
247
+ const vehicleIdsFromDriverTab = [...this.selectedDriverIds]
248
+ .map((driverId) => {
249
+ const driver = this.availableDrivers.find((d) => d.public_id === driverId);
250
+ // driver.vehicle is an async belongsTo proxy — use the scalar vehicle_id attr instead
251
+ return driver?.vehicle_id ?? null;
252
+ })
253
+ .filter(Boolean);
254
+
255
+ const resolvedVehicleIds = [...new Set([...vehicleIdsFromVehicleTab, ...vehicleIdsFromDriverTab])];
256
+ const vehicleIds = resolvedVehicleIds.length > 0 ? resolvedVehicleIds : this.availableVehicles.map((v) => v.public_id);
257
+
258
+ const driverIds = this.selectedDriverIds.size > 0 ? [...this.selectedDriverIds] : null;
259
+
260
+ try {
261
+ const payload = {
262
+ order_ids: orderIds,
263
+ vehicle_ids: vehicleIds,
264
+ mode: phase.mode,
265
+ order_statuses: phase.orderStatuses ?? ['created'],
266
+ options: {
267
+ engine: phase.engine ?? 'greedy',
268
+ balance_workload: phase.balanceWorkload ?? false,
269
+ respect_skills: phase.respectSkills ?? true,
270
+ respect_capacity: phase.respectCapacity ?? true,
271
+ return_to_depot: phase.returnToDepot ?? false,
272
+ },
273
+ // Pass any assignments from prior phases so the server can use
274
+ // them for phase-aware order/vehicle resolution without requiring
275
+ // a DB commit between phases.
276
+ prior_assignments: this.proposedPlan?.length ? this.proposedPlan : [],
277
+ };
278
+ if (driverIds) {
279
+ payload.driver_ids = driverIds;
280
+ }
281
+
282
+ const result = yield this.fetch.post('fleet-ops/orchestrator/run', payload);
283
+
284
+ // Merge results — later phases can override earlier assignments
285
+ const newAssignments = result.assignments ?? [];
286
+ const existing = this.proposedPlan ?? [];
287
+ const merged = [...existing];
288
+ for (const assignment of newAssignments) {
289
+ const idx = merged.findIndex((a) => a.order_id === assignment.order_id);
290
+ if (idx >= 0) {
291
+ merged[idx] = { ...merged[idx], ...assignment };
292
+ } else {
293
+ merged.push(assignment);
294
+ }
295
+ }
296
+ this.proposedPlan = merged;
297
+ this.unassignedAfterRun = result.unassigned ?? [];
298
+ // Track which phase modes have been executed so the UI can adapt
299
+ // (e.g. group by driver when assign_drivers has run).
300
+ const ranTypes = new Set(this.ranPhaseTypes);
301
+ ranTypes.add(phase.mode);
302
+ this.ranPhaseTypes = ranTypes;
303
+ if (result.message) {
304
+ this.orchestratorRunMessage = result.message;
305
+ }
306
+ // If the run returned zero assignments, surface the error/message to
307
+ // the user immediately — otherwise the right panel stays blank with
308
+ // no feedback (PlanViewer is only rendered when hasProposedPlan).
309
+ if (!newAssignments.length) {
310
+ const errorMsg = result.message ?? this.intl.t('orchestrator.no-assignments-returned');
311
+ this.runError = errorMsg;
312
+ this.notifications.warning(errorMsg, { autoClear: true, clearDuration: 6000 });
313
+ } else {
314
+ this.runError = null;
315
+ }
316
+ // Fit the map to the full planned route bounds after the plan is set.
317
+ // Deferred so planByVehicle getter has time to recompute first.
318
+ later(this, () => this._drawRoutingControls(), 200);
319
+
320
+ // Update route summaries
321
+ const summaries = { ...this.routeSummaries };
322
+ for (const assignment of newAssignments) {
323
+ if (assignment.vehicle_id && !summaries[assignment.vehicle_id]) {
324
+ summaries[assignment.vehicle_id] = {
325
+ duration: assignment.route_duration ?? null,
326
+ distance: assignment.route_distance ?? null,
327
+ };
328
+ }
329
+ }
330
+ this.routeSummaries = summaries;
331
+ } catch (error) {
332
+ this.notifications.serverError(error);
333
+ }
334
+ }
335
+
336
+ /** Commit the (possibly modified) proposed plan and generate manifests. */
337
+ @task *commitPlan() {
338
+ if (!this.proposedPlan?.length) return;
339
+
340
+ const finalAssignments = this.proposedPlan.map((assignment) => {
341
+ const override = this.manualOverrides[assignment.order_id];
342
+ return override ? { ...assignment, ...override } : assignment;
343
+ });
344
+
345
+ try {
346
+ const result = yield this.fetch.post('fleet-ops/orchestrator/commit', {
347
+ assignments: finalAssignments,
348
+ });
349
+
350
+ this.notifications.success(this.intl.t('orchestrator.committed', { count: result?.committed?.length ?? finalAssignments.length }));
351
+ this.isCommitted = true;
352
+ this.proposedPlan = null;
353
+ this.unassignedAfterRun = [];
354
+ this.manualOverrides = {};
355
+ this.routeSummaries = {};
356
+ yield this.loadData.perform();
357
+ } catch (error) {
358
+ this.notifications.serverError(error);
359
+ }
360
+ }
361
+
362
+ @action discardPlan() {
363
+ this._clearRoutingControls();
364
+ this.proposedPlan = null;
365
+ this.unassignedAfterRun = [];
366
+ this.manualOverrides = {};
367
+ this.isCommitted = false;
368
+ this.routeSummaries = {};
369
+ this.orchestratorRunMessage = null;
370
+ this.ranPhaseTypes = new Set();
371
+ }
372
+ @action clearRunError() {
373
+ this.runError = null;
374
+ this.proposedPlan = null;
375
+ this.unassignedAfterRun = [];
376
+ this.orchestratorRunMessage = null;
377
+ this.ranPhaseTypes = new Set();
378
+ }
379
+
380
+ // ── Phase management ──────────────────────────────────────────────────────
381
+
382
+ @action onPhasesChange(phases) {
383
+ this.phases = phases;
384
+ }
385
+
386
+ @action onRunPhases(phases) {
387
+ this.phases = phases;
388
+ this.runOrchestration.perform();
389
+ }
390
+
391
+ _legacyPhase() {
392
+ return {
393
+ id: 'legacy',
394
+ mode: 'allocate',
395
+ label: 'Allocate',
396
+ engine: 'greedy',
397
+ orderStatuses: ['created'],
398
+ balanceWorkload: false,
399
+ respectSkills: true,
400
+ respectCapacity: true,
401
+ returnToDepot: false,
402
+ autoCommit: false,
403
+ };
404
+ }
405
+
406
+ // ── Panel toggles ─────────────────────────────────────────────────────────
407
+
408
+ @action toggleLeftPanel() {
409
+ this.leftPanelCollapsed = !this.leftPanelCollapsed;
410
+ }
411
+ @action toggleRightPanel() {
412
+ this.rightPanelCollapsed = !this.rightPanelCollapsed;
413
+ }
414
+
415
+ @action togglePhaseBuilder() {
416
+ this.showPhaseBuilder = !this.showPhaseBuilder;
417
+ this.showCardFieldsSettings = false;
418
+ }
419
+
420
+ @action toggleCardFieldsSettings() {
421
+ this.showCardFieldsSettings = !this.showCardFieldsSettings;
422
+ this.showPhaseBuilder = false;
423
+ }
424
+
425
+ @action onCardFieldsSaved() {
426
+ this.showCardFieldsSettings = false;
427
+ this.loadCardFields.perform();
428
+ }
429
+
430
+ // ── Import ────────────────────────────────────────────────────────────────
431
+
432
+ @action openImportModal() {
433
+ this.modalsManager.show('modals/orchestrator-import', {
434
+ title: this.intl.t('orchestrator.import-orders'),
435
+ modalClass: 'modal-xl fleetops-order-import',
436
+ modalHeaderClass: 'import-modal-header',
437
+ hideAcceptButton: true,
438
+ onImportComplete: () => this.loadOrders.perform(),
439
+ });
440
+ }
441
+
442
+ // ── Run message ───────────────────────────────────────────────────────────
443
+
444
+ @action dismissRunMessage() {
445
+ this.orchestratorRunMessage = null;
446
+ }
447
+
448
+ // ── Order selection ───────────────────────────────────────────────────────
449
+
450
+ @action toggleOrderSelection(order) {
451
+ const ids = new Set(this.selectedOrderIds);
452
+ ids.has(order.public_id) ? ids.delete(order.public_id) : ids.add(order.public_id);
453
+ this.selectedOrderIds = ids;
454
+ }
455
+
456
+ @action clearOrderSelection() {
457
+ this.selectedOrderIds = new Set();
458
+ }
459
+
460
+ // ── Vehicle selection ─────────────────────────────────────────────────────
461
+
462
+ @action toggleVehicleSelection(vehicle) {
463
+ const ids = new Set(this.selectedVehicleIds);
464
+ ids.has(vehicle.public_id) ? ids.delete(vehicle.public_id) : ids.add(vehicle.public_id);
465
+ this.selectedVehicleIds = ids;
466
+ }
467
+
468
+ @action clearVehicleSelection() {
469
+ this.selectedVehicleIds = new Set();
470
+ }
471
+
472
+ // ── Driver selection ──────────────────────────────────────────────────────
473
+
474
+ @action toggleDriverSelection(driver) {
475
+ const ids = new Set(this.selectedDriverIds);
476
+ ids.has(driver.public_id) ? ids.delete(driver.public_id) : ids.add(driver.public_id);
477
+ this.selectedDriverIds = ids;
478
+ }
479
+
480
+ @action clearDriverSelection() {
481
+ this.selectedDriverIds = new Set();
482
+ this.selectedVehicleIds = new Set();
483
+ }
484
+
485
+ // ── Drag-and-drop ─────────────────────────────────────────────────────────
486
+
487
+ @action onOrderDragStart(order, event) {
488
+ this._draggingOrder = order;
489
+ event.dataTransfer.setData('text/plain', order.public_id);
490
+ event.dataTransfer.effectAllowed = 'move';
491
+ }
492
+
493
+ @action onAssignedOrderDragStart(order, event) {
494
+ this._draggingOrder = order;
495
+ event.dataTransfer.setData('text/plain', order.public_id);
496
+ event.dataTransfer.effectAllowed = 'move';
497
+ }
498
+
499
+ @action onDragOver(event) {
500
+ event.preventDefault();
501
+ event.dataTransfer.dropEffect = 'move';
502
+ }
503
+
504
+ @action onDropOnVehicle(vehicleId, driverId, event) {
505
+ event.preventDefault();
506
+ const orderId = event.dataTransfer.getData('text/plain');
507
+ if (!orderId) return;
508
+
509
+ this.manualOverrides = {
510
+ ...this.manualOverrides,
511
+ [orderId]: { vehicle_id: vehicleId, driver_id: driverId, _overridden: true },
512
+ };
513
+
514
+ const existingAssignment = this.proposedPlan?.find((a) => a.order_id === orderId);
515
+ if (existingAssignment) {
516
+ this.proposedPlan = this.proposedPlan.map((a) => (a.order_id === orderId ? { ...a, vehicle_id: vehicleId, driver_id: driverId, _overridden: true } : a));
517
+ } else {
518
+ const order = this.unassignedOrders.find((o) => o.public_id === orderId);
519
+ const vehicle = this.availableVehicles.find((v) => v.public_id === vehicleId);
520
+ if (order && vehicle) {
521
+ const newAssignment = {
522
+ order_id: orderId,
523
+ vehicle_id: vehicleId,
524
+ driver_id: driverId,
525
+ sequence: (this.proposedPlan?.filter((a) => a.vehicle_id === vehicleId).length ?? 0) + 1,
526
+ _overridden: true,
527
+ };
528
+ this.proposedPlan = [...(this.proposedPlan ?? []), newAssignment];
529
+ }
530
+ }
531
+ this._draggingOrder = null;
532
+ }
533
+
534
+ // ── Map ───────────────────────────────────────────────────────────────────
535
+
536
+ @action onMapLoad({ target: map }) {
537
+ this.leafletMap = map;
538
+ // Register the map with the service so addRoutingControl / ensureInteractive
539
+ // can resolve it. Without this call, waitForMap() never resolves and
540
+ // routing controls silently time out.
541
+ this.leafletMapManager.setMap(map);
542
+ // If orders have already loaded before the map mounted, re-center now
543
+ // using the same fitBounds strategy as _centerMapOnOrders().
544
+ if (this._mapCenteredOnOrders) {
545
+ this._centerMapOnOrders();
546
+ } else {
547
+ map.setView([this.mapCenter.lat, this.mapCenter.lng], this.mapZoom);
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Draw one OSRM road-snapped polyline per vehicle group onto the Leaflet map.
553
+ *
554
+ * Instead of using leaflet-routing-machine (which only supports a single
555
+ * routing control per map — see https://github.com/perliedman/leaflet-routing-machine/issues/219)
556
+ * we call the OSRM route/v1 HTTP API directly, decode the encoded polyline
557
+ * geometry, and draw each group's route as a plain L.polyline. This gives us
558
+ * one independent coloured polyline per vehicle group with no interference.
559
+ *
560
+ * Called automatically (deferred 200ms) after a run completes so that
561
+ * planByVehicle has time to recompute first.
562
+ */
563
+ async _drawRoutingControls() {
564
+ this._clearRoutingControls();
565
+ const map = this.leafletMap;
566
+ if (!map) return;
567
+ const groups = this.planByVehicle;
568
+ if (!groups.length) return;
569
+
570
+ const routingHost = getRoutingHost();
571
+ const allLatLngs = [];
572
+
573
+ for (const group of groups) {
574
+ const waypoints = group.routeWaypoints;
575
+ if (!waypoints || waypoints.length < 2) continue;
576
+ try {
577
+ // OSRM route/v1 expects coordinates as lon,lat pairs joined by semicolons.
578
+ const coordStr = waypoints.map(([lat, lng]) => `${lng},${lat}`).join(';');
579
+ const url = `${routingHost}/route/v1/driving/${coordStr}?overview=full&geometries=polyline`;
580
+ const resp = await fetch(url);
581
+ if (!resp.ok) continue;
582
+ const data = await resp.json();
583
+ const geometry = data?.routes?.[0]?.geometry;
584
+ if (!geometry) continue;
585
+ // polyline.decode returns [[lat, lng], ...] — exactly what L.polyline expects.
586
+ const latlngs = polyline.decode(geometry);
587
+ if (!latlngs.length) continue;
588
+ const pl = L.polyline(latlngs, {
589
+ color: group.routeColor,
590
+ weight: 4,
591
+ opacity: 0.85,
592
+ });
593
+ pl.addTo(map);
594
+ this._routingControls.push(pl);
595
+ allLatLngs.push(...latlngs);
596
+ } catch {
597
+ // Non-fatal: routing may fail for individual vehicles if OSRM
598
+ // cannot find a route (e.g. ferry-only legs, missing road data)
599
+ }
600
+ }
601
+
602
+ // Fit the map to show all drawn routes.
603
+ if (allLatLngs.length >= 2) {
604
+ try {
605
+ map.fitBounds(allLatLngs, { padding: [40, 40], maxZoom: 14 });
606
+ } catch {
607
+ // ignore fitBounds errors
608
+ }
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Remove all route polylines that were added for the current plan.
614
+ */
615
+ _clearRoutingControls() {
616
+ const map = this.leafletMap;
617
+ for (const layer of this._routingControls) {
618
+ try {
619
+ if (map) {
620
+ map.removeLayer(layer);
621
+ } else {
622
+ layer.remove?.();
623
+ }
624
+ } catch {
625
+ // ignore removal errors
626
+ }
627
+ }
628
+ this._routingControls = [];
629
+ }
630
+
631
+ _centerMapOnOrders() {
632
+ const orders = this.unassignedOrders;
633
+ if (!orders.length) return;
634
+ // Use _getOrderStops so multi-drop (waypoints) orders are included
635
+ const allStops = orders.flatMap((o) => this._getOrderStops(o));
636
+ const lats = allStops.map((s) => s.lat).filter(Boolean);
637
+ const lngs = allStops.map((s) => s.lng).filter(Boolean);
638
+ if (!lats.length) return;
639
+ // Mark that we have centered on orders so getUserLocation() cannot override.
640
+ this._mapCenteredOnOrders = true;
641
+ // Use fitBounds so the map zooms to show ALL markers regardless of how
642
+ // geographically spread they are. A raw centroid of stops in Sydney,
643
+ // Singapore and Mongolia would land in Brunei — fitBounds avoids this.
644
+ const minLat = Math.min(...lats);
645
+ const maxLat = Math.max(...lats);
646
+ const minLng = Math.min(...lngs);
647
+ const maxLng = Math.max(...lngs);
648
+ // Update mapCenter to the geographic centre of the bounding box so the
649
+ // LeafletMap @lat/@lng args stay in sync.
650
+ const lat = (minLat + maxLat) / 2;
651
+ const lng = (minLng + maxLng) / 2;
652
+ this.mapCenter = { lat, lng };
653
+ if (this.leafletMap) {
654
+ if (lats.length === 1) {
655
+ // Single point — setView with a sensible zoom level.
656
+ this.leafletMap.setView([lat, lng], 14);
657
+ } else {
658
+ // Multiple points — fit the bounding box with padding.
659
+ this.leafletMap.fitBounds(
660
+ [
661
+ [minLat, minLng],
662
+ [maxLat, maxLng],
663
+ ],
664
+ { padding: [40, 40], maxZoom: 14 }
665
+ );
666
+ }
667
+ }
668
+ }
669
+
670
+ get tileSourceUrl() {
671
+ const isDark = document.documentElement.classList.contains('dark');
672
+ return isDark ? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' : 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
673
+ }
674
+
675
+ // ── Computed helpers ──────────────────────────────────────────────────────
676
+
677
+ get selectedOrderIdsArray() {
678
+ return [...this.selectedOrderIds];
679
+ }
680
+ get selectedVehicleIdsArray() {
681
+ return [...this.selectedVehicleIds];
682
+ }
683
+ get selectedDriverIdsArray() {
684
+ return [...this.selectedDriverIds];
685
+ }
686
+
687
+ get hasProposedPlan() {
688
+ return Array.isArray(this.proposedPlan) && this.proposedPlan.length > 0;
689
+ }
690
+
691
+ get hasUnassigned() {
692
+ return this.unassignedAfterRun.length > 0;
693
+ }
694
+
695
+ /**
696
+ * True when an assign_drivers phase has been executed in the current run.
697
+ * Used by the plan viewer to show driver as the primary label on route cards
698
+ * and timeline rows instead of vehicle.
699
+ */
700
+ get hasDriverPhase() {
701
+ return this.ranPhaseTypes.has('assign_drivers');
702
+ }
703
+
704
+ get planByVehicle() {
705
+ if (!this.proposedPlan?.length) return [];
706
+ const grouped = this._groupByVehicle(this.proposedPlan);
707
+ return Object.entries(grouped).map(([vehicleId, group]) => {
708
+ // Derive a deterministic color from the vehicle public_id so the same
709
+ // vehicle always gets the same color across runs and page refreshes.
710
+ const routeColor = colorForId(group.vehicle?.public_id ?? vehicleId);
711
+ // Build the ordered [[lat, lng], ...] waypoint array for the OSRM route API.
712
+ const routeWaypoints = this._buildRouteWaypoints(group.orders);
713
+ // Annotate each order's stops with sequential letter labels that match
714
+ // the route list card labels (A, B, C…). The route list uses
715
+ // {{@getStopLabel idx}} where idx is the order's 0-based position in
716
+ // group.orders. Each stop within that order gets a sub-label:
717
+ // - Single-stop orders: just the order letter (A, B, C…)
718
+ // - Multi-stop orders: order letter + stop number (A1, A2, B1…)
719
+ const annotatedOrders = group.orders.map((item, orderIdx) => {
720
+ const orderLabel = this.getStopLabel(orderIdx);
721
+ const stops = this._getOrderStops(item.order);
722
+ const labelledStops = stops.map((stop, stopIdx) => ({
723
+ ...stop,
724
+ // Single-stop orders: use just the letter. Multi-stop: letter + number.
725
+ label: stops.length === 1 ? orderLabel : `${orderLabel}${stopIdx + 1}`,
726
+ }));
727
+ return {
728
+ ...item,
729
+ _labelledStops: labelledStops,
730
+ // Convenience accessors for the template — avoids needing a
731
+ // sub/minus helper to compute the last index.
732
+ _firstStop: labelledStops[0] ?? null,
733
+ _lastStop: labelledStops[labelledStops.length - 1] ?? null,
734
+ };
735
+ });
736
+ return {
737
+ ...group,
738
+ orders: annotatedOrders,
739
+ routeColor,
740
+ summary: this.routeSummaries[vehicleId] ?? {},
741
+ routeWaypoints,
742
+ };
743
+ });
744
+ }
745
+ /**
746
+ * Build an ordered [[lat, lng], ...] waypoint array for a vehicle's stop list.
747
+ * Starts at the vehicle/driver's current location (if known), then threads
748
+ * through each stop in sequence order. This array is passed directly to
749
+ * leafletMapManager.addRoutingControl() which calls OSRM to draw the actual
750
+ * road path on the map.
751
+ *
752
+ * @param {Array} orders - Sorted stop items { order, sequence, arrival }
753
+ * @returns {Array|null} - [[lat,lng], ...] or null if fewer than 2 valid points
754
+ */
755
+ _buildRouteWaypoints(orders) {
756
+ const points = [];
757
+ // Only include the actual order stop coordinates (pickup + dropoff / waypoints).
758
+ // Do NOT prepend the driver/vehicle current GPS location — that adds an extra
759
+ // OSRM waypoint that is not part of the selected orders and produces incorrect
760
+ // route geometry (e.g. 3 points for a 1-order plan with 2 stops).
761
+ for (const item of orders) {
762
+ const stops = this._getOrderStops(item.order);
763
+ for (const stop of stops) {
764
+ if (stop.lat && stop.lng) {
765
+ points.push([stop.lat, stop.lng]);
766
+ }
767
+ }
768
+ }
769
+ return points.length >= 2 ? points : null;
770
+ }
771
+
772
+ /**
773
+ * Extract { lat, lng } from a place object.
774
+ *
775
+ * Handles multiple serialization formats:
776
+ * 1. GeoJSON Point with type: { type: 'Point', coordinates: [lng, lat] }
777
+ * 2. GeoJSON Point without type: { coordinates: [lng, lat] }
778
+ * (LaravelMysqlSpatial SpatialExpression may omit the type field)
779
+ * 3. Ember Data model / plain object with latitude/longitude properties
780
+ * 4. Flat lat/lng on the location object itself
781
+ * 5. Numeric array [lat, lng] (some internal formats)
782
+ *
783
+ * Note: GeoJSON uses [lng, lat] order, not [lat, lng].
784
+ *
785
+ * @param {Object} place
786
+ * @returns {{ lat: number, lng: number } | null}
787
+ */
788
+ _placeCoords(place) {
789
+ if (!place) return null;
790
+ const loc = place.location;
791
+ if (loc) {
792
+ // GeoJSON Point — with or without the type field.
793
+ // LaravelMysqlSpatial SpatialExpression may serialize without type.
794
+ if (Array.isArray(loc.coordinates) && loc.coordinates.length >= 2) {
795
+ const lng = parseFloat(loc.coordinates[0]);
796
+ const lat = parseFloat(loc.coordinates[1]);
797
+ // Use isFinite to accept 0 as a valid coordinate (not just truthy)
798
+ if (isFinite(lat) && isFinite(lng) && (lat !== 0 || lng !== 0)) {
799
+ return { lat, lng };
800
+ }
801
+ }
802
+ // Flat lat/lng on the location object itself
803
+ if (loc.lat !== undefined && loc.lng !== undefined) {
804
+ const lat = parseFloat(loc.lat);
805
+ const lng = parseFloat(loc.lng);
806
+ if (isFinite(lat) && isFinite(lng) && (lat !== 0 || lng !== 0)) {
807
+ return { lat, lng };
808
+ }
809
+ }
810
+ // Numeric array [lat, lng] (some internal formats)
811
+ if (Array.isArray(loc) && loc.length >= 2) {
812
+ const lat = parseFloat(loc[0]);
813
+ const lng = parseFloat(loc[1]);
814
+ if (isFinite(lat) && isFinite(lng) && (lat !== 0 || lng !== 0)) {
815
+ return { lat, lng };
816
+ }
817
+ }
818
+ }
819
+ // Direct latitude/longitude properties (Ember Data model or plain object)
820
+ const directLat = parseFloat(place.latitude ?? place.lat);
821
+ const directLng = parseFloat(place.longitude ?? place.lng);
822
+ if (isFinite(directLat) && isFinite(directLng) && (directLat !== 0 || directLng !== 0)) {
823
+ return { lat: directLat, lng: directLng };
824
+ }
825
+ return null;
826
+ }
827
+
828
+ /**
829
+ * Normalise an order's stops into a flat array of { lat, lng, address, label }
830
+ * regardless of whether the order uses pickup/dropoff or a waypoints array.
831
+ *
832
+ * Works with both plain JSON objects (from orchestrator/orders endpoint) and
833
+ * Ember Data model records. Handles GeoJSON Point location format.
834
+ *
835
+ * - Pickup/dropoff orders: returns [pickup, dropoff] (either may be absent)
836
+ * - Multi-drop orders (payload.waypoints): returns each waypoint's place in
837
+ * sequence order, labelled by stop number (1, 2, 3…)
838
+ *
839
+ * @param {Object} order
840
+ * @returns {Array<{lat: number, lng: number, address: string, label: string}>}
841
+ */
842
+ _getOrderStops(order) {
843
+ const payload = order?.payload;
844
+ if (!payload) return [];
845
+
846
+ // Multi-drop: payload has a non-empty waypoints array and no pickup/dropoff.
847
+ // NOTE: isMultiDrop is an Ember Data computed property and won't exist on
848
+ // plain JSON objects returned by the orchestrator/orders endpoint.
849
+ const waypoints = payload.waypoints;
850
+ const hasWaypoints = Array.isArray(waypoints) && waypoints.length > 0;
851
+ const isMultiDrop = payload.isMultiDrop === true || (hasWaypoints && !payload.pickup && !payload.dropoff);
852
+ if (isMultiDrop && hasWaypoints) {
853
+ const sorted = [...waypoints].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
854
+ return sorted
855
+ .map((wp, idx) => {
856
+ const place = wp.place ?? wp;
857
+ const coords = this._placeCoords(place);
858
+ if (!coords) return null;
859
+ return { ...coords, address: place.address ?? '', label: String(idx + 1) };
860
+ })
861
+ .filter(Boolean);
862
+ }
863
+
864
+ // Standard pickup → dropoff order
865
+ const stops = [];
866
+ const pickup = payload.pickup;
867
+ const dropoff = payload.dropoff;
868
+ const pickupCoords = this._placeCoords(pickup);
869
+ const dropoffCoords = this._placeCoords(dropoff);
870
+ // Shared order-level metadata attached to each stop so the template
871
+ // can render notes, time windows, and POD requirements without
872
+ // needing to traverse back up to the parent order.
873
+ const orderNotes = order?.notes || null;
874
+ const timeWindowStart = order?.time_window_start || order?.scheduled_at || null;
875
+ const timeWindowEnd = order?.time_window_end || null;
876
+ const requiresPod = !!(order?.require_pod ?? order?.meta?.require_pod);
877
+ // Address fallbacks: the orchestrator/orders endpoint may return an empty
878
+ // place.address string. Fall back to order-level pickup_name / dropoff_name
879
+ // fields which are always populated from the API resource.
880
+ if (pickupCoords) {
881
+ const addr = pickup?.address || order?.pickup_name || order?.payload?.pickup?.name || '';
882
+ stops.push({
883
+ ...pickupCoords,
884
+ address: addr,
885
+ label: 'P',
886
+ stopType: 'pickup',
887
+ notes: orderNotes,
888
+ timeWindowStart,
889
+ timeWindowEnd,
890
+ requiresPod,
891
+ });
892
+ }
893
+ if (dropoffCoords) {
894
+ const addr = dropoff?.address || order?.dropoff_name || order?.payload?.dropoff?.name || '';
895
+ stops.push({
896
+ ...dropoffCoords,
897
+ address: addr,
898
+ label: 'D',
899
+ stopType: 'dropoff',
900
+ notes: orderNotes,
901
+ timeWindowStart,
902
+ timeWindowEnd,
903
+ requiresPod,
904
+ });
905
+ }
906
+ return stops;
907
+ }
908
+
909
+ /**
910
+ * HBS-callable wrapper around _getOrderStops.
911
+ * Returns a normalised stop array for any order type.
912
+ *
913
+ * @param {Object} order
914
+ * @returns {Array<{lat, lng, address, label}>}
915
+ */
916
+ @action getOrderStops(order) {
917
+ return this._getOrderStops(order);
918
+ }
919
+
920
+ /**
921
+ * Build the HTML string for a waypoint marker.
922
+ * Used via the ember-leaflet {{div-icon html=(this.waypointIconHtml ...)}} helper
923
+ * in the HBS template — ember-leaflet's {{div-icon}} constructs the proper
924
+ * L.DivIcon instance, so we only need to return the HTML string here.
925
+ *
926
+ * @param {string} label - Marker label ("P", "D", or a stop number)
927
+ * @param {string} bgColor - CSS background color
928
+ * @returns {string}
929
+ */
930
+ @action waypointIconHtml(label, bgColor) {
931
+ return waypointIconHtml(label, bgColor);
932
+ }
933
+
934
+ /**
935
+ * Return a letter label (A, B, C, ...) for a stop at the given 0-based index.
936
+ * Matches the labels shown on the map markers so the user can cross-reference
937
+ * the route list with the map easily.
938
+ *
939
+ * @param {number} index - 0-based stop index
940
+ * @returns {string} e.g. 'A', 'B', 'C', ..., 'Z', 'AA', 'AB', ...
941
+ */
942
+ @action getStopLabel(index) {
943
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
944
+ let label = '';
945
+ let n = index;
946
+ do {
947
+ label = alphabet[n % 26] + label;
948
+ n = Math.floor(n / 26) - 1;
949
+ } while (n >= 0);
950
+ return label;
951
+ }
952
+
953
+ _groupByVehicle(assignments) {
954
+ const groups = {};
955
+ for (const assignment of assignments) {
956
+ const { vehicle_id } = assignment;
957
+ if (!groups[vehicle_id]) {
958
+ const vehicle = this.availableVehicles.find((v) => v.public_id === vehicle_id);
959
+ // vehicle.driver is an async belongsTo proxy — resolve from availableDrivers by driver_id
960
+ const driver = this.availableDrivers.find((d) => d.public_id === assignment.driver_id) ?? this.availableDrivers.find((d) => d.vehicle_id === vehicle_id);
961
+ groups[vehicle_id] = { vehicle, driver, orders: [] };
962
+ }
963
+ const order = this.unassignedOrders.find((o) => o.public_id === assignment.order_id);
964
+ if (order) {
965
+ groups[vehicle_id].orders.push({
966
+ order,
967
+ sequence: assignment.sequence,
968
+ arrival: assignment.arrival,
969
+ _overridden: assignment._overridden ?? false,
970
+ });
971
+ }
972
+ }
973
+ for (const g of Object.values(groups)) {
974
+ g.orders.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
975
+ }
976
+ return groups;
977
+ }
978
+
979
+ get phaseCount() {
980
+ return this.phases.length;
981
+ }
982
+
983
+ // ── Formatters ────────────────────────────────────────────────────────────
984
+
985
+ formatDuration(seconds) {
986
+ if (!seconds) return '';
987
+ const h = Math.floor(seconds / 3600);
988
+ const m = Math.floor((seconds % 3600) / 60);
989
+ return h > 0 ? `${h}h ${m}m` : `${m}m`;
990
+ }
991
+
992
+ formatDistance(metres) {
993
+ if (!metres) return '';
994
+ return metres >= 1000 ? `${(metres / 1000).toFixed(1)} km` : `${metres} m`;
995
+ }
996
+
997
+ formatUnixTime(unix) {
998
+ if (!unix) return '';
999
+ return new Date(unix * 1000).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
1000
+ }
1001
+ /**
1002
+ * Format an ISO 8601 datetime string as a short HH:MM time.
1003
+ * Used to display time_window_start / time_window_end on stop rows.
1004
+ *
1005
+ * @param {string|null} iso - ISO 8601 string or null
1006
+ * @returns {string}
1007
+ */
1008
+ @action formatIsoTime(iso) {
1009
+ if (!iso) return '';
1010
+ try {
1011
+ return new Date(iso).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
1012
+ } catch {
1013
+ return '';
1014
+ }
1015
+ }
1016
+ /**
1017
+ * Build a compact time-window label from ISO start/end strings.
1018
+ * Examples: "09:00", "09:00 – 11:00"
1019
+ *
1020
+ * @param {string|null} start
1021
+ * @param {string|null} end
1022
+ * @returns {string}
1023
+ */
1024
+ @action formatTimeWindow(start, end) {
1025
+ const s = this.formatIsoTime(start);
1026
+ const e = this.formatIsoTime(end);
1027
+ if (s && e && s !== e) return `${s} – ${e}`;
1028
+ return s || e || '';
1029
+ }
1030
+
1031
+ // ── Panel resize ──────────────────────────────────────────────────────────
1032
+
1033
+ /**
1034
+ * Begin dragging the left panel resize handle.
1035
+ * Attaches mousemove/mouseup listeners to the document for the duration
1036
+ * of the drag so the resize works even when the cursor leaves the handle.
1037
+ */
1038
+ @action startLeftResize(event) {
1039
+ event.preventDefault();
1040
+ const startX = event.clientX;
1041
+ const startWidth = this.leftPanelWidth;
1042
+
1043
+ const onMove = (e) => {
1044
+ const delta = e.clientX - startX;
1045
+ const next = Math.min(480, Math.max(200, startWidth + delta));
1046
+ this.leftPanelWidth = next;
1047
+ };
1048
+ const onUp = () => {
1049
+ document.removeEventListener('mousemove', onMove);
1050
+ document.removeEventListener('mouseup', onUp);
1051
+ document.body.style.cursor = '';
1052
+ document.body.style.userSelect = '';
1053
+ };
1054
+
1055
+ document.body.style.cursor = 'col-resize';
1056
+ document.body.style.userSelect = 'none';
1057
+ document.addEventListener('mousemove', onMove);
1058
+ document.addEventListener('mouseup', onUp);
1059
+ }
1060
+
1061
+ /**
1062
+ * Begin dragging the right panel resize handle.
1063
+ * Dragging left increases the panel width (delta is inverted).
1064
+ */
1065
+ @action startRightResize(event) {
1066
+ event.preventDefault();
1067
+ const startX = event.clientX;
1068
+ const startWidth = this.rightPanelWidth;
1069
+
1070
+ const onMove = (e) => {
1071
+ const delta = startX - e.clientX; // inverted: drag left = wider
1072
+ const next = Math.min(560, Math.max(240, startWidth + delta));
1073
+ this.rightPanelWidth = next;
1074
+ };
1075
+ const onUp = () => {
1076
+ document.removeEventListener('mousemove', onMove);
1077
+ document.removeEventListener('mouseup', onUp);
1078
+ document.body.style.cursor = '';
1079
+ document.body.style.userSelect = '';
1080
+ };
1081
+
1082
+ document.body.style.cursor = 'col-resize';
1083
+ document.body.style.userSelect = 'none';
1084
+ document.addEventListener('mousemove', onMove);
1085
+ document.addEventListener('mouseup', onUp);
1086
+ }
1087
+ }