@fleetbase/fleetops-engine
Version:
Fleet & Transport Management Extension for Fleetbase
342 lines (305 loc) • 13.8 kB
JavaScript
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action, computed } from '@ember/object';
import { task } from 'ember-concurrency';
import { format, startOfWeek, endOfWeek, addWeeks, formatISO } from 'date-fns';
import { Tooltip } from '@fleetbase/ember-ui/utils/floating';
/**
* OperationsSchedulerFleetScheduleController
*
* Fleet-wide driver schedule calendar using FullCalendar `resourceTimelineWeek` view.
* Each driver is a resource row; materialised ScheduleItem records are rendered as
* events; approved ScheduleExceptions are shown as red background blocks.
*
* Data flow:
* 1. Route loads active drivers and passes them to this controller
* 2. loadScheduleItems task fetches materialised shifts for the current window
* 3. loadScheduleExceptions task fetches approved time-off blocks
* 4. events / calendarResources computed properties transform data for FullCalendar
*/
export default class OperationsSchedulerFleetScheduleController extends Controller {
modalsManager;
notifications;
store;
fetch;
intl;
drivers = [];
scheduleItems = [];
exceptions = [];
calendarApi = null;
// ── Calendar window ───────────────────────────────────────────────────────
get windowStart() {
return formatISO(startOfWeek(new Date(), { weekStartsOn: 1 }));
}
get windowEnd() {
return formatISO(addWeeks(endOfWeek(new Date(), { weekStartsOn: 1 }), 4));
}
// ── FullCalendar data ─────────────────────────────────────────────────────
/**
* Transform drivers into FullCalendar resource objects.
*/
get calendarResources() {
return this.drivers.map((driver) => ({
id: driver.id,
title: driver.name,
extendedProps: { driver },
}));
}
/**
* Transform schedule items and approved exceptions into FullCalendar event objects.
* Approved exceptions are rendered as red background blocks.
*/
get events() {
const shiftEvents = this.scheduleItems.map((item) => {
const driver = this.drivers.find((d) => d.id === item.assignee_uuid);
return {
id: item.id,
resourceId: item.assignee_uuid,
title: item.title || (driver ? `${driver.name} — Shift` : 'Shift'),
start: item.start_at,
end: item.end_at,
backgroundColor: this.getShiftColor(item),
borderColor: this.getShiftColor(item),
extendedProps: { scheduleItem: item, driver },
};
});
const exceptionEvents = this.exceptions
.filter((e) => e.status === 'approved')
.map((exception) => ({
id: `exc-${exception.id}`,
resourceId: exception.subject_uuid,
start: exception.start_at,
end: exception.end_at,
display: 'background',
backgroundColor: '#FCA5A5', // red-300
extendedProps: { exception },
}));
return [...shiftEvents, ...exceptionEvents];
}
/**
* Map shift status to a color for the calendar event.
*/
getShiftColor(item) {
const colors = {
scheduled: '#6366f1', // indigo
confirmed: '#22c55e', // green
in_progress: '#3b82f6', // blue
completed: '#9ca3af', // gray
cancelled: '#ef4444', // red
no_show: '#f97316', // orange
};
return colors[item.status] || '#6366f1';
}
// ── Data loading ──────────────────────────────────────────────────────────
/**
* Load materialised schedule items for all drivers within the calendar window.
*/
*loadScheduleItems() {
try {
const items = yield this.store.query('schedule-item', {
assignee_type: 'fleet-ops:driver',
start_at_gte: this.windowStart,
end_at_lte: this.windowEnd,
limit: 500,
});
this.scheduleItems = items.toArray();
yield this.loadScheduleExceptions.perform();
} catch (error) {
this.notifications.serverError(error);
}
}
/**
* Load approved ScheduleException records (time-off, sick leave, etc.)
* within the calendar window to render as background blocks.
*/
*loadScheduleExceptions() {
try {
const exceptions = yield this.store.query('schedule-exception', {
subject_type: 'fleet-ops:driver',
start_at_gte: this.windowStart,
end_at_lte: this.windowEnd,
limit: 200,
});
this.exceptions = exceptions.toArray();
} catch (error) {
// Non-critical — suppress and continue without exception blocks
this.exceptions = [];
}
}
// ── Calendar API ──────────────────────────────────────────────────────────
setCalendarApi(calendar) {
this.calendarApi = calendar;
calendar.setOption('eventDidMount', (info) => {
if (!info.event.extendedProps.scheduleItem) return;
const item = info.event.extendedProps.scheduleItem;
const driver = info.event.extendedProps.driver;
const tooltip = `${driver?.name || 'Driver'}\n${format(new Date(item.start_at), 'HH:mm')}–${format(new Date(item.end_at), 'HH:mm')}`;
info.tooltip = new Tooltip(info.el, { text: tooltip });
});
calendar.setOption('eventWillUnmount', (info) => {
info.tooltip?.destroy();
});
}
// ── Event handlers ────────────────────────────────────────────────────────
/**
* Handle clicking a shift event — opens the driver-shift edit modal.
*/
onEventClick(info) {
const { scheduleItem } = info.event.extendedProps;
if (!scheduleItem) return;
this.modalsManager.show('modals/driver-shift', {
title: this.intl.t('scheduler.edit-shift'),
acceptButtonText: this.intl.t('common.save-changes'),
acceptButtonIcon: 'save',
item: scheduleItem,
confirm: async (modal) => {
modal.startLoading();
const options = modal.getOptions();
try {
scheduleItem.setProperties({
title: options.title,
start_at: options.startAt,
end_at: options.endAt,
notes: options.notes,
});
await scheduleItem.save();
this.notifications.success(this.intl.t('scheduler.shift-updated'));
await this.loadScheduleItems.perform();
modal.done();
} catch (error) {
this.notifications.serverError(error);
modal.stopLoading();
}
},
});
}
/**
* Handle drag-and-drop rescheduling — supports cross-driver reassignment.
*/
async onEventDrop(info) {
const { scheduleItem } = info.event.extendedProps;
if (!scheduleItem) {
info.revert();
return;
}
try {
scheduleItem.setProperties({
start_at: info.event.start.toISOString(),
end_at: info.event.end?.toISOString() || scheduleItem.end_at,
assignee_uuid: info.newResource?.id || scheduleItem.assignee_uuid, // newResource.id is driver.id (see calendarResources)
});
await scheduleItem.save();
this.notifications.success(this.intl.t('scheduler.shift-updated'));
} catch (error) {
info.revert();
this.notifications.serverError(error);
}
}
// ── Shift management ──────────────────────────────────────────────────────
/**
* Open the Add Shift modal, optionally pre-selecting a driver.
*/
addShiftForDriver(driver) {
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.drivers,
selectedDriver: driver,
confirm: async (modal) => {
modal.startLoading();
const options = modal.getOptions();
const targetDriver = options.selectedDriver || driver;
try {
if (options.isRecurring) {
// Build RRULE-based ScheduleTemplate and apply it to the driver's Schedule
const template = this.store.createRecord('schedule-template', {
name: options.templateName || `${targetDriver.name} Recurring Schedule`,
rrule: options.rrule,
start_time: options.shiftStartTime,
end_time: options.shiftEndTime,
break_start_time: options.breakStartTime || null,
break_end_time: options.breakEndTime || null,
color: options.templateColor || '#6366f1',
});
const savedTemplate = await template.save();
// Find or create the driver's Schedule container
const schedules = await this.store.query('schedule', {
subject_type: 'fleet-ops:driver',
subject_uuid: targetDriver.id,
limit: 1,
});
let schedule;
if (schedules.length > 0) {
schedule = schedules.firstObject;
} else {
schedule = await this.store
.createRecord('schedule', {
subject_type: 'fleet-ops:driver',
subject_uuid: targetDriver.id,
name: `${targetDriver.name} Schedule`,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
status: 'draft',
})
.save();
}
// Apply the template — core-api materialises ScheduleItems
await this.fetch.post(`schedule-templates/${savedTemplate.id}/apply`, {
subject_type: 'fleet-ops:driver',
subject_uuid: targetDriver.id,
schedule_uuid: schedule.id,
effective_from: options.recurrenceStartDate || new Date().toISOString(),
effective_until: options.recurrenceEndDate || null,
});
this.notifications.success(this.intl.t('scheduler.recurring-schedule-created'));
} else {
// Single one-off shift
const scheduleItem = this.store.createRecord('schedule-item', {
assignee_type: 'fleet-ops:driver',
assignee_uuid: targetDriver.id,
title: options.title || null,
start_at: options.startAt,
end_at: options.endAt,
notes: options.notes || null,
status: 'scheduled',
});
await scheduleItem.save();
this.notifications.success(this.intl.t('scheduler.shift-created'));
}
await this.loadScheduleItems.perform();
modal.done();
} catch (error) {
this.notifications.serverError(error);
modal.stopLoading();
}
},
});
}
/**
* Open the Add Shift modal without a pre-selected driver.
*/
addShift() {
this.addShiftForDriver(null);
}
// ── Calendar navigation ───────────────────────────────────────────────────
previousWeek() {
this.calendarApi?.prev();
}
nextWeek() {
this.calendarApi?.next();
}
goToToday() {
this.calendarApi?.today();
}
}