-
-
- {{#if avail.is_available}}
-
Available
- {{else}}
-
Unavailable
+
+
+
+
+
+ {{#if avail.is_available}}
+ {{t "scheduler.available"}}
+ {{else}}
+ {{t "scheduler.unavailable"}}
+ {{/if}}
+
+
+ {{format-date-fns avail.start_at "MMM d"}}
+ –
+ {{format-date-fns avail.end_at "MMM d, yyyy"}}
+
+ {{#if avail.reason}}
+
{{avail.reason}}
{{/if}}
-
- {{format-date-fns avail.start_at "MMM DD, YYYY"}}
- -
- {{format-date-fns avail.end_at "MMM DD, YYYY"}}
-
- {{#if avail.reason}}
-
{{avail.reason}}
- {{/if}}
+
{{/each}}
{{else}}
-
- No availability restrictions set
+
+ {{t "scheduler.no-availability-restrictions"}}
{{/if}}
-
\ No newline at end of file
+
diff --git a/addon/components/driver/schedule.js b/addon/components/driver/schedule.js
index a26a1eca1..a2fad7743 100644
--- a/addon/components/driver/schedule.js
+++ b/addon/components/driver/schedule.js
@@ -3,46 +3,83 @@ import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { task } from 'ember-concurrency';
+import { startOfWeek, endOfWeek, addWeeks, formatISO } from 'date-fns';
/**
* Driver::Schedule Component
*
- * Displays and manages a driver's schedule from their detail page.
- * Includes HOS compliance tracking, upcoming shifts, and availability management.
+ * Displays and manages a driver's schedule from their detail panel.
+ * Renders upcoming shifts in a list view and provides actions to add shifts,
+ * request time off, and manage availability windows.
+ *
+ * Wires directly to the core-api scheduling system via the driverScheduling
+ * service. The schedule_items relationship on the Driver model uses
+ * assignee_type='driver' and assignee_uuid=driver.id as the polymorphic key.
*
* @example
- *
+ *
*/
export default class DriverScheduleComponent extends Component {
@service driverScheduling;
@service notifications;
@service modalsManager;
@service store;
+ @service fetch;
+ @service intl;
+
@tracked scheduleItems = [];
@tracked upcomingShifts = [];
- @tracked hosStatus = null;
@tracked availability = [];
- @tracked timeOffRequests = [];
- @tracked selectedItem = null;
+ @tracked hosStatus = null;
+ @tracked isLoadingSchedule = false;
+ @tracked isLoadingAvailability = false;
- get scheduleActionButtons() {
- return [
- {
- type: 'default',
- text: 'Request Time Off',
- icon: 'calendar-times',
- iconPrefix: 'fas',
- permission: 'fleet-ops update driver',
- onClick: this.requestTimeOff,
- },
- ];
+ constructor() {
+ super(...arguments);
+ this.loadDriverSchedule.perform();
+ this.loadAvailability.perform();
+ this.loadHOSStatus.perform();
+ }
+
+ /**
+ * Start of the current week — used as the lower bound for schedule queries.
+ */
+ get startDate() {
+ return formatISO(startOfWeek(new Date(), { weekStartsOn: 1 }));
+ }
+
+ /**
+ * Four weeks from now — used as the upper bound for schedule queries.
+ */
+ get endDate() {
+ return formatISO(addWeeks(endOfWeek(new Date(), { weekStartsOn: 1 }), 4));
+ }
+
+ /**
+ * Derive a color-coded HOS compliance badge from the loaded hosStatus.
+ */
+ get hosComplianceBadge() {
+ if (!this.hosStatus) {
+ return { color: 'gray', label: 'Unknown' };
+ }
+ const { daily_hours, weekly_hours } = this.hosStatus;
+ if (daily_hours >= 11 || weekly_hours >= 70) {
+ return { color: 'red', label: 'At Limit' };
+ }
+ if (daily_hours >= 9 || weekly_hours >= 60) {
+ return { color: 'yellow', label: 'Approaching Limit' };
+ }
+ return { color: 'green', label: 'Compliant' };
}
+ /**
+ * Action buttons rendered in the Upcoming Shifts ContentPanel header.
+ */
get shiftActionButtons() {
return [
{
type: 'default',
- text: 'Add Shift',
+ text: this.intl.t('scheduler.add-shift'),
icon: 'plus',
iconPrefix: 'fas',
permission: 'fleet-ops update driver',
@@ -51,217 +88,271 @@ export default class DriverScheduleComponent extends Component {
];
}
+ /**
+ * Action buttons rendered in the Availability ContentPanel header.
+ */
get availabilityActionButtons() {
return [
{
type: 'default',
- text: 'Set Availability',
+ text: this.intl.t('scheduler.set-availability'),
icon: 'clock',
iconPrefix: 'fas',
permission: 'fleet-ops update driver',
onClick: this.setAvailability,
},
+ {
+ type: 'default',
+ text: this.intl.t('scheduler.request-time-off'),
+ icon: 'calendar-times',
+ iconPrefix: 'fas',
+ permission: 'fleet-ops update driver',
+ onClick: this.requestTimeOff,
+ },
];
}
- constructor() {
- super(...arguments);
- this.loadDriverSchedule.perform();
- this.loadHOSStatus.perform();
- this.loadAvailability.perform();
- }
-
/**
- * Load driver schedule items
+ * Load all schedule items (shifts) for this driver within the 4-week window.
+ * Uses the driverScheduling service which calls the core-api schedule-items endpoint
+ * with assignee_type=driver and assignee_uuid=driver.id.
*/
@task *loadDriverSchedule() {
+ this.isLoadingSchedule = true;
try {
const items = yield this.driverScheduling.getScheduleItemsForAssignee.perform('driver', this.args.resource.id, {
start_at: this.startDate,
end_at: this.endDate,
});
-
this.scheduleItems = items.toArray();
- this.upcomingShifts = this.scheduleItems.filter((item) => new Date(item.start_at) > new Date()).slice(0, 5);
- } catch (error) {
- console.error('Failed to load driver schedule:', error);
- }
- }
-
- /**
- * Load HOS status for the driver
- */
- @task *loadHOSStatus() {
- try {
- const response = yield this.fetch.get(`drivers/${this.args.resource.id}/hos-status`);
- this.hosStatus = response;
+ this.upcomingShifts = this.scheduleItems
+ .filter((item) => new Date(item.start_at) >= new Date())
+ .sort((a, b) => new Date(a.start_at) - new Date(b.start_at));
} catch (error) {
- console.error('Failed to load HOS status:', error);
+ this.notifications.serverError(error);
+ } finally {
+ this.isLoadingSchedule = false;
}
}
/**
- * Load driver availability
+ * Load availability records (time-off, preferred hours) for this driver.
+ * Uses the core-api schedule-availability endpoint with subject_type=driver.
*/
@task *loadAvailability() {
+ this.isLoadingAvailability = true;
try {
const availability = yield this.store.query('schedule-availability', {
subject_type: 'driver',
subject_uuid: this.args.resource.id,
- start_at: this.startDate,
- end_at: this.endDate,
});
-
this.availability = availability.toArray();
} catch (error) {
- console.error('Failed to load availability:', error);
+ this.notifications.serverError(error);
+ } finally {
+ this.isLoadingAvailability = false;
}
}
/**
- * Get start date for schedule query (current week)
+ * Load HOS (Hours of Service) status from the FleetOps driver endpoint.
+ * This is a best-effort load — if the endpoint is not yet implemented,
+ * the hosStatus will remain null and the HOS panel will be hidden.
*/
- get startDate() {
- const now = new Date();
- const dayOfWeek = now.getDay();
- const diff = now.getDate() - dayOfWeek;
- return new Date(now.setDate(diff)).toISOString();
- }
-
- /**
- * Get end date for schedule query (4 weeks out)
- */
- get endDate() {
- const now = new Date();
- return new Date(now.setDate(now.getDate() + 28)).toISOString();
- }
-
- /**
- * Get HOS compliance badge color
- */
- get hosComplianceBadge() {
- if (!this.hosStatus) {
- return { color: 'gray', text: 'Unknown' };
- }
-
- const { daily_hours, weekly_hours } = this.hosStatus;
-
- if (daily_hours >= 11 || weekly_hours >= 70) {
- return { color: 'red', text: 'At Limit' };
- }
-
- if (daily_hours >= 9 || weekly_hours >= 60) {
- return { color: 'yellow', text: 'Approaching Limit' };
+ @task *loadHOSStatus() {
+ try {
+ const response = yield this.fetch.get(`fleet-ops/drivers/${this.args.resource.id}/hos-status`);
+ this.hosStatus = response;
+ } catch {
+ // HOS endpoint not yet implemented — suppress error silently
+ this.hosStatus = null;
}
-
- return { color: 'green', text: 'Compliant' };
- }
-
- /**
- * Handle item click
- */
- @action
- handleItemClick(item) {
- this.selectedItem = item;
- this.modalsManager.show('modals/schedule-item-details', {
- item,
- onEdit: this.editScheduleItem,
- onDelete: this.deleteScheduleItem,
- });
}
/**
- * Add new shift
+ * Open the Add Shift modal. Uses the same modalsManager pattern as the
+ * global scheduler's addDriverShift() action, reusing modals/add-driver-shift.
*/
@action
addShift() {
- this.modalsManager.show('modals/add-shift', {
- driver: this.args.resource,
- onSave: this.handleShiftAdded,
+ this.modalsManager.show('modals/add-driver-shift', {
+ title: this.intl.t('scheduler.add-shift'),
+ acceptButtonText: this.intl.t('scheduler.create-shift'),
+ acceptButtonIcon: 'plus',
+ drivers: [this.args.resource],
+ selectedDriver: this.args.resource,
+ confirm: async (modal) => {
+ modal.startLoading();
+ const { startAt, endAt, duration } = modal.getOptions();
+ try {
+ const scheduleItem = this.store.createRecord('schedule-item', {
+ assignee_type: 'driver',
+ assignee_uuid: this.args.resource.id,
+ start_at: startAt,
+ end_at: endAt,
+ duration: duration,
+ status: 'pending',
+ });
+ yield scheduleItem.save();
+ this.notifications.success(this.intl.t('scheduler.shift-created'));
+ yield this.loadDriverSchedule.perform();
+ yield this.loadHOSStatus.perform();
+ modal.done();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
});
}
/**
- * Edit schedule item
+ * Open the Edit Shift modal for an existing schedule item.
*/
@action
- async editScheduleItem(item) {
- this.modalsManager.show('modals/edit-shift', {
+ editShift(item) {
+ this.modalsManager.show('modals/driver-shift', {
+ title: this.intl.t('scheduler.edit-shift'),
+ acceptButtonText: this.intl.t('common.save-changes'),
+ acceptButtonIcon: 'save',
item,
- driver: this.args.resource,
- onSave: this.handleShiftUpdated,
+ confirm: async (modal) => {
+ modal.startLoading();
+ const { startAt, endAt } = modal.getOptions();
+ try {
+ item.setProperties({ start_at: startAt, end_at: endAt });
+ yield item.save();
+ this.notifications.success(this.intl.t('scheduler.shift-updated'));
+ yield this.loadDriverSchedule.perform();
+ modal.done();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
});
}
/**
- * Delete schedule item
+ * Delete a shift after inline confirmation via modalsManager.
*/
@action
- async deleteScheduleItem(item) {
- if (confirm('Are you sure you want to delete this shift?')) {
- try {
- await this.driverScheduling.deleteScheduleItem.perform(item);
- await this.loadDriverSchedule.perform();
- } catch (error) {
- console.error('Failed to delete shift:', error);
- }
- }
- }
-
- /**
- * Handle shift added
- */
- @action
- async handleShiftAdded() {
- await this.loadDriverSchedule.perform();
- await this.loadHOSStatus.perform();
- }
-
- /**
- * Handle shift updated
- */
- @action
- async handleShiftUpdated() {
- await this.loadDriverSchedule.perform();
- await this.loadHOSStatus.perform();
+ deleteShift(item) {
+ this.modalsManager.confirm({
+ title: this.intl.t('scheduler.delete-shift'),
+ body: this.intl.t('scheduler.delete-shift-confirm'),
+ acceptButtonText: this.intl.t('common.delete'),
+ acceptButtonScheme: 'danger',
+ confirm: async (modal) => {
+ modal.startLoading();
+ try {
+ yield this.driverScheduling.deleteScheduleItem.perform(item);
+ yield this.loadDriverSchedule.perform();
+ yield this.loadHOSStatus.perform();
+ modal.done();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
}
/**
- * Request time off
+ * Open the Set Availability modal to record preferred working hours.
*/
@action
- requestTimeOff() {
- this.modalsManager.show('modals/request-time-off', {
+ setAvailability() {
+ this.modalsManager.show('modals/set-driver-availability', {
+ title: this.intl.t('scheduler.set-availability'),
+ acceptButtonText: this.intl.t('common.save'),
+ acceptButtonIcon: 'check',
driver: this.args.resource,
- onSave: this.handleTimeOffRequested,
+ isAvailable: true,
+ confirm: async (modal) => {
+ modal.startLoading();
+ const { startAt, endAt, isAvailable, reason, notes } = modal.getOptions();
+ try {
+ const availability = this.store.createRecord('schedule-availability', {
+ subject_type: 'driver',
+ subject_uuid: this.args.resource.id,
+ start_at: startAt,
+ end_at: endAt,
+ is_available: isAvailable,
+ reason,
+ notes,
+ });
+ yield availability.save();
+ this.notifications.success(this.intl.t('scheduler.availability-set'));
+ yield this.loadAvailability.perform();
+ modal.done();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
});
}
/**
- * Handle time off requested
- */
- @action
- async handleTimeOffRequested() {
- await this.loadAvailability.perform();
- await this.loadDriverSchedule.perform();
- }
-
- /**
- * Set availability
+ * Open the Request Time Off modal. Creates a schedule-availability record
+ * with is_available=false to mark the driver as unavailable.
*/
@action
- setAvailability() {
- this.modalsManager.show('modals/set-availability', {
+ requestTimeOff() {
+ this.modalsManager.show('modals/set-driver-availability', {
+ title: this.intl.t('scheduler.request-time-off'),
+ acceptButtonText: this.intl.t('scheduler.submit-request'),
+ acceptButtonIcon: 'calendar-times',
driver: this.args.resource,
- onSave: this.handleAvailabilitySet,
+ isAvailable: false,
+ confirm: async (modal) => {
+ modal.startLoading();
+ const { startAt, endAt, reason, notes } = modal.getOptions();
+ try {
+ const availability = this.store.createRecord('schedule-availability', {
+ subject_type: 'driver',
+ subject_uuid: this.args.resource.id,
+ start_at: startAt,
+ end_at: endAt,
+ is_available: false,
+ reason,
+ notes,
+ });
+ yield availability.save();
+ this.notifications.success(this.intl.t('scheduler.time-off-requested'));
+ yield this.loadAvailability.perform();
+ modal.done();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
});
}
/**
- * Handle availability set
+ * Delete an availability record.
*/
@action
- async handleAvailabilitySet() {
- await this.loadAvailability.perform();
+ deleteAvailability(avail) {
+ this.modalsManager.confirm({
+ title: this.intl.t('scheduler.delete-availability'),
+ body: this.intl.t('scheduler.delete-availability-confirm'),
+ acceptButtonText: this.intl.t('common.delete'),
+ acceptButtonScheme: 'danger',
+ confirm: async (modal) => {
+ modal.startLoading();
+ try {
+ yield avail.destroyRecord();
+ this.notifications.success(this.intl.t('scheduler.availability-deleted'));
+ yield this.loadAvailability.perform();
+ modal.done();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
}
}
diff --git a/addon/components/modals/add-driver-shift.hbs b/addon/components/modals/add-driver-shift.hbs
new file mode 100644
index 000000000..01aaee3bf
--- /dev/null
+++ b/addon/components/modals/add-driver-shift.hbs
@@ -0,0 +1,67 @@
+
+
+
+
+ {{! Driver selector — only shown when multiple drivers are available }}
+ {{#if this.hasManyDrivers}}
+
+
+ {{driver.name}}
+
+
+ {{/if}}
+
+ {{! Shift Title }}
+
+
+
+
+ {{! Start Date/Time }}
+
+
+
+
+ {{! End Date/Time }}
+
+
+
+
+ {{! Notes }}
+
+
+
+
+
+
+
diff --git a/addon/components/modals/add-driver-shift.js b/addon/components/modals/add-driver-shift.js
new file mode 100644
index 000000000..f53420bab
--- /dev/null
+++ b/addon/components/modals/add-driver-shift.js
@@ -0,0 +1,71 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+
+/**
+ * Modals::AddDriverShift
+ *
+ * Modal for creating a new shift (ScheduleItem) for a driver.
+ * Used from both the global scheduler (operations/scheduler) and the
+ * per-driver schedule panel (driver/schedule).
+ *
+ * Options accepted:
+ * - drivers: array of Driver records (for the dropdown in the global scheduler)
+ * - selectedDriver: pre-selected Driver record (when opened from driver panel)
+ *
+ * @example
+ * this.modalsManager.show('modals/add-driver-shift', {
+ * selectedDriver: this.args.resource,
+ * confirm: async (modal) => { ... }
+ * });
+ */
+export default class ModalsAddDriverShiftComponent extends Component {
+ @tracked startAt = null;
+ @tracked endAt = null;
+ @tracked title = '';
+ @tracked notes = '';
+ @tracked selectedDriver = null;
+
+ constructor() {
+ super(...arguments);
+ this.selectedDriver = this.args.options?.selectedDriver ?? null;
+ }
+
+ get drivers() {
+ return this.args.options?.drivers ?? [];
+ }
+
+ get hasManyDrivers() {
+ return this.drivers.length > 1;
+ }
+
+ @action
+ updateStartAt(value) {
+ this.startAt = value;
+ this.args.options.startAt = value;
+ }
+
+ @action
+ updateEndAt(value) {
+ this.endAt = value;
+ this.args.options.endAt = value;
+ }
+
+ @action
+ updateTitle(value) {
+ this.title = value;
+ this.args.options.title = value;
+ }
+
+ @action
+ updateNotes(value) {
+ this.notes = value;
+ this.args.options.notes = value;
+ }
+
+ @action
+ selectDriver(driver) {
+ this.selectedDriver = driver;
+ this.args.options.selectedDriver = driver;
+ }
+}
diff --git a/addon/components/modals/driver-shift.hbs b/addon/components/modals/driver-shift.hbs
new file mode 100644
index 000000000..f00d43f15
--- /dev/null
+++ b/addon/components/modals/driver-shift.hbs
@@ -0,0 +1,49 @@
+
+
+
+
+ {{! Shift Title }}
+
+
+
+
+ {{! Start Date/Time }}
+
+
+
+
+ {{! End Date/Time }}
+
+
+
+
+ {{! Notes }}
+
+
+
+
+
+
+
diff --git a/addon/components/modals/driver-shift.js b/addon/components/modals/driver-shift.js
new file mode 100644
index 000000000..5e673fd60
--- /dev/null
+++ b/addon/components/modals/driver-shift.js
@@ -0,0 +1,60 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+
+/**
+ * Modals::DriverShift
+ *
+ * Modal for editing an existing ScheduleItem (shift).
+ * Pre-populates fields from the passed `item` option.
+ *
+ * @example
+ * this.modalsManager.show('modals/driver-shift', {
+ * item: scheduleItem,
+ * confirm: async (modal) => { ... }
+ * });
+ */
+export default class ModalsDriverShiftComponent extends Component {
+ @tracked startAt = null;
+ @tracked endAt = null;
+ @tracked title = '';
+ @tracked notes = '';
+
+ constructor() {
+ super(...arguments);
+ const item = this.args.options?.item;
+ if (item) {
+ this.startAt = item.start_at;
+ this.endAt = item.end_at;
+ this.title = item.title ?? '';
+ this.notes = item.notes ?? '';
+ // Seed modal options so the confirm callback can read them
+ this.args.options.startAt = item.start_at;
+ this.args.options.endAt = item.end_at;
+ }
+ }
+
+ @action
+ updateStartAt(value) {
+ this.startAt = value;
+ this.args.options.startAt = value;
+ }
+
+ @action
+ updateEndAt(value) {
+ this.endAt = value;
+ this.args.options.endAt = value;
+ }
+
+ @action
+ updateTitle(value) {
+ this.title = value;
+ this.args.options.title = value;
+ }
+
+ @action
+ updateNotes(value) {
+ this.notes = value;
+ this.args.options.notes = value;
+ }
+}
diff --git a/addon/components/modals/set-driver-availability.hbs b/addon/components/modals/set-driver-availability.hbs
new file mode 100644
index 000000000..fd077be6f
--- /dev/null
+++ b/addon/components/modals/set-driver-availability.hbs
@@ -0,0 +1,72 @@
+
+
+
diff --git a/addon/components/modals/set-driver-availability.js b/addon/components/modals/set-driver-availability.js
new file mode 100644
index 000000000..dc61dcd8e
--- /dev/null
+++ b/addon/components/modals/set-driver-availability.js
@@ -0,0 +1,60 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+
+/**
+ * Modals::SetDriverAvailability
+ *
+ * Shared modal for both "Set Availability" (is_available=true) and
+ * "Request Time Off" (is_available=false) flows. The caller controls
+ * the initial value of `isAvailable` via modal options.
+ *
+ * @example (from driver/schedule.js)
+ * this.modalsManager.show('modals/set-driver-availability', {
+ * isAvailable: false,
+ * confirm: async (modal) => { ... }
+ * });
+ */
+export default class ModalsSetDriverAvailabilityComponent extends Component {
+ @tracked startAt = null;
+ @tracked endAt = null;
+ @tracked reason = '';
+ @tracked notes = '';
+ @tracked isAvailable = true;
+
+ constructor() {
+ super(...arguments);
+ // Initialise from modal options so the caller can pre-set isAvailable
+ this.isAvailable = this.args.options?.isAvailable ?? true;
+ }
+
+ @action
+ updateStartAt(value) {
+ this.startAt = value;
+ this.args.options.startAt = value;
+ }
+
+ @action
+ updateEndAt(value) {
+ this.endAt = value;
+ this.args.options.endAt = value;
+ }
+
+ @action
+ updateReason(value) {
+ this.reason = value;
+ this.args.options.reason = value;
+ }
+
+ @action
+ updateNotes(value) {
+ this.notes = value;
+ this.args.options.notes = value;
+ }
+
+ @action
+ toggleAvailability(value) {
+ this.isAvailable = value;
+ this.args.options.isAvailable = value;
+ }
+}
diff --git a/addon/controllers/operations/scheduler/index.js b/addon/controllers/operations/scheduler/index.js
index 4a472584a..6be6512dd 100644
--- a/addon/controllers/operations/scheduler/index.js
+++ b/addon/controllers/operations/scheduler/index.js
@@ -48,14 +48,29 @@ export default class OperationsSchedulerIndexController extends Controller {
@tracked unscheduledOrders = [];
@tracked drivers = [];
@tracked scheduleItems = [];
+ @tracked driverAvailabilities = [];
@tracked viewMode = 'orders'; // 'orders' or 'drivers'
- @computed('drivers', 'scheduleItems.[]', 'scheduledOrders.[]', 'viewMode') get events() {
+ @computed('drivers', 'scheduleItems.[]', 'driverAvailabilities.[]', 'scheduledOrders.[]', 'viewMode') get events() {
if (this.viewMode === 'drivers') {
- return this.scheduleItems.map((item) => {
+ const shiftEvents = this.scheduleItems.map((item) => {
const driver = this.drivers.find((d) => d.id === item.assignee_uuid);
return createFullCalendarEventFromScheduleItem(item, driver);
});
+ // Render availability restrictions as background events so dispatchers
+ // can see time-off blocks and unavailable windows at a glance.
+ const availabilityEvents = this.driverAvailabilities
+ .filter((avail) => !avail.is_available)
+ .map((avail) => ({
+ id: `avail-${avail.id}`,
+ resourceId: avail.subject_uuid,
+ start: avail.start_at,
+ end: avail.end_at,
+ display: 'background',
+ backgroundColor: '#FCA5A5', // red-300 — unavailable/time-off
+ extendedProps: { availability: avail },
+ }));
+ return [...shiftEvents, ...availabilityEvents];
}
return this.scheduledOrders.map(createFullCalendarEventFromOrder);
}
@@ -102,6 +117,25 @@ export default class OperationsSchedulerIndexController extends Controller {
}
}
+ /**
+ * Load all driver availability records within the calendar window.
+ * Unavailable records are rendered as red background events on the calendar
+ * to prevent dispatchers from scheduling shifts during time-off periods.
+ */
+ @task *loadDriverAvailabilities() {
+ try {
+ const availabilities = yield this.store.query('schedule-availability', {
+ subject_type: 'driver',
+ start_at_after: this.calendarStartDate,
+ end_at_before: this.calendarEndDate,
+ });
+ this.driverAvailabilities = availabilities.toArray();
+ } catch (error) {
+ // Non-critical — suppress and continue
+ this.driverAvailabilities = [];
+ }
+ }
+
@action setCalendarApi(calendar) {
this.calendar = calendar;
// setup some custom post initialization stuff here
@@ -181,6 +215,7 @@ export default class OperationsSchedulerIndexController extends Controller {
if (mode === 'drivers') {
await this.loadDrivers.perform();
await this.loadScheduleItems.perform();
+ await this.loadDriverAvailabilities.perform();
later(() => {
if (this.calendar) {
this.calendar.changeView('resourceTimelineWeek');
@@ -306,8 +341,8 @@ export default class OperationsSchedulerIndexController extends Controller {
@action async addDriverShift() {
this.modalsManager.show('modals/add-driver-shift', {
- title: 'Add Driver Shift',
- acceptButtonText: 'Create Shift',
+ title: this.intl.t('scheduler.add-shift'),
+ acceptButtonText: this.intl.t('scheduler.create-shift'),
acceptButtonIcon: 'plus',
drivers: this.drivers,
confirm: async (modal) => {
diff --git a/addon/services/driver-actions.js b/addon/services/driver-actions.js
index 692fe5e6c..64f05ab88 100644
--- a/addon/services/driver-actions.js
+++ b/addon/services/driver-actions.js
@@ -73,6 +73,10 @@ export default class DriverActionsService extends ResourceActionService {
label: this.intl.t('common.overview'),
component: 'driver/details',
},
+ {
+ label: this.intl.t('common.schedule'),
+ component: 'driver/schedule',
+ },
],
...options,
});
diff --git a/server/src/Http/Controllers/Internal/v1/DriverController.php b/server/src/Http/Controllers/Internal/v1/DriverController.php
index 93e6452bf..5d789dbd5 100644
--- a/server/src/Http/Controllers/Internal/v1/DriverController.php
+++ b/server/src/Http/Controllers/Internal/v1/DriverController.php
@@ -29,6 +29,7 @@
class DriverController extends FleetOpsController
{
+ use \Fleetbase\FleetOps\Http\Controllers\Internal\v1\Traits\DriverSchedulingTrait;
/**
* The resource to query.
*
diff --git a/server/src/Http/Controllers/Internal/v1/Traits/DriverSchedulingTrait.php b/server/src/Http/Controllers/Internal/v1/Traits/DriverSchedulingTrait.php
new file mode 100644
index 000000000..03ec97367
--- /dev/null
+++ b/server/src/Http/Controllers/Internal/v1/Traits/DriverSchedulingTrait.php
@@ -0,0 +1,133 @@
+firstOrFail();
+
+ $query = $driver->scheduleItems();
+
+ if ($request->filled('start_at')) {
+ $query->where('start_at', '>=', $request->input('start_at'));
+ }
+
+ if ($request->filled('end_at')) {
+ $query->where('end_at', '<=', $request->input('end_at'));
+ }
+
+ $items = $query->orderBy('start_at')->get();
+
+ return response()->json([
+ 'data' => $items,
+ ]);
+ }
+
+ /**
+ * Return all ScheduleAvailability records for this driver.
+ * Supports optional date range filtering via start_at and end_at query params.
+ *
+ * GET /int/v1/fleet-ops/drivers/{id}/availabilities
+ */
+ public function availabilities(string $id, Request $request): JsonResponse
+ {
+ $driver = Driver::where('public_id', $id)->firstOrFail();
+
+ $query = $driver->availabilities();
+
+ if ($request->filled('start_at')) {
+ $query->where('start_at', '>=', $request->input('start_at'));
+ }
+
+ if ($request->filled('end_at')) {
+ $query->where('end_at', '<=', $request->input('end_at'));
+ }
+
+ $availabilities = $query->orderBy('start_at')->get();
+
+ return response()->json([
+ 'data' => $availabilities,
+ ]);
+ }
+
+ /**
+ * Return the driver's Hours of Service (HOS) status.
+ *
+ * Calculates daily and weekly driving hours from completed ScheduleItem
+ * records. This is a computed endpoint — it does not persist HOS data.
+ * A future iteration may integrate with a dedicated HOS tracking system.
+ *
+ * GET /int/v1/fleet-ops/drivers/{id}/hos-status
+ */
+ public function hosStatus(string $id): JsonResponse
+ {
+ $driver = Driver::where('public_id', $id)->firstOrFail();
+
+ // Daily hours: sum of completed/in-progress shift durations today
+ $dailyMinutes = $driver->scheduleItems()
+ ->whereDate('start_at', today())
+ ->whereIn('status', ['completed', 'in_progress'])
+ ->sum(\Illuminate\Support\Facades\DB::raw('TIMESTAMPDIFF(MINUTE, start_at, COALESCE(end_at, NOW()))'));
+
+ // Weekly hours: sum of completed/in-progress shift durations this week
+ $weeklyMinutes = $driver->scheduleItems()
+ ->whereBetween('start_at', [now()->startOfWeek(), now()->endOfWeek()])
+ ->whereIn('status', ['completed', 'in_progress'])
+ ->sum(\Illuminate\Support\Facades\DB::raw('TIMESTAMPDIFF(MINUTE, start_at, COALESCE(end_at, NOW()))'));
+
+ $dailyHours = round($dailyMinutes / 60, 1);
+ $weeklyHours = round($weeklyMinutes / 60, 1);
+
+ return response()->json([
+ 'daily_hours' => $dailyHours,
+ 'weekly_hours' => $weeklyHours,
+ 'daily_limit' => 11,
+ 'weekly_limit' => 70,
+ 'is_compliant' => $dailyHours < 11 && $weeklyHours < 70,
+ ]);
+ }
+
+ /**
+ * Return the driver's currently active shift for today.
+ * Used by the AllocationPayloadBuilder to inject time_window constraints.
+ *
+ * GET /int/v1/fleet-ops/drivers/{id}/active-shift
+ */
+ public function activeShift(string $id): JsonResponse
+ {
+ $driver = Driver::where('public_id', $id)->firstOrFail();
+
+ $shift = $driver->activeShiftFor(now());
+
+ if (!$shift) {
+ return response()->json(['data' => null]);
+ }
+
+ return response()->json(['data' => $shift]);
+ }
+}
diff --git a/server/src/Models/Driver.php b/server/src/Models/Driver.php
index aa36424dd..1eda511c2 100644
--- a/server/src/Models/Driver.php
+++ b/server/src/Models/Driver.php
@@ -11,6 +11,9 @@
use Fleetbase\LaravelMysqlSpatial\Types\Point as SpatialPoint;
use Fleetbase\Models\File;
use Fleetbase\Models\Model;
+use Fleetbase\Models\Schedule;
+use Fleetbase\Models\ScheduleAvailability;
+use Fleetbase\Models\ScheduleItem;
use Fleetbase\Models\User;
use Fleetbase\Traits\HasApiModelBehavior;
use Fleetbase\Traits\HasApiModelCache;
@@ -25,6 +28,7 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
+use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
@@ -300,6 +304,48 @@ public function devices(): HasMany
return $this->hasMany(\Fleetbase\Models\UserDevice::class, 'user_uuid', 'user_uuid');
}
+ /**
+ * Get all schedules assigned to this driver.
+ * The driver acts as the polymorphic `subject` on the Schedule model.
+ */
+ public function schedules(): MorphMany
+ {
+ return $this->morphMany(Schedule::class, 'subject');
+ }
+
+ /**
+ * Get all individual schedule items (shifts) assigned to this driver.
+ * The driver acts as the polymorphic `assignee` on the ScheduleItem model.
+ */
+ public function scheduleItems(): MorphMany
+ {
+ return $this->morphMany(ScheduleItem::class, 'assignee');
+ }
+
+ /**
+ * Get the active schedule item (shift) for a given date.
+ * Used by AllocationPayloadBuilder to inject time_window constraints.
+ */
+ public function activeShiftFor(\DateTimeInterface $date = null): ?ScheduleItem
+ {
+ $date = $date ?? now();
+
+ return $this->scheduleItems()
+ ->whereDate('start_at', $date)
+ ->whereIn('status', ['pending', 'active'])
+ ->orderBy('start_at')
+ ->first();
+ }
+
+ /**
+ * Get all availability records (time-off, preferred hours) for this driver.
+ * The driver acts as the polymorphic `subject` on the ScheduleAvailability model.
+ */
+ public function availabilities(): MorphMany
+ {
+ return $this->morphMany(ScheduleAvailability::class, 'subject');
+ }
+
/**
* Get avatar url.
*/
diff --git a/server/src/routes.php b/server/src/routes.php
index d6c9b086c..3b6d3be7e 100644
--- a/server/src/routes.php
+++ b/server/src/routes.php
@@ -278,6 +278,11 @@ function ($router, $controller) {
$router->match(['get', 'post'], 'export', $controller('export'));
$router->delete('bulk-delete', $controller('bulkDelete'));
$router->post('import', $controller('import'));
+ // Driver scheduling endpoints
+ $router->get('{id}/schedule-items', $controller('scheduleItems'));
+ $router->get('{id}/availabilities', $controller('availabilities'));
+ $router->get('{id}/hos-status', $controller('hosStatus'));
+ $router->get('{id}/active-shift', $controller('activeShift'));
}
);
$router->fleetbaseRoutes('entities');