diff --git a/addon/components/equipment/card.hbs b/addon/components/equipment/card.hbs new file mode 100644 index 000000000..385218c7f --- /dev/null +++ b/addon/components/equipment/card.hbs @@ -0,0 +1,49 @@ + + +
{{or @resource.name @resource.public_id}}
+
{{or @resource.serial_number @resource.code @resource.public_id}}
+ {{#if (has-block "header")}} + {{yield to="header"}} + {{/if}} +
+ + +
+ {{#if @resource.type}} +
+ + {{humanize @resource.type}} +
+ {{/if}} + {{#if @resource.status}} +
+ + {{humanize @resource.status}} +
+ {{/if}} + {{#if @resource.year}} +
+ + {{@resource.year}} +
+ {{/if}} +
+ {{#if (has-block "body")}} + {{yield to="body"}} + {{/if}} +
+ +
+
+ {{#if (has-block "footer")}} + {{yield to="footer"}} + {{/if}} +
+
Last Modified: {{@resource.updatedAt}}
+
+
+
+{{yield}} diff --git a/addon/components/equipment/card.js b/addon/components/equipment/card.js new file mode 100644 index 000000000..7da0e4981 --- /dev/null +++ b/addon/components/equipment/card.js @@ -0,0 +1,6 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; + +export default class EquipmentCardComponent extends Component { + @service equipmentActions; +} diff --git a/addon/components/equipment/details.hbs b/addon/components/equipment/details.hbs index 1703f37bc..f21b3d180 100644 --- a/addon/components/equipment/details.hbs +++ b/addon/components/equipment/details.hbs @@ -1,63 +1,75 @@ -
- -
+
+ + {{! ===== OVERVIEW ===== }} + +
+ + {{! — Photo — }} + {{#if (or @resource.photo_url (config "defaultValues.equipmentImage"))}} +
+
+ {{@resource.name}} +
+
+

{{n-a @resource.name}}

+

{{n-a @resource.public_id}}

+
+
+ {{/if}} + + {{! — Identity & Classification — }} +
+ Identity & Classification +
-
Name
-
{{n-a @resource.name}}
+
ID
+ {{n-a @resource.public_id}}
-
Code
-
{{n-a @resource.code}}
+ {{n-a @resource.code}} +
+
+
Name
+
{{n-a @resource.name}}
-
Type
-
{{smart-humanize @resource.type}}
+
{{n-a (smart-humanize @resource.type)}}
-
Status
{{smart-humanize @resource.status}}
-
Serial Number
-
{{n-a @resource.serial_number}}
+
{{n-a @resource.serial_number}}
+ {{! — Make & Model — }} +
+ Make & Model +
Manufacturer
{{n-a @resource.manufacturer}}
-
Model
{{n-a @resource.model}}
-
-
Purchased At
-
{{format-date @resource.purchased_at}}
-
- -
-
Purchase Price
-
{{format-currency @resource.purchase_price}}
+ {{! — Assignment — }} +
+ Assignment
- -
-
Warranty
-
{{n-a @resource.warranty_name}}
-
- -
-
Equipped To
-
{{n-a @resource.equipped_to_name}}
-
-
Equipped Status
@@ -68,28 +80,55 @@ {{/if}}
+ {{#if @resource.is_equipped}} +
+
Equipped To
+
{{n-a @resource.equipped_to_name}}
+
+ {{/if}} +
+ + {{! ===== FINANCIALS ===== }} + +
+
+
Purchase Price
+
{{format-currency @resource.purchase_price @resource.currency}}
+
+
+
Purchased At
+
{{n-a (format-date-fns @resource.purchased_at "dd MMM yyyy")}}
+
Age
{{n-a @resource.age_in_days}} days
-
Depreciated Value
-
{{format-currency @resource.depreciated_value}}
+
{{format-currency @resource.depreciated_value @resource.currency}}
+
+
+
Currency
+
{{n-a @resource.currency}}
- - - -
-
-
- {{@resource.name}} + {{! ===== WARRANTY ===== }} + {{#if @resource.warranty_name}} + +
+
+
Warranty
+
{{n-a @resource.warranty_name}}
-
- + + {{/if}} + + {{! ===== CUSTOM FIELDS ===== }} + + +
diff --git a/addon/components/equipment/form.hbs b/addon/components/equipment/form.hbs index 848161cf5..93d664ca7 100644 --- a/addon/components/equipment/form.hbs +++ b/addon/components/equipment/form.hbs @@ -1,10 +1,20 @@ -
- +
+ + {{! ===== IDENTIFICATION (includes photo, matching vehicle/form convention) ===== }} + + {{! Photo row — identical structure to vehicle/form UploadButton pattern }}
- {{@resource.name}} + {{@resource.name}} - +
@@ -14,23 +24,70 @@ @onFileAdded={{perform this.handlePhotoUpload}} @icon="upload" @size="xs" - @buttonText={{t "common.upload-image"}} + @buttonText="Upload Image" @disabled={{cannot-write @resource}} />
- {{t "common.upload-image-supported"}} + Supports JPG, PNG, GIF up to 10 MB
+
- - + {{! Equipment Identification }} +
+ Equipment Details +
+ + - - + + + + + + + + + + + {{! Type & Status }} +
+ Type & Status +
-
+
+
- - - - - - - - - - + {{! ===== ASSIGNMENT ===== }} + +
+
+ Equipped To +
+ +
+ + {{option.label}} + +
+ {{#if this.equipableModelName}} + + + {{or model.name model.public_id}} + + + {{/if}} +
+
- - +
+
+ Purchase Information +
+ + - + + + - +
+ Warranty +
+ - - - -
+ {{! ===== REGISTRY & METADATA ===== }} + + + - + + +
diff --git a/addon/components/equipment/form.js b/addon/components/equipment/form.js index 8d010e504..8e0585d0c 100644 --- a/addon/components/equipment/form.js +++ b/addon/components/equipment/form.js @@ -1,27 +1,95 @@ import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; import { task } from 'ember-concurrency'; +/** + * Maps a user-facing polymorphic type string to the Ember Data model name + * used by ModelSelect. + */ +const TYPE_TO_MODEL = { + 'fleet-ops:vehicle': 'vehicle', + 'fleet-ops:driver': 'driver', + 'fleet-ops:equipment': 'equipment', +}; + export default class EquipmentFormComponent extends Component { + @service fetch; + @service currentUser; + @service notifications; + + /** Equipment type options. */ + equipmentTypeOptions = ['ppe', 'refrigeration_unit', 'tool', 'liftgate', 'ramp', 'container', 'pallet_jack', 'forklift', 'safety_equipment', 'communication_device', 'other']; + + /** Status options for equipment. */ + statusOptions = ['available', 'in_use', 'maintenance', 'retired', 'lost', 'damaged']; + /** - * Equipment type options + * Polymorphic equipable type options — the asset this equipment is attached to. + * Each entry has a `value` (stored on the model) and a `label` (displayed in the UI). */ - equipmentTypeOptions = ['ppe', 'refrigeration_unit', 'tool', 'liftgate', 'ramp', 'container', 'pallet_jack', 'forklift', 'safety_equipment', 'communication_device', 'other']; + equipableTypeOptions = [ + { value: 'fleet-ops:vehicle', label: 'Vehicle' }, + { value: 'fleet-ops:driver', label: 'Driver' }, + ]; + + /** Derived Ember Data model name for the currently selected equipable type. */ + @tracked equipableModelName = null; + + /** The currently selected equipable type option object (drives the PowerSelect trigger label). */ + @tracked selectedEquipableType = null; + + constructor(owner, args) { + super(owner, args); + const { resource } = args; + if (resource?.equipable_type) { + this.equipableModelName = TYPE_TO_MODEL[resource.equipable_type] ?? null; + this.selectedEquipableType = this.equipableTypeOptions.find((o) => o.value === resource.equipable_type) ?? null; + } + } /** - * Status options for equipment + * Handles a change to the equipable type selector. Resets the equipable + * relationship so a stale association is not persisted. */ - statusOptions = ['available', 'in_use', 'maintenance', 'retired', 'lost', 'damaged']; + @action onEquipableTypeChange(option) { + this.selectedEquipableType = option; + this.args.resource.equipable_type = option.value; + this.args.resource.equipable_uuid = null; + this.args.resource.equipable = null; + this.equipableModelName = TYPE_TO_MODEL[option.value] ?? null; + } + + /** Assigns the selected equipable model to the resource. */ + @action assignEquipable(model) { + this.args.resource.equipable = model; + this.args.resource.equipable_uuid = model?.id ?? null; + } /** - * Task to handle photo upload + * Handles photo upload using the Fleetbase fetch service upload pattern. */ @task *handlePhotoUpload(file) { try { - const response = yield file.upload(this.args.resource); - this.args.resource.photo_uuid = response.uuid; - this.args.resource.photo_url = response.url; - } catch (error) { - console.error('Photo upload failed:', error); + yield this.fetch.uploadFile.perform( + file, + { + path: `uploads/${this.currentUser.companyId}/equipment/${this.args.resource.id}`, + subject_uuid: this.args.resource.id, + subject_type: 'fleet-ops:equipment', + type: 'equipment_photo', + }, + (uploadedFile) => { + this.args.resource.setProperties({ + photo_uuid: uploadedFile.id, + photo_url: uploadedFile.url, + photo: uploadedFile, + }); + } + ); + } catch (err) { + this.notifications.error('Unable to upload photo: ' + err.message); } } } diff --git a/addon/components/equipment/panel-header.hbs b/addon/components/equipment/panel-header.hbs new file mode 100644 index 000000000..80a8453be --- /dev/null +++ b/addon/components/equipment/panel-header.hbs @@ -0,0 +1,29 @@ +
+
+
+
+ {{@resource.name}} +
+
+
+

{{@resource.name}}

+ {{smart-humanize @resource.status}} +
+
{{smart-humanize @resource.type}}
+
{{n-a @resource.serial_number}}
+
+
+
+ +
+
+
diff --git a/addon/components/equipment/panel-header.js b/addon/components/equipment/panel-header.js new file mode 100644 index 000000000..79e3438e1 --- /dev/null +++ b/addon/components/equipment/panel-header.js @@ -0,0 +1,2 @@ +import Component from '@glimmer/component'; +export default class EquipmentPanelHeaderComponent extends Component {} diff --git a/addon/components/layout/fleet-ops-sidebar.js b/addon/components/layout/fleet-ops-sidebar.js index f075267d0..f4fca4196 100644 --- a/addon/components/layout/fleet-ops-sidebar.js +++ b/addon/components/layout/fleet-ops-sidebar.js @@ -183,6 +183,14 @@ export default class LayoutFleetOpsSidebarComponent extends Component { ]; const maintenanceItems = [ + { + intl: 'menu.schedules', + title: this.intl.t('menu.schedules'), + icon: 'calendar-alt', + route: 'maintenance.schedules', + permission: 'fleet-ops list maintenance-schedule', + visible: this.abilities.can('fleet-ops see maintenance-schedule'), + }, { intl: 'menu.work-orders', title: this.intl.t('menu.work-orders'), @@ -191,6 +199,14 @@ export default class LayoutFleetOpsSidebarComponent extends Component { permission: 'fleet-ops list work-order', visible: this.abilities.can('fleet-ops see work-order'), }, + { + intl: 'menu.maintenances', + title: this.intl.t('menu.maintenances'), + icon: 'history', + route: 'maintenance.maintenances', + permission: 'fleet-ops list maintenance', + visible: this.abilities.can('fleet-ops see maintenance'), + }, { intl: 'menu.equipment', title: this.intl.t('menu.equipment'), @@ -289,10 +305,10 @@ export default class LayoutFleetOpsSidebarComponent extends Component { open: this.appCache.get('fleet-ops:sidebar:management:open', true), onToggle: (open) => this.appCache.set('fleet-ops:sidebar:management:open', open), }), - // createPanel('menu.maintenance', 'maintenance', maintenanceItems, { - // open: this.appCache.get('fleet-ops:sidebar:maintenance:open', false), - // onToggle: (open) => this.appCache.set('fleet-ops:sidebar:maintenance:open', open), - // }), + createPanel('menu.maintenance', 'maintenance', maintenanceItems, { + open: this.appCache.get('fleet-ops:sidebar:maintenance:open', false), + onToggle: (open) => this.appCache.set('fleet-ops:sidebar:maintenance:open', open), + }), createPanel('menu.connectivity', 'connectivity', connectivityItems, { open: this.appCache.get('fleet-ops:sidebar:connectivity:open', false), onToggle: (open) => this.appCache.set('fleet-ops:sidebar:connectivity:open', open), diff --git a/addon/components/maintenance-schedule/details.hbs b/addon/components/maintenance-schedule/details.hbs new file mode 100644 index 000000000..b3ca89a57 --- /dev/null +++ b/addon/components/maintenance-schedule/details.hbs @@ -0,0 +1,237 @@ +
+ + {{! ===== OVERVIEW ===== }} + +
+ + {{! — Identity — }} +
+ Identity +
+
+
ID
+ {{n-a @resource.public_id}} +
+
+
Name
+
{{n-a @resource.name}}
+
+
+
Type
+
{{n-a (smart-humanize @resource.type)}}
+
+
+
Status
+
+ {{smart-humanize @resource.status}} +
+
+ + {{! — Assignment — }} +
+ Assignment +
+
+
Asset
+
{{n-a (or @resource.subject.displayName @resource.subject.name @resource.subject_name)}}
+
+ {{#if (or @resource.default_assignee.displayName @resource.default_assignee.name)}} +
+
Default Assignee
+
{{n-a (or @resource.default_assignee.displayName @resource.default_assignee.name)}}
+
+ {{/if}} +
+
Default Priority
+
+ {{#if (eq @resource.default_priority "critical")}} + Critical + {{else if (eq @resource.default_priority "high")}} + High + {{else if (eq @resource.default_priority "medium")}} + Medium + {{else}} + Low + {{/if}} +
+
+
+
+ + {{! ===== INTERVAL TRIGGERS ===== }} + +
+ + {{#if @resource.interval_value}} +
+ Time-Based +
+
+
Repeat Every
+
{{@resource.interval_value}} {{@resource.interval_unit}}
+
+ {{/if}} + + {{#if @resource.interval_distance}} +
+ Distance-Based +
+
+
Every (km)
+
{{@resource.interval_distance}} km
+
+ {{/if}} + + {{#if @resource.interval_engine_hours}} +
+ Engine Hours-Based +
+
+
Every (hours)
+
{{@resource.interval_engine_hours}} hrs
+
+ {{/if}} +
+
+ + {{! ===== SERVICE HISTORY & NEXT DUE ===== }} + +
+ +
+ Last Service +
+
+
Last Service Date
+
{{n-a (format-date-fns @resource.last_service_date "dd MMM yyyy")}}
+
+
+
Last Odometer
+
+ {{#if @resource.last_service_odometer}} + {{@resource.last_service_odometer}} km + {{else}} + — + {{/if}} +
+
+
+
Last Engine Hours
+
{{n-a @resource.last_service_engine_hours}}
+
+ +
+ Next Due +
+
+
Next Due Date
+
{{n-a (format-date-fns @resource.next_due_date "dd MMM yyyy")}}
+
+
+
Next Due Odometer
+
+ {{#if @resource.next_due_odometer}} + {{@resource.next_due_odometer}} km + {{else}} + — + {{/if}} +
+
+
+
Next Due Engine Hours
+
{{n-a @resource.next_due_engine_hours}}
+
+
+
+ + {{! ===== INSTRUCTIONS ===== }} + {{#if @resource.instructions}} + +
+ {{@resource.instructions}} +
+
+ {{/if}} + + {{! ===== UPCOMING SCHEDULE CALENDAR (time-based only) ===== }} + {{#if this.isTimeBased}} + + {{! Calendar header }} +
+ + {{this.calendarMonthName}} + +
+ + {{! Day-of-week header row }} +
+ {{#each this.dayNames as |dayName|}} +
{{dayName}}
+ {{/each}} +
+ + {{! Calendar grid }} +
+ {{#each this.calendarWeeks as |week|}} +
+ {{#each week as |cell|}} +
+ + {{cell.day}} + +
+ {{/each}} +
+ {{/each}} +
+ + {{! Legend }} +
+
+ 1 + Scheduled service +
+
+ 1 + Today +
+
+ + {{! Upcoming occurrences list }} + {{#if this.upcomingOccurrences.length}} +
+
+

Next occurrences

+
+
+ {{#each this.upcomingOccurrences as |occurrence index|}} +
+ + {{add index 1}} + + + {{format-date-fns occurrence "EEEE, MMMM d, yyyy"}} + +
+ {{/each}} +
+
+ {{/if}} +
+ {{/if}} + + {{! ===== CUSTOM FIELDS ===== }} + + + +
diff --git a/addon/components/maintenance-schedule/details.js b/addon/components/maintenance-schedule/details.js new file mode 100644 index 000000000..97f279ff2 --- /dev/null +++ b/addon/components/maintenance-schedule/details.js @@ -0,0 +1,121 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +/** + * Compute the next N occurrences of a time-based maintenance schedule + * starting from the given `nextDueDate`, stepping by `intervalValue` `intervalUnit`. + */ +function computeOccurrences(nextDueDate, intervalValue, intervalUnit, count = 12) { + if (!nextDueDate || !intervalValue || !intervalUnit) return []; + const occurrences = []; + let cursor = new Date(nextDueDate); + for (let i = 0; i < count; i++) { + occurrences.push(new Date(cursor)); + switch (intervalUnit) { + case 'days': + cursor.setDate(cursor.getDate() + intervalValue); + break; + case 'weeks': + cursor.setDate(cursor.getDate() + intervalValue * 7); + break; + case 'months': + cursor.setMonth(cursor.getMonth() + intervalValue); + break; + case 'years': + cursor.setFullYear(cursor.getFullYear() + intervalValue); + break; + default: + cursor.setDate(cursor.getDate() + intervalValue); + } + } + return occurrences; +} + +/** + * Build a 6-week calendar grid for a given year/month. + * Each cell: { date, day, isCurrentMonth, isToday, isScheduled } + */ +function buildCalendarGrid(year, month, scheduledDates) { + const scheduledSet = new Set(scheduledDates.map((d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`)); + const today = new Date(); + const todayKey = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`; + const firstDay = new Date(year, month, 1); + const cursor = new Date(year, month, 1 - firstDay.getDay()); + const weeks = []; + for (let w = 0; w < 6; w++) { + const week = []; + for (let d = 0; d < 7; d++) { + const date = new Date(cursor); + const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; + week.push({ date, day: date.getDate(), isCurrentMonth: date.getMonth() === month, isToday: key === todayKey, isScheduled: scheduledSet.has(key) }); + cursor.setDate(cursor.getDate() + 1); + } + weeks.push(week); + } + return weeks; +} + +const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; +const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +export default class MaintenanceScheduleDetailsComponent extends Component { + @tracked calendarYear; + @tracked calendarMonth; + + constructor() { + super(...arguments); + const seed = this.args.resource?.next_due_date ? new Date(this.args.resource.next_due_date) : new Date(); + this.calendarYear = seed.getFullYear(); + this.calendarMonth = seed.getMonth(); + } + + get resource() { + return this.args.resource; + } + + get occurrences() { + const r = this.resource; + if (!r) return []; + return computeOccurrences(r.next_due_date, r.interval_value, r.interval_unit, 12); + } + + get calendarMonthName() { + return `${MONTH_NAMES[this.calendarMonth]} ${this.calendarYear}`; + } + get dayNames() { + return DAY_NAMES; + } + + get calendarWeeks() { + return buildCalendarGrid(this.calendarYear, this.calendarMonth, this.occurrences); + } + + get upcomingOccurrences() { + const now = new Date(); + return this.occurrences.filter((d) => d >= now).slice(0, 6); + } + + get isTimeBased() { + const m = this.resource?.interval_method; + return !m || m === 'time'; + } + + @action prevMonth() { + if (this.calendarMonth === 0) { + this.calendarMonth = 11; + this.calendarYear = this.calendarYear - 1; + } else { + this.calendarMonth = this.calendarMonth - 1; + } + } + + @action nextMonth() { + if (this.calendarMonth === 11) { + this.calendarMonth = 0; + this.calendarYear = this.calendarYear + 1; + } else { + this.calendarMonth = this.calendarMonth + 1; + } + } +} diff --git a/addon/components/maintenance-schedule/form.hbs b/addon/components/maintenance-schedule/form.hbs new file mode 100644 index 000000000..3a0a04884 --- /dev/null +++ b/addon/components/maintenance-schedule/form.hbs @@ -0,0 +1,286 @@ +
+ + {{! ===== IDENTIFICATION ===== }} + +
+ + + + + +
+ + {{smart-humanize type}} + +
+
+ + +
+ + {{smart-humanize status}} + +
+
+ +
+
+ + {{! ===== ASSET (SUBJECT) ===== }} + +
+ +
+ Which asset does this schedule apply to? +
+ + +
+ + {{option.label}} + +
+
+ + {{#if this.subjectModelName}} + + + {{or model.displayName model.name model.public_id}} + + + {{/if}} + +
+
+ + {{! ===== INTERVAL ===== }} + +
+ +
+ How should this maintenance be triggered? +
+ + {{! Interval method selector — drives which fields are shown below }} + +
+ + {{option.label}} + +
+
+ + {{! TIME-BASED fields }} + {{#if this.isTimeBased}} +
+ Time-Based Interval +
+ + + + +
+ + {{smart-humanize unit}} + +
+
+ + + + {{/if}} + + {{! DISTANCE-BASED fields }} + {{#if this.isDistanceBased}} +
+ Distance-Based Interval +
+ + + + + + + {{/if}} + + {{! ENGINE HOURS-BASED fields }} + {{#if this.isEngineHoursBased}} +
+ Engine Hours-Based Interval +
+ + + + + + + {{/if}} + +
+
+ + {{! ===== WORK ORDER DEFAULTS ===== }} + +
+ +
+ These settings are used when a work order is auto-generated from this schedule. +
+ + +
+ + {{smart-humanize priority}} + +
+
+ + +
+ + {{option.label}} + +
+
+ + {{#if this.assigneeModelName}} + + + {{or model.displayName model.name model.public_id}} + + + {{/if}} + + +