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/inspection-field/form.hbs b/addon/components/inspection-field/form.hbs
new file mode 100644
index 000000000..94342955d
--- /dev/null
+++ b/addon/components/inspection-field/form.hbs
@@ -0,0 +1,103 @@
+
+ {{#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/layout/fleet-ops-sidebar.js b/addon/components/layout/fleet-ops-sidebar.js
index 1258c5df0..442955818 100644
--- a/addon/components/layout/fleet-ops-sidebar.js
+++ b/addon/components/layout/fleet-ops-sidebar.js
@@ -160,6 +160,14 @@ export default class LayoutFleetOpsSidebarComponent extends Component {
'service readiness',
'maintenance control panel',
]),
+ this.createItem('menu.inspection-forms', 'clipboard-check', 'maintenance.inspection-forms', 'fleet-ops list inspection-form', 'fleet-ops see inspection-form', [
+ 'dvir',
+ 'checklist',
+ ]),
+ this.createItem('menu.inspections', 'list-check', 'maintenance.inspection-submissions', 'fleet-ops list inspection-submission', 'fleet-ops see inspection-submission', [
+ 'dvir',
+ 'defects',
+ ]),
this.createItem('menu.schedules', 'calendar-alt', 'maintenance.schedules', 'fleet-ops list maintenance-schedule', 'fleet-ops see maintenance-schedule'),
this.createItem('menu.work-orders', 'clipboard-list', 'maintenance.work-orders', 'fleet-ops list work-order', 'fleet-ops see work-order'),
this.createItem('menu.maintenances', 'history', 'maintenance.maintenances', 'fleet-ops list maintenance', 'fleet-ops see maintenance'),
diff --git a/addon/components/modals/inspection-follow-up.hbs b/addon/components/modals/inspection-follow-up.hbs
new file mode 100644
index 000000000..b28310bd0
--- /dev/null
+++ b/addon/components/modals/inspection-follow-up.hbs
@@ -0,0 +1,31 @@
+
+
+
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}}
+
+
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 @@
{{t "common.status"}}:
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/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/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/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/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/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/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/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 }}.