Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
dbb29dc
feat: add first-class trailer operations
roncodes Sep 3, 2026
62a376a
fix: support device installation soft deletes
roncodes Sep 3, 2026
821ec7b
ci: exercise trailer Postman lifecycle
roncodes Sep 3, 2026
57c6cfb
fix: persist trailer spatial tracking
roncodes Sep 3, 2026
c5a6211
test: emulate MySQL spatial hydration
roncodes Sep 3, 2026
c2cb17e
fix: correct trailer navigation and payload integration
roncodes Sep 6, 2026
79519f9
Merge branch 'main' into feature/first-class-trailers
roncodes Sep 7, 2026
cc42298
fix: align Trailer API contracts and guards with Fleet-Ops conventions
roncodes Sep 7, 2026
0feb871
feat: production-ready Trailer console experience
roncodes Sep 7, 2026
0aecd59
test: cover Trailer navigation, store normalization, actions and cons…
roncodes Sep 7, 2026
762433f
ci: pin the contract run to the updated Trailer Postman revision
roncodes Sep 7, 2026
8e19e1b
fix: keep the inline internal-request guards on Trailer identifier fi…
roncodes Sep 7, 2026
609d7cc
feat(trailer): polish trailer UI and unify attachment tabs
roncodes Sep 8, 2026
ab3edbd
feat(live-map): show linked trailers and devices in vehicle popovers
roncodes Sep 8, 2026
a8cf9f9
Merge remote-tracking branch 'origin/main' into feature/first-class-t…
roncodes Sep 8, 2026
75ac1d8
feat(vehicle): align the vehicle card with the trailer card layout
roncodes Sep 8, 2026
b7aad4d
feat(cards): unify driver, vehicle and trailer cards
roncodes Sep 8, 2026
8fb8c55
feat: grid views for vendors, contacts and customers
roncodes Sep 8, 2026
54a4ba3
fix: track the vendor card components
roncodes Sep 8, 2026
f48a16f
fix(cards): titleize the driver status badge and fill the image body
roncodes Sep 8, 2026
7624d2e
fix(devices): refuse to re-home an installed device without detaching
roncodes Sep 8, 2026
851c196
feat(filters): attachment, multi-select and batch id filters
roncodes Sep 8, 2026
461d2ef
fix(filters): let the multi-model trigger grow with its selections
roncodes Sep 8, 2026
e56c71e
feat(placeholders): one silhouette set for every resource image
roncodes Sep 9, 2026
5da072a
feat(placeholders): silhouette set, helpers and card/header wiring
roncodes Sep 9, 2026
4ad8f90
fix(placeholders): draw the driver silhouette as a clear baseball cap
roncodes Sep 9, 2026
4f02418
feat(placeholders): use the silhouette set everywhere a resource imag…
roncodes Sep 9, 2026
2c8b97d
ci: follow Postman contract from main
roncodes Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions addon/components/attachment-list.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<div class="fleetops-attachment-list {{@wrapperClass}}" ...attributes>
<div class="fleetops-attachment-list-header">
<div class="min-w-0">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{@title}}</h3>
{{#if @description}}
<p class="text-xs text-gray-500 dark:text-gray-400">{{@description}}</p>
{{/if}}
</div>
<div class="flex flex-row items-center space-x-2 shrink-0">
{{yield to="actions"}}
{{#if @actionText}}
<Button
@icon={{or @actionIcon "plus"}}
@text={{@actionText}}
@size="xs"
@type={{or @actionType "primary"}}
@onClick={{@onAction}}
@permission={{@actionPermission}}
@disabled={{@actionDisabled}}
/>
{{/if}}
</div>
</div>

{{#if @isLoading}}
<div class="fleetops-attachment-list-loading">
<Spinner />
</div>
{{else if @items.length}}
<div class="fleetops-attachment-list-items" role="list">
{{#each @items as |item index|}}
<div class="fleetops-attachment-list-item" role="listitem">
<div class="fleetops-attachment-list-item-content">
{{yield item index to="item"}}
</div>
{{#if (has-block "itemActions")}}
<div class="fleetops-attachment-list-item-actions">
{{yield item index to="itemActions"}}
</div>
{{/if}}
</div>
{{/each}}
</div>
{{else}}
<div class="fleetops-attachment-list-empty">
<FaIcon @icon={{or @emptyIcon "box-open"}} @size="2x" class="text-gray-400 dark:text-gray-500 mb-2" />
<h3 class="text-base font-semibold text-gray-700 dark:text-gray-300">{{@emptyTitle}}</h3>
{{#if @emptyDescription}}
<p class="text-sm text-gray-500 dark:text-gray-400">{{@emptyDescription}}</p>
{{/if}}
{{#if (and @actionText (not @hideEmptyAction))}}
<Button
@icon={{or @actionIcon "plus"}}
@text={{@actionText}}
@size="xs"
@type={{or @actionType "primary"}}
@onClick={{@onAction}}
@permission={{@actionPermission}}
@disabled={{@actionDisabled}}
class="mt-3"
/>
{{/if}}
</div>
{{/if}}
</div>
8 changes: 8 additions & 0 deletions addon/components/attachment-list.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Component from '@glimmer/component';

/**
* Shared layout for "things attached to an asset" tabs (devices, equipment,
* trailers, towing history) so every attachment tab reads the same way: a
* header with a description and primary action, uniform rows, one empty state.
*/
export default class AttachmentListComponent extends Component {}
2 changes: 1 addition & 1 deletion addon/components/cell/attached-vehicle.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{{else}}
<div class="flex items-center gap-2 py-1 text-xs text-gray-400 dark:text-gray-500">
<FaIcon @icon="link-slash" />
<span>Unattached</span>
<span>{{t "trailer.attachment.detached"}}</span>
</div>
{{/if}}
</div>
6 changes: 3 additions & 3 deletions addon/components/cell/attached-vehicle.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ export default class CellAttachedVehicleComponent extends Component {
}

get hasVehicle() {
return Boolean(this.device?.attachable_uuid && this.isVehicleAttachment);
return Boolean(this.device?.attachable_uuid && this.isSupportedAttachment);
}

get isVehicleAttachment() {
get isSupportedAttachment() {
const attachableType = `${this.device?.attachable_type ?? ''}`.toLowerCase();

return !attachableType || attachableType.includes('vehicle');
return !attachableType || attachableType.includes('vehicle') || attachableType.includes('trailer');
}

@action onClick(_vehicle, event) {
Expand Down
8 changes: 4 additions & 4 deletions addon/components/cell/driver-identity.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Component from '@glimmer/component';
import { action, get } from '@ember/object';
import config from 'ember-get-config';
import { getPlaceholderImage, resolveResourceImage } from '../../utils/placeholder-images';
import { resolveIdentityCellResource } from '../../utils/identity-cell-resource';

const DEFAULT_STATUS_TONES = {
Expand Down Expand Up @@ -34,11 +34,11 @@ export default class CellDriverIdentityComponent extends Component {
}

get mediaUrl() {
return get(this.resource, 'photo_url');
return resolveResourceImage(get(this.resource, 'photo_url'), 'driver');
}

get fallbackImage() {
return config?.defaultValues?.driverImage;
return getPlaceholderImage('driver');
}

get hasCompactStatusDot() {
Expand Down Expand Up @@ -97,7 +97,7 @@ export default class CellDriverIdentityComponent extends Component {
...(this.args.column ?? {}),
labelPath: 'name',
mediaPath: 'photo_url',
fallbackImage: config?.defaultValues?.driverImage,
fallbackImage: getPlaceholderImage('driver'),
statusPath: 'status',
onlinePath: 'online',
showStatusBadge: this.args.column?.showStatusBadge ?? true,
Expand Down
2 changes: 1 addition & 1 deletion addon/components/cell/driver-name.hbs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{{#let (or @row.driver this.driver) as |driver|}}
{{#if driver}}
<div class="relative flex items-center" ...attributes>
<img src={{driver.photo_url}} alt={{driver.name}} {{fallback-img-src (config "defaultValues.driverImage")}} class="w-5 h-5 mr-2 rounded-md" />
<img src={{resource-image driver.photo_url "driver"}} alt={{driver.name}} {{fallback-img-src (placeholder-image "driver")}} class="w-5 h-5 mr-2 rounded-md" />
<a href="javascript:;" class="relative flex flex-row items-center" disabled={{and @column.permission (cannot @column.permission)}} {{on "click" (fn this.onClick driver)}}>
<span>{{get-default-value driver.name}}</span>
{{#if @row.vehicle_assigned}}
Expand Down
9 changes: 9 additions & 0 deletions addon/components/cell/translated-value.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{{#if this.label}}
{{#if this.isBadge}}
<Badge @status={{this.value}} @text={{this.label}} @icon={{this.icon}} @hideStatusDot={{unless this.icon false true}} ...attributes />
{{else}}
<span class={{@column.className}} ...attributes>{{this.label}}</span>
{{/if}}
{{else}}
<span class="text-gray-400 dark:text-gray-500" ...attributes>{{this.emptyText}}</span>
{{/if}}
57 changes: 57 additions & 0 deletions addon/components/cell/translated-value.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { get } from '@ember/object';

/**
* Table cell that renders a stable enum value (`dry_van`, `never_connected`, ...)
* through its localized label. Configure the column with:
*
* translationPrefix: 'trailer.types' // required, joined with the raw value
* badge: true // optional, render as a status badge
* badgeIconPath / badgeIcons // optional badge icon per value
*/
export default class CellTranslatedValueComponent extends Component {
@service intl;

get value() {
const row = this.args.row;
const valuePath = this.args.column?.valuePath;

if (this.args.value !== undefined && this.args.value !== null) {
return this.args.value;
}

return valuePath ? get(row, valuePath) : null;
}

get label() {
const value = this.value;
const prefix = this.args.column?.translationPrefix;

if (value === null || value === undefined || value === '') {
return null;
}

if (!prefix) {
return String(value);
}

const key = `${prefix}.${value}`;

return this.intl.exists(key) ? this.intl.t(key) : String(value);
}

get isBadge() {
return Boolean(this.args.column?.badge);
}

get icon() {
const icons = this.args.column?.badgeIcons;

return icons && this.value ? icons[this.value] : undefined;
}

get emptyText() {
return this.args.column?.emptyText ?? '-';
}
}
58 changes: 58 additions & 0 deletions addon/components/contact/card.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<Layout::Resource::Card class="fleetops-resource-card contact-card" ...attributes as |Card|>
<Card.header class={{@headerClass}}>
<RegistryYield @registry="fleet-ops:component:contact:card:header:start" as |RegistryComponent|>
<RegistryComponent @resource={{@resource}} />
</RegistryYield>
<div class="fleetops-card-title">
<div class="font-semibold flex-1 truncate">{{n-a @resource.name}}</div>
</div>
<div class="text-gray-400 dark:text-gray-500 text-sm w-full truncate">{{n-a @resource.title @resource.internal_id @resource.public_id}}</div>
{{#if (has-block "header")}}
{{yield to="header"}}
{{/if}}
<RegistryYield @registry="fleet-ops:component:contact:card:header:end" as |RegistryComponent|>
<RegistryComponent @resource={{@resource}} />
</RegistryYield>
</Card.header>
<Card.body class="{{@bodyClass}} h-40 overflow-hidden">
<Image src={{resource-image @resource.photo_url this.placeholderType}} @fallbackSrc={{(placeholder-image this.placeholderType)}} alt={{@resource.name}} class="fleetops-card-image fleetops-card-image--cover" />
{{#if (has-block "body")}}
{{yield to="body"}}
{{/if}}
</Card.body>
<Card.footer class={{@footerClass}}>
<RegistryYield @registry="fleet-ops:component:contact:card:footer:start" as |RegistryComponent|>
<RegistryComponent @resource={{@resource}} />
</RegistryYield>
<div class="flex flex-row items-center justify-between w-full">
<Badge @status={{or @resource.status @resource.type "default"}} @size="xs" />
<div class="flex flex-row items-center space-x-1">
<Button @icon="eye" @size="xs" @helpText={{t "common.view"}} @onClick={{fn this.resourceActions.transition.view @resource}} @permission="fleet-ops view contact" />
<Button @icon="pencil" @size="xs" @helpText={{t "common.edit"}} @onClick={{fn this.resourceActions.transition.edit @resource}} @permission="fleet-ops update contact" />
<Button @icon="trash" @size="xs" @type="danger" @helpText={{t "common.delete"}} @onClick={{fn this.resourceActions.delete @resource}} @permission="fleet-ops delete contact" />
</div>
</div>
<RegistryYield @registry="fleet-ops:component:contact:card:footer:end" as |RegistryComponent|>
<RegistryComponent @resource={{@resource}} />
</RegistryYield>
{{#if (has-block "footer")}}
{{yield to="footer"}}
{{/if}}
<div class="fleetops-card-meta-list">
<div class="fleetops-card-meta">
<FaIcon @icon="envelope" @prefix="fas" />
<span class="fleetops-card-meta-value">{{#if @resource.email}}<a href="mailto:{{@resource.email}}" class="fleetops-card-meta-link">{{@resource.email}}</a>{{else}}-{{/if}}</span>
</div>
<div class="fleetops-card-meta">
<FaIcon @icon="phone" @prefix="fas" />
<span class="fleetops-card-meta-value">{{#if @resource.phone}}<a href="tel:{{@resource.phone}}" class="fleetops-card-meta-link">{{@resource.phone}}</a>{{else}}-{{/if}}</span>
</div>
<div class="fleetops-card-meta">
<FaIcon @icon="location-dot" @prefix="fas" />
<span class="fleetops-card-meta-value">{{n-a @resource.address @resource.place.address}}</span>
</div>
<div class="fleetops-card-meta">{{t "common.last-modified"}}: {{@resource.updatedAt}}</div>
</div>
</Card.footer>
</Layout::Resource::Card>
{{yield}}
19 changes: 19 additions & 0 deletions addon/components/contact/card.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';

/**
* Card for contacts and customers. Customers are contacts driven by the customer
* actions service, so the actions service can be passed in as `@resourceActions`.
*/
export default class ContactCardComponent extends Component {
@service contactActions;

get resourceActions() {
return this.args.resourceActions ?? this.contactActions;
}

/** Customers get their own silhouette; plain contacts the generic one. */
get placeholderType() {
return this.args.placeholderType ?? 'contact';
}
}
2 changes: 1 addition & 1 deletion addon/components/contact/form.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<ContentPanel @title={{t "contact.fields.contact-details"}} @open={{true}} @wrapperClass="bordered-top">
<div class="flex flex-row space-x-3 mb-3">
<div class="flex items-center justify-start rounded-full">
<Image src={{@resource.photo_url}} @fallbackSrc={{config "defaultValues.userImage"}} alt={{@resource.name}} height="48" width="48" class="h-14 w-14 rounded-full shadow-sm" />
<Image src={{resource-image @resource.photo_url "contact"}} @fallbackSrc={{(placeholder-image "contact")}} alt={{@resource.name}} height="48" width="48" class="h-14 w-14 rounded-full shadow-sm" />
<Attach::Tooltip @class="clean" @animation="scale" @placement="top">
<InputInfo @text={{t "common.upload-new-photo"}} />
</Attach::Tooltip>
Expand Down
2 changes: 1 addition & 1 deletion addon/components/contact/panel-header.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<div class="flex flex-1 flex-row items-start justify-between">
<div class="flex flex-row space-x-3">
<div class="flex items-start justify-start rounded-full">
<Image src={{@resource.photo_url}} @fallbackSrc={{config "defaultValues.userImage"}} alt={{@resource.name}} height="48" width="48" class="h-14 w-14 rounded-full shadow-sm" />
<Image src={{resource-image @resource.photo_url "contact"}} @fallbackSrc={{(placeholder-image "contact")}} alt={{@resource.name}} height="48" width="48" class="h-14 w-14 rounded-full shadow-sm" />
<Attach::Tooltip @class="clean" @animation="scale" @placement="top">
<InputInfo @text={{@resource.name}} />
</Attach::Tooltip>
Expand Down
4 changes: 2 additions & 2 deletions addon/components/customer/form.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
<div class="flex flex-row space-x-3 mb-3">
<div class="flex items-center justify-start rounded-full">
<Image
src={{@resource.photo_url}}
@fallbackSrc={{config "defaultValues.contactImage"}}
src={{resource-image @resource.photo_url "customer"}}
@fallbackSrc={{(placeholder-image "customer")}}
alt={{@resource.name}}
height="48"
width="48"
Expand Down
54 changes: 26 additions & 28 deletions addon/components/device/manager.hbs
Original file line number Diff line number Diff line change
@@ -1,28 +1,26 @@
<div class="fleetops-device-manager" ...attributes>
<div class="flex flex-row items-center justify-between mb-4 px-3 pt-2">
<div></div>
<div>
<Button @icon="plus" @text={{t "device.actions.attach-device"}} @onClick={{this.addDevice}} @disabled={{not @resource}} />
</div>
</div>
<div>
{{#each this.devices as |device|}}
<div class="flex flex-row justify-between px-3 py-4 border-t border-gray-200 dark:border-gray-700">
<Device::Pill @device={{device}} />
<div>
<Button @type="danger" @icon="times" @text={{t "device.actions.detach-from-vehicle"}} @size="xs" @onClick={{fn this.removeDevice device}} />
</div>
</div>
{{else}}
<div class="empty-state bg-white dark:bg-gray-800 rounded-lg shadow-sm p-12 text-center">
<FaIcon @icon="hard-drive" @size="2x" class="text-gray-400 mb-2" />
<h3 class="text-base font-semibold text-gray-700 dark:text-gray-300">
{{t "device.prompts.no-devices-attached-title"}}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{t "device.prompts.no-devices-attached-body" resourceType=(get-model-name @resource)}}
</p>
</div>
{{/each}}
</div>
</div>
<AttachmentList
@title={{t "resource.devices"}}
@description={{t "device.prompts.manager-description" resourceType=(get-model-name @resource)}}
@actionText={{t "device.actions.attach-device"}}
@actionIcon="plus"
@onAction={{this.addDevice}}
@actionDisabled={{not @resource}}
@isLoading={{this.loadDevices.isRunning}}
@items={{this.devices}}
@emptyIcon="hard-drive"
@emptyTitle={{t "device.prompts.no-devices-attached-title"}}
@emptyDescription={{t "device.prompts.no-devices-attached-body" resourceType=(get-model-name @resource)}}
class="fleetops-device-manager"
...attributes
>
<:item as |device|>
<Device::Pill @device={{device}} @onClick={{fn this.viewDevice device}} />
{{#if device.status}}
<Badge @status={{device.status}} @size="xs" class="fleetops-attachment-list-item-badge" />
{{/if}}
</:item>
<:itemActions as |device|>
<Button @icon="eye" @size="xs" @helpText={{t "common.view"}} @onClick={{fn this.viewDevice device}} />
<Button @type="danger" @icon="unlink" @size="xs" @helpText={{t "device.attachment.detach"}} @onClick={{fn this.removeDevice device}} />
</:itemActions>
</AttachmentList>
Loading
Loading