@fleetbase/fleetops-engine 0.6.37 → 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 (434) 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/details.js +5 -0
  155. package/addon/controllers/operations/orders/index.js +6 -0
  156. package/addon/controllers/operations/scheduler/fleet-schedule.js +341 -0
  157. package/addon/controllers/operations/scheduler/index.js +799 -275
  158. package/addon/controllers/operations/scheduler.js +21 -0
  159. package/addon/controllers/settings/orchestrator.js +70 -0
  160. package/addon/controllers/settings/scheduling.js +155 -0
  161. package/addon/extension.js +74 -1
  162. package/addon/instance-initializers/register-vroom-allocation.js +27 -0
  163. package/addon/models/maintenance-schedule.js +61 -0
  164. package/addon/routes/maintenance/equipment/index/details.js +27 -1
  165. package/addon/routes/maintenance/equipment/index/edit.js +27 -1
  166. package/addon/routes/maintenance/maintenances/index/details/index.js +3 -0
  167. package/addon/routes/maintenance/maintenances/index/details.js +29 -0
  168. package/addon/routes/maintenance/maintenances/index/edit.js +29 -0
  169. package/addon/routes/maintenance/maintenances/index/new.js +3 -0
  170. package/addon/routes/maintenance/maintenances/index.js +23 -0
  171. package/addon/routes/maintenance/maintenances.js +3 -0
  172. package/addon/routes/maintenance/parts/index/details.js +27 -1
  173. package/addon/routes/maintenance/parts/index/edit.js +27 -1
  174. package/addon/routes/maintenance/schedules/index/details/index.js +2 -0
  175. package/addon/routes/maintenance/schedules/index/details/work-orders.js +11 -0
  176. package/addon/routes/maintenance/schedules/index/details.js +25 -0
  177. package/addon/routes/maintenance/schedules/index/edit.js +25 -0
  178. package/addon/routes/maintenance/schedules/index/new.js +2 -0
  179. package/addon/routes/maintenance/schedules/index.js +21 -0
  180. package/addon/routes/maintenance/schedules.js +2 -0
  181. package/addon/routes/maintenance/work-orders/index/details.js +27 -1
  182. package/addon/routes/maintenance/work-orders/index/edit.js +27 -1
  183. package/addon/routes/management/vehicles/index/details/maintenance-history.js +3 -0
  184. package/addon/routes/management/vehicles/index/details/schedules.js +3 -0
  185. package/addon/routes/management/vehicles/index/details/work-orders.js +3 -0
  186. package/addon/routes/operations/orchestrator.js +23 -0
  187. package/addon/routes/operations/orders/index/details/virtual.js +23 -0
  188. package/addon/routes/operations/scheduler/fleet-schedule.js +28 -0
  189. package/addon/routes/operations/scheduler/index.js +48 -26
  190. package/addon/routes/operations/scheduler.js +14 -1
  191. package/addon/routes/settings/orchestrator.js +27 -0
  192. package/addon/routes/settings/scheduling.js +3 -0
  193. package/addon/routes.js +33 -1
  194. package/addon/services/driver-actions.js +40 -7
  195. package/addon/services/driver-scheduling.js +4 -1
  196. package/addon/services/equipment-actions.js +15 -5
  197. package/addon/services/leaflet-map-manager.js +14 -6
  198. package/addon/services/maintenance-actions.js +17 -14
  199. package/addon/services/maintenance-schedule-actions.js +118 -0
  200. package/addon/services/orchestration-engine-interface.js +49 -0
  201. package/addon/services/orchestration-engine.js +74 -0
  202. package/addon/services/order-actions.js +15 -0
  203. package/addon/services/order-allocation.js +116 -0
  204. package/addon/services/part-actions.js +12 -2
  205. package/addon/services/scheduling.js +316 -0
  206. package/addon/services/vehicle-actions.js +70 -7
  207. package/addon/services/vroom-allocation-engine.js +45 -0
  208. package/addon/services/work-order-actions.js +80 -0
  209. package/addon/styles/fleetops-engine.css +1658 -0
  210. package/addon/templates/analytics/reports/index/edit.hbs +1 -1
  211. package/addon/templates/analytics/reports/index/new.hbs +1 -1
  212. package/addon/templates/application.hbs +6 -1
  213. package/addon/templates/connectivity/devices/index/details/events.hbs +0 -1
  214. package/addon/templates/connectivity/devices.hbs +0 -1
  215. package/addon/templates/connectivity/events/index/details.hbs +0 -1
  216. package/addon/templates/connectivity/events.hbs +0 -1
  217. package/addon/templates/connectivity/sensors.hbs +0 -1
  218. package/addon/templates/connectivity/telematics/index/details/devices.hbs +0 -1
  219. package/addon/templates/connectivity/telematics/index/details/events.hbs +0 -1
  220. package/addon/templates/connectivity/telematics/index/details/sensors.hbs +0 -1
  221. package/addon/templates/connectivity/telematics.hbs +0 -1
  222. package/addon/templates/connectivity/tracking.hbs +0 -1
  223. package/addon/templates/connectivity.hbs +0 -1
  224. package/addon/templates/maintenance/equipment/index/details/index.hbs +1 -2
  225. package/addon/templates/maintenance/equipment/index/details.hbs +15 -2
  226. package/addon/templates/maintenance/equipment/index/edit.hbs +12 -2
  227. package/addon/templates/maintenance/equipment/index/new.hbs +1 -2
  228. package/addon/templates/maintenance/equipment/index.hbs +48 -13
  229. package/addon/templates/maintenance/equipment.hbs +0 -1
  230. package/addon/templates/maintenance/maintenances/index/details/index.hbs +1 -0
  231. package/addon/templates/maintenance/maintenances/index/details.hbs +15 -0
  232. package/addon/templates/maintenance/maintenances/index/edit.hbs +12 -0
  233. package/addon/templates/maintenance/maintenances/index/new.hbs +11 -0
  234. package/addon/templates/maintenance/maintenances/index.hbs +14 -0
  235. package/addon/templates/maintenance/maintenances.hbs +1 -0
  236. package/addon/templates/maintenance/parts/index/details/index.hbs +1 -2
  237. package/addon/templates/maintenance/parts/index/details.hbs +15 -2
  238. package/addon/templates/maintenance/parts/index/edit.hbs +12 -2
  239. package/addon/templates/maintenance/parts/index/new.hbs +1 -2
  240. package/addon/templates/maintenance/parts/index.hbs +48 -13
  241. package/addon/templates/maintenance/parts.hbs +0 -1
  242. package/addon/templates/maintenance/schedules/index/details/index.hbs +1 -0
  243. package/addon/templates/maintenance/schedules/index/details/work-orders.hbs +39 -0
  244. package/addon/templates/maintenance/schedules/index/details.hbs +14 -0
  245. package/addon/templates/maintenance/schedules/index/edit.hbs +12 -0
  246. package/addon/templates/maintenance/schedules/index/new.hbs +11 -0
  247. package/addon/templates/maintenance/schedules/index.hbs +40 -0
  248. package/addon/templates/maintenance/schedules.hbs +1 -0
  249. package/addon/templates/maintenance/work-orders/index/details.hbs +2 -1
  250. package/addon/templates/maintenance/work-orders/index/edit.hbs +2 -4
  251. package/addon/templates/maintenance/work-orders/index/new.hbs +1 -2
  252. package/addon/templates/maintenance/work-orders.hbs +0 -1
  253. package/addon/templates/management/contacts/customers/edit.hbs +1 -2
  254. package/addon/templates/management/contacts/customers/new.hbs +1 -2
  255. package/addon/templates/management/contacts/customers.hbs +1 -1
  256. package/addon/templates/management/contacts/index/edit.hbs +1 -2
  257. package/addon/templates/management/contacts/index/new.hbs +1 -2
  258. package/addon/templates/management/drivers/index/details/orders.hbs +0 -1
  259. package/addon/templates/management/drivers/index/edit.hbs +1 -2
  260. package/addon/templates/management/drivers/index/new.hbs +1 -2
  261. package/addon/templates/management/fleets/index/edit.hbs +1 -2
  262. package/addon/templates/management/fleets/index/new.hbs +1 -2
  263. package/addon/templates/management/fleets/index.hbs +1 -2
  264. package/addon/templates/management/fuel-reports/index/edit.hbs +1 -2
  265. package/addon/templates/management/fuel-reports/index/new.hbs +1 -2
  266. package/addon/templates/management/fuel-reports/index.hbs +1 -2
  267. package/addon/templates/management/issues/index/edit.hbs +1 -2
  268. package/addon/templates/management/issues/index/new.hbs +1 -2
  269. package/addon/templates/management/issues/index.hbs +1 -2
  270. package/addon/templates/management/places/index/details/activity.hbs +0 -1
  271. package/addon/templates/management/places/index/details/comments.hbs +0 -1
  272. package/addon/templates/management/places/index/details/documents.hbs +0 -1
  273. package/addon/templates/management/places/index/details/map.hbs +0 -1
  274. package/addon/templates/management/places/index/details/operations.hbs +0 -1
  275. package/addon/templates/management/places/index/details/performance.hbs +0 -1
  276. package/addon/templates/management/places/index/details/rules.hbs +0 -1
  277. package/addon/templates/management/vehicles/index/details/equipment.hbs +0 -1
  278. package/addon/templates/management/vehicles/index/details/maintenance-history.hbs +2 -0
  279. package/addon/templates/management/vehicles/index/details/schedules.hbs +2 -0
  280. package/addon/templates/management/vehicles/index/details/work-orders.hbs +2 -0
  281. package/addon/templates/management/vehicles/index/details.hbs +1 -1
  282. package/addon/templates/management/vehicles/index/edit.hbs +1 -2
  283. package/addon/templates/management/vehicles/index/new.hbs +1 -2
  284. package/addon/templates/management/vendors/index/edit.hbs +1 -2
  285. package/addon/templates/management/vendors/index/new.hbs +1 -2
  286. package/addon/templates/management/vendors/index.hbs +1 -2
  287. package/addon/templates/management/vendors/integrated.hbs +1 -2
  288. package/addon/templates/operations/orchestrator.hbs +1 -0
  289. package/addon/templates/operations/orders/index/details/virtual.hbs +1 -0
  290. package/addon/templates/operations/orders/index.hbs +6 -1
  291. package/addon/templates/operations/scheduler/fleet-schedule.hbs +41 -0
  292. package/addon/templates/operations/scheduler/index.hbs +147 -88
  293. package/addon/templates/operations/scheduler.hbs +7 -1
  294. package/addon/templates/settings/avatars.hbs +1 -1
  295. package/addon/templates/settings/orchestrator.hbs +65 -0
  296. package/addon/templates/settings/payments/index.hbs +1 -5
  297. package/addon/templates/settings/scheduling.hbs +82 -0
  298. package/addon/utils/create-full-calendar-event-from-order.js +52 -14
  299. package/addon/utils/create-full-calendar-event-from-schedule-item.js +50 -0
  300. package/addon/utils/fleet-ops-options.js +254 -0
  301. package/addon/utils/route-colors.js +99 -0
  302. package/addon/utils/to-calendar-date.js +70 -0
  303. package/app/components/driver/schedule.js +1 -0
  304. package/app/components/maintenance/cost-panel.js +1 -0
  305. package/app/components/maintenance/panel-header.js +1 -0
  306. package/app/components/maintenance-schedule/details.js +1 -0
  307. package/app/components/maintenance-schedule/form.js +1 -0
  308. package/app/components/modals/add-driver-shift.js +1 -0
  309. package/app/components/modals/bulk-assign-orders.js +1 -0
  310. package/app/components/modals/driver-shift.js +1 -0
  311. package/app/components/modals/orchestrator-import.js +1 -0
  312. package/app/components/modals/scheduling-conflict.js +1 -0
  313. package/app/components/modals/send-work-order.js +1 -0
  314. package/app/components/modals/set-driver-availability.js +1 -0
  315. package/app/components/orchestrator/card-fields-settings.js +1 -0
  316. package/app/components/orchestrator/order-pool.js +1 -0
  317. package/app/components/orchestrator/phase-builder.js +1 -0
  318. package/app/components/orchestrator/plan-viewer.js +1 -0
  319. package/app/components/orchestrator/resource-panel.js +1 -0
  320. package/app/components/orchestrator-workbench.js +1 -0
  321. package/app/components/vehicle/details/maintenance-history.js +1 -0
  322. package/app/components/vehicle/details/schedules.js +1 -0
  323. package/app/components/vehicle/details/work-orders.js +1 -0
  324. package/app/controllers/operations/orchestrator.js +1 -0
  325. package/app/controllers/settings/orchestrator.js +1 -0
  326. package/app/controllers/settings/scheduling.js +1 -0
  327. package/app/routes/operations/orchestrator.js +1 -0
  328. package/app/routes/operations/orders/index/details/virtual.js +1 -0
  329. package/app/routes/settings/orchestrator.js +1 -0
  330. package/app/routes/settings/scheduling.js +1 -0
  331. package/app/services/maintenance-schedule-actions.js +1 -0
  332. package/app/services/orchestration-engine-interface.js +1 -0
  333. package/app/services/orchestration-engine.js +1 -0
  334. package/app/services/order-allocation.js +1 -0
  335. package/app/services/scheduling.js +1 -0
  336. package/app/services/vroom-allocation-engine.js +1 -0
  337. package/app/templates/operations/orders/index/details/virtual.js +1 -0
  338. package/app/templates/settings/scheduling.js +1 -0
  339. package/app/utils/create-full-calendar-event-from-schedule-item.js +1 -0
  340. package/app/utils/route-colors.js +1 -0
  341. package/composer.json +5 -3
  342. package/extension.json +1 -1
  343. package/package.json +6 -5
  344. package/server/config/fleetops.php +20 -1
  345. package/server/migrations/2025_08_28_054927_create_parts_table.php +2 -2
  346. package/server/migrations/2025_08_28_054932_add_public_id_to_maintenance_tables.php +45 -0
  347. package/server/migrations/2025_09_01_000001_create_maintenance_schedules_table.php +88 -0
  348. package/server/migrations/2026_04_01_000001_fix_monetary_columns_in_parts_table.php +48 -0
  349. package/server/migrations/2026_04_01_000003_add_photo_uuid_to_equipment_and_parts_tables.php +61 -0
  350. package/server/migrations/2026_04_01_000004_add_public_id_to_equipments_table.php +38 -0
  351. package/server/migrations/2026_04_01_000005_add_missing_columns_to_parts_table.php +67 -0
  352. package/server/migrations/2026_04_04_000001_add_reminder_offsets_to_maintenance_schedules.php +44 -0
  353. package/server/migrations/2026_04_08_000001_add_orchestrator_columns_to_vehicles_table.php +58 -0
  354. package/server/migrations/2026_04_08_000002_add_orchestrator_columns_to_drivers_table.php +41 -0
  355. package/server/migrations/2026_04_08_000003_add_orchestrator_columns_to_orders_table.php +38 -0
  356. package/server/migrations/2026_04_08_000004_add_orchestrator_columns_to_payloads_table.php +41 -0
  357. package/server/migrations/2026_04_08_000005_add_orchestrator_columns_to_waypoints_table.php +38 -0
  358. package/server/migrations/2026_04_09_000001_create_manifests_table.php +48 -0
  359. package/server/migrations/2026_04_09_000002_create_manifest_stops_table.php +48 -0
  360. package/server/migrations/2026_04_09_000003_add_manifest_uuid_to_orders_table.php +28 -0
  361. package/server/migrations/2026_04_13_000001_add_pod_notes_columns_to_waypoints_table.php +39 -0
  362. package/server/resources/views/mail/maintenance-schedule-reminder.blade.php +59 -0
  363. package/server/resources/views/mail/work-order-dispatched.blade.php +67 -0
  364. package/server/src/Auth/Schemas/FleetOps.php +44 -0
  365. package/server/src/Console/Commands/ProcessMaintenanceTriggers.php +150 -0
  366. package/server/src/Console/Commands/SendMaintenanceReminders.php +128 -0
  367. package/server/src/Http/Controllers/Internal/v1/DriverController.php +1 -0
  368. package/server/src/Http/Controllers/Internal/v1/EquipmentController.php +27 -0
  369. package/server/src/Http/Controllers/Internal/v1/LiveController.php +9 -2
  370. package/server/src/Http/Controllers/Internal/v1/MaintenanceController.php +165 -0
  371. package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +304 -0
  372. package/server/src/Http/Controllers/Internal/v1/ManifestController.php +138 -0
  373. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +975 -0
  374. package/server/src/Http/Controllers/Internal/v1/OrderController.php +42 -0
  375. package/server/src/Http/Controllers/Internal/v1/PartController.php +27 -0
  376. package/server/src/Http/Controllers/Internal/v1/SettingController.php +118 -0
  377. package/server/src/Http/Controllers/Internal/v1/Traits/DriverSchedulingTrait.php +214 -0
  378. package/server/src/Http/Controllers/Internal/v1/WorkOrderController.php +68 -0
  379. package/server/src/Http/Resources/v1/Driver.php +1 -0
  380. package/server/src/Http/Resources/v1/Maintenance.php +138 -0
  381. package/server/src/Http/Resources/v1/MaintenanceSchedule.php +137 -0
  382. package/server/src/Http/Resources/v1/Orchestrator/Order.php +116 -0
  383. package/server/src/Http/Resources/v1/Order.php +7 -4
  384. package/server/src/Http/Resources/v1/Waypoint.php +7 -0
  385. package/server/src/Http/Resources/v1/WorkOrder.php +136 -0
  386. package/server/src/Imports/EquipmentImport.php +32 -0
  387. package/server/src/Imports/MaintenanceImport.php +32 -0
  388. package/server/src/Imports/MaintenanceScheduleImport.php +32 -0
  389. package/server/src/Imports/PartImport.php +32 -0
  390. package/server/src/Imports/WorkOrderImport.php +32 -0
  391. package/server/src/Jobs/ProcessAllocationJob.php +119 -0
  392. package/server/src/Listeners/HandleDeliveryCompletion.php +47 -0
  393. package/server/src/Listeners/NotifyDriverOnShiftChange.php +63 -0
  394. package/server/src/Mail/MaintenanceScheduleReminder.php +68 -0
  395. package/server/src/Mail/WorkOrderDispatched.php +58 -0
  396. package/server/src/Models/Asset.php +2 -2
  397. package/server/src/Models/Device.php +1 -1
  398. package/server/src/Models/Driver.php +82 -4
  399. package/server/src/Models/Equipment.php +62 -2
  400. package/server/src/Models/Maintenance.php +127 -9
  401. package/server/src/Models/MaintenanceSchedule.php +353 -0
  402. package/server/src/Models/Manifest.php +214 -0
  403. package/server/src/Models/ManifestStop.php +162 -0
  404. package/server/src/Models/Order.php +70 -0
  405. package/server/src/Models/OrderConfig.php +5 -2
  406. package/server/src/Models/Part.php +69 -3
  407. package/server/src/Models/Payload.php +7 -2
  408. package/server/src/Models/Place.php +1 -1
  409. package/server/src/Models/Sensor.php +1 -1
  410. package/server/src/Models/ServiceQuote.php +1 -1
  411. package/server/src/Models/Vehicle.php +20 -1
  412. package/server/src/Models/Warranty.php +1 -1
  413. package/server/src/Models/Waypoint.php +7 -1
  414. package/server/src/Models/WorkOrder.php +122 -12
  415. package/server/src/Notifications/DriverShiftChanged.php +110 -0
  416. package/server/src/Observers/WorkOrderObserver.php +107 -0
  417. package/server/src/Orchestration/Contracts/OrchestrationEngineInterface.php +63 -0
  418. package/server/src/Orchestration/Engines/DriverAssignmentEngine.php +265 -0
  419. package/server/src/Orchestration/Engines/GreedyOrchestrationEngine.php +155 -0
  420. package/server/src/Orchestration/Engines/RouteSequencingEngine.php +272 -0
  421. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +192 -0
  422. package/server/src/Orchestration/OrchestrationEngineRegistry.php +83 -0
  423. package/server/src/Orchestration/Support/OrchestrationPayloadBuilder.php +290 -0
  424. package/server/src/Providers/EventServiceProvider.php +7 -1
  425. package/server/src/Providers/FleetOpsServiceProvider.php +42 -15
  426. package/server/src/routes.php +65 -4
  427. package/translations/ar-ae.yml +44 -12
  428. package/translations/bg-bg.yaml +51 -10
  429. package/translations/en-us.yaml +444 -1
  430. package/translations/fr-fr.yaml +51 -10
  431. package/translations/mn-mn.yaml +51 -10
  432. package/translations/pt-br.yaml +51 -10
  433. package/translations/ru-ru.yaml +51 -10
  434. package/translations/vi-vn.yaml +48 -12
@@ -2,384 +2,908 @@ import Controller from '@ember/controller';
2
2
  import { tracked } from '@glimmer/tracking';
3
3
  import { inject as service } from '@ember/service';
4
4
  import { action, computed } from '@ember/object';
5
- import { later } from '@ember/runloop';
5
+ import { isNone } from '@ember/utils';
6
+ import { isValid as isValidDate } from 'date-fns';
6
7
  import { task } from 'ember-concurrency';
7
- import { format, isValid as isValidDate } from 'date-fns';
8
- import { Tooltip } from '@fleetbase/ember-ui/utils/floating';
9
8
  import isObject from '@fleetbase/ember-core/utils/is-object';
10
9
  import isJson from '@fleetbase/ember-core/utils/is-json';
11
- import createFullCalendarEventFromOrder, { createOrderEventTitle, createOrderEventDescription } from '../../../utils/create-full-calendar-event-from-order';
12
-
13
- function createFullCalendarEventFromScheduleItem(item, driver) {
14
- return {
15
- id: item.id,
16
- resourceId: driver.id,
17
- title: `${driver.name} - Shift`,
18
- start: item.start_at,
19
- end: item.end_at,
20
- backgroundColor: getScheduleItemColor(item),
21
- extendedProps: {
22
- scheduleItem: item,
23
- driver: driver,
24
- },
25
- };
26
- }
27
-
28
- function getScheduleItemColor(item) {
29
- const statusColors = {
30
- pending: '#FFA500',
31
- confirmed: '#4CAF50',
32
- in_progress: '#2196F3',
33
- completed: '#9E9E9E',
34
- cancelled: '#F44336',
35
- no_show: '#FF5722',
36
- };
37
- return statusColors[item.status] || '#4CAF50';
38
- }
39
-
10
+ import createFullCalendarEventFromOrder from '../../../utils/create-full-calendar-event-from-order';
11
+ import createFullCalendarEventFromScheduleItem from '../../../utils/create-full-calendar-event-from-schedule-item';
12
+ import toCalendarDate from '../../../utils/to-calendar-date';
13
+
14
+ /**
15
+ * OperationsSchedulerIndexController
16
+ *
17
+ * Unified order dispatch board controller.
18
+ * All scheduling domain logic is delegated to the injected `scheduling` service.
19
+ *
20
+ * Calendar library: @event-calendar/core (MIT licensed).
21
+ * This replaces FullCalendar Premium resource-timeline plugins which are
22
+ * incompatible with Fleetbase's dual AGPL v3 / commercial license.
23
+ *
24
+ * Timezone handling
25
+ * -----------------
26
+ * @event-calendar/core has no timezone support — it reads the browser-local
27
+ * fields of any Date and positions events at that wall-clock time (see
28
+ * https://github.com/vkurko/calendar/issues/576).
29
+ *
30
+ * The solution (as recommended by the maintainer) is to convert all UTC dates
31
+ * to "fake local" Dates whose local fields equal the company wall-clock time
32
+ * before passing them to the calendar. This is done via `toCalendarDate()`.
33
+ *
34
+ * The same conversion is applied to:
35
+ * - Event start/end (order events and shift background blocks)
36
+ * - The `now` option (current-time indicator position)
37
+ * - The `date` option (which day is highlighted as "today")
38
+ *
39
+ * When the user drops or drags an event, the calendar returns a Date whose
40
+ * local fields equal the visible wall-clock time. `_reinterpretDateInTimezone`
41
+ * converts that back to a true UTC instant for the API.
42
+ *
43
+ * Data flow:
44
+ * Route -> store.query() -> Ember Data store
45
+ * Controller computed getters -> store.peekAll() -> reactive UI
46
+ * Socket service -> store.pushPayload() -> reactive UI (no page refresh)
47
+ *
48
+ * External drag-and-drop:
49
+ * Sidebar cards use native HTML5 draggable="true".
50
+ * onSidebarDragStart stores the dragged order reference.
51
+ * onCalendarDrop uses calendar.dateFromPoint(x, y) to resolve the target
52
+ * date and resource, then delegates to SchedulingService.assignOrder().
53
+ */
40
54
  export default class OperationsSchedulerIndexController extends Controller {
41
- @service modalsManager;
42
- @service notifications;
55
+ @service scheduling;
56
+ @service socket;
43
57
  @service store;
58
+ @service notifications;
59
+ @service modalsManager;
60
+ @service currentUser;
44
61
  @service intl;
45
- @service hostRouter;
46
- // @service scheduling;
47
- @tracked scheduledOrders = [];
48
- @tracked unscheduledOrders = [];
62
+ @service fetch;
63
+
64
+ // UI State
65
+ @tracked calendar = null;
66
+ @tracked viewDate = new Date();
67
+ @tracked viewRange = 'week';
68
+ @tracked searchQuery = '';
69
+ @tracked activeFilters = [];
70
+ @tracked selectedOrderIds = new Set();
49
71
  @tracked drivers = [];
50
- @tracked scheduleItems = [];
51
- @tracked viewMode = 'orders'; // 'orders' or 'drivers'
52
-
53
- @computed('drivers', 'scheduleItems.[]', 'scheduledOrders.[]', 'viewMode') get events() {
54
- if (this.viewMode === 'drivers') {
55
- return this.scheduleItems.map((item) => {
56
- const driver = this.drivers.find((d) => d.id === item.assignee_uuid);
57
- return createFullCalendarEventFromScheduleItem(item, driver);
72
+ @tracked sidebarCollapsed = false;
73
+
74
+ // Holds the order being dragged from the sidebar so onCalendarDrop can access it.
75
+ _draggedOrder = null;
76
+
77
+ // Revision counter — incremented after every successful save so that
78
+ // @computed('_orderRevision') getters recompute even when Ember Data's
79
+ // @each tracking misses a deep attribute change on an existing record.
80
+ @tracked _orderRevision = 0;
81
+
82
+ // -------------------------------------------------------------------------
83
+ // Reactive Computed Getters
84
+ // -------------------------------------------------------------------------
85
+
86
+ @computed('_orderRevision', 'store')
87
+ get allActiveOrders() {
88
+ // Return all orders with an active status — no date window filtering.
89
+ // The calendar renders only what falls in the visible range; unscheduled
90
+ // orders appear in the sidebar panel. Past orders are kept for historical
91
+ // context and future orders are always visible regardless of current view.
92
+ const statuses = ['created', 'dispatched', 'active'];
93
+ return this.store.peekAll('order').filter((order) => statuses.includes(order.status));
94
+ }
95
+
96
+ @computed('allActiveOrders.@each.scheduled_at', 'searchQuery', 'searchQuery.length', 'activeFilters.[]')
97
+ get unscheduledOrders() {
98
+ let orders = this.allActiveOrders.filter((o) => isNone(o.scheduled_at) || !isValidDate(new Date(o.scheduled_at)));
99
+ if (this.searchQuery && this.searchQuery.length >= 2) {
100
+ const q = this.searchQuery.toLowerCase();
101
+ orders = orders.filter((o) => {
102
+ return (o.public_id ?? '').toLowerCase().includes(q) || (o.tracking ?? '').toLowerCase().includes(q) || (o.payload?.dropoff?.address ?? '').toLowerCase().includes(q);
58
103
  });
59
104
  }
60
- return this.scheduledOrders.map(createFullCalendarEventFromOrder);
105
+ this.activeFilters.forEach((filter) => {
106
+ if (filter.type === 'type') orders = orders.filter((o) => o.type === filter.value);
107
+ if (filter.type === 'priority') orders = orders.filter((o) => o.priority === filter.value);
108
+ });
109
+ return orders;
61
110
  }
62
111
 
63
- @computed('drivers.[]') get calendarResources() {
64
- return this.drivers.map((driver) => ({
65
- id: driver.id,
66
- title: driver.name,
67
- extendedProps: { driver },
68
- }));
112
+ @computed('allActiveOrders.@each.{scheduled_at,driver_assigned_uuid,status}', 'currentUser.company.timezone', 'companyTimezone')
113
+ get calendarEvents() {
114
+ const tz = this.companyTimezone;
115
+ return this.allActiveOrders.filter((o) => !isNone(o.scheduled_at) && isValidDate(new Date(o.scheduled_at))).map((o) => createFullCalendarEventFromOrder(o, tz));
69
116
  }
70
117
 
71
- get calendarStartDate() {
72
- const now = new Date();
73
- const dayOfWeek = now.getDay();
74
- const diff = now.getDate() - dayOfWeek;
75
- return new Date(now.setDate(diff)).toISOString();
118
+ @computed('drivers.[]', 'allActiveOrders.@each.{scheduled_at,driver_assigned_uuid}')
119
+ get calendarResources() {
120
+ return this.drivers.map((driver) => {
121
+ const assignedCount = this.allActiveOrders.filter((o) => o.driver_assigned_uuid === driver.id && !isNone(o.scheduled_at)).length;
122
+ const maxCapacity = driver.max_daily_orders ?? 10;
123
+ const pct = Math.round((assignedCount / maxCapacity) * 100);
124
+ return {
125
+ id: driver.id,
126
+ title: driver.name,
127
+ extendedProps: {
128
+ driver,
129
+ workload: { assigned: assignedCount, capacity: maxCapacity, percentage: Math.min(pct, 100) },
130
+ },
131
+ };
132
+ });
76
133
  }
77
134
 
78
- get calendarEndDate() {
79
- const now = new Date();
80
- return new Date(now.setDate(now.getDate() + 28)).toISOString();
135
+ @computed('drivers.@each.currentShift', 'currentUser.company.timezone', 'companyTimezone')
136
+ get backgroundEvents() {
137
+ const tz = this.companyTimezone;
138
+ const events = [];
139
+ this.drivers.forEach((driver) => {
140
+ const shift = driver.currentShift;
141
+ if (shift) {
142
+ events.push(
143
+ createFullCalendarEventFromScheduleItem(shift, driver, tz, {
144
+ display: 'background',
145
+ backgroundColor: 'rgba(99, 102, 241, 0.08)',
146
+ borderColor: 'rgba(99, 102, 241, 0.25)',
147
+ })
148
+ );
149
+ }
150
+ });
151
+ return events;
81
152
  }
82
153
 
83
- @task *loadDrivers() {
84
- try {
85
- const drivers = yield this.store.query('driver', { limit: 100 });
86
- this.drivers = drivers.toArray();
87
- } catch (error) {
88
- this.notifications.serverError(error);
89
- }
154
+ @computed('calendarEvents.[]', 'backgroundEvents.[]')
155
+ get allCalendarEvents() {
156
+ return [...this.calendarEvents, ...this.backgroundEvents];
90
157
  }
91
158
 
92
- @task *loadScheduleItems() {
93
- try {
94
- const items = yield this.store.query('schedule-item', {
95
- assignee_type: 'driver',
96
- start_at_after: this.calendarStartDate,
97
- end_at_before: this.calendarEndDate,
98
- });
99
- this.scheduleItems = items.toArray();
100
- } catch (error) {
101
- this.notifications.serverError(error);
102
- }
159
+ /**
160
+ * The view name string passed to EventCalendar's @view arg.
161
+ * @event-calendar/core uses 'resourceTimelineDay' / 'resourceTimelineWeek'
162
+ * identical to FullCalendar's naming convention.
163
+ */
164
+ get currentCalendarView() {
165
+ const viewMap = { day: 'resourceTimelineDay', week: 'resourceTimelineWeek' };
166
+ return viewMap[this.viewRange] ?? 'resourceTimelineDay';
167
+ }
168
+
169
+ /**
170
+ * Minimal header toolbar — only the date title is shown.
171
+ * Navigation (prev/next/today) and view-range buttons are already
172
+ * provided by the section header above the calendar, so we suppress
173
+ * the duplicates here.
174
+ */
175
+ get calendarHeaderToolbar() {
176
+ return { start: '', center: 'title', end: '' };
177
+ }
178
+
179
+ /**
180
+ * Returns the IANA timezone string for the current organisation.
181
+ * Falls back to the browser's local timezone when the company record has
182
+ * not yet loaded or has no timezone set.
183
+ *
184
+ * @returns {string} e.g. 'Asia/Singapore', 'America/New_York'
185
+ */
186
+ get companyTimezone() {
187
+ return this.currentUser?.company?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
188
+ }
189
+
190
+ /**
191
+ * Options passed to the EventCalendar @options arg.
192
+ * These control display formatting only — timezone conversion is handled
193
+ * by toCalendarDate() before events reach the calendar.
194
+ *
195
+ * @returns {object}
196
+ */
197
+ @computed('currentUser.company.timezone')
198
+ get calendarOptions() {
199
+ return {
200
+ slotLabelFormat: { hour: '2-digit', minute: '2-digit', hour12: false },
201
+ eventTimeFormat: { hour: '2-digit', minute: '2-digit', hour12: false },
202
+ dayHeaderFormat: { weekday: 'short', month: 'numeric', day: 'numeric' },
203
+ };
204
+ }
205
+
206
+ /**
207
+ * The current moment expressed as a "fake local" Date in the company
208
+ * timezone. Passed to the calendar as the `now` option so that the
209
+ * current-time indicator appears at the correct position on the timeline.
210
+ *
211
+ * @returns {Date}
212
+ */
213
+ @computed('currentUser.company.timezone', 'companyTimezone')
214
+ get calendarNow() {
215
+ return toCalendarDate(new Date(), this.companyTimezone);
216
+ }
217
+
218
+ /** Full 24-hour day visible on the timeline. */
219
+ get calendarSlotMinTime() {
220
+ return '00:00:00';
221
+ }
222
+
223
+ get calendarSlotMaxTime() {
224
+ return '24:00:00';
225
+ }
226
+
227
+ // -------------------------------------------------------------------------
228
+ // EventCalendar Render Hooks
229
+ // -------------------------------------------------------------------------
230
+
231
+ /**
232
+ * Renders the resource label cell for each driver row.
233
+ * Returns an HTML string that EventCalendar injects into the label cell.
234
+ * Shows driver name and a full-width capacity bar.
235
+ */
236
+ @action renderResourceLabel({ resource }) {
237
+ const { driver, workload } = resource.extendedProps ?? {};
238
+ if (!driver) return resource.title ?? '';
239
+ const { assigned = 0, capacity = 10, percentage = 0 } = workload ?? {};
240
+ const barColour = percentage >= 90 ? '#ef4444' : percentage >= 70 ? '#f59e0b' : '#6366f1';
241
+ return {
242
+ html: `<div class="ec-resource-label-inner" style="width:100%;box-sizing:border-box;padding:4px 8px;">
243
+ <div style="font-size:0.75rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:100%;">${driver.name ?? ''}</div>
244
+ <div style="display:flex;align-items:center;gap:4px;margin-top:3px;width:100%;">
245
+ <div style="flex:1;min-width:0;height:4px;background:#374151;border-radius:9999px;overflow:hidden;">
246
+ <div style="height:100%;width:${percentage}%;background:${barColour};border-radius:9999px;transition:width 0.3s;"></div>
247
+ </div>
248
+ <span style="font-size:0.625rem;color:#9ca3af;white-space:nowrap;flex-shrink:0;">${assigned}/${capacity}</span>
249
+ </div>
250
+ </div>`,
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Renders the event tile content inside the timeline.
256
+ * Returns an HTML string for order events; shift background events render
257
+ * with no custom content (EventCalendar handles background display natively).
258
+ * Shows: tracking number, status badge, driver name, scheduled time, destination.
259
+ */
260
+ @action renderEventContent({ event }) {
261
+ if (event.display === 'background') return null;
262
+ const { order, status } = event.extendedProps ?? {};
263
+ const tracking = event.title ?? order?.tracking ?? order?.public_id ?? '';
264
+ const driverName = order?.driver_assigned?.name ?? order?.get?.('driver_assigned.name') ?? '';
265
+ const destination = order?.pickupName ?? order?.get?.('pickupName') ?? '';
266
+ const scheduledTime = order?.scheduledAtTime ?? order?.get?.('scheduledAtTime') ?? '';
267
+ const statusLabel = status ? status.charAt(0).toUpperCase() + status.slice(1) : '';
268
+ const metaLine = [scheduledTime, driverName].filter(Boolean).join(' · ');
269
+ return {
270
+ html: `<div style="display:flex;flex-direction:column;gap:2px;padding:2px 0;overflow:hidden;height:100%;">
271
+ <div style="display:flex;align-items:center;gap:4px;flex-wrap:nowrap;">
272
+ <span style="width:7px;height:7px;border-radius:50%;background:#ffffff;opacity:0.9;flex-shrink:0;"></span>
273
+ <span style="font-size:0.72rem;font-weight:700;color:#ffffff;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;">${tracking}</span>
274
+ <span style="font-size:0.58rem;background:rgba(255,255,255,0.2);color:#ffffff;border-radius:3px;padding:1px 4px;white-space:nowrap;flex-shrink:0;">${statusLabel}</span>
275
+ </div>
276
+ ${metaLine ? `<div style="font-size:0.65rem;color:rgba(255,255,255,0.85);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${metaLine}</div>` : ''}
277
+ ${destination ? `<div style="font-size:0.65rem;color:rgba(255,255,255,0.7);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">→ ${destination}</div>` : ''}
278
+ </div>`,
279
+ };
280
+ }
281
+
282
+ // -------------------------------------------------------------------------
283
+ // Sidebar Selection
284
+ // -------------------------------------------------------------------------
285
+
286
+ get selectedOrders() {
287
+ return this.unscheduledOrders.filter((o) => this.selectedOrderIds.has(o.id));
288
+ }
289
+
290
+ get hasSelection() {
291
+ return this.selectedOrderIds.size > 0;
292
+ }
293
+
294
+ @action isOrderSelected(orderId) {
295
+ return this.selectedOrderIds.has(orderId);
296
+ }
297
+
298
+ @action toggleOrderSelection(orderId) {
299
+ const next = new Set(this.selectedOrderIds);
300
+ next.has(orderId) ? next.delete(orderId) : next.add(orderId);
301
+ this.selectedOrderIds = next;
302
+ }
303
+
304
+ @action selectAllOrders() {
305
+ this.selectedOrderIds = new Set(this.unscheduledOrders.map((o) => o.id));
306
+ }
307
+
308
+ @action clearSelection() {
309
+ this.selectedOrderIds = new Set();
310
+ }
311
+
312
+ // -------------------------------------------------------------------------
313
+ // Debounced Sidebar Search
314
+ // -------------------------------------------------------------------------
315
+
316
+ @task({ restartable: true })
317
+ *searchTask(query) {
318
+ yield new Promise((resolve) => setTimeout(resolve, 300));
319
+ this.searchQuery = query;
320
+ }
321
+
322
+ @action onSearchInput(event) {
323
+ this.searchTask.perform(event.target.value);
103
324
  }
104
325
 
326
+ @action clearSearch() {
327
+ this.searchQuery = '';
328
+ }
329
+
330
+ // -------------------------------------------------------------------------
331
+ // EventCalendar Lifecycle
332
+ // -------------------------------------------------------------------------
333
+
334
+ /**
335
+ * Receives the EventCalendar instance once it is mounted.
336
+ * The instance exposes: setOption(), getOption(), prev(), next(),
337
+ * getEventById(), removeEventById(), updateEvent(), dateFromPoint().
338
+ */
105
339
  @action setCalendarApi(calendar) {
106
340
  this.calendar = calendar;
107
- // setup some custom post initialization stuff here
108
- // calendar.setOption('height', 800);
109
- calendar.setOption('eventDidMount', (info) => {
110
- if (!info.event.extendedProps.description) return;
341
+ }
342
+
343
+ // -------------------------------------------------------------------------
344
+ // Drag-and-Drop: External Drop from Sidebar (native HTML5)
345
+ // -------------------------------------------------------------------------
346
+
347
+ /**
348
+ * Called on dragstart for each sidebar order card.
349
+ * Stores the order reference so onCalendarDrop can retrieve it.
350
+ */
351
+ @action onSidebarDragStart(order, event) {
352
+ this._draggedOrder = order;
353
+ // Set a minimal dataTransfer payload as a fallback identifier.
354
+ event.dataTransfer.setData('text/plain', order.id);
355
+ event.dataTransfer.effectAllowed = 'move';
356
+ }
111
357
 
112
- info.tooltip = new Tooltip(info.el, {
113
- text: info.event.extendedProps.description,
358
+ /**
359
+ * Prevents the browser's default "no drop" behaviour so the drop event fires.
360
+ * Also sets a data attribute on the timeline container to trigger the CSS
361
+ * drag-over highlight, and moves a thin cursor line to the exact drop column.
362
+ */
363
+ @action onCalendarDragOver(event) {
364
+ event.preventDefault();
365
+ event.dataTransfer.dropEffect = 'move';
366
+ const el = document.getElementById('fleet-ops-scheduler-timeline');
367
+ if (!el) return;
368
+ el.dataset.draggingOver = 'true';
369
+ // Move the drop-cursor indicator to the current pointer X position.
370
+ const ecMain = el.querySelector('.ec-main');
371
+ if (ecMain) {
372
+ let cursor = ecMain.querySelector('.ec-drop-cursor');
373
+ if (!cursor) {
374
+ cursor = document.createElement('div');
375
+ cursor.className = 'ec-drop-cursor';
376
+ ecMain.style.position = 'relative';
377
+ ecMain.appendChild(cursor);
378
+ }
379
+ const rect = ecMain.getBoundingClientRect();
380
+ const scrollLeft = ecMain.scrollLeft;
381
+ cursor.style.left = event.clientX - rect.left + scrollLeft + 'px';
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Clears the drag-over highlight and removes the cursor indicator when the
387
+ * pointer genuinely exits the timeline container (not just moves to a child).
388
+ */
389
+ @action onCalendarDragLeave(event) {
390
+ const el = document.getElementById('fleet-ops-scheduler-timeline');
391
+ if (el && !el.contains(event.relatedTarget)) {
392
+ delete el.dataset.draggingOver;
393
+ const cursor = el.querySelector('.ec-drop-cursor');
394
+ if (cursor) cursor.remove();
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Handles a sidebar card being dropped onto the EventCalendar timeline.
400
+ * Uses calendar.dateFromPoint(x, y) to resolve the target date and resource
401
+ * from the drop coordinates — this is the @event-calendar/core equivalent
402
+ * of FullCalendar's onDrop / eventReceive callback.
403
+ *
404
+ * dateFromPoint() returns a Date whose LOCAL fields equal the wall-clock
405
+ * time the user sees on screen (because the calendar stored the "fake local"
406
+ * dates we passed in, and returns them the same way). We reinterpret those
407
+ * local fields as a true UTC instant using the company timezone.
408
+ */
409
+ @action async onCalendarDrop(event) {
410
+ event.preventDefault();
411
+ // Clear the drag-over highlight and cursor indicator.
412
+ const timelineEl = document.getElementById('fleet-ops-scheduler-timeline');
413
+ if (timelineEl) {
414
+ delete timelineEl.dataset.draggingOver;
415
+ const cursor = timelineEl.querySelector('.ec-drop-cursor');
416
+ if (cursor) cursor.remove();
417
+ }
418
+ const order = this._draggedOrder;
419
+ this._draggedOrder = null;
420
+ if (!order || !this.calendar) return;
421
+
422
+ // Preserve scroll position so the drop doesn't reset the timeline view.
423
+ const ecMain = timelineEl?.querySelector('.ec-main');
424
+ const savedScrollLeft = ecMain ? ecMain.scrollLeft : 0;
425
+ const savedScrollTop = ecMain ? ecMain.scrollTop : 0;
426
+
427
+ // Resolve drop position to a date + resource using EventCalendar's API.
428
+ const dropInfo = this.calendar.dateFromPoint(event.clientX, event.clientY);
429
+ if (!dropInfo) return;
430
+ const { date, resource } = dropInfo;
431
+ const driverId = resource?.id ?? null;
432
+
433
+ // dateFromPoint() returns a Date whose local fields equal the wall-clock
434
+ // time visible on screen. Convert to a true UTC instant for the API.
435
+ const scheduledAt = date ? this._reinterpretDateInTimezone(date, this.companyTimezone) : new Date();
436
+
437
+ const result = await this.scheduling.assignOrder(order, driverId, scheduledAt);
438
+
439
+ // Bump the revision counter so allActiveOrders / unscheduledOrders
440
+ // recompute immediately — this removes the order from the sidebar.
441
+ if (!result.error) {
442
+ this._orderRevision += 1;
443
+ }
444
+
445
+ // Restore scroll position after the calendar re-renders.
446
+ if (ecMain) {
447
+ requestAnimationFrame(() => {
448
+ ecMain.scrollLeft = savedScrollLeft;
449
+ ecMain.scrollTop = savedScrollTop;
114
450
  });
115
- });
451
+ }
116
452
 
117
- calendar.setOption('eventWillUnmount', (info) => {
118
- info.tooltip?.destroy();
119
- });
453
+ if (result.hasConflict) {
454
+ this._showConflictModal(order, driverId, scheduledAt, result.conflicts);
455
+ }
120
456
  }
121
457
 
122
- @action viewEvent(order) {
123
- // get the event from the calendar
124
- let event = this.calendar.getEventById(order.id);
458
+ // -------------------------------------------------------------------------
459
+ // Drag-and-Drop: Reschedule Existing Event (internal timeline drag)
460
+ // -------------------------------------------------------------------------
461
+
462
+ /**
463
+ * Handles an existing calendar event being dragged to a new time/resource.
464
+ * @event-calendar/core eventDrop info shape:
465
+ * { event, oldEvent, oldResource, newResource, delta, revert, jsEvent, view }
466
+ * event.resourceIds[0] replaces FullCalendar's event.getResources()[0]?.id
467
+ */
468
+ @action async rescheduleEventFromDrag(info) {
469
+ const { event, revert } = info;
470
+ const { start, end, extendedProps } = event;
471
+ const tz = this.companyTimezone;
472
+ if (extendedProps?.scheduleItem) {
473
+ // Shift block drag — update the ScheduleItem record directly.
474
+ const scheduleItem = extendedProps.scheduleItem;
475
+ const newResourceId = event.resourceIds?.[0];
476
+ try {
477
+ scheduleItem.set('start_at', this._reinterpretDateInTimezone(start, tz));
478
+ scheduleItem.set('end_at', this._reinterpretDateInTimezone(end ?? start, tz));
479
+ if (newResourceId) scheduleItem.set('assignee_uuid', newResourceId);
480
+ await scheduleItem.save();
481
+ this.notifications.success(this.intl.t('scheduler.shift-updated'));
482
+ } catch (error) {
483
+ this.notifications.serverError(error);
484
+ revert();
485
+ }
486
+ return;
487
+ }
488
+ // Order event drag — delegate to SchedulingService.
489
+ const order = this.store.peekRecord('order', event.id);
490
+ if (!order) return;
491
+ const newDriverId = event.resourceIds?.[0] ?? order.driver_assigned_uuid;
492
+ const result = await this.scheduling.assignOrder(order, newDriverId, this._reinterpretDateInTimezone(start, tz));
493
+ const tzStart = this._reinterpretDateInTimezone(start, tz);
494
+ if (result.hasConflict) {
495
+ revert();
496
+ this._showConflictModal(order, newDriverId, tzStart, result.conflicts);
497
+ } else if (result.error) {
498
+ revert();
499
+ } else {
500
+ this._orderRevision += 1;
501
+ }
502
+ }
503
+
504
+ // -------------------------------------------------------------------------
505
+ // Event Click
506
+ // -------------------------------------------------------------------------
507
+
508
+ /**
509
+ * @event-calendar/core eventClick info shape: { event, el, jsEvent, view }
510
+ * Identical to FullCalendar — no changes needed to the info object access.
511
+ */
512
+ @action viewOrderAsEvent(info) {
513
+ const { event } = info;
514
+ if (event.extendedProps?.scheduleItem) return this._viewShiftEvent(event);
515
+ const order = this.store.peekRecord('order', event.id);
516
+ if (order) this.viewEvent(order);
517
+ }
125
518
 
519
+ @action viewEvent(order) {
126
520
  this.modalsManager.show('modals/order-event', {
127
- title: this.intl.t('scheduler.scheduling-for', { orderId: order.tracking }),
128
- acceptButtonText: 'Save Changes',
521
+ title: this.intl.t('scheduler.scheduling-for', { orderId: order.tracking ?? order.public_id }),
522
+ acceptButtonText: this.intl.t('common.save-changes'),
129
523
  acceptButtonIcon: 'save',
130
524
  hideDeclineButton: true,
131
525
  order,
132
526
  reschedule: (date) => {
133
- if (date && typeof date.toDate === 'function') {
134
- date = date.toDate();
135
- }
136
-
527
+ if (date && typeof date.toDate === 'function') date = date.toDate();
137
528
  order.set('scheduled_at', date);
138
529
  },
139
- unschedule: () => {
140
- order.set('scheduled_at', null);
530
+ unschedule: async (modalsManager, done) => {
531
+ modalsManager.startLoading();
532
+ await this.scheduling.unscheduleOrder(order);
533
+ done();
141
534
  },
142
- confirm: async (modal) => {
143
- modal.startLoading();
144
-
145
- if (!order.get('hasDirtyAttributes')) {
146
- return modal.done();
147
- }
148
-
535
+ confirm: async (modalsManager, done) => {
536
+ modalsManager.startLoading();
537
+ if (!order.get('hasDirtyAttributes')) return done();
149
538
  try {
150
539
  await order.save();
151
- // remove event from calendar
152
- if (event) {
153
- this.removeEvent(event);
154
- }
155
-
156
540
  if (order.scheduled_at) {
157
- // notify order has been scheduled
158
- this.notifications.success(this.intl.t('scheduler.info-message', { orderId: order.public_id, orderAt: order.scheduledAt }));
159
- // add event to calendar
160
- event = this.calendar.addEvent(createFullCalendarEventFromOrder(order));
541
+ this.notifications.success(this.intl.t('scheduler.success-message', { orderId: order.public_id, orderAt: order.scheduledAt }));
161
542
  } else {
162
543
  this.notifications.info(this.intl.t('scheduler.info-message', { orderId: order.public_id }));
163
544
  }
164
-
165
- // update event props
166
- this.setEventProperty(event, 'title', createOrderEventTitle(order));
167
- this.setEventProperty(event, 'description', createOrderEventDescription(order));
168
-
169
- // refresh route
170
- return this.hostRouter.refresh();
545
+ done();
171
546
  } catch (error) {
172
547
  this.notifications.serverError(error);
173
- modal.stopLoading();
548
+ modalsManager.stopLoading();
174
549
  }
175
550
  },
176
551
  });
177
552
  }
178
553
 
179
- @action async switchViewMode(mode) {
180
- this.viewMode = mode;
181
- if (mode === 'drivers') {
182
- await this.loadDrivers.perform();
183
- await this.loadScheduleItems.perform();
184
- later(() => {
185
- if (this.calendar) {
186
- this.calendar.changeView('resourceTimelineWeek');
187
- }
188
- }, 100);
189
- } else {
190
- later(() => {
191
- if (this.calendar) {
192
- this.calendar.changeView('dayGridMonth');
193
- }
194
- }, 100);
195
- }
196
- }
197
-
198
- @action viewOrderAsEvent(eventClickInfo) {
199
- const { event } = eventClickInfo;
200
- if (event.extendedProps && event.extendedProps.scheduleItem) {
201
- return this.viewScheduleItem(event.extendedProps.scheduleItem, event.extendedProps.driver);
202
- }
203
- const order = this.store.peekRecord('order', event.id);
204
- this.viewEvent(order, eventClickInfo);
205
- }
206
-
207
- @action viewScheduleItem(scheduleItem, driver) {
554
+ _viewShiftEvent(event) {
555
+ const { scheduleItem, driver } = event.extendedProps;
208
556
  this.modalsManager.show('modals/driver-shift', {
209
- title: `${driver.name} - Shift Details`,
210
- acceptButtonText: 'Save Changes',
211
- acceptButtonIcon: 'save',
557
+ title: driver ? `${driver.name} ${this.intl.t('scheduler.shift')}` : this.intl.t('scheduler.shift'),
212
558
  scheduleItem,
213
559
  driver,
214
- confirm: async (modal) => {
215
- modal.startLoading();
560
+ confirm: async (modalsManager, done) => {
561
+ modalsManager.startLoading();
216
562
  try {
217
563
  await scheduleItem.save();
218
- this.notifications.success('Shift updated successfully');
219
- await this.loadScheduleItems.perform();
220
- modal.done();
564
+ this.notifications.success(this.intl.t('scheduler.shift-updated'));
565
+ done();
221
566
  } catch (error) {
222
567
  this.notifications.serverError(error);
223
- modal.stopLoading();
568
+ modalsManager.stopLoading();
224
569
  }
225
570
  },
226
- delete: async (modal) => {
227
- if (confirm('Are you sure you want to delete this shift?')) {
228
- modal.startLoading();
229
- try {
230
- await scheduleItem.destroyRecord();
231
- this.notifications.success('Shift deleted successfully');
232
- await this.loadScheduleItems.perform();
233
- modal.done();
234
- } catch (error) {
235
- this.notifications.serverError(error);
236
- modal.stopLoading();
571
+ delete: async (modalsManager, done) => {
572
+ modalsManager.startLoading();
573
+ try {
574
+ await scheduleItem.destroyRecord();
575
+ this.notifications.success(this.intl.t('scheduler.shift-deleted'));
576
+ done();
577
+ } catch (error) {
578
+ this.notifications.serverError(error);
579
+ modalsManager.stopLoading();
580
+ }
581
+ },
582
+ });
583
+ }
584
+
585
+ // -------------------------------------------------------------------------
586
+ // Add Driver Shift
587
+ // -------------------------------------------------------------------------
588
+
589
+ @action addDriverShift() {
590
+ this.modalsManager.show('modals/add-driver-shift', {
591
+ title: this.intl.t('scheduler.add-shift'),
592
+ acceptButtonText: this.intl.t('scheduler.create-shift'),
593
+ acceptButtonIcon: 'plus',
594
+ drivers: this.drivers,
595
+ confirm: async (modalsManager, done) => {
596
+ modalsManager.startLoading();
597
+ const options = modalsManager.getOptions();
598
+ const targetDriver = options.selectedDriver;
599
+ try {
600
+ if (options.isRecurring) {
601
+ const template = this.store.createRecord('schedule-template', {
602
+ name: options.templateName || `${targetDriver?.name} Recurring Schedule`,
603
+ rrule: options.rrule,
604
+ start_time: options.shiftStartTime,
605
+ end_time: options.shiftEndTime,
606
+ break_start_time: options.breakStartTime || null,
607
+ break_end_time: options.breakEndTime || null,
608
+ color: options.templateColor || '#6366f1',
609
+ });
610
+ const savedTemplate = await template.save();
611
+ const schedules = await this.store.query('schedule', { subject_type: 'driver', subject_uuid: targetDriver.id, limit: 1 });
612
+ let schedule;
613
+ if (schedules.length > 0) {
614
+ schedule = schedules.firstObject;
615
+ } else {
616
+ schedule = await this.store
617
+ .createRecord('schedule', {
618
+ subject_type: 'driver',
619
+ subject_uuid: targetDriver.id,
620
+ name: `${targetDriver.name} Schedule`,
621
+ timezone: this.companyTimezone,
622
+ status: 'draft',
623
+ })
624
+ .save();
625
+ }
626
+ await this.fetch.post(`schedule-templates/${savedTemplate.id}/apply`, {
627
+ subject_type: 'driver',
628
+ subject_uuid: targetDriver.id,
629
+ schedule_uuid: schedule.id,
630
+ effective_from: options.recurrenceStartDate || new Date().toISOString(),
631
+ effective_until: options.recurrenceEndDate || null,
632
+ });
633
+ this.notifications.success(this.intl.t('scheduler.recurring-schedule-created'));
634
+ } else {
635
+ const scheduleItem = this.store.createRecord('schedule-item', {
636
+ assignee_type: 'driver',
637
+ assignee_uuid: targetDriver?.id,
638
+ title: options.title || null,
639
+ start_at: options.startAt,
640
+ end_at: options.endAt,
641
+ notes: options.notes || null,
642
+ status: 'scheduled',
643
+ });
644
+ await scheduleItem.save();
645
+ this.notifications.success(this.intl.t('scheduler.shift-created'));
237
646
  }
647
+ done();
648
+ } catch (error) {
649
+ this.notifications.serverError(error);
650
+ modalsManager.stopLoading();
238
651
  }
239
652
  },
240
653
  });
241
654
  }
242
655
 
243
- @action async scheduleEventFromDrop(dropInfo) {
244
- const { draggedEl, date } = dropInfo;
245
- const { dataset } = draggedEl;
246
- const { event } = dataset;
247
- const data = JSON.parse(event);
248
- const order = this.store.peekRecord('order', data.id);
656
+ // -------------------------------------------------------------------------
657
+ // Bulk Operations
658
+ // -------------------------------------------------------------------------
249
659
 
250
- try {
251
- order.set('scheduled_at', date);
252
- await order.save();
253
- return this.hostRouter.refresh();
254
- } catch (error) {
255
- this.notifications.serverError(error);
256
- this.removeEvent(event);
257
- }
660
+ @action openBulkAssignModal() {
661
+ if (!this.hasSelection) return;
662
+ const orders = this.selectedOrders;
663
+ this.modalsManager.show('modals/bulk-assign-orders', {
664
+ title: this.intl.t('scheduler.bulk-assign-title', { count: orders.length }),
665
+ orders,
666
+ drivers: this.drivers,
667
+ confirm: async (modalsManager, done) => {
668
+ modalsManager.startLoading();
669
+ const { driver, date } = modalsManager.getOptions();
670
+ try {
671
+ await this.scheduling.bulkAssign(orders, driver.id, date);
672
+ this.clearSelection();
673
+ this.notifications.success(this.intl.t('scheduler.bulk-assign-success', { count: orders.length }));
674
+ done();
675
+ } catch (error) {
676
+ this.notifications.serverError(error);
677
+ modalsManager.stopLoading();
678
+ }
679
+ },
680
+ });
258
681
  }
259
682
 
260
- @action receivedEvent(eventReceivedInfo) {
261
- const { event } = eventReceivedInfo;
262
- const order = this.store.peekRecord('order', event.id);
683
+ // -------------------------------------------------------------------------
684
+ // Conflict Resolution
685
+ // -------------------------------------------------------------------------
263
686
 
264
- this.setEventProperty(event, 'title', createOrderEventTitle(order));
265
- this.setEventProperty(event, 'description', createOrderEventDescription(order));
687
+ _showConflictModal(order, driverId, scheduledAt, conflicts) {
688
+ const driver = this.store.peekRecord('driver', driverId);
689
+ this.modalsManager.show('modals/scheduling-conflict', {
690
+ title: this.intl.t('scheduler.conflict-title'),
691
+ order,
692
+ driver,
693
+ conflicts,
694
+ scheduledAt,
695
+ assignAnyway: async (modalsManager, done) => {
696
+ modalsManager.startLoading();
697
+ await this.scheduling.assignOrder(order, driverId, scheduledAt, { skipConflictCheck: true });
698
+ done();
699
+ },
700
+ autoAdjust: async (modalsManager, done) => {
701
+ modalsManager.startLoading();
702
+ const bestFit = await this.scheduling.findBestFit(driverId, order);
703
+ await this.scheduling.assignOrder(order, driverId, bestFit, { skipConflictCheck: true });
704
+ done();
705
+ },
706
+ });
266
707
  }
267
708
 
268
- @action async rescheduleEventFromDrag(eventDropInfo) {
269
- const { event } = eventDropInfo;
270
- const { start, end } = event;
709
+ // -------------------------------------------------------------------------
710
+ // Undo / Redo
711
+ // -------------------------------------------------------------------------
271
712
 
272
- if (event.extendedProps && event.extendedProps.scheduleItem) {
273
- const scheduleItem = event.extendedProps.scheduleItem;
274
- const newResourceId = event.getResources()[0]?.id;
275
- try {
276
- scheduleItem.set('start_at', start);
277
- scheduleItem.set('end_at', end || start);
278
- if (newResourceId && newResourceId !== scheduleItem.assignee_uuid) {
279
- scheduleItem.set('assignee_uuid', newResourceId);
280
- }
281
- await scheduleItem.save();
282
- this.notifications.success('Shift rescheduled successfully');
283
- await this.loadScheduleItems.perform();
284
- } catch (error) {
285
- this.notifications.serverError(error);
286
- eventDropInfo.revert();
287
- }
288
- return;
713
+ @action undo() {
714
+ return this.scheduling.undo();
715
+ }
716
+
717
+ @action redo() {
718
+ return this.scheduling.redo();
719
+ }
720
+
721
+ // -------------------------------------------------------------------------
722
+ // Real-Time Socket Subscriptions
723
+ // -------------------------------------------------------------------------
724
+
725
+ @action async subscribeToRealTimeUpdates() {
726
+ const orgId = this.currentUser?.companyId ?? this.currentUser?.company?.id;
727
+ if (!orgId) return;
728
+ await this.socket.listen(`company.${orgId}.orders`, (payload) => this._handleOrderSocketEvent(payload));
729
+ this.drivers.forEach(async (driver) => {
730
+ await this.socket.listen(`driver.${driver.id}`, (payload) => this._handleDriverSocketEvent(payload));
731
+ });
732
+ }
733
+
734
+ @action unsubscribeFromRealTimeUpdates() {
735
+ if (this.socket && typeof this.socket.closeChannels === 'function') {
736
+ this.socket.closeChannels();
289
737
  }
738
+ }
290
739
 
291
- const order = this.store.peekRecord('order', event.id);
292
- const scheduledTime = order.scheduledAtTime;
293
- const newDate = new Date(`${format(start, 'PP')} ${scheduledTime}`);
740
+ _handleOrderSocketEvent({ data } = {}) {
741
+ if (!data?.id) return;
742
+ try {
743
+ this.store.pushPayload('order', { order: data });
744
+ } catch {
745
+ /* ignore */
746
+ }
747
+ }
294
748
 
749
+ _handleDriverSocketEvent({ event, data } = {}) {
750
+ if (!data?.id) return;
751
+ if (event === 'driver.location_changed') return;
295
752
  try {
296
- order.set('scheduled_at', isValidDate(newDate) ? newDate : start);
297
- await order.save();
298
- this.setEventProperty(event, 'title', createOrderEventTitle(order));
299
- this.setEventProperty(event, 'description', createOrderEventDescription(order));
300
- return this.hostRouter.refresh();
301
- } catch (error) {
302
- this.notifications.serverError(error);
303
- this.removeEvent(event);
753
+ this.store.pushPayload('driver', { driver: data });
754
+ } catch {
755
+ /* ignore */
304
756
  }
305
757
  }
306
758
 
307
- @action async addDriverShift() {
308
- this.modalsManager.show('modals/add-driver-shift', {
309
- title: 'Add Driver Shift',
310
- acceptButtonText: 'Create Shift',
311
- acceptButtonIcon: 'plus',
312
- drivers: this.drivers,
313
- confirm: async (modal) => {
314
- modal.startLoading();
315
- const { driver, startAt, endAt, duration } = modal.getOptions();
316
- try {
317
- const scheduleItem = this.store.createRecord('schedule-item', {
318
- assignee_type: 'driver',
319
- assignee_uuid: driver.id,
320
- start_at: startAt,
321
- end_at: endAt,
322
- duration: duration,
323
- status: 'pending',
324
- });
325
- await scheduleItem.save();
326
- this.notifications.success('Shift created successfully');
327
- await this.loadScheduleItems.perform();
328
- modal.done();
329
- } catch (error) {
330
- this.notifications.serverError(error);
331
- modal.stopLoading();
332
- }
333
- },
334
- });
759
+ // -------------------------------------------------------------------------
760
+ // View Navigation
761
+ // -------------------------------------------------------------------------
762
+
763
+ /**
764
+ * Navigation uses EventCalendar's setOption/getOption API:
765
+ * calendar.setOption('date', newDate) replaces calendar.today() / gotoDate()
766
+ * calendar.getOption('date') replaces calendar.getDate()
767
+ * calendar.setOption('view', viewName) replaces calendar.changeView()
768
+ * calendar.prev() / calendar.next() are identical in both libraries
769
+ */
770
+ @action goToToday() {
771
+ // Use the company-local "today" so the calendar highlights the correct day.
772
+ this.viewDate = toCalendarDate(new Date(), this.companyTimezone);
773
+ this.calendar?.setOption('date', this.viewDate);
335
774
  }
336
775
 
337
- removeEvent(event) {
338
- if (isObject(event) && typeof event.remove === 'function') {
339
- event.remove();
340
- return true;
341
- }
776
+ @action goToPrev() {
777
+ this.calendar?.prev();
778
+ const d = this.calendar?.getOption('date');
779
+ if (d) this.viewDate = d;
780
+ }
781
+
782
+ @action goToNext() {
783
+ this.calendar?.next();
784
+ const d = this.calendar?.getOption('date');
785
+ if (d) this.viewDate = d;
786
+ }
787
+
788
+ @action setViewRange(range) {
789
+ this.viewRange = range;
790
+ // currentCalendarView getter returns the correct view name string.
791
+ // EventCalendar re-renders reactively when @view arg changes, but we
792
+ // also call setOption for immediate imperative update if needed.
793
+ this.calendar?.setOption('view', this.currentCalendarView);
794
+ }
342
795
 
796
+ // -------------------------------------------------------------------------
797
+ // Legacy helpers (adapted for @event-calendar/core API)
798
+ // -------------------------------------------------------------------------
799
+
800
+ /**
801
+ * Removes an event from the calendar by ID.
802
+ * @event-calendar/core uses removeEventById(id) instead of event.remove().
803
+ */
804
+ removeEvent(event) {
343
805
  if (isObject(event) && typeof event.id === 'string') {
344
- return this.removeEvent(event.id);
806
+ this.calendar?.removeEventById(event.id);
807
+ return true;
345
808
  }
346
-
347
809
  if (isJson(event)) {
348
810
  event = JSON.parse(event);
349
- return this.removeEvent(event.id);
811
+ this.calendar?.removeEventById(event.id);
812
+ return true;
350
813
  }
351
-
352
814
  if (typeof event === 'string') {
353
- event = this.calendar.getEventById(event);
354
- if (typeof event.remove === 'function') {
355
- event.remove();
356
- return true;
357
- }
815
+ this.calendar?.removeEventById(event);
816
+ return true;
358
817
  }
359
-
360
818
  return false;
361
819
  }
362
820
 
821
+ /**
822
+ * Retrieves an event object from the calendar by ID.
823
+ * @event-calendar/core uses getEventById(id) — same method name as FullCalendar.
824
+ */
363
825
  getEvent(event) {
364
826
  if (isJson(event)) {
365
827
  event = JSON.parse(event);
366
- return this.calendar.getEventById(event.id);
367
- }
368
-
369
- if (typeof event === 'string') {
370
- return this.calendar.getEventById(event);
828
+ return this.calendar?.getEventById(event.id);
371
829
  }
372
-
830
+ if (typeof event === 'string') return this.calendar?.getEventById(event);
373
831
  return event;
374
832
  }
375
833
 
834
+ /**
835
+ * Updates a single property on a calendar event.
836
+ * @event-calendar/core uses updateEvent({...event, [prop]: value})
837
+ * instead of FullCalendar's event.setProp(prop, value).
838
+ */
376
839
  setEventProperty(event, prop, value) {
377
840
  const eventInstance = this.getEvent(event);
378
- if (typeof eventInstance.setProp === 'function') {
379
- eventInstance.setProp(prop, value);
841
+ if (eventInstance) {
842
+ this.calendar?.updateEvent({ ...eventInstance, [prop]: value });
380
843
  return true;
381
844
  }
382
-
383
845
  return false;
384
846
  }
847
+
848
+ // -------------------------------------------------------------------------
849
+ // Timezone Utilities
850
+ // -------------------------------------------------------------------------
851
+
852
+ /**
853
+ * Converts a Date whose **local** fields represent a wall-clock time back
854
+ * into the correct UTC instant for that moment in the given timezone.
855
+ *
856
+ * This is the inverse of toCalendarDate().
857
+ *
858
+ * When the calendar returns a Date from dateFromPoint() or an eventDrop
859
+ * callback, its local fields equal the wall-clock time the user sees on
860
+ * screen (because we passed "fake local" Dates in and the library echoes
861
+ * them back the same way). We must convert those local fields to a true
862
+ * UTC instant before sending to the API.
863
+ *
864
+ * Example: user drops at 22:30 on Apr 6 (visible on screen, SGT)
865
+ * date.getHours() === 22, date.getDate() === 6
866
+ * → returns a Date whose getUTCHours() === 14 (22:30 SGT = 14:30 UTC)
867
+ *
868
+ * @param {Date} date The Date returned by dateFromPoint() or eventDrop.
869
+ * @param {string} timezone IANA timezone string, e.g. 'Asia/Singapore'.
870
+ * @returns {Date}
871
+ */
872
+ _reinterpretDateInTimezone(date, timezone) {
873
+ try {
874
+ // Read the local fields — these hold the wall-clock time the user
875
+ // sees on screen.
876
+ const y = date.getFullYear();
877
+ const mo = date.getMonth() + 1;
878
+ const d = date.getDate();
879
+ const h = date.getHours();
880
+ const mi = date.getMinutes();
881
+ const s = date.getSeconds();
882
+
883
+ // Build a UTC probe at the same wall-clock instant and ask Intl
884
+ // what offset the target timezone applies at that moment.
885
+ const wallClock = `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}T${String(h).padStart(2, '0')}:${String(mi).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
886
+ const probe = new Date(`${wallClock}Z`);
887
+ const parts = new Intl.DateTimeFormat('en-US', {
888
+ timeZone: timezone,
889
+ year: 'numeric',
890
+ month: '2-digit',
891
+ day: '2-digit',
892
+ hour: '2-digit',
893
+ minute: '2-digit',
894
+ second: '2-digit',
895
+ hour12: false,
896
+ }).formatToParts(probe);
897
+
898
+ const get = (type) => parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10);
899
+ const tzHour = get('hour') % 24;
900
+ const probeLocal = Date.UTC(get('year'), get('month') - 1, get('day'), tzHour, get('minute'), get('second'));
901
+ const offsetMs = probe.getTime() - probeLocal;
902
+
903
+ const wallMs = Date.UTC(y, mo - 1, d, h, mi, s);
904
+ return new Date(wallMs + offsetMs);
905
+ } catch {
906
+ return date;
907
+ }
908
+ }
385
909
  }