diff --git a/RELEASE.md b/RELEASE.md
index af8b70063..820f96c41 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -1,15 +1,20 @@
-> v0.6.65 ~ "First-class trailer operations"
+> v0.6.66 ~ "Inspections"
---
## What's New
-- **Trailers become a first-class Fleet-Ops resource.** Create and manage trailers independently, attach them to vehicles, connect equipment and telematics devices, and review operational, maintenance, location, and towing details from dedicated Trailer screens.
-- **Vehicle and equipment relationships are easier to understand.** Vehicle details expose attached trailers and equipment, while Trailer details show the current towing vehicle and installed equipment.
-- **Trailer APIs support complete integrations.** Internal and public endpoints cover trailer lifecycle, filtering, towing connections, equipment, devices, telemetry, imports, and exports.
+- **Inspection forms are built from typed fields.** A form is groups of fields laid out on a grid: pass/fail checks with severity and on-fail settings, text, numbers, selections, dates, photo uploads and signatures. Forms are built and published from a form builder in the console.
+- **One inspection sheet everywhere.** The same sheet is used to fill in an inspection in the console, to read one back, and on a public link. A failed check opens its severity, unsafe flag, comment and photos in place, and a defects tray summarises what needs attention. It works on phones and tablets.
+- **Drivers file inspections through the API.** The `v1` inspection endpoints list published forms, file an inspection against one, and read back submissions and a vehicle's history, for the Navigator app and other integrations.
+- **Inspection links for anyone in the organisation.** A link can be assigned to any user, protected by a six-digit PIN, and emailed or texted to them. A failed inspection can raise an issue and open a work order, and every submission records who filed it.
+
+---
+## Fixes
+- Place, zone and service area details, and the place and point map modals, no longer fail to render with "A resolved helper cannot be passed as a named argument".
---
## Testing
-- Added frontend, backend, API-contract, attachment, telematics, spatial-data, permission, and serialization coverage for first-class trailers.
-- Expanded the Fleetbase Postman collection and documentation alongside the Fleet-Ops implementation.
+- Model and controller contract tests cover inspection forms, submissions, links and the PIN lockout.
+- The Fleetbase Postman collection documents the `v1` inspection endpoints.
---
## Need help?
diff --git a/addon/components/afaqy-integration/status.hbs b/addon/components/afaqy-integration/status.hbs
new file mode 100644
index 000000000..e08fbf0ba
--- /dev/null
+++ b/addon/components/afaqy-integration/status.hbs
@@ -0,0 +1,35 @@
+
+
Provide the secure URL to AFAQY to register Device Signals / Positions. The payload adapter is provisional until a real delivery has been verified. Polling continues
+ after registration.
+
+ {{#if this.webhookUrl}}
+
+
This URL contains a secret. Rotating it immediately invalidates the previous URL and requires registration with AFAQY again.
+
+ {{/if}}
+ {{#each this.diagnostics.recent_failures as |delivery|}}
+
+ {{#each this.colSpanOptions as |size|}}
+
+ {{/each}}
+
+
+
diff --git a/addon/components/inspection-field/form.js b/addon/components/inspection-field/form.js
new file mode 100644
index 000000000..e1215755b
--- /dev/null
+++ b/addon/components/inspection-field/form.js
@@ -0,0 +1,211 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { INSPECTION_FIELD_TYPES, INSPECTION_SEVERITIES, componentForFieldType, isOptionFieldType } from '../../utils/inspection-field-types';
+
+/**
+ * The editor for one inspection field.
+ *
+ * It is the platform's custom-field editor plus what an inspection needs: the
+ * eleven types an inspection form may be built from, and — only for
+ * `pass-fail` — the *On fail* rules the driver app enforces and the server
+ * re-checks (`InspectionSubmitter::normalizeValue()`).
+ *
+ * The field is a plain object owned by the builder's draft, not an Ember Data
+ * record: a form is laid out before the form record exists and the whole
+ * structure is written on the first save. Nothing here mutates `@field` — each
+ * change builds a new object and hands it to `@onChange`, so no write ever
+ * happens during render.
+ */
+export default class InspectionFieldFormComponent extends Component {
+ @tracked newOption = '';
+
+ fieldTypes = INSPECTION_FIELD_TYPES;
+ severityOptions = INSPECTION_SEVERITIES;
+ colSpanOptions = [1, 2, 3];
+
+ /**
+ * The field being edited, held locally so an edit re-renders.
+ *
+ * Invoked directly by the builder the field arrives as `@field`; rendered
+ * inside the resource context panel it arrives on the overlay
+ * definition's shared `state`, which is a plain object — mutating it
+ * would never re-render, so the component owns a tracked copy and writes
+ * through on every change. That shared handle is what the builder reads
+ * back when the author saves.
+ */
+ @tracked localField = this.args.field ?? this.args.overlay?.state?.field ?? {};
+
+ get field() {
+ return this.localField;
+ }
+
+ get isDisabled() {
+ return this.args.disabled ?? this.args.overlay?.disabled ?? false;
+ }
+
+ get meta() {
+ const meta = this.field.meta;
+ return meta && typeof meta === 'object' ? meta : {};
+ }
+
+ get isPassFail() {
+ return this.field.type === 'pass-fail';
+ }
+
+ get isNumber() {
+ return this.field.type === 'number';
+ }
+
+ get hasOptions() {
+ return isOptionFieldType(this.field.type);
+ }
+
+ get options() {
+ return Array.isArray(this.field.options) ? this.field.options : [];
+ }
+
+ get isOdometer() {
+ return this.meta.role === 'odometer';
+ }
+
+ /** Every change to the field goes through here, and only from an action. */
+ change(attributes) {
+ const next = { ...this.field, ...attributes };
+ this.localField = next;
+
+ if (this.args.overlay?.state) {
+ this.args.overlay.state.field = next;
+ }
+
+ if (typeof this.args.onChange === 'function') {
+ this.args.onChange(next);
+ }
+ }
+
+ changeMeta(attributes) {
+ this.change({ meta: { ...this.meta, ...attributes } });
+ }
+
+ /**
+ * The name a label derives to. The label names the field; this machine
+ * name follows it until the author types one of their own.
+ *
+ * Not `dasherize`: it only rewrites spaces and underscores, so a label
+ * like "Sidewall condition, offside rear" kept its comma and produced
+ * `sidewall-condition,-offside-rear`. The name is an identifier — it
+ * travels as an item result's `item_key` and is what a report groups on —
+ * so anything that is not a letter or a digit becomes a separator, and
+ * runs of separators collapse.
+ */
+ slugify(value) {
+ return String(value ?? '')
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+ }
+
+ @action setLabel(event) {
+ const label = event.target.value;
+ const derived = this.slugify(this.field.label);
+ const current = (this.field.name ?? '').trim();
+
+ // The name follows the label until an author types their own.
+ const follows = current === '' || current === derived;
+
+ this.change({
+ label,
+ name: follows ? this.slugify(label) : current,
+ });
+ }
+
+ @action setName(event) {
+ this.change({ name: this.slugify(event.target.value) });
+ }
+
+ @action setDescription(event) {
+ this.change({ description: event.target.value });
+ }
+
+ @action setHelpText(event) {
+ this.change({ help_text: event.target.value });
+ }
+
+ @action setType(event) {
+ const type = event.target.value;
+ const attributes = { type, component: componentForFieldType(type) };
+
+ // A field that has just become pass-fail needs the defaults its rules
+ // are read from; one that has stopped being pass-fail keeps its meta,
+ // because the author may be switching back.
+ if (type === 'pass-fail' && this.meta.severity === undefined) {
+ attributes.meta = { ...this.meta, severity: 'medium', require_photo_on_fail: false, require_comment_on_fail: false, unsafe_on_fail: false };
+ }
+
+ this.change(attributes);
+ }
+
+ @action setRequired(required) {
+ this.change({ required: Boolean(required) });
+ }
+
+ @action setEditable(editable) {
+ this.change({ editable: Boolean(editable) });
+ }
+
+ @action setColSpan(colSpan) {
+ this.changeMeta({ colSpan });
+ }
+
+ @action setUnit(event) {
+ this.changeMeta({ unit: event.target.value });
+ }
+
+ @action toggleOdometerRole(isOdometer) {
+ this.changeMeta({ role: isOdometer ? 'odometer' : null });
+ }
+
+ @action setSeverity(severity) {
+ this.changeMeta({ severity });
+ }
+
+ @action setRequirePhotoOnFail(value) {
+ this.changeMeta({ require_photo_on_fail: Boolean(value) });
+ }
+
+ @action setRequireCommentOnFail(value) {
+ this.changeMeta({ require_comment_on_fail: Boolean(value) });
+ }
+
+ @action setUnsafeOnFail(value) {
+ this.changeMeta({ unsafe_on_fail: Boolean(value) });
+ }
+
+ @action setInstructions(event) {
+ this.changeMeta({ instructions: event.target.value });
+ }
+
+ @action setNewOption(event) {
+ this.newOption = event.target.value;
+ }
+
+ @action addOption() {
+ const option = this.newOption.trim();
+ if (option === '') {
+ return;
+ }
+
+ this.newOption = '';
+ this.change({ options: [...this.options, option] });
+ }
+
+ @action updateOption(index, event) {
+ const value = event.target.value;
+ this.change({ options: this.options.map((option, optionIndex) => (optionIndex === index ? value : option)) });
+ }
+
+ @action removeOption(index) {
+ this.change({ options: this.options.filter((_, optionIndex) => optionIndex !== index) });
+ }
+}
diff --git a/addon/components/inspection-field/input.hbs b/addon/components/inspection-field/input.hbs
new file mode 100644
index 000000000..e5fcc810c
--- /dev/null
+++ b/addon/components/inspection-field/input.hbs
@@ -0,0 +1,270 @@
+{{!
+ One field of an inspection, being answered.
+
+ A note, an upload or a signature is a full-width band, because the form
+ decides its size. Everything else is a cell in its group's grid, label
+ above control, and stays that size whatever is answered.
+
+ A failed check keeps its cell. Its detail — severity, unsafe, comment,
+ photos — opens in a flyout anchored to the cell, and a chip left in the
+ cell summarises it once the flyout is closed. Answering a field can
+ therefore never change the layout of the sheet.
+}}
+{{#if this.isRoomy}}
+
+{{/if}}
diff --git a/addon/components/inspection-field/input.js b/addon/components/inspection-field/input.js
new file mode 100644
index 000000000..36eb727c7
--- /dev/null
+++ b/addon/components/inspection-field/input.js
@@ -0,0 +1,472 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { INSPECTION_SEVERITIES } from '../../utils/inspection-field-types';
+import { answerState, isUnsafeAnswer, isBlank, defectSummary, ROOMY_FIELD_TYPES } from '../../utils/inspection-answers';
+
+/** The date-ish types that are still one compact control. */
+const INPUT_TYPES = { 'date-picker': 'date', 'date-time-input': 'datetime-local' };
+
+const PASS_FAIL_DEFAULT = { passed: true, not_applicable: false, severity: null, comments: '', photos: [], unsafe: false };
+
+/**
+ * One inspection field, being answered.
+ *
+ * Every one of the eleven field types is rendered here rather than some being
+ * handed to the platform's `custom-field/input`. Delegating made the sheet
+ * read as two different forms interleaved — its own label chrome, its own
+ * spacing, its own idea of what a control looks like — and an inspection is
+ * one list that an inspector reads straight down. Owning them all is what
+ * makes every row the same shape.
+ *
+ * The component owns no copy of the answer. `@value` in, `@onChange` out —
+ * the answering screen holds the values, so nothing is written during render.
+ */
+export default class InspectionFieldInputComponent extends Component {
+ @service fetch;
+ @service intl;
+ @tracked uploadProgress = null;
+
+ /** Freshly uploaded files, so a photo can be shown before it is saved. */
+ @tracked previews = {};
+
+ get field() {
+ return this.args.field ?? {};
+ }
+
+ get meta() {
+ const meta = this.field.meta;
+ return meta && typeof meta === 'object' ? meta : {};
+ }
+
+ /** A failed check, which owes a severity, and maybe a comment and photos. */
+ get isDefect() {
+ return this.field.type === 'pass-fail' && this.answerState === 'fail';
+ }
+
+ /** This field's flyout is the one open on the sheet — only ever one is. */
+ get isFlyoutOpen() {
+ return this.isDefect && Boolean(this.field.uuid) && this.args.openFieldId === this.field.uuid;
+ }
+
+ /** What the failure has recorded, for the chip it leaves in the cell. */
+ get defect() {
+ return defectSummary(this.field, this.args.value);
+ }
+
+ get severityLabel() {
+ const severity = this.defect.severity;
+
+ if (!severity) {
+ return null;
+ }
+
+ return INSPECTION_SEVERITIES.includes(severity) ? this.intl.t(`inspection.severity.${severity}`) : severity;
+ }
+
+ /** What a closed failure still owes, said on its chip in amber. */
+ get defectStatus() {
+ const { needsComment, needsPhoto } = this.defect;
+
+ if (needsComment && needsPhoto) {
+ return this.intl.t('inspection.defect.needs-both');
+ }
+
+ if (needsComment) {
+ return this.intl.t('inspection.defect.needs-comment');
+ }
+
+ return needsPhoto ? this.intl.t('inspection.defect.needs-photo') : null;
+ }
+
+ get flyoutTitle() {
+ return this.intl.t('inspection.flyout.title', { label: this.label });
+ }
+
+ /** A note, an upload or a signature — never a column, whatever the answer. */
+ get isRoomy() {
+ return ROOMY_FIELD_TYPES.includes(this.field.type);
+ }
+
+ /** A note puts its control under the label; a file puts it beside. */
+ get isStackedBand() {
+ return this.field.type === 'textarea';
+ }
+
+ get isTargeted() {
+ return Boolean(this.field.uuid) && this.args.targetId === this.field.uuid;
+ }
+
+ /** A required answer still missing, which its own edge says in amber. */
+ get isOutstanding() {
+ return Boolean(this.field.required) && isBlank(this.field, this.args.value);
+ }
+
+ get passFailOptions() {
+ return [
+ { value: 'pass', label: this.intl.t('inspection.answer.pass') },
+ { value: 'fail', label: this.intl.t('inspection.answer.fail') },
+ { value: 'na', label: this.intl.t('inspection.answer.not-applicable') },
+ ];
+ }
+
+ get severityOptions() {
+ return INSPECTION_SEVERITIES.map((severity) => ({
+ value: severity,
+ label: this.intl.t(`inspection.severity.${severity}`),
+ }));
+ }
+
+ get inputType() {
+ return INPUT_TYPES[this.field.type] ?? 'text';
+ }
+
+ get fileIcon() {
+ return this.field.type === 'signature' ? 'signature' : 'upload';
+ }
+
+ get uploadLabel() {
+ return this.field.type === 'signature' ? this.intl.t('inspection.answer.upload-signature') : this.intl.t('inspection.answer.upload-photo');
+ }
+
+ get emptyFileNote() {
+ return this.field.type === 'signature' ? this.intl.t('inspection.answer.no-signature') : this.intl.t('inspection.answer.no-photo');
+ }
+
+ /** What this failure still owes, said once beside the photo slots. */
+ get defectRequirement() {
+ if (this.requiresComment && this.requiresPhoto) {
+ return this.intl.t('inspection.answer.comment-and-photo-required');
+ }
+
+ if (this.requiresPhoto) {
+ return this.intl.t('inspection.answer.photo-required');
+ }
+
+ return this.requiresComment ? this.intl.t('inspection.answer.comment-required') : null;
+ }
+
+ /** A field with no label still needs something to click on. */
+ get label() {
+ return this.field.label || this.field.name || this.intl.t('inspection.builder.untitled-field');
+ }
+
+ get instructions() {
+ return this.meta.instructions ?? null;
+ }
+
+ get unit() {
+ return this.meta.unit ?? null;
+ }
+
+ /** What this row currently says, for the row's own `data-answer`. */
+ get answerState() {
+ return answerState(this.field, this.args.value);
+ }
+
+ /**
+ * What an empty control should suggest. An author can write their own; a
+ * number otherwise shows a zero rather than nothing at all, which is what
+ * an inspector reaches for on a tread depth or a pressure.
+ */
+ get placeholder() {
+ if (this.meta.placeholder) {
+ return this.meta.placeholder;
+ }
+
+ switch (this.field.type) {
+ case 'number':
+ return '0';
+ case 'select':
+ return this.intl.t('inspection.answer.select-placeholder');
+ case 'textarea':
+ return this.intl.t('inspection.answer.note-placeholder');
+ default:
+ return this.intl.t('inspection.answer.text-placeholder');
+ }
+ }
+
+ /**
+ * Whether this row may offer an upload.
+ *
+ * A public link runs unauthenticated, and the file endpoint the uploader
+ * posts to does not. So a link renders the field and says the photo has
+ * to come from the console or the driver app, rather than showing a
+ * button that can only fail.
+ */
+ get canUpload() {
+ return this.args.allowUploads !== false && !this.args.disabled;
+ }
+
+ get uploadsBlocked() {
+ return this.args.allowUploads === false;
+ }
+
+ /** The answers a `select` or `radio-button` field offers. */
+ get choiceOptions() {
+ const options = this.field.options;
+ return Array.isArray(options) && options.length ? options : null;
+ }
+
+ // ---------- pass-fail ----------
+
+ get answer() {
+ const value = this.args.value;
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ return { ...PASS_FAIL_DEFAULT, ...value, photos: Array.isArray(value.photos) ? value.photos : [] };
+ }
+
+ if (typeof value === 'boolean') {
+ return { ...PASS_FAIL_DEFAULT, passed: value };
+ }
+
+ return { ...PASS_FAIL_DEFAULT };
+ }
+
+ get severity() {
+ return this.answer.severity ?? this.meta.severity ?? 'medium';
+ }
+
+ get isUnsafe() {
+ return isUnsafeAnswer(this.field, this.args.value);
+ }
+
+ get comments() {
+ return this.answer.comments ?? '';
+ }
+
+ /** The photos on a failed pass-fail answer, ready to render. */
+ get answerPhotos() {
+ return this.answer.photos.map((photo) => this.#describeFile(photo));
+ }
+
+ get requiresComment() {
+ return this.isDefect && this.meta.require_comment_on_fail === true;
+ }
+
+ get requiresPhoto() {
+ return this.isDefect && this.meta.require_photo_on_fail === true;
+ }
+
+ // ---------- file / signature ----------
+
+ get file() {
+ const value = this.args.value;
+ if (!value) {
+ return null;
+ }
+
+ return this.#describeFile(value);
+ }
+
+ get booleanValue() {
+ const value = this.args.value;
+ if (typeof value === 'boolean') {
+ return value;
+ }
+
+ return value === 'true' || value === 1 || value === '1';
+ }
+
+ // ---------- actions ----------
+
+ emit(value) {
+ if (typeof this.args.onChange === 'function') {
+ this.args.onChange(value, this.field);
+ }
+ }
+
+ @action setText(event) {
+ this.emit(event.target.value);
+ }
+
+ @action setNumber(event) {
+ const value = event.target.value;
+ this.emit(value === '' ? null : Number(value));
+ }
+
+ @action setBoolean(value) {
+ this.emit(Boolean(value));
+ }
+
+ @action setChoice(option) {
+ this.emit(option ?? null);
+ }
+
+ /**
+ * One of pass, fail or n/a. Failing seeds the severity and the unsafe flag
+ * from what the field's author set as its default, so the common case is
+ * already answered; passing or marking n/a clears both, because a check
+ * that did not fail cannot carry a severity.
+ */
+ @action setPassFail(choice) {
+ if (choice === 'fail') {
+ this.emit({
+ ...this.answer,
+ passed: false,
+ not_applicable: false,
+ severity: this.answer.severity ?? this.meta.severity ?? 'medium',
+ unsafe: this.answer.unsafe ?? Boolean(this.meta.unsafe_on_fail),
+ });
+
+ // Choosing Fail already means "record a defect": no second click.
+ this.openFlyout();
+
+ return;
+ }
+
+ // The comment and photos survive a switch away, so an accidental Pass
+ // followed by Fail again brings them back.
+ this.emit({
+ ...this.answer,
+ passed: true,
+ not_applicable: choice === 'na',
+ severity: null,
+ unsafe: false,
+ });
+
+ if (typeof this.args.onCloseFlyout === 'function') {
+ this.args.onCloseFlyout(this.field);
+ }
+ }
+
+ @action openFlyout() {
+ if (typeof this.args.onOpenFlyout === 'function') {
+ this.args.onOpenFlyout(this.field);
+ }
+ }
+
+ /**
+ * Close this field's flyout. Focus goes back to its Fail button when the
+ * inspector closed it themselves, but not when they pressed somewhere
+ * else on the page — their attention is already there.
+ */
+ @action closeFlyout(reason) {
+ if (typeof this.args.onCloseFlyout === 'function') {
+ this.args.onCloseFlyout(this.field);
+ }
+
+ if (reason === 'outside') {
+ return;
+ }
+
+ document.querySelector(`#inspection-field-${this.field.uuid} [data-answer="fail"]`)?.focus({ preventScroll: true });
+ }
+
+ @action setSeverity(severity) {
+ this.emit({ ...this.answer, severity });
+ }
+
+ @action setUnsafe(unsafe) {
+ this.emit({ ...this.answer, unsafe: Boolean(unsafe) });
+ }
+
+ @action setComments(event) {
+ this.emit({ ...this.answer, comments: event.target.value });
+ }
+
+ @action removePhoto(index) {
+ this.emit({ ...this.answer, photos: this.answer.photos.filter((_, photoIndex) => photoIndex !== index) });
+ }
+
+ @action clearFile() {
+ this.emit(null);
+ }
+
+ /**
+ * A photo or signature picked in the console is uploaded straight away and
+ * the answer keeps `file:`, the platform's own convention. The
+ * server claims any file referenced this way when the submission is saved
+ * (`InspectionFileStore::attachReferenced`).
+ */
+ @action addPhoto(file) {
+ return this.#upload(file, 'inspection_photo', (uploaded) => {
+ this.emit({ ...this.answer, photos: [...this.answer.photos, `file:${uploaded.id}`] });
+ });
+ }
+
+ @action setFile(file) {
+ const type = this.field.type === 'signature' ? 'inspection_signature' : 'inspection_photo';
+
+ return this.#upload(file, type, (uploaded) => {
+ this.emit(`file:${uploaded.id}`);
+ });
+ }
+
+ #upload(file, type, onUploaded) {
+ if (['queued', 'failed', 'timed_out', 'aborted'].indexOf(file.state) === -1) {
+ return;
+ }
+
+ this.uploadProgress = file;
+
+ const done = (uploaded) => {
+ this.uploadProgress = null;
+ this.previews = { ...this.previews, [`file:${uploaded.id}`]: { url: uploaded.url, filename: uploaded.original_filename ?? uploaded.filename } };
+ onUploaded(uploaded);
+ };
+
+ const failed = () => {
+ this.uploadProgress = null;
+
+ if (file.queue && typeof file.queue.remove === 'function') {
+ file.queue.remove(file);
+ }
+ };
+
+ // A public link has no session to upload with, so it hands in an
+ // uploader of its own that posts through the link's token. It answers
+ // in the same shape, so nothing after this point knows the difference.
+ if (typeof this.args.uploader === 'function') {
+ return Promise.resolve()
+ .then(() => this.args.uploader(file, type))
+ .then(done, failed);
+ }
+
+ return this.fetch.uploadFile.perform(
+ file,
+ {
+ path: `uploads/inspections/${this.field.uuid ?? 'field'}`,
+ type,
+ ...this.#subjectParams(),
+ },
+ done,
+ failed
+ );
+ }
+
+ #subjectParams() {
+ const subject = this.args.subject;
+ const subjectUuid = subject?.uuid ?? subject?.id;
+ if (!subjectUuid || subject.isNew) {
+ return {};
+ }
+
+ return { subject_uuid: subjectUuid, subject_type: 'fleet-ops:inspection-submission' };
+ }
+
+ /**
+ * A file value in one shape, whichever way it arrived: a `file:`
+ * reference just uploaded here, or the `{ id, url, filename }` the
+ * submission resource resolves a stored reference to.
+ */
+ #describeFile(value) {
+ if (value && typeof value === 'object') {
+ return { reference: value.id, url: value.url, filename: value.filename, contentType: value.content_type };
+ }
+
+ if (typeof value !== 'string' || value === '') {
+ return { reference: null, url: null, filename: null, contentType: null };
+ }
+
+ const preview = this.previews[value];
+
+ return {
+ reference: value,
+ url: preview?.url ?? (value.startsWith('http') ? value : null),
+ filename: preview?.filename ?? null,
+ contentType: null,
+ };
+ }
+}
diff --git a/addon/components/inspection-field/value.hbs b/addon/components/inspection-field/value.hbs
new file mode 100644
index 000000000..66ad416d7
--- /dev/null
+++ b/addon/components/inspection-field/value.hbs
@@ -0,0 +1,90 @@
+{{!
+ One stored answer, read-only.
+
+ Every answer keeps the column the form gave it: a pass or a fail is a
+ coloured card in its cell, and only a note, an upload or a signature —
+ which need the room — spans the grid.
+}}
+{{#if this.isResult}}
+ {{! Pass, fail and N/A share one card, coloured by the answer, sitting in
+ the column the form gave it. A fail carries its severity, its unsafe
+ flag, the comment and the photos beneath the head. }}
+
+{{/if}}
diff --git a/addon/components/inspection-field/value.js b/addon/components/inspection-field/value.js
new file mode 100644
index 000000000..b1790a2f4
--- /dev/null
+++ b/addon/components/inspection-field/value.js
@@ -0,0 +1,158 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { INSPECTION_SEVERITIES } from '../../utils/inspection-field-types';
+import { answerState, ROOMY_FIELD_TYPES } from '../../utils/inspection-answers';
+
+/**
+ * One stored answer, read-only — what the record's Overview shows.
+ *
+ * The submission resource hands the console a value already projected: a file
+ * value resolved to `{ id, url, filename, content_type }`, and a pass-fail
+ * answer as an object with its photos resolved the same way. This renders
+ * that, whichever shape the value arrived in; it never fetches and never
+ * writes.
+ */
+export default class InspectionFieldValueComponent extends Component {
+ @service intl;
+
+ get field() {
+ return this.args.field ?? {};
+ }
+
+ get meta() {
+ const meta = this.field.meta;
+ return meta && typeof meta === 'object' ? meta : {};
+ }
+
+ get label() {
+ return this.field.label || this.field.name || this.intl.t('inspection.builder.untitled-field');
+ }
+
+ get answerState() {
+ return answerState(this.field, this.args.value);
+ }
+
+ /** Pass, fail and N/A all read as the same card. */
+ get isResult() {
+ return this.isPassFail;
+ }
+
+ /** A fail shows what it was marked with; a pass has nothing to add. */
+ get hasResultMeta() {
+ return this.answerState === 'fail' && Boolean(this.severityLabel || this.answer.unsafe);
+ }
+
+ get hasResultDetail() {
+ return this.hasResultMeta || Boolean(this.answer.comments) || this.photos.length > 0;
+ }
+
+ get isDefect() {
+ return this.isPassFail && this.answerState === 'fail';
+ }
+
+ /** Whether anything was actually answered, so an empty one can say so. */
+ get isAnswered() {
+ const value = this.args.value;
+
+ return value !== null && value !== undefined && String(value).trim() !== '';
+ }
+
+ get isRoomy() {
+ return ROOMY_FIELD_TYPES.includes(this.field.type);
+ }
+
+ get isStackedBand() {
+ return this.field.type === 'textarea';
+ }
+
+ get isTargeted() {
+ return Boolean(this.field.uuid) && this.args.targetId === this.field.uuid;
+ }
+
+ /** A failure's comment and photos, shown only when there is something to show. */
+ get hasDefectDetail() {
+ return this.isPassFail && (Boolean(this.answer.comments) || this.photos.length > 0);
+ }
+
+ get isPassFail() {
+ return this.field.type === 'pass-fail';
+ }
+
+ get isFile() {
+ return this.field.type === 'file-upload' || this.field.type === 'signature';
+ }
+
+ get isBoolean() {
+ return this.field.type === 'boolean';
+ }
+
+ get answer() {
+ const value = this.args.value;
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ return { ...value, photos: Array.isArray(value.photos) ? value.photos : [] };
+ }
+
+ if (typeof value === 'boolean') {
+ return { passed: value, not_applicable: false, photos: [] };
+ }
+
+ return { passed: null, not_applicable: false, photos: [] };
+ }
+
+ get resultLabel() {
+ const answer = this.answer;
+ if (answer.not_applicable === true) {
+ return 'inspection.answer.not-applicable';
+ }
+
+ if (answer.passed === false) {
+ return 'inspection.answer.fail';
+ }
+
+ if (answer.passed === true) {
+ return 'inspection.answer.pass';
+ }
+
+ return 'inspection.answer.unanswered';
+ }
+
+ get resultStatus() {
+ const answer = this.answer;
+ if (answer.not_applicable === true) {
+ return 'info';
+ }
+
+ return answer.passed === false ? 'failed' : 'passed';
+ }
+
+ /** The severity's own label, or the raw value when it is not one of ours. */
+ get severityLabel() {
+ const severity = this.answer.severity;
+ return INSPECTION_SEVERITIES.includes(severity) ? `inspection.severity.${severity}` : null;
+ }
+
+ get photos() {
+ return this.answer.photos.map((photo) => this.#describeFile(photo));
+ }
+
+ get file() {
+ return this.#describeFile(this.args.value);
+ }
+
+ get booleanValue() {
+ const value = this.args.value;
+ return value === true || value === 'true' || value === 1 || value === '1';
+ }
+
+ #describeFile(value) {
+ if (value && typeof value === 'object') {
+ return { reference: value.id, url: value.url, filename: value.filename };
+ }
+
+ if (typeof value !== 'string' || value === '') {
+ return { reference: null, url: null, filename: null };
+ }
+
+ return { reference: value, url: value.startsWith('http') ? value : null, filename: null };
+ }
+}
diff --git a/addon/components/inspection-flyout.hbs b/addon/components/inspection-flyout.hbs
new file mode 100644
index 000000000..b525a2d26
--- /dev/null
+++ b/addon/components/inspection-flyout.hbs
@@ -0,0 +1,34 @@
+{{#if this.mount}}
+ {{#in-element this.mount insertBefore=null}}
+ {{#if this.isSheet}}
+
+ {{/if}}
+
+ {{#unless this.isSheet}}
+
+ {{/unless}}
+
+ {{@title}}
+
+
+
+ {{yield}}
+
+
+ {{#if @note}}
+ {{@note}}
+ {{/if}}
+
+
+
+ {{/in-element}}
+{{/if}}
diff --git a/addon/components/inspection-flyout.js b/addon/components/inspection-flyout.js
new file mode 100644
index 000000000..ea8b4aeb7
--- /dev/null
+++ b/addon/components/inspection-flyout.js
@@ -0,0 +1,66 @@
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+
+/**
+ * Below this sheet width a floating panel would crowd the page and fight the
+ * on-screen keyboard, so the same content slides up as a bottom sheet.
+ */
+const SHEET_BREAKPOINT = 520;
+
+/**
+ * The panel a failed check opens, anchored to its field.
+ *
+ * It renders outside the field so the layout never changes when a check
+ * fails. Floating, it lives in the sheet's own flyout layer, so it scrolls
+ * with its field and is never clipped by the sheet's rounded card. As a bottom
+ * sheet on a phone it lives in the application's root wormhole instead,
+ * because a fixed panel inside a container-query element would be pinned to
+ * that element rather than to the screen.
+ *
+ * It is non-modal and traps nothing. It closes on Done, on its close button,
+ * on Escape, and on a press anywhere outside it and its field — and closing is
+ * always safe, because every answer inside it is saved as it is typed.
+ */
+export default class InspectionFlyoutComponent extends Component {
+ /** Decided once, when it opens: a panel should not change shape under the user. */
+ presentation = this.measurePresentation();
+
+ get anchor() {
+ return document.getElementById(`inspection-field-${this.args.fieldId}`);
+ }
+
+ get isSheet() {
+ return this.presentation === 'sheet';
+ }
+
+ get mount() {
+ if (this.isSheet) {
+ return document.getElementById('application-root-wormhole') ?? document.body;
+ }
+
+ return this.anchor?.closest('.inspection-sheet')?.querySelector(':scope > .inspection-sheet__flyouts') ?? null;
+ }
+
+ /**
+ * Where focus lands on opening. Floating, it is the comment — usually the
+ * first thing a failure still owes. On a phone it is the panel itself:
+ * focusing the comment would throw the keyboard up over the sheet before
+ * the inspector has read it.
+ */
+ get focusTarget() {
+ return this.isSheet ? true : '[data-flyout-focus]';
+ }
+
+ measurePresentation() {
+ const anchor = document.getElementById(`inspection-field-${this.args.fieldId}`);
+ const width = anchor?.closest('.inspection-sheet')?.clientWidth ?? window.innerWidth;
+
+ return width < SHEET_BREAKPOINT ? 'sheet' : 'floating';
+ }
+
+ @action dismiss(reason) {
+ if (typeof this.args.onClose === 'function') {
+ this.args.onClose(reason);
+ }
+ }
+}
diff --git a/addon/components/inspection-form/builder.hbs b/addon/components/inspection-form/builder.hbs
new file mode 100644
index 000000000..bd89035f1
--- /dev/null
+++ b/addon/components/inspection-form/builder.hbs
@@ -0,0 +1,88 @@
+
+
+
{{t "inspection.builder.help"}}
+
+
+
+ {{#if this.load.isRunning}}
+
+
+
+ {{else}}
+
+ {{#each this.groups key="uuid" as |group groupIndex|}}
+
+
+ {{#each group.fields key="uuid" as |field fieldIndex|}}
+ {{!
+ The name reads first, on its own line. Sharing a
+ row with four buttons left every label truncated
+ to a few characters, which is unusable for
+ keeping track of what is what while building.
+ }}
+
diff --git a/addon/components/inspection-form/builder.js b/addon/components/inspection-form/builder.js
new file mode 100644
index 000000000..efc67c64b
--- /dev/null
+++ b/addon/components/inspection-form/builder.js
@@ -0,0 +1,211 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { next } from '@ember/runloop';
+import { task } from 'ember-concurrency';
+import inlineTask from '@fleetbase/ember-core/utils/inline-task';
+import { createField, createFieldGroup } from '../../utils/inspection-form-structure';
+
+/**
+ * The form builder: field groups, each with a grid size and its own typed
+ * fields.
+ *
+ * A form is laid out before the form record exists, so the structure is a
+ * draft of plain objects rather than Ember Data records — the `inspection-form`
+ * model belongs to `@fleetbase/fleetops-data` and declares no attribute for it.
+ * The controller posts it with the save, and `InspectionFormSync` writes it in
+ * one go.
+ *
+ * **The draft lives on the controller, not here.** `ContentPanel` unrenders its
+ * body when it is collapsed, so this component is destroyed and rebuilt every
+ * time the author folds the builder away; state held here went with it and the
+ * form came back empty. So the component is controlled: it renders `@groups`
+ * and reports every change through `@onChange`, and owns nothing that a
+ * collapse can take.
+ *
+ * Nothing here mutates a group or a field in place. Every change builds new
+ * objects and assigns them from an action — never during render.
+ */
+export default class InspectionFormBuilderComponent extends Component {
+ @service inspectionFormActions;
+ @service modalsManager;
+ @service resourceContextPanel;
+ @service notifications;
+ @service intl;
+
+ gridSizeOptions = [1, 2, 3];
+
+ /** The draft, owned by the controller so it survives a panel collapse. */
+ get groups() {
+ return Array.isArray(this.args.groups) ? this.args.groups : [];
+ }
+
+ constructor() {
+ super(...arguments);
+ next(() => {
+ if (this.isDestroying || this.isDestroyed) {
+ return;
+ }
+
+ this.load.perform();
+ });
+ }
+
+ get isDraft() {
+ const resource = this.args.resource;
+ return !resource?.id || resource?.isNew === true;
+ }
+
+ @task *load() {
+ // A form that does not exist yet has nothing to read back.
+ if (this.isDraft) {
+ return;
+ }
+
+ // Already held by the controller — either loaded once before, or
+ // carrying edits the author has not saved. Re-reading here would
+ // throw those away every time the panel was reopened.
+ if (Array.isArray(this.args.groups)) {
+ return;
+ }
+
+ try {
+ this.write(yield this.inspectionFormActions.loadStructure(this.args.resource));
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ /** The one place the draft is announced. The controller stores it. */
+ write(groups) {
+ if (typeof this.args.onChange === 'function') {
+ this.args.onChange(groups);
+ }
+ }
+
+ replaceGroup(uuid, attributes) {
+ this.write(this.groups.map((group) => (group.uuid === uuid ? { ...group, ...attributes } : group)));
+ }
+
+ /**
+ * Paints an input's starting value without binding it.
+ *
+ * The iteration is keyed, so the node survives an edit — but a bound
+ * `value` is rewritten on every render, and assigning to `value` mid-word
+ * moves the caret to the end. Setting it once on insert leaves the DOM to
+ * own the text, and `input` reports each change back.
+ */
+ @action setInitialValue(value, element) {
+ element.value = value ?? '';
+ }
+
+ @action addGroup() {
+ this.write([...this.groups, createFieldGroup({ name: this.intl.t('inspection.builder.untitled-group'), order: this.groups.length + 1 })]);
+ }
+
+ @action setGroupName(uuid, event) {
+ this.replaceGroup(uuid, { name: event.target.value });
+ }
+
+ @action setGroupDescription(uuid, event) {
+ this.replaceGroup(uuid, { description: event.target.value });
+ }
+
+ @action setGridSize(group, size) {
+ this.replaceGroup(group.uuid, { meta: { ...(group.meta ?? {}), grid_size: size } });
+ }
+
+ @action moveGroup(index, offset) {
+ const target = index + offset;
+ if (target < 0 || target >= this.groups.length) {
+ return;
+ }
+
+ const groups = [...this.groups];
+ const [moved] = groups.splice(index, 1);
+ groups.splice(target, 0, moved);
+ this.write(groups);
+ }
+
+ @action deleteGroup(group) {
+ this.modalsManager.confirm({
+ title: this.intl.t('inspection.builder.delete-group-title'),
+ body: this.intl.t('inspection.builder.delete-group-body'),
+ acceptButtonText: this.intl.t('inspection.builder.delete'),
+ acceptButtonType: 'danger',
+ confirm: (modal) => {
+ this.write(this.groups.filter((candidate) => candidate.uuid !== group.uuid));
+ modal.done();
+ },
+ });
+ }
+
+ @action addField(group) {
+ this.editField(group, createField('pass-fail', { label: this.intl.t('inspection.builder.untitled-field'), order: (group.fields?.length ?? 0) + 1 }), true);
+ }
+
+ /**
+ * The field editor opens as a right-side overlay over the form's own
+ * panel rather than as a modal: a modal covers the form the author is
+ * building, and the two are read together. `xs` keeps it narrower than
+ * the form panel behind it, so the form stays visible alongside.
+ *
+ * The field is a plain object in the builder's draft, so there is nothing
+ * for the panel's default save to persist — `state` is the handle both
+ * sides hold, and the inline task applies whatever the editor last
+ * produced when the author saves.
+ */
+ @action editField(group, field, isNew = false) {
+ const state = { field };
+
+ this.resourceContextPanel.open({
+ content: 'inspection-field/form',
+ title: isNew ? this.intl.t('inspection.builder.new-field') : this.intl.t('inspection.builder.edit-field', { label: field.label }),
+ size: 'xs',
+ panelContentClass: 'py-2 px-4',
+ // The field is a plain object, not an Ember Data record. Without
+ // this the header's save falls through to `cannot-write` on a
+ // resource it cannot resolve, which denies by default and leaves
+ // the button disabled for good.
+ pojoResource: true,
+ state,
+ disabled: this.args.disabled,
+ saveTask: inlineTask((resource, { overlay } = {}) => {
+ this.applyField(group, state.field, isNew);
+ this.resourceContextPanel.close(overlay?.id);
+ }),
+ });
+ }
+
+ applyField(group, field, isNew) {
+ const fields = group.fields ?? [];
+ const nextFields = isNew ? [...fields, field] : fields.map((candidate) => (candidate.uuid === field.uuid ? field : candidate));
+
+ this.replaceGroup(group.uuid, { fields: nextFields });
+ }
+
+ @action moveField(group, index, offset) {
+ const fields = [...(group.fields ?? [])];
+ const target = index + offset;
+ if (target < 0 || target >= fields.length) {
+ return;
+ }
+
+ const [moved] = fields.splice(index, 1);
+ fields.splice(target, 0, moved);
+ this.replaceGroup(group.uuid, { fields });
+ }
+
+ @action deleteField(group, field) {
+ this.modalsManager.confirm({
+ title: this.intl.t('inspection.builder.delete-field-title'),
+ body: this.intl.t('inspection.builder.delete-field-body'),
+ acceptButtonText: this.intl.t('inspection.builder.delete'),
+ acceptButtonType: 'danger',
+ confirm: (modal) => {
+ this.replaceGroup(group.uuid, { fields: (group.fields ?? []).filter((candidate) => candidate.uuid !== field.uuid) });
+ modal.done();
+ },
+ });
+ }
+}
diff --git a/addon/components/inspection-form/details.hbs b/addon/components/inspection-form/details.hbs
new file mode 100644
index 000000000..7574a80d3
--- /dev/null
+++ b/addon/components/inspection-form/details.hbs
@@ -0,0 +1,97 @@
+
diff --git a/addon/components/inspection-form/details.js b/addon/components/inspection-form/details.js
new file mode 100644
index 000000000..ac044629b
--- /dev/null
+++ b/addon/components/inspection-form/details.js
@@ -0,0 +1,57 @@
+import Component from '@glimmer/component';
+import { INSPECTION_SEVERITIES } from '../../utils/inspection-field-types';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { debug } from '@ember/debug';
+import { next } from '@ember/runloop';
+import { task } from 'ember-concurrency';
+
+/**
+ * An inspection form, read-only: what it is, and the groups of fields it is
+ * built from, in the order a driver answers them.
+ */
+export default class InspectionFormDetailsComponent extends Component {
+ @service inspectionFormActions;
+
+ @tracked groups = [];
+
+ constructor() {
+ super(...arguments);
+ next(() => {
+ if (this.isDestroying || this.isDestroyed) {
+ return;
+ }
+
+ this.load.perform();
+ });
+ }
+
+ /**
+ * The four severities that have a label of their own, as a lookup. A field
+ * converted from a hand-written first-cut item can carry anything, and
+ * asking for a translation of that would put "Missing translation" on the
+ * screen.
+ */
+ severityLabels = INSPECTION_SEVERITIES.reduce((carry, severity) => ({ ...carry, [severity]: `inspection.severity.${severity}` }), {});
+
+ get fieldCount() {
+ return this.groups.reduce((count, group) => count + (group.fields?.length ?? 0), 0);
+ }
+
+ get legacyItems() {
+ const items = this.args.resource?.items;
+ return Array.isArray(items) ? items : [];
+ }
+
+ @task *load() {
+ if (!this.args.resource?.id) {
+ return;
+ }
+
+ try {
+ this.groups = yield this.inspectionFormActions.loadStructure(this.args.resource);
+ } catch (error) {
+ debug('Unable to load inspection form structure: ' + error.message);
+ }
+ }
+}
diff --git a/addon/components/inspection-form/details/submissions.hbs b/addon/components/inspection-form/details/submissions.hbs
new file mode 100644
index 000000000..4c0c37184
--- /dev/null
+++ b/addon/components/inspection-form/details/submissions.hbs
@@ -0,0 +1,49 @@
+
+ {{#if this.loadSubmissions.isRunning}}
+
+
+
+ {{else if this.submissions.length}}
+
+ {{#each this.submissions as |submission|}}
+
+ {{/each}}
+
diff --git a/addon/components/inspection-form/details/submissions.js b/addon/components/inspection-form/details/submissions.js
new file mode 100644
index 000000000..67292f9fd
--- /dev/null
+++ b/addon/components/inspection-form/details/submissions.js
@@ -0,0 +1,42 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { task } from 'ember-concurrency';
+
+/**
+ * What has been filed against this form, newest first.
+ *
+ * The mirror of the vehicle's Inspections tab: there you ask what happened to
+ * one truck, here what a form has collected. Every row shares the form, so the
+ * vehicle leads instead and the form name is left out entirely.
+ */
+export default class InspectionFormDetailsSubmissionsComponent extends Component {
+ @service inspectionSubmissionActions;
+ @service notifications;
+ @service store;
+ @tracked submissions = [];
+
+ get form() {
+ return this.args.resource ?? this.args.form;
+ }
+
+ constructor() {
+ super(...arguments);
+ this.loadSubmissions.perform();
+ }
+
+ @task *loadSubmissions() {
+ try {
+ // `inspection_form_uuid` is fillable, which is what makes the index
+ // filter bind to it — the same route `vehicle_uuid` takes.
+ const submissions = yield this.store.query('inspection-submission', {
+ inspection_form_uuid: this.form.uuid ?? this.form.id,
+ sort: '-created_at',
+ });
+
+ this.submissions = Array.from(submissions ?? []);
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+}
diff --git a/addon/components/inspection-form/form.hbs b/addon/components/inspection-form/form.hbs
new file mode 100644
index 000000000..47cce3609
--- /dev/null
+++ b/addon/components/inspection-form/form.hbs
@@ -0,0 +1,93 @@
+
diff --git a/addon/components/inspection-form/form.js b/addon/components/inspection-form/form.js
new file mode 100644
index 000000000..62164f766
--- /dev/null
+++ b/addon/components/inspection-form/form.js
@@ -0,0 +1,79 @@
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+
+/**
+ * The inspection form screen: what the form is, and what it is built from.
+ *
+ * The structure itself belongs to `inspection-form/builder`, which holds it as
+ * a draft so a form can be laid out before the record exists; this component
+ * only passes that draft up to the controller, which posts it with the save.
+ *
+ * Nothing writes to `@resource` during render — text inputs update from the
+ * DOM event, and every other change arrives from an action.
+ */
+export default class InspectionFormFormComponent extends Component {
+ @service intl;
+
+ /**
+ * The three switches a form actually has. Two are read by the server when
+ * a submission has failures (`InspectionSubmitter`); the third is read by
+ * the driver app before it will let a driver submit.
+ */
+ get settingOptions() {
+ return [
+ {
+ key: 'create_issue_on_failure',
+ label: this.intl.t('inspection.form.setting-create-issue'),
+ description: this.intl.t('inspection.form.setting-create-issue-help'),
+ },
+ {
+ key: 'create_work_order_on_failure',
+ label: this.intl.t('inspection.form.setting-create-work-order'),
+ description: this.intl.t('inspection.form.setting-create-work-order-help'),
+ },
+ {
+ key: 'require_signature',
+ label: this.intl.t('inspection.form.setting-require-signature'),
+ description: this.intl.t('inspection.form.setting-require-signature-help'),
+ },
+ ];
+ }
+
+ get settings() {
+ const settings = this.args.resource?.settings;
+ return settings && typeof settings === 'object' ? settings : {};
+ }
+
+ /** The first cut's checklist, kept read-only until it has been migrated. */
+ get legacyItems() {
+ const items = this.args.resource?.items;
+ return Array.isArray(items) ? items : [];
+ }
+
+ @action setName(event) {
+ this.args.resource.name = event.target.value;
+ }
+
+ @action setDescription(event) {
+ this.args.resource.description = event.target.value;
+ }
+
+ @action setType(option) {
+ this.args.resource.type = option?.value ?? null;
+ }
+
+ @action setStatus(option) {
+ this.args.resource.status = option?.value ?? null;
+ }
+
+ @action setSetting(key, event) {
+ this.args.resource.settings = { ...this.settings, [key]: event.target.checked };
+ }
+
+ @action setStructure(groups) {
+ if (typeof this.args.onStructureChange === 'function') {
+ this.args.onStructureChange(groups);
+ }
+ }
+}
diff --git a/addon/components/inspection-link/list.hbs b/addon/components/inspection-link/list.hbs
new file mode 100644
index 000000000..79875f6ab
--- /dev/null
+++ b/addon/components/inspection-link/list.hbs
@@ -0,0 +1,94 @@
+
diff --git a/addon/components/inspection-link/list.js b/addon/components/inspection-link/list.js
new file mode 100644
index 000000000..1302b364b
--- /dev/null
+++ b/addon/components/inspection-link/list.js
@@ -0,0 +1,129 @@
+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';
+import copyToClipboard from '@fleetbase/ember-core/utils/copy-to-clipboard';
+
+/**
+ * The public links minted for one inspection form.
+ *
+ * Generating a link used to leave nothing behind but a toast: the URL went to
+ * the clipboard and, once that clipboard was overwritten, there was no way to
+ * find out what had been handed out, to whom, or whether it still worked. So
+ * every link is listed — who and what it was for, when it was made, whether it
+ * has been opened, and whether it is still live — with the link itself there
+ * to copy again and a way to take it out of use.
+ *
+ * It reloads whenever a link is generated anywhere in the console, by
+ * watching the form actions service, so a list on the details panel stays
+ * current while the generate modal is used on top of it.
+ */
+export default class InspectionLinkListComponent extends Component {
+ @service fetch;
+ @service notifications;
+ @service intl;
+ @service inspectionFormActions;
+
+ @tracked links = [];
+ @tracked error = null;
+
+ /** Which link is being revoked, so only its own button spins. */
+ @tracked revokingId = null;
+
+ /** Which link's PIN is being sent, and how: `:email` or `:sms`. */
+ @tracked sendingKey = null;
+
+ constructor() {
+ super(...arguments);
+ this.load.perform();
+ }
+
+ get formId() {
+ const form = this.args.form;
+ return form?.id ?? form?.public_id ?? form ?? null;
+ }
+
+ get hasLinks() {
+ return this.links.length > 0;
+ }
+
+ /** Who and what a link is for, in one line: its assignee, vehicle and driver. */
+ labelFor(link) {
+ const names = [link?.assignee?.name, link?.vehicle?.name, link?.driver?.name].filter(Boolean);
+ return [...new Set(names)].join(' · ');
+ }
+
+ /** The absolute URL for a link, which the server returns only as a path. */
+ urlFor(link) {
+ return link?.path ? `${window.location.origin}${link.path}` : null;
+ }
+
+ @task({ restartable: true }) *load() {
+ this.error = null;
+
+ if (!this.formId) {
+ this.links = [];
+ return;
+ }
+
+ try {
+ const response = yield this.fetch.get(`inspection-forms/${this.formId}/links`);
+ this.links = (response?.links ?? []).map((link) => ({ ...link, url: this.urlFor(link), label: this.labelFor(link) }));
+ } catch (error) {
+ this.error = error?.payload?.error ?? error?.message ?? this.intl.t('inspection.link.load-failed');
+ }
+ }
+
+ /** Reload whenever the caller says it has minted one. */
+ @action reload() {
+ return this.load.perform();
+ }
+
+ @action copy(link) {
+ if (!link.url) {
+ return;
+ }
+
+ copyToClipboard(link.url);
+ this.notifications.success(this.intl.t('inspection.link.copied'));
+ }
+
+ @action copyPin(link) {
+ if (!link.pin) {
+ return;
+ }
+
+ copyToClipboard(link.pin);
+ this.notifications.success(this.intl.t('inspection.link.copied-pin'));
+ }
+
+ /** Send the PIN again, to whoever the link is for. */
+ @task({ drop: true }) *sendPin(link, via) {
+ this.sendingKey = `${link.id}:${via}`;
+
+ try {
+ const response = yield this.fetch.post(`inspection-forms/${this.formId}/links/${link.id}/send-pin`, { via });
+ this.inspectionFormActions.notifyPinDelivery(response?.pin_delivery);
+ yield this.load.perform();
+ } catch (error) {
+ this.notifications.serverError(error);
+ } finally {
+ this.sendingKey = null;
+ }
+ }
+
+ @task({ drop: true }) *revoke(link) {
+ this.revokingId = link.id;
+
+ try {
+ yield this.fetch.delete(`inspection-forms/${this.formId}/links/${link.id}`);
+ this.notifications.success(this.intl.t('inspection.link.revoked'));
+ yield this.load.perform();
+ } catch (error) {
+ this.notifications.serverError(error);
+ } finally {
+ this.revokingId = null;
+ }
+ }
+}
diff --git a/addon/components/inspection-sheet.hbs b/addon/components/inspection-sheet.hbs
new file mode 100644
index 000000000..a6d0a64a5
--- /dev/null
+++ b/addon/components/inspection-sheet.hbs
@@ -0,0 +1,77 @@
+
+ {{! Floating flyouts render here: inside the sheet so they scroll with their field, outside its card so they are never clipped. }}
+
+
+
+
+ {{#each this.groups key="uuid" as |group|}}
+
+ {{/each}}
+
diff --git a/addon/components/inspection-sheet.js b/addon/components/inspection-sheet.js
new file mode 100644
index 000000000..d0321e9de
--- /dev/null
+++ b/addon/components/inspection-sheet.js
@@ -0,0 +1,152 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+import { flattenFields } from '../utils/inspection-form-structure';
+import { summarize, listDefects } from '../utils/inspection-answers';
+import { INSPECTION_SEVERITIES } from '../utils/inspection-field-types';
+
+/**
+ * An inspection form, being filled in.
+ *
+ * One component renders the sheet wherever it is answered — the console's
+ * submission screen, the read-only record, and the public link a driver opens
+ * on a phone — so the three cannot drift apart. `@values` in, `@onChange`
+ * out; the screen that owns the answers holds them, and nothing is written
+ * here during render.
+ *
+ * The foot is a tally and, when something is wrong, a banner that names the
+ * field rather than counting it: an inspector is told what to go and fix, and
+ * can jump straight to it.
+ */
+export default class InspectionSheetComponent extends Component {
+ @service intl;
+
+ /** The field a banner last jumped to, held for a moment so the eye finds it. */
+ @tracked targetId = null;
+
+ /** The one field whose defect flyout is open. Opening another closes it. */
+ @tracked openFieldId = null;
+
+ get groups() {
+ return Array.isArray(this.args.groups) ? this.args.groups : [];
+ }
+
+ get fields() {
+ return flattenFields(this.groups);
+ }
+
+ get summary() {
+ return summarize(this.fields, this.args.values ?? {});
+ }
+
+ get hasFields() {
+ return this.fields.length > 0;
+ }
+
+ /**
+ * Every failure on the sheet, for the tray at its foot: the severity, the
+ * field, and what evidence it has or still owes. It is the record of a
+ * defect once its flyout is closed, and the review step before submitting.
+ */
+ get defects() {
+ return listDefects(this.fields, this.args.values ?? {}).map((defect) => ({
+ ...defect,
+ label: defect.field.label || this.intl.t('inspection.builder.untitled-field'),
+ severityLabel: this.severityLabel(defect.severity),
+ evidence: this.evidenceOf(defect),
+ }));
+ }
+
+ severityLabel(severity) {
+ if (!severity) {
+ return this.intl.t('inspection.answer.fail');
+ }
+
+ return INSPECTION_SEVERITIES.includes(severity) ? this.intl.t(`inspection.severity.${severity}`) : severity;
+ }
+
+ /** "2 photos · comment", or what is still owed, in the order it is owed. */
+ evidenceOf(defect) {
+ if (defect.needsComment && defect.needsPhoto) {
+ return this.intl.t('inspection.defect.needs-both');
+ }
+
+ if (defect.needsComment) {
+ return this.intl.t('inspection.defect.needs-comment');
+ }
+
+ if (defect.needsPhoto) {
+ return this.intl.t('inspection.defect.needs-photo');
+ }
+
+ const parts = [];
+
+ if (defect.photoCount) {
+ parts.push(this.intl.t('inspection.defect.photos', { count: defect.photoCount }));
+ }
+
+ if (defect.hasComment) {
+ parts.push(this.intl.t('inspection.defect.comment'));
+ }
+
+ return parts.length ? parts.join(' · ') : this.intl.t('inspection.defect.no-evidence');
+ }
+
+ get outstandingDescription() {
+ const field = this.summary.firstOutstanding;
+
+ if (!field) {
+ return null;
+ }
+
+ return this.intl.t('inspection.record.outstanding-field', {
+ label: field.label || this.intl.t('inspection.builder.untitled-field'),
+ });
+ }
+
+ /**
+ * Scroll a named field into view and mark it.
+ *
+ * By id rather than by a held element reference: a promoted field moves
+ * between the grid and its band as the answer changes, so the element the
+ * banner points at is not the one that existed when the banner rendered.
+ */
+ @action openFlyout(field) {
+ this.openFieldId = field?.uuid ?? null;
+ }
+
+ /**
+ * Close a field's flyout — only if it is still the open one, so a close
+ * that arrives after another field has opened cannot shut the new one.
+ */
+ @action closeFlyout(field) {
+ if (!field || this.openFieldId === field.uuid) {
+ this.openFieldId = null;
+ }
+ }
+
+ /** From the tray: bring the defect into view and open it to be edited. */
+ @action reviewDefect(field) {
+ this.jumpTo(field);
+
+ if (!this.args.readonly && !this.args.disabled) {
+ this.openFieldId = field?.uuid ?? null;
+ }
+ }
+
+ @action jumpTo(field) {
+ if (!field?.uuid) {
+ return;
+ }
+
+ this.targetId = field.uuid;
+
+ const element = document.getElementById(`inspection-field-${field.uuid}`);
+
+ if (element) {
+ element.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ element.querySelector('input, textarea, button')?.focus({ preventScroll: true });
+ }
+ }
+}
diff --git a/addon/components/inspection-sheet/group.hbs b/addon/components/inspection-sheet/group.hbs
new file mode 100644
index 000000000..7d2368bce
--- /dev/null
+++ b/addon/components/inspection-sheet/group.hbs
@@ -0,0 +1,45 @@
+
+
+ {{/if}}
+
diff --git a/addon/components/inspection-sheet/group.js b/addon/components/inspection-sheet/group.js
new file mode 100644
index 000000000..c0c5dbcf3
--- /dev/null
+++ b/addon/components/inspection-sheet/group.js
@@ -0,0 +1,75 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { summarize, fieldMarker } from '../../utils/inspection-answers';
+
+const MAX_COLUMNS = 4;
+
+/**
+ * One group of an inspection form.
+ *
+ * The author's `grid_size` is honoured for fields that stay compact. A field
+ * that needs room keeps its place in the order and spans the full width of
+ * the grid instead, which is what keeps one answer from changing the shape of
+ * another: alone on its row, it has no neighbouring cell to stretch.
+ *
+ * It spans in place rather than moving to the end of the group. Moving it
+ * re-sorted the group the moment a check failed, and the fields after it
+ * jumped up past it.
+ *
+ * The header carries one dot per field, in the order they are answered, so an
+ * inspector can see what is still open in a group without reading a label.
+ */
+export default class InspectionSheetGroupComponent extends Component {
+ @service intl;
+
+ get group() {
+ return this.args.group ?? {};
+ }
+
+ get title() {
+ return this.group.name || this.intl.t('inspection.builder.untitled-group');
+ }
+
+ get fields() {
+ return Array.isArray(this.group.fields) ? this.group.fields : [];
+ }
+
+ get values() {
+ return this.args.values ?? {};
+ }
+
+ /** What the author asked for, within what a panel can actually show. */
+ get columns() {
+ const size = Number(this.group.meta?.grid_size);
+
+ if (!Number.isFinite(size) || size < 1) {
+ return 1;
+ }
+
+ return Math.min(Math.round(size), MAX_COLUMNS);
+ }
+
+ get markers() {
+ return this.fields.map((field) => ({
+ uuid: field.uuid,
+ marker: fieldMarker(field, this.values[field.uuid]),
+ }));
+ }
+
+ get summary() {
+ return summarize(this.fields, this.values);
+ }
+
+ /**
+ * The one thing the header says on the right, and only when there is
+ * something to say. How the group is laid out is not news to whoever is
+ * filling it in.
+ */
+ get outstanding() {
+ return this.intl.t('inspection.record.section-outstanding', { count: this.summary.outstanding });
+ }
+
+ get hasOutstanding() {
+ return this.summary.outstanding > 0;
+ }
+}
diff --git a/addon/components/inspection-submission/details.hbs b/addon/components/inspection-submission/details.hbs
new file mode 100644
index 000000000..d4b783b03
--- /dev/null
+++ b/addon/components/inspection-submission/details.hbs
@@ -0,0 +1,154 @@
+
+ {{/if}}
+
+ {{! Derived from the pass/fail answers above, so it is shown only for a
+ submission filed before typed fields, which has nothing else to read. }}
+ {{#unless this.hasAnswers}}
+
+
diff --git a/addon/components/inspection-submission/details.js b/addon/components/inspection-submission/details.js
new file mode 100644
index 000000000..a1bdd38d4
--- /dev/null
+++ b/addon/components/inspection-submission/details.js
@@ -0,0 +1,130 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { debug } from '@ember/debug';
+import { next } from '@ember/runloop';
+import { task } from 'ember-concurrency';
+
+/**
+ * The Overview tab of an inspection record.
+ *
+ * The answers are read from the submission's own payload — the resource
+ * projects them with each field's identity and every file reference resolved —
+ * and laid out against the form's groups, so the record reads the way the form
+ * was built. Nothing is written here.
+ */
+export default class InspectionSubmissionDetailsComponent extends Component {
+ @service inspectionFormActions;
+ @service inspectionSubmissionActions;
+ @service intl;
+ @service hostRouter;
+ @service vehicleActions;
+ @service driverActions;
+
+ @tracked groups = [];
+ @tracked values = {};
+
+ constructor() {
+ super(...arguments);
+ next(() => {
+ if (this.isDestroying || this.isDestroyed) {
+ return;
+ }
+
+ this.load.perform();
+ });
+ }
+
+ /**
+ * Who filed it: the account it is credited to — whoever signed in to the
+ * console, or whoever a public link was for — or else the name typed on
+ * the link, when the link was for nobody in particular.
+ */
+ get submitter() {
+ const submission = this.args.resource;
+ return submission?.submitted_by?.name ?? submission?.meta?.completed_by_name ?? null;
+ }
+
+ /**
+ * How that was established, for a submission that came through a public
+ * link: whether a PIN stood in the way, and what name was typed when it
+ * differs from the account or there is no account to check it against.
+ */
+ get submitterNote() {
+ const submission = this.args.resource;
+ if (submission?.source !== 'public_link') {
+ return null;
+ }
+
+ const typed = (submission.meta?.completed_by_name ?? '').trim();
+ const account = (submission.submitted_by?.name ?? '').trim();
+ const notes = [this.intl.t(submission.meta?.pin_verified ? 'inspection.record.via-link-pin' : 'inspection.record.via-link')];
+
+ if (typed && account && typed.toLowerCase() !== account.toLowerCase()) {
+ notes.push(this.intl.t('inspection.record.signed-as', { name: typed }));
+ } else if (typed && !account) {
+ notes.push(this.intl.t('inspection.record.name-unverified'));
+ }
+
+ return notes.join(' · ');
+ }
+
+ /** Follow-ups open where they live, the way the rest of the console navigates. */
+ @action openForm() {
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index.details', this.args.resource.form);
+ }
+
+ /**
+ * A linked record opens beside what you were reading, which is what the
+ * console does everywhere else; the route transition is the fallback for
+ * a resource whose panel is not registered.
+ */
+ @action viewVehicle() {
+ const vehicle = this.args.resource?.vehicle;
+ if (!vehicle) {
+ return;
+ }
+
+ return this.vehicleActions.panel?.view ? this.vehicleActions.panel.view(vehicle) : this.vehicleActions.transition.view(vehicle);
+ }
+
+ @action viewDriver() {
+ const driver = this.args.resource?.driver;
+ if (!driver) {
+ return;
+ }
+
+ return this.driverActions.panel?.view ? this.driverActions.panel.view(driver) : this.driverActions.transition.view(driver);
+ }
+
+ @action openIssue() {
+ return this.hostRouter.transitionTo('console.fleet-ops.management.issues.index.details', this.args.resource.issue);
+ }
+
+ @action openWorkOrder() {
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.work-orders.index.details', this.args.resource.work_order);
+ }
+
+ get hasAnswers() {
+ return this.groups.some((group) => (group.fields ?? []).length > 0);
+ }
+
+ @task *load() {
+ const submission = this.args.resource;
+ if (!submission?.id) {
+ return;
+ }
+
+ try {
+ this.values = yield this.inspectionSubmissionActions.loadAnswers(submission);
+
+ const form = submission.form;
+ if (form?.id) {
+ this.groups = yield this.inspectionFormActions.loadStructure(form);
+ }
+ } catch (error) {
+ debug('Unable to load inspection answers: ' + error.message);
+ }
+ }
+}
diff --git a/addon/components/inspection-submission/form.hbs b/addon/components/inspection-submission/form.hbs
new file mode 100644
index 000000000..47f33ef62
--- /dev/null
+++ b/addon/components/inspection-submission/form.hbs
@@ -0,0 +1,111 @@
+{{!
+ An inspection being filled in, in the console.
+
+ The record's own details stay in a content panel, like every other
+ resource form in the console. Only the selected form's field groups are
+ rendered differently, as an `inspection-sheet` — the same sheet a public
+ link renders, so the two cannot drift.
+}}
+
diff --git a/addon/components/inspection-submission/form.js b/addon/components/inspection-submission/form.js
new file mode 100644
index 000000000..7305dd394
--- /dev/null
+++ b/addon/components/inspection-submission/form.js
@@ -0,0 +1,129 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { next } from '@ember/runloop';
+import { task } from 'ember-concurrency';
+import { flattenFields } from '../../utils/inspection-form-structure';
+import { answerRows, seedAnswers } from '../../utils/inspection-answers';
+
+const STATUS_OPTIONS = ['draft', 'submitted', 'needs_review', 'resolved'];
+
+/**
+ * An inspection being filled in.
+ *
+ * The details of the inspection come first, then the selected form's field
+ * groups as an `inspection-sheet` — the same sheet a public link renders, so
+ * the two cannot drift. The answers live here, keyed by field uuid, and are
+ * handed up through `@onAnswersChange` as the rows the server accepts — the
+ * same `custom_field_values` body the driver API takes, so the console, a
+ * link and the app all write the same thing and the item results are derived
+ * from the pass-fail answers among them.
+ *
+ * Nothing writes to `@resource` during render: the structure and the stored
+ * answers are loaded in tasks, and every value change arrives from an event.
+ */
+export default class InspectionSubmissionFormComponent extends Component {
+ @service inspectionFormActions;
+ @service inspectionSubmissionActions;
+ @service notifications;
+
+ @tracked groups = [];
+ @tracked values = {};
+
+ statusOptions = STATUS_OPTIONS;
+
+ constructor() {
+ super(...arguments);
+ next(() => {
+ if (this.isDestroying || this.isDestroyed) {
+ return;
+ }
+
+ this.load.perform(this.args.resource?.form);
+ });
+ }
+
+ get fields() {
+ return flattenFields(this.groups);
+ }
+
+ get hasStructure() {
+ return this.fields.length > 0;
+ }
+
+ @task *load(form) {
+ this.groups = [];
+
+ if (!form?.id) {
+ return;
+ }
+
+ try {
+ const groups = yield this.inspectionFormActions.loadStructure(form);
+ const stored = this.args.resource?.id && !this.args.resource?.isNew ? yield this.inspectionSubmissionActions.loadAnswers(this.args.resource) : {};
+
+ this.groups = groups;
+ this.values = seedAnswers(groups, stored);
+ this.announce();
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ /** The answers, as the server accepts them. */
+ get rows() {
+ return answerRows(this.fields, this.values);
+ }
+
+ announce() {
+ if (typeof this.args.onAnswersChange === 'function') {
+ this.args.onAnswersChange(this.rows);
+ }
+ }
+
+ @action setValue(value, field) {
+ this.values = { ...this.values, [field.uuid]: value };
+
+ // A meter field marked as the odometer keeps the submission's own
+ // odometer column in step, so the vehicle's reading follows the
+ // inspection without the inspector typing it twice.
+ if (field.type === 'number' && field.meta?.role === 'odometer' && value !== null && value !== '') {
+ this.args.resource.odometer = Number(value);
+ }
+
+ this.announce();
+ }
+
+ @action assignForm(form) {
+ this.args.resource.form = form;
+ // The relationship is what the save sends; the column is kept in step
+ // with the form's own uuid, never the id the console addresses it by.
+ this.args.resource.inspection_form_uuid = form?.uuid ?? form?.id ?? null;
+ this.args.resource.type = form?.type || this.args.resource.type || 'dvir';
+
+ return this.load.perform(form);
+ }
+
+ @action assignVehicle(vehicle) {
+ this.args.resource.vehicle = vehicle;
+ }
+
+ @action assignDriver(driver) {
+ this.args.resource.driver = driver;
+ }
+
+ @action setStatus(option) {
+ this.args.resource.status = option?.value ?? null;
+ }
+
+ @action setOdometer(event) {
+ const value = event.target.value;
+ this.args.resource.odometer = value === '' ? null : Number(value);
+ }
+
+ @action setEngineHours(event) {
+ const value = event.target.value;
+ this.args.resource.engine_hours = value === '' ? null : Number(value);
+ }
+}
diff --git a/addon/components/inspection-submission/photos.hbs b/addon/components/inspection-submission/photos.hbs
new file mode 100644
index 000000000..cc7c54ffb
--- /dev/null
+++ b/addon/components/inspection-submission/photos.hbs
@@ -0,0 +1,20 @@
+
+
+ {{#if this.load.isRunning}}
+
+
+
+ {{else}}
+
+ {{/if}}
+
+
+
diff --git a/addon/components/inspection-submission/photos.js b/addon/components/inspection-submission/photos.js
new file mode 100644
index 000000000..7ca3e0bcc
--- /dev/null
+++ b/addon/components/inspection-submission/photos.js
@@ -0,0 +1,57 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { debug } from '@ember/debug';
+import { next } from '@ember/runloop';
+import { task } from 'ember-concurrency';
+
+const PHOTO_TYPE = 'inspection_photo';
+
+/**
+ * The Photos tab of an inspection record.
+ *
+ * Every photo filed against the submission — the ones the driver sent with a
+ * failed pass-fail answer, stored by `InspectionFileStore`, and the ones added
+ * here — is a platform file whose subject is the submission, so one query
+ * finds them all and `ModelMultiFileUpload` adds to the same pile.
+ */
+export default class InspectionSubmissionPhotosComponent extends Component {
+ @service store;
+
+ @tracked files = [];
+
+ photoType = PHOTO_TYPE;
+
+ constructor() {
+ super(...arguments);
+ next(() => {
+ if (this.isDestroying || this.isDestroyed) {
+ return;
+ }
+
+ this.load.perform();
+ });
+ }
+
+ @task *load() {
+ const submission = this.args.resource;
+ // Files are filed against the submission's uuid, which is what the
+ // internal resource answers beside the id the console addresses it by.
+ const subjectUuid = submission?.uuid ?? submission?.id;
+ if (!subjectUuid) {
+ return;
+ }
+
+ try {
+ const files = yield this.store.query('file', { subject_uuid: subjectUuid, type: PHOTO_TYPE, limit: -1 });
+ this.files = files.toArray();
+ } catch (error) {
+ debug('Unable to load inspection photos: ' + error.message);
+ }
+ }
+
+ @action addFile(file) {
+ this.files = [...this.files, file];
+ }
+}
diff --git a/addon/components/inspection-submission/pill.hbs b/addon/components/inspection-submission/pill.hbs
new file mode 100644
index 000000000..bd49f15ab
--- /dev/null
+++ b/addon/components/inspection-submission/pill.hbs
@@ -0,0 +1,26 @@
+{{#let (or @submission @resource) as |resource|}}
+
+ <:image>
+
+ {{! A draft has no result yet; an empty badge is just a coloured dot. }}
+ {{#if resource.result}}
+ {{smart-humanize resource.result}}
+ {{/if}}
+ {{n-a resource.submittedAt}}
+
+
+
+{{/let}}
diff --git a/addon/components/inspection-submission/pill.js b/addon/components/inspection-submission/pill.js
new file mode 100644
index 000000000..0ec5d6fc6
--- /dev/null
+++ b/addon/components/inspection-submission/pill.js
@@ -0,0 +1,8 @@
+import Component from '@glimmer/component';
+
+/**
+ * An inspection submission as a linked record: the form it was filled in from,
+ * what it came to and when. Built like the order pill, the other resource with
+ * no photo of its own, so it takes an icon where the others take an image.
+ */
+export default class InspectionSubmissionPillComponent extends Component {}
diff --git a/addon/components/issue/details.hbs b/addon/components/issue/details.hbs
index 2b7d0e5a7..9ca7d4a3d 100644
--- a/addon/components/issue/details.hbs
+++ b/addon/components/issue/details.hbs
@@ -138,6 +138,18 @@
+ {{! Only when there is one: most issues were never raised from an inspection. }}
+ {{#if this.inspections.length}}
+
+
+
diff --git a/addon/components/modals/inspection-follow-up.js b/addon/components/modals/inspection-follow-up.js
new file mode 100644
index 000000000..b9da7c0d5
--- /dev/null
+++ b/addon/components/modals/inspection-follow-up.js
@@ -0,0 +1,39 @@
+import Component from '@glimmer/component';
+
+/**
+ * What an action is about to do to an inspection, shown before it happens.
+ *
+ * Creating an issue, creating a work order and resolving all used to fire on
+ * click: the first a dispatcher knew of it was a toast and a new id. Each one
+ * now states what it will make, from which failed items, and — where it
+ * matters — what else it does on the way.
+ */
+export default class ModalsInspectionFollowUpComponent extends Component {
+ get submission() {
+ return this.args.options.submission;
+ }
+
+ /** The failures the follow-up is built from, worst first. */
+ get failures() {
+ const order = { critical: 0, high: 1, medium: 2, low: 3 };
+ const results = this.submission?.item_results ?? [];
+
+ return results
+ .filter((item) => item.passed === false)
+ .slice()
+ .sort((a, b) => (order[a.severity] ?? 9) - (order[b.severity] ?? 9));
+ }
+
+ get unsafeCount() {
+ return this.failures.filter((item) => item.meta?.unsafe || item.severity === 'critical').length;
+ }
+
+ get highestSeverity() {
+ return this.failures[0]?.severity ?? null;
+ }
+
+ /** Nothing failed, so there is nothing to raise: the server would say so after the fact. */
+ get hasNothingToDo() {
+ return this.args.options.kind !== 'resolve' && this.failures.length === 0;
+ }
+}
diff --git a/addon/components/modals/inspection-link.hbs b/addon/components/modals/inspection-link.hbs
new file mode 100644
index 000000000..489b052d5
--- /dev/null
+++ b/addon/components/modals/inspection-link.hbs
@@ -0,0 +1,100 @@
+
+
+
diff --git a/addon/components/modals/inspection-link.js b/addon/components/modals/inspection-link.js
new file mode 100644
index 000000000..1ac7ad70d
--- /dev/null
+++ b/addon/components/modals/inspection-link.js
@@ -0,0 +1,83 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { action, get, set } from '@ember/object';
+import copyToClipboard from '@fleetbase/ember-core/utils/copy-to-clipboard';
+import { toDatetimeLocal } from '../../services/inspection-form-actions';
+
+export default class ModalsInspectionLinkComponent extends Component {
+ @service intl;
+ @service notifications;
+
+ get formState() {
+ return this.args.options.formState;
+ }
+
+ /** A link cannot be made to expire in the past; the server refuses it too. */
+ get minExpiry() {
+ return toDatetimeLocal(new Date());
+ }
+
+ /**
+ * Who the PIN would be sent to: whoever the link is assigned to, or else
+ * the driver's own account. The server makes the same choice.
+ *
+ * Read with `get`: the form state is a plain object changed with `set`,
+ * and a native read of it is not tracked, so a getter reading it directly
+ * never recomputed and email and SMS stayed disabled after a pick.
+ */
+ get recipientName() {
+ const assignee = get(this.formState, 'assignee');
+ const driver = get(this.formState, 'driver');
+ return assignee?.name ?? driver?.name ?? null;
+ }
+
+ get deliveryHelp() {
+ const name = this.recipientName;
+ return name ? this.intl.t('inspection.link.pin-delivery-help', { name }) : this.intl.t('inspection.link.pin-delivery-no-recipient');
+ }
+
+ /** With nobody left to send it to, the PIN goes back to being shared by hand. */
+ keepDeliveryPossible() {
+ if (!this.recipientName && get(this.formState, 'pin_delivery') !== 'none') {
+ set(this.formState, 'pin_delivery', 'none');
+ }
+ }
+
+ @action assignAssignee(user) {
+ set(this.formState, 'assignee', user);
+ this.keepDeliveryPossible();
+ }
+
+ @action assignDriver(driver) {
+ set(this.formState, 'driver', driver);
+ this.keepDeliveryPossible();
+ }
+
+ @action assignVehicle(vehicle) {
+ set(this.formState, 'vehicle', vehicle);
+ }
+
+ @action updateExpiry(event) {
+ set(this.formState, 'expires_at', event.target.value);
+ }
+
+ @action setPinDelivery(event) {
+ set(this.formState, 'pin_delivery', event.target.value);
+ }
+
+ @action copyLink() {
+ const url = this.formState.generated?.url;
+ if (url) {
+ copyToClipboard(url);
+ this.notifications.success(this.intl.t('inspection.link.copied'));
+ }
+ }
+
+ @action copyPin() {
+ const pin = this.formState.generated?.pin;
+ if (pin) {
+ copyToClipboard(pin);
+ this.notifications.success(this.intl.t('inspection.link.copied-pin'));
+ }
+ }
+}
diff --git a/addon/components/modals/place-details.hbs b/addon/components/modals/place-details.hbs
index 6be51d8a1..ca2f96b78 100644
--- a/addon/components/modals/place-details.hbs
+++ b/addon/components/modals/place-details.hbs
@@ -3,7 +3,7 @@
diff --git a/addon/components/public-inspection.js b/addon/components/public-inspection.js
new file mode 100644
index 000000000..520908826
--- /dev/null
+++ b/addon/components/public-inspection.js
@@ -0,0 +1,279 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action, get } from '@ember/object';
+import config from 'ember-get-config';
+import { task } from 'ember-concurrency';
+import { normalizeFieldGroups, flattenFields } from '../utils/inspection-form-structure';
+import { answerRows, seedAnswers, summarize } from '../utils/inspection-answers';
+
+/*
+ * FleetOps mounts its API at the application root — `fleetops.api.routing.prefix`
+ * is null, which is why its consumable routes are `/v1/...` and its internal
+ * ones `/int/v1/...` rather than sitting under an engine name the way ledger's
+ * do. The public inspection routes follow it, so the namespace here is `public`
+ * and not `fleet-ops/public`.
+ */
+const PUBLIC_NAMESPACE = 'public';
+
+/** The largest photo the link's upload endpoint accepts, matched to the server's limit. */
+const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
+
+/** How many digits a link's PIN has, matched to the server. */
+const PIN_LENGTH = 6;
+
+/*
+ * The fetch service turns a failed response carrying a string `error` into a
+ * bare Error of that message, dropping the rest of the body, so the page never
+ * saw `pin_required` and showed the PIN prompt as an error with nowhere to
+ * type. `rawError` rejects with the body itself.
+ */
+const REQUEST_OPTIONS = { namespace: PUBLIC_NAMESPACE, rawError: true };
+
+/** Asked for explicitly: without it Laravel answers a refused request with a redirect. */
+const JSON_ACCEPT = { Accept: 'application/json' };
+
+/**
+ * An inspection filled in from a tokenised link, outside the console.
+ *
+ * Registered into the `auth:login` menu registry as the hidden slug
+ * `inspection`, which the host console's top-level `virtual` route resolves at
+ * `/~/inspection` — a sibling of `console`, so none of the console's chrome or
+ * its authentication gate applies. The link itself carries the form and the
+ * token as query parameters.
+ *
+ * The sheet is the same `inspection-sheet` the console renders: whoever built
+ * the form sees it laid out the way they built it, whether it is being
+ * answered by a manager at a desk or a contractor on a phone.
+ */
+export default class PublicInspectionComponent extends Component {
+ @service urlSearchParams;
+ @service fetch;
+ @service intl;
+
+ @tracked form = null;
+ @tracked identity = null;
+ @tracked groups = [];
+ @tracked values = {};
+ @tracked odometer = '';
+ @tracked engineHours = '';
+ @tracked signatureName = '';
+ @tracked error = null;
+ @tracked submission = null;
+
+ /** The PIN given with the link, asked for before the form is shown. */
+ @tracked pin = '';
+ @tracked pinRequired = false;
+ @tracked pinError = null;
+
+ constructor() {
+ super(...arguments);
+ this.loadInspection.perform();
+ }
+
+ get formId() {
+ return this.urlSearchParams.get('id');
+ }
+
+ get token() {
+ return this.urlSearchParams.get('token');
+ }
+
+ get pinIsComplete() {
+ return this.pin.length === PIN_LENGTH;
+ }
+
+ /**
+ * The PIN travels as a header on every request the page makes, so it
+ * stays out of the URL and the access logs that record URLs.
+ */
+ get pinHeaders() {
+ return this.pin ? { ...JSON_ACCEPT, 'X-Inspection-Pin': this.pin } : { ...JSON_ACCEPT };
+ }
+
+ get fields() {
+ return flattenFields(this.groups);
+ }
+
+ get summary() {
+ return summarize(this.fields, this.values);
+ }
+
+ get hasSheet() {
+ return this.fields.length > 0;
+ }
+
+ /**
+ * What still stops this being submitted, said plainly rather than by
+ * greying out a button with no explanation.
+ */
+ get blockedReason() {
+ const { missingRequired, incompleteDefects } = this.summary;
+
+ if (missingRequired) {
+ return 'required';
+ }
+
+ return incompleteDefects ? 'defects' : null;
+ }
+
+ get canSubmit() {
+ return this.hasSheet && !this.blockedReason && !this.submitInspection.isRunning && !this.submission;
+ }
+
+ @task({ restartable: true })
+ *loadInspection() {
+ this.error = null;
+
+ if (!this.formId || !this.token) {
+ this.error = 'This inspection link is missing its form or its token.';
+ return;
+ }
+
+ try {
+ const response = yield this.fetch.get(`inspections/forms/${this.formId}`, { token: this.token }, { ...REQUEST_OPTIONS, headers: this.pinHeaders });
+
+ this.pinRequired = false;
+ this.pinError = null;
+ this.form = response?.form;
+ this.identity = response?.identity;
+ this.groups = normalizeFieldGroups(this.form);
+ this.values = seedAnswers(this.groups);
+ } catch (error) {
+ const body = yield this.failureBody(error);
+
+ // The link wants its PIN, or the one given was wrong: ask again,
+ // saying how many tries are left. Anything else, including a link
+ // locked by too many wrong PINs, is the page's error.
+ if (body?.pin_required) {
+ this.pinRequired = true;
+ this.pinError = this.pin ? this.intl.t('inspection.public.pin-wrong', { count: body.attempts_left ?? 0 }) : null;
+ this.pin = '';
+ return;
+ }
+
+ this.pinRequired = false;
+ this.error = yield this.describeFailure(error, 'This inspection could not be loaded.', body);
+ }
+ }
+
+ @task({ drop: true })
+ *submitInspection() {
+ this.error = null;
+
+ try {
+ const response = yield this.fetch.post(
+ `inspections/forms/${this.formId}/submit`,
+ {
+ token: this.token,
+ odometer: this.odometer === '' ? null : parseInt(this.odometer, 10),
+ engine_hours: this.engineHours === '' ? null : parseInt(this.engineHours, 10),
+ signature: this.signatureName ? { name: this.signatureName, signed_at: new Date().toISOString() } : null,
+ custom_field_values: answerRows(this.fields, this.values),
+ },
+ { ...REQUEST_OPTIONS, headers: this.pinHeaders }
+ );
+
+ this.submission = response?.submission;
+ } catch (error) {
+ this.error = yield this.describeFailure(error, 'This inspection could not be submitted.');
+ }
+ }
+
+ /**
+ * Upload a photo or a signature through this link.
+ *
+ * The console's uploader posts to the platform's file endpoint, which
+ * needs a session a link does not have; this posts to the link's own
+ * upload endpoint with its token instead, and answers in the shape the
+ * sheet expects from the console.
+ */
+ @action async uploadFile(file, type) {
+ if (file?.size > MAX_UPLOAD_BYTES) {
+ this.error = 'That photo is larger than 10 MB. Try a smaller one.';
+ throw new Error(this.error);
+ }
+
+ const url = `${get(config, 'API.host')}/${PUBLIC_NAMESPACE}/inspections/forms/${encodeURIComponent(this.formId)}/files`;
+
+ try {
+ const response = await file.upload(url, { data: { token: this.token, type }, headers: { Accept: 'application/json', ...this.pinHeaders } });
+ const body = await response.json();
+
+ this.error = null;
+
+ return { id: body.file.id, url: body.file.url, filename: body.file.filename };
+ } catch (error) {
+ this.error = await this.describeFailure(error, 'This photo could not be uploaded.');
+ throw error;
+ }
+ }
+
+ /**
+ * What the server said went wrong, in words an inspector can act on. A
+ * link that was already used or has expired says so; being rate limited
+ * says to wait rather than showing a bare status code.
+ */
+ async describeFailure(error, fallback, body = null) {
+ body = body ?? (await this.failureBody(error));
+
+ // Laravel's throttle answers with this message and nothing else.
+ if (error?.status === 429 || body?.message === 'Too Many Attempts.') {
+ return 'Too many attempts from this device. Wait a minute and try again.';
+ }
+
+ const message = body?.error ?? body?.errors?.[0] ?? body?.message ?? error?.message;
+
+ return typeof message === 'string' && message ? message : fallback;
+ }
+
+ /**
+ * The JSON the server answered a failed request with. A `rawError` request
+ * rejects with that body itself; an upload rejects with the response, whose
+ * body can be read only once.
+ */
+ async failureBody(error) {
+ if (!error) {
+ return null;
+ }
+
+ if (error.payload) {
+ return error.payload;
+ }
+
+ if (typeof error.json === 'function') {
+ return await error.json().catch(() => null);
+ }
+
+ return error instanceof Error ? null : error;
+ }
+
+ /** Digits only, and no more than a PIN has: pasted spaces or dashes are dropped. */
+ @action updatePin(event) {
+ this.pin = String(event.target.value ?? '')
+ .replace(/\D/g, '')
+ .slice(0, PIN_LENGTH);
+ this.pinError = null;
+ }
+
+ @action submitPin() {
+ if (this.pinIsComplete && !this.loadInspection.isRunning) {
+ this.loadInspection.perform();
+ }
+ }
+
+ @action pinKeydown(event) {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ this.submitPin();
+ }
+ }
+
+ @action setValue(value, field) {
+ this.values = { ...this.values, [field.uuid]: value };
+ }
+
+ @action updateReading(key, event) {
+ this[key] = event.target.value;
+ }
+}
diff --git a/addon/components/select-option.hbs b/addon/components/select-option.hbs
new file mode 100644
index 000000000..828e7bc54
--- /dev/null
+++ b/addon/components/select-option.hbs
@@ -0,0 +1,11 @@
+
diff --git a/addon/components/select-option.js b/addon/components/select-option.js
new file mode 100644
index 000000000..181ad46f7
--- /dev/null
+++ b/addon/components/select-option.js
@@ -0,0 +1,24 @@
+import Component from '@glimmer/component';
+
+/**
+ * One option in a ModelSelect or PowerSelect: a photo, a name, and a line of
+ * detail beneath it — the select's counterpart to a card.
+ *
+ * `@details` is a list; empty entries are dropped, so a record missing its
+ * email or phone reads cleanly rather than with a stray separator. `@compact`
+ * puts everything on one line at a smaller photo, for a select's closed
+ * trigger, where two stacked lines do not fit.
+ *
+ * The record-specific options (`SelectOption::User`, `::Driver`, `::Vehicle`)
+ * are built on this. It lives in FleetOps for now and is meant to move to
+ * ember-ui as a shared primitive.
+ */
+export default class SelectOptionComponent extends Component {
+ get details() {
+ return (this.args.details ?? []).filter((detail) => detail !== null && detail !== undefined && String(detail).trim() !== '').join(' · ');
+ }
+
+ get photo() {
+ return this.args.photo || this.args.fallbackPhoto || null;
+ }
+}
diff --git a/addon/components/select-option/driver.hbs b/addon/components/select-option/driver.hbs
new file mode 100644
index 000000000..f65a98439
--- /dev/null
+++ b/addon/components/select-option/driver.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/select-option/driver.js b/addon/components/select-option/driver.js
new file mode 100644
index 000000000..d85a5e87c
--- /dev/null
+++ b/addon/components/select-option/driver.js
@@ -0,0 +1,25 @@
+import Component from '@glimmer/component';
+import { get } from '@ember/object';
+import config from 'ember-get-config';
+
+/**
+ * A driver as a select option: photo, then name over phone and email. Takes
+ * the record as `@option` or `@model`, like `SelectOption::User`.
+ */
+export default class SelectOptionDriverComponent extends Component {
+ get driver() {
+ return this.args.option ?? this.args.model ?? null;
+ }
+
+ get fallbackPhoto() {
+ return get(config, 'defaultValues.driverImage');
+ }
+
+ get title() {
+ return this.driver?.name || this.driver?.public_id;
+ }
+
+ get details() {
+ return [this.driver?.phone, this.driver?.email];
+ }
+}
diff --git a/addon/components/select-option/user.hbs b/addon/components/select-option/user.hbs
new file mode 100644
index 000000000..5617c7da5
--- /dev/null
+++ b/addon/components/select-option/user.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/components/select-option/user.js b/addon/components/select-option/user.js
new file mode 100644
index 000000000..b05aa62ef
--- /dev/null
+++ b/addon/components/select-option/user.js
@@ -0,0 +1,26 @@
+import Component from '@glimmer/component';
+import { get } from '@ember/object';
+import config from 'ember-get-config';
+
+/**
+ * A user as a select option: photo, then name over email and phone. Takes the
+ * record as `@option`, which is how PowerSelect hands a `@selectedItemComponent`
+ * its selection, or as `@model` inside an option block.
+ */
+export default class SelectOptionUserComponent extends Component {
+ get user() {
+ return this.args.option ?? this.args.model ?? null;
+ }
+
+ get fallbackPhoto() {
+ return get(config, 'defaultValues.userImage');
+ }
+
+ get title() {
+ return this.user?.name || this.user?.email || this.user?.public_id;
+ }
+
+ get details() {
+ return [this.user?.email, this.user?.phone];
+ }
+}
diff --git a/addon/components/select-option/vehicle.hbs b/addon/components/select-option/vehicle.hbs
new file mode 100644
index 000000000..ff6d66a6b
--- /dev/null
+++ b/addon/components/select-option/vehicle.hbs
@@ -0,0 +1,9 @@
+
diff --git a/addon/components/select-option/vehicle.js b/addon/components/select-option/vehicle.js
new file mode 100644
index 000000000..cbba7fdd6
--- /dev/null
+++ b/addon/components/select-option/vehicle.js
@@ -0,0 +1,55 @@
+import Component from '@glimmer/component';
+import { inject as service } from '@ember/service';
+import { get } from '@ember/object';
+import config from 'ember-get-config';
+
+/**
+ * A vehicle as a select option: photo, then name over one identifier. The
+ * plate when it has one; otherwise its VIN, serial number or call sign,
+ * whichever it has first, labelled so a bare string of characters is not
+ * mistaken for a plate.
+ */
+export default class SelectOptionVehicleComponent extends Component {
+ @service intl;
+
+ get vehicle() {
+ return this.args.option ?? this.args.model ?? null;
+ }
+
+ get photo() {
+ return this.vehicle?.photo_url || this.vehicle?.avatar_url || null;
+ }
+
+ get fallbackPhoto() {
+ return get(config, 'defaultValues.vehicleImage');
+ }
+
+ get title() {
+ const vehicle = this.vehicle;
+ return vehicle?.displayName || vehicle?.display_name || vehicle?.name || vehicle?.public_id;
+ }
+
+ get identifier() {
+ const vehicle = this.vehicle;
+
+ if (!vehicle) {
+ return null;
+ }
+
+ if (vehicle.plate_number) {
+ return vehicle.plate_number;
+ }
+
+ for (const key of ['vin', 'serial_number', 'call_sign']) {
+ if (vehicle[key]) {
+ return `${this.intl.t(`select-option.vehicle.${key}`)} ${vehicle[key]}`;
+ }
+ }
+
+ return null;
+ }
+
+ get details() {
+ return [this.identifier];
+ }
+}
diff --git a/addon/components/service-area/details.hbs b/addon/components/service-area/details.hbs
index 3068c00e7..ff3b2ab57 100644
--- a/addon/components/service-area/details.hbs
+++ b/addon/components/service-area/details.hbs
@@ -27,7 +27,7 @@
@zoomControl={{false}}
as |layers|
>
-
+
+
+ {{#if @value}}
+ {{or (get-fleet-ops-option-label @column.optionsKey @value) (smart-humanize @value)}}
+ {{else}}
+ -
+ {{/if}}
+
+
Use the AFAQY live telemetry section to obtain the secure registration URL.
+
+ {{else if this.provider.supports_webhooks}}
{{#if this.hasWebhookUrl}}
diff --git a/addon/components/telematic/settings.js b/addon/components/telematic/settings.js
index 767f30529..ea45cbfdd 100644
--- a/addon/components/telematic/settings.js
+++ b/addon/components/telematic/settings.js
@@ -19,6 +19,7 @@ export default class TelematicSettingsComponent extends Component {
}
get webhookUrl() {
+ if (this.provider?.key === 'afaqy') return null;
const url = this.provider.webhook_url;
const id = this.args.resource?.public_id;
diff --git a/addon/components/vehicle/details/inspections.hbs b/addon/components/vehicle/details/inspections.hbs
new file mode 100644
index 000000000..4eeedb158
--- /dev/null
+++ b/addon/components/vehicle/details/inspections.hbs
@@ -0,0 +1,49 @@
+
+ {{#if this.loadInspections.isRunning}}
+
+
+
+ {{else if this.inspections.length}}
+
+ {{#each this.inspections as |inspection|}}
+
+ {{/each}}
+
+ {{else}}
+
+
+
{{t "vehicle.empty.inspections"}}
+
{{t "vehicle.empty.inspections-description"}}
+
+ {{/if}}
+
diff --git a/addon/components/vehicle/details/inspections.js b/addon/components/vehicle/details/inspections.js
new file mode 100644
index 000000000..46d1e75e7
--- /dev/null
+++ b/addon/components/vehicle/details/inspections.js
@@ -0,0 +1,44 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { task } from 'ember-concurrency';
+
+/**
+ * A vehicle's inspection history, newest first.
+ *
+ * The same submissions the Inspections screen lists, narrowed to this truck:
+ * what was filed against it, what failed, and a way into the record. Filing an
+ * inspection is a driver's job — through the app or a public link — so nothing
+ * is created from here.
+ */
+export default class VehicleDetailsInspectionsComponent extends Component {
+ @service inspectionSubmissionActions;
+ @service notifications;
+ @service store;
+ @tracked inspections = [];
+
+ get vehicle() {
+ return this.args.resource ?? this.args.vehicle;
+ }
+
+ constructor() {
+ super(...arguments);
+ this.loadInspections.perform();
+ }
+
+ @task *loadInspections() {
+ try {
+ // `vehicle` is neither a column nor searchable on this model, so it
+ // is dropped silently; `vehicle_uuid` is fillable and is what the
+ // index filter actually matches on.
+ const inspections = yield this.store.query('inspection-submission', {
+ vehicle_uuid: this.vehicle.id,
+ sort: '-created_at',
+ });
+
+ this.inspections = Array.from(inspections ?? []);
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+}
diff --git a/addon/components/vehicle/details/maintenance-history.hbs b/addon/components/vehicle/details/maintenance-history.hbs
index 12df509f9..7326759e4 100644
--- a/addon/components/vehicle/details/maintenance-history.hbs
+++ b/addon/components/vehicle/details/maintenance-history.hbs
@@ -1,5 +1,5 @@
+ {{#each this.inspections as |inspection|}}
+
+ {{/each}}
+
+
+ {{/if}}
{{! — Scheduling — }}
diff --git a/addon/components/work-order/details.js b/addon/components/work-order/details.js
index be4c2d096..c4e523a53 100644
--- a/addon/components/work-order/details.js
+++ b/addon/components/work-order/details.js
@@ -1,3 +1,43 @@
import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { debug } from '@ember/debug';
+import { task } from 'ember-concurrency';
-export default class WorkOrderDetailsComponent extends Component {}
+/**
+ * A work order raised from a failed inspection is named on that inspection,
+ * and the panel read none of it — the inspection that caused a repair was
+ * invisible from the repair. It is looked up rather than read from meta, so
+ * one attached by hand shows up too.
+ */
+export default class WorkOrderDetailsComponent extends Component {
+ @service store;
+ @service inspectionSubmissionActions;
+ @tracked inspections = [];
+
+ constructor() {
+ super(...arguments);
+ this.loadInspections.perform();
+ }
+
+ @task *loadInspections() {
+ const id = this.args.resource?.uuid ?? this.args.resource?.id;
+ if (!id) {
+ return;
+ }
+
+ try {
+ const inspections = yield this.store.query('inspection-submission', { work_order_uuid: id, sort: '-created_at' });
+ this.inspections = Array.from(inspections ?? []);
+ } catch (error) {
+ // Supplementary to the work order: a failed lookup should not put an
+ // error in front of someone who opened the work order to read it.
+ debug(`Could not load the inspections linked to work order ${id}: ${error.message}`);
+ }
+ }
+
+ @action viewInspection(submission) {
+ return this.inspectionSubmissionActions.transition.view(submission);
+ }
+}
diff --git a/addon/components/zone/details.hbs b/addon/components/zone/details.hbs
index 284582073..ae17e1099 100644
--- a/addon/components/zone/details.hbs
+++ b/addon/components/zone/details.hbs
@@ -22,7 +22,7 @@
@zoomControl={{false}}
as |layers|
>
-
+ this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index'),
+ });
+ }
+}
diff --git a/addon/controllers/maintenance/inspection-forms/index/edit.js b/addon/controllers/maintenance/inspection-forms/index/edit.js
new file mode 100644
index 000000000..859e4a7a7
--- /dev/null
+++ b/addon/controllers/maintenance/inspection-forms/index/edit.js
@@ -0,0 +1,39 @@
+import Controller from '@ember/controller';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { task } from 'ember-concurrency';
+
+export default class MaintenanceInspectionFormsIndexEditController extends Controller {
+ @service inspectionFormActions;
+ @service hostRouter;
+ @service notifications;
+ @service intl;
+
+ @tracked overlay;
+
+ /** The builder's draft, set only once the author has changed something. */
+ @tracked structure = null;
+
+ @task *save(inspectionForm) {
+ try {
+ yield inspectionForm.save();
+ yield this.inspectionFormActions.saveStructure(inspectionForm, this.structure);
+
+ this.overlay?.close();
+ yield this.hostRouter.refresh();
+ yield this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index.details', inspectionForm);
+ this.notifications.success(this.intl.t('inspection.form.updated'));
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @action setStructure(groups) {
+ this.structure = groups;
+ }
+
+ @action cancel() {
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index.details', this.model);
+ }
+}
diff --git a/addon/controllers/maintenance/inspection-forms/index/new.js b/addon/controllers/maintenance/inspection-forms/index/new.js
new file mode 100644
index 000000000..f5c1f29b9
--- /dev/null
+++ b/addon/controllers/maintenance/inspection-forms/index/new.js
@@ -0,0 +1,48 @@
+import Controller from '@ember/controller';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { task } from 'ember-concurrency';
+
+export default class MaintenanceInspectionFormsIndexNewController extends Controller {
+ @service inspectionFormActions;
+ @service hostRouter;
+ @service notifications;
+ @service intl;
+ @service events;
+
+ @tracked overlay;
+ @tracked inspectionForm = this.inspectionFormActions.createNewInstance();
+
+ /** The builder's draft, laid out before the form record exists. */
+ @tracked structure = null;
+
+ @task *save(inspectionForm) {
+ try {
+ yield inspectionForm.save();
+
+ // The structure is posted separately, under the key the server
+ // reads it from — Ember Data cannot carry it, because the
+ // `inspection-form` model declares no attribute for it.
+ yield this.inspectionFormActions.saveStructure(inspectionForm, this.structure);
+
+ this.events.trackResourceCreated(inspectionForm);
+ this.overlay?.close();
+ yield this.hostRouter.refresh();
+ yield this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index.details', inspectionForm);
+ this.notifications.success(this.intl.t('inspection.form.created'));
+ this.resetForm();
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @action setStructure(groups) {
+ this.structure = groups;
+ }
+
+ @action resetForm() {
+ this.structure = null;
+ this.inspectionForm = this.inspectionFormActions.createNewInstance();
+ }
+}
diff --git a/addon/controllers/maintenance/inspection-submissions/index.js b/addon/controllers/maintenance/inspection-submissions/index.js
new file mode 100644
index 000000000..67ef79642
--- /dev/null
+++ b/addon/controllers/maintenance/inspection-submissions/index.js
@@ -0,0 +1,111 @@
+import Controller from '@ember/controller';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+
+export default class MaintenanceInspectionSubmissionsIndexController extends Controller {
+ @service inspectionSubmissionActions;
+ @service intl;
+
+ @tracked queryParams = ['status', 'result', 'type', 'page', 'limit', 'sort', 'query', 'public_id', 'vehicle', 'driver', 'created_at', 'updated_at'];
+ @tracked page = 1;
+ @tracked limit;
+ @tracked sort = '-created_at';
+ @tracked public_id;
+ @tracked status;
+ @tracked result;
+ @tracked type;
+ @tracked vehicle;
+ @tracked driver;
+
+ get actionButtons() {
+ return [
+ { icon: 'refresh', onClick: this.inspectionSubmissionActions.refresh, helpText: this.intl.t('common.refresh') },
+ { text: this.intl.t('common.new'), type: 'primary', icon: 'plus', onClick: this.inspectionSubmissionActions.transition.create },
+ ];
+ }
+
+ get bulkActions() {
+ return [{ label: 'Delete selected...', class: 'text-red-500', fn: this.inspectionSubmissionActions.bulkDelete }];
+ }
+
+ get columns() {
+ return [
+ {
+ label: 'Inspection',
+ valuePath: 'public_id',
+ cellComponent: 'table/cell/anchor',
+ action: this.inspectionSubmissionActions.transition.view,
+ permission: 'fleet-ops view inspection-submission',
+ resizable: true,
+ sortable: true,
+ filterable: true,
+ filterParam: 'public_id',
+ filterComponent: 'filter/string',
+ },
+ { label: 'Form', valuePath: 'form_name', resizable: true, sortable: false },
+ { label: 'Vehicle', valuePath: 'vehicle_name', resizable: true, sortable: false },
+ { label: 'Driver', valuePath: 'driver_name', resizable: true, sortable: false },
+ {
+ label: 'Result',
+ valuePath: 'result',
+ cellComponent: 'table/cell/status',
+ resizable: true,
+ sortable: true,
+ filterable: true,
+ filterParam: 'result',
+ filterComponent: 'filter/string',
+ },
+ { label: 'Failed', valuePath: 'failed_items', resizable: true, sortable: true },
+ { label: 'Submitted', valuePath: 'submittedAt', sortParam: 'submitted_at', resizable: true, sortable: true, filterable: true, filterComponent: 'filter/date' },
+ {
+ label: '',
+ cellComponent: 'table/cell/dropdown',
+ ddButtonText: false,
+ ddButtonIcon: 'ellipsis-h',
+ ddButtonIconPrefix: 'fas',
+ cellClassNames: 'overflow-visible',
+ wrapperClass: 'flex items-center justify-end mx-2',
+ actions: [
+ { label: 'View inspection', fn: this.inspectionSubmissionActions.transition.view, permission: 'fleet-ops view inspection-submission' },
+ { label: 'Edit inspection', fn: this.inspectionSubmissionActions.transition.edit, permission: 'fleet-ops update inspection-submission' },
+ {
+ separator: true,
+ // Everything below this rule is conditional. On a row
+ // with none of it — a resolved inspection that already
+ // raised what it had to — the rule still drew, landing
+ // next to the one above Delete as a double line.
+ isVisible: (row) => row?.status === 'draft' || row?.status !== 'resolved' || Boolean(row?.has_failures && (!row?.issue_uuid || !row?.work_order_uuid)),
+ },
+ {
+ label: 'Submit',
+ fn: this.inspectionSubmissionActions.submit,
+ permission: 'fleet-ops submit inspection-submission',
+ // Submitting files a draft. On anything already filed it
+ // did nothing and still reported success, so it is only
+ // offered where it has something to do.
+ isVisible: (row) => row?.status === 'draft',
+ },
+ {
+ label: 'Create issue',
+ fn: this.inspectionSubmissionActions.createIssue,
+ permission: 'fleet-ops create-issue inspection-submission',
+ isVisible: (row) => row?.has_failures && !row?.issue_uuid,
+ },
+ {
+ label: 'Create work order',
+ fn: this.inspectionSubmissionActions.createWorkOrder,
+ permission: 'fleet-ops create-work-order inspection-submission',
+ isVisible: (row) => row?.has_failures && !row?.work_order_uuid,
+ },
+ { label: 'Resolve', fn: this.inspectionSubmissionActions.resolve, permission: 'fleet-ops resolve inspection-submission', isVisible: (row) => row?.status !== 'resolved' },
+ { separator: true },
+ { label: 'Delete inspection', fn: this.inspectionSubmissionActions.delete, class: 'text-red-500', permission: 'fleet-ops delete inspection-submission' },
+ ],
+ sortable: false,
+ filterable: false,
+ resizable: false,
+ searchable: false,
+ },
+ ];
+ }
+}
diff --git a/addon/controllers/maintenance/inspection-submissions/index/details.js b/addon/controllers/maintenance/inspection-submissions/index/details.js
new file mode 100644
index 000000000..f425cdb07
--- /dev/null
+++ b/addon/controllers/maintenance/inspection-submissions/index/details.js
@@ -0,0 +1,100 @@
+import Controller from '@ember/controller';
+import { inject as service } from '@ember/service';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+
+export default class MaintenanceInspectionSubmissionsIndexDetailsController extends Controller {
+ @service inspectionSubmissionActions;
+ @service hostRouter;
+ @service intl;
+ @tracked overlay;
+
+ get tabs() {
+ return [
+ { route: 'maintenance.inspection-submissions.index.details.index', label: this.intl.t('inspection.record.overview') },
+ { route: 'maintenance.inspection-submissions.index.details.photos', label: this.intl.t('inspection.record.photos') },
+ { route: 'maintenance.inspection-submissions.index.details.audit', label: this.intl.t('inspection.record.audit') },
+ ];
+ }
+
+ /**
+ * Editing is the one action worth a button of its own; the rest sit behind
+ * the ellipsis, which is what the vehicle panel does. Five buttons
+ * overflowed the overlay header and pushed the title out of sight.
+ */
+ get actionButtons() {
+ return [
+ { icon: 'edit', fn: this.edit, permission: 'fleet-ops update inspection-submission' },
+ {
+ icon: 'ellipsis-h',
+ iconPrefix: 'fas',
+ renderInPlace: true,
+ items: this.followUpItems,
+ },
+ ];
+ }
+
+ /**
+ * A submission raises one issue and one work order, and the server enforces
+ * that — `createIssueFromFailures()` hands back what already exists. The
+ * console kept offering both anyway, so the same click reported success
+ * over and over while creating nothing. What is already raised is linked
+ * from the Follow Up panel instead of offered again here.
+ */
+ get followUpItems() {
+ const record = this.model;
+ const items = [];
+
+ if (record?.has_failures && !record?.issue_uuid) {
+ items.push({
+ text: this.intl.t('inspection.record.create-issue'),
+ icon: 'triangle-exclamation',
+ fn: this.createIssue,
+ permission: 'fleet-ops create-issue inspection-submission',
+ });
+ }
+
+ if (record?.has_failures && !record?.work_order_uuid) {
+ items.push({
+ text: this.intl.t('inspection.record.create-work-order'),
+ icon: 'clipboard-list',
+ fn: this.createWorkOrder,
+ permission: 'fleet-ops create-work-order inspection-submission',
+ });
+ }
+
+ if (record?.status !== 'resolved') {
+ items.push({ text: this.intl.t('inspection.record.resolve'), icon: 'check', fn: this.resolve, permission: 'fleet-ops resolve inspection-submission' });
+ }
+
+ if (items.length) {
+ items.push({ separator: true });
+ }
+
+ items.push({ text: this.intl.t('common.delete'), icon: 'trash', class: 'text-red-500', fn: this.delete, permission: 'fleet-ops delete inspection-submission' });
+
+ return items;
+ }
+
+ @action createIssue() {
+ return this.inspectionSubmissionActions.createIssue(this.model);
+ }
+
+ @action createWorkOrder() {
+ return this.inspectionSubmissionActions.createWorkOrder(this.model);
+ }
+
+ @action resolve() {
+ return this.inspectionSubmissionActions.resolve(this.model);
+ }
+
+ @action edit() {
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index.edit', this.model);
+ }
+
+ @action delete() {
+ return this.inspectionSubmissionActions.delete(this.model, {
+ onConfirm: () => this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index'),
+ });
+ }
+}
diff --git a/addon/controllers/maintenance/inspection-submissions/index/edit.js b/addon/controllers/maintenance/inspection-submissions/index/edit.js
new file mode 100644
index 000000000..40c1d1162
--- /dev/null
+++ b/addon/controllers/maintenance/inspection-submissions/index/edit.js
@@ -0,0 +1,39 @@
+import Controller from '@ember/controller';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { task } from 'ember-concurrency';
+
+export default class MaintenanceInspectionSubmissionsIndexEditController extends Controller {
+ @service inspectionSubmissionActions;
+ @service hostRouter;
+ @service notifications;
+ @service intl;
+
+ @tracked overlay;
+
+ /** The answers, as the server accepts them. */
+ @tracked answers = null;
+
+ @task *save(inspectionSubmission) {
+ try {
+ yield inspectionSubmission.save();
+ yield this.inspectionSubmissionActions.saveAnswers(inspectionSubmission, this.answers);
+
+ this.overlay?.close();
+ yield this.hostRouter.refresh();
+ yield this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index.details', inspectionSubmission);
+ this.notifications.success(this.intl.t('inspection.record.updated'));
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @action setAnswers(rows) {
+ this.answers = rows;
+ }
+
+ @action cancel() {
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index.details', this.model);
+ }
+}
diff --git a/addon/controllers/maintenance/inspection-submissions/index/new.js b/addon/controllers/maintenance/inspection-submissions/index/new.js
new file mode 100644
index 000000000..972179434
--- /dev/null
+++ b/addon/controllers/maintenance/inspection-submissions/index/new.js
@@ -0,0 +1,48 @@
+import Controller from '@ember/controller';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { task } from 'ember-concurrency';
+
+export default class MaintenanceInspectionSubmissionsIndexNewController extends Controller {
+ @service inspectionSubmissionActions;
+ @service hostRouter;
+ @service notifications;
+ @service intl;
+ @service events;
+
+ @tracked overlay;
+ @tracked inspectionSubmission = this.inspectionSubmissionActions.createNewInstance();
+
+ /** The answers, as the server accepts them. */
+ @tracked answers = null;
+
+ @task *save(inspectionSubmission) {
+ try {
+ yield inspectionSubmission.save();
+
+ // The answers are posted separately, under the key the server
+ // reads them from — the `inspection-submission` model declares no
+ // `custom_field_values` relationship, so Ember Data drops them.
+ yield this.inspectionSubmissionActions.saveAnswers(inspectionSubmission, this.answers);
+
+ this.events.trackResourceCreated(inspectionSubmission);
+ this.overlay?.close();
+ yield this.hostRouter.refresh();
+ yield this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index.details', inspectionSubmission);
+ this.notifications.success(this.intl.t('inspection.record.saved'));
+ this.resetForm();
+ } catch (err) {
+ this.notifications.serverError(err);
+ }
+ }
+
+ @action setAnswers(rows) {
+ this.answers = rows;
+ }
+
+ @action resetForm() {
+ this.answers = null;
+ this.inspectionSubmission = this.inspectionSubmissionActions.createNewInstance();
+ }
+}
diff --git a/addon/controllers/management/vehicles/index/details.js b/addon/controllers/management/vehicles/index/details.js
index 0f7cc4be6..61f0483d0 100644
--- a/addon/controllers/management/vehicles/index/details.js
+++ b/addon/controllers/management/vehicles/index/details.js
@@ -53,6 +53,11 @@ export default class ManagementVehiclesIndexDetailsController extends Controller
route: 'management.vehicles.index.details.maintenance-history',
label: 'Maintenance',
},
+ {
+ id: 'inspections',
+ route: 'management.vehicles.index.details.inspections',
+ label: this.intl.t('resource.inspections'),
+ },
...(isArray(registeredTabs) ? registeredTabs : []),
];
}
diff --git a/addon/extension.js b/addon/extension.js
index e5be926b9..0a6bc89a6 100644
--- a/addon/extension.js
+++ b/addon/extension.js
@@ -118,6 +118,21 @@ export default {
})
);
+ menuService.registerMenuItem(
+ 'auth:login',
+ new MenuItem({
+ title: 'Inspection',
+ route: 'virtual',
+ slug: 'inspection',
+ type: 'link',
+ wrapperClass: 'hidden',
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'public-inspection'),
+ onClick: (menuItem) => {
+ universe.transitionMenuItem('virtual', menuItem);
+ },
+ })
+ );
+
// Register widgets
this.registerWidgets(widgetService);
@@ -394,6 +409,11 @@ export default {
'fleet-ops:component:maintenance:form',
'fleet-ops:component:maintenance:form:details',
'fleet-ops:component:maintenance:details',
+ 'fleet-ops:component:inspection-form:form',
+ 'fleet-ops:component:inspection-form:details',
+ 'fleet-ops:component:inspection-submission:form',
+ 'fleet-ops:component:inspection-submission:details',
+ 'fleet-ops:component:public-inspection',
'fleet-ops:component:work-order:form',
'fleet-ops:component:work-order:form:details',
'fleet-ops:component:work-order:details',
diff --git a/addon/helpers/leaflet-tile-url.js b/addon/helpers/leaflet-tile-url.js
index 681cee375..8a0774821 100644
--- a/addon/helpers/leaflet-tile-url.js
+++ b/addon/helpers/leaflet-tile-url.js
@@ -5,9 +5,14 @@ import { inject as service } from '@ember/service';
* Resolves the Leaflet tile URL from Fleet-Ops map settings.
*
* Usage:
- *
+ *
*
*
+ * With no arguments it must be invoked in parentheses. A bare
+ * `@url={{leaflet-tile-url}}` passes the helper by name, which Glimmer refuses
+ * with "A resolved helper cannot be passed as a named argument" and the whole
+ * template fails to render.
+ *
* Recomputes automatically when map settings load or change.
*/
export default class LeafletTileUrlHelper extends Helper {
diff --git a/addon/modifiers/inspection-flyout.js b/addon/modifiers/inspection-flyout.js
new file mode 100644
index 000000000..b234dd83f
--- /dev/null
+++ b/addon/modifiers/inspection-flyout.js
@@ -0,0 +1,237 @@
+import { modifier } from 'ember-modifier';
+
+/** Room left between the flyout and its field, and between it and the sheet's edge. */
+const GAP = 8;
+const EDGE = 8;
+
+/** The nearest ancestor that actually scrolls, or null for the window. */
+function scrollParentOf(element) {
+ let node = element?.parentElement;
+
+ while (node && node !== document.body) {
+ const { overflowY } = getComputedStyle(node);
+
+ if (/(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight > node.clientHeight) {
+ return node;
+ }
+
+ node = node.parentElement;
+ }
+
+ return null;
+}
+
+/**
+ * Place a floating flyout against its field.
+ *
+ * Both live inside the same sheet, so they scroll together and nothing has to
+ * follow the scroll. The flyout goes below the field unless only the visible
+ * space above it can hold it, is kept inside the sheet horizontally, and
+ * points its caret at the field's Fail button.
+ */
+export function placeFlyout(element, anchor, container) {
+ const containerRect = container.getBoundingClientRect();
+ const anchorRect = anchor.getBoundingClientRect();
+ const width = element.offsetWidth;
+ const height = element.offsetHeight;
+
+ const scroller = scrollParentOf(anchor);
+ const viewTop = scroller ? scroller.getBoundingClientRect().top : 0;
+ const viewBottom = scroller ? scroller.getBoundingClientRect().bottom : window.innerHeight;
+
+ const fitsBelow = viewBottom - anchorRect.bottom >= height + GAP;
+ const fitsAbove = anchorRect.top - viewTop >= height + GAP;
+
+ // Below, unless only the space above can hold it. Whatever hangs below a
+ // field can always be scrolled to — an absolutely placed panel extends
+ // the scroll area — but a panel pushed above the start of the sheet
+ // cannot be reached at all, so "more room above" is not reason enough.
+ const below = fitsBelow || !fitsAbove;
+
+ const top = below ? anchorRect.bottom - containerRect.top + GAP : anchorRect.top - containerRect.top - height - GAP;
+
+ const maxLeft = Math.max(EDGE, container.clientWidth - width - EDGE);
+ const left = Math.max(EDGE, Math.min(anchorRect.left - containerRect.left, maxLeft));
+
+ element.style.top = `${Math.round(top)}px`;
+ element.style.left = `${Math.round(left)}px`;
+ element.dataset.placement = below ? 'bottom' : 'top';
+
+ const pointAt = anchor.querySelector('[data-answer="fail"]') ?? anchor;
+ const pointRect = pointAt.getBoundingClientRect();
+ const caret = pointRect.left + pointRect.width / 2 - containerRect.left - left;
+
+ element.style.setProperty('--flyout-caret-x', `${Math.round(Math.max(16, Math.min(caret, width - 16)))}px`);
+
+ // Where it now sits, worked out from layout rather than read off its
+ // rendered box: the open animation is still translating it, and a reveal
+ // measured from the moving box stops exactly that many pixels short.
+ const flyoutTop = containerRect.top + top;
+
+ return { placement: below ? 'bottom' : 'top', flyoutTop, flyoutBottom: flyoutTop + height, viewTop, viewBottom, scroller };
+}
+
+/**
+ * Scroll just enough to bring a newly opened flyout fully into view, keeping
+ * a small margin from the edge. If it is taller than the view, its top — the
+ * title, and the first thing to answer — is what stays in view. Returns how
+ * far it scrolled.
+ */
+export function revealFlyout({ flyoutTop, flyoutBottom, viewTop, viewBottom, scroller }) {
+ let delta = 0;
+
+ if (flyoutBottom + EDGE > viewBottom) {
+ delta = flyoutBottom + EDGE - viewBottom;
+ }
+
+ if (flyoutTop - delta - EDGE < viewTop) {
+ delta = flyoutTop - EDGE - viewTop;
+ }
+
+ if (Math.abs(delta) < 1) {
+ return 0;
+ }
+
+ const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
+ (scroller ?? window).scrollBy({ top: delta, behavior: reduce ? 'auto' : 'smooth' });
+
+ return delta;
+}
+
+/**
+ * Run something once the flyout's opening animation has finished.
+ *
+ * The animation translates the panel, and a translated box changes the
+ * scrollable area: a reveal worked out mid-animation is clamped against a
+ * scroll area that is briefly too short, and lands short by exactly the
+ * animated offset. Without an animation — reduced motion — it runs at once.
+ * Returns a timer to clear on teardown.
+ */
+function afterOpening(element, callback) {
+ const style = getComputedStyle(element);
+ const seconds = parseFloat(style.animationDuration) || 0;
+
+ if (style.animationName === 'none' || seconds === 0) {
+ callback();
+ return null;
+ }
+
+ let done = false;
+ const finish = () => {
+ if (done) {
+ return;
+ }
+
+ done = true;
+ element.removeEventListener('animationend', finish);
+ callback();
+ };
+
+ element.addEventListener('animationend', finish);
+
+ // In case the event never comes: a hidden tab, an interrupted animation.
+ return setTimeout(finish, seconds * 1000 + 50);
+}
+
+/**
+ * Keep a defect flyout attached to its field, and close it the natural way.
+ *
+ *
+ *
+ * It closes on a press outside itself and outside its own field, and on
+ * Escape. It deliberately does not close on blur or on scroll: focus leaves
+ * the page for the native photo picker, and scrolling moves the flyout with
+ * its field anyway. Nothing is lost by closing, because every answer is saved
+ * as it is typed.
+ *
+ * A bottom sheet is placed by its stylesheet, so it only takes the
+ * dismissal half of this.
+ */
+export default modifier(function inspectionFlyout(element, [anchor], { presentation = 'floating', onDismiss, focus } = {}) {
+ if (!(anchor instanceof Element)) {
+ return;
+ }
+
+ const container = anchor.closest('.inspection-sheet') ?? document.body;
+ const floating = presentation === 'floating';
+ let frame = null;
+ let revealed = false;
+ let revealTimer = null;
+
+ const place = () => {
+ if (!floating || !element.isConnected || !anchor.isConnected) {
+ return;
+ }
+
+ cancelAnimationFrame(frame);
+ frame = requestAnimationFrame(() => {
+ placeFlyout(element, anchor, container);
+
+ // A panel that opens half off the page is not natural. Bring it
+ // into view once, by the least scroll that will do it, as soon as
+ // it has finished opening; after that the inspector is in charge
+ // of the scrolling.
+ if (!revealed) {
+ revealed = true;
+ revealTimer = afterOpening(element, () => {
+ if (element.isConnected && anchor.isConnected) {
+ revealFlyout(placeFlyout(element, anchor, container));
+ }
+ });
+ }
+ });
+ };
+
+ const dismiss = (reason) => {
+ if (typeof onDismiss === 'function') {
+ onDismiss(reason);
+ }
+ };
+
+ // A press anywhere but the flyout or its own field closes it. Pointerdown,
+ // not click, so pressing another field's Fail closes this one first.
+ const onPointerDown = (event) => {
+ const target = event.target;
+
+ if (element.contains(target) || anchor.contains(target)) {
+ return;
+ }
+
+ dismiss('outside');
+ };
+
+ const onKeyDown = (event) => {
+ if (event.key === 'Escape') {
+ event.stopPropagation();
+ dismiss('escape');
+ }
+ };
+
+ document.addEventListener('pointerdown', onPointerDown, true);
+ document.addEventListener('keydown', onKeyDown, true);
+ window.addEventListener('resize', place);
+
+ // Re-place when anything around it changes height: a field above it
+ // gaining a line, or the flyout itself growing as a photo is added.
+ const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(place) : null;
+ observer?.observe(container);
+ observer?.observe(element);
+
+ place();
+
+ if (focus) {
+ requestAnimationFrame(() => {
+ const target = focus === true ? element : element.querySelector(focus);
+ target?.focus({ preventScroll: true });
+ });
+ }
+
+ return () => {
+ cancelAnimationFrame(frame);
+ clearTimeout(revealTimer);
+ document.removeEventListener('pointerdown', onPointerDown, true);
+ document.removeEventListener('keydown', onKeyDown, true);
+ window.removeEventListener('resize', place);
+ observer?.disconnect();
+ };
+});
diff --git a/addon/modifiers/sync-value.js b/addon/modifiers/sync-value.js
new file mode 100644
index 000000000..0618c0cf5
--- /dev/null
+++ b/addon/modifiers/sync-value.js
@@ -0,0 +1,26 @@
+import { modifier } from 'ember-modifier';
+
+/**
+ * Keep an input's value in step with a bound one, without taking the caret.
+ *
+ * Binding `value={{@value}}` on an input whose every keystroke re-renders the
+ * component sends the caret to the end mid-word, which is what made the form
+ * builder's group inputs unusable. Leaving the value unbound instead means an
+ * input never shows a value that arrives after it was rendered — the stored
+ * answers an inspection loads a moment after the sheet appears.
+ *
+ * So write the value in, and only while the field is not being typed in.
+ *
+ *
+ */
+export default modifier(function syncValue(element, [value]) {
+ if (element.ownerDocument?.activeElement === element) {
+ return;
+ }
+
+ const next = value === null || value === undefined ? '' : String(value);
+
+ if (element.value !== next) {
+ element.value = next;
+ }
+});
diff --git a/addon/modifiers/when-changed.js b/addon/modifiers/when-changed.js
new file mode 100644
index 000000000..a3b50458b
--- /dev/null
+++ b/addon/modifiers/when-changed.js
@@ -0,0 +1,25 @@
+import { modifier } from 'ember-modifier';
+
+/** No value has been seen for this element yet — distinct from `undefined`. */
+const NEVER = Symbol('never');
+const seen = new WeakMap();
+
+/**
+ * Run something when a value changes, but not when it first appears.
+ *
+ * `{{did-update}}` does this and is deprecated for it. The distinction it does
+ * not make, and this does, is between the first render and a later change: a
+ * component that already loads its own data on construction must not load it
+ * again the moment it is inserted.
+ *
+ *
+ */
+export default modifier(function whenChanged(element, [value, callback]) {
+ const previous = seen.has(element) ? seen.get(element) : NEVER;
+
+ seen.set(element, value);
+
+ if (previous !== NEVER && previous !== value && typeof callback === 'function') {
+ callback(value);
+ }
+});
diff --git a/addon/routes.js b/addon/routes.js
index a60c70b1a..3454717b5 100644
--- a/addon/routes.js
+++ b/addon/routes.js
@@ -90,6 +90,7 @@ export default buildRoutes(function () {
this.route('schedules');
this.route('work-orders');
this.route('maintenance-history');
+ this.route('inspections');
this.route('virtual', { path: '/:slug' });
});
this.route('edit', { path: '/edit/:public_id' });
@@ -227,6 +228,29 @@ export default buildRoutes(function () {
this.route('tracking');
});
this.route('maintenance', function () {
+ this.route('inspection-forms', function () {
+ this.route('index', { path: '/' }, function () {
+ this.route('new');
+ this.route('edit', { path: '/edit/:public_id' });
+ this.route('details', { path: '/:public_id' }, function () {
+ this.route('index', { path: '/' });
+ this.route('submissions');
+ });
+ });
+ });
+
+ this.route('inspection-submissions', function () {
+ this.route('index', { path: '/' }, function () {
+ this.route('new');
+ this.route('edit', { path: '/edit/:public_id' });
+ this.route('details', { path: '/:public_id' }, function () {
+ this.route('index', { path: '/' });
+ this.route('photos');
+ this.route('audit');
+ });
+ });
+ });
+
this.route('schedules', function () {
this.route('index', { path: '/' }, function () {
this.route('new');
diff --git a/addon/routes/maintenance/inspection-forms.js b/addon/routes/maintenance/inspection-forms.js
new file mode 100644
index 000000000..97a8c467a
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionFormsRoute extends Route {}
diff --git a/addon/routes/maintenance/inspection-forms/index.js b/addon/routes/maintenance/inspection-forms/index.js
new file mode 100644
index 000000000..3ddda8c95
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms/index.js
@@ -0,0 +1,22 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class MaintenanceInspectionFormsIndexRoute extends Route {
+ @service store;
+
+ queryParams = {
+ page: { refreshModel: true },
+ limit: { refreshModel: true },
+ sort: { refreshModel: true },
+ query: { refreshModel: true },
+ public_id: { refreshModel: true },
+ status: { refreshModel: true },
+ type: { refreshModel: true },
+ created_at: { refreshModel: true },
+ updated_at: { refreshModel: true },
+ };
+
+ model(params) {
+ return this.store.query('inspection-form', { ...params });
+ }
+}
diff --git a/addon/routes/maintenance/inspection-forms/index/details.js b/addon/routes/maintenance/inspection-forms/index/details.js
new file mode 100644
index 000000000..202e83766
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms/index/details.js
@@ -0,0 +1,18 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+export default class MaintenanceInspectionFormsIndexDetailsRoute extends Route {
+ @service store;
+ @service hostRouter;
+ @service notifications;
+
+ model({ public_id }) {
+ return this.store.findRecord('inspection-form', public_id);
+ }
+
+ @action error(error) {
+ this.notifications.serverError(error);
+ return this.hostRouter.transitionTo('maintenance.inspection-forms.index');
+ }
+}
diff --git a/addon/routes/maintenance/inspection-forms/index/details/index.js b/addon/routes/maintenance/inspection-forms/index/details/index.js
new file mode 100644
index 000000000..b8b2de553
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms/index/details/index.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionFormsIndexDetailsIndexRoute extends Route {}
diff --git a/addon/routes/maintenance/inspection-forms/index/details/submissions.js b/addon/routes/maintenance/inspection-forms/index/details/submissions.js
new file mode 100644
index 000000000..a7b227c3a
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms/index/details/submissions.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionFormsIndexDetailsSubmissionsRoute extends Route {}
diff --git a/addon/routes/maintenance/inspection-forms/index/edit.js b/addon/routes/maintenance/inspection-forms/index/edit.js
new file mode 100644
index 000000000..f8b15d6dd
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms/index/edit.js
@@ -0,0 +1,18 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+export default class MaintenanceInspectionFormsIndexEditRoute extends Route {
+ @service store;
+ @service hostRouter;
+ @service notifications;
+
+ model({ public_id }) {
+ return this.store.findRecord('inspection-form', public_id);
+ }
+
+ @action error(error) {
+ this.notifications.serverError(error);
+ return this.hostRouter.transitionTo('maintenance.inspection-forms.index');
+ }
+}
diff --git a/addon/routes/maintenance/inspection-forms/index/new.js b/addon/routes/maintenance/inspection-forms/index/new.js
new file mode 100644
index 000000000..96a813892
--- /dev/null
+++ b/addon/routes/maintenance/inspection-forms/index/new.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionFormsIndexNewRoute extends Route {}
diff --git a/addon/routes/maintenance/inspection-submissions.js b/addon/routes/maintenance/inspection-submissions.js
new file mode 100644
index 000000000..73b235dce
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionSubmissionsRoute extends Route {}
diff --git a/addon/routes/maintenance/inspection-submissions/index.js b/addon/routes/maintenance/inspection-submissions/index.js
new file mode 100644
index 000000000..48c87642b
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index.js
@@ -0,0 +1,25 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class MaintenanceInspectionSubmissionsIndexRoute extends Route {
+ @service store;
+
+ queryParams = {
+ page: { refreshModel: true },
+ limit: { refreshModel: true },
+ sort: { refreshModel: true },
+ query: { refreshModel: true },
+ public_id: { refreshModel: true },
+ status: { refreshModel: true },
+ result: { refreshModel: true },
+ type: { refreshModel: true },
+ vehicle: { refreshModel: true },
+ driver: { refreshModel: true },
+ created_at: { refreshModel: true },
+ updated_at: { refreshModel: true },
+ };
+
+ model(params) {
+ return this.store.query('inspection-submission', { ...params });
+ }
+}
diff --git a/addon/routes/maintenance/inspection-submissions/index/details.js b/addon/routes/maintenance/inspection-submissions/index/details.js
new file mode 100644
index 000000000..ebb148013
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index/details.js
@@ -0,0 +1,18 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+export default class MaintenanceInspectionSubmissionsIndexDetailsRoute extends Route {
+ @service store;
+ @service hostRouter;
+ @service notifications;
+
+ model({ public_id }) {
+ return this.store.findRecord('inspection-submission', public_id);
+ }
+
+ @action error(error) {
+ this.notifications.serverError(error);
+ return this.hostRouter.transitionTo('maintenance.inspection-submissions.index');
+ }
+}
diff --git a/addon/routes/maintenance/inspection-submissions/index/details/audit.js b/addon/routes/maintenance/inspection-submissions/index/details/audit.js
new file mode 100644
index 000000000..63707bc25
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index/details/audit.js
@@ -0,0 +1,8 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionSubmissionsIndexDetailsAuditRoute extends Route {
+ /** The tab renders the submission the record panel is showing. */
+ model() {
+ return this.modelFor('maintenance.inspection-submissions.index.details');
+ }
+}
diff --git a/addon/routes/maintenance/inspection-submissions/index/details/index.js b/addon/routes/maintenance/inspection-submissions/index/details/index.js
new file mode 100644
index 000000000..5ade01403
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index/details/index.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionSubmissionsIndexDetailsIndexRoute extends Route {}
diff --git a/addon/routes/maintenance/inspection-submissions/index/details/photos.js b/addon/routes/maintenance/inspection-submissions/index/details/photos.js
new file mode 100644
index 000000000..a493628b0
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index/details/photos.js
@@ -0,0 +1,8 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionSubmissionsIndexDetailsPhotosRoute extends Route {
+ /** The tab renders the submission the record panel is showing. */
+ model() {
+ return this.modelFor('maintenance.inspection-submissions.index.details');
+ }
+}
diff --git a/addon/routes/maintenance/inspection-submissions/index/edit.js b/addon/routes/maintenance/inspection-submissions/index/edit.js
new file mode 100644
index 000000000..9e6f0bb2f
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index/edit.js
@@ -0,0 +1,18 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+
+export default class MaintenanceInspectionSubmissionsIndexEditRoute extends Route {
+ @service store;
+ @service hostRouter;
+ @service notifications;
+
+ model({ public_id }) {
+ return this.store.findRecord('inspection-submission', public_id);
+ }
+
+ @action error(error) {
+ this.notifications.serverError(error);
+ return this.hostRouter.transitionTo('maintenance.inspection-submissions.index');
+ }
+}
diff --git a/addon/routes/maintenance/inspection-submissions/index/new.js b/addon/routes/maintenance/inspection-submissions/index/new.js
new file mode 100644
index 000000000..f5c57fe09
--- /dev/null
+++ b/addon/routes/maintenance/inspection-submissions/index/new.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class MaintenanceInspectionSubmissionsIndexNewRoute extends Route {}
diff --git a/addon/routes/management/vehicles/index/details/inspections.js b/addon/routes/management/vehicles/index/details/inspections.js
new file mode 100644
index 000000000..9a9884c57
--- /dev/null
+++ b/addon/routes/management/vehicles/index/details/inspections.js
@@ -0,0 +1,3 @@
+import Route from '@ember/routing/route';
+
+export default class ManagementVehiclesIndexDetailsInspectionsRoute extends Route {}
diff --git a/addon/services/inspection-form-actions.js b/addon/services/inspection-form-actions.js
new file mode 100644
index 000000000..4275e9367
--- /dev/null
+++ b/addon/services/inspection-form-actions.js
@@ -0,0 +1,196 @@
+import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import { action, set } from '@ember/object';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import copyToClipboard from '@fleetbase/ember-core/utils/copy-to-clipboard';
+import { normalizeFieldGroups, serializeFieldGroups } from '../utils/inspection-form-structure';
+
+/** A link's life when nobody chooses; the server applies the same when left blank. */
+const DEFAULT_LINK_TTL_HOURS = 72;
+
+/** A date as a `datetime-local` input reads it: local time, to the minute. */
+export function toDatetimeLocal(date) {
+ const pad = (n) => String(n).padStart(2, '0');
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
+}
+
+/** The default expiry, filled into the form so the dispatcher can see it. */
+function defaultLinkExpiry() {
+ return toDatetimeLocal(new Date(Date.now() + DEFAULT_LINK_TTL_HOURS * 60 * 60 * 1000));
+}
+
+export default class InspectionFormActionsService extends ResourceActionService {
+ @service fetch;
+ @service notifications;
+ @service intl;
+
+ /**
+ * When a public link was last generated. Every open link list watches
+ * this — the one inside the generate modal and the one on the form's
+ * details panel — so a new link appears in both without a reload.
+ */
+ @tracked linksChangedAt = 0;
+
+ constructor() {
+ super(...arguments);
+ this.initialize('inspection-form', {
+ defaultAttributes: {
+ type: 'dvir',
+ status: 'draft',
+ items: [],
+ settings: {
+ require_signature: true,
+ create_issue_on_failure: true,
+ create_work_order_on_failure: false,
+ },
+ },
+ });
+ }
+
+ transition = {
+ view: (form) => this.transitionTo('maintenance.inspection-forms.index.details', form),
+ edit: (form) => this.transitionTo('maintenance.inspection-forms.index.edit', form),
+ create: () => this.transitionTo('maintenance.inspection-forms.index.new'),
+ };
+
+ /**
+ * A form's structure — its field groups and their fields.
+ *
+ * The `inspection-form` model belongs to `@fleetbase/fleetops-data` and
+ * declares no attribute for the structure, so Ember Data drops it on the
+ * way in and on the way out. Both directions go through the internal
+ * endpoint directly instead: a read carries `field_groups` beside a flat
+ * `fields` list, and a write posts the whole thing back under
+ * `inspection_form.field_groups`, which is the key
+ * `InspectionFormController::syncStructureFromRequest()` reads.
+ */
+ async loadStructure(form) {
+ if (!form?.id) {
+ return [];
+ }
+
+ const response = await this.fetch.get(`inspection-forms/${form.id}`);
+
+ return normalizeFieldGroups(response?.inspection_form ?? response?.inspectionForm ?? response);
+ }
+
+ /**
+ * Writes the whole structure. The builder always posts every group and
+ * every field, so the server prunes what the post no longer lists — a
+ * field the post dropped is a field the author deleted.
+ */
+ async saveStructure(form, groups) {
+ if (!form?.id || !Array.isArray(groups) || groups.length === 0) {
+ return null;
+ }
+
+ return this.fetch.put(`inspection-forms/${form.id}`, {
+ inspection_form: { field_groups: serializeFieldGroups(groups) },
+ });
+ }
+
+ @action async publish(form) {
+ try {
+ await this.fetch.post(`inspection-forms/${form.id}/publish`);
+ this.notifications.success('Inspection form published.');
+ await this.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ @action async archive(form) {
+ try {
+ await this.fetch.post(`inspection-forms/${form.id}/archive`);
+ this.notifications.success('Inspection form archived.');
+ await this.refresh();
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ /**
+ * Say whether a PIN went out, when one was asked to be sent. A delivery
+ * that failed is a warning and not an error: the link exists either way,
+ * and its PIN is on screen to share by hand.
+ */
+ notifyPinDelivery(delivery) {
+ if (!delivery) {
+ this.notifications.success(this.intl.t('inspection.link.generated-toast'));
+ return;
+ }
+
+ if (delivery.sent) {
+ this.notifications.success(this.intl.t(`inspection.link.pin-sent-${delivery.via}`, { to: delivery.to }));
+ return;
+ }
+
+ this.notifications.warning(this.intl.t('inspection.link.pin-not-sent', { reason: delivery.error }));
+ }
+
+ @action generateLink(form) {
+ if (form.status !== 'published') {
+ this.notifications.warning(this.intl.t('inspection.link.publish-first'));
+ return;
+ }
+
+ // Who the link is for, and the driver and vehicle being inspected, are
+ // each optional: anyone in the organisation may complete an inspection.
+ const formState = {
+ assignee: null,
+ driver: null,
+ vehicle: null,
+ expires_at: defaultLinkExpiry(),
+ pin_delivery: 'none',
+ generated: null,
+ };
+
+ return this.modalsManager.show('modals/inspection-link', {
+ title: 'Generate Inspection Link',
+ acceptButtonText: 'Generate Link',
+ acceptButtonIcon: 'link',
+ declineButtonText: 'Close',
+ form,
+ formState,
+ confirm: async (modal) => {
+ modal.startLoading();
+ try {
+ const response = await this.fetch.post(`inspection-forms/${form.id}/generate-link`, {
+ assignee: formState.assignee?.id,
+ driver: formState.driver?.id,
+ vehicle: formState.vehicle?.id,
+ // The input holds local time with no zone; sent as it was,
+ // the server read it as its own zone and the link expired
+ // hours early or late. Sent as an instant, it means what
+ // the dispatcher picked.
+ expires_at: formState.expires_at ? new Date(formState.expires_at).toISOString() : null,
+ single_use: true,
+ pin_delivery: formState.pin_delivery,
+ });
+ const link = response?.link;
+ const url = link?.path ? `${window.location.origin}${link.path}` : null;
+
+ // Shown in the modal until it closes: the link, and the PIN to
+ // share with it by some other way.
+ set(formState, 'generated', { url, pin: link?.pin ?? null });
+
+ // Every open link list watches this and reloads, so the link
+ // that was just minted appears to be read, copied again or
+ // revoked — rather than living only in the clipboard.
+ this.linksChangedAt = Date.now();
+
+ if (url) {
+ await copyToClipboard(url);
+ }
+
+ this.notifyPinDelivery(response?.pin_delivery);
+
+ modal.stopLoading();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+}
diff --git a/addon/services/inspection-submission-actions.js b/addon/services/inspection-submission-actions.js
new file mode 100644
index 000000000..9fdd7af1f
--- /dev/null
+++ b/addon/services/inspection-submission-actions.js
@@ -0,0 +1,191 @@
+import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
+import { action } from '@ember/object';
+import { inject as service } from '@ember/service';
+
+export default class InspectionSubmissionActionsService extends ResourceActionService {
+ @service fetch;
+ @service notifications;
+ @service modalsManager;
+ @service intl;
+
+ constructor() {
+ super(...arguments);
+ this.initialize('inspection-submission', {
+ defaultAttributes: {
+ type: 'dvir',
+ status: 'draft',
+ source: 'console',
+ item_results: [],
+ },
+ });
+ }
+
+ transition = {
+ view: (submission) => this.transitionTo('maintenance.inspection-submissions.index.details', submission),
+ edit: (submission) => this.transitionTo('maintenance.inspection-submissions.index.edit', submission),
+ create: () => this.transitionTo('maintenance.inspection-submissions.index.new'),
+ };
+
+ /**
+ * The answers filed against a submission, as the resource projects them:
+ * every value beside the field it answers, with `file:` references
+ * resolved to something fetchable.
+ *
+ * The `inspection-submission` model belongs to `@fleetbase/fleetops-data`
+ * and declares no `custom_field_values` relationship, so Ember Data drops
+ * the projection; this reads it from the internal payload directly.
+ *
+ * @return {Object} the answers keyed by the field uuid they answer
+ */
+ async loadAnswers(submission) {
+ if (!submission?.id) {
+ return {};
+ }
+
+ const response = await this.fetch.get(`inspection-submissions/${submission.id}`);
+ const record = response?.inspection_submission ?? response?.inspectionSubmission ?? response;
+ const values = Array.isArray(record?.custom_field_values) ? record.custom_field_values : [];
+
+ return values.reduce((carry, value) => {
+ const key = value?.custom_field;
+ if (key) {
+ carry[key] = value.value;
+ }
+
+ return carry;
+ }, {});
+ }
+
+ /**
+ * Writes the answers. `inspection_submission.custom_field_values` is what
+ * `InspectionSubmissionController::syncAnswersFromRequest()` reads, and it
+ * is the same body the driver API accepts — the console and the app write
+ * the same rows, and the server derives the item results from the
+ * pass-fail answers among them.
+ *
+ * @param {Array} rows [{ custom_field, value, value_type }]
+ */
+ async saveAnswers(submission, rows) {
+ if (!submission?.id || !Array.isArray(rows) || rows.length === 0) {
+ return null;
+ }
+
+ return this.fetch.put(`inspection-submissions/${submission.id}`, {
+ inspection_submission: { custom_field_values: rows },
+ });
+ }
+
+ @action submit(submission) {
+ return this.modalsManager.confirm({
+ title: this.intl.t('inspection.follow-up.submit-title'),
+ body: this.intl.t('inspection.follow-up.submit-summary'),
+ acceptButtonText: this.intl.t('inspection.follow-up.submit-accept'),
+ acceptButtonIcon: 'paper-plane',
+ declineButtonText: this.intl.t('inspection.follow-up.cancel'),
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ try {
+ await this.postAction(submission, 'submit', 'Inspection submitted.');
+ modal.done();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ /**
+ * Each of these used to fire on click: the first anyone knew of a new
+ * issue was a toast and an id. They ask first, and say what they are about
+ * to make and from which failed checks.
+ */
+ @action createIssue(submission) {
+ return this.confirmFollowUp(submission, {
+ kind: 'issue',
+ title: this.intl.t('inspection.follow-up.create-issue-title'),
+ summary: this.intl.t('inspection.follow-up.create-issue-summary'),
+ acceptButtonText: this.intl.t('inspection.follow-up.create-issue-accept'),
+ acceptButtonIcon: 'triangle-exclamation',
+ endpoint: 'create-issue',
+ message: 'Issue created from failed inspection items.',
+ });
+ }
+
+ @action createWorkOrder(submission) {
+ return this.confirmFollowUp(submission, {
+ kind: 'work-order',
+ title: this.intl.t('inspection.follow-up.create-work-order-title'),
+ summary: this.intl.t('inspection.follow-up.create-work-order-summary'),
+ acceptButtonText: this.intl.t('inspection.follow-up.create-work-order-accept'),
+ acceptButtonIcon: 'clipboard-list',
+ dueNote: this.dueNoteFor(submission),
+ endpoint: 'create-work-order',
+ message: 'Work order created from failed inspection items.',
+ });
+ }
+
+ @action resolve(submission) {
+ return this.confirmFollowUp(submission, {
+ kind: 'resolve',
+ title: this.intl.t('inspection.follow-up.resolve-title'),
+ summary: this.intl.t('inspection.follow-up.resolve-summary'),
+ acceptButtonText: this.intl.t('inspection.follow-up.resolve-accept'),
+ acceptButtonIcon: 'check',
+ endpoint: 'resolve',
+ message: 'Inspection resolved.',
+ });
+ }
+
+ /** When the work order falls due, which the server takes from the worst failure. */
+ dueNoteFor(submission) {
+ const failures = (submission?.item_results ?? []).filter((item) => item.passed === false);
+ const critical = failures.some((item) => item.severity === 'critical');
+ const due = new Date();
+ due.setDate(due.getDate() + (critical ? 1 : 7));
+
+ return this.intl.t('inspection.follow-up.due-note', { due: due.toLocaleDateString() });
+ }
+
+ confirmFollowUp(submission, { kind, title, summary, acceptButtonText, acceptButtonIcon, dueNote, endpoint, message }) {
+ const failures = (submission?.item_results ?? []).filter((item) => item.passed === false);
+ const nothingToDo = kind !== 'resolve' && failures.length === 0;
+
+ return this.modalsManager.show('modals/inspection-follow-up', {
+ title,
+ summary,
+ submission,
+ kind,
+ dueNote,
+ acceptButtonText,
+ acceptButtonIcon,
+ // Nothing failed: the server would only say so once the request was
+ // already made, which is a strange moment to find out.
+ acceptButtonDisabled: nothingToDo,
+ declineButtonText: this.intl.t('inspection.follow-up.cancel'),
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ try {
+ await this.postAction(submission, endpoint, message);
+ modal.done();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+ }
+
+ async postAction(submission, actionName, message) {
+ const response = await this.fetch.post(`inspection-submissions/${submission.id}/${actionName}`);
+
+ // The endpoints answer with what they did, and say so when a submission
+ // had nothing to raise.
+ this.notifications.success(response?.message ?? message);
+ await this.refresh();
+
+ return response;
+ }
+}
diff --git a/addon/services/movement-tracker.js b/addon/services/movement-tracker.js
index 38b2545d6..eb0ad59db 100644
--- a/addon/services/movement-tracker.js
+++ b/addon/services/movement-tracker.js
@@ -18,6 +18,7 @@ import { inject as service } from '@ember/service';
import { getOwner } from '@ember/application';
import { task, timeout } from 'ember-concurrency';
import { debug } from '@ember/debug';
+import { registerDestructor } from '@ember/destroyable';
import getModelName from '@fleetbase/ember-core/utils/get-model-name';
import LeafletTrackingMarkerComponent from '../components/leaflet-tracking-marker';
@@ -30,12 +31,15 @@ export class EventBuffer {
/** @type {import('./map-manager').default|null} */
mapManager = null;
+ latestAfaqyPosition = null;
constructor(model, { callback = null, waitTime = 1000 * 3, mapManager = null }) {
this.model = model;
this.callback = callback;
this.waitTime = waitTime;
this.mapManager = mapManager;
+ const initialPosition = model?.telematics?.last_provider === 'afaqy' ? Date.parse(model.telematics.last_event_at) : NaN;
+ this.latestAfaqyPosition = Number.isFinite(initialPosition) ? initialPosition : null;
}
/**
@@ -105,6 +109,12 @@ export class EventBuffer {
for (const output of eventsToProcess) {
const { event, data } = output;
+ if (data?.additionalData?.provider === 'afaqy') {
+ const observedAt = Date.parse(data.additionalData.position_at);
+ if (!Number.isFinite(observedAt) || (this.latestAfaqyPosition !== null && observedAt < this.latestAfaqyPosition)) continue;
+ this.latestAfaqyPosition = observedAt;
+ this.model?.setProperties?.({ location: data.location, speed: data.speed, heading: data.heading });
+ }
// Resolve marker via adapter (provider-agnostic) or Leaflet fallback
const markerId = this.#getMarkerId();
@@ -176,10 +186,15 @@ export default class MovementTrackerService extends Service {
@tracked channels = [];
@tracked buffers = new Map();
+ reconnectConsumer;
constructor() {
super(...arguments);
this.registerTrackingMarker();
+ registerDestructor(this, () => {
+ this.reconnectConsumer?.close();
+ this.buffers.forEach((buffer) => buffer.stop());
+ });
}
#getOwner(owner = null) {
@@ -213,9 +228,14 @@ export default class MovementTrackerService extends Service {
}
closeChannels() {
+ this.watchReconnect.cancelAll();
+ this.reconnectConsumer?.close();
+ this.buffers.forEach((buffer) => buffer.stop());
+ this.buffers.clear();
this.channels.forEach((channel) => {
channel.close();
});
+ this.channels = [];
}
watch(models = []) {
@@ -224,9 +244,39 @@ export default class MovementTrackerService extends Service {
});
}
+ @task *watchReconnect() {
+ this.reconnectConsumer = this.socket.instance().listener('connect').createConsumer();
+ while (true) {
+ const { done } = yield this.reconnectConsumer.next();
+ if (done) break;
+ // Refresh only the visible AFAQY assets, with bounded request concurrency.
+ const buffers = [...this.buffers.values()].filter((buffer) => buffer.model?.telematics?.last_provider === 'afaqy');
+ for (let i = 0; i < buffers.length; i += 5) {
+ yield Promise.allSettled(
+ buffers.slice(i, i + 5).map(async (buffer) => {
+ const model = await buffer.model.reload();
+ buffer.clear();
+ buffer.add({
+ event: `${getModelName(model)}.location_changed`,
+ created_at: new Date().toISOString(),
+ data: {
+ id: model.id,
+ location: model.location,
+ speed: model.speed,
+ heading: model.heading,
+ additionalData: { provider: 'afaqy', position_at: model.telematics?.last_event_at },
+ },
+ });
+ })
+ );
+ }
+ }
+ }
+
async track(model, options = {}) {
// Create socket instance
const socket = this.socket.instance();
+ if (this.watchReconnect.isIdle) this.watchReconnect.perform();
// Get model type and identifier
const type = getModelName(model);
@@ -246,7 +296,7 @@ export default class MovementTrackerService extends Service {
this.channels = [...this.channels, channel];
// Listen to the channel for events
- await channel.listener('subscribe').once();
+ if (channel.state !== 'subscribed') await channel.listener('subscribe').once();
// Create event buffer for tracking model
const eventBuffer = this.#getBuffer(channelId, model, options);
diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css
index dbc585215..3ecf92a5e 100644
--- a/addon/styles/fleetops-engine.css
+++ b/addon/styles/fleetops-engine.css
@@ -8573,6 +8573,348 @@ body[data-theme='dark'] .fleetbase-ai-create-order-preview__cancelled {
color: #fca5a5;
}
+/* ── The follow-up an inspection raises ──
+ What an action will do, previewed before it happens, and what it made,
+ shown afterwards as something you can open rather than a bare id. */
+.inspection-follow-up__heading {
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #6b7280;
+ margin-bottom: 0.375rem;
+}
+
+.inspection-follow-up__list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.inspection-follow-up__item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ background-color: #f9fafb;
+ padding: 0.375rem 0.5rem;
+}
+
+.inspection-follow-up__label {
+ flex: 1 1 auto;
+ min-width: 0;
+ font-size: 0.8125rem;
+ font-weight: 500;
+}
+
+.inspection-follow-up__note,
+.inspection-follow-up__empty {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.inspection-follow-up__empty {
+ border: 1px dashed #e5e7eb;
+ border-radius: 0.375rem;
+ padding: 0.625rem 0.75rem;
+}
+
+body[data-theme='dark'] .inspection-follow-up__item {
+ border-color: #374151;
+ background-color: #1f2937;
+}
+
+body[data-theme='dark'] .inspection-follow-up__empty {
+ border-color: #374151;
+}
+
+body[data-theme='dark'] .inspection-follow-up__heading,
+body[data-theme='dark'] .inspection-follow-up__note,
+body[data-theme='dark'] .inspection-follow-up__empty {
+ color: #9ca3af;
+}
+
+/* The record's own follow-ups: a row that opens what it names. */
+.inspection-followup {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ width: 100%;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ background-color: #f9fafb;
+ padding: 0.625rem 0.75rem;
+ text-align: left;
+ cursor: pointer;
+}
+
+.inspection-followup:hover {
+ border-color: #9ca3af;
+}
+
+.inspection-followup__head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.inspection-followup__kind {
+ font-size: 0.625rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: #6b7280;
+}
+
+.inspection-followup__id {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 0.6875rem;
+ color: #6b7280;
+}
+
+.inspection-followup__open {
+ margin-left: auto;
+ font-size: 0.6875rem;
+ color: #9ca3af;
+}
+
+.inspection-followup__title {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: #111827;
+}
+
+.inspection-followup__meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem;
+}
+
+.inspection-followup__type {
+ font-size: 0.6875rem;
+ color: #6b7280;
+}
+
+body[data-theme='dark'] .inspection-followup {
+ border-color: #374151;
+ background-color: #1f2937;
+}
+
+body[data-theme='dark'] .inspection-followup:hover {
+ border-color: #6b7280;
+}
+
+body[data-theme='dark'] .inspection-followup__title {
+ color: #f9fafb;
+}
+
+/* ── Pills ──
+ The identity pill is how the console shows a linked record, but it gave no
+ sign it could be clicked: the cursor stayed default and nothing responded.
+ Only pills that were given something to do react. */
+.fleetbase-pill > a[href] {
+ border-radius: 0.375rem;
+ padding: 0.125rem;
+ margin: -0.125rem;
+ transition:
+ background-color 120ms ease,
+ opacity 120ms ease;
+}
+
+.fleetbase-pill > a[href]:hover {
+ background-color: rgb(0 0 0 / 4%);
+}
+
+.fleetbase-pill > a[href]:hover .text-sm {
+ text-decoration: underline;
+}
+
+body[data-theme='dark'] .fleetbase-pill > a[href]:hover {
+ background-color: rgb(255 255 255 / 6%);
+}
+
+/* ── A link out of a details panel to the record it names ──
+ The form name was a bare button with no styling and a full-size icon
+ sitting apart from it; it did not read as a link at all. */
+.field-info-container > .inspection-record__link,
+.inspection-record__link {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ color: #3b82f6;
+ cursor: pointer;
+ text-align: left;
+ background: none;
+ border: 0;
+ padding: 0;
+}
+
+.field-info-container > .inspection-record__link:hover,
+.inspection-record__link:hover {
+ color: #2563eb;
+ text-decoration: underline;
+}
+
+.inspection-record__link-icon {
+ flex-shrink: 0;
+ opacity: 0.75;
+}
+
+body[data-theme='dark'] .field-info-container > .inspection-record__link,
+body[data-theme='dark'] .inspection-record__link {
+ color: #60a5fa;
+}
+
+body[data-theme='dark'] .field-info-container > .inspection-record__link:hover,
+body[data-theme='dark'] .inspection-record__link:hover {
+ color: #93c5fd;
+}
+
+/* ── A vehicle's inspection history ──
+ A table could not survive the overlay's width: six columns squeezed into
+ a 580px panel wrapped the form name onto three lines and pushed the date
+ off the edge behind a scrollbar. One block per inspection instead. */
+.inspection-history {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.inspection-history__card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ width: 100%;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ background-color: #f9fafb;
+ padding: 0.75rem;
+ text-align: left;
+ cursor: pointer;
+}
+
+.inspection-history__card:hover {
+ border-color: #9ca3af;
+}
+
+.inspection-history__head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem;
+}
+
+.inspection-history__form {
+ flex: 1 1 auto;
+ min-width: 0;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: #111827;
+}
+
+.inspection-history__meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem;
+ font-size: 0.6875rem;
+ color: #6b7280;
+}
+
+.inspection-history__meta > span {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.inspection-history__meta-icon {
+ opacity: 0.7;
+}
+
+.inspection-history__failed {
+ color: #b91c1c;
+ font-weight: 500;
+}
+
+.inspection-history__id {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 0.625rem;
+ color: #9ca3af;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+body[data-theme='dark'] .inspection-history__card {
+ border-color: #374151;
+ background-color: #1f2937;
+}
+
+body[data-theme='dark'] .inspection-history__card:hover {
+ border-color: #6b7280;
+}
+
+body[data-theme='dark'] .inspection-history__form {
+ color: #f9fafb;
+}
+
+body[data-theme='dark'] .inspection-history__failed {
+ color: #fca5a5;
+}
+
+/* ── Inspection result, status and defect badges ──
+ ember-ui's badge palette has no entries for these values, so a result of
+ "passed", a "submitted" or "needs review" inspection, or an unsafe defect
+ would render as an unstyled chip. `needs_review` is selectable from the
+ status field and counted by the hub, so it is reachable, not theoretical.
+ Colors follow the ember-ui badge.css convention
+ (800 background / 700 border / 100 text). */
+.status-badge.passed-status-badge > span {
+ background-color: #166534;
+ border-color: #15803d;
+ color: #dcfce7;
+}
+
+.status-badge.passed-status-badge > span svg {
+ color: #86efac;
+}
+
+.status-badge.submitted-status-badge > span {
+ background-color: #1e40af;
+ border-color: #1d4ed8;
+ color: #dbeafe;
+}
+
+.status-badge.submitted-status-badge > span svg {
+ color: #93c5fd;
+}
+
+/* Waiting on a supervisor: the warning family, like ember-ui's "pending". */
+.status-badge.needs-review-status-badge > span {
+ background-color: #92400e;
+ border-color: #b45309;
+ color: #fef3c7;
+}
+
+.status-badge.needs-review-status-badge > span svg {
+ color: #fcd34d;
+}
+
+.status-badge.unsafe-status-badge > span {
+ background-color: #991b1b;
+ border-color: #b91c1c;
+ color: #fee2e2;
+}
+
+.status-badge.unsafe-status-badge > span svg {
+ color: #fca5a5;
+}
+
/* ── Attachment lists (devices, equipment, trailers, towing history) ── */
.fleetops-attachment-list {
display: flex;
@@ -8883,3 +9225,1698 @@ body[data-theme='dark'] .filter-multi-model > .clear-button {
height: 1.625rem;
min-width: 4rem;
}
+
+/* ==========================================================================
+ Inspection sheet — "Promotion"
+ --------------------------------------------------------------------------
+ The author's grid survives, but only for fields that stay compact. The
+ moment a field needs room — a pass-fail that failed and now owes a
+ severity, a comment and photos, or a note, upload or signature that never
+ fitted a column — it is promoted out of the grid into a full-width band at
+ the end of its group. Nothing stretches its neighbour, because after
+ promotion it has no neighbour.
+
+ Each group header carries one dot per field, so an inspector can see at a
+ glance what is still open without reading a label.
+
+ Rendered identically by the console's submission form, the read-only
+ record, and the public link a driver opens on a phone.
+ ========================================================================== */
+
+.inspection-sheet,
+.inspection-flyout {
+ --ins-bg: #fff;
+ --ins-bg-sunken: #f9fafb;
+ --ins-border: #e5e7eb;
+ --ins-border-strong: #d1d5db;
+ --ins-text: #111827;
+ --ins-text-soft: #374151;
+ --ins-text-muted: #6b7280;
+ --ins-text-faint: #9ca3af;
+ --ins-pass: #16a34a;
+ --ins-pass-text: #15803d;
+ --ins-pass-edge: #bbf7d0;
+ --ins-pass-fill: #f0fdf4;
+ --ins-fail: #dc2626;
+ --ins-fail-text: #b91c1c;
+ --ins-fail-edge: #fecaca;
+ --ins-fail-fill: #fef2f2;
+ --ins-fail-fill-strong: #fee2e2;
+ --ins-fail-field: #fff;
+ --ins-fail-placeholder: #f87171;
+ --ins-fail-ring: rgb(220 38 38 / 22%);
+ --ins-fail-hatch-a: #fecaca;
+ --ins-fail-hatch-b: #fee2e2;
+ --ins-na: #6b7280;
+ --ins-warn: #d97706;
+ --ins-warn-text: #92400e;
+ --ins-warn-strong: #b45309;
+ --ins-hatch-a: #e5e7eb;
+ --ins-hatch-b: #f3f4f6;
+ --ins-mono: ui-monospace, 'IBM Plex Mono', sfmono-regular, menlo, monaco, consolas, monospace;
+}
+
+.inspection-sheet {
+ /* The floating flyouts are positioned against the sheet. */
+ position: relative;
+ display: flex;
+ flex-direction: column;
+
+ /*
+ * The sheet is measured, not the window. It renders in a ~600px overlay
+ * panel on a wide screen and full width on a phone, so a viewport media
+ * query would collapse the author's columns in exactly the wrong places.
+ */
+ container-type: inline-size;
+}
+
+body[data-theme='dark'] .inspection-sheet,
+body[data-theme='dark'] .inspection-flyout {
+ --ins-bg: #1f2937;
+ --ins-bg-sunken: #1f2937;
+ --ins-border: #374151;
+ --ins-border-strong: #4b5563;
+ --ins-text: #f9fafb;
+ --ins-text-soft: #e5e7eb;
+ --ins-text-muted: #9ca3af;
+ --ins-text-faint: #6b7280;
+ --ins-pass: #16a34a;
+ --ins-pass-text: #86efac;
+ --ins-pass-edge: #2f5a3c;
+ --ins-pass-fill: #1c2e22;
+ --ins-fail: #dc2626;
+ --ins-fail-text: #fca5a5;
+ --ins-fail-edge: #4c2326;
+ --ins-fail-fill: #241b1e;
+ --ins-fail-fill-strong: #3f1d1d;
+ --ins-fail-field: #1b1416;
+ --ins-fail-placeholder: #a86b6e;
+ --ins-fail-ring: rgb(239 68 68 / 28%);
+ --ins-fail-hatch-a: #3b2326;
+ --ins-fail-hatch-b: #2c1b1e;
+ --ins-na: #9ca3af;
+ --ins-warn: #b45309;
+ --ins-warn-text: #fcd34d;
+ --ins-warn-strong: #fbbf24;
+ --ins-hatch-a: #374151;
+ --ins-hatch-b: #2b3644;
+}
+
+/* The sheet is one card, not one card per group. */
+.inspection-sheet__body {
+ border: 1px solid var(--ins-border);
+ border-radius: 0.5rem;
+ background-color: var(--ins-bg);
+ box-shadow: 0 1px 2px rgb(0 0 0 / 6%);
+ overflow: hidden;
+}
+
+body[data-theme='dark'] .inspection-sheet__body {
+ box-shadow: 0 1px 3px rgb(0 0 0 / 35%);
+}
+
+.inspection-sheet__groups {
+ padding: 1rem;
+}
+
+/* --- group header ------------------------------------------------------- */
+
+.inspection-group__header {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ padding-bottom: 0.5625rem;
+ border-bottom: 1px solid var(--ins-border);
+}
+
+.inspection-group + .inspection-group {
+ margin-top: 1.75rem;
+}
+
+.inspection-group__name {
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: 1;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ins-text-soft);
+ min-width: 0;
+}
+
+/* One dot per field: what is answered, what failed, what is still open. */
+.inspection-group__dots {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ flex-shrink: 0;
+}
+
+.inspection-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ box-sizing: border-box;
+ background-color: var(--ins-text-faint);
+}
+
+.inspection-dot[data-marker='pass'] {
+ background-color: var(--ins-pass);
+}
+
+.inspection-dot[data-marker='fail'] {
+ background-color: var(--ins-fail);
+}
+
+.inspection-dot[data-marker='na'] {
+ background-color: var(--ins-na);
+}
+
+.inspection-dot[data-marker='outstanding'] {
+ background-color: var(--ins-warn-strong);
+}
+
+.inspection-dot[data-marker='empty'] {
+ background-color: transparent;
+ border: 1px solid var(--ins-text-faint);
+}
+
+.inspection-group__meta {
+ margin-left: auto;
+ flex-shrink: 0;
+ font-family: var(--ins-mono);
+ font-size: 0.6875rem;
+ line-height: 1;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ color: var(--ins-text-faint);
+ white-space: nowrap;
+}
+
+.inspection-group__meta[data-outstanding='true'] {
+ color: var(--ins-warn-strong);
+}
+
+.inspection-group__description {
+ margin: 0.5rem 0 0;
+ font-size: 0.75rem;
+ line-height: 1.45;
+ color: var(--ins-text-muted);
+}
+
+.inspection-group__empty {
+ padding: 0.75rem 0;
+ font-size: 0.75rem;
+ color: var(--ins-text-muted);
+}
+
+/* --- the author's grid, for fields that stay compact -------------------- */
+
+.inspection-group__grid {
+ display: grid;
+ gap: 1rem 0.875rem;
+ align-items: start;
+ padding-top: 0.875rem;
+}
+
+.inspection-group__grid > .inspection-band {
+ grid-column: 1 / -1;
+}
+
+.inspection-group__grid[data-columns='1'] {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.inspection-group__grid[data-columns='2'] {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.inspection-group__grid[data-columns='3'] {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.inspection-group__grid[data-columns='4'] {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+}
+
+/* A narrow sheet cannot hold the wider authored grids. */
+@container (width <= 660px) {
+ .inspection-group__grid[data-columns='3'],
+ .inspection-group__grid[data-columns='4'] {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+/*
+ * A phone-width sheet stacks every grid. Matched to the `[data-columns]`
+ * rules' specificity: as a bare class it lost to them, and an authored two- or
+ * three-column group stayed in two 160px columns on a phone.
+ */
+@container (width <= 420px) {
+ .inspection-group__grid[data-columns] {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+/* --- a compact field --------------------------------------------------- */
+
+.inspection-cell {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ min-width: 0;
+}
+
+.inspection-cell__label {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ line-height: 1.3;
+ color: var(--ins-text);
+}
+
+.inspection-required {
+ color: var(--ins-fail);
+ margin-left: 0.125rem;
+}
+
+.inspection-cell__hint {
+ font-size: 0.75rem;
+ line-height: 1.4;
+ color: var(--ins-text-muted);
+ white-space: pre-wrap;
+}
+
+.inspection-cell__control {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 0;
+ width: 100%;
+}
+
+.inspection-cell__control > .fleetbase-model-select,
+.inspection-cell__control > .ember-basic-dropdown {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.inspection-unit {
+ flex-shrink: 0;
+ font-family: var(--ins-mono);
+ font-size: 0.75rem;
+ color: var(--ins-text-muted);
+}
+
+.inspection-note {
+ font-size: 0.75rem;
+ color: var(--ins-text-faint);
+}
+
+/* A required answer that is still missing says so on its own edge. */
+.inspection-input--outstanding.form-input,
+.inspection-input--outstanding .ember-power-select-trigger {
+ border-color: var(--ins-warn);
+}
+
+/* --- the pass / fail / n-a and severity segmented controls -------------- */
+
+.inspection-choice {
+ display: flex;
+ align-items: stretch;
+ min-height: 2.25rem;
+ border: 1px solid var(--ins-border);
+ border-radius: 0.375rem;
+ overflow: hidden;
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+/* On a promoted band the control is pushed to the right at its natural size. */
+.inspection-choice--auto {
+ flex: 0 0 auto;
+ margin-left: auto;
+}
+
+.inspection-choice__option {
+ appearance: none;
+ border: 0;
+ background: transparent;
+ flex: 1 1 0;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.375rem 0.5rem;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ line-height: 1;
+ color: var(--ins-text-muted);
+ cursor: pointer;
+ white-space: nowrap;
+ transition:
+ background-color 0.12s ease,
+ color 0.12s ease;
+}
+
+.inspection-choice--auto .inspection-choice__option {
+ flex: 0 0 auto;
+ padding: 0 0.75rem;
+}
+
+.inspection-choice__option + .inspection-choice__option {
+ border-left: 1px solid var(--ins-border);
+}
+
+.inspection-choice__option:hover:not(:disabled) {
+ color: var(--ins-text);
+}
+
+.inspection-choice__option:focus-visible {
+ outline: 2px solid #2563eb;
+ outline-offset: -2px;
+}
+
+.inspection-choice__option:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.inspection-choice__option[aria-pressed='true'] {
+ font-weight: 600;
+ color: #fff;
+ background-color: var(--ins-na);
+}
+
+.inspection-choice__option[aria-pressed='true'][data-answer='pass'] {
+ background-color: var(--ins-pass);
+}
+
+.inspection-choice__option[aria-pressed='true'][data-answer='fail'],
+.inspection-choice__option[aria-pressed='true'][data-answer='severity'] {
+ background-color: var(--ins-fail);
+}
+
+/* --- a promoted band ---------------------------------------------------- */
+
+.inspection-band {
+ border: 1px solid var(--ins-border);
+ border-radius: 0.375rem;
+ overflow: hidden;
+}
+
+.inspection-band__head {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ padding: 0.6875rem 0.75rem;
+}
+
+.inspection-band__label {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ line-height: 1.3;
+ color: var(--ins-text);
+ min-width: 0;
+}
+
+.inspection-band__aside {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 0.5625rem;
+ flex-shrink: 0;
+}
+
+.inspection-band__body {
+ padding: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.625rem;
+}
+
+.inspection-band--stacked .inspection-band__body {
+ padding-top: 0;
+}
+
+/* A failure keeps the same band, in red, with its own detail below. */
+.inspection-band--defect {
+ border-color: var(--ins-fail);
+ background-color: var(--ins-fail-fill);
+}
+
+.inspection-band--defect .inspection-band__head {
+ border-bottom: 1px solid var(--ins-fail-edge);
+ padding-right: 0.75rem;
+}
+
+.inspection-chip {
+ flex-shrink: 0;
+ font-family: var(--ins-mono);
+ font-size: 0.625rem;
+ font-weight: 700;
+ line-height: 1;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ins-fail-text);
+ border: 1px solid var(--ins-fail-edge);
+ border-radius: 0.1875rem;
+ padding: 0.25rem 0.3125rem;
+}
+
+.inspection-band--defect .form-input {
+ border-color: var(--ins-fail-edge);
+ background-color: transparent;
+}
+
+.inspection-band--defect .form-input:focus {
+ border-color: var(--ins-fail);
+}
+
+.inspection-band--defect .inspection-choice,
+.inspection-band--defect .inspection-slot {
+ border-color: var(--ins-fail-edge);
+}
+
+.inspection-defect__row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 0.625rem;
+ align-items: center;
+}
+
+@container (width <= 560px) {
+ .inspection-defect__row {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+/* The unsafe switch is a pill, not a bare toggle: it is the gravest thing here. */
+.inspection-unsafe {
+ display: flex;
+ align-items: center;
+ gap: 0.4375rem;
+ min-height: 2.25rem;
+ padding: 0 0.625rem;
+ border: 1px solid var(--ins-fail);
+ border-radius: 0.375rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--ins-fail-text);
+ white-space: nowrap;
+}
+
+.inspection-unsafe[data-on='true'] {
+ background-color: var(--ins-fail-fill-strong);
+}
+
+/* --- photo slots -------------------------------------------------------- */
+
+.inspection-slots {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.inspection-slot {
+ display: block;
+ position: relative;
+ width: 60px;
+ height: 44px;
+ border-radius: 0.3125rem;
+ border: 1px solid var(--ins-border);
+ overflow: hidden;
+ flex-shrink: 0;
+ background: repeating-linear-gradient(45deg, var(--ins-hatch-a) 0 6px, var(--ins-hatch-b) 6px 12px);
+}
+
+.inspection-slot img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.inspection-slot__icon {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ align-items: center;
+ justify-content: center;
+ color: var(--ins-text-faint);
+}
+
+.inspection-slot__remove {
+ position: absolute;
+ top: 0;
+ right: 0;
+}
+
+/* The empty slot invites the next photo rather than sitting as a button. */
+.inspection-slot--add {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px dashed var(--ins-border-strong);
+ background: none;
+ cursor: pointer;
+ font-size: 1.125rem;
+ line-height: 1;
+ color: var(--ins-text-faint);
+ padding: 0;
+ text-decoration: none;
+}
+
+.inspection-slot--add:hover {
+ color: var(--ins-text);
+ border-color: var(--ins-text-faint);
+}
+
+.inspection-slots__note {
+ margin-left: auto;
+ text-align: right;
+ font-family: var(--ins-mono);
+ font-size: 0.6875rem;
+ line-height: 1.4;
+ text-transform: uppercase;
+ color: var(--ins-text-faint);
+}
+
+/* --- the foot ----------------------------------------------------------- */
+
+.inspection-foot {
+ border-top: 1px solid var(--ins-border);
+ background-color: var(--ins-bg-sunken);
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.625rem;
+}
+
+.inspection-tallies {
+ display: flex;
+ align-items: stretch;
+ gap: 0.5rem;
+}
+
+.inspection-tally {
+ flex: 1 1 0;
+ min-width: 0;
+ background-color: var(--ins-bg);
+ border: 1px solid var(--ins-border);
+ border-left: 3px solid var(--ins-na);
+ border-radius: 0.375rem;
+ padding: 0.625rem 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.3125rem;
+}
+
+.inspection-tally[data-kind='passed'] {
+ border-left-color: var(--ins-pass);
+}
+
+.inspection-tally[data-kind='failed'] {
+ border-left-color: var(--ins-fail);
+}
+
+.inspection-tally[data-kind='outstanding'] {
+ border-left-color: var(--ins-warn);
+}
+
+.inspection-tally[data-kind='outstanding'][data-any='true'] {
+ border-color: var(--ins-warn);
+ border-left-color: var(--ins-warn);
+}
+
+.inspection-tally__value {
+ font-size: 1.25rem;
+ font-weight: 700;
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+ color: var(--ins-text);
+}
+
+.inspection-tally[data-kind='outstanding'][data-any='true'] .inspection-tally__value {
+ color: var(--ins-warn-text);
+}
+
+.inspection-tally__label {
+ font-size: 0.6875rem;
+ font-weight: 500;
+ line-height: 1;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--ins-text-muted);
+}
+
+/*
+ * On a phone, four tallies in a row leave "Outstanding" no room: two by two.
+ * A grid rather than wrapping flex items at half width, which only pairs up
+ * under border-box sizing; with padding added to the basis they stack one high.
+ */
+@container (width <= 420px) {
+ .inspection-tallies {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+.inspection-tally[data-kind='outstanding'][data-any='true'] .inspection-tally__label {
+ color: var(--ins-warn-strong);
+}
+
+.inspection-banner {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ padding: 0.6875rem 0.75rem;
+ border: 1px solid var(--ins-warn);
+ border-radius: 0.375rem;
+ background-color: var(--ins-bg);
+ font-size: 0.75rem;
+ line-height: 1.4;
+ color: var(--ins-warn-text);
+}
+
+.inspection-banner--unsafe {
+ border-color: var(--ins-fail);
+ background-color: var(--ins-fail-fill);
+ color: var(--ins-fail-text);
+}
+
+body[data-theme='dark'] .inspection-banner--unsafe {
+ background-color: #3f1d1d;
+}
+
+.inspection-banner__chip {
+ flex-shrink: 0;
+ font-family: var(--ins-mono);
+ font-size: 0.6875rem;
+ font-weight: 700;
+ line-height: 1;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: #fff;
+ background-color: var(--ins-fail);
+ border-radius: 0.25rem;
+ padding: 0.3125rem 0.4375rem;
+}
+
+.inspection-banner__jump {
+ appearance: none;
+ border: 0;
+ background: none;
+ margin-left: auto;
+ flex-shrink: 0;
+ padding: 0;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: inherit;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.inspection-banner__jump:hover {
+ text-decoration: underline;
+}
+
+/* A jumped-to field is held for a moment so the eye can find it. */
+.inspection-cell--targeted,
+.inspection-band--targeted {
+ animation: inspection-target 1.6s ease-out;
+}
+
+@keyframes inspection-target {
+ 0%,
+ 60% {
+ box-shadow: 0 0 0 2px var(--ins-warn);
+ }
+
+ 100% {
+ box-shadow: 0 0 0 2px transparent;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .inspection-cell--targeted,
+ .inspection-band--targeted {
+ animation: none;
+ box-shadow: 0 0 0 2px var(--ins-warn);
+ }
+}
+
+/*
+ * The sheet sits inside an overlay panel that has its own edge. Without an
+ * inset its borders land on that edge and read as a double rule.
+ */
+.inspection-sheet-inset {
+ padding: 0.75rem;
+}
+
+/* ==========================================================================
+ Inspection links
+ --------------------------------------------------------------------------
+ A generated link used to exist only as a toast. These are the rows that
+ replaced it: one per link, with the link itself, who and what it was for,
+ and whether it still works.
+ ========================================================================== */
+
+.inspection-link-list {
+ --inspection-border: #e5e7eb;
+ --inspection-surface: #fff;
+ --inspection-surface-sunken: #f9fafb;
+ --inspection-text: #111827;
+ --inspection-text-muted: #6b7280;
+}
+
+body[data-theme='dark'] .inspection-link-list {
+ --inspection-border: #374151;
+ --inspection-surface: #1f2937;
+ --inspection-surface-sunken: #1f2937;
+ --inspection-text: #f9fafb;
+ --inspection-text-muted: #9ca3af;
+}
+
+.inspection-link-list__empty {
+ padding: 0.75rem;
+ font-size: 0.75rem;
+ color: var(--inspection-text-muted);
+}
+
+/* The modal shows the list inside its own bordered block. */
+.inspection-link-panel {
+ border: 1px solid var(--inspection-border, #e5e7eb);
+ border-radius: 0.5rem;
+ overflow: hidden;
+}
+
+body[data-theme='dark'] .inspection-link-panel {
+ border-color: #374151;
+}
+
+.inspection-link-panel__header {
+ padding: 0.5rem 0.625rem;
+ border-bottom: 1px solid var(--inspection-border, #e5e7eb);
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: #374151;
+}
+
+body[data-theme='dark'] .inspection-link-panel__header {
+ border-bottom-color: #374151;
+ color: #e5e7eb;
+}
+
+.inspection-link {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ padding: 0.5rem 0.625rem;
+}
+
+.inspection-link + .inspection-link {
+ border-top: 1px solid var(--inspection-border);
+}
+
+/* A link that can no longer be used recedes rather than disappearing. */
+.inspection-link:not([data-state='active']) {
+ opacity: 0.6;
+}
+
+.inspection-link__head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.inspection-link__state {
+ flex-shrink: 0;
+ border-radius: 9999px;
+ padding: 0.125rem 0.5rem;
+ font-size: 0.625rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ background-color: #e5e7eb;
+ color: #374151;
+}
+
+.inspection-link__state[data-state='active'] {
+ background-color: #16a34a;
+ color: #fff;
+}
+
+.inspection-link__state[data-state='expired'],
+.inspection-link__state[data-state='revoked'] {
+ background-color: #dc2626;
+ color: #fff;
+}
+
+.inspection-link__state[data-state='used'] {
+ background-color: #2563eb;
+ color: #fff;
+}
+
+/* Locked after too many wrong PINs: stopped, but by a guesser, not by anyone here. */
+.inspection-link__state[data-state='locked'] {
+ background-color: #d97706;
+ color: #fff;
+}
+
+.inspection-link__for {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--inspection-text);
+ min-width: 0;
+}
+
+.inspection-link__when {
+ margin-left: auto;
+ flex-shrink: 0;
+ font-size: 0.6875rem;
+ color: var(--inspection-text-muted);
+}
+
+.inspection-link__url {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ border: 1px solid var(--inspection-border);
+ border-radius: 0.375rem;
+ background-color: var(--inspection-surface);
+ box-shadow: inset 0 1px 2px rgb(0 0 0 / 6%);
+ padding: 0.25rem 0.375rem;
+}
+
+body[data-theme='dark'] .inspection-link__url {
+ box-shadow: inset 0 1px 2px rgb(0 0 0 / 25%);
+}
+
+.inspection-link__code {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow-x: auto;
+ white-space: nowrap;
+ font-size: 0.6875rem;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ color: var(--inspection-text-muted);
+}
+
+.inspection-link__note {
+ font-size: 0.6875rem;
+ font-style: italic;
+ color: var(--inspection-text-muted);
+}
+
+.inspection-link__foot {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem;
+ font-size: 0.6875rem;
+ color: var(--inspection-text-muted);
+}
+
+.inspection-link__sep {
+ padding: 0 0.125rem;
+ color: var(--inspection-text-muted);
+}
+
+.inspection-link__warn {
+ color: #b45309;
+}
+
+body[data-theme='dark'] .inspection-link__warn {
+ color: #fbbf24;
+}
+
+/* The PIN that goes with a link, with ways to copy it or send it again. */
+.inspection-link__pin {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem;
+ font-size: 0.6875rem;
+ color: var(--inspection-text-muted);
+}
+
+.inspection-link__pin-label {
+ font-size: 0.625rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.inspection-link__pin-code {
+ margin-right: 0.25rem;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 0.875rem;
+ font-weight: 700;
+ letter-spacing: 0.2em;
+ color: var(--inspection-text, #111827);
+}
+
+body[data-theme='dark'] .inspection-link__pin-code {
+ color: #f9fafb;
+}
+
+/* The PIN asked for before a public link shows its form: large, spaced, easy to type on a phone. */
+.public-inspection-pin input.public-inspection-pin__input.form-input {
+ height: auto;
+ padding: 0.625rem 0.75rem;
+ text-align: center;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 1.5rem;
+ letter-spacing: 0.5em;
+ text-indent: 0.5em;
+}
+
+/* On a phone the submit button spans the page, where a thumb can reach it. */
+@media (width <= 639px) {
+ .public-inspection-submit__button,
+ .public-inspection-submit__button > button {
+ width: 100%;
+ justify-content: center;
+ }
+}
+
+/*
+ * A select option: photo, name, and a line of detail. Colours are inherited
+ * rather than set, so an option reads correctly on the dropdown's highlighted
+ * row and in dark mode without rules of its own for either.
+ */
+.select-option {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ min-width: 0;
+ padding: 0.125rem 0;
+}
+
+.select-option__photo {
+ flex-shrink: 0;
+ width: 2rem;
+ height: 2rem;
+ object-fit: cover;
+ border-radius: 9999px;
+ background-color: rgb(156 163 175 / 20%);
+}
+
+.select-option__photo[data-shape='square'] {
+ border-radius: 0.375rem;
+}
+
+.select-option__text {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: 0.125rem;
+ min-width: 0;
+}
+
+.select-option__title,
+.select-option__details {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.select-option__title {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ line-height: 1.2;
+}
+
+.select-option__details {
+ font-size: 0.6875rem;
+ line-height: 1.2;
+ opacity: 0.7;
+}
+
+/* In a closed select there is room for one line: a small photo, the name, then the detail. */
+.select-option--compact {
+ gap: 0.5rem;
+ padding: 0;
+}
+
+.select-option--compact .select-option__photo {
+ width: 1.25rem;
+ height: 1.25rem;
+}
+
+.select-option--compact .select-option__text {
+ flex-direction: row;
+ align-items: baseline;
+ gap: 0.375rem;
+}
+
+.select-option--compact .select-option__title {
+ flex-shrink: 0;
+ max-width: 60%;
+ font-weight: 500;
+}
+
+/* A link just generated, shown above the form until the modal closes. */
+.inspection-link-generated {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ border: 1px solid #bbf7d0;
+ border-radius: 0.5rem;
+ background-color: #f0fdf4;
+ padding: 0.625rem;
+}
+
+body[data-theme='dark'] .inspection-link-generated {
+ border-color: #166534;
+ background-color: rgb(22 101 52 / 20%);
+}
+
+.inspection-link-generated__title {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: #14532d;
+}
+
+body[data-theme='dark'] .inspection-link-generated__title {
+ color: #bbf7d0;
+}
+
+.inspection-link-generated__help {
+ margin: 0;
+ font-size: 0.6875rem;
+ color: #166534;
+}
+
+body[data-theme='dark'] .inspection-link-generated__help {
+ color: #86efac;
+}
+
+.inspection-link-generated .inspection-link__url {
+ background-color: #fff;
+}
+
+body[data-theme='dark'] .inspection-link-generated .inspection-link__url {
+ background-color: #1f2937;
+ border-color: #374151;
+}
+
+/* A stored answer, read back: the same row, with the value where the control was. */
+.inspection-row__answer {
+ font-size: 0.8125rem;
+ color: var(--inspection-text);
+}
+
+/* A stored answer, read back: the value sits where the control was. */
+.inspection-answer {
+ margin: 0;
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ color: var(--ins-text-soft);
+ white-space: pre-wrap;
+ min-width: 0;
+}
+
+/*
+ * A read-back answer keeps the shape of the field it was given in. A cell had
+ * no container at all — a bare label with a value loose beneath it — while a
+ * note or an upload drew a bordered card, so one row read as a card and the
+ * next as stray text. Cells take the same card, quieter than a defect's.
+ */
+.inspection-cell--readonly {
+ gap: 0.375rem;
+
+ /* Each card is as tall as its own answer: stretched to the row's tallest,
+ a lone chip sat in half a card of empty space. */
+ align-self: start;
+ border: 1px solid var(--ins-border);
+ border-radius: 0.375rem;
+ background-color: var(--ins-bg-sunken);
+ padding: 0.625rem 0.75rem;
+}
+
+/*
+ * A pass and a fail are the same card, coloured by the answer. The fail used
+ * to be a band, and a band spans the grid — so one answer ran the full width
+ * while its neighbours sat in columns. It keeps its detail; it just keeps it
+ * in the column the form gave it.
+ */
+.inspection-cell--result {
+ gap: 0;
+ padding: 0;
+ overflow: hidden;
+}
+
+.inspection-cell--result .inspection-cell__head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ min-width: 0;
+ padding: 0.5rem 0.625rem;
+}
+
+.inspection-cell--result .inspection-cell__label {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.inspection-cell--result .inspection-cell__body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding: 0 0.625rem 0.5rem;
+}
+
+.inspection-cell--result .inspection-cell__meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+}
+
+.inspection-cell--readonly[data-answer='pass'] {
+ border-color: var(--ins-pass-edge);
+ background-color: var(--ins-pass-fill);
+}
+
+.inspection-cell--readonly[data-answer='pass'] .inspection-cell__head {
+ border-bottom: 0;
+}
+
+.inspection-cell--readonly[data-answer='fail'] {
+ border-color: var(--ins-fail);
+ background-color: var(--ins-fail-fill);
+}
+
+.inspection-cell--readonly[data-answer='fail'] .inspection-cell__body {
+ border-top: 1px solid var(--ins-fail-edge);
+ padding-top: 0.5rem;
+}
+
+/* The chip takes the answer's colour: red is not the only state. */
+.inspection-cell--readonly[data-answer='pass'] .inspection-chip {
+ color: var(--ins-pass-text);
+ border-color: var(--ins-pass-edge);
+}
+
+.inspection-cell--readonly[data-answer='na'] .inspection-chip {
+ color: var(--ins-text-muted);
+ border-color: var(--ins-border);
+}
+
+.inspection-cell--readonly .inspection-cell__label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ letter-spacing: 0.02em;
+ color: var(--ins-text-muted);
+}
+
+/* The answer itself is what the eye should land on. */
+.inspection-cell--readonly .inspection-answer {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--ins-text);
+}
+
+/* ==========================================================================
+ Defect flyout
+ --------------------------------------------------------------------------
+ A failed check keeps its cell. Its severity, unsafe flag, comment and
+ photos open in a panel anchored to that cell, so failing a check can never
+ change the layout of the sheet. Closing it leaves a chip in the cell, and
+ the defects tray at the foot keeps the record.
+ ========================================================================== */
+
+/* The open field is ringed with an outline, which takes no space: opening a
+ flyout must not move anything either. */
+.inspection-cell--flyout-open {
+ outline: 2px solid var(--ins-fail);
+ outline-offset: 6px;
+ border-radius: 0.375rem;
+}
+
+/* --- the chip a closed failure leaves in its cell ----------------------- */
+
+.inspection-defect-chip {
+ appearance: none;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem 0.5rem;
+ width: 100%;
+ padding: 0.4375rem 0.625rem;
+ border: 1px solid var(--ins-fail-edge);
+ border-left: 3px solid var(--ins-fail);
+ border-radius: 0.375rem;
+ background-color: var(--ins-fail-fill);
+ color: var(--ins-fail-text);
+ font-size: 0.75rem;
+ line-height: 1.3;
+ text-align: left;
+ cursor: pointer;
+}
+
+.inspection-defect-chip:hover:not(:disabled) {
+ border-color: var(--ins-fail);
+}
+
+.inspection-defect-chip:focus-visible {
+ outline: 2px solid #2563eb;
+ outline-offset: 2px;
+}
+
+.inspection-defect-chip:disabled {
+ cursor: default;
+}
+
+.inspection-defect-chip.is-incomplete {
+ border-left-color: var(--ins-warn);
+}
+
+.inspection-defect-chip__severity {
+ font-size: 0.6875rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.inspection-defect-chip__unsafe {
+ font-family: var(--ins-mono);
+ font-size: 0.625rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: #fff;
+ background-color: var(--ins-fail);
+ border-radius: 0.1875rem;
+ padding: 0.1875rem 0.3125rem;
+}
+
+.inspection-defect-chip__meta {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ color: var(--ins-text-muted);
+}
+
+.inspection-defect-chip__status {
+ font-weight: 600;
+ color: var(--ins-warn-strong);
+}
+
+.inspection-defect-chip__edit {
+ margin-left: auto;
+ color: var(--ins-text-muted);
+}
+
+/* --- the flyout -------------------------------------------------------- */
+
+.inspection-flyout {
+ display: flex;
+ flex-direction: column;
+ background-color: var(--ins-fail-fill);
+ color: var(--ins-text);
+ border: 1px solid var(--ins-fail);
+ border-radius: 0.5rem;
+ box-shadow:
+ 0 12px 32px rgb(0 0 0 / 18%),
+ 0 2px 6px rgb(0 0 0 / 8%);
+ outline: none;
+}
+
+body[data-theme='dark'] .inspection-flyout {
+ box-shadow:
+ 0 16px 40px rgb(0 0 0 / 55%),
+ 0 2px 6px rgb(0 0 0 / 30%);
+}
+
+.inspection-flyout--floating {
+ position: absolute;
+ z-index: 40;
+ width: min(30rem, calc(100% - 1rem));
+ animation: inspection-flyout-in 0.14s ease-out;
+}
+
+.inspection-flyout--floating[data-placement='top'] {
+ animation-name: inspection-flyout-in-above;
+}
+
+/*
+ * Room for the reveal's margin at the very end of the page. An absolutely
+ * placed box extends the scroll area, but its margin does not, so without this
+ * a flyout opened below the last field could only ever sit flush against the
+ * bottom edge of the panel.
+ */
+.inspection-flyout--floating::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: -0.5rem;
+ height: 0.5rem;
+ pointer-events: none;
+}
+
+/* A caret points at the Fail button of the field it belongs to. */
+.inspection-flyout__caret {
+ position: absolute;
+ left: var(--flyout-caret-x, 1.5rem);
+ width: 12px;
+ height: 12px;
+ background-color: var(--ins-fail-fill);
+ border: 1px solid var(--ins-fail);
+ transform: translateX(-50%) rotate(45deg);
+ pointer-events: none;
+}
+
+.inspection-flyout[data-placement='bottom'] .inspection-flyout__caret {
+ top: -7px;
+ border-right: 0;
+ border-bottom: 0;
+}
+
+.inspection-flyout[data-placement='top'] .inspection-flyout__caret {
+ bottom: -7px;
+ border-top: 0;
+ border-left: 0;
+}
+
+.inspection-flyout__head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.625rem 0.75rem;
+ border-bottom: 1px solid var(--ins-fail-edge);
+}
+
+.inspection-flyout__title {
+ min-width: 0;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.inspection-flyout__close {
+ appearance: none;
+ border: 0;
+ background: none;
+ margin-left: auto;
+ padding: 0.25rem 0.375rem;
+ border-radius: 0.25rem;
+ color: var(--ins-text-muted);
+ cursor: pointer;
+}
+
+.inspection-flyout__close:hover {
+ color: var(--ins-text);
+}
+
+.inspection-flyout__close:focus-visible {
+ outline: 2px solid #2563eb;
+}
+
+.inspection-flyout__body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.625rem;
+ padding: 0.75rem;
+ overflow-y: auto;
+}
+
+.inspection-flyout__foot {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.75rem;
+ padding: 0.625rem 0.75rem;
+ border-top: 1px solid var(--ins-fail-edge);
+}
+
+.inspection-flyout__note {
+ margin-right: auto;
+ font-family: var(--ins-mono);
+ font-size: 0.6875rem;
+ line-height: 1.4;
+ text-transform: uppercase;
+ color: var(--ins-warn-strong);
+}
+
+/* Inside a failure, every control takes the failure's colours. */
+.inspection-flyout .inspection-choice {
+ border-color: var(--ins-fail-edge);
+}
+
+.inspection-flyout .inspection-choice__option + .inspection-choice__option {
+ border-left-color: var(--ins-fail-edge);
+}
+
+.inspection-flyout .inspection-choice__option:hover:not(:disabled) {
+ background-color: var(--ins-fail-fill-strong);
+}
+
+/*
+ * The comment box belongs to the failure. ember-ui styles every console
+ * input with `body[data-theme='dark'] .fleetbase-console .form-input`, which
+ * outranks a plain `.inspection-flyout .form-input` — so an earlier attempt
+ * at this left a grey box inside a red panel. This selector is deliberately
+ * heavier than that rule, and takes its colours from the theme tokens.
+ */
+html body .inspection-flyout textarea.inspection-defect-comment.form-input {
+ background-color: var(--ins-fail-field);
+ border-color: var(--ins-fail-edge);
+ color: var(--ins-text);
+ box-shadow: none;
+}
+
+html body .inspection-flyout textarea.inspection-defect-comment.form-input::placeholder {
+ color: var(--ins-fail-placeholder);
+}
+
+html body .inspection-flyout textarea.inspection-defect-comment.form-input:focus {
+ border-color: var(--ins-fail);
+ box-shadow: 0 0 0 3px var(--ins-fail-ring);
+ outline: none;
+}
+
+/* Photos sit in the failure too: red hatching, a red-edged slot for the next. */
+.inspection-flyout .inspection-slot:not(.inspection-slot--add) {
+ border-color: var(--ins-fail-edge);
+ background: repeating-linear-gradient(45deg, var(--ins-fail-hatch-a) 0 6px, var(--ins-fail-hatch-b) 6px 12px);
+}
+
+.inspection-flyout .inspection-slot--add {
+ border-color: var(--ins-fail-edge);
+ color: var(--ins-fail-text);
+}
+
+.inspection-flyout .inspection-slot--add:hover {
+ border-color: var(--ins-fail);
+ color: var(--ins-fail);
+}
+
+/* --- on a phone, the same content as a bottom sheet --------------------- */
+
+.inspection-flyout--sheet {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 1100;
+ max-height: 85vh;
+ border-bottom: 0;
+ border-radius: 0.875rem 0.875rem 0 0;
+ padding-bottom: env(safe-area-inset-bottom);
+ animation: inspection-sheet-in 0.2s ease-out;
+}
+
+/* A grab handle, so it reads as something that came up and will go down. */
+.inspection-flyout--sheet::before {
+ content: '';
+ display: block;
+ width: 2.5rem;
+ height: 0.25rem;
+ margin: 0.5rem auto 0;
+ border-radius: 9999px;
+ background-color: var(--ins-border-strong);
+}
+
+.inspection-flyout-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 1099;
+ background-color: rgb(0 0 0 / 40%);
+ animation: inspection-fade-in 0.2s ease-out;
+}
+
+@keyframes inspection-flyout-in {
+ from {
+ opacity: 0;
+ transform: translateY(-4px);
+ }
+
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@keyframes inspection-flyout-in-above {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@keyframes inspection-sheet-in {
+ from {
+ transform: translateY(100%);
+ }
+
+ to {
+ transform: none;
+ }
+}
+
+@keyframes inspection-fade-in {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .inspection-flyout,
+ .inspection-flyout-backdrop {
+ animation: none;
+ }
+}
+
+/* --- the defects tray -------------------------------------------------- */
+
+.inspection-tray {
+ border: 1px solid var(--ins-border);
+ border-radius: 0.375rem;
+ background-color: var(--ins-bg);
+ overflow: hidden;
+}
+
+.inspection-tray__head {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid var(--ins-border);
+ font-size: 0.6875rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ins-text-soft);
+}
+
+.inspection-tray__count {
+ min-width: 1.25rem;
+ padding: 0.125rem 0.375rem;
+ border-radius: 9999px;
+ background-color: var(--ins-fail);
+ color: #fff;
+ font-size: 0.625rem;
+ text-align: center;
+ letter-spacing: 0;
+}
+
+.inspection-tray__row {
+ appearance: none;
+ border: 0;
+ background: none;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ padding: 0.625rem 0.75rem;
+ text-align: left;
+ cursor: pointer;
+ color: var(--ins-text);
+ font-size: 0.8125rem;
+}
+
+.inspection-tray__row + .inspection-tray__row {
+ border-top: 1px solid var(--ins-border);
+}
+
+.inspection-tray__row:hover {
+ background-color: rgb(127 127 127 / 8%);
+}
+
+.inspection-tray__row:focus-visible {
+ outline: 2px solid #2563eb;
+ outline-offset: -2px;
+}
+
+.inspection-tray__severity {
+ flex-shrink: 0;
+ min-width: 4.25rem;
+ padding: 0.25rem 0.375rem;
+ border: 1px solid var(--ins-fail-edge);
+ border-radius: 0.25rem;
+ font-size: 0.625rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-align: center;
+ text-transform: uppercase;
+ color: var(--ins-fail-text);
+}
+
+.inspection-tray__severity[data-severity='high'],
+.inspection-tray__severity[data-severity='critical'] {
+ border-color: var(--ins-fail);
+ background-color: var(--ins-fail);
+ color: #fff;
+}
+
+.inspection-tray__label {
+ flex: 1 1 auto;
+ min-width: 0;
+ font-weight: 600;
+}
+
+.inspection-tray__evidence {
+ flex-shrink: 0;
+ font-size: 0.75rem;
+ color: var(--ins-text-muted);
+}
+
+.inspection-tray__evidence[data-incomplete='true'] {
+ font-weight: 600;
+ color: var(--ins-warn-strong);
+}
+
+.inspection-tray__action {
+ flex-shrink: 0;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--ins-fail-text);
+}
+
+@container (width <= 480px) {
+ .inspection-tray__row {
+ flex-wrap: wrap;
+ }
+
+ .inspection-tray__evidence {
+ flex-basis: 100%;
+ order: 5;
+ }
+}
diff --git a/addon/templates/maintenance/inspection-forms.hbs b/addon/templates/maintenance/inspection-forms.hbs
new file mode 100644
index 000000000..c24cd6895
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms.hbs
@@ -0,0 +1 @@
+{{outlet}}
diff --git a/addon/templates/maintenance/inspection-forms/index.hbs b/addon/templates/maintenance/inspection-forms/index.hbs
new file mode 100644
index 000000000..72171f8b5
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms/index.hbs
@@ -0,0 +1,28 @@
+
+{{outlet}}
diff --git a/addon/templates/maintenance/inspection-forms/index/details.hbs b/addon/templates/maintenance/inspection-forms/index/details.hbs
new file mode 100644
index 000000000..e76360366
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms/index/details.hbs
@@ -0,0 +1,14 @@
+
+
+ {{outlet}}
+
+
diff --git a/addon/templates/maintenance/inspection-forms/index/details/index.hbs b/addon/templates/maintenance/inspection-forms/index/details/index.hbs
new file mode 100644
index 000000000..190892683
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms/index/details/index.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/templates/maintenance/inspection-forms/index/details/submissions.hbs b/addon/templates/maintenance/inspection-forms/index/details/submissions.hbs
new file mode 100644
index 000000000..0d24d2846
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms/index/details/submissions.hbs
@@ -0,0 +1,2 @@
+
+{{outlet}}
diff --git a/addon/templates/maintenance/inspection-forms/index/edit.hbs b/addon/templates/maintenance/inspection-forms/index/edit.hbs
new file mode 100644
index 000000000..4a6c5e5b4
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms/index/edit.hbs
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/addon/templates/maintenance/inspection-forms/index/new.hbs b/addon/templates/maintenance/inspection-forms/index/new.hbs
new file mode 100644
index 000000000..bf5b379e9
--- /dev/null
+++ b/addon/templates/maintenance/inspection-forms/index/new.hbs
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/addon/templates/maintenance/inspection-submissions.hbs b/addon/templates/maintenance/inspection-submissions.hbs
new file mode 100644
index 000000000..c24cd6895
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions.hbs
@@ -0,0 +1 @@
+{{outlet}}
diff --git a/addon/templates/maintenance/inspection-submissions/index.hbs b/addon/templates/maintenance/inspection-submissions/index.hbs
new file mode 100644
index 000000000..19b3d01c7
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index.hbs
@@ -0,0 +1,28 @@
+
+{{outlet}}
diff --git a/addon/templates/maintenance/inspection-submissions/index/details.hbs b/addon/templates/maintenance/inspection-submissions/index/details.hbs
new file mode 100644
index 000000000..f35b3c62c
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index/details.hbs
@@ -0,0 +1,14 @@
+
+
+ {{outlet}}
+
+
diff --git a/addon/templates/maintenance/inspection-submissions/index/details/audit.hbs b/addon/templates/maintenance/inspection-submissions/index/details/audit.hbs
new file mode 100644
index 000000000..56b77f233
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index/details/audit.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/templates/maintenance/inspection-submissions/index/details/index.hbs b/addon/templates/maintenance/inspection-submissions/index/details/index.hbs
new file mode 100644
index 000000000..16e7c0516
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index/details/index.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/templates/maintenance/inspection-submissions/index/details/photos.hbs b/addon/templates/maintenance/inspection-submissions/index/details/photos.hbs
new file mode 100644
index 000000000..dbd948e04
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index/details/photos.hbs
@@ -0,0 +1 @@
+
diff --git a/addon/templates/maintenance/inspection-submissions/index/edit.hbs b/addon/templates/maintenance/inspection-submissions/index/edit.hbs
new file mode 100644
index 000000000..03d23e740
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index/edit.hbs
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/addon/templates/maintenance/inspection-submissions/index/new.hbs b/addon/templates/maintenance/inspection-submissions/index/new.hbs
new file mode 100644
index 000000000..460ad5e82
--- /dev/null
+++ b/addon/templates/maintenance/inspection-submissions/index/new.hbs
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/addon/templates/management/vehicles/index/details/inspections.hbs b/addon/templates/management/vehicles/index/details/inspections.hbs
new file mode 100644
index 000000000..8f70a0b42
--- /dev/null
+++ b/addon/templates/management/vehicles/index/details/inspections.hbs
@@ -0,0 +1,2 @@
+
+{{outlet}}
diff --git a/addon/utils/device-table-columns.js b/addon/utils/device-table-columns.js
index 048cbe110..3cd919879 100644
--- a/addon/utils/device-table-columns.js
+++ b/addon/utils/device-table-columns.js
@@ -170,6 +170,8 @@ export default function buildDeviceTableColumns(controller, options = {}) {
filterParam: 'type',
filterComponent: 'filter/multi-option',
filterOptions: controller.deviceTypeOptions,
+ filterOptionLabel: 'label',
+ filterOptionValue: 'value',
},
{
label: 'Serial Number',
@@ -192,6 +194,8 @@ export default function buildDeviceTableColumns(controller, options = {}) {
filterParam: 'status',
filterComponent: 'filter/multi-option',
filterOptions: controller.deviceStatusOptions,
+ filterOptionLabel: 'label',
+ filterOptionValue: 'value',
},
{
label: 'Attachment',
diff --git a/addon/utils/fleet-ops-options.js b/addon/utils/fleet-ops-options.js
index ab201b51e..338cdd714 100644
--- a/addon/utils/fleet-ops-options.js
+++ b/addon/utils/fleet-ops-options.js
@@ -216,6 +216,41 @@ export const fuelReportStatuses = [
{ label: 'Reimbursed', value: 'reimbursed', description: 'Driver expense reimbursed' },
];
+export const inspectionFormTypes = [
+ { label: 'DVIR', value: 'dvir', description: 'Driver vehicle inspection report — the daily walk-round a driver signs.' },
+ { label: 'Pre-Trip', value: 'pre_trip', description: 'Completed before the vehicle leaves.' },
+ { label: 'Post-Trip', value: 'post_trip', description: 'Completed when the vehicle returns.' },
+ { label: 'Pre-Operational', value: 'pre_operational', description: 'Checks performed before operation begins.' },
+ { label: 'Post-Operational', value: 'post_operational', description: 'Checks performed after operation ends.' },
+ { label: 'Safety Inspection', value: 'safety_inspection', description: 'Comprehensive safety and compliance inspection.' },
+ { label: 'Maintenance Inspection', value: 'maintenance_inspection', description: 'Scheduled maintenance check and service inspection.' },
+ { label: 'Damage Assessment', value: 'damage_assessment', description: 'Inspection to assess damage or condition issues.' },
+ { label: 'Annual Inspection', value: 'annual_inspection', description: 'Yearly comprehensive vehicle inspection.' },
+ { label: 'Safety', value: 'safety', description: 'General safety checklist.' },
+ { label: 'Compliance', value: 'compliance', description: 'Regulatory or audit checklist.' },
+ { label: 'Maintenance', value: 'maintenance', description: 'Workshop or technician checklist.' },
+];
+
+export const inspectionFormStatuses = [
+ { label: 'Draft', value: 'draft', description: 'Still being written. Drivers cannot see it.' },
+ { label: 'Published', value: 'published', description: 'Available to drivers and to the public link.' },
+ { label: 'Archived', value: 'archived', description: 'Retired. Kept for the records already filed against it.' },
+];
+
+export const inspectionSeverities = [
+ { label: 'Minor', value: 'low', description: 'Monitor it. Nothing stops.' },
+ { label: 'Medium', value: 'medium', description: 'Book it in.' },
+ { label: 'High', value: 'high', description: 'Unsafe. Needs attention before the next trip.' },
+ { label: 'Critical', value: 'critical', description: 'Immobilise the vehicle.' },
+];
+
+export const inspectionSubmissionStatuses = [
+ { label: 'Draft', value: 'draft', description: 'Started but not filed.' },
+ { label: 'Submitted', value: 'submitted', description: 'Filed by the driver or the console.' },
+ { label: 'Needs Review', value: 'needs_review', description: 'Flagged for a supervisor to look at.' },
+ { label: 'Resolved', value: 'resolved', description: 'Follow-up is complete.' },
+];
+
export const workOrderStatuses = [
{ label: 'Open', value: 'open', description: 'Work order has been created and is awaiting planning or assignment' },
{ label: 'Scheduled', value: 'scheduled', description: 'Work has been planned for a specific service window' },
@@ -945,6 +980,11 @@ export default function fleetOpsOptions(key) {
routingConstraintOptions,
serviceTimePresets,
importColumnMappings,
+ // Inspections
+ inspectionFormTypes,
+ inspectionFormStatuses,
+ inspectionSeverities,
+ inspectionSubmissionStatuses,
};
return allOptions[key] ?? [];
diff --git a/addon/utils/inspection-answers.js b/addon/utils/inspection-answers.js
new file mode 100644
index 000000000..2255a5f3f
--- /dev/null
+++ b/addon/utils/inspection-answers.js
@@ -0,0 +1,283 @@
+/**
+ * Reading an inspection's answers.
+ *
+ * The sheet, its section headers and its running total all need the same
+ * questions answered — did this row pass, is a required row still blank, is
+ * anything on this form unsafe to operate — and they must agree, so they ask
+ * here rather than each working it out from the raw value.
+ *
+ * The rules mirror the driver app's `src/v3/data/useInspections.ts`, so a form
+ * filled in from a phone and the same form filled in from the console are
+ * counted the same way.
+ */
+
+import { flattenFields } from './inspection-form-structure';
+import { valueTypeForFieldType } from './inspection-field-types';
+
+/** A pass-fail answer, in one shape whatever was stored. */
+export function passFailAnswer(value) {
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ return value;
+ }
+
+ // The first cut stored a bare boolean.
+ if (typeof value === 'boolean') {
+ return { passed: value, not_applicable: false };
+ }
+
+ return null;
+}
+
+/**
+ * What a pass-fail row currently says: `pass`, `fail`, `na`, or null when the
+ * field is not a pass-fail field at all.
+ */
+export function answerState(field, value) {
+ if (field?.type !== 'pass-fail') {
+ return null;
+ }
+
+ const answer = passFailAnswer(value);
+ if (!answer) {
+ return null;
+ }
+
+ if (answer.not_applicable === true) {
+ return 'na';
+ }
+
+ return answer.passed === false ? 'fail' : 'pass';
+}
+
+/** Whether a failed row was marked unsafe to operate. */
+export function isUnsafeAnswer(field, value) {
+ return answerState(field, value) === 'fail' && passFailAnswer(value)?.unsafe === true;
+}
+
+/**
+ * Whether a required field is still waiting for an answer.
+ *
+ * A pass-fail row is never blank — it opens on Pass, which is what the driver
+ * app does too — and a toggle is never blank, because off is an answer.
+ */
+export function isBlank(field, value) {
+ if (field?.type === 'pass-fail' || field?.type === 'boolean') {
+ return false;
+ }
+
+ if (value === null || value === undefined || value === '') {
+ return true;
+ }
+
+ return Array.isArray(value) && value.length === 0;
+}
+
+/** Whether a failed row is still missing the comment or photo its field demands. */
+export function defectIncomplete(field, value) {
+ if (answerState(field, value) !== 'fail') {
+ return false;
+ }
+
+ const meta = field?.meta && typeof field.meta === 'object' ? field.meta : {};
+ const answer = passFailAnswer(value) ?? {};
+
+ if (meta.require_comment_on_fail === true && !String(answer.comments ?? '').trim()) {
+ return true;
+ }
+
+ return meta.require_photo_on_fail === true && !(Array.isArray(answer.photos) && answer.photos.length > 0);
+}
+
+/** The types that always need the full width of a group, whatever the answer. */
+export const ROOMY_FIELD_TYPES = ['textarea', 'file-upload', 'signature'];
+
+/**
+ * Whether a field spans the full width of its group's grid.
+ *
+ * Only the types whose size the form itself decides: a note, an upload, a
+ * signature. It depends on the field and never on the answer, so answering a
+ * field can never change the layout. A failure's detail used to widen its
+ * field too, which re-flowed the group every time a check failed; it now
+ * opens in a flyout instead, and the field stays the size it was.
+ */
+// eslint-disable-next-line no-unused-vars
+export function isPromoted(field, value) {
+ return ROOMY_FIELD_TYPES.includes(field?.type);
+}
+
+/**
+ * What a failed check has recorded, in one shape: for the chip a closed
+ * failure leaves behind in its field, and for the defects tray.
+ */
+export function defectSummary(field, value) {
+ const answer = passFailAnswer(value) ?? {};
+ const photos = Array.isArray(answer.photos) ? answer.photos : [];
+ const meta = field?.meta && typeof field.meta === 'object' ? field.meta : {};
+ const hasComment = Boolean(String(answer.comments ?? '').trim());
+
+ return {
+ field,
+ severity: answer.severity ?? meta.severity ?? null,
+ unsafe: answer.unsafe === true,
+ photoCount: photos.length,
+ hasComment,
+ needsComment: meta.require_comment_on_fail === true && !hasComment,
+ needsPhoto: meta.require_photo_on_fail === true && photos.length === 0,
+ incomplete: defectIncomplete(field, value),
+ };
+}
+
+/** Every failed check on the sheet, in the order they are answered. */
+export function listDefects(fields = [], values = {}) {
+ return fields.filter((field) => answerState(field, values?.[field.uuid]) === 'fail').map((field) => defectSummary(field, values?.[field.uuid]));
+}
+
+/**
+ * The one-word state a group header's dot shows for a field: `pass`, `fail`,
+ * `na`, `outstanding` (required and unanswered, or a failure still owing its
+ * comment or photo), `done`, or `empty`.
+ */
+export function fieldMarker(field, value) {
+ const state = answerState(field, value);
+
+ if (state === 'fail') {
+ return defectIncomplete(field, value) ? 'outstanding' : 'fail';
+ }
+
+ if (state) {
+ return state;
+ }
+
+ if (field?.required && isBlank(field, value)) {
+ return 'outstanding';
+ }
+
+ return isBlank(field, value) ? 'empty' : 'done';
+}
+
+/**
+ * The totals a section header and the sheet's foot both read.
+ *
+ * `outstanding` is what still stops the sheet being finished: a required field
+ * left blank, or a failure that owes a comment or a photo.
+ */
+export function summarize(fields = [], values = {}) {
+ const summary = {
+ total: fields.length,
+ checks: 0,
+ passed: 0,
+ failed: 0,
+ notApplicable: 0,
+ missingRequired: 0,
+ incompleteDefects: 0,
+ unsafe: false,
+ unsafeField: null,
+ firstOutstanding: null,
+ };
+
+ for (const field of fields) {
+ const value = values?.[field.uuid];
+ const state = answerState(field, value);
+
+ if (state) {
+ summary.checks += 1;
+
+ if (state === 'pass') {
+ summary.passed += 1;
+ } else if (state === 'fail') {
+ summary.failed += 1;
+ } else {
+ summary.notApplicable += 1;
+ }
+ }
+
+ if (field.required && isBlank(field, value)) {
+ summary.missingRequired += 1;
+ }
+
+ if (defectIncomplete(field, value)) {
+ summary.incompleteDefects += 1;
+ }
+
+ // The banners name the field rather than counting it, so an inspector
+ // is told what to go and fix, not how many things are wrong.
+ if (!summary.firstOutstanding && fieldMarker(field, value) === 'outstanding') {
+ summary.firstOutstanding = field;
+ }
+
+ if (isUnsafeAnswer(field, value)) {
+ summary.unsafe = true;
+
+ if (!summary.unsafeField) {
+ summary.unsafeField = field;
+ }
+ }
+ }
+
+ summary.outstanding = summary.missingRequired + summary.incompleteDefects;
+
+ return summary;
+}
+
+/**
+ * The answer every field starts with.
+ *
+ * A pass-fail row opens on Pass, so a sheet saved untouched still files a
+ * complete set — which is what the driver app does, and what the first cut of
+ * the console did. Everything else starts empty.
+ */
+export function seedAnswers(groups = [], stored = {}) {
+ return flattenFields(groups).reduce((carry, field) => {
+ if (stored?.[field.uuid] !== undefined) {
+ carry[field.uuid] = stored[field.uuid];
+ return carry;
+ }
+
+ carry[field.uuid] = field.type === 'pass-fail' ? { passed: true, not_applicable: false, severity: null, comments: '', photos: [], unsafe: false } : null;
+
+ return carry;
+ }, {});
+}
+
+/**
+ * A file value is a reference on the way out, whichever way it came in: the
+ * `file:` a fresh upload leaves, or the `{ id, url, … }` the submission
+ * resource resolved a stored reference to.
+ */
+function serializeFile(value) {
+ if (value && typeof value === 'object') {
+ return value.id ?? null;
+ }
+
+ return typeof value === 'string' && value !== '' ? value : null;
+}
+
+function serializeValue(field, value) {
+ if (field.type === 'pass-fail') {
+ const answer = passFailAnswer(value) ?? { passed: true, not_applicable: false };
+
+ return {
+ ...answer,
+ photos: (Array.isArray(answer.photos) ? answer.photos : []).map(serializeFile).filter(Boolean),
+ };
+ }
+
+ if (field.type === 'file-upload' || field.type === 'signature') {
+ return serializeFile(value);
+ }
+
+ return value;
+}
+
+/**
+ * The answers as the server takes them — the same `custom_field_values` body
+ * the driver API accepts, so the console, a public link and the app all file
+ * the same rows and the item results are derived from the same place.
+ */
+export function answerRows(fields = [], values = {}) {
+ return fields.map((field) => ({
+ custom_field: field.uuid,
+ value_type: valueTypeForFieldType(field.type),
+ value: serializeValue(field, values?.[field.uuid]),
+ }));
+}
diff --git a/addon/utils/inspection-field-types.js b/addon/utils/inspection-field-types.js
new file mode 100644
index 000000000..97b56f425
--- /dev/null
+++ b/addon/utils/inspection-field-types.js
@@ -0,0 +1,74 @@
+/**
+ * The field types an inspection form may be built from.
+ *
+ * This list mirrors `Fleetbase\FleetOps\Models\InspectionForm::FIELD_TYPES`
+ * exactly — the server refuses anything else and falls back to `input`, so the
+ * builder must not offer a type the writer will silently rewrite.
+ */
+export const INSPECTION_FIELD_TYPES = ['pass-fail', 'input', 'textarea', 'number', 'select', 'radio-button', 'boolean', 'date-picker', 'date-time-input', 'file-upload', 'signature'];
+
+/** The severities a failed pass-fail answer can carry. */
+export const INSPECTION_SEVERITIES = ['low', 'medium', 'high', 'critical'];
+
+/** The types whose answer is chosen from a list the author writes. */
+export const OPTION_FIELD_TYPES = ['select', 'radio-button'];
+
+/**
+ * The types FleetOps renders itself. `pass-fail`, `signature` and the
+ * inspection flavour of `file-upload` are inspection-only; `textarea`,
+ * `number` and `boolean` are ordinary but absent from the platform's
+ * custom-field type map, so there is nothing to delegate them to.
+ */
+export const FLEETOPS_OWNED_FIELD_TYPES = ['pass-fail', 'signature', 'file-upload', 'textarea', 'number', 'boolean'];
+
+/** The types the platform's own `custom-field/input` already renders. */
+export const DELEGATED_FIELD_TYPES = ['input', 'select', 'radio-button', 'date-picker', 'date-time-input'];
+
+/**
+ * The console component that renders a field type. Mirrors
+ * `InspectionFormSync::componentFor()` so a field built here and a field
+ * converted from the first cut's checklist name the same component.
+ */
+export function componentForFieldType(type) {
+ return type === 'radio-button' ? 'radio-button-select' : type;
+}
+
+/**
+ * How the server stores an answer of this type — the `value_type` a submitted
+ * `custom_field_values` row carries. Mirrors `InspectionSubmitter::normalizeValue()`
+ * and the app's own `valueTypeFor` in `src/v3/data/useInspections.ts`, so the
+ * console and the driver app file the same rows.
+ */
+export function valueTypeForFieldType(type) {
+ switch (type) {
+ case 'pass-fail':
+ return 'object';
+ case 'file-upload':
+ case 'signature':
+ return 'file';
+ case 'number':
+ return 'number';
+ case 'boolean':
+ return 'boolean';
+ case 'date-picker':
+ return 'date';
+ case 'date-time-input':
+ return 'datetime';
+ default:
+ return 'text';
+ }
+}
+
+/** Whether a field of this type needs the author to write its options. */
+export function isOptionFieldType(type) {
+ return OPTION_FIELD_TYPES.includes(type);
+}
+
+/** Whether FleetOps renders this type itself rather than delegating it. */
+export function isFleetOpsOwnedFieldType(type) {
+ return FLEETOPS_OWNED_FIELD_TYPES.includes(type);
+}
+
+export default function inspectionFieldTypes() {
+ return INSPECTION_FIELD_TYPES;
+}
diff --git a/addon/utils/inspection-form-structure.js b/addon/utils/inspection-form-structure.js
new file mode 100644
index 000000000..6f655fe6d
--- /dev/null
+++ b/addon/utils/inspection-form-structure.js
@@ -0,0 +1,161 @@
+import generateUuid from '@fleetbase/ember-core/utils/generate-uuid';
+import isObject from '@fleetbase/ember-core/utils/is-object';
+import { componentForFieldType, INSPECTION_FIELD_TYPES } from './inspection-field-types';
+
+/**
+ * A form's structure, in the one shape the console holds it in.
+ *
+ * The `inspection-form` model belongs to `@fleetbase/fleetops-data` and
+ * declares no structure attribute, so the builder cannot hang the groups off
+ * the record and let Ember Data carry them. It reads the structure from the
+ * internal form payload and writes it back whole under
+ * `inspection_form.field_groups`, which is what
+ * `InspectionFormController::syncStructureFromRequest()` looks for and what
+ * `InspectionFormSync::sync()` matches on `uuid`.
+ *
+ * Everything here is plain objects. Nothing in this file mutates its argument.
+ */
+
+/** Sorts groups or fields the way the builder laid them out. */
+function byOrder(a, b) {
+ const ao = a?.order ?? Number.MAX_SAFE_INTEGER;
+ const bo = b?.order ?? Number.MAX_SAFE_INTEGER;
+ return ao - bo;
+}
+
+function metaOf(value) {
+ return isObject(value) ? { ...value } : {};
+}
+
+/** One field, as the builder and the answering screen both read it. */
+export function normalizeField(field, index = 0) {
+ const type = INSPECTION_FIELD_TYPES.includes(field?.type) ? field.type : 'input';
+
+ return {
+ uuid: field?.uuid ?? field?.id ?? generateUuid(),
+ name: field?.name ?? '',
+ label: field?.label ?? '',
+ description: field?.description ?? null,
+ help_text: field?.help_text ?? null,
+ type,
+ component: field?.component ?? componentForFieldType(type),
+ required: Boolean(field?.required),
+ editable: field?.editable === undefined ? true : Boolean(field.editable),
+ options: Array.isArray(field?.options) ? [...field.options] : [],
+ order: field?.order ?? index + 1,
+ meta: metaOf(field?.meta),
+ };
+}
+
+/** One group, with its fields inside it and sorted. */
+export function normalizeGroup(group, index = 0, fields = []) {
+ const own = Array.isArray(group?.fields) ? group.fields : Array.isArray(group?.customFields) ? group.customFields : fields;
+
+ return {
+ uuid: group?.uuid ?? group?.id ?? generateUuid(),
+ name: group?.name ?? '',
+ description: group?.description ?? null,
+ order: group?.order ?? index + 1,
+ meta: { grid_size: 1, ...metaOf(group?.meta) },
+ fields: [...own].sort(byOrder).map((field, fieldIndex) => normalizeField(field, fieldIndex)),
+ };
+}
+
+/**
+ * The structure held in an internal form payload.
+ *
+ * A read carries `field_groups` (the groups alone) beside a flat `fields` list
+ * that names its group by `category_uuid`; `grouped_fields` carries the same
+ * thing already nested, and is what the driver API answers with. Either is
+ * accepted, so this works against a record read through the console and one
+ * read through the public link.
+ */
+export function normalizeFieldGroups(payload) {
+ if (!payload) {
+ return [];
+ }
+
+ const groups = Array.isArray(payload.field_groups) ? payload.field_groups : [];
+ const fields = Array.isArray(payload.fields) ? payload.fields : [];
+
+ const fromFieldGroups = groups
+ .slice()
+ .sort(byOrder)
+ .map((group, index) => {
+ const groupUuid = group?.uuid ?? group?.id;
+ const own = fields.filter((field) => (field?.category_uuid ?? null) === groupUuid);
+ return normalizeGroup(group, index, own);
+ });
+
+ const fromGroupedFields = (Array.isArray(payload.grouped_fields) ? payload.grouped_fields : [])
+ .slice()
+ .sort(byOrder)
+ .map((group, index) => normalizeGroup(group, index));
+
+ /*
+ * Two shapes describe the same structure. `field_groups` carries no fields
+ * of its own — they arrive in the sibling `fields` array, joined on
+ * `category_uuid` — while `grouped_fields` nests them. A public payload
+ * omits `category_uuid`, so the join finds nothing and every group comes
+ * back empty; take whichever shape actually produced fields.
+ *
+ * When neither did, prefer `field_groups`: a form whose groups are laid
+ * out but still empty is a real state in the builder, and returning
+ * nothing would lose those groups.
+ */
+ if (fromFieldGroups.some((group) => group.fields.length > 0)) {
+ return fromFieldGroups;
+ }
+
+ if (fromGroupedFields.some((group) => group.fields.length > 0)) {
+ return fromGroupedFields;
+ }
+
+ return fromFieldGroups.length ? fromFieldGroups : fromGroupedFields;
+}
+
+/** Every field of every group, flattened, in the order they are answered. */
+export function flattenFields(groups = []) {
+ return groups.reduce((carry, group) => carry.concat(Array.isArray(group?.fields) ? group.fields : []), []);
+}
+
+/**
+ * The structure as the server writes it. `order` is rewritten from the
+ * builder's own ordering so a drag or a delete renumbers the whole form, and
+ * the uuid is kept so a second save updates rather than duplicates.
+ */
+export function serializeFieldGroups(groups = []) {
+ return groups.map((group, groupIndex) => ({
+ uuid: group.uuid,
+ name: group.name,
+ description: group.description ?? null,
+ order: groupIndex + 1,
+ meta: metaOf(group.meta),
+ fields: (Array.isArray(group.fields) ? group.fields : []).map((field, fieldIndex) => ({
+ uuid: field.uuid,
+ name: field.name || null,
+ label: field.label,
+ description: field.description ?? null,
+ help_text: field.help_text ?? null,
+ type: field.type,
+ component: componentForFieldType(field.type),
+ required: Boolean(field.required),
+ editable: field.editable === undefined ? true : Boolean(field.editable),
+ options: Array.isArray(field.options) ? field.options.filter((option) => typeof option === 'string' && option.trim() !== '') : [],
+ order: fieldIndex + 1,
+ meta: metaOf(field.meta),
+ })),
+ }));
+}
+
+/** A blank group, ready for the builder to name. */
+export function createFieldGroup(attributes = {}) {
+ return normalizeGroup({ uuid: generateUuid(), name: '', meta: { grid_size: 1 }, fields: [], ...attributes });
+}
+
+/** A blank field of the given type. */
+export function createField(type = 'pass-fail', attributes = {}) {
+ const meta = type === 'pass-fail' ? { severity: 'medium', require_photo_on_fail: false, require_comment_on_fail: false, unsafe_on_fail: false } : {};
+
+ return normalizeField({ uuid: generateUuid(), label: '', type, meta, ...attributes });
+}
diff --git a/app/components/afaqy-integration/status.js b/app/components/afaqy-integration/status.js
new file mode 100644
index 000000000..8eaee7f95
--- /dev/null
+++ b/app/components/afaqy-integration/status.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/afaqy-integration/status';
diff --git a/app/components/device/telemetry.js b/app/components/device/telemetry.js
new file mode 100644
index 000000000..f258396b5
--- /dev/null
+++ b/app/components/device/telemetry.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/device/telemetry';
diff --git a/app/components/inspection-field/form.js b/app/components/inspection-field/form.js
new file mode 100644
index 000000000..b7aa86835
--- /dev/null
+++ b/app/components/inspection-field/form.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-field/form';
diff --git a/app/components/inspection-field/input.js b/app/components/inspection-field/input.js
new file mode 100644
index 000000000..c99fd657a
--- /dev/null
+++ b/app/components/inspection-field/input.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-field/input';
diff --git a/app/components/inspection-field/value.js b/app/components/inspection-field/value.js
new file mode 100644
index 000000000..ad0a25c1c
--- /dev/null
+++ b/app/components/inspection-field/value.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-field/value';
diff --git a/app/components/inspection-flyout.js b/app/components/inspection-flyout.js
new file mode 100644
index 000000000..ac0b52ba2
--- /dev/null
+++ b/app/components/inspection-flyout.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-flyout';
diff --git a/app/components/inspection-form/builder.js b/app/components/inspection-form/builder.js
new file mode 100644
index 000000000..55ff7f1e9
--- /dev/null
+++ b/app/components/inspection-form/builder.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-form/builder';
diff --git a/app/components/inspection-form/details.js b/app/components/inspection-form/details.js
new file mode 100644
index 000000000..fdb229602
--- /dev/null
+++ b/app/components/inspection-form/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-form/details';
diff --git a/app/components/inspection-form/details/submissions.js b/app/components/inspection-form/details/submissions.js
new file mode 100644
index 000000000..51f120c77
--- /dev/null
+++ b/app/components/inspection-form/details/submissions.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-form/details/submissions';
diff --git a/app/components/inspection-form/form.js b/app/components/inspection-form/form.js
new file mode 100644
index 000000000..2d0c16046
--- /dev/null
+++ b/app/components/inspection-form/form.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-form/form';
diff --git a/app/components/inspection-link/list.js b/app/components/inspection-link/list.js
new file mode 100644
index 000000000..dc3d6244f
--- /dev/null
+++ b/app/components/inspection-link/list.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-link/list';
diff --git a/app/components/inspection-sheet.js b/app/components/inspection-sheet.js
new file mode 100644
index 000000000..57053b1cd
--- /dev/null
+++ b/app/components/inspection-sheet.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-sheet';
diff --git a/app/components/inspection-sheet/group.js b/app/components/inspection-sheet/group.js
new file mode 100644
index 000000000..f8190b170
--- /dev/null
+++ b/app/components/inspection-sheet/group.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-sheet/group';
diff --git a/app/components/inspection-submission/details.js b/app/components/inspection-submission/details.js
new file mode 100644
index 000000000..471c6437a
--- /dev/null
+++ b/app/components/inspection-submission/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-submission/details';
diff --git a/app/components/inspection-submission/form.js b/app/components/inspection-submission/form.js
new file mode 100644
index 000000000..d8ea4bc64
--- /dev/null
+++ b/app/components/inspection-submission/form.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-submission/form';
diff --git a/app/components/inspection-submission/photos.js b/app/components/inspection-submission/photos.js
new file mode 100644
index 000000000..172bb17c1
--- /dev/null
+++ b/app/components/inspection-submission/photos.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-submission/photos';
diff --git a/app/components/inspection-submission/pill.js b/app/components/inspection-submission/pill.js
new file mode 100644
index 000000000..dc39e1d58
--- /dev/null
+++ b/app/components/inspection-submission/pill.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/inspection-submission/pill';
diff --git a/app/components/modals/inspection-follow-up.js b/app/components/modals/inspection-follow-up.js
new file mode 100644
index 000000000..a5e7acd49
--- /dev/null
+++ b/app/components/modals/inspection-follow-up.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/modals/inspection-follow-up';
diff --git a/app/components/modals/inspection-link.js b/app/components/modals/inspection-link.js
new file mode 100644
index 000000000..520107efc
--- /dev/null
+++ b/app/components/modals/inspection-link.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/modals/inspection-link';
diff --git a/app/components/public-inspection.js b/app/components/public-inspection.js
new file mode 100644
index 000000000..964f60f2c
--- /dev/null
+++ b/app/components/public-inspection.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/public-inspection';
diff --git a/app/components/select-option.js b/app/components/select-option.js
new file mode 100644
index 000000000..350e72565
--- /dev/null
+++ b/app/components/select-option.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/select-option';
diff --git a/app/components/select-option/driver.js b/app/components/select-option/driver.js
new file mode 100644
index 000000000..85f6b655b
--- /dev/null
+++ b/app/components/select-option/driver.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/select-option/driver';
diff --git a/app/components/select-option/user.js b/app/components/select-option/user.js
new file mode 100644
index 000000000..0fe9ca48a
--- /dev/null
+++ b/app/components/select-option/user.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/select-option/user';
diff --git a/app/components/select-option/vehicle.js b/app/components/select-option/vehicle.js
new file mode 100644
index 000000000..faa85864d
--- /dev/null
+++ b/app/components/select-option/vehicle.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/select-option/vehicle';
diff --git a/app/components/table/cell/fleet-ops-option.js b/app/components/table/cell/fleet-ops-option.js
new file mode 100644
index 000000000..2c6995c32
--- /dev/null
+++ b/app/components/table/cell/fleet-ops-option.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/table/cell/fleet-ops-option';
diff --git a/app/components/vehicle/details/inspections.js b/app/components/vehicle/details/inspections.js
new file mode 100644
index 000000000..5c8d3a56a
--- /dev/null
+++ b/app/components/vehicle/details/inspections.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/vehicle/details/inspections';
diff --git a/app/controllers/maintenance/inspection-forms/index.js b/app/controllers/maintenance/inspection-forms/index.js
new file mode 100644
index 000000000..658466d90
--- /dev/null
+++ b/app/controllers/maintenance/inspection-forms/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/maintenance/inspection-forms/index';
diff --git a/app/controllers/maintenance/inspection-forms/index/details.js b/app/controllers/maintenance/inspection-forms/index/details.js
new file mode 100644
index 000000000..a1589e7d1
--- /dev/null
+++ b/app/controllers/maintenance/inspection-forms/index/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/maintenance/inspection-forms/index/details';
diff --git a/app/controllers/maintenance/inspection-forms/index/edit.js b/app/controllers/maintenance/inspection-forms/index/edit.js
new file mode 100644
index 000000000..8e7ec60af
--- /dev/null
+++ b/app/controllers/maintenance/inspection-forms/index/edit.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/maintenance/inspection-forms/index/edit';
diff --git a/app/controllers/maintenance/inspection-forms/index/new.js b/app/controllers/maintenance/inspection-forms/index/new.js
new file mode 100644
index 000000000..e0d1dfaac
--- /dev/null
+++ b/app/controllers/maintenance/inspection-forms/index/new.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/maintenance/inspection-forms/index/new';
diff --git a/app/controllers/maintenance/inspection-submissions/index.js b/app/controllers/maintenance/inspection-submissions/index.js
new file mode 100644
index 000000000..3778a2e95
--- /dev/null
+++ b/app/controllers/maintenance/inspection-submissions/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/maintenance/inspection-submissions/index';
diff --git a/app/controllers/maintenance/inspection-submissions/index/details.js b/app/controllers/maintenance/inspection-submissions/index/details.js
new file mode 100644
index 000000000..604b86920
--- /dev/null
+++ b/app/controllers/maintenance/inspection-submissions/index/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/maintenance/inspection-submissions/index/details';
diff --git a/app/controllers/maintenance/inspection-submissions/index/edit.js b/app/controllers/maintenance/inspection-submissions/index/edit.js
new file mode 100644
index 000000000..df557f39e
--- /dev/null
+++ b/app/controllers/maintenance/inspection-submissions/index/edit.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/maintenance/inspection-submissions/index/edit';
diff --git a/app/controllers/maintenance/inspection-submissions/index/new.js b/app/controllers/maintenance/inspection-submissions/index/new.js
new file mode 100644
index 000000000..c5d5b20ed
--- /dev/null
+++ b/app/controllers/maintenance/inspection-submissions/index/new.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/maintenance/inspection-submissions/index/new';
diff --git a/app/modifiers/inspection-flyout.js b/app/modifiers/inspection-flyout.js
new file mode 100644
index 000000000..6d152b644
--- /dev/null
+++ b/app/modifiers/inspection-flyout.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/modifiers/inspection-flyout';
diff --git a/app/modifiers/sync-value.js b/app/modifiers/sync-value.js
new file mode 100644
index 000000000..99eab02a0
--- /dev/null
+++ b/app/modifiers/sync-value.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/modifiers/sync-value';
diff --git a/app/modifiers/when-changed.js b/app/modifiers/when-changed.js
new file mode 100644
index 000000000..857f44d5b
--- /dev/null
+++ b/app/modifiers/when-changed.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/modifiers/when-changed';
diff --git a/app/routes/maintenance/inspection-forms.js b/app/routes/maintenance/inspection-forms.js
new file mode 100644
index 000000000..6e1880140
--- /dev/null
+++ b/app/routes/maintenance/inspection-forms.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-forms';
diff --git a/app/routes/maintenance/inspection-forms/index.js b/app/routes/maintenance/inspection-forms/index.js
new file mode 100644
index 000000000..78e1f0b28
--- /dev/null
+++ b/app/routes/maintenance/inspection-forms/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-forms/index';
diff --git a/app/routes/maintenance/inspection-forms/index/details.js b/app/routes/maintenance/inspection-forms/index/details.js
new file mode 100644
index 000000000..46c57d525
--- /dev/null
+++ b/app/routes/maintenance/inspection-forms/index/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-forms/index/details';
diff --git a/app/routes/maintenance/inspection-forms/index/details/index.js b/app/routes/maintenance/inspection-forms/index/details/index.js
new file mode 100644
index 000000000..05712c55a
--- /dev/null
+++ b/app/routes/maintenance/inspection-forms/index/details/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-forms/index/details/index';
diff --git a/app/routes/maintenance/inspection-forms/index/details/submissions.js b/app/routes/maintenance/inspection-forms/index/details/submissions.js
new file mode 100644
index 000000000..571396b62
--- /dev/null
+++ b/app/routes/maintenance/inspection-forms/index/details/submissions.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-forms/index/details/submissions';
diff --git a/app/routes/maintenance/inspection-forms/index/edit.js b/app/routes/maintenance/inspection-forms/index/edit.js
new file mode 100644
index 000000000..f6007ae62
--- /dev/null
+++ b/app/routes/maintenance/inspection-forms/index/edit.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-forms/index/edit';
diff --git a/app/routes/maintenance/inspection-forms/index/new.js b/app/routes/maintenance/inspection-forms/index/new.js
new file mode 100644
index 000000000..e7d7a677b
--- /dev/null
+++ b/app/routes/maintenance/inspection-forms/index/new.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-forms/index/new';
diff --git a/app/routes/maintenance/inspection-submissions.js b/app/routes/maintenance/inspection-submissions.js
new file mode 100644
index 000000000..613391020
--- /dev/null
+++ b/app/routes/maintenance/inspection-submissions.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-submissions';
diff --git a/app/routes/maintenance/inspection-submissions/index.js b/app/routes/maintenance/inspection-submissions/index.js
new file mode 100644
index 000000000..aaeb0ff1f
--- /dev/null
+++ b/app/routes/maintenance/inspection-submissions/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-submissions/index';
diff --git a/app/routes/maintenance/inspection-submissions/index/details.js b/app/routes/maintenance/inspection-submissions/index/details.js
new file mode 100644
index 000000000..342a1d491
--- /dev/null
+++ b/app/routes/maintenance/inspection-submissions/index/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-submissions/index/details';
diff --git a/app/routes/maintenance/inspection-submissions/index/details/audit.js b/app/routes/maintenance/inspection-submissions/index/details/audit.js
new file mode 100644
index 000000000..c7ffdd249
--- /dev/null
+++ b/app/routes/maintenance/inspection-submissions/index/details/audit.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-submissions/index/details/audit';
diff --git a/app/routes/maintenance/inspection-submissions/index/details/index.js b/app/routes/maintenance/inspection-submissions/index/details/index.js
new file mode 100644
index 000000000..cf373b50f
--- /dev/null
+++ b/app/routes/maintenance/inspection-submissions/index/details/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-submissions/index/details/index';
diff --git a/app/routes/maintenance/inspection-submissions/index/details/photos.js b/app/routes/maintenance/inspection-submissions/index/details/photos.js
new file mode 100644
index 000000000..1fa654a4b
--- /dev/null
+++ b/app/routes/maintenance/inspection-submissions/index/details/photos.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-submissions/index/details/photos';
diff --git a/app/routes/maintenance/inspection-submissions/index/edit.js b/app/routes/maintenance/inspection-submissions/index/edit.js
new file mode 100644
index 000000000..15cdf427d
--- /dev/null
+++ b/app/routes/maintenance/inspection-submissions/index/edit.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-submissions/index/edit';
diff --git a/app/routes/maintenance/inspection-submissions/index/new.js b/app/routes/maintenance/inspection-submissions/index/new.js
new file mode 100644
index 000000000..ffd2fc854
--- /dev/null
+++ b/app/routes/maintenance/inspection-submissions/index/new.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-submissions/index/new';
diff --git a/app/routes/management/vehicles/index/details/inspections.js b/app/routes/management/vehicles/index/details/inspections.js
new file mode 100644
index 000000000..562e7f924
--- /dev/null
+++ b/app/routes/management/vehicles/index/details/inspections.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/management/vehicles/index/details/inspections';
diff --git a/app/services/inspection-form-actions.js b/app/services/inspection-form-actions.js
new file mode 100644
index 000000000..c478f90c6
--- /dev/null
+++ b/app/services/inspection-form-actions.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/services/inspection-form-actions';
diff --git a/app/services/inspection-submission-actions.js b/app/services/inspection-submission-actions.js
new file mode 100644
index 000000000..a98b3dab0
--- /dev/null
+++ b/app/services/inspection-submission-actions.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/services/inspection-submission-actions';
diff --git a/app/templates/maintenance/inspection-forms.js b/app/templates/maintenance/inspection-forms.js
new file mode 100644
index 000000000..3d144bf67
--- /dev/null
+++ b/app/templates/maintenance/inspection-forms.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-forms';
diff --git a/app/templates/maintenance/inspection-forms/index.js b/app/templates/maintenance/inspection-forms/index.js
new file mode 100644
index 000000000..b637e78d1
--- /dev/null
+++ b/app/templates/maintenance/inspection-forms/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-forms/index';
diff --git a/app/templates/maintenance/inspection-forms/index/details.js b/app/templates/maintenance/inspection-forms/index/details.js
new file mode 100644
index 000000000..3e1b537b8
--- /dev/null
+++ b/app/templates/maintenance/inspection-forms/index/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-forms/index/details';
diff --git a/app/templates/maintenance/inspection-forms/index/details/index.js b/app/templates/maintenance/inspection-forms/index/details/index.js
new file mode 100644
index 000000000..335eebd25
--- /dev/null
+++ b/app/templates/maintenance/inspection-forms/index/details/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-forms/index/details/index';
diff --git a/app/templates/maintenance/inspection-forms/index/details/submissions.js b/app/templates/maintenance/inspection-forms/index/details/submissions.js
new file mode 100644
index 000000000..e0c90d883
--- /dev/null
+++ b/app/templates/maintenance/inspection-forms/index/details/submissions.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-forms/index/details/submissions';
diff --git a/app/templates/maintenance/inspection-forms/index/edit.js b/app/templates/maintenance/inspection-forms/index/edit.js
new file mode 100644
index 000000000..906dc3cac
--- /dev/null
+++ b/app/templates/maintenance/inspection-forms/index/edit.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-forms/index/edit';
diff --git a/app/templates/maintenance/inspection-forms/index/new.js b/app/templates/maintenance/inspection-forms/index/new.js
new file mode 100644
index 000000000..d76d94a29
--- /dev/null
+++ b/app/templates/maintenance/inspection-forms/index/new.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-forms/index/new';
diff --git a/app/templates/maintenance/inspection-submissions.js b/app/templates/maintenance/inspection-submissions.js
new file mode 100644
index 000000000..90158422a
--- /dev/null
+++ b/app/templates/maintenance/inspection-submissions.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-submissions';
diff --git a/app/templates/maintenance/inspection-submissions/index.js b/app/templates/maintenance/inspection-submissions/index.js
new file mode 100644
index 000000000..d1e5594c4
--- /dev/null
+++ b/app/templates/maintenance/inspection-submissions/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-submissions/index';
diff --git a/app/templates/maintenance/inspection-submissions/index/details.js b/app/templates/maintenance/inspection-submissions/index/details.js
new file mode 100644
index 000000000..e48e1e6af
--- /dev/null
+++ b/app/templates/maintenance/inspection-submissions/index/details.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-submissions/index/details';
diff --git a/app/templates/maintenance/inspection-submissions/index/details/audit.js b/app/templates/maintenance/inspection-submissions/index/details/audit.js
new file mode 100644
index 000000000..094ec5262
--- /dev/null
+++ b/app/templates/maintenance/inspection-submissions/index/details/audit.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-submissions/index/details/audit';
diff --git a/app/templates/maintenance/inspection-submissions/index/details/index.js b/app/templates/maintenance/inspection-submissions/index/details/index.js
new file mode 100644
index 000000000..b342b3262
--- /dev/null
+++ b/app/templates/maintenance/inspection-submissions/index/details/index.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-submissions/index/details/index';
diff --git a/app/templates/maintenance/inspection-submissions/index/details/photos.js b/app/templates/maintenance/inspection-submissions/index/details/photos.js
new file mode 100644
index 000000000..d9b86fd4e
--- /dev/null
+++ b/app/templates/maintenance/inspection-submissions/index/details/photos.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-submissions/index/details/photos';
diff --git a/app/templates/maintenance/inspection-submissions/index/edit.js b/app/templates/maintenance/inspection-submissions/index/edit.js
new file mode 100644
index 000000000..f3b7b51c0
--- /dev/null
+++ b/app/templates/maintenance/inspection-submissions/index/edit.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-submissions/index/edit';
diff --git a/app/templates/maintenance/inspection-submissions/index/new.js b/app/templates/maintenance/inspection-submissions/index/new.js
new file mode 100644
index 000000000..9d7bf2372
--- /dev/null
+++ b/app/templates/maintenance/inspection-submissions/index/new.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-submissions/index/new';
diff --git a/app/templates/management/vehicles/index/details/inspections.js b/app/templates/management/vehicles/index/details/inspections.js
new file mode 100644
index 000000000..c76f4fca3
--- /dev/null
+++ b/app/templates/management/vehicles/index/details/inspections.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/management/vehicles/index/details/inspections';
diff --git a/app/utils/inspection-field-types.js b/app/utils/inspection-field-types.js
new file mode 100644
index 000000000..1f8e9cea2
--- /dev/null
+++ b/app/utils/inspection-field-types.js
@@ -0,0 +1,2 @@
+export { default } from '@fleetbase/fleetops-engine/utils/inspection-field-types';
+export * from '@fleetbase/fleetops-engine/utils/inspection-field-types';
diff --git a/app/utils/inspection-form-structure.js b/app/utils/inspection-form-structure.js
new file mode 100644
index 000000000..a9db08bed
--- /dev/null
+++ b/app/utils/inspection-form-structure.js
@@ -0,0 +1 @@
+export * from '@fleetbase/fleetops-engine/utils/inspection-form-structure';
diff --git a/composer.json b/composer.json
index 2319514ec..0732c866f 100644
--- a/composer.json
+++ b/composer.json
@@ -1,6 +1,6 @@
{
"name": "fleetbase/fleetops-api",
- "version": "0.6.65",
+ "version": "0.6.66",
"description": "Fleet & Transport Management Extension for Fleetbase",
"keywords": [
"fleetbase-extension",
diff --git a/docs/AFAQY.md b/docs/AFAQY.md
new file mode 100644
index 000000000..3d6ad21ba
--- /dev/null
+++ b/docs/AFAQY.md
@@ -0,0 +1,87 @@
+# AFAQY live telemetry
+
+## Behavior
+
+The existing AFAQY integration already used Units List. The new path requests 1,000 units per page with `simplify: 0` and the documented `basic` / `last_update` projection groups. It fetches all pages, places batches of 100 units in an encrypted durable inbox, and ingests them asynchronously. A connection in transient `error` status remains eligible; `disabled` connections and connections with `meta.afaqy_sync_enabled: false` do not.
+
+Position webhooks use the same ingestion path and do not authenticate against AFAQY. The adapter provisionally accepts a unit object, a unit array, or a `data` envelope containing either, including `_id` with nested `data.last_update`. This is an assumption, not a verified AFAQY webhook contract. Unsupported authenticated JSON is quarantined and can be replayed after an adapter correction. Vehicle event webhooks and Signals history are outside this implementation.
+
+`dtt` orders positions. `dts` records last contact, falling back to `dtt`. UTC receipt and processing times are recorded separately. Old signals can enter history but cannot replace newer device, vehicle, or trailer positions. Equal-time samples keep the first current position. Duplicate signals do not create another event or broadcast. `active` is not treated as connectivity.
+
+## Stage 1 deployment
+
+1. Apply the Fleet-Ops migration `2026_09_15_000001_create_afaqy_telemetry_inbox`. It adds the inbox, polling-run diagnostics, and encrypted webhook token tables. It also adds a composite device lookup index; existing telemetry data is preserved.
+2. Use a shared cache with distributed locks (Redis recommended) across all scheduler and worker instances. An array cache is suitable only for isolated tests. Cached login tokens are encrypted, scoped to account/host/password, and reused for up to 29 days; a credential change selects a new cache namespace. All API attempts for the same host/account share a rolling 60-request/minute budget.
+3. Set the following environment variables and rebuild the application's configuration cache:
+
+ ```dotenv
+ AFAQY_POLLING_ENABLED=true
+ AFAQY_WEBHOOKS_ENABLED=false
+ AFAQY_POLL_QUEUE=afaqy-poll
+ AFAQY_INGESTION_QUEUE=afaqy-ingest
+ ```
+
+4. Provision workers for both queues. For example:
+
+ ```sh
+ php artisan queue:work --queue=afaqy-poll --timeout=120 --tries=5
+ php artisan queue:work --queue=afaqy-ingest --timeout=60 --tries=1
+ ```
+
+ Set the queue connection's `retry_after` above 150 seconds (for example 180). Run multiple ingestion workers, and retain workers for the application's broadcast/default queue. Queue names default to `default` if not configured, for compatibility; dedicated workers prevent discovery or unrelated jobs from delaying telemetry.
+
+5. Confirm the application scheduler executes every minute. It runs both `fleetops:sync-telematics` and `fleetops:drain-afaqy-inbox`. The drain recovers accepted deliveries if broker dispatch failed or a worker stopped; no payload is acknowledged before durable persistence.
+6. Open the AFAQY connection's live telemetry panel. Verify a full polling run transitions from fetching to ingesting to completed, with expected unit/page/applied counts. `incomplete` and `partial` are failures to investigate, not complete fleet coverage.
+
+Rate-limit retries honor `Retry-After` (bounded to one hour). Other polling failures use 15/60/180/300-second backoff, with five queue attempts. Poll HTTP requests have 20-second response and five-second connection limits. Sweeps stop at a 90-second fetch budget or 100 pages and report incomplete coverage. Pending poll ingestion coalesces later polling ticks. Failed ingestion retries only failed units; the original encrypted body remains available. Five failed/interrupted attempts quarantine the delivery.
+
+## Stage 2 registration
+
+1. Configure the application's public API URL as HTTPS and set `AFAQY_WEBHOOKS_ENABLED=true`.
+2. In the connection's live telemetry panel select **Show registration URL**. Provide that exact URL to AFAQY and request **Device Signals / Positions** registration. Each URL contains the connection ID and a 256-bit random secret. The server requires both before accepting a body.
+3. Redact the `key` and `token` query parameters in reverse-proxy/access logs, tracing, error reporting, and shared screenshots. The application does not log webhook bodies or secrets. Protect database backups and the application encryption key.
+4. Confirm the first delivery's actual envelope, unit identifier, timestamps, content type, retry/acknowledgement behavior, and signing/header capabilities with AFAQY. Save a sanitized payload as a contract fixture. Until this is done, the UI continues to describe the adapter as provisional.
+5. Inspect quarantined deliveries in a restricted server session. Correct the adapter and use **Replay delivery**. The API deliberately does not expose raw location payloads or the stored secret through diagnostics.
+6. Run a 24-hour hybrid pilot before expanding. Polling continues every minute regardless of webhook capability. **Rotate secret and invalidate old URL** requires AFAQY to register the replacement URL.
+
+A successful HTTP 200 means **durably accepted**, not successfully applied. Bad credentials return 403, non-POST requests 405, malformed JSON 422, oversized bodies 413, and unavailable persistence/capacity 503. No undocumented provider signature scheme is assumed.
+
+## Interfaces and configuration
+
+Existing provider interfaces and vehicle/trailer event names remain compatible. New internal endpoints are scoped to the authenticated company:
+
+- `GET telematics/{id}/afaqy-diagnostics`
+- `POST telematics/{id}/afaqy-webhook`, with optional `{ "rotate": true }`
+- `POST telematics/{id}/afaqy-deliveries/{delivery}/replay`
+
+The public receiver remains `POST webhooks/telematics/afaqy?telematic=...&key=...` under the application's API prefix. A new `device.telemetry_updated` broadcast identifies the device; an open device panel reloads its authorized resource and refetches after socket reconnection. Company broadcast identity comes from the persisted asset, not a worker session. Map movement uses the existing vehicle/trailer events, rejects older AFAQY position timestamps, and reloads visible AFAQY assets after socket reconnection with at most five concurrent requests.
+
+Configure `telematics.afaqy` using `server/config/afaqy.php` or host configuration overrides. Defaults: two MiB per webhook, 10,000 pending deliveries per connection, processed payload retention 24 hours, quarantine retention seven days, stale-position thresholds 120 seconds with ignition on and 600 seconds with ignition off/unknown. Each device's metadata records the effective threshold. Connection status categories remain unchanged.
+
+## Incident verification
+
+For the screenshot's vehicle, compare the same AFAQY unit ID/IMEI and attached Fleetbase asset. Fetch its Units List record and compare raw UTC `dtt`/`dts` with `device.meta.afaqy`, `last_online_at`, and `vehicle.telematics.last_event_at`. Then inspect polling-run status, oldest pending delivery, worker/broadcast queue delay, and the open browser panel. A June-versus-September date gap cannot be accounted for by UTC+3 alone. No affected production account was accessed during implementation.
+
+## Validation and acceptance gates
+
+Run the new provider contract and database ingestion suites with the package Pest runner, and the AFAQY browser tests with Ember:
+
+```sh
+php scripts/pest-runner.php server/tests/Unit/Support/Telematics/Providers/AfaqyRealtimeContractTest.php
+php scripts/pest-runner.php server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php
+node_modules/.bin/ember test --filter=AFAQY
+```
+
+The opt-in local SQLite benchmark (`XDEBUG_MODE=off AFAQY_RUN_LOAD_TESTS=1` with the ingestion suite above) processed 5,000 signals plus 5,000 duplicate reconciliation observations in 45.9 seconds on the development machine. It uses real model/database ingestion with indexed fixture tables, simulated spatial functions, and captured broadcasts; it excludes provider networking, broker latency, attached-asset load, and browser rendering.
+
+Package validation passed 103 selected backend tests, including database ingestion, sensor preservation, retention recovery, and provider contracts. A separate check against the installed Laravel dispatcher verified that broker failures release uniqueness leases. The standard browser launcher is blocked by the linked Testem/execa CommonJS/ES-module mismatch; the two focused browser tests passed all nine assertions in an isolated local harness with lazy engine assets and dummy host configuration. These cover live panel refresh, reconnect, freshness labels, map movement, and reversed broadcasts; they do not exercise a production socket server. PHPStan is not clean in the package-only runtime (unresolved Laravel helpers/model types and strict type findings); it is not counted as a passing gate.
+
+Use an isolated deployment with 5,000 linked test units and production-like database/cache/queue/broadcast workers for load acceptance. Replay the confirmed position envelope at 167 signals/second while polling all 5,000 units. Measure gateway acknowledgement p95, inbox receipt-to-processing p95, browser receipt-to-render p95, and queue depth through a burst, worker restart, and provider timeout/429 period.
+
+Acceptance targets are a fully applied polling sweep within 60 seconds, webhook acknowledgement p95 below one second, receipt-to-visible-update p95 below five seconds, and bounded backlog that drains after disruption. Local contract tests validate five 1,000-unit pages; they are not proof of these deployment throughput or end-to-end latency targets. At 167 signals/second, 24-hour raw retention is about 14.4 million deliveries; size storage, retention, and cleanup capacity using actual encrypted payload sizes.
+
+Device reporting frequency still depends on AFAQY/device configuration: approximately 30 seconds engine-on and five minutes engine-off. Fleetbase cannot display a position before the physical device reports it.
+
+## Rollback
+
+Set `AFAQY_WEBHOOKS_ENABLED=false` to disable push independently; retained deliveries remain available. Keep polling enabled for reconciliation. Set `AFAQY_POLLING_ENABLED=false` to return scheduled AFAQY polling to the existing discovery path. Do not roll back/drop inbox tables until retained deliveries have been processed or deliberately exported/discarded. Preserve the shared encryption key for replay.
diff --git a/extension.json b/extension.json
index fc8c79f20..2be6c6e91 100644
--- a/extension.json
+++ b/extension.json
@@ -1,6 +1,6 @@
{
"name": "Fleet-Ops",
- "version": "0.6.65",
+ "version": "0.6.66",
"description": "Fleet & Transport Management Extension for Fleetbase",
"repository": "https://github.com/fleetbase/fleetops",
"license": "AGPL-3.0-or-later",
diff --git a/package.json b/package.json
index a01ccaf85..c34606890 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@fleetbase/fleetops-engine",
- "version": "0.6.65",
+ "version": "0.6.66",
"description": "Fleet & Transport Management Extension for Fleetbase",
"fleetbase": {
"route": "fleet-ops"
diff --git a/server/config/afaqy.php b/server/config/afaqy.php
new file mode 100644
index 000000000..f71076e6d
--- /dev/null
+++ b/server/config/afaqy.php
@@ -0,0 +1,15 @@
+ env('AFAQY_POLLING_ENABLED', false),
+ 'webhooks_enabled' => env('AFAQY_WEBHOOKS_ENABLED', false),
+ 'poll_queue' => env('AFAQY_POLL_QUEUE', 'default'),
+ 'ingestion_queue' => env('AFAQY_INGESTION_QUEUE', 'default'),
+ 'max_pages' => 100,
+ 'max_payload_bytes' => 2097152,
+ 'max_pending_deliveries' => 10000,
+ 'processed_retention_hours' => 24,
+ 'quarantine_retention_days' => 7,
+ 'stale_engine_on_seconds' => 120,
+ 'stale_engine_off_seconds' => 600,
+];
diff --git a/server/config/telematics.php b/server/config/telematics.php
index f53c7ba3f..fa984bf95 100644
--- a/server/config/telematics.php
+++ b/server/config/telematics.php
@@ -153,7 +153,7 @@
'validation' => 'required_without:username|string',
],
],
- 'supports_webhooks' => false,
+ 'supports_webhooks' => true,
'supports_discovery' => true,
'metadata' => [
'rate_limit' => 60,
diff --git a/server/migrations/2026_09_09_000001_create_inspection_tables.php b/server/migrations/2026_09_09_000001_create_inspection_tables.php
new file mode 100644
index 000000000..765246d8b
--- /dev/null
+++ b/server/migrations/2026_09_09_000001_create_inspection_tables.php
@@ -0,0 +1,152 @@
+increments('id');
+ $table->uuid('uuid')->index();
+ $table->string('_key')->nullable()->index();
+ $table->string('public_id', 191)->nullable()->unique()->index();
+ $table->foreignUuid('company_uuid')->constrained('companies', 'uuid')->cascadeOnDelete();
+
+ $table->string('name')->index();
+ $table->text('description')->nullable();
+ $table->string('type')->default('dvir')->index();
+ $table->string('status')->default('draft')->index();
+ $table->string('frequency')->nullable()->index();
+
+ $table->string('subject_type')->nullable();
+ $table->uuid('subject_uuid')->nullable();
+ $table->index(['subject_type', 'subject_uuid']);
+
+ $table->json('items')->nullable();
+ $table->json('settings')->nullable();
+ $table->json('meta')->nullable();
+ $table->timestamp('published_at')->nullable()->index();
+
+ $table->foreignUuid('created_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+ $table->foreignUuid('updated_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+
+ $table->softDeletes();
+ $table->timestamps();
+
+ $table->index(['company_uuid', 'status', 'type']);
+ });
+
+ Schema::create('inspection_links', function (Blueprint $table) {
+ $table->increments('id');
+ $table->uuid('uuid')->index();
+ $table->string('_key')->nullable()->index();
+ $table->string('public_id', 191)->nullable()->unique()->index();
+ $table->foreignUuid('company_uuid')->constrained('companies', 'uuid')->cascadeOnDelete();
+ $table->foreignUuid('inspection_form_uuid')->constrained('inspection_forms', 'uuid')->cascadeOnDelete();
+ $table->foreignUuid('driver_uuid')->nullable()->constrained('drivers', 'uuid')->nullOnDelete();
+ $table->foreignUuid('vehicle_uuid')->nullable()->constrained('vehicles', 'uuid')->nullOnDelete();
+ $table->foreignUuid('created_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+
+ $table->string('token_hash', 191)->unique();
+ $table->string('status')->default('active')->index();
+ $table->boolean('single_use')->default(true)->index();
+ $table->timestamp('expires_at')->nullable()->index();
+ $table->timestamp('last_viewed_at')->nullable();
+ $table->timestamp('used_at')->nullable()->index();
+ $table->string('used_ip')->nullable();
+ $table->text('used_user_agent')->nullable();
+ $table->json('meta')->nullable();
+
+ $table->softDeletes();
+ $table->timestamps();
+
+ $table->index(['company_uuid', 'inspection_form_uuid', 'status']);
+ });
+
+ Schema::create('inspection_submissions', function (Blueprint $table) {
+ $table->increments('id');
+ $table->uuid('uuid')->index();
+ $table->string('_key')->nullable()->index();
+ $table->string('public_id', 191)->nullable()->unique()->index();
+ $table->foreignUuid('company_uuid')->constrained('companies', 'uuid')->cascadeOnDelete();
+ $table->foreignUuid('inspection_form_uuid')->nullable()->constrained('inspection_forms', 'uuid')->nullOnDelete();
+ $table->foreignUuid('vehicle_uuid')->nullable()->constrained('vehicles', 'uuid')->nullOnDelete();
+ $table->foreignUuid('driver_uuid')->nullable()->constrained('drivers', 'uuid')->nullOnDelete();
+ $table->foreignUuid('submitted_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+ $table->foreignUuid('issue_uuid')->nullable()->constrained('issues', 'uuid')->nullOnDelete();
+ $table->foreignUuid('work_order_uuid')->nullable()->constrained('work_orders', 'uuid')->nullOnDelete();
+
+ $table->string('type')->default('dvir')->index();
+ $table->string('status')->default('draft')->index();
+ $table->string('result')->nullable()->index();
+ $table->string('source')->nullable()->index();
+ $table->unsignedBigInteger('odometer')->nullable();
+ $table->unsignedBigInteger('engine_hours')->nullable();
+ $table->unsignedInteger('total_items')->default(0);
+ $table->unsignedInteger('failed_items')->default(0);
+ $table->timestamp('started_at')->nullable()->index();
+ $table->timestamp('submitted_at')->nullable()->index();
+ $table->timestamp('resolved_at')->nullable()->index();
+
+ $table->json('location')->nullable();
+ $table->json('signature')->nullable();
+ $table->json('attachments')->nullable();
+ $table->json('meta')->nullable();
+
+ $table->foreignUuid('created_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+ $table->foreignUuid('updated_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+
+ $table->softDeletes();
+ $table->timestamps();
+
+ $table->index(['company_uuid', 'status', 'result']);
+ $table->index(['vehicle_uuid', 'submitted_at']);
+ });
+
+ Schema::create('inspection_item_results', function (Blueprint $table) {
+ $table->increments('id');
+ $table->uuid('uuid')->index();
+ $table->string('_key')->nullable()->index();
+ $table->foreignUuid('company_uuid')->constrained('companies', 'uuid')->cascadeOnDelete();
+ $table->foreignUuid('inspection_submission_uuid')->constrained('inspection_submissions', 'uuid')->cascadeOnDelete();
+ $table->foreignUuid('issue_uuid')->nullable()->constrained('issues', 'uuid')->nullOnDelete();
+ $table->foreignUuid('work_order_uuid')->nullable()->constrained('work_orders', 'uuid')->nullOnDelete();
+
+ $table->string('item_key')->nullable()->index();
+ $table->string('label')->index();
+ $table->string('category')->nullable()->index();
+ $table->string('status')->default('passed')->index();
+ $table->string('severity')->nullable()->index();
+ $table->boolean('passed')->default(true)->index();
+ $table->text('comments')->nullable();
+ $table->json('photos')->nullable();
+ $table->json('meta')->nullable();
+
+ $table->foreignUuid('created_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+ $table->foreignUuid('updated_by_uuid')->nullable()->constrained('users', 'uuid')->nullOnDelete();
+
+ $table->softDeletes();
+ $table->timestamps();
+
+ $table->index(['company_uuid', 'passed', 'severity']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::disableForeignKeyConstraints();
+ Schema::dropIfExists('inspection_item_results');
+ Schema::dropIfExists('inspection_submissions');
+ Schema::dropIfExists('inspection_links');
+ Schema::dropIfExists('inspection_forms');
+ Schema::enableForeignKeyConstraints();
+ }
+};
diff --git a/server/migrations/2026_09_10_000001_convert_inspection_form_items_to_field_groups.php b/server/migrations/2026_09_10_000001_convert_inspection_form_items_to_field_groups.php
new file mode 100644
index 000000000..03c8fbb3f
--- /dev/null
+++ b/server/migrations/2026_09_10_000001_convert_inspection_form_items_to_field_groups.php
@@ -0,0 +1,44 @@
+whereNotNull('items')
+ ->orderBy('id')
+ ->chunkById(100, function ($forms) {
+ foreach ($forms as $form) {
+ InspectionFormSync::convertLegacyItems($form);
+ }
+ });
+ }
+
+ public function down(): void
+ {
+ // Nothing to undo: `items` was never removed, and the fields written
+ // here may have been edited since.
+ }
+};
diff --git a/server/migrations/2026_09_10_000002_add_token_to_inspection_links.php b/server/migrations/2026_09_10_000002_add_token_to_inspection_links.php
new file mode 100644
index 000000000..67a775144
--- /dev/null
+++ b/server/migrations/2026_09_10_000002_add_token_to_inspection_links.php
@@ -0,0 +1,39 @@
+text('token')->nullable()->after('token_hash');
+ });
+ }
+
+ public function down()
+ {
+ Schema::table('inspection_links', function (Blueprint $table) {
+ $table->dropColumn('token');
+ });
+ }
+};
diff --git a/server/migrations/2026_09_11_000001_add_assignee_and_pin_to_inspection_links.php b/server/migrations/2026_09_11_000001_add_assignee_and_pin_to_inspection_links.php
new file mode 100644
index 000000000..71ad88239
--- /dev/null
+++ b/server/migrations/2026_09_11_000001_add_assignee_and_pin_to_inspection_links.php
@@ -0,0 +1,39 @@
+foreignUuid('assignee_uuid')->nullable()->after('vehicle_uuid')->constrained('users', 'uuid')->nullOnDelete();
+ $table->string('pin_hash')->nullable()->after('token');
+ $table->text('pin')->nullable()->after('pin_hash');
+ $table->unsignedSmallInteger('pin_attempts')->default(0)->after('pin');
+ $table->string('pin_sent_via', 20)->nullable()->after('pin_attempts');
+ $table->timestamp('pin_sent_at')->nullable()->after('pin_sent_via');
+ });
+ }
+
+ public function down()
+ {
+ Schema::table('inspection_links', function (Blueprint $table) {
+ $table->dropConstrainedForeignId('assignee_uuid');
+ $table->dropColumn(['pin_hash', 'pin', 'pin_attempts', 'pin_sent_via', 'pin_sent_at']);
+ });
+ }
+};
diff --git a/server/migrations/2026_09_15_000001_create_afaqy_telemetry_inbox.php b/server/migrations/2026_09_15_000001_create_afaqy_telemetry_inbox.php
new file mode 100644
index 000000000..9099e4455
--- /dev/null
+++ b/server/migrations/2026_09_15_000001_create_afaqy_telemetry_inbox.php
@@ -0,0 +1,62 @@
+ $table->index(['company_uuid', 'telematic_uuid', 'device_id'], 'devices_afaqy_lookup'));
+ Schema::create('afaqy_sync_runs', function (Blueprint $table) {
+ $table->uuid('uuid')->primary();
+ $table->uuid('telematic_uuid')->index();
+ $table->string('status')->default('fetching')->index();
+ $table->unsignedInteger('pages')->default(0);
+ $table->unsignedInteger('units')->default(0);
+ $table->unsignedInteger('applied')->default(0);
+ $table->unsignedInteger('failed')->default(0);
+ $table->text('error')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('afaqy_deliveries', function (Blueprint $table) {
+ $table->uuid('uuid')->primary();
+ $table->uuid('telematic_uuid')->index();
+ $table->uuid('run_uuid')->nullable()->index();
+ $table->string('source', 16);
+ $table->string('status', 24)->default('pending');
+ $table->char('payload_hash', 64);
+ $table->longText('payload');
+ $table->longText('retry_payload')->nullable();
+ $table->unsignedInteger('invalid_count')->default(0);
+ $table->unsignedInteger('attempts')->default(0);
+ $table->unsignedInteger('queue_delay_seconds')->nullable();
+ $table->unsignedInteger('source_delay_seconds')->nullable();
+ $table->unsignedInteger('applied')->default(0);
+ $table->unsignedInteger('failed')->default(0);
+ $table->text('error')->nullable();
+ $table->timestamp('received_at');
+ $table->timestamp('available_at')->nullable();
+ $table->timestamp('processed_at')->nullable();
+ $table->timestamps();
+ $table->index(['status', 'available_at']);
+ $table->index(['status', 'updated_at']);
+ $table->index(['telematic_uuid', 'status']);
+ $table->index(['telematic_uuid', 'received_at']);
+ $table->index(['telematic_uuid', 'source', 'received_at']);
+ });
+ Schema::create('afaqy_webhook_tokens', function (Blueprint $table) {
+ $table->uuid('telematic_uuid')->primary();
+ $table->text('token');
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('devices', fn (Blueprint $table) => $table->dropIndex('devices_afaqy_lookup'));
+ Schema::dropIfExists('afaqy_webhook_tokens');
+ Schema::dropIfExists('afaqy_deliveries');
+ Schema::dropIfExists('afaqy_sync_runs');
+ }
+};
diff --git a/server/resources/views/mail/inspection-link-pin.blade.php b/server/resources/views/mail/inspection-link-pin.blade.php
new file mode 100644
index 000000000..5c04268bd
--- /dev/null
+++ b/server/resources/views/mail/inspection-link-pin.blade.php
@@ -0,0 +1,34 @@
+@php
+ // Strings are built here, not inline: Blade only reads `@` as a directive
+ // when it does not follow a word character, so `inspection@if(...)` would
+ // stay literal text while its `@endif` compiled and broke the view.
+ $formName = $form?->name ?: 'inspection';
+ $vehicleName = $vehicle ? ($vehicle->display_name ?? $vehicle->name) : null;
+ $forVehicle = $vehicleName ? ' for ' . $vehicleName : '';
+ $senderLine = $sender?->name ? $sender->name . ' has asked you to complete this inspection.' : 'You have been asked to complete this inspection.';
+@endphp
+
+
Complete the {{ $formName }} inspection
+
+@if($recipient && $recipient->name)
+
Hi {{ $recipient->name }},
+@endif
+
+
{{ $senderLine }} It is the {{ $formName }} inspection{{ $forVehicle }}.