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 @@ +
+ + + + + + + + + + + + + + + + + + + + +
+ + +
+ + {{#if this.hasOptions}} + +
+ {{#each this.options as |option index|}} +
+ +
+ {{else}} +
{{t "inspection.field.no-options"}}
+ {{/each}} +
+ +
+
+
+ {{/if}} + + {{#if this.isNumber}} + + + + + {{/if}} + + {{#if this.isPassFail}} +
+
{{t "inspection.field.on-fail"}}
+
{{t "inspection.field.on-fail-help"}}
+ + +
+ + {{t (concat "inspection.severity." severity)}} + +
+
+ + + + + + + + +
+ {{/if}} + + +
+ {{#each this.colSpanOptions as |size|}} +
+
+
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}} +
+
+ + {{this.label}} + {{#if this.field.required}}{{/if}} + + + {{#unless this.isStackedBand}} +
+ {{#if this.file.url}} + {{or + {{else if this.file.reference}} + + {{else}} + {{this.emptyFileNote}} + {{/if}} + + {{#if this.canUpload}} + + + {{this.uploadLabel}} + + + {{#if this.file.reference}} +
+ {{/unless}} +
+ + {{#if this.isStackedBand}} +
+ {{#if this.field.description}} + {{this.field.description}} + {{/if}} + +
+ {{/if}} +
+ +{{else}} +
+ + {{this.label}} + {{#if this.field.required}}{{/if}} + + + {{#if this.field.description}} + {{this.field.description}} + {{/if}} + {{#if this.instructions}} + {{this.instructions}} + {{/if}} + +
+ {{#if (eq this.field.type "pass-fail")}} +
+ {{#each this.passFailOptions key="value" as |option|}} + + {{/each}} +
+ + {{else if (eq this.field.type "boolean")}} + + + {{else if (eq this.field.type "number")}} + + {{#if this.unit}}{{this.unit}}{{/if}} + + {{else if (eq this.field.type "select")}} +
+ + {{option}} + +
+ + {{else if (eq this.field.type "radio-button")}} + {{#if this.choiceOptions}} +
+ {{#each this.choiceOptions key="@index" as |option|}} + + {{/each}} +
+ {{else}} + {{t "inspection.answer.no-options"}} + {{/if}} + + {{else}} + + {{/if}} +
+ + {{#if this.isDefect}} + + + {{#if this.isFlyoutOpen}} + +
+
+ {{#each this.severityOptions key="value" as |option|}} + + {{/each}} +
+ + + {{t "inspection.answer.unsafe"}} + +
+ + + +
+ {{#each this.answerPhotos key="reference" as |photo index|}} +
+ {{#if photo.url}} + {{or + {{else}} + + {{/if}} + {{#unless @disabled}} +
+ {{/each}} + + {{#if this.canUpload}} + + + + + {{/if}} + + {{#if this.uploadProgress}} + {{round this.uploadProgress.progress}}% + {{/if}} + + {{#if this.uploadsBlocked}} + {{t "inspection.answer.uploads-unavailable"}} + {{/if}} +
+
+ {{/if}} + {{/if}} +
+{{/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..1430a827e --- /dev/null +++ b/addon/components/inspection-field/value.hbs @@ -0,0 +1,80 @@ +{{! + One stored answer, read-only. + + The same two shapes as `inspection-field/input` — a compact cell in the + group's grid, or a full-width band for a failure, a note, an upload or a + signature — so a submitted inspection reads exactly like the sheet it was + filled in on. +}} +{{#if this.isDefect}} +
+
+ {{t "inspection.answer.fail"}} + {{this.label}} + + {{#if this.severityLabel}}{{t this.severityLabel}}{{/if}} + {{#if this.answer.unsafe}}{{t "inspection.answer.unsafe"}}{{/if}} + +
+ {{#if this.hasDefectDetail}} +
+ {{#if this.answer.comments}} +

{{this.answer.comments}}

+ {{/if}} + {{#if this.photos.length}} +
+ {{#each this.photos key="reference" as |photo|}} + {{#if photo.url}} + + {{or + + {{else}} + + {{/if}} + {{/each}} +
+ {{/if}} +
+ {{/if}} +
+ +{{else if this.isRoomy}} +
+
+ {{this.label}} + {{#unless this.isStackedBand}} + + {{#if this.file.url}} + + {{or + + {{else if this.file.reference}} + + {{else}} + {{t "inspection.answer.unanswered"}} + {{/if}} + + {{/unless}} +
+ {{#if this.isStackedBand}} +
+

{{n-a @value}}

+
+ {{/if}} +
+ +{{else}} +
+ {{this.label}} +
+ {{#if this.isPassFail}} + {{t this.resultLabel}} + {{else if this.isBoolean}} + {{if this.booleanValue (t "common.yes") (t "common.no")}} + {{else}} + {{n-a @value}} + {{#if this.meta.unit}}{{this.meta.unit}}{{/if}} + {{/if}} +
+
+{{/if}} diff --git a/addon/components/inspection-field/value.js b/addon/components/inspection-field/value.js new file mode 100644 index 000000000..564efbdd1 --- /dev/null +++ b/addon/components/inspection-field/value.js @@ -0,0 +1,137 @@ +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); + } + + get isDefect() { + return this.isPassFail && this.answerState === 'fail'; + } + + 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 ? 'danger' : 'success'; + } + + /** 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}} + + {{/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. + }} +
+
+
{{or field.label (t "inspection.builder.untitled-field")}}
+ {{#if field.required}} + * + {{/if}} +
+ {{field.type}} +
+
+
+
+
+ {{else}} +
{{t "inspection.builder.no-fields"}}
+ {{/each}} +
+
+ {{else}} +
+
+
+ +
{{t "inspection.builder.empty-title"}}
+
{{t "inspection.builder.empty-description"}}
+
+
+
+ {{/each}} +
+ {{/if}} +
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..6be84879f --- /dev/null +++ b/addon/components/inspection-form/details.hbs @@ -0,0 +1,97 @@ +
+ +
+
+
{{t "inspection.form.name"}}
+
{{n-a @resource.name}}
+
+
+
{{t "inspection.form.status"}}
+
{{or (get-fleet-ops-option-label "inspectionFormStatuses" @resource.status) (smart-humanize @resource.status)}}
+
+
+
{{t "inspection.form.type"}}
+
{{or (get-fleet-ops-option-label "inspectionFormTypes" @resource.type) (n-a (smart-humanize @resource.type))}}
+
+
+
{{t "inspection.form.fields"}}
+
{{this.fieldCount}}
+
+
+
{{t "inspection.form.published"}}
+
{{n-a (format-date-fns @resource.published_at "dd MMM yyyy, HH:mm")}}
+
+
+
{{t "inspection.form.description"}}
+
{{n-a @resource.description}}
+
+
+
+ + + + + + + {{#if this.load.isRunning}} +
+ +
+ {{else}} +
+ {{#each this.groups as |group|}} +
+
{{or group.name (t "inspection.builder.untitled-group")}}
+ {{#if group.description}} +
{{group.description}}
+ {{/if}} +
+ {{#each group.fields as |field|}} +
+
+ {{or field.label (t "inspection.builder.untitled-field")}} + {{#if field.required}}*{{/if}} +
+
+ {{#if (and (eq field.type "pass-fail") field.meta.severity)}} + + {{#let (get this.severityLabels field.meta.severity) as |severityLabel|}} + {{#if severityLabel}}{{t severityLabel}}{{else}}{{smart-humanize field.meta.severity}}{{/if}} + {{/let}} + + {{/if}} + {{field.type}} +
+
+ {{else}} +
{{t "inspection.builder.no-fields"}}
+ {{/each}} +
+
+ {{else}} +
{{t "inspection.form.no-structure"}}
+ {{/each}} +
+ {{/if}} +
+ + {{#if this.legacyItems.length}} + +
{{t "inspection.form.legacy-checklist-help"}}
+
+ {{#each this.legacyItems as |item|}} +
+
+
{{n-a item.label}}
+
{{n-a item.category}}
+
+ {{smart-humanize item.severity}} +
+ {{/each}} +
+
+ {{/if}} + + + +
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/form.hbs b/addon/components/inspection-form/form.hbs new file mode 100644 index 000000000..007739015 --- /dev/null +++ b/addon/components/inspection-form/form.hbs @@ -0,0 +1,93 @@ +
+ +
+ + + + +
+ +
+
{{option.label}}
+
{{option.description}}
+
+
+
+
+ +
+ +
+
{{option.label}}
+
{{option.description}}
+
+
+
+
+ + + +
+
+ + +
+ {{#each this.settingOptions as |setting|}} + + + + {{/each}} +
+
+ + + + + + {{#if this.legacyItems.length}} + +
{{t "inspection.form.legacy-checklist-help"}}
+
+ {{#each this.legacyItems as |item|}} +
+
+
{{n-a item.label}}
+
{{n-a item.category}}
+
+ {{get-fleet-ops-option-label "inspectionSeverities" item.severity}} +
+ {{/each}} +
+
+ {{/if}} + + +
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}} +
+ + {{#if this.hasFields}} +
+
+
+ {{this.summary.passed}} + {{t "inspection.answer.pass"}} +
+
+ {{this.summary.failed}} + {{t "inspection.answer.fail"}} +
+
+ {{this.summary.notApplicable}} + {{t "inspection.answer.not-applicable"}} +
+
+ {{this.summary.outstanding}} + {{t "inspection.record.outstanding"}} +
+
+ + {{#if this.defects}} +
+
+ {{t "inspection.tray.title"}} + {{this.defects.length}} +
+ {{#each this.defects key="field.uuid" as |defect|}} + + {{/each}} +
+ {{/if}} + + {{#if this.summary.firstOutstanding}} +
+ {{this.outstandingDescription}} + +
+ {{/if}} +
+ {{/if}} +
+
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 @@ +
+
+ {{this.title}} + {{#if this.markers}} + + {{/if}} + {{#if this.hasOutstanding}} + {{this.outstanding}} + {{/if}} +
+ + {{#if @group.description}} +

{{@group.description}}

+ {{/if}} + + {{#if this.fields}} +
+ {{#each this.fields key="uuid" as |field|}} + {{#if @readonly}} + + {{else}} + + {{/if}} + {{/each}} +
+ {{else}} +
{{t "inspection.record.group-has-no-fields"}}
+ {{/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..135d79245 --- /dev/null +++ b/addon/components/inspection-submission/details.hbs @@ -0,0 +1,109 @@ +
+ +
+
+
{{t "inspection.record.inspection"}}
+ {{n-a @resource.public_id}} +
+
+
{{t "inspection.record.result"}}
+
{{smart-humanize @resource.result}}
+
+
+
{{t "inspection.record.form"}}
+
{{n-a (or @resource.form.name @resource.form_name)}}
+
+
+
{{t "inspection.record.status"}}
+
{{smart-humanize @resource.status}}
+
+
+
{{t "inspection.record.vehicle"}}
+
{{n-a (or @resource.vehicle.displayName @resource.vehicle_name)}}
+
+
+
{{t "inspection.record.driver"}}
+
{{n-a (or @resource.driver.name @resource.driver_name)}}
+
+
+
{{t "inspection.record.submitted-by"}}
+
+ {{n-a this.submitter}} + {{#if this.submitterNote}} +
{{this.submitterNote}}
+ {{/if}} +
+
+
+
{{t "inspection.record.odometer"}}
+
{{n-a @resource.odometer}}
+
+
+
{{t "inspection.record.engine-hours"}}
+
{{n-a @resource.engine_hours}}
+
+
+
{{t "inspection.record.submitted"}}
+
{{n-a (format-date-fns @resource.submitted_at "dd MMM yyyy, HH:mm")}}
+
+
+
{{t "inspection.record.resolved"}}
+
{{n-a (format-date-fns @resource.resolved_at "dd MMM yyyy, HH:mm")}}
+
+
+
+ + {{#if this.load.isRunning}} + +
+ +
+
+ {{else if this.hasAnswers}} +
+ +
+ {{/if}} + + +
+ {{#each @resource.item_results as |item|}} +
+
+
+
{{n-a item.label}}
+
{{n-a item.category}}
+ {{#if item.comments}} +
{{item.comments}}
+ {{/if}} +
+
+ {{if item.passed (t "inspection.answer.pass") (t "inspection.answer.fail")}} + {{#if item.severity}} + {{smart-humanize item.severity}} + {{/if}} +
+
+
+ {{else}} +
{{t "inspection.record.no-item-results"}}
+ {{/each}} +
+
+ + +
+
+
{{t "inspection.record.linked-issue"}}
+
{{n-a (or @resource.issue.public_id @resource.issue_uuid)}}
+
+
+
{{t "inspection.record.linked-work-order"}}
+
{{n-a (or @resource.work_order.public_id @resource.work_order_uuid)}}
+
+
+
+ + + +
diff --git a/addon/components/inspection-submission/details.js b/addon/components/inspection-submission/details.js new file mode 100644 index 000000000..a39ab96b1 --- /dev/null +++ b/addon/components/inspection-submission/details.js @@ -0,0 +1,90 @@ +import Component from '@glimmer/component'; +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'; + +/** + * 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; + + @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(' · '); + } + + 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. +}} +
+ +
+ + + {{form.name}} + + + +
+ +
+
{{option.label}}
+
{{option.description}}
+
+
+
+
+ + + {{or vehicle.displayName vehicle.name vehicle.public_id}} + + + + + {{or driver.name driver.public_id}} + + + + + + + + +
+
+ +
+ {{#if this.load.isRunning}} +
+
+
+ +
+
+
+ {{else if this.hasStructure}} + + {{else}} +
+
+
+ {{#if @resource.form}}{{t "inspection.record.form-has-no-fields"}}{{else}}{{t "inspection.record.choose-a-form"}}{{/if}} +
+
+
+ {{/if}} +
+ + + + + + +
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-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/public-inspection.hbs b/addon/components/public-inspection.hbs new file mode 100644 index 000000000..e4ecca449 --- /dev/null +++ b/addon/components/public-inspection.hbs @@ -0,0 +1,174 @@ +{{! + An inspection filled in from a tokenised link. + + Rendered by the host console's top-level `virtual` route at + `/~/inspection?id=…&token=…`, which sits outside the console's shell and + outside its authentication gate — so this page brings its own full-height + background and its own header, and never assumes a signed-in user. +}} +
+
+ {{#if (and this.pinRequired (not this.form))}} +
+ +

{{t "inspection.public.pin-title"}}

+

{{t "inspection.public.pin-help"}}

+
+ + {{#if this.pinError}} + + {{/if}} +
+
+ + + {{else if this.loadInspection.isRunning}} +
+ +
+ + {{else if this.submission}} +
+ +

{{t "inspection.public.submitted-title"}}

+

{{t "inspection.public.submitted-body"}}

+

{{this.submission.id}}

+
+ + {{else if (and this.error (not this.form))}} +
+ +

{{t "inspection.public.unavailable-title"}}

+

{{this.error}}

+
+ + {{else if this.form}} +
+

{{this.form.name}}

+ {{#if this.form.description}} +

{{this.form.description}}

+ {{/if}} +
+ {{#if this.identity.assignee}} +
{{t "inspection.public.for"}}: {{this.identity.assignee.name}}
+ {{/if}} + {{#if this.identity.vehicle}} +
{{t "inspection.record.vehicle"}}: {{this.identity.vehicle.name}}
+ {{/if}} + {{#if this.identity.driver}} +
{{t "inspection.record.driver"}}: {{this.identity.driver.name}}
+ {{/if}} + {{#if this.identity.expires_at}} +
{{t "inspection.link.expires"}}: {{format-date-fns this.identity.expires_at "dd MMM yyyy, HH:mm"}}
+ {{/if}} +
+
+ +
+
+
+
+
+ {{t "inspection.record.details"}} +
+
+
+ {{t "inspection.record.odometer"}} +
+ +
+
+
+ {{t "inspection.record.engine-hours"}} +
+ +
+
+
+
+
+
+
+ + {{#if this.hasSheet}} + + {{else}} +
+
+
{{t "inspection.record.form-has-no-fields"}}
+
+
+ {{/if}} + +
+
+
+
+
+ {{t "inspection.public.sign-off"}} +
+
+
+ {{t "inspection.public.your-name"}} + {{t "inspection.public.your-name-help"}} +
+ +
+
+
+
+
+
+
+ + {{#if this.error}} +
{{this.error}}
+ {{/if}} + +
+
+ {{#if (eq this.blockedReason "required")}} + {{t "inspection.public.blocked-required" count=this.summary.missingRequired}} + {{else if (eq this.blockedReason "defects")}} + {{t "inspection.public.blocked-defects" count=this.summary.incompleteDefects}} + {{/if}} +
+
+ + + {{/if}} +
+
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 @@ +
+ {{#if this.photo}} + + {{/if}} +
+ {{@title}} + {{#if this.details}} + {{this.details}} + {{/if}} +
+
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/table/cell/fleet-ops-option.hbs b/addon/components/table/cell/fleet-ops-option.hbs new file mode 100644 index 000000000..6d8c8ae42 --- /dev/null +++ b/addon/components/table/cell/fleet-ops-option.hbs @@ -0,0 +1,20 @@ +{{! + A table cell that shows an option's label rather than the value stored on + the record, so a Type column reads "DVIR" where the row holds "dvir". + + The column names the list it draws from: + + { valuePath: 'type', cellComponent: 'table/cell/fleet-ops-option', optionsKey: 'inspectionFormTypes' } + + A value with no matching option is humanized rather than blanked, so a row + written before an option was retired is still legible. +}} +
+ + {{#if @value}} + {{or (get-fleet-ops-option-label @column.optionsKey @value) (smart-humanize @value)}} + {{else}} + - + {{/if}} + +
diff --git a/addon/controllers/maintenance/inspection-forms/index.js b/addon/controllers/maintenance/inspection-forms/index.js new file mode 100644 index 000000000..53d812903 --- /dev/null +++ b/addon/controllers/maintenance/inspection-forms/index.js @@ -0,0 +1,90 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +export default class MaintenanceInspectionFormsIndexController extends Controller { + @service inspectionFormActions; + @service intl; + + @tracked queryParams = ['status', 'type', 'page', 'limit', 'sort', 'query', 'public_id', 'created_at', 'updated_at']; + @tracked page = 1; + @tracked limit; + @tracked sort = '-created_at'; + @tracked public_id; + @tracked status; + @tracked type; + + get actionButtons() { + return [ + { icon: 'refresh', onClick: this.inspectionFormActions.refresh, helpText: this.intl.t('common.refresh') }, + { text: this.intl.t('common.new'), type: 'primary', icon: 'plus', onClick: this.inspectionFormActions.transition.create }, + ]; + } + + get bulkActions() { + return [{ label: 'Delete selected...', class: 'text-red-500', fn: this.inspectionFormActions.bulkDelete }]; + } + + get columns() { + return [ + { + label: 'Name', + valuePath: 'name', + cellComponent: 'table/cell/anchor', + action: this.inspectionFormActions.transition.view, + permission: 'fleet-ops view inspection-form', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'name', + filterComponent: 'filter/string', + }, + { + label: 'Type', + valuePath: 'type', + cellComponent: 'table/cell/fleet-ops-option', + optionsKey: 'inspectionFormTypes', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'type', + filterComponent: 'filter/string', + }, + { + label: 'Status', + valuePath: 'status', + cellComponent: 'table/cell/status', + resizable: true, + sortable: true, + filterable: true, + filterParam: 'status', + filterComponent: 'filter/string', + }, + { label: 'Items', valuePath: 'item_count', resizable: true, sortable: false }, + { label: this.intl.t('column.created-at'), valuePath: 'createdAt', sortParam: 'created_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 form', fn: this.inspectionFormActions.transition.view, permission: 'fleet-ops view inspection-form' }, + { label: 'Edit form', fn: this.inspectionFormActions.transition.edit, permission: 'fleet-ops update inspection-form' }, + { separator: true }, + { label: 'Publish', fn: this.inspectionFormActions.publish, permission: 'fleet-ops publish inspection-form' }, + { label: 'Archive', fn: this.inspectionFormActions.archive, permission: 'fleet-ops archive inspection-form' }, + { label: 'Generate inspection link', fn: this.inspectionFormActions.generateLink, permission: 'fleet-ops view inspection-form' }, + { separator: true }, + { label: 'Delete form', fn: this.inspectionFormActions.delete, class: 'text-red-500', permission: 'fleet-ops delete inspection-form' }, + ], + sortable: false, + filterable: false, + resizable: false, + searchable: false, + }, + ]; + } +} diff --git a/addon/controllers/maintenance/inspection-forms/index/details.js b/addon/controllers/maintenance/inspection-forms/index/details.js new file mode 100644 index 000000000..4cef722a9 --- /dev/null +++ b/addon/controllers/maintenance/inspection-forms/index/details.js @@ -0,0 +1,44 @@ +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 MaintenanceInspectionFormsIndexDetailsController extends Controller { + @service inspectionFormActions; + @service hostRouter; + @tracked overlay; + + /** + * Publish disappears once the form is published — leaving it there invites + * an author to press a button that can only tell them it is already done. + * The public link needs a published form, so it appears at the same moment. + */ + get actionButtons() { + const isPublished = this.model?.is_published === true || this.model?.status === 'published'; + + return [ + ...(isPublished ? [] : [{ icon: 'check', fn: this.publish, text: 'Publish', type: 'success', permission: 'fleet-ops publish inspection-form' }]), + ...(isPublished ? [{ icon: 'link', fn: this.generateLink, text: 'Generate Link', permission: 'fleet-ops view inspection-form' }] : []), + { icon: 'edit', fn: this.edit, permission: 'fleet-ops update inspection-form' }, + { icon: 'trash', fn: this.delete, type: 'danger', permission: 'fleet-ops delete inspection-form' }, + ]; + } + + @action publish() { + return this.inspectionFormActions.publish(this.model); + } + + @action generateLink() { + return this.inspectionFormActions.generateLink(this.model); + } + + @action edit() { + return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index.edit', this.model); + } + + @action delete() { + return this.inspectionFormActions.delete(this.model, { + onConfirm: () => 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..1d4caf670 --- /dev/null +++ b/addon/controllers/maintenance/inspection-submissions/index.js @@ -0,0 +1,86 @@ +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 }, + { label: 'Submit', fn: this.inspectionSubmissionActions.submit, permission: 'fleet-ops submit inspection-submission' }, + { label: 'Create issue', fn: this.inspectionSubmissionActions.createIssue, permission: 'fleet-ops create-issue inspection-submission' }, + { label: 'Create work order', fn: this.inspectionSubmissionActions.createWorkOrder, permission: 'fleet-ops create-work-order inspection-submission' }, + { label: 'Resolve', fn: this.inspectionSubmissionActions.resolve, permission: 'fleet-ops resolve inspection-submission' }, + { 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..a2377c87c --- /dev/null +++ b/addon/controllers/maintenance/inspection-submissions/index/details.js @@ -0,0 +1,51 @@ +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') }, + ]; + } + + get actionButtons() { + return [ + { icon: 'triangle-exclamation', fn: this.createIssue, text: 'Create Issue', permission: 'fleet-ops create-issue inspection-submission' }, + { icon: 'clipboard-list', fn: this.createWorkOrder, text: 'Create Work Order', permission: 'fleet-ops create-work-order inspection-submission' }, + { icon: 'check', fn: this.resolve, text: 'Resolve', permission: 'fleet-ops resolve inspection-submission' }, + { icon: 'edit', fn: this.edit, permission: 'fleet-ops update inspection-submission' }, + { icon: 'trash', fn: this.delete, type: 'danger', permission: 'fleet-ops delete inspection-submission' }, + ]; + } + + @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/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/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..6a03c807c 100644 --- a/addon/routes.js +++ b/addon/routes.js @@ -227,6 +227,28 @@ 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('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/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/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..4427569a0 --- /dev/null +++ b/addon/services/inspection-submission-actions.js @@ -0,0 +1,101 @@ +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; + + 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 async submit(submission) { + return this.postAction(submission, 'submit', 'Inspection submitted.'); + } + + @action async createIssue(submission) { + return this.postAction(submission, 'create-issue', 'Issue created from failed inspection items.'); + } + + @action async createWorkOrder(submission) { + return this.postAction(submission, 'create-work-order', 'Work order created from failed inspection items.'); + } + + @action async resolve(submission) { + return this.postAction(submission, 'resolve', 'Inspection resolved.'); + } + + async postAction(submission, actionName, message) { + try { + await this.fetch.post(`inspection-submissions/${submission.id}/${actionName}`); + this.notifications.success(message); + await this.refresh(); + } catch (error) { + this.notifications.serverError(error); + } + } +} diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css index dbc585215..5958dd48a 100644 --- a/addon/styles/fleetops-engine.css +++ b/addon/styles/fleetops-engine.css @@ -8883,3 +8883,1592 @@ 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-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-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; +} + +/* ========================================================================== + 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..143f18fd2 --- /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/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..a787113ee --- /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/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/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-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/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/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/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/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/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/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 }}.

+ +@if($url) +

Open the inspection

+@endif + +

When it asks, enter this PIN:

+ +

{{ $pin }}

+ +

The link locks after {{ $maxAttempts }} incorrect PINs.@if($expiresAt) It expires on {{ $expiresAt->format('j M Y, H:i T') }}.@endif

+ +@if($url) +

If the button does not work, copy this address into your browser:
{{ $url }}

+@endif + +

If you were not expecting this, you can ignore it. Do not forward this email: anyone with it can open the inspection.

+
diff --git a/server/src/Auth/Schemas/FleetOps.php b/server/src/Auth/Schemas/FleetOps.php index 081aabc2f..74bf863ae 100644 --- a/server/src/Auth/Schemas/FleetOps.php +++ b/server/src/Auth/Schemas/FleetOps.php @@ -125,6 +125,14 @@ class FleetOps 'name' => 'work-order', 'actions' => ['export', 'import'], ], + [ + 'name' => 'inspection-form', + 'actions' => ['publish', 'archive'], + ], + [ + 'name' => 'inspection-submission', + 'actions' => ['submit', 'create-issue', 'create-work-order', 'resolve'], + ], [ 'name' => 'equipment', 'actions' => ['export', 'import'], @@ -297,6 +305,8 @@ class FleetOps 'see extension', '* maintenance', '* work-order', + '* inspection-form', + '* inspection-submission', '* equipment', '* part', '* trailer', @@ -322,6 +332,8 @@ class FleetOps '* place', '* maintenance', '* work-order', + '* inspection-form', + '* inspection-submission', '* equipment', '* part', '* trailer', diff --git a/server/src/Exports/InspectionExport.php b/server/src/Exports/InspectionExport.php new file mode 100644 index 000000000..90357ab8a --- /dev/null +++ b/server/src/Exports/InspectionExport.php @@ -0,0 +1,125 @@ +selections = $selections; + } + + public function map($submission): array + { + return [ + $submission->public_id, + $submission->form_name, + $submission->vehicle_name, + $submission->driver_name, + $submission->type, + $submission->status, + $submission->result, + $submission->source, + $submission->odometer, + $submission->engine_hours, + $submission->total_items, + $submission->failed_items, + static::failedLabels($submission), + static::unsafeLabel($submission), + $submission->issue?->public_id, + $submission->workOrder?->public_id, + $submission->started_at, + $submission->submitted_at, + $submission->resolved_at, + $submission->created_at, + ]; + } + + public function headings(): array + { + return [ + 'ID', + 'Form', + 'Vehicle', + 'Driver', + 'Type', + 'Status', + 'Result', + 'Source', + 'Odometer', + 'Engine Hours', + 'Items', + 'Defects', + 'Failed Items', + 'Unsafe', + 'Issue', + 'Work Order', + 'Started', + 'Submitted', + 'Resolved', + 'Date Created', + ]; + } + + public function columnFormats(): array + { + return [ + 'Q' => NumberFormat::FORMAT_DATE_DDMMYYYY, + 'R' => NumberFormat::FORMAT_DATE_DDMMYYYY, + 'S' => NumberFormat::FORMAT_DATE_DDMMYYYY, + 'T' => NumberFormat::FORMAT_DATE_DDMMYYYY, + ]; + } + + /** The defects, named, so the spreadsheet says what actually failed. */ + public static function failedLabels(InspectionSubmission $submission): string + { + return $submission->itemResults + ->filter(fn ($result) => !$result->passed) + ->map(fn ($result) => trim((string) $result->label)) + ->filter() + ->implode(', '); + } + + /** Whether the inspection took the vehicle out of service. */ + public static function unsafeLabel(InspectionSubmission $submission): string + { + $unsafe = filter_var(data_get($submission->meta, 'unsafe', false), FILTER_VALIDATE_BOOLEAN) + || $submission->itemResults->contains(fn ($result) => filter_var(data_get($result->meta, 'unsafe', false), FILTER_VALIDATE_BOOLEAN)); + + return $unsafe ? 'Yes' : 'No'; + } + + /** + * @return \Illuminate\Support\Collection + */ + public function collection() + { + $query = InspectionSubmission::where('company_uuid', session('company')) + ->with(['form', 'vehicle', 'driver', 'itemResults', 'issue', 'workOrder']); + + if ($this->selections) { + $query->whereIn('uuid', $this->selections); + } + + return $query->get(); + } +} diff --git a/server/src/Http/Controllers/Api/v1/GeofenceController.php b/server/src/Http/Controllers/Api/v1/GeofenceController.php index 433fdd62f..385b72e60 100644 --- a/server/src/Http/Controllers/Api/v1/GeofenceController.php +++ b/server/src/Http/Controllers/Api/v1/GeofenceController.php @@ -2,9 +2,9 @@ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1; +use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\GeofenceEventLog; use Fleetbase\FleetOps\Support\Utils; -use Fleetbase\FleetOps\Models\Driver; use Fleetbase\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; diff --git a/server/src/Http/Controllers/Api/v1/InspectionController.php b/server/src/Http/Controllers/Api/v1/InspectionController.php new file mode 100644 index 000000000..00698f67c --- /dev/null +++ b/server/src/Http/Controllers/Api/v1/InspectionController.php @@ -0,0 +1,300 @@ +filled('type')) { + $query->whereIn('type', Utils::arrayFrom($request->input('type'))); + } + + if ($request->filled('vehicle')) { + $vehicle = static::findVehicleRecord($request->input('vehicle')); + if (!$vehicle) { + return response()->apiError('Vehicle resource not found.', 404); + } + + $query->where(function ($query) use ($vehicle) { + $query->whereNull('subject_uuid')->orWhere(function ($query) use ($vehicle) { + $query->where('subject_type', Vehicle::class)->where('subject_uuid', $vehicle->uuid); + }); + }); + } + + return InspectionFormResource::collection($query->limit(static::limit($request))->get()); + } + + /** + * GET /v1/inspection-forms/{id} — one published form, items and settings included. + * + * A draft or archived form answers 404 rather than 403: to a driver a form + * that cannot be filled in does not exist, and the distinction would only + * tell them something about the console they cannot act on. + */ + public function findForm(string $id) + { + $form = static::findPublishedForm($id); + if (!$form) { + return response()->apiError('Inspection form resource not found.', 404); + } + + return new InspectionFormResource($form); + } + + /** + * POST /v1/inspections — file an inspection against a published form. + * + * The body is exactly what the public link accepts, plus the three things + * the link already knew: which form, which vehicle, which driver. The app + * queues a submit when it is offline and replays it later, so a replay + * that carries the same `Idempotency-Key` header answers with the + * submission the first attempt created rather than filing a second one. + */ + public function submit(Request $request) + { + $validated = $request->validate(array_merge(InspectionSubmitter::rules(), [ + 'inspection_form' => 'required|string', + 'driver' => 'required|string', + 'vehicle' => 'nullable|string', + 'started_at' => 'nullable|date', + ])); + + $driver = static::findDriverRecord($request->input('driver')); + if (!$driver) { + return response()->apiError('Driver resource not found.', 404); + } + + $form = static::findPublishedForm($request->input('inspection_form')); + if (!$form) { + return response()->apiError('Inspection form resource not found.', 404); + } + + $vehicle = null; + if ($request->filled('vehicle')) { + $vehicle = static::findVehicleRecord($request->input('vehicle')); + if (!$vehicle) { + return response()->apiError('Vehicle resource not found.', 404); + } + } + + $idempotencyKey = trim((string) $request->header('Idempotency-Key')); + if ($idempotencyKey !== '') { + $replayed = static::findSubmissionByIdempotencyKey($driver, $idempotencyKey); + if ($replayed) { + return new InspectionSubmissionResource($replayed->load(static::SUBMISSION_RELATIONS)); + } + } + + $attributes = [ + // Without an explicit vehicle the inspection is of the truck the + // driver is assigned to, which is what a pre-trip almost always is. + 'vehicle_uuid' => $vehicle?->uuid ?? $driver->vehicle_uuid, + 'driver_uuid' => $driver->uuid, + 'submitted_by_uuid' => $driver->user_uuid, + 'source' => 'navigator', + 'meta' => $idempotencyKey !== '' ? ['idempotency_key' => $idempotencyKey] : [], + ]; + + if ($request->filled('started_at')) { + $attributes['started_at'] = Carbon::parse($request->input('started_at')); + } + + $submission = InspectionSubmitter::submit($form, $validated, $attributes); + + return new InspectionSubmissionResource($submission->fresh(static::SUBMISSION_RELATIONS)); + } + + /** + * GET /v1/inspections — submissions, newest first. + */ + public function query(Request $request) + { + $query = static::submissions(); + + if ($request->filled('driver')) { + $driver = static::findDriverRecord($request->input('driver')); + if (!$driver) { + return response()->apiError('Driver resource not found.', 404); + } + + $query->where('driver_uuid', $driver->uuid); + } + + if ($request->filled('vehicle')) { + $vehicle = static::findVehicleRecord($request->input('vehicle')); + if (!$vehicle) { + return response()->apiError('Vehicle resource not found.', 404); + } + + $query->where('vehicle_uuid', $vehicle->uuid); + } + + static::applySubmissionFilters($request, $query); + + return InspectionSubmissionResource::collection($query->limit(static::limit($request))->get()); + } + + /** + * GET /v1/inspections/{id} — one submission with its item results and follow-up. + */ + public function find(string $id) + { + $submission = static::findSubmission($id); + if (!$submission) { + return response()->apiError('Inspection resource not found.', 404); + } + + return new InspectionSubmissionResource($submission->load(static::SUBMISSION_RELATIONS)); + } + + /** + * GET /v1/vehicles/{id}/inspections — a vehicle's inspection history. + * + * The same rows `query` answers with `vehicle=`, addressed the way the app + * holds them: it is on a vehicle's screen, and wants that vehicle's history. + */ + public function forVehicle(Request $request, string $id) + { + $vehicle = static::findVehicleRecord($id); + if (!$vehicle) { + return response()->apiError('Vehicle resource not found.', 404); + } + + $query = static::submissions()->where('vehicle_uuid', $vehicle->uuid); + static::applySubmissionFilters($request, $query); + + return InspectionSubmissionResource::collection($query->limit(static::limit($request))->get()); + } + + /** The column filters shared by the two listings. Each accepts one value or a comma separated list. */ + protected static function applySubmissionFilters(Request $request, $query): void + { + foreach (['type', 'result', 'status'] as $column) { + if ($request->filled($column)) { + $query->whereIn($column, Utils::arrayFrom($request->input($column))); + } + } + } + + /** + * How many rows a listing answers with. Defaults to what fits on a screen; + * a driver's whole history is thousands of rows nobody asked for. + */ + protected static function limit(Request $request): int + { + $limit = (int) $request->input('limit', 30); + + return $limit > 0 ? $limit : 30; + } + + /** + * Every lookup is scoped to the company the API key belongs to. The + * consumable API serves one company per credential, and a public id from + * another company must be indistinguishable from one that does not exist. + */ + protected static function publishedForms() + { + return InspectionForm::where('company_uuid', session('company')) + ->where('status', 'published') + ->whereNotNull('published_at') + // The form's structure is the point of the read; loading it with + // the forms keeps a listing to two queries instead of two a form. + ->with(['fieldGroups', 'fields']) + ->orderBy('published_at', 'desc'); + } + + protected static function submissions() + { + return InspectionSubmission::where('company_uuid', session('company')) + ->with(['form', 'vehicle', 'driver', 'customFieldValues.customField', 'files']) + ->orderBy('submitted_at', 'desc') + ->orderBy('created_at', 'desc'); + } + + protected static function findPublishedForm(string $id): ?InspectionForm + { + return static::publishedForms() + ->where(function ($query) use ($id) { + $query->where('public_id', $id)->orWhere('uuid', $id); + }) + ->first(); + } + + protected static function findSubmission(string $id): ?InspectionSubmission + { + return InspectionSubmission::where('company_uuid', session('company')) + ->where(function ($query) use ($id) { + $query->where('public_id', $id)->orWhere('uuid', $id); + }) + ->first(); + } + + protected static function findSubmissionByIdempotencyKey(Driver $driver, string $key): ?InspectionSubmission + { + return InspectionSubmission::where('company_uuid', $driver->company_uuid) + ->where('driver_uuid', $driver->uuid) + ->where('meta->idempotency_key', $key) + ->first(); + } + + protected static function findDriverRecord(string $id): ?Driver + { + return Driver::where('company_uuid', session('company')) + ->where(function ($query) use ($id) { + $query->where('public_id', $id)->orWhere('uuid', $id); + }) + ->first(); + } + + protected static function findVehicleRecord(string $id): ?Vehicle + { + return Vehicle::where('company_uuid', session('company')) + ->where(function ($query) use ($id) { + $query->where('public_id', $id)->orWhere('uuid', $id); + }) + ->first(); + } +} diff --git a/server/src/Http/Controllers/Internal/v1/HubController.php b/server/src/Http/Controllers/Internal/v1/HubController.php index a8c83e5e0..d1a7c9866 100644 --- a/server/src/Http/Controllers/Internal/v1/HubController.php +++ b/server/src/Http/Controllers/Internal/v1/HubController.php @@ -9,6 +9,8 @@ use Fleetbase\FleetOps\Models\Fleet; use Fleetbase\FleetOps\Models\FuelProviderTransaction; use Fleetbase\FleetOps\Models\FuelReport; +use Fleetbase\FleetOps\Models\InspectionForm; +use Fleetbase\FleetOps\Models\InspectionSubmission; use Fleetbase\FleetOps\Models\Issue; use Fleetbase\FleetOps\Models\Maintenance; use Fleetbase\FleetOps\Models\MaintenanceSchedule; @@ -112,24 +114,36 @@ public function maintenance(Request $request) $now = Carbon::now(); $next7 = Carbon::now()->addDays(7); - $overdueSchedules = $this->count(MaintenanceSchedule::query()->where('status', 'active')->whereNotNull('next_due_date')->where('next_due_date', '<', $now), $company); - $upcomingSchedules = $this->count(MaintenanceSchedule::query()->where('status', 'active')->whereBetween('next_due_date', [$now, $next7]), $company); - $openWorkOrders = $this->count(WorkOrder::query()->whereIn('status', ['open', 'in_progress']), $company); - $overdueWorkOrders = $this->count(WorkOrder::query()->whereNotIn('status', ['closed', 'canceled'])->whereNotNull('due_at')->where('due_at', '<', $now), $company); - $openMaintenance = $this->count(Maintenance::query()->whereNotIn('status', ['completed', 'canceled']), $company); - $highPriorityMaintenance = $this->count(Maintenance::query()->whereNotIn('status', ['completed', 'canceled'])->whereIn('priority', ['high', 'urgent', 'critical']), $company); - $lowStockParts = $this->count(Part::query()->where('quantity_on_hand', '>', 0)->where('quantity_on_hand', '<=', 5), $company); - $equipment = $this->count(Equipment::query(), $company); + $overdueSchedules = $this->count(MaintenanceSchedule::query()->where('status', 'active')->whereNotNull('next_due_date')->where('next_due_date', '<', $now), $company); + $upcomingSchedules = $this->count(MaintenanceSchedule::query()->where('status', 'active')->whereBetween('next_due_date', [$now, $next7]), $company); + $openWorkOrders = $this->count(WorkOrder::query()->whereIn('status', ['open', 'in_progress']), $company); + $overdueWorkOrders = $this->count(WorkOrder::query()->whereNotIn('status', ['closed', 'canceled'])->whereNotNull('due_at')->where('due_at', '<', $now), $company); + $openMaintenance = $this->count(Maintenance::query()->whereNotIn('status', ['completed', 'canceled']), $company); + $highPriorityMaintenance = $this->count(Maintenance::query()->whereNotIn('status', ['completed', 'canceled'])->whereIn('priority', ['high', 'urgent', 'critical']), $company); + $publishedInspectionForms = $this->count(InspectionForm::query()->where('status', 'published'), $company); + $failedInspections = $this->count(InspectionSubmission::query()->whereIn('status', ['submitted', 'needs_review'])->where('result', 'failed'), $company); + $unresolvedInspections = $this->count(InspectionSubmission::query()->where('result', 'failed')->whereNull('resolved_at'), $company); + $lowStockParts = $this->count(Part::query()->where('quantity_on_hand', '>', 0)->where('quantity_on_hand', '<=', 5), $company); + $equipment = $this->count(Equipment::query(), $company); return response()->json([ 'kpis' => [ $this->kpi('overdue_schedules', 'Overdue Schedules', $overdueSchedules, $overdueSchedules > 0 ? 'Recurring service needs attention.' : 'No recurring service is overdue.', $overdueSchedules > 0 ? 'rose' : 'green', 'calendar-xmark', 'maintenance.schedules'), $this->kpi('upcoming_schedules', 'Due This Week', $upcomingSchedules, 'Maintenance schedules due in the next 7 days.', 'blue', 'calendar-day', 'maintenance.schedules'), + $this->kpi('failed_inspections', 'Failed Inspections', $failedInspections, $failedInspections > 0 ? 'Failed DVIR items need issue or work order follow-up.' : 'No failed inspections need review.', $failedInspections > 0 ? 'rose' : 'green', 'clipboard-check', 'maintenance.inspection-submissions'), $this->kpi('open_work_orders', 'Open Work Orders', $openWorkOrders, $openWorkOrders > 0 ? 'Active work needs assignment or closure.' : 'No open work orders right now.', $openWorkOrders > 0 ? 'amber' : 'green', 'clipboard-list', 'maintenance.work-orders'), - $this->kpi('low_stock_parts', 'Low Stock Parts', $lowStockParts, $lowStockParts > 0 ? 'Parts inventory may need replenishment.' : 'No low-stock parts detected.', $lowStockParts > 0 ? 'amber' : 'green', 'cog', 'maintenance.parts'), ], - 'actions' => $this->maintenanceActions($overdueSchedules, $upcomingSchedules, $openWorkOrders, $overdueWorkOrders, $highPriorityMaintenance, $lowStockParts, $equipment), + 'actions' => $this->maintenanceActions($overdueSchedules, $upcomingSchedules, $openWorkOrders, $overdueWorkOrders, $highPriorityMaintenance, $lowStockParts, $equipment, $failedInspections, $unresolvedInspections, $publishedInspectionForms), 'sections' => [ + [ + 'key' => 'inspections', + 'title' => 'Inspections And DVIR', + 'description' => 'Capture driver inspections, failed items, and repair follow-up.', + 'links' => [ + $this->link('Inspection Forms', 'maintenance.inspection-forms', 'clipboard-check', $publishedInspectionForms, 'Published forms available for driver and technician inspections.'), + $this->link('Inspections', 'maintenance.inspection-submissions', 'list-check', $unresolvedInspections, 'Submitted DVIRs, failed items, and linked follow-up work.'), + ], + ], [ 'key' => 'planning', 'title' => 'Planning', @@ -152,6 +166,7 @@ public function maintenance(Request $request) ], 'docs' => [ $this->doc('Schedules', 'calendar-alt', 'fleet-ops/maintenance/schedules/overview', 'Maintenance schedules guide', 'Plan recurring service windows and convert due schedules into work orders.'), + $this->doc('Inspections', 'clipboard-check', 'fleet-ops/maintenance/inspections/overview', 'Inspections guide', 'Capture DVIR checks, failed items, and issue or work order follow-up.'), $this->doc('Work Orders', 'clipboard-list', 'fleet-ops/maintenance/work-orders/overview', 'Work orders guide', 'Coordinate assigned maintenance work, vendors, due dates, and completion.'), $this->doc('Equipment', 'trailer', 'fleet-ops/maintenance/equipment/overview', 'Equipment guide', 'Track serviceable equipment that participates in maintenance operations.'), $this->doc('Parts', 'cog', 'fleet-ops/maintenance/parts/overview', 'Parts guide', 'Manage parts inventory and restocking signals used by maintenance teams.'), @@ -231,10 +246,16 @@ protected function resourceActions(array $counts, int $fuelRecords): array return $actions ? array_slice($actions, 0, 6) : [$this->action('ready', 'Core resources look ready', 'Drivers, vehicles, issues, and supporting records are in a healthy operating posture.', 'success', 'check-circle', null)]; } - protected function maintenanceActions(int $overdueSchedules, int $upcomingSchedules, int $openWorkOrders, int $overdueWorkOrders, int $highPriorityMaintenance, int $lowStockParts, int $equipment): array + protected function maintenanceActions(int $overdueSchedules, int $upcomingSchedules, int $openWorkOrders, int $overdueWorkOrders, int $highPriorityMaintenance, int $lowStockParts, int $equipment, int $failedInspections = 0, int $unresolvedInspections = 0, int $publishedInspectionForms = 0): array { $actions = []; + if ($failedInspections > 0) { + $actions[] = $this->action('failed_inspections', 'Review failed inspections', "{$failedInspections} failed inspection" . ($failedInspections === 1 ? ' needs' : 's need') . ' issue or work order follow-up.', 'warning', 'clipboard-check', 'maintenance.inspection-submissions', ['result' => 'failed']); + } elseif ($publishedInspectionForms === 0) { + $actions[] = $this->action('create_inspection_forms', 'Create inspection forms', 'Publish DVIR forms so drivers can report vehicle defects before dispatch.', 'info', 'clipboard-check', 'maintenance.inspection-forms'); + } + if ($overdueSchedules > 0) { $actions[] = $this->action('overdue_schedules', 'Review overdue schedules', "{$overdueSchedules} recurring service schedule" . ($overdueSchedules === 1 ? ' is' : 's are') . ' overdue.', 'warning', 'calendar-xmark', 'maintenance.schedules'); } @@ -259,6 +280,10 @@ protected function maintenanceActions(int $overdueSchedules, int $upcomingSchedu $actions[] = $this->action('low_stock_parts', 'Replenish low-stock parts', "{$lowStockParts} stocked part" . ($lowStockParts === 1 ? ' is' : 's are') . ' at or below the default threshold.', 'warning', 'cog', 'maintenance.parts'); } + if ($unresolvedInspections > 0 && $failedInspections === 0) { + $actions[] = $this->action('unresolved_inspections', 'Resolve inspection follow-up', "{$unresolvedInspections} failed inspection" . ($unresolvedInspections === 1 ? ' remains' : 's remain') . ' unresolved.', 'warning', 'list-check', 'maintenance.inspection-submissions', ['result' => 'failed']); + } + if ($equipment === 0) { $actions[] = $this->action('add_equipment', 'Add serviceable equipment', 'Equipment records make maintenance planning more complete.', 'info', 'trailer', 'maintenance.equipment'); } diff --git a/server/src/Http/Controllers/Internal/v1/InspectionFormController.php b/server/src/Http/Controllers/Internal/v1/InspectionFormController.php new file mode 100644 index 000000000..b77373a82 --- /dev/null +++ b/server/src/Http/Controllers/Internal/v1/InspectionFormController.php @@ -0,0 +1,326 @@ +syncStructureFromRequest($request, $inspectionForm); + } + + public function onAfterUpdate(Request $request, InspectionForm $inspectionForm): void + { + $this->syncStructureFromRequest($request, $inspectionForm); + } + + /** The console edits the structure, so a read has to carry it. */ + public function onFindRecord($builder, $request): void + { + $builder->with(['fieldGroups', 'fields']); + } + + public function onQueryRecord($builder, $request): void + { + $builder->with(['fieldGroups', 'fields']); + } + + /** + * Writes the posted structure, pruning what the post no longer lists — + * the builder always posts the whole form, so a field it dropped is a + * field the author deleted. + */ + protected function syncStructureFromRequest(Request $request, InspectionForm $form): void + { + $draft = null; + foreach (['inspection_form.field_groups', 'field_groups', 'inspection_form.draft', 'draft'] as $key) { + $posted = $request->input($key); + if (is_array($posted) && !empty($posted)) { + $draft = $posted; + break; + } + } + + if ($draft === null) { + return; + } + + InspectionFormSync::sync($form, $draft, true); + $form->unsetRelation('fieldGroups'); + $form->unsetRelation('fields'); + $form->load(['fieldGroups', 'fields']); + } + + public function publish(string $id): JsonResponse + { + $form = $this->resolveForm($id) + ->firstOrFail(); + + $form->publish(); + + return response()->json([ + 'status' => 'ok', + 'message' => 'Inspection form published.', + 'data' => $form->fresh(), + ]); + } + + public function archive(string $id): JsonResponse + { + $form = $this->resolveForm($id) + ->firstOrFail(); + + $form->archive(); + + return response()->json([ + 'status' => 'ok', + 'message' => 'Inspection form archived.', + 'data' => $form->fresh(), + ]); + } + + public function generateLink(Request $request, string $id): JsonResponse + { + $form = $this->resolveForm($id) + ->firstOrFail(); + + if (!$form->is_published) { + return response()->json([ + 'error' => 'Inspection form must be published before generating a public link.', + ], 422); + } + + $validated = $request->validate([ + 'assignee' => 'nullable|string', + 'driver' => 'nullable|string', + 'vehicle' => 'nullable|string', + 'expires_at' => 'nullable|date|after:now', + 'single_use' => 'nullable|boolean', + 'pin_delivery' => 'nullable|in:none,email,sms', + ]); + + // Who the link is for, and the inspection's driver and vehicle, are + // each optional and independent: anyone in the organisation may + // complete an inspection, not only a driver. + $assignee = $this->resolveAssignee(data_get($validated, 'assignee')); + $driver = $this->resolveDriver(data_get($validated, 'driver')); + $vehicle = $this->resolveVehicle(data_get($validated, 'vehicle')); + $delivery = data_get($validated, 'pin_delivery') ?: 'none'; + $token = InspectionLink::generateToken(); + + // A delivery that cannot happen is refused before anything is minted, + // so the dispatcher hears now rather than finding a link nobody got + // the PIN for. + if ($delivery !== 'none') { + $reason = InspectionLinkPin::unavailableReason($assignee ?? $driver?->user, $delivery); + + if ($reason) { + return response()->json(['error' => $reason], 422); + } + } + + $link = InspectionLink::create([ + 'company_uuid' => $form->company_uuid, + 'inspection_form_uuid' => $form->uuid, + 'driver_uuid' => $driver?->uuid, + 'vehicle_uuid' => $vehicle?->uuid, + 'assignee_uuid' => $assignee?->uuid, + 'created_by_uuid' => session('user'), + 'token_hash' => InspectionLink::hashToken($token), + 'token' => $token, + 'status' => 'active', + 'single_use' => data_get($validated, 'single_use', true), + // A link nobody put a limit on used to stay live until it was + // used or revoked. It now lasts DEFAULT_TTL_HOURS unless chosen. + 'expires_at' => data_get($validated, 'expires_at') ?? now()->addHours(InspectionLink::DEFAULT_TTL_HOURS), + ]); + + // Every link minted here carries a PIN. It protects anything only when + // it travels a different way from the link, which is why a delivery + // sends the PIN and never the link. + $pin = InspectionLink::generatePin(); + $link->setPin($pin); + $link->save(); + + $delivered = $delivery !== 'none' ? InspectionLinkPin::send($link, $delivery) : null; + + // `~/` is what puts the page outside the console: the host app routes + // `/~/:slug` at the top level, a sibling of `console`, so neither the + // console's chrome nor its authentication gate applies. Without it the + // link lands on the authenticated `console/:slug` route instead, which + // bounces a signed-out recipient to the login page and renders blank + // for everyone else. + $path = '/~/inspection?id=' . urlencode($form->public_id ?? $form->uuid) . '&token=' . urlencode($token); + + return response()->json([ + 'status' => 'ok', + 'message' => 'Inspection link generated.', + 'link' => array_merge( + (new InspectionLinkResource($link->fresh(['form', 'driver', 'vehicle', 'assignee', 'createdBy'])))->resolve(), + ['path' => $path, 'token' => $token, 'pin' => $pin] + ), + 'pin_delivery' => $delivered, + ]); + } + + /** + * Every link minted for this form, newest first. + * + * A link used to vanish the moment the modal that minted it closed. An + * operator needs to see what is outstanding: which vehicle and driver a + * link was for, when it was made, whether it has been opened, and whether + * it still works. + */ + public function links(Request $request, string $id): JsonResponse + { + $form = $this->resolveForm($id)->firstOrFail(); + + $links = InspectionLink::where('inspection_form_uuid', $form->uuid) + ->with(['form', 'driver', 'vehicle', 'assignee', 'createdBy']) + ->orderByDesc('created_at') + ->limit((int) $request->input('limit', 50)) + ->get(); + + return response()->json([ + 'links' => InspectionLinkResource::collection($links)->resolve(), + ]); + } + + /** Take a link out of use, leaving the record of it in the list. */ + public function revokeLink(Request $request, string $id, string $linkId): JsonResponse + { + $form = $this->resolveForm($id)->firstOrFail(); + + $link = InspectionLink::where('inspection_form_uuid', $form->uuid) + ->where(function ($query) use ($linkId) { + $query->where('uuid', $linkId)->orWhere('public_id', $linkId); + + if (is_numeric($linkId)) { + $query->orWhere('id', (int) $linkId); + } + }) + ->firstOrFail(); + + $link->revoke(); + + return response()->json([ + 'status' => 'ok', + 'message' => 'Inspection link revoked.', + 'link' => (new InspectionLinkResource($link->fresh(['form', 'driver', 'vehicle', 'createdBy'])))->resolve(), + ]); + } + + /** + * Send a link's PIN again, by email or SMS, to whoever the link is for. + * A failed delivery answers 200 with `pin_delivery.sent` false and why, so + * the console shows it as a warning rather than an error. + */ + public function sendPin(Request $request, string $id, string $linkId): JsonResponse + { + $form = $this->resolveForm($id)->firstOrFail(); + $validated = $request->validate(['via' => 'required|in:email,sms']); + + $link = InspectionLink::where('inspection_form_uuid', $form->uuid) + ->where(function ($query) use ($linkId) { + $query->where('uuid', $linkId)->orWhere('public_id', $linkId); + }) + ->firstOrFail(); + + if ($link->state !== 'active') { + return response()->json(['error' => 'Only an active link can have its PIN sent.'], 422); + } + + $reason = InspectionLinkPin::unavailableReason(InspectionLinkPin::recipientFor($link), $validated['via']); + + if ($reason) { + return response()->json(['error' => $reason], 422); + } + + $result = InspectionLinkPin::send($link, $validated['via']); + + return response()->json([ + 'status' => $result['sent'] ? 'ok' : 'error', + 'message' => $result['sent'] ? 'PIN sent.' : $result['error'], + 'pin_delivery' => $result, + 'link' => (new InspectionLinkResource($link->fresh(['form', 'driver', 'vehicle', 'assignee', 'createdBy'])))->resolve(), + ]); + } + + /** A user a link may be assigned to: only a member of this organisation. */ + protected function resolveAssignee(?string $id): ?User + { + if (!$id) { + return null; + } + + return User::where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->whereHas('companyUsers', function ($query) { + $query->where('company_uuid', session('company')); + }) + ->firstOrFail(); + } + + protected function resolveDriver(?string $id): ?Driver + { + if (!$id) { + return null; + } + + return Driver::where('company_uuid', session('company')) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->firstOrFail(); + } + + protected function resolveForm(string $id) + { + return InspectionForm::where('company_uuid', session('company')) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }); + } + + protected function resolveVehicle(?string $id): ?Vehicle + { + if (!$id) { + return null; + } + + return Vehicle::where('company_uuid', session('company')) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->firstOrFail(); + } +} diff --git a/server/src/Http/Controllers/Internal/v1/InspectionSubmissionController.php b/server/src/Http/Controllers/Internal/v1/InspectionSubmissionController.php new file mode 100644 index 000000000..441053dbc --- /dev/null +++ b/server/src/Http/Controllers/Internal/v1/InspectionSubmissionController.php @@ -0,0 +1,229 @@ +submitted_by_uuid && session('user')) { + $record->forceFill(['submitted_by_uuid' => session('user')])->save(); + } + + $this->syncAnswersFromRequest($request, $record); + $record->load(array_merge(['submittedBy'], static::RELATIONS)); + } + + public function onAfterUpdate($request, InspectionSubmission $record, array $input): void + { + $this->syncAnswersFromRequest($request, $record); + $record->load(static::RELATIONS); + } + + public function onFindRecord($builder, $request): void + { + $builder->with(array_merge(['submittedBy', 'issue', 'workOrder'], static::RELATIONS)); + } + + public function onQueryRecord($builder, $request): void + { + $builder->with(['form', 'vehicle', 'driver', 'itemResults']); + } + + /** + * The answers, then the results derived from them. + * + * A form built from fields is answered with `custom_field_values`, exactly + * as the driver API answers it, so the console and the app write the same + * rows; the submitter stores any photo or signature and mirrors every + * pass-fail answer into an item result. A submission against a legacy + * checklist still posts `item_results` directly, and that door stays open. + */ + protected function syncAnswersFromRequest(Request $request, InspectionSubmission $submission): void + { + $values = static::arrayInput($request, 'inspection_submission.custom_field_values', 'custom_field_values'); + if (!empty($values)) { + InspectionSubmitter::applyCustomFieldValues($submission, $values, session('user')); + + return; + } + + $this->syncItemResultsFromRequest($request, $submission); + } + + /** + * The first of the given keys that carries a list. + * + * The console posts a record under its resource name and the app posts it + * flat, so both spellings are read. `Request::array()` arrived in Laravel + * 11 and this runs on 10, where calling it is a BadMethodCallException. + * + * @return array + */ + protected static function arrayInput(Request $request, string ...$keys): array + { + foreach ($keys as $key) { + $value = $request->input($key); + if (is_array($value) && !empty($value)) { + return $value; + } + } + + return []; + } + + /** + * Export inspections to excel or csv. + * + * @return \Illuminate\Http\Response + */ + public function export(ExportRequest $request) + { + $format = $request->input('format', 'xlsx'); + $selections = static::arrayInput($request, 'selections'); + $fileName = trim(Str::slug('inspections-' . date('Y-m-d-H:i')) . '.' . $format); + + return $this->downloadExport(new InspectionExport($selections), $fileName); + } + + public function submit(string $id): JsonResponse + { + $submission = InspectionSubmission::where('uuid', $id) + ->orWhere('public_id', $id) + ->with(['itemResults', 'vehicle', 'driver', 'form']) + ->firstOrFail(); + + $submission->syncResultCounts(); + + return response()->json([ + 'status' => 'ok', + 'message' => 'Inspection submitted.', + 'data' => $submission->fresh(['itemResults', 'vehicle', 'driver', 'form']), + ]); + } + + public function createIssue(string $id): JsonResponse + { + $submission = InspectionSubmission::where('uuid', $id) + ->orWhere('public_id', $id) + ->with(['itemResults', 'vehicle', 'driver', 'form']) + ->firstOrFail(); + + $submission->syncResultCounts(); + $issue = $submission->createIssueFromFailures(); + + return response()->json([ + 'status' => 'ok', + 'message' => $issue ? 'Issue created from failed inspection items.' : 'No failed inspection items found.', + 'issue' => $issue, + 'data' => $submission->fresh(['itemResults', 'issue']), + ]); + } + + public function createWorkOrder(string $id): JsonResponse + { + $submission = InspectionSubmission::where('uuid', $id) + ->orWhere('public_id', $id) + ->with(['itemResults', 'vehicle', 'driver', 'form']) + ->firstOrFail(); + + $submission->syncResultCounts(); + $submission->createIssueFromFailures(); + $workOrder = $submission->createWorkOrderFromFailures(); + + return response()->json([ + 'status' => 'ok', + 'message' => $workOrder ? 'Work order created from failed inspection items.' : 'No failed inspection items found.', + 'work_order' => $workOrder, + 'data' => $submission->fresh(['itemResults', 'issue', 'workOrder']), + ]); + } + + public function resolve(string $id): JsonResponse + { + $submission = InspectionSubmission::where('uuid', $id) + ->orWhere('public_id', $id) + ->firstOrFail(); + + $submission->update([ + 'status' => 'resolved', + 'resolved_at' => now(), + ]); + + return response()->json([ + 'status' => 'ok', + 'message' => 'Inspection resolved.', + 'data' => $submission->fresh(['itemResults', 'issue', 'workOrder']), + ]); + } + + protected function syncItemResultsFromRequest(Request $request, InspectionSubmission $submission): void + { + $items = static::arrayInput($request, 'inspection_submission.item_results', 'item_results'); + if (empty($items)) { + return; + } + + $seen = []; + foreach ($items as $item) { + $uuid = data_get($item, 'uuid'); + $payload = [ + 'company_uuid' => $submission->company_uuid, + 'inspection_submission_uuid' => $submission->uuid, + 'item_key' => data_get($item, 'item_key'), + 'label' => data_get($item, 'label', data_get($item, 'title', 'Inspection item')), + 'category' => data_get($item, 'category'), + 'status' => data_get($item, 'status', data_get($item, 'passed') === false ? 'failed' : 'passed'), + 'severity' => data_get($item, 'severity'), + 'passed' => (bool) data_get($item, 'passed', data_get($item, 'status') !== 'failed'), + 'comments' => data_get($item, 'comments'), + 'photos' => data_get($item, 'photos'), + 'meta' => data_get($item, 'meta'), + ]; + + $lookup = [ + 'inspection_submission_uuid' => $submission->uuid, + ]; + + if ($uuid) { + $lookup['uuid'] = $uuid; + } elseif ($payload['item_key']) { + $lookup['item_key'] = $payload['item_key']; + } else { + $lookup['label'] = $payload['label']; + } + + $result = InspectionItemResult::updateOrCreate($lookup, $payload); + $seen[] = $result->uuid; + } + + if (!empty($seen)) { + $submission->itemResults()->whereNotIn('uuid', $seen)->delete(); + } + + $submission->syncResultCounts(); + } +} diff --git a/server/src/Http/Controllers/Public/PublicInspectionController.php b/server/src/Http/Controllers/Public/PublicInspectionController.php new file mode 100644 index 000000000..d740dd8ed --- /dev/null +++ b/server/src/Http/Controllers/Public/PublicInspectionController.php @@ -0,0 +1,243 @@ + 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'image/gif' => 'gif', + 'image/heic' => 'heic', + 'image/heif' => 'heif', + ]; + + public function show(Request $request, string $id): JsonResponse + { + [$form, $link] = $this->resolvePublishedFormAndLink($request, $id); + + $link->markViewed(); + + return response()->json([ + 'form' => (new InspectionFormResource($form))->resolve(), + 'identity' => $this->identityPayload($link), + ]); + } + + public function submit(Request $request, string $id): JsonResponse + { + [$form, $link] = $this->resolvePublishedFormAndLink($request, $id); + + $validated = $request->validate(InspectionSubmitter::rules()); + + // A link submits through its form's fields. The older flat checklist + // stores photo URLs exactly as given, which on a public link would let + // anyone put an arbitrary outside image on the record. + if (!empty($validated['item_results']) && empty($validated['custom_field_values'])) { + abort(response()->json(['error' => 'Submit this inspection through its form fields.'], 422)); + } + + // A single-use link is claimed before anything is written, inside the + // same transaction as the submission: a second submit at the same + // moment finds it already taken and writes nothing, and a submission + // that fails to save gives the link back. + $submission = DB::connection($link->getConnectionName())->transaction(function () use ($form, $link, $validated, $request) { + if ($link->single_use && !$link->claim($request->ip(), (string) $request->userAgent())) { + abort(response()->json(['error' => 'This inspection link has already been used.'], 409)); + } + + $submission = InspectionSubmitter::submit($form, $validated, [ + 'vehicle_uuid' => $link->vehicle_uuid, + 'driver_uuid' => $link->driver_uuid, + // The person the link is for: its assignee, or the driver's + // account. What they typed as their name is kept beside it, + // with whether a PIN stood between the link and the form. + 'submitted_by_uuid' => InspectionLinkPin::recipientFor($link)?->uuid, + 'source' => 'public_link', + 'meta' => [ + 'inspection_link_uuid' => $link->uuid, + 'inspection_link_id' => $link->public_id, + 'completed_by_name' => data_get($validated, 'signature.name'), + 'pin_verified' => $link->hasPin(), + ], + ]); + + if (!$link->single_use) { + $link->markUsed($request->ip(), (string) $request->userAgent()); + } + + return $submission; + }); + + return response()->json([ + 'message' => 'Inspection submitted.', + 'submission' => (new InspectionSubmissionResource($submission->fresh(['form', 'vehicle', 'driver', 'itemResults', 'issue', 'workOrder'])))->resolve(), + ]); + } + + /** + * Upload a photo or a signature through the link, before submitting. + * + * The console uploads through the platform's file endpoint, which needs a + * session; a link has none, so this is the link's own. It checks the link + * exactly as submitting does, takes images only, caps how many one link + * may send, and tags each file with the link it came through — a + * submission through a link may only reference files uploaded through + * that same link. + */ + public function upload(Request $request, string $id): JsonResponse + { + [, $link] = $this->resolvePublishedFormAndLink($request, $id); + + $request->validate([ + 'file' => 'required|file|mimetypes:image/jpeg,image/png,image/webp,image/gif,image/heic,image/heif|max:' . static::MAX_UPLOAD_KB, + 'type' => 'nullable|in:' . InspectionFileStore::TYPE_PHOTO . ',' . InspectionFileStore::TYPE_SIGNATURE, + ]); + + $already = File::query() + ->where('company_uuid', $link->company_uuid) + ->where('meta->inspection_link_uuid', $link->uuid) + ->count(); + + if ($already >= static::MAX_UPLOADS_PER_LINK) { + abort(response()->json(['error' => 'This inspection link has reached its upload limit.'], 422)); + } + + $upload = $request->file('file'); + $disk = config('filesystems.default'); + $bucket = config('filesystems.disks.' . $disk . '.bucket', config('filesystems.disks.s3.bucket')); + // The stored name's extension comes from the type the server found in + // the bytes, never from the name the device sent. Validation checks the + // content, so an image that is also valid PHP, uploaded as `photo.php`, + // would pass it — and stored under that extension on a web-served local + // disk, it would be an invitation to run it. + $extension = static::EXTENSIONS[$upload->getMimeType()] ?? 'jpg'; + $stored = $upload->storeAs('inspections/links/' . $link->uuid, File::randomFileNameFromRequest($request, 'file', $extension), ['disk' => $disk]); + + if ($stored === false) { + abort(response()->json(['error' => 'This photo could not be stored.'], 500)); + } + + // Built here rather than through File::createFromUpload(), which takes + // the company and uploader from the session — and a link has none. + // The type comes from the bytes the server received, not from the + // name the device gave the file. + $file = File::create([ + 'company_uuid' => $link->company_uuid, + 'uploader_uuid' => InspectionLinkPin::recipientFor($link)?->uuid, + 'original_filename' => $upload->getClientOriginalName(), + 'content_type' => $upload->getMimeType(), + 'disk' => $disk, + 'path' => $stored, + 'bucket' => $bucket, + 'type' => $request->input('type', InspectionFileStore::TYPE_PHOTO), + 'file_size' => $upload->getSize(), + 'meta' => ['inspection_link_uuid' => $link->uuid], + ]); + + return response()->json([ + 'file' => [ + 'id' => $file->public_id, + 'url' => $file->url, + 'filename' => $file->original_filename, + 'content_type' => $file->content_type, + ], + ]); + } + + protected function resolvePublishedFormAndLink(Request $request, string $id): array + { + $token = (string) $request->query('token', $request->input('token')); + if (empty($token)) { + abort(response()->json(['error' => 'Inspection token is required.'], 403)); + } + + $form = InspectionForm::where('uuid', $id) + ->orWhere('public_id', $id) + ->firstOrFail(); + + if (!$form->is_published) { + abort(response()->json(['error' => 'This inspection form is not available.'], 403)); + } + + $link = InspectionLink::where('inspection_form_uuid', $form->uuid) + ->where('token_hash', InspectionLink::hashToken($token)) + ->first(); + + if (!$link) { + abort(response()->json(['error' => 'Inspection link is invalid.'], 403)); + } + + if ($link->status === 'locked') { + abort(response()->json(['error' => static::LOCKED_MESSAGE, 'locked' => true], 403)); + } + + if (!$link->isUsable()) { + abort(response()->json(['error' => 'Inspection link is expired, revoked, or already used.'], 403)); + } + + // A link with a PIN answers nothing, not even the form's name, until + // the PIN is given. Wrong guesses count against the link and lock it. + // Every refusal here is a 403, so the page handles them all one way. + // The page sends it as a header, so it stays out of URLs and the + // access logs that record them; a `pin` field is accepted as well. + switch ($link->verifyPin($request->header('X-Inspection-Pin') ?: $request->input('pin'))) { + case 'missing': + abort(response()->json(['error' => 'Enter the PIN you were given with this link.', 'pin_required' => true], 403)); + // no break + case 'wrong': + abort(response()->json(['error' => 'That PIN is not right.', 'pin_required' => true, 'attempts_left' => $link->pinAttemptsLeft()], 403)); + // no break + case 'locked': + abort(response()->json(['error' => static::LOCKED_MESSAGE, 'locked' => true], 403)); + } + + return [$form, $link]; + } + + protected function identityPayload(InspectionLink $link): array + { + return [ + 'assignee' => $link->assignee ? [ + 'id' => $link->assignee->public_id, + 'name' => $link->assignee->name, + ] : null, + // Name, not phone number: whoever holds the link needs to know who + // it is for, not how to reach them. + 'driver' => $link->driver ? [ + 'id' => $link->driver->public_id, + 'name' => $link->driver->name, + ] : null, + 'vehicle' => $link->vehicle ? [ + 'id' => $link->vehicle->public_id, + 'name' => $link->vehicle->display_name ?? $link->vehicle->name, + 'plate_number' => $link->vehicle->plate_number, + ] : null, + 'expires_at' => $link->expires_at, + ]; + } +} diff --git a/server/src/Http/Middleware/ForceJsonResponse.php b/server/src/Http/Middleware/ForceJsonResponse.php new file mode 100644 index 000000000..c6612d2f6 --- /dev/null +++ b/server/src/Http/Middleware/ForceJsonResponse.php @@ -0,0 +1,25 @@ +headers->set('Accept', 'application/json'); + + return $next($request); + } +} diff --git a/server/src/Http/Resources/v1/InspectionForm.php b/server/src/Http/Resources/v1/InspectionForm.php new file mode 100644 index 000000000..db7ca58f1 --- /dev/null +++ b/server/src/Http/Resources/v1/InspectionForm.php @@ -0,0 +1,149 @@ +withCustomFields([ + 'id' => $this->when($internal, $this->id, $this->public_id), + 'uuid' => $this->when($internal, $this->uuid), + 'public_id' => $this->when($internal, $this->public_id), + 'company_uuid' => $this->when($internal, $this->company_uuid), + 'created_by_uuid'=> $this->when($internal, $this->created_by_uuid), + 'updated_by_uuid'=> $this->when($internal, $this->updated_by_uuid), + 'subject_uuid' => $this->when($internal, $this->subject_uuid), + 'subject_type' => $this->when($internal, $this->subject_type ? Utils::toEmberResourceType($this->subject_type) : null), + 'subject' => $this->whenLoaded('subject', fn () => $this->setSubjectType($this->transformMorphResource($this->subject))), + 'name' => $this->name, + 'description' => $this->description, + 'type' => $this->type, + 'status' => $this->status, + 'items' => data_get($this, 'items', []), + 'grouped_fields' => array_map(fn (Category $group) => static::groupToArray($group, $internal), $this->grouped_fields), + 'field_groups' => $this->whenLoaded('fieldGroups', fn () => $this->fieldGroups->map(fn (Category $group) => static::groupToArray($group, $internal, false))->values()->all()), + 'fields' => $this->whenLoaded('fields', fn () => $this->fields->map(fn (CustomField $field) => static::fieldToArray($field, $internal))->values()->all()), + 'settings' => data_get($this, 'settings', (object) []), + 'meta' => data_get($this, 'meta', (object) []), + 'subject_name' => $this->subject_name, + 'item_count' => $this->item_count, + 'is_published' => $this->is_published, + 'published_at' => $this->published_at, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, + ]); + } + + /** + * A field group as the API answers it. The console gets the identifiers it + * addresses the category by; the driver gets what it renders. + */ + public static function groupToArray(Category $group, bool $internal, bool $withFields = true): array + { + $data = [ + 'id' => $internal ? $group->uuid : ($group->public_id ?? $group->uuid), + 'uuid' => $group->uuid, + 'name' => $group->name, + 'description' => $group->description, + 'order' => $group->order === null ? null : (int) $group->order, + 'meta' => is_array($group->meta) && !empty($group->meta) ? $group->meta : (object) [], + ]; + + if ($internal) { + $data['public_id'] = $group->public_id; + $data['company_uuid'] = $group->company_uuid; + $data['owner_uuid'] = $group->owner_uuid; + $data['owner_type'] = $group->owner_type; + $data['for'] = $group->for; + } + + if ($withFields) { + $fields = $group->relationLoaded('fields') ? $group->getRelation('fields') : collect(); + $data['fields'] = collect($fields)->map(fn (CustomField $field) => static::fieldToArray($field, $internal))->values()->all(); + } + + return $data; + } + + /** + * A field as the API answers it. A custom field has no public id, so the + * uuid is the id on both sides; it is what a submit names the field by. + */ + public static function fieldToArray(CustomField $field, bool $internal): array + { + $data = [ + 'id' => $field->uuid, + 'uuid' => $field->uuid, + 'name' => $field->name, + 'label' => $field->label, + 'description' => $field->description, + 'help_text' => $field->help_text, + 'type' => $field->type, + // A field written straight into the table — by a seed, or by an + // older builder — may carry no component. The type names its own. + 'component' => $field->component ?: InspectionFormSync::componentFor((string) $field->type), + 'required' => (bool) $field->required, + 'editable' => $field->editable === null ? true : (bool) $field->editable, + 'options' => is_array($field->options) ? array_values($field->options) : [], + 'order' => $field->order === null ? null : (int) $field->order, + 'meta' => is_array($field->meta) && !empty($field->meta) ? $field->meta : (object) [], + ]; + + if ($internal) { + $data['company_uuid'] = $field->company_uuid; + $data['category_uuid'] = $field->category_uuid; + $data['subject_uuid'] = $field->subject_uuid; + $data['subject_type'] = $field->subject_type; + $data['for'] = $field->for; + $data['default_value'] = $field->default_value; + $data['validation_rules'] = $field->validation_rules; + } + + return $data; + } + + protected function setSubjectType(?array $resolved): ?array + { + if (empty($resolved)) { + return $resolved; + } + + $bareSlug = Str::kebab(class_basename($this->subject_type ?? '')); + + data_set($resolved, 'type', 'maintenance-subject-' . $bareSlug); + data_set($resolved, 'subject_type', 'maintenance-subject-' . $bareSlug); + + return $resolved; + } + + protected function transformMorphResource($model): ?array + { + if (!$model) { + return null; + } + + // Always answers: a model with no resource of its own is served by the + // base FleetbaseResource, so there is no second fallback to keep here. + $resourceClass = \Fleetbase\Support\Find::httpResourceForModel($model); + + return (new $resourceClass($model))->resolve(); + } +} diff --git a/server/src/Http/Resources/v1/InspectionItemResult.php b/server/src/Http/Resources/v1/InspectionItemResult.php new file mode 100644 index 000000000..7c4235a3e --- /dev/null +++ b/server/src/Http/Resources/v1/InspectionItemResult.php @@ -0,0 +1,41 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->uuid), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'company_uuid' => $this->when(Http::isInternalRequest(), $this->company_uuid), + 'inspection_submission_uuid' => $this->when(Http::isInternalRequest(), $this->inspection_submission_uuid), + 'issue_uuid' => $this->when(Http::isInternalRequest(), $this->issue_uuid), + 'work_order_uuid' => $this->when(Http::isInternalRequest(), $this->work_order_uuid), + 'item_key' => $this->item_key, + 'label' => $this->label, + 'category' => $this->category, + 'status' => $this->status, + 'severity' => $this->severity, + 'passed' => $this->passed, + 'comments' => $this->comments, + 'photos' => data_get($this, 'photos', []), + 'meta' => data_get($this, 'meta', Utils::createObject()), + 'submission_id' => $this->submission_id, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/server/src/Http/Resources/v1/InspectionLink.php b/server/src/Http/Resources/v1/InspectionLink.php new file mode 100644 index 000000000..7e8cbcc1a --- /dev/null +++ b/server/src/Http/Resources/v1/InspectionLink.php @@ -0,0 +1,84 @@ +id` is the table's auto-increment + // column, which no lookup here resolves — a list built from it + // could show links but never revoke one. + 'id' => $this->public_id, + 'uuid' => $this->when($internal, $this->uuid), + 'public_id' => $this->when($internal, $this->public_id), + 'path' => $this->path, + 'state' => $this->state, + 'status' => $this->status, + 'single_use' => (bool) $this->single_use, + 'has_pin' => $this->hasPin(), + // Shown in the console, like the link, so a dispatcher can read it + // out or send it again. Never on a public request. + 'pin' => $this->when($internal, fn () => $this->pin), + 'pin_sent_via' => $this->pin_sent_via, + 'pin_sent_at' => $this->pin_sent_at, + 'pin_attempts' => $this->when($internal, fn () => (int) $this->pin_attempts), + 'recipient' => $this->when($internal, function () { + $recipient = InspectionLinkPin::recipientFor($this->resource); + + return $recipient ? ['name' => $recipient->name] : null; + }), + 'can_send_pin' => $this->when($internal, function () { + $recipient = InspectionLinkPin::recipientFor($this->resource); + + return [ + 'email' => InspectionLinkPin::unavailableReason($recipient, 'email') === null, + 'sms' => InspectionLinkPin::unavailableReason($recipient, 'sms') === null, + ]; + }), + 'assignee' => $this->assignee ? [ + 'id' => $this->assignee->public_id, + 'name' => $this->assignee->name, + ] : null, + 'driver' => $this->driver ? [ + 'id' => $this->driver->public_id, + 'name' => $this->driver->name, + ] : null, + 'vehicle' => $this->vehicle ? [ + 'id' => $this->vehicle->public_id, + 'name' => $this->vehicle->display_name ?? $this->vehicle->name, + ] : null, + 'created_by' => $this->createdBy ? [ + 'id' => $this->createdBy->public_id, + 'name' => $this->createdBy->name, + ] : null, + 'expires_at' => $this->expires_at, + 'last_viewed_at' => $this->last_viewed_at, + 'used_at' => $this->used_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/server/src/Http/Resources/v1/InspectionSubmission.php b/server/src/Http/Resources/v1/InspectionSubmission.php new file mode 100644 index 000000000..f7fefadf9 --- /dev/null +++ b/server/src/Http/Resources/v1/InspectionSubmission.php @@ -0,0 +1,160 @@ +withCustomFields([ + 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), + 'company_uuid' => $this->when(Http::isInternalRequest(), $this->company_uuid), + 'inspection_form_uuid' => $this->when(Http::isInternalRequest(), $this->inspection_form_uuid), + 'vehicle_uuid' => $this->when(Http::isInternalRequest(), $this->vehicle_uuid), + 'driver_uuid' => $this->when(Http::isInternalRequest(), $this->driver_uuid), + 'submitted_by_uuid' => $this->when(Http::isInternalRequest(), $this->submitted_by_uuid), + 'issue_uuid' => $this->when(Http::isInternalRequest(), $this->issue_uuid), + 'work_order_uuid' => $this->when(Http::isInternalRequest(), $this->work_order_uuid), + 'form' => $this->whenLoaded('form', fn () => new InspectionForm($this->form)), + 'vehicle' => $this->whenLoaded('vehicle', fn () => new Vehicle($this->vehicle)), + 'driver' => $this->whenLoaded('driver', fn () => new Driver($this->driver)), + 'submitted_by' => $this->whenLoaded('submittedBy', fn () => new User($this->submittedBy)), + 'issue' => $this->whenLoaded('issue', fn () => new Issue($this->issue)), + 'work_order' => $this->whenLoaded('workOrder', fn () => new WorkOrder($this->workOrder)), + 'item_results' => InspectionItemResult::collection($this->whenLoaded('itemResults')), + 'type' => $this->type, + 'status' => $this->status, + 'result' => $this->result, + 'source' => $this->source, + 'odometer' => $this->odometer, + 'engine_hours' => $this->engine_hours, + 'total_items' => $this->total_items, + 'failed_items' => $this->failed_items, + 'location' => data_get($this, 'location', (object) []), + 'signature' => data_get($this, 'signature', (object) []), + 'attachments' => data_get($this, 'attachments', []), + 'meta' => data_get($this, 'meta', Utils::createObject()), + 'form_name' => $this->form_name, + 'vehicle_name' => $this->vehicle_name, + 'driver_name' => $this->driver_name, + 'has_failures' => $this->has_failures, + 'started_at' => $this->started_at, + 'submitted_at' => $this->submitted_at, + 'resolved_at' => $this->resolved_at, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, + ]); + + // The platform's own `withCustomFields` puts the raw value models + // under `custom_field_values` for the console. What both consoles and + // the app want is the field's identity beside its answer, with file + // references resolved, so that projection replaces it. + $data['custom_field_values'] = $this->projectCustomFieldValues(); + $data['files'] = $this->projectFiles(); + + return $data; + } + + /** + * The answers, as the app and the console read them: which field, what it + * is called, its type, and the value with every `file:` resolved to + * something fetchable. + */ + protected function projectCustomFieldValues(): array + { + // `withCustomFields()` has already loaded the values and the fields + // they answer, so there is nothing to guard against here. + $internal = Http::isInternalRequest(); + + return collect($this->resource?->customFieldValues)->map(function (CustomFieldValue $value) use ($internal) { + $field = $value->customField; + $row = [ + 'custom_field' => $value->custom_field_uuid, + 'name' => $field?->name, + 'label' => $field?->label ?? $value->custom_field_label, + 'type' => $field?->type ?? $value->value_type, + 'value_type' => $value->value_type, + 'value' => static::projectValue($value), + ]; + + if ($internal) { + $row['uuid'] = $value->uuid; + $row['category_uuid'] = $field?->category_uuid; + $row['order'] = $field?->order === null ? null : (int) $field->order; + $row['meta'] = is_array($field?->meta) && !empty($field->meta) ? $field->meta : (object) []; + } + + return $row; + })->values()->all(); + } + + /** + * One stored value, with file references resolved. A pass-fail answer + * carries its photos inside it, so those are resolved too. + */ + protected static function projectValue(CustomFieldValue $value): mixed + { + $raw = $value->getRawOriginal('value'); + + if (in_array($value->value_type, ['object', 'array'], true)) { + $decoded = is_string($raw) ? json_decode($raw, true) : $raw; + if (!is_array($decoded)) { + return $decoded; + } + + if (isset($decoded['photos']) && is_array($decoded['photos'])) { + $decoded['photos'] = array_values(array_map(fn ($photo) => InspectionFileStore::project($photo), $decoded['photos'])); + } + + return $decoded; + } + + // A value column is a string, so a meter reading comes back as one. + // The app compares and charts these; hand it the number it wrote. + if ($value->value_type === 'number') { + return is_numeric($raw) ? $raw + 0 : $raw; + } + + if ($value->value_type === 'boolean') { + return filter_var($raw, FILTER_VALIDATE_BOOLEAN); + } + + return InspectionFileStore::project($raw); + } + + /** The photos and signatures filed with the inspection. */ + protected function projectFiles(): array + { + if (!$this->resource || !$this->resource->relationLoaded('files')) { + return []; + } + + return $this->resource->files->map(fn (File $file) => [ + 'id' => $file->public_id ?? $file->uuid, + 'uuid' => $file->uuid, + 'url' => $file->url, + 'original_filename' => $file->original_filename, + 'content_type' => $file->content_type, + 'type' => $file->type, + 'caption' => $file->caption, + 'created_at' => $file->created_at, + ])->values()->all(); + } +} diff --git a/server/src/Mail/InspectionLinkPinMail.php b/server/src/Mail/InspectionLinkPinMail.php new file mode 100644 index 000000000..7f98029ba --- /dev/null +++ b/server/src/Mail/InspectionLinkPinMail.php @@ -0,0 +1,57 @@ +link = $link; + $this->pin = $pin; + $this->recipient = $recipient; + $this->url = $url; + } + + /** The PIN is kept out of the subject, which a locked phone shows. */ + public function envelope(): Envelope + { + return new Envelope(subject: 'Complete the ' . ($this->link->form?->name ?? 'inspection') . ' inspection'); + } + + public function content(): Content + { + return new Content( + markdown: 'fleetops::mail.inspection-link-pin', + with: [ + 'pin' => $this->pin, + 'url' => $this->url, + 'recipient' => $this->recipient, + 'form' => $this->link->form, + 'vehicle' => $this->link->vehicle, + 'sender' => $this->link->createdBy, + 'expiresAt' => $this->link->expires_at, + 'maxAttempts' => InspectionLink::MAX_PIN_ATTEMPTS, + ] + ); + } +} diff --git a/server/src/Models/InspectionForm.php b/server/src/Models/InspectionForm.php new file mode 100644 index 000000000..da5eed410 --- /dev/null +++ b/server/src/Models/InspectionForm.php @@ -0,0 +1,240 @@ + Json::class, + 'settings' => Json::class, + 'meta' => Json::class, + 'published_at' => 'datetime', + 'subject_type' => PolymorphicType::class, + ]; + + protected $appends = ['subject_name', 'item_count', 'is_published']; + protected $with = ['subject']; + + protected static $logName = 'inspection_form'; + protected static $logAttributes = '*'; + protected static $submitEmptyLogs = false; + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults()->logAll()->logOnlyDirty(); + } + + public function subject(): MorphTo + { + return $this->morphTo(__FUNCTION__, 'subject_type', 'subject_uuid'); + } + + public function submissions(): HasMany + { + return $this->hasMany(InspectionSubmission::class, 'inspection_form_uuid', 'uuid'); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_uuid', 'uuid'); + } + + public function updatedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by_uuid', 'uuid'); + } + + /** + * The groups the form's fields are laid out in: platform categories owned + * by the form, in the order the builder put them. + */ + public function fieldGroups(): HasMany + { + return $this->hasMany(Category::class, 'owner_uuid', 'uuid') + ->where('for', static::GROUP_FOR) + ->orderByRaw('COALESCE(`order`, 999999), created_at'); + } + + /** + * The fields a driver fills in: platform custom fields whose subject is + * the form, filed under the inspection kind so a field attached to the + * form record by the console's generic custom-field panel is not one. + */ + public function fields(): HasMany + { + return $this->hasMany(CustomField::class, 'subject_uuid', 'uuid') + ->where('for', static::FIELD_FOR) + ->orderByRaw('COALESCE(`order`, 999999), created_at'); + } + + /** + * The form as the driver sees it: every group with its fields inside, + * both sorted by `order` then creation, with the fields that belong to + * no group gathered into an "Ungrouped" tail. + * + * @return Category[] + */ + public function getGroupedFieldsAttribute(): array + { + $this->loadMissing(['fieldGroups', 'fields']); + + $fieldsByGroup = $this->fields->groupBy(fn (CustomField $field) => $field->category_uuid ?: '_ungrouped'); + + $grouped = static::sortByOrder($this->fieldGroups)->map(function (Category $group) use ($fieldsByGroup) { + $group->setRelation('fields', static::sortByOrder($fieldsByGroup->get($group->uuid, collect()))); + + return $group; + }); + + if ($fieldsByGroup->has('_ungrouped')) { + $ungrouped = new Category([ + 'name' => 'Ungrouped', + 'for' => static::GROUP_FOR, + 'owner_uuid' => $this->uuid, + ]); + $ungrouped->exists = false; + $ungrouped->setRelation('fields', static::sortByOrder($fieldsByGroup->get('_ungrouped'))); + $grouped->push($ungrouped); + } + + return $grouped->values()->all(); + } + + /** + * Sorts groups or fields the way the builder laid them out: an explicit + * `order` first (lowest first), then anything without one in creation order. + */ + public static function sortByOrder(Collection $items): Collection + { + return $items->sort(function ($a, $b) { + $aOrder = $a->order === null ? null : (int) $a->order; + $bOrder = $b->order === null ? null : (int) $b->order; + + if ($aOrder !== null && $bOrder !== null && $aOrder !== $bOrder) { + return $aOrder <=> $bOrder; + } + + if ($aOrder !== null && $bOrder === null) { + return -1; + } + + if ($aOrder === null && $bOrder !== null) { + return 1; + } + + return (string) ($a->created_at ?? '') <=> (string) ($b->created_at ?? ''); + })->values(); + } + + public function getSubjectNameAttribute(): ?string + { + return $this->subject?->name ?? $this->subject?->display_name ?? $this->subject?->public_id; + } + + /** + * How many things a driver answers. Fields once the form has been built + * with them; the legacy checklist while it still is one. + */ + public function getItemCountAttribute(): int + { + $fields = $this->relationLoaded('fields') ? $this->fields->count() : $this->fields()->count(); + if ($fields > 0) { + return $fields; + } + + return count($this->items ?? []); + } + + public function getIsPublishedAttribute(): bool + { + return $this->status === 'published' && $this->published_at !== null; + } + + public function publish(): bool + { + return $this->update([ + 'status' => 'published', + 'published_at' => $this->published_at ?? now(), + ]); + } + + public function archive(): bool + { + return $this->update(['status' => 'archived']); + } +} diff --git a/server/src/Models/InspectionItemResult.php b/server/src/Models/InspectionItemResult.php new file mode 100644 index 000000000..0700c5b9f --- /dev/null +++ b/server/src/Models/InspectionItemResult.php @@ -0,0 +1,90 @@ + 'boolean', + 'photos' => Json::class, + 'meta' => Json::class, + ]; + + protected $appends = ['submission_id']; + protected $with = []; + + protected static $logName = 'inspection_item_result'; + protected static $logAttributes = '*'; + protected static $submitEmptyLogs = false; + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults()->logAll()->logOnlyDirty(); + } + + public function submission(): BelongsTo + { + return $this->belongsTo(InspectionSubmission::class, 'inspection_submission_uuid', 'uuid'); + } + + public function issue(): BelongsTo + { + return $this->belongsTo(Issue::class, 'issue_uuid', 'uuid'); + } + + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class, 'work_order_uuid', 'uuid'); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_uuid', 'uuid'); + } + + public function getSubmissionIdAttribute(): ?string + { + return $this->submission?->public_id; + } +} diff --git a/server/src/Models/InspectionLink.php b/server/src/Models/InspectionLink.php new file mode 100644 index 000000000..7b9978e2b --- /dev/null +++ b/server/src/Models/InspectionLink.php @@ -0,0 +1,284 @@ + 'encrypted', + // Kept so the console can show the PIN again, like the link; the hash + // beside it is what a guess is checked against. + 'pin' => 'encrypted', + 'pin_attempts' => 'integer', + 'pin_sent_at' => 'datetime', + 'single_use' => 'boolean', + 'expires_at' => 'datetime', + 'last_viewed_at' => 'datetime', + 'used_at' => 'datetime', + 'meta' => Json::class, + ]; + + protected $with = ['form', 'driver', 'vehicle', 'assignee']; + + public static function generateToken(): string + { + return Str::random(64); + } + + public static function hashToken(string $token): string + { + return hash('sha256', $token); + } + + public function form(): BelongsTo + { + return $this->belongsTo(InspectionForm::class, 'inspection_form_uuid', 'uuid'); + } + + public function driver(): BelongsTo + { + return $this->belongsTo(Driver::class, 'driver_uuid', 'uuid'); + } + + public function vehicle(): BelongsTo + { + return $this->belongsTo(Vehicle::class, 'vehicle_uuid', 'uuid'); + } + + /** Whoever in the organisation the link is meant for, if anyone. */ + public function assignee(): BelongsTo + { + return $this->belongsTo(User::class, 'assignee_uuid', 'uuid'); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_uuid', 'uuid'); + } + + /** A new PIN: six digits from a cryptographically secure source. */ + public static function generatePin(): string + { + return str_pad((string) random_int(0, (10 ** static::PIN_LENGTH) - 1), static::PIN_LENGTH, '0', STR_PAD_LEFT); + } + + /** Give the link a PIN, and forget any wrong guesses at the last one. */ + public function setPin(string $pin): void + { + $this->pin_hash = password_hash($pin, PASSWORD_BCRYPT); + $this->pin = $pin; + $this->pin_attempts = 0; + } + + /** Links minted before PINs existed have none, and ask for none. */ + public function hasPin(): bool + { + return !empty($this->pin_hash); + } + + public function pinAttemptsLeft(): int + { + return max(0, static::MAX_PIN_ATTEMPTS - (int) $this->pin_attempts); + } + + /** + * Check a PIN given for this link: `ok`, `missing`, `wrong` or `locked`. + * + * A wrong guess is counted with an atomic increment, so guesses made at + * the same moment cannot all read the same count, and the link locks when + * the count reaches MAX_PIN_ATTEMPTS. A right one clears the count. + */ + public function verifyPin(?string $pin): string + { + if (!$this->hasPin()) { + return 'ok'; + } + + if ($this->status === 'locked') { + return 'locked'; + } + + $pin = preg_replace('/\D/', '', (string) $pin); + + if ($pin === '') { + return 'missing'; + } + + if (password_verify($pin, $this->pin_hash)) { + if ($this->pin_attempts) { + $this->forceFill(['pin_attempts' => 0])->save(); + } + + return 'ok'; + } + + static::query()->whereKey($this->getKey())->increment('pin_attempts'); + $this->refresh(); + + if ($this->pin_attempts >= static::MAX_PIN_ATTEMPTS) { + static::query()->whereKey($this->getKey())->update(['status' => 'locked']); + $this->refresh(); + + return 'locked'; + } + + return 'wrong'; + } + + /** + * Why a link cannot be used, or `active` when it can. + * + * `status` records only what a person did to it — a revoked link is + * `revoked` — so expiry and single-use exhaustion have to be read off the + * timestamps. A list of links is unreadable without this: an expired link + * and a live one both say `active` in the column. + */ + public function getStateAttribute(): string + { + if ($this->status !== 'active') { + return $this->status; + } + + if ($this->expires_at && $this->expires_at->isPast()) { + return 'expired'; + } + + if ($this->single_use && $this->used_at) { + return 'used'; + } + + return 'active'; + } + + /** The path this link opens, or null for a link minted before tokens were kept. */ + public function getPathAttribute(): ?string + { + $token = $this->token; + + if (empty($token)) { + return null; + } + + $form = $this->form; + + return '/~/inspection?id=' . urlencode($form->public_id ?? $form->uuid) . '&token=' . urlencode($token); + } + + public function isUsable(): bool + { + if ($this->status !== 'active') { + return false; + } + + if ($this->expires_at && $this->expires_at->isPast()) { + return false; + } + + return !($this->single_use && $this->used_at); + } + + public function markViewed(): void + { + $this->forceFill(['last_viewed_at' => now()])->save(); + } + + /** + * Take a single-use link for one submission, atomically. + * + * Checking `isUsable()` and then marking the link used once the submission + * was saved left a window in which two submits at the same moment could + * both pass the check. A claim is one conditional update instead: the + * database lets exactly one of them set `used_at`, and the other finds + * nothing left to update. + */ + public function claim(?string $ip = null, ?string $userAgent = null): bool + { + $claimed = static::query() + ->whereKey($this->getKey()) + ->where('status', 'active') + ->whereNull('used_at') + ->where(function ($query) { + $query->whereNull('expires_at')->orWhere('expires_at', '>', now()); + }) + ->update(['used_at' => now(), 'used_ip' => $ip, 'used_user_agent' => $userAgent]); + + if ($claimed === 1) { + $this->refresh(); + } + + return $claimed === 1; + } + + /** Take a link out of use without deleting the record of it. */ + public function revoke(): void + { + $this->forceFill(['status' => 'revoked'])->save(); + } + + public function markUsed(?string $ip = null, ?string $userAgent = null): void + { + $this->forceFill([ + 'used_at' => now(), + 'used_ip' => $ip, + 'used_user_agent' => $userAgent, + ])->save(); + } +} diff --git a/server/src/Models/InspectionSubmission.php b/server/src/Models/InspectionSubmission.php new file mode 100644 index 000000000..d81721d8a --- /dev/null +++ b/server/src/Models/InspectionSubmission.php @@ -0,0 +1,440 @@ + 'datetime', + 'submitted_at' => 'datetime', + 'resolved_at' => 'datetime', + 'location' => Json::class, + 'signature' => Json::class, + 'attachments' => Json::class, + 'meta' => Json::class, + 'odometer' => 'integer', + 'engine_hours' => 'integer', + 'total_items' => 'integer', + 'failed_items' => 'integer', + ]; + + /** + * The column defaults, restored when a client sends null for them. + * + * An explicit null in an insert overrides the column's default rather than + * falling back to it, and these columns are NOT NULL. The console's model + * serialises every attribute, so it sent `total_items: null` and the insert + * was refused before the counts could be worked out from the answers. + */ + public const COLUMN_DEFAULTS = [ + 'type' => 'dvir', + 'status' => 'draft', + 'total_items' => 0, + 'failed_items' => 0, + ]; + + protected static function booted(): void + { + static::saving(function (InspectionSubmission $submission) { + foreach (static::COLUMN_DEFAULTS as $column => $default) { + if ($submission->getAttribute($column) === null) { + $submission->setAttribute($column, $default); + } + } + }); + } + + protected $appends = ['form_name', 'vehicle_name', 'driver_name', 'has_failures']; + protected $with = ['form', 'vehicle', 'driver']; + + protected static $logName = 'inspection_submission'; + protected static $logAttributes = '*'; + protected static $submitEmptyLogs = false; + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults()->logAll()->logOnlyDirty(); + } + + public function form(): BelongsTo + { + return $this->belongsTo(InspectionForm::class, 'inspection_form_uuid', 'uuid'); + } + + public function vehicle(): BelongsTo + { + return $this->belongsTo(Vehicle::class, 'vehicle_uuid', 'uuid'); + } + + public function driver(): BelongsTo + { + return $this->belongsTo(Driver::class, 'driver_uuid', 'uuid'); + } + + public function submittedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'submitted_by_uuid', 'uuid'); + } + + public function issue(): BelongsTo + { + return $this->belongsTo(Issue::class, 'issue_uuid', 'uuid'); + } + + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class, 'work_order_uuid', 'uuid'); + } + + public function itemResults(): HasMany + { + return $this->hasMany(InspectionItemResult::class, 'inspection_submission_uuid', 'uuid'); + } + + public function failedItemResults(): HasMany + { + return $this->itemResults()->where('passed', false); + } + + /** + * The photos and signatures filed with the inspection: platform files + * whose subject is the submission, however they arrived — inside the + * driver's submit body, or dropped on the console's Photos panel. + */ + public function files(): HasMany + { + return $this->hasMany(File::class, 'subject_uuid', 'uuid')->orderBy('created_at'); + } + + /** + * Every `pass-fail` answer, as the row the rest of the platform reads. + * + * Issues, work orders and the history are all built from + * `inspection_item_results`; a form built from fields answers through + * custom-field values instead, so each pass-fail value is mirrored into + * a result row keyed the way the app keys it — the field's name, or its + * uuid when it has none. Rows for pass-fail fields the submission no + * longer answers are dropped; rows written any other way (a legacy + * checklist, the console's results editor) are left alone. + * + * @return int the number of result rows derived + */ + public function syncItemResultsFromCustomFieldValues(): int + { + $this->unsetRelation('customFieldValues'); + $this->load('customFieldValues.customField'); + + $answered = $this->customFieldValues->filter(fn (CustomFieldValue $value) => $value->customField?->type === 'pass-fail'); + $groups = Category::query()->whereIn('uuid', $answered->map(fn (CustomFieldValue $value) => $value->customField->category_uuid)->filter()->unique()->values())->pluck('name', 'uuid'); + $unsafe = false; + $kept = []; + + foreach ($answered as $value) { + /** @var CustomField $field */ + $field = $value->customField; + $answer = is_array($value->value) ? $value->value : []; + $notApplicable = filter_var($answer['not_applicable'] ?? false, FILTER_VALIDATE_BOOLEAN); + $passed = $notApplicable || filter_var($answer['passed'] ?? true, FILTER_VALIDATE_BOOLEAN); + $failed = !$passed; + $isUnsafe = $failed && filter_var($answer['unsafe'] ?? data_get($field->meta, 'unsafe_on_fail', false), FILTER_VALIDATE_BOOLEAN); + $unsafe = $unsafe || $isUnsafe; + $itemKey = static::itemKeyFor($field); + + InspectionItemResult::updateOrCreate([ + 'inspection_submission_uuid' => $this->uuid, + 'item_key' => $itemKey, + ], [ + 'company_uuid' => $this->company_uuid, + 'label' => $field->label ?? $field->name, + 'category' => $groups->get($field->category_uuid) ?? data_get($field->meta, 'category'), + 'status' => $notApplicable ? 'not_applicable' : ($passed ? 'passed' : 'failed'), + 'severity' => $failed ? ($answer['severity'] ?? data_get($field->meta, 'severity')) : null, + 'passed' => $passed, + 'comments' => $answer['comments'] ?? null, + 'photos' => array_values(array_filter((array) ($answer['photos'] ?? []), 'is_string')), + 'meta' => [ + 'custom_field_uuid' => $field->uuid, + 'not_applicable' => $notApplicable, + 'unsafe' => $isUnsafe, + ], + ]); + + $kept[] = $itemKey; + } + + $stale = CustomField::query() + ->where('subject_uuid', $this->inspection_form_uuid) + ->where('for', InspectionForm::FIELD_FOR) + ->where('type', 'pass-fail') + ->get() + ->map(fn (CustomField $field) => static::itemKeyFor($field)) + ->diff($kept) + ->values(); + if ($stale->isNotEmpty()) { + $this->itemResults()->whereIn('item_key', $stale)->delete(); + } + + $meta = $this->meta ?? []; + if (($meta['unsafe'] ?? null) !== $unsafe) { + $this->update(['meta' => array_merge($meta, ['unsafe' => $unsafe])]); + } + + InspectionFileStore::attachReferenced($this, $this->referencedFileUuids()); + $this->unsetRelation('itemResults'); + + return count($kept); + } + + /** + * The key a pass-fail field's result row is filed under. The app derives + * the same key (`field.name ?? field.id`), so a result the app built and + * one the server derived name the same item. + */ + public static function itemKeyFor(CustomField $field): string + { + return $field->name ?: $field->uuid; + } + + /** + * Every `file:` the submission's values point at: file and + * signature values, and the photos inside a failed pass-fail answer. + * + * @return string[] + */ + public function referencedFileUuids(): array + { + $uuids = []; + foreach ($this->customFieldValues as $value) { + $raw = $value->getRawOriginal('value'); + $candidates = is_array($value->value) ? (array) ($value->value['photos'] ?? []) : [$raw]; + foreach ($candidates as $candidate) { + $uuid = InspectionFileStore::referencedUuid($candidate); + if ($uuid) { + $uuids[] = $uuid; + } + } + } + + return array_values(array_unique($uuids)); + } + + public function getFormNameAttribute(): ?string + { + return $this->form?->name; + } + + public function getVehicleNameAttribute(): ?string + { + return $this->vehicle?->display_name ?? $this->vehicle?->name; + } + + public function getDriverNameAttribute(): ?string + { + return $this->driver?->name; + } + + public function getHasFailuresAttribute(): bool + { + return (int) $this->failed_items > 0 || $this->result === 'failed'; + } + + public function syncResultCounts(): bool + { + $total = $this->itemResults()->count(); + $failed = $this->failedItemResults()->count(); + + return $this->update([ + 'total_items' => $total, + 'failed_items' => $failed, + 'result' => $failed > 0 ? 'failed' : 'passed', + 'status' => $this->status === 'draft' ? 'submitted' : $this->status, + 'submitted_at' => $this->submitted_at ?? now(), + ]); + } + + public function createIssueFromFailures(): ?Issue + { + if (!$this->has_failures || $this->issue_uuid) { + return $this->issue; + } + + $failedLabels = $this->failedItemResults()->limit(6)->pluck('label')->filter()->values()->all(); + $issue = Issue::create([ + 'company_uuid' => $this->company_uuid, + 'reported_by_uuid' => $this->submitted_by_uuid, + 'vehicle_uuid' => $this->vehicle_uuid, + 'driver_uuid' => $this->driver_uuid, + 'type' => 'inspection', + 'category' => 'inspection_failed', + 'location' => $this->failureLocation(), + 'title' => 'Failed inspection: ' . ($this->vehicle_name ?? $this->public_id), + 'report' => empty($failedLabels) ? 'Inspection failed.' : 'Failed items: ' . implode(', ', $failedLabels), + 'priority' => $this->highestFailureSeverity(), + 'status' => 'pending', + 'meta' => [ + 'inspection_submission_uuid' => $this->uuid, + 'inspection_submission_id' => $this->public_id, + 'inspection_form_uuid' => $this->inspection_form_uuid, + 'failed_items' => $failedLabels, + ], + ]); + + $this->update(['issue_uuid' => $issue->uuid]); + + return $issue; + } + + /** + * Where the failure was reported, for the issue it raises. + * + * `issues.location` is a spatial column with no default, so an insert that + * leaves it out is refused outright — MySQL 1364, which reached a driver + * filing a failed inspection as a 500. The submission's own coordinates + * come first, then the vehicle's last known position, then the driver's, + * and an empty point when nothing is known. + */ + public function failureLocation(): Point + { + // Read here rather than through Utils::getPointFromMixed(), which + // throws when it cannot resolve a point: every source below is + // routinely empty, and an inspection must not fail for want of one. + foreach ([$this->location, $this->vehicle?->location, $this->driver?->location] as $candidate) { + if ($candidate instanceof Point) { + return $candidate; + } + + $latitude = data_get($candidate, 'latitude', data_get($candidate, 'lat')); + $longitude = data_get($candidate, 'longitude', data_get($candidate, 'lng')); + + if (is_numeric($latitude) && is_numeric($longitude)) { + return new Point((float) $latitude, (float) $longitude); + } + } + + return new Point(0, 0); + } + + public function createWorkOrderFromFailures(): ?WorkOrder + { + if (!$this->has_failures || $this->work_order_uuid) { + return $this->workOrder; + } + + $failedItems = $this->failedItemResults()->get(); + $checklist = $failedItems->map(fn (InspectionItemResult $item) => [ + 'title' => $item->label, + 'required' => true, + 'completed' => false, + 'source' => 'inspection', + 'item_key' => $item->item_key, + 'severity' => $item->severity, + 'created_at' => now(), + ])->values()->all(); + + $workOrder = WorkOrder::create([ + 'company_uuid' => $this->company_uuid, + 'subject' => 'Inspection repair: ' . ($this->vehicle_name ?? $this->public_id), + 'status' => 'open', + 'priority' => $this->highestFailureSeverity(), + 'target_type' => $this->vehicle_uuid ? Vehicle::class : null, + 'target_uuid' => $this->vehicle_uuid, + 'opened_at' => now(), + 'due_at' => now()->addDays($this->highestFailureSeverity() === 'critical' ? 1 : 7), + 'instructions' => 'Resolve failed inspection items and record completion details.', + 'checklist' => $checklist, + 'currency' => $this->vehicle?->currency, + 'created_by_uuid' => $this->submitted_by_uuid, + 'meta' => [ + 'source' => 'inspection', + 'inspection_submission_uuid' => $this->uuid, + 'inspection_submission_id' => $this->public_id, + 'issue_uuid' => $this->issue_uuid, + ], + ]); + + $this->update(['work_order_uuid' => $workOrder->uuid]); + $this->failedItemResults()->update(['work_order_uuid' => $workOrder->uuid]); + + return $workOrder; + } + + public function highestFailureSeverity(): string + { + $severity = $this->failedItemResults() + ->pluck('severity') + ->map(fn ($value) => Str::slug((string) $value)) + ->filter() + ->all(); + + foreach (['critical', 'high', 'medium', 'low'] as $candidate) { + if (in_array($candidate, $severity, true)) { + return $candidate; + } + } + + return $this->has_failures ? 'high' : 'low'; + } +} diff --git a/server/src/Models/WorkOrder.php b/server/src/Models/WorkOrder.php index 2cfd2cdcf..f6688816a 100644 --- a/server/src/Models/WorkOrder.php +++ b/server/src/Models/WorkOrder.php @@ -75,6 +75,7 @@ class WorkOrder extends Model */ protected $fillable = [ 'company_uuid', + 'schedule_uuid', 'code', 'subject', 'category', @@ -97,6 +98,8 @@ class WorkOrder extends Model 'cost_center', 'budget_code', 'meta', + 'created_by_uuid', + 'updated_by_uuid', ]; /** diff --git a/server/src/Observers/WorkOrderObserver.php b/server/src/Observers/WorkOrderObserver.php index 17dc6e125..7a715d150 100644 --- a/server/src/Observers/WorkOrderObserver.php +++ b/server/src/Observers/WorkOrderObserver.php @@ -61,7 +61,7 @@ protected function createMaintenanceRecord(WorkOrder $workOrder): void 'maintainable_type' => $workOrder->target_type, 'maintainable_uuid' => $workOrder->target_uuid, 'type' => 'scheduled', - 'status' => 'done', + 'status' => 'completed', 'priority' => $workOrder->priority, 'scheduled_at' => $workOrder->opened_at, 'completed_at' => $workOrder->closed_at ?? now(), diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index d150a1132..613c74578 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -218,6 +218,11 @@ public function registerMorphMap(): void '\\Fleetbase\\Models\\Vehicle' => \Fleetbase\FleetOps\Models\Vehicle::class, 'fleet-ops:vehicle' => \Fleetbase\FleetOps\Models\Vehicle::class, 'fleet-ops:trailer' => \Fleetbase\FleetOps\Models\Trailer::class, + // A photo or signature filed with an inspection is a platform file + // whose subject is the submission. The console names that subject + // the way Ember names it, so the alias has to resolve here. + 'fleet-ops:inspection-submission' => \Fleetbase\FleetOps\Models\InspectionSubmission::class, + 'fleet-ops:inspection-form' => \Fleetbase\FleetOps\Models\InspectionForm::class, ]); } diff --git a/server/src/Rules/Base64OrUrl.php b/server/src/Rules/Base64OrUrl.php new file mode 100644 index 000000000..2516b0432 --- /dev/null +++ b/server/src/Rules/Base64OrUrl.php @@ -0,0 +1,54 @@ +`, which + * is what every value leaves here as — a URL, or a reference to a file that + * already exists, is kept as it came. + * + * A reference is only ever kept for a file the submission may use: one that + * belongs to the submission's own company. A submission through a public + * link may go further than that only in one direction — it may reference + * nothing but files uploaded through that same link, and it may not use an + * outside URL as a photo at all. + */ +class InspectionFileStore +{ + public const TYPE_PHOTO = 'inspection_photo'; + public const TYPE_SIGNATURE = 'inspection_signature'; + + /** + * Normalizes one file value. Returns the value unchanged when it is not a + * string, is empty, or is something other than base64 or a file reference. + */ + public static function normalize(mixed $value, InspectionSubmission $submission, string $type = self::TYPE_PHOTO, ?string $uploaderUuid = null): mixed + { + if (!is_string($value) || trim($value) === '') { + return $value; + } + + $value = trim($value); + + $linkUuid = static::linkUuidOf($submission); + + // A reference to a file that already exists — `file:`, a bare + // uuid, or the public id an upload answers with — is kept only for a + // file the submission may use. + if (Str::startsWith($value, 'file:') || Str::isUuid($value) || Str::startsWith($value, 'file_')) { + return static::ownedReference($value, $submission, $linkUuid); + } + + if (static::isUrl($value)) { + if ($linkUuid) { + throw ValidationException::withMessages(['photos' => 'A photo on an inspection link must be uploaded through the link.']); + } + + return $value; + } + + if (static::isBase64($value)) { + $file = static::store($value, $submission, $type, $uploaderUuid); + + return $file ? 'file:' . $file->uuid : $value; + } + + return $value; + } + + /** + * Stores a base64 payload — bare or as a data URI — as a file that belongs + * to the submission, under `inspections//`. + */ + public static function store(string $base64, InspectionSubmission $submission, string $type = self::TYPE_PHOTO, ?string $uploaderUuid = null): ?File + { + $contentType = null; + $data = $base64; + + if (preg_match('/^data:(?[^;]+);base64,(?.+)$/s', $base64, $matches)) { + $contentType = $matches['content_type']; + $data = $matches['data']; + } + + $data = preg_replace('/\s+/', '', $data); + $contentType = $contentType ?? static::sniffContentType($data); + $extension = static::extensionFor($contentType); + $fileName = Str::lower(Str::random(20)) . '.' . $extension; + + $file = File::createFromBase64($data, $fileName, 'inspections/' . $submission->uuid, $type, $contentType); + if (!$file instanceof File) { + return null; + } + + $file->company_uuid = $submission->company_uuid; + $file->uploader_uuid = $uploaderUuid ?? $submission->submitted_by_uuid ?? $file->uploader_uuid; + $file->setSubject($submission, $type); + + return $file; + } + + /** + * Files referenced by `file:` values that were uploaded before the + * submission existed — the console uploads a photo as soon as it is picked + * — are claimed by the submission so its Photos panel lists them. + * + * @param string[] $uuids + */ + public static function attachReferenced(InspectionSubmission $submission, array $uuids): int + { + $uuids = array_values(array_unique(array_filter($uuids, 'is_string'))); + if (empty($uuids)) { + return 0; + } + + // Only the submission's own company's files, and — for a submission + // through a public link — only files uploaded through that link. This + // claim was unscoped: any unattached file anywhere whose uuid appeared + // in an answer was taken. + $query = File::query() + ->whereIn('uuid', $uuids) + ->where('company_uuid', $submission->company_uuid) + ->whereNull('subject_uuid'); + + if ($linkUuid = static::linkUuidOf($submission)) { + $query->where('meta->inspection_link_uuid', $linkUuid); + } + + return $query->update(['subject_uuid' => $submission->uuid, 'subject_type' => $submission->getMorphClass()]); + } + + /** + * The inspection link a submission came through, or null when it came + * through the console or the driver app. + */ + protected static function linkUuidOf(InspectionSubmission $submission): ?string + { + if ($submission->source !== 'public_link') { + return null; + } + + $uuid = data_get($submission->meta, 'inspection_link_uuid'); + + return is_string($uuid) && $uuid !== '' ? $uuid : null; + } + + /** + * A reference to an existing file, as `file:`, kept only when the + * file is the submission's to use. + * + * The lookup used to be unscoped, so a submission that named another + * company's file — by uuid or public id — kept the reference, the file was + * attached to it, and the submission resource then handed out that file's + * URL. It is now scoped to the submission's company; a submission through + * a public link must also have uploaded the file through that link, and is + * refused outright rather than silently losing a photo it named. + */ + protected static function ownedReference(string $value, InspectionSubmission $submission, ?string $linkUuid): ?string + { + $reference = Str::startsWith($value, 'file:') ? substr($value, 5) : $value; + + $file = File::query() + ->where('company_uuid', $submission->company_uuid) + ->where(Str::isUuid($reference) ? 'uuid' : 'public_id', $reference) + ->first(); + + if ($file && (!$linkUuid || data_get($file->meta, 'inspection_link_uuid') === $linkUuid)) { + return 'file:' . $file->uuid; + } + + if ($linkUuid) { + throw ValidationException::withMessages(['photos' => 'A photo on an inspection link must be uploaded through the link.']); + } + + return null; + } + + /** The uuid a `file:` value points at, or null for anything else. */ + public static function referencedUuid(mixed $value): ?string + { + if (!is_string($value) || !Str::startsWith($value, 'file:')) { + return null; + } + + $uuid = substr($value, 5); + + return Str::isUuid($uuid) ? $uuid : null; + } + + /** Resolves a `file:` value to its file, or null. */ + public static function resolve(mixed $value): ?File + { + $uuid = static::referencedUuid($value); + + return $uuid ? File::query()->where('uuid', $uuid)->first() : null; + } + + /** + * What the driver API answers for a file value: the file's id and where to + * fetch it, or the value as it was stored when it is a URL. + */ + public static function project(mixed $value): mixed + { + $file = static::resolve($value); + if (!$file) { + return $value; + } + + return [ + 'id' => $file->public_id, + 'url' => $file->url, + 'filename' => $file->original_filename, + 'content_type' => $file->content_type, + ]; + } + + public static function isUrl(string $value): bool + { + return (bool) preg_match('#^https?://#i', $value); + } + + /** Bare base64 of at least a few bytes: long enough not to be a word, and only base64 characters. */ + public static function isBase64(string $value): bool + { + if (Str::startsWith($value, 'data:')) { + return (bool) preg_match('/^data:[^;]+;base64,[A-Za-z0-9+\/=\s]+$/s', $value); + } + + $stripped = preg_replace('/\s+/', '', $value); + + return strlen($stripped) >= 16 + && strlen($stripped) % 4 === 0 + && (bool) preg_match('/^[A-Za-z0-9+\/]+={0,2}$/', $stripped); + } + + /** The image type from the decoded bytes' signature; PNG when it is not a known image. */ + public static function sniffContentType(string $base64): string + { + $bytes = (string) base64_decode(substr($base64, 0, 32), true); + + if (str_starts_with($bytes, "\xFF\xD8\xFF")) { + return 'image/jpeg'; + } + + if (str_starts_with($bytes, 'GIF8')) { + return 'image/gif'; + } + + if (str_starts_with($bytes, 'RIFF') && substr($bytes, 8, 4) === 'WEBP') { + return 'image/webp'; + } + + if (str_starts_with($bytes, '%PDF')) { + return 'application/pdf'; + } + + return 'image/png'; + } + + public static function extensionFor(string $contentType): string + { + return match (strtolower($contentType)) { + 'image/jpeg', 'image/jpg' => 'jpg', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + 'application/pdf' => 'pdf', + 'image/svg+xml' => 'svg', + default => 'png', + }; + } +} diff --git a/server/src/Support/InspectionFormSync.php b/server/src/Support/InspectionFormSync.php new file mode 100644 index 000000000..905bc4edd --- /dev/null +++ b/server/src/Support/InspectionFormSync.php @@ -0,0 +1,231 @@ +where(['owner_uuid' => $form->uuid, 'for' => InspectionForm::GROUP_FOR]) + ->get() + ->keyBy('uuid'); + // Only the fields a driver answers. A custom field the console's + // generic panel added to the form *record* has the same subject and + // would otherwise be pruned as a field the builder no longer lists. + $existingFields = CustomField::query() + ->where(['subject_uuid' => $form->uuid, 'for' => InspectionForm::FIELD_FOR]) + ->get() + ->keyBy('uuid'); + + $keptGroups = []; + $keptFields = []; + $now = now(); + + foreach (array_values($draft) as $groupIndex => $groupData) { + $groupUuid = Arr::get($groupData, $primaryKey); + $group = $groupUuid ? $existingGroups->get($groupUuid) : null; + $groupUuid = $group?->uuid ?? ($groupUuid ?: (string) Str::uuid()); + $groupAttrs = static::normalizeRow(Arr::only($groupData, static::GROUP_COLUMNS), $groupIndex); + + if ($group) { + DB::table('categories')->where('uuid', $groupUuid)->update(array_merge($groupAttrs, ['updated_at' => $now])); + } else { + DB::table('categories')->insert(array_merge($groupAttrs, [ + 'uuid' => $groupUuid, + 'public_id' => 'category_' . Str::lower(Str::random(14)), + 'company_uuid' => $form->company_uuid, + 'owner_uuid' => $form->uuid, + 'owner_type' => $form->getMorphClass(), + 'for' => InspectionForm::GROUP_FOR, + 'slug' => Str::slug((string) ($groupAttrs['name'] ?? 'group')), + 'created_at' => $now, + 'updated_at' => $now, + ])); + } + + $keptGroups[] = $groupUuid; + + $fields = Arr::get($groupData, 'fields', Arr::get($groupData, 'customFields', [])); + foreach (array_values(is_array($fields) ? $fields : []) as $fieldIndex => $fieldData) { + $fieldUuid = Arr::get($fieldData, $primaryKey); + $field = $fieldUuid ? $existingFields->get($fieldUuid) : null; + $fieldUuid = $field?->uuid ?? ($fieldUuid ?: (string) Str::uuid()); + $fieldAttrs = static::normalizeField(Arr::only($fieldData, static::FIELD_COLUMNS), $fieldIndex); + + if ($field) { + DB::table('custom_fields')->where('uuid', $fieldUuid)->update(array_merge($fieldAttrs, ['category_uuid' => $groupUuid, 'updated_at' => $now])); + } else { + DB::table('custom_fields')->insert(array_merge($fieldAttrs, [ + 'uuid' => $fieldUuid, + 'company_uuid' => $form->company_uuid, + 'category_uuid' => $groupUuid, + 'subject_uuid' => $form->uuid, + 'subject_type' => $form->getMorphClass(), + 'created_at' => $now, + 'updated_at' => $now, + ])); + } + + $keptFields[] = $fieldUuid; + } + } + + if ($pruneMissing) { + static::prune($form, $existingGroups, $keptGroups, $existingFields, $keptFields); + } + + $form->unsetRelation('fieldGroups'); + $form->unsetRelation('fields'); + $form->load(['fieldGroups', 'fields']); + + return ['groups' => $form->fieldGroups->all(), 'fields' => $form->fields->all()]; + } + + /** + * Converts the first cut's `items` checklist into a "Checklist" group of + * pass-fail fields. Skips forms that have already been built with fields, + * and forms with nothing to convert, so it can be run any number of times. + * + * @return int the number of fields written + */ + public static function convertLegacyItems(InspectionForm $form): int + { + $items = array_values(array_filter(is_array($form->items) ? $form->items : [], 'is_array')); + if (empty($items) || $form->fields()->count() > 0) { + return 0; + } + + $fields = []; + foreach ($items as $index => $item) { + $label = trim((string) ($item['label'] ?? $item['title'] ?? '')); + $label = $label === '' ? 'Item ' . ($index + 1) : $label; + $fields[] = [ + 'label' => $label, + 'name' => Str::slug((string) ($item['key'] ?? $label)), + 'type' => 'pass-fail', + 'required' => (bool) ($item['required'] ?? true), + 'description' => $item['description'] ?? null, + 'order' => $index + 1, + 'meta' => [ + 'severity' => $item['severity'] ?? 'medium', + 'category' => $item['category'] ?? null, + 'require_photo_on_fail' => false, + 'require_comment_on_fail' => false, + 'unsafe_on_fail' => ($item['severity'] ?? null) === 'critical', + ], + ]; + } + + $synced = static::sync($form, [[ + 'name' => 'Checklist', + 'order' => 1, + 'meta' => ['grid_size' => 1, 'converted_from_items' => true], + 'fields' => $fields, + ]]); + + return count($synced['fields']); + } + + /** Group attributes as the categories table stores them. */ + protected static function normalizeRow(array $attributes, int $index): array + { + if (!array_key_exists('order', $attributes) || $attributes['order'] === null) { + $attributes['order'] = $index + 1; + } + + foreach ($attributes as $key => $value) { + if (is_array($value) || is_object($value)) { + $attributes[$key] = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + } + + return $attributes; + } + + /** Field attributes as the custom_fields table stores them. */ + protected static function normalizeField(array $attributes, int $index): array + { + $label = trim((string) ($attributes['label'] ?? '')); + if ($label === '') { + $label = 'Untitled field'; + } + + $attributes['label'] = $label; + $attributes['name'] = Str::slug((string) (($attributes['name'] ?? '') ?: $label)); + $attributes['type'] = in_array($attributes['type'] ?? null, InspectionForm::FIELD_TYPES, true) ? $attributes['type'] : 'input'; + $attributes['for'] = InspectionForm::FIELD_FOR; + + if (empty($attributes['component'])) { + $attributes['component'] = static::componentFor($attributes['type']); + } + + foreach (['required', 'editable'] as $flag) { + $attributes[$flag] = (int) filter_var($attributes[$flag] ?? ($flag === 'editable'), FILTER_VALIDATE_BOOLEAN); + } + + return static::normalizeRow($attributes, $index); + } + + /** + * The console component that edits a field type. The platform's own + * types keep the platform's components; the inspection-only types are + * rendered by FleetOps and named after themselves. + */ + public static function componentFor(string $type): string + { + return $type === 'radio-button' ? 'radio-button-select' : $type; + } + + protected static function prune(InspectionForm $form, Collection $existingGroups, array $keptGroups, Collection $existingFields, array $keptFields): void + { + $groupsToDelete = $existingGroups->keys()->diff($keptGroups)->values(); + if ($groupsToDelete->isNotEmpty()) { + CustomField::query() + ->where(['subject_uuid' => $form->uuid, 'for' => InspectionForm::FIELD_FOR]) + ->whereIn('category_uuid', $groupsToDelete) + ->delete(); + Category::query()->whereIn('uuid', $groupsToDelete)->delete(); + } + + $fieldsToDelete = $existingFields->keys()->diff($keptFields)->values(); + if ($fieldsToDelete->isNotEmpty()) { + CustomField::query()->whereIn('uuid', $fieldsToDelete)->delete(); + } + } +} diff --git a/server/src/Support/InspectionLinkPin.php b/server/src/Support/InspectionLinkPin.php new file mode 100644 index 000000000..2a8aeca63 --- /dev/null +++ b/server/src/Support/InspectionLinkPin.php @@ -0,0 +1,150 @@ +assignee ?? $link->driver?->user; + } + + /** Why a PIN cannot go to this person this way, or null when it can. */ + public static function unavailableReason(?User $recipient, string $via): ?string + { + if (!$recipient) { + return 'Assign the link to someone, or pick a driver, to send the PIN to.'; + } + + if ($via === 'sms' && !filled($recipient->phone)) { + return ($recipient->name ?: 'This person') . ' has no phone number to text the PIN to.'; + } + + if ($via === 'email' && !filled($recipient->email)) { + return ($recipient->name ?: 'This person') . ' has no email address to send the PIN to.'; + } + + return null; + } + + /** + * Send the link's PIN. Never throws: a failed delivery is reported in the + * result, so minting a link does not fail because a mailer is misconfigured. + * + * @return array{sent: bool, via: string, to: ?string, error: ?string} + */ + public static function send(InspectionLink $link, string $via): array + { + $recipient = static::recipientFor($link); + $reason = static::unavailableReason($recipient, $via); + + if (!$link->pin) { + $reason = 'This link has no PIN to send.'; + } + + if ($reason) { + return ['sent' => false, 'via' => $via, 'to' => null, 'error' => $reason]; + } + + try { + if ($via === 'sms') { + // Resolved from the container rather than built here, so the + // provider can be swapped out without touching this class. + $result = app(SmsService::class)->send($recipient->phone, static::smsText($link, $link->pin, static::urlFor($link)), static::smsOptions($link)); + + if (is_array($result) && array_key_exists('success', $result) && !$result['success']) { + return ['sent' => false, 'via' => $via, 'to' => null, 'error' => 'The PIN could not be texted: ' . ($result['error'] ?? $result['message'] ?? 'the SMS provider refused it.')]; + } + + $to = static::maskPhone($recipient->phone); + } else { + Mail::to($recipient)->send(new InspectionLinkPinMail($link, $link->pin, $recipient, static::urlFor($link))); + $to = static::maskEmail($recipient->email); + } + } catch (\Throwable $e) { + report($e); + + return ['sent' => false, 'via' => $via, 'to' => null, 'error' => 'The PIN could not be sent: ' . $e->getMessage()]; + } + + $link->forceFill(['pin_sent_via' => $via, 'pin_sent_at' => now()])->save(); + + return ['sent' => true, 'via' => $via, 'to' => $to, 'error' => null]; + } + + /** + * The link as the recipient opens it, on the console's own host. Null for a + * link minted before tokens were kept, whose URL cannot be rebuilt. + */ + public static function urlFor(InspectionLink $link): ?string + { + $path = $link->path; + + return $path ? Utils::consoleUrl($path) : null; + } + + /** Naming the organisation so it is recognised, and short enough to read at a glance. */ + public static function smsText(InspectionLink $link, string $pin, ?string $url = null): string + { + $company = Company::select(['uuid', 'name'])->find($link->company_uuid)?->name ?? config('app.name'); + $form = $link->form?->name ?? 'inspection'; + + if ($url) { + return "{$company}: complete the {$form} inspection at {$url} using PIN {$pin}. Do not share this PIN."; + } + + return "{$company}: your PIN for the {$form} inspection is {$pin}. Do not share this PIN."; + } + + /** + * The organisation's alphanumeric sender ID, when it has one, the same way + * the platform's own verification texts use it: as a Twilio-only option, + * so other providers are not handed a sender they would reject. + */ + protected static function smsOptions(InspectionLink $link): array + { + $company = Company::select(['uuid', 'options'])->find($link->company_uuid); + + if (!$company) { + return []; + } + + $enabled = Utils::castBoolean($company->getOption('alpha_numeric_sender_id_enabled')); + $senderId = $company->getOption('alpha_numeric_sender_id'); + + return $enabled && $senderId ? ['twilioParams' => ['from' => $senderId]] : []; + } + + /** "r•••@fleetbase.io": enough to recognise, not enough to copy. */ + public static function maskEmail(string $email): string + { + [$local, $domain] = array_pad(explode('@', $email, 2), 2, ''); + + return mb_substr($local, 0, 1) . '•••@' . $domain; + } + + /** "•••1969": the last four digits. */ + public static function maskPhone(string $phone): string + { + return '•••' . substr(preg_replace('/\D/', '', $phone), -4); + } +} diff --git a/server/src/Support/InspectionSubmitter.php b/server/src/Support/InspectionSubmitter.php new file mode 100644 index 000000000..489d5ada2 --- /dev/null +++ b/server/src/Support/InspectionSubmitter.php @@ -0,0 +1,286 @@ + 'nullable|integer|min:0', + 'engine_hours' => 'nullable|integer|min:0', + // Every key of an answer needs a rule of its own. validate() returns + // only keys that have one, and with nested rules present an array's + // other keys are dropped: without these, `value` never reached the + // submitter, so every answer arrived empty and a failed check + // carrying its photo was refused for having none. + 'custom_field_values' => 'required_without:item_results|array', + 'custom_field_values.*.custom_field' => 'required_without:custom_field_values.*.custom_field_uuid|string|max:191', + 'custom_field_values.*.custom_field_uuid' => 'nullable|string|max:191', + 'custom_field_values.*.value' => 'nullable', + 'custom_field_values.*.value_type' => 'nullable|string|max:50', + 'item_results' => 'required_without:custom_field_values|array', + 'item_results.*.item_key' => 'nullable|string|max:191', + 'item_results.*.label' => 'required|string|max:255', + 'item_results.*.category' => 'nullable|string|max:191', + 'item_results.*.status' => 'nullable|string|max:50', + 'item_results.*.severity' => 'nullable|string|max:50', + 'item_results.*.passed' => 'required|boolean', + 'item_results.*.comments' => 'nullable|string|max:2000', + 'item_results.*.photos' => 'nullable|array', + 'item_results.*.photos.*' => ['string', new Base64OrUrl()], + 'location' => 'nullable|array', + 'signature' => 'nullable|array', + 'attachments' => 'nullable|array', + ]; + } + + /** + * @param array $validated the request body, already validated against rules() + * @param array $attributes columns the door owns: vehicle_uuid, driver_uuid, + * submitted_by_uuid, source, started_at, meta + */ + public static function submit(InspectionForm $form, array $validated, array $attributes = []): InspectionSubmission + { + $submission = InspectionSubmission::create(array_merge([ + 'company_uuid' => $form->company_uuid, + 'inspection_form_uuid' => $form->uuid, + 'type' => $form->type ?? 'dvir', + 'status' => 'submitted', + 'odometer' => data_get($validated, 'odometer'), + 'engine_hours' => data_get($validated, 'engine_hours'), + 'started_at' => now(), + 'submitted_at' => now(), + 'location' => data_get($validated, 'location'), + 'signature' => data_get($validated, 'signature'), + 'attachments' => data_get($validated, 'attachments'), + ], $attributes)); + + $values = data_get($validated, 'custom_field_values'); + if (is_array($values) && !empty($values)) { + static::applyCustomFieldValues($submission, $values, $submission->submitted_by_uuid); + } else { + foreach ((array) data_get($validated, 'item_results', []) as $item) { + InspectionItemResult::create([ + 'company_uuid' => $form->company_uuid, + 'inspection_submission_uuid' => $submission->uuid, + 'item_key' => data_get($item, 'item_key'), + 'label' => data_get($item, 'label'), + 'category' => data_get($item, 'category'), + 'status' => data_get($item, 'status', data_get($item, 'passed') ? 'passed' : 'failed'), + 'severity' => data_get($item, 'severity'), + 'passed' => (bool) data_get($item, 'passed'), + 'comments' => data_get($item, 'comments'), + 'photos' => data_get($item, 'photos'), + ]); + } + } + + $submission->syncResultCounts(); + + if (data_get($form->settings, 'create_issue_on_failure') && $submission->has_failures) { + $submission->createIssueFromFailures(); + } + + if (data_get($form->settings, 'create_work_order_on_failure') && $submission->has_failures) { + $submission->createWorkOrderFromFailures(); + } + + return $submission; + } + + /** + * Stores a set of answers against the submission's form. + * + * Each row names a field of the form by uuid (or name), carries a value + * and, optionally, a value type. A field that does not belong to the form, + * or a failed pass-fail answer missing the comment or photo the field + * insists on, refuses the whole set with a 422 so nothing half-filled is + * written. Base64 photos and signatures become platform files on the way + * in; when the answers are in, the item results and the counts follow. + * + * @param array $rows [{custom_field|custom_field_uuid, value, value_type}] + * @param string|null $uploaderUuid the user a stored photo or signature is credited to + * + * @throws ValidationException + */ + public static function applyCustomFieldValues(InspectionSubmission $submission, array $rows, ?string $uploaderUuid = null): array + { + $fields = CustomField::query()->where('subject_uuid', $submission->inspection_form_uuid)->where('for', InspectionForm::FIELD_FOR)->get(); + $payload = []; + $errors = []; + + foreach (array_values($rows) as $index => $row) { + $key = Arr::get($row, 'custom_field', Arr::get($row, 'custom_field_uuid')); + $field = static::findField($fields, $key); + if (!$field) { + $errors["custom_field_values.{$index}.custom_field"] = ['The field "' . (is_scalar($key) ? $key : '?') . '" does not belong to this inspection form.']; + continue; + } + + [$value, $valueType, $fieldErrors] = static::normalizeValue($field, Arr::get($row, 'value'), Arr::get($row, 'value_type'), $submission, $uploaderUuid); + foreach ($fieldErrors as $message) { + $errors["custom_field_values.{$index}.value"][] = $message; + } + + $payload[] = [ + 'custom_field_uuid' => $field->uuid, + 'value' => $value, + 'value_type' => $valueType, + ]; + } + + if (!empty($errors)) { + throw ValidationException::withMessages($errors); + } + + // `custom_field_values.value` is a NOT NULL column, so an answer that + // is nothing is not an answer: it clears whatever was there instead. + $summary = $submission->syncCustomFieldValues($payload, ['treat_null_as_delete' => true]); + $submission->syncItemResultsFromCustomFieldValues(); + $submission->syncResultCounts(); + + return $summary; + } + + /** Finds a field of the form by uuid or, failing that, by its name. */ + protected static function findField(Collection $fields, mixed $key): ?CustomField + { + if (!is_string($key) || $key === '') { + return null; + } + + return $fields->first(fn (CustomField $field) => $field->uuid === $key) + ?? $fields->first(fn (CustomField $field) => $field->name === $key); + } + + /** + * The value as it is stored, its type, and anything wrong with it. + * + * @return array{0: mixed, 1: string, 2: string[]} + */ + protected static function normalizeValue(CustomField $field, mixed $value, mixed $valueType, InspectionSubmission $submission, ?string $uploaderUuid): array + { + $errors = []; + + switch ($field->type) { + case 'pass-fail': + $answer = static::passFailAnswer($value); + $failed = !$answer['not_applicable'] && !filter_var($answer['passed'], FILTER_VALIDATE_BOOLEAN); + $meta = is_array($field->meta) ? $field->meta : []; + $photos = array_values(array_filter($answer['photos'], 'is_string')); + + // What the field insists on is checked before anything is + // stored, so a refused answer leaves no orphan photo behind. + if ($failed) { + if (data_get($meta, 'require_comment_on_fail') && trim((string) $answer['comments']) === '') { + $errors[] = 'A comment is required when "' . $field->label . '" fails.'; + } + if (data_get($meta, 'require_photo_on_fail') && empty($photos)) { + $errors[] = 'A photo is required when "' . $field->label . '" fails.'; + } + } + + if (!empty($errors)) { + return [$answer, 'object', $errors]; + } + + $answer['photos'] = array_values(array_map( + fn ($photo) => InspectionFileStore::normalize($photo, $submission, InspectionFileStore::TYPE_PHOTO, $uploaderUuid), + $photos + )); + + if ($failed) { + $answer['severity'] = $answer['severity'] ?? data_get($meta, 'severity'); + $answer['unsafe'] = filter_var($answer['unsafe'] ?? data_get($meta, 'unsafe_on_fail', false), FILTER_VALIDATE_BOOLEAN); + } else { + $answer['severity'] = null; + $answer['unsafe'] = false; + } + + return [$answer, 'object', $errors]; + + case 'file-upload': + case 'signature': + $type = $field->type === 'signature' ? InspectionFileStore::TYPE_SIGNATURE : InspectionFileStore::TYPE_PHOTO; + + return [InspectionFileStore::normalize($value, $submission, $type, $uploaderUuid), 'file', $errors]; + + case 'number': + return [$value === null || $value === '' ? null : (is_numeric($value) ? $value + 0 : $value), 'number', $errors]; + + case 'boolean': + return [filter_var($value, FILTER_VALIDATE_BOOLEAN), 'boolean', $errors]; + + default: + if (is_array($value)) { + return [$value, 'array', $errors]; + } + + return [$value, is_string($valueType) && $valueType !== '' ? $valueType : 'text', $errors]; + } + } + + /** + * A pass-fail answer in one shape, whatever the client sent: an object, + * its JSON, a bare boolean, or the words "pass" / "fail". + * + * "Not applicable" is an answer of its own — the app sends it as + * `passed: true, not_applicable: true`, and it must not read as a pass + * once it is a result row, or a tail lift a rigid does not have would + * count towards the vehicle's clean record. + */ + public static function passFailAnswer(mixed $value): array + { + if (is_string($value) && Str::startsWith(trim($value), '{')) { + $decoded = json_decode($value, true); + $value = is_array($decoded) ? $decoded : $value; + } + + if (!is_array($value)) { + $passed = is_string($value) ? !in_array(Str::lower(trim($value)), ['fail', 'failed', 'false', '0', 'no'], true) : (bool) $value; + $value = ['passed' => $passed]; + } + + $notApplicable = filter_var($value['not_applicable'] ?? $value['na'] ?? false, FILTER_VALIDATE_BOOLEAN) + || (array_key_exists('passed', $value) && $value['passed'] === null); + + return [ + 'passed' => $notApplicable ? true : filter_var($value['passed'] ?? $value['pass'] ?? true, FILTER_VALIDATE_BOOLEAN), + 'not_applicable' => $notApplicable, + 'severity' => isset($value['severity']) ? (Str::slug((string) $value['severity']) ?: null) : null, + 'comments' => isset($value['comments']) ? (string) $value['comments'] : null, + 'photos' => is_array($value['photos'] ?? null) ? $value['photos'] : [], + 'unsafe' => $value['unsafe'] ?? null, + ]; + } +} diff --git a/server/src/Support/Reporting/FleetOpsReportSchema.php b/server/src/Support/Reporting/FleetOpsReportSchema.php index 7def10879..dc83928ff 100644 --- a/server/src/Support/Reporting/FleetOpsReportSchema.php +++ b/server/src/Support/Reporting/FleetOpsReportSchema.php @@ -38,6 +38,11 @@ public function registerReportSchema(ReportSchemaRegistry $registry): void // Register Fuel Reports table $registry->registerTable($this->createFuelReportsTable()); + + // Register Maintenance tables + $registry->registerTable($this->createWorkOrdersTable()); + $registry->registerTable($this->createMaintenancesTable()); + $registry->registerTable($this->createInspectionSubmissionsTable()); } /** @@ -892,4 +897,177 @@ protected function createFuelReportsTable(): Table ]), ]); } + + /** + * Create the Work Orders table definition. + */ + protected function createWorkOrdersTable(): Table + { + return Table::make('work_orders') + ->label('Work Orders') + ->description('Maintenance work orders, assignments, budgets, and lifecycle status') + ->category('Maintenance') + ->extension('fleet-ops') + ->excludeColumns(['uuid', 'deleted_at', 'meta', 'checklist', 'cost_breakdown']) + ->maxRows(100000) + ->columns([ + Column::make('public_id', 'string')->label('Work Order ID')->searchable()->filterable()->sortable(), + Column::make('code', 'string')->label('Code')->searchable()->filterable()->sortable(), + Column::make('subject', 'string')->label('Subject')->searchable()->filterable()->sortable(), + Column::make('status', 'string')->label('Status')->filterable()->sortable()->aggregatable(), + Column::make('priority', 'string')->label('Priority')->filterable()->sortable()->aggregatable(), + Column::make('opened_at', 'datetime')->label('Opened At')->filterable()->sortable()->aggregatable(), + Column::make('due_at', 'datetime')->label('Due At')->filterable()->sortable()->aggregatable(), + Column::make('closed_at', 'datetime')->label('Closed At')->filterable()->sortable()->aggregatable(), + Column::make('estimated_cost', 'integer')->label('Estimated Cost')->aggregatable()->sortable(), + Column::make('approved_budget', 'integer')->label('Approved Budget')->aggregatable()->sortable(), + Column::make('actual_cost', 'integer')->label('Actual Cost')->aggregatable()->sortable(), + Column::make('currency', 'string')->label('Currency')->filterable()->aggregatable(), + Column::make('cost_center', 'string')->label('Cost Center')->filterable()->sortable(), + Column::make('budget_code', 'string')->label('Budget Code')->filterable()->sortable(), + Column::make('created_at', 'datetime')->label('Created At')->filterable()->sortable()->aggregatable(), + ]) + ->computedColumns([ + Column::count('total_work_orders', 'id')->label('Total Work Orders')->description('Count of work orders'), + Column::sum('total_actual_cost', 'actual_cost')->label('Total Actual Cost')->description('Sum of completed work order actual cost'), + Column::avg('average_actual_cost', 'actual_cost')->label('Average Actual Cost')->description('Average actual work order cost'), + ]) + ->relationships([ + Relationship::hasAutoJoin('vehicle_target', 'vehicles') + ->label('Target Vehicle') + ->localKey('target_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('public_id', 'string')->label('Vehicle ID'), + Column::make('plate_number', 'string')->label('Plate Number'), + Column::make('make', 'string')->label('Make'), + Column::make('model', 'string')->label('Model'), + Column::make('odometer', 'integer')->label('Odometer'), + ]), + ]); + } + + /** + * Create the Maintenances table definition. + */ + protected function createMaintenancesTable(): Table + { + return Table::make('maintenances') + ->label('Maintenance History') + ->description('Completed and scheduled maintenance records with labor, parts, tax, and total cost') + ->category('Maintenance') + ->extension('fleet-ops') + ->excludeColumns(['uuid', 'deleted_at', 'meta', 'line_items', 'attachments']) + ->maxRows(100000) + ->columns([ + Column::make('public_id', 'string')->label('Maintenance ID')->searchable()->filterable()->sortable(), + Column::make('type', 'string')->label('Type')->filterable()->sortable()->aggregatable(), + Column::make('status', 'string')->label('Status')->filterable()->sortable()->aggregatable(), + Column::make('priority', 'string')->label('Priority')->filterable()->sortable()->aggregatable(), + Column::make('scheduled_at', 'datetime')->label('Scheduled At')->filterable()->sortable()->aggregatable(), + Column::make('started_at', 'datetime')->label('Started At')->filterable()->sortable()->aggregatable(), + Column::make('completed_at', 'datetime')->label('Completed At')->filterable()->sortable()->aggregatable(), + Column::make('odometer', 'integer')->label('Odometer')->aggregatable()->sortable(), + Column::make('engine_hours', 'integer')->label('Engine Hours')->aggregatable()->sortable(), + Column::make('summary', 'string')->label('Summary')->searchable()->filterable()->sortable(), + Column::make('labor_cost', 'integer')->label('Labor Cost')->aggregatable()->sortable(), + Column::make('parts_cost', 'integer')->label('Parts Cost')->aggregatable()->sortable(), + Column::make('tax', 'integer')->label('Tax')->aggregatable()->sortable(), + Column::make('total_cost', 'integer')->label('Total Cost')->aggregatable()->sortable(), + Column::make('currency', 'string')->label('Currency')->filterable()->aggregatable(), + Column::make('created_at', 'datetime')->label('Created At')->filterable()->sortable()->aggregatable(), + ]) + ->computedColumns([ + Column::count('total_maintenance_records', 'id')->label('Total Maintenance Records')->description('Count of maintenance records'), + Column::sum('total_maintenance_cost', 'total_cost')->label('Total Maintenance Cost')->description('Sum of maintenance total cost'), + Column::avg('average_maintenance_cost', 'total_cost')->label('Average Maintenance Cost')->description('Average maintenance total cost'), + Column::sum('total_parts_cost', 'parts_cost')->label('Total Parts Cost')->description('Sum of parts cost'), + Column::sum('total_labor_cost', 'labor_cost')->label('Total Labor Cost')->description('Sum of labor cost'), + ]) + ->relationships([ + Relationship::hasAutoJoin('work_order', 'work_orders') + ->label('Work Order') + ->localKey('work_order_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('public_id', 'string')->label('Work Order ID'), + Column::make('code', 'string')->label('Work Order Code'), + Column::make('subject', 'string')->label('Work Order Subject'), + ]), + Relationship::hasAutoJoin('vehicle', 'vehicles') + ->label('Vehicle') + ->localKey('maintainable_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('public_id', 'string')->label('Vehicle ID'), + Column::make('plate_number', 'string')->label('Plate Number'), + Column::make('make', 'string')->label('Make'), + Column::make('model', 'string')->label('Model'), + Column::make('acquisition_cost', 'integer')->label('Acquisition Cost'), + ]), + ]); + } + + /** + * Create the Inspection Submissions table definition. + */ + protected function createInspectionSubmissionsTable(): Table + { + return Table::make('inspection_submissions') + ->label('Inspections') + ->description('DVIR and inspection submissions, pass/fail status, and linked maintenance follow-up') + ->category('Maintenance') + ->extension('fleet-ops') + ->excludeColumns(['uuid', 'deleted_at', 'meta', 'location', 'signature', 'attachments']) + ->maxRows(100000) + ->columns([ + Column::make('public_id', 'string')->label('Inspection ID')->searchable()->filterable()->sortable(), + Column::make('type', 'string')->label('Type')->filterable()->sortable()->aggregatable(), + Column::make('status', 'string')->label('Status')->filterable()->sortable()->aggregatable(), + Column::make('result', 'string')->label('Result')->filterable()->sortable()->aggregatable(), + Column::make('source', 'string')->label('Source')->filterable()->sortable()->aggregatable(), + Column::make('odometer', 'integer')->label('Odometer')->aggregatable()->sortable(), + Column::make('engine_hours', 'integer')->label('Engine Hours')->aggregatable()->sortable(), + Column::make('total_items', 'integer')->label('Total Items')->aggregatable()->sortable(), + Column::make('failed_items', 'integer')->label('Failed Items')->aggregatable()->sortable(), + Column::make('started_at', 'datetime')->label('Started At')->filterable()->sortable()->aggregatable(), + Column::make('submitted_at', 'datetime')->label('Submitted At')->filterable()->sortable()->aggregatable(), + Column::make('resolved_at', 'datetime')->label('Resolved At')->filterable()->sortable()->aggregatable(), + Column::make('created_at', 'datetime')->label('Created At')->filterable()->sortable()->aggregatable(), + ]) + ->computedColumns([ + Column::count('total_inspections', 'id')->label('Total Inspections')->description('Count of inspection submissions'), + Column::sum('total_failed_items', 'failed_items')->label('Total Failed Items')->description('Sum of failed inspection items'), + Column::avg('average_failed_items', 'failed_items')->label('Average Failed Items')->description('Average failed items per inspection'), + ]) + ->relationships([ + Relationship::hasAutoJoin('inspection_form', 'inspection_forms') + ->label('Inspection Form') + ->localKey('inspection_form_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('name', 'string')->label('Form Name'), + Column::make('type', 'string')->label('Form Type'), + ]), + Relationship::hasAutoJoin('vehicle', 'vehicles') + ->label('Vehicle') + ->localKey('vehicle_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('public_id', 'string')->label('Vehicle ID'), + Column::make('plate_number', 'string')->label('Plate Number'), + Column::make('make', 'string')->label('Make'), + Column::make('model', 'string')->label('Model'), + ]), + Relationship::hasAutoJoin('work_order', 'work_orders') + ->label('Work Order') + ->localKey('work_order_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('public_id', 'string')->label('Work Order ID'), + Column::make('status', 'string')->label('Work Order Status'), + Column::make('actual_cost', 'integer')->label('Work Order Actual Cost'), + ]), + ]); + } } diff --git a/server/src/routes.php b/server/src/routes.php index ad7f0df65..ddf0efae3 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -14,6 +14,18 @@ */ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\FleetOps\Http\Controllers')->group( function ($router) { + // Tokenised inspection links, for people with no console login. The + // link itself is the credential, so what guards it lives here: a rate + // limit per address on every call, and a tighter one on uploads. Each + // limiter has its own prefix so the two do not share one counter. + // Every answer is JSON, so a refused submission reaches the page with + // its reasons instead of as a redirect back to it. + $router->prefix('public')->namespace('Public')->middleware([Fleetbase\FleetOps\Http\Middleware\ForceJsonResponse::class, 'throttle:60,1,inspection-public'])->group(function ($router) { + $router->get('inspections/forms/{id}', 'PublicInspectionController@show'); + $router->post('inspections/forms/{id}/submit', 'PublicInspectionController@submit'); + $router->post('inspections/forms/{id}/files', 'PublicInspectionController@upload')->middleware('throttle:20,1,inspection-upload'); + }); + /* |-------------------------------------------------------------------------- | Consumable FleetOps API Routes @@ -207,6 +219,20 @@ function ($router) { $router->patch('{id}', 'ManifestController@updateStop'); $router->post('{id}', 'ManifestController@updateStop'); }); + // inspections — a driver's DVIR. Read the published forms and file + // against them; authoring forms and reviewing submissions is fleet + // management and stays on the internal namespace. A refused submit + // answers 422 in JSON: without ForceJsonResponse, a client that did + // not ask for JSON was redirected instead. + $router->group(['prefix' => 'inspection-forms', 'middleware' => [Fleetbase\FleetOps\Http\Middleware\ForceJsonResponse::class]], function () use ($router) { + $router->get('/', 'InspectionController@queryForms'); + $router->get('{id}', 'InspectionController@findForm'); + }); + $router->group(['prefix' => 'inspections', 'middleware' => [Fleetbase\FleetOps\Http\Middleware\ForceJsonResponse::class]], function () use ($router) { + $router->post('/', 'InspectionController@submit'); + $router->get('/', 'InspectionController@query'); + $router->get('{id}', 'InspectionController@find'); + }); // entities routes $router->group(['prefix' => 'entities'], function () use ($router) { @@ -300,6 +326,8 @@ function ($router) { $router->delete('{id}', 'VehicleController@delete'); $router->match(['put', 'patch', 'post'], '{id}/track', 'VehicleController@track'); $router->get('{id}/trailers', 'TrailerController@vehicleTrailers'); + // A vehicle's inspection history, for the driver app's vehicle screen. + $router->get('{id}/inspections', 'InspectionController@forVehicle')->middleware(Fleetbase\FleetOps\Http\Middleware\ForceJsonResponse::class); }); // trailer routes $router->group(['prefix' => 'trailers'], function () use ($router) { @@ -642,6 +670,26 @@ function ($router, $controller) { $router->get('calendar-feed', $controller('calendarFeed')); $router->get('{id}/ical', $controller('ical')); }); + // The console validates these with $request->validate(), which + // redirects a request that did not ask for JSON; they answer + // in JSON so a refusal reaches the console as a 422. + $router->group(['middleware' => [Fleetbase\FleetOps\Http\Middleware\ForceJsonResponse::class]], function ($router) { + $router->fleetbaseRoutes('inspection-forms', function ($router, $controller) { + $router->post('{id}/publish', $controller('publish')); + $router->post('{id}/archive', $controller('archive')); + $router->post('{id}/generate-link', $controller('generateLink')); + $router->get('{id}/links', $controller('links')); + $router->delete('{id}/links/{linkId}', $controller('revokeLink')); + $router->post('{id}/links/{linkId}/send-pin', $controller('sendPin')); + }); + $router->fleetbaseRoutes('inspection-submissions', function ($router, $controller) { + $router->match(['get', 'post'], 'export', $controller('export')); + $router->post('{id}/submit', $controller('submit')); + $router->post('{id}/create-issue', $controller('createIssue')); + $router->post('{id}/create-work-order', $controller('createWorkOrder')); + $router->post('{id}/resolve', $controller('resolve')); + }); + }); $router->fleetbaseRoutes('work-orders', function ($router, $controller) { $router->match(['get', 'post'], 'export', $controller('export')); $router->post('import', $controller('import')); diff --git a/server/tests/ApiManifestControllerContractsTest.php b/server/tests/ApiManifestControllerContractsTest.php index f94da2f79..8f31651ac 100644 --- a/server/tests/ApiManifestControllerContractsTest.php +++ b/server/tests/ApiManifestControllerContractsTest.php @@ -84,8 +84,8 @@ public function json(mixed $payload = null, int $status = 200): FleetOpsTestResp /** A stop that records what was done to it instead of touching a database. */ class FleetOpsManifestStopFake extends ManifestStop { - public array $marks = []; - public array $updates = []; + public array $marks = []; + public array $updates = []; public ?object $placeForTest = null; public function markArrived(): ManifestStop @@ -148,7 +148,7 @@ public function relationLoaded($key): bool /** A manifest whose stops and appended counts are supplied, not queried. */ class FleetOpsManifestFakeRecord extends Manifest { - public $stopsForTest = null; + public $stopsForTest; public function getCompletedStopsAttribute(): int { @@ -263,7 +263,7 @@ public function limit(int $limit): self return $this; } - public function get(): \Illuminate\Support\Collection + public function get(): Illuminate\Support\Collection { return collect($this->results); } @@ -451,7 +451,7 @@ function fleetopsManifestStop(string $publicId, string $status, int $sequence, ? $manifest = new FleetOpsManifestFakeRecord(); $manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a'], true); - $manifest->stopsForTest = collect([$done, $far, $near, $mid]); + $manifest->stopsForTest = collect([$done, $far, $near, $mid]); FleetOpsManifestControllerProbe::$manifest = $manifest; FleetOpsManifestControllerProbe::$distances = [ @@ -482,8 +482,8 @@ function fleetopsManifestStop(string $publicId, string $status, int $sequence, ? $manifest = new FleetOpsManifestFakeRecord(); $manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a'], true); - $manifest->stopsForTest = collect([$a, $b, $c]); - FleetOpsManifestControllerProbe::$manifest = $manifest; + $manifest->stopsForTest = collect([$a, $b, $c]); + FleetOpsManifestControllerProbe::$manifest = $manifest; FleetOpsManifestControllerProbe::$distances = [ '1.3,103.8->1.3,103.8' => 0.0, '1.3,103.8->1.31,103.81' => 1500.0, @@ -506,7 +506,7 @@ function fleetopsManifestStop(string $publicId, string $status, int $sequence, ? $manifest = new FleetOpsManifestFakeRecord(); $manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a'], true); - $manifest->stopsForTest = collect([$a, $b, $c]); + $manifest->stopsForTest = collect([$a, $b, $c]); FleetOpsManifestControllerProbe::$manifest = $manifest; $controller = new FleetOpsManifestControllerProbe(); @@ -585,7 +585,7 @@ public static function callManifestsFor(Driver $driver) $reached = function (callable $lookup): bool { try { $lookup(); - } catch (\Throwable $e) { + } catch (Throwable $e) { return true; } diff --git a/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php b/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php index a1b90fd94..8ea039c47 100644 --- a/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +++ b/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php @@ -184,10 +184,10 @@ class FleetOpsGeofenceControllerFake extends GeofenceController public ?FleetOpsGeofenceQueryFake $nextEventQuery = null; public array $serializedEvents = []; - public ?Fleetbase\FleetOps\Models\Driver $historyDriver = null; + public ?Driver $historyDriver = null; public array $historyDriverLookups = []; - protected function findDriverForHistory(string $driverId): ?Fleetbase\FleetOps\Models\Driver + protected function findDriverForHistory(string $driverId): ?Driver { $this->historyDriverLookups[] = $driverId; @@ -364,7 +364,7 @@ function fleetopsGeofenceEvent(array $attributes): GeofenceEventLog // The endpoint is addressed by public_id and resolves the driver itself; the query // still filters on the driver's uuid. - $historyDriver = new Fleetbase\FleetOps\Models\Driver(); + $historyDriver = new Driver(); $historyDriver->setRawAttributes(['uuid' => 'driver-9', 'public_id' => 'driver_public9'], true); $controller->historyDriver = $historyDriver; @@ -384,7 +384,7 @@ function fleetopsGeofenceEvent(array $attributes): GeofenceEventLog test('driver history answers 404 when the driver does not resolve', function () { // The endpoint's own not-found branch: findDriverForHistory returns null and the // request must not proceed to a query keyed on nothing. - $controller = new FleetOpsGeofenceControllerFake(); + $controller = new FleetOpsGeofenceControllerFake(); $controller->historyDriver = null; $response = $controller->driverHistory(Request::create('/geofences/driver/driver_missing/history', 'GET'), 'driver_missing'); diff --git a/server/tests/Feature/Http/Internal/HubControllerTest.php b/server/tests/Feature/Http/Internal/HubControllerTest.php index 4e8f3f7d4..f87caab86 100644 --- a/server/tests/Feature/Http/Internal/HubControllerTest.php +++ b/server/tests/Feature/Http/Internal/HubControllerTest.php @@ -163,6 +163,9 @@ protected function companyUuid(Request $request): ?string 5, // overdue work orders 6, // open maintenance 7, // high priority maintenance + 1, // published inspection forms + 2, // failed inspections awaiting review + 3, // unresolved failed inspections 8, // low stock parts 9, // equipment ], 'company-maintenance'); @@ -175,32 +178,55 @@ protected function companyUuid(Request $request): ?string 'value' => 2, 'tone' => 'rose', ]) + // Failed inspections take the third tile: a failed DVIR is a truck that + // should not be on the road, which outranks the parts shelf. ->and($payload['kpis'][2])->toMatchArray([ + 'key' => 'failed_inspections', + 'value' => 2, + 'tone' => 'rose', + 'route' => 'maintenance.inspection-submissions', + ]) + ->and($payload['kpis'][3])->toMatchArray([ 'key' => 'open_work_orders', 'value' => 4, 'tone' => 'amber', ]) ->and(array_column($payload['actions'], 'key'))->toBe([ + 'failed_inspections', 'overdue_schedules', 'overdue_work_orders', 'high_priority_maintenance', 'upcoming_service', - 'low_stock_parts', + ]) + ->and($payload['actions'][0])->toMatchArray([ + 'description' => '2 failed inspections need issue or work order follow-up.', + 'tone' => 'warning', + 'route' => 'maintenance.inspection-submissions', ]) ->and(array_column($payload['sections'], 'key'))->toBe([ + 'inspections', 'planning', 'records', ]) ->and($payload['sections'][0]['links'][0])->toMatchArray([ + 'label' => 'Inspection Forms', + 'count' => 1, + ]) + ->and($payload['sections'][0]['links'][1])->toMatchArray([ + 'label' => 'Inspections', + 'count' => 3, + ]) + ->and($payload['sections'][1]['links'][0])->toMatchArray([ 'label' => 'Schedules', 'count' => 5, ]) ->and(array_column($payload['docs'], 'label'))->toBe([ 'Schedules', + 'Inspections', 'Work Orders', 'Equipment', 'Parts', ]) - ->and($controller->countCalls)->toHaveCount(8) + ->and($controller->countCalls)->toHaveCount(11) ->and(array_unique($controller->countCalls))->toBe(['company-maintenance']); }); diff --git a/server/tests/HubAndGettingStartedContractsTest.php b/server/tests/HubAndGettingStartedContractsTest.php index 75c741861..e9d75fb5c 100644 --- a/server/tests/HubAndGettingStartedContractsTest.php +++ b/server/tests/HubAndGettingStartedContractsTest.php @@ -90,15 +90,38 @@ public function callHelper(string $method): array $actions = $controller->callHelper('maintenanceActions', 1, 2, 0, 3, 4, 5, 0); + // No published inspection form is the first thing a new maintenance setup is + // told about: without one, drivers have nothing to file a DVIR against. expect(array_column($actions, 'key'))->toBe([ + 'create_inspection_forms', 'overdue_schedules', 'overdue_work_orders', 'high_priority_maintenance', 'upcoming_service', - 'no_open_work_orders', ]) - ->and($actions[0]['description'])->toContain('1 recurring service schedule is overdue') - ->and($actions[1]['description'])->toContain('3 work orders are past due'); + ->and($actions[0])->toMatchArray(['tone' => 'info', 'route' => 'maintenance.inspection-forms']) + ->and($actions[1]['description'])->toContain('1 recurring service schedule is overdue') + ->and($actions[2]['description'])->toContain('3 work orders are past due'); +}); + +test('hub controller maintenance actions surface failed and unresolved inspections', function () { + $controller = new FleetOpsHubControllerProbe(); + + // A failed inspection awaiting review outranks everything else, and while one + // is waiting the unresolved count is not repeated as a second action. + $failed = $controller->callHelper('maintenanceActions', 0, 0, 1, 0, 0, 0, 1, 1, 4, 2); + expect(array_column($failed, 'key'))->toBe(['failed_inspections']) + ->and($failed[0]['description'])->toBe('1 failed inspection needs issue or work order follow-up.') + ->and((array) $failed[0]['query'])->toBe(['result' => 'failed']); + + // Reviewed but not yet resolved: the follow-up prompt, after the service ones. + $unresolved = $controller->callHelper('maintenanceActions', 0, 0, 1, 0, 0, 0, 1, 0, 2, 1); + expect(array_column($unresolved, 'key'))->toBe(['unresolved_inspections']) + ->and($unresolved[0]['description'])->toBe('2 failed inspections remain unresolved.'); + + // Forms published and nothing failed: no inspection action at all. + $quiet = $controller->callHelper('maintenanceActions', 0, 0, 1, 0, 0, 0, 1, 0, 0, 3); + expect(array_column($quiet, 'key'))->toBe([]); }); test('hub controller small helpers build dashboard payload fragments', function () { diff --git a/server/tests/InspectionControllerContractsTest.php b/server/tests/InspectionControllerContractsTest.php new file mode 100644 index 000000000..771609504 --- /dev/null +++ b/server/tests/InspectionControllerContractsTest.php @@ -0,0 +1,1329 @@ +sqliteCreateFunction('ST_PointFromText', $asStoredPoint); + $pdo->sqliteCreateFunction('ST_GeomFromText', $asStoredPoint); + $connection = new SQLiteConnection($pdo); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + EloquentModel::setEventDispatcher(new Dispatcher()); + EloquentModel::clearBootedModels(); + + $config = new Repository([ + 'activitylog' => ['enabled' => false, 'default_auth_driver' => null, 'default_log_name' => 'default'], + 'api' => ['cache' => ['enabled' => false]], + 'filesystems' => ['default' => 'local'], + ]); + app()->instance('config', $config); + app()->instance(Illuminate\Contracts\Config\Repository::class, $config); + app()->instance(Spatie\Activitylog\CauserResolver::class, new class extends Spatie\Activitylog\CauserResolver { + public function __construct() + { + } + + public function resolve(EloquentModel|int|string|null $subject = null): ?EloquentModel + { + return null; + } + }); + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $connection) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->connection; + } + + public function __call($method, $arguments) + { + return $this->connection->{$method}(...$arguments); + } + }); + Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + app()->instance('db.schema', $connection->getSchemaBuilder()); + // The link's token and PIN are stored with the `encrypted` cast, which + // resolves the container's encrypter. A reversible stand-in is enough to + // show a value goes in encrypted and comes back as it was. + $encrypter = new class { + // Eloquent's `encrypted` cast calls encrypt($value, false) and + // decrypt($value, false); the string variants are here for anything + // that goes through Crypt::encryptString() instead. + public function encrypt($value, $serialize = true) + { + return 'enc:' . base64_encode($serialize ? serialize($value) : (string) $value); + } + + public function decrypt($value, $unserialize = true) + { + if (!is_string($value) || !str_starts_with($value, 'enc:')) { + throw new RuntimeException('Unable to decrypt.'); + } + + $decoded = base64_decode(substr($value, 4), true); + + return $unserialize ? unserialize($decoded) : $decoded; + } + + public function encryptString($value) + { + return $this->encrypt($value, false); + } + + public function decryptString($value) + { + return $this->decrypt($value, false); + } + }; + app()->instance('encrypter', $encrypter); + Illuminate\Support\Facades\Crypt::clearResolvedInstance('encrypter'); + EloquentModel::encryptUsing($encrypter); + app()->instance('responsecache', new class { + public function __call($method, $arguments) + { + return null; + } + }); + app()->instance('request', Request::create('/')); + + $schema = $connection->getSchemaBuilder(); + $tables = [ + 'inspection_forms' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'description', 'type', 'status', 'subject_type', 'subject_uuid', 'items', 'settings', 'meta', 'published_at', 'created_by_uuid', 'updated_by_uuid'], + 'inspection_links' => ['uuid', 'public_id', '_key', 'company_uuid', 'inspection_form_uuid', 'driver_uuid', 'vehicle_uuid', 'assignee_uuid', 'created_by_uuid', 'token_hash', 'token', 'pin_hash', 'pin', 'pin_attempts', 'pin_sent_via', 'pin_sent_at', 'status', 'single_use', 'expires_at', 'last_viewed_at', 'used_at', 'used_ip', 'used_user_agent', 'meta'], + 'inspection_submissions' => ['uuid', 'public_id', '_key', 'company_uuid', 'inspection_form_uuid', 'vehicle_uuid', 'driver_uuid', 'submitted_by_uuid', 'issue_uuid', 'work_order_uuid', 'type', 'status', 'result', 'source', 'odometer', 'engine_hours', 'total_items', 'failed_items', 'started_at', 'submitted_at', 'resolved_at', 'location', 'signature', 'attachments', 'meta', 'created_by_uuid', 'updated_by_uuid'], + 'inspection_item_results' => ['uuid', '_key', 'company_uuid', 'inspection_submission_uuid', 'issue_uuid', 'work_order_uuid', 'item_key', 'label', 'category', 'status', 'severity', 'passed', 'comments', 'photos', 'meta', 'created_by_uuid', 'updated_by_uuid'], + 'issues' => ['uuid', 'public_id', '_key', 'company_uuid', 'reported_by_uuid', 'assigned_to_uuid', 'vehicle_uuid', 'driver_uuid', 'order_uuid', 'issue_id', 'location', 'category', 'type', 'report', 'title', 'tags', 'priority', 'meta', 'resolved_at', 'status'], + 'work_orders' => ['uuid', 'public_id', '_key', 'company_uuid', 'schedule_uuid', 'code', 'subject', 'category', 'status', 'priority', 'target_type', 'target_uuid', 'assignee_type', 'assignee_uuid', 'opened_at', 'due_at', 'closed_at', 'instructions', 'checklist', 'currency', 'estimated_cost', 'approved_budget', 'actual_cost', 'cost_center', 'budget_code', 'meta', 'created_by_uuid', 'updated_by_uuid'], + 'vehicles' => ['uuid', 'public_id', 'internal_id', '_key', 'company_uuid', 'vendor_uuid', 'photo_uuid', 'name', 'make', 'model', 'year', 'trim', 'plate_number', 'vin', 'status', 'currency', 'slug', 'online', 'location'], + 'drivers' => ['uuid', 'public_id', 'internal_id', '_key', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'vendor_uuid', 'current_job_uuid', 'photo_uuid', 'status', 'online', 'location', 'slug'], + 'users' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'email', 'phone', 'avatar_uuid', 'type', 'status'], + 'companies' => ['uuid', 'public_id', '_key', 'name', 'owner_uuid', 'options'], + 'company_users' => ['uuid', '_key', 'company_uuid', 'user_uuid', 'role_uuid', 'status'], + 'settings' => ['key', 'value'], + 'files' => ['uuid', 'public_id', '_key', 'company_uuid', 'uploader_uuid', 'subject_uuid', 'subject_type', 'path', 'disk', 'bucket', 'folder', 'etag', 'meta', 'original_filename', 'type', 'content_type', 'file_size', 'slug', 'caption'], + 'vendors' => ['uuid', 'public_id', '_key', 'company_uuid', 'name'], + 'orders' => ['uuid', 'public_id', '_key', 'company_uuid', 'driver_assigned_uuid', 'status'], + 'positions' => ['uuid', 'public_id', '_key', 'company_uuid', 'subject_uuid', 'subject_type', 'coordinates'], + 'maintenances' => ['uuid', 'public_id', '_key', 'company_uuid', 'maintainable_type', 'maintainable_uuid', 'status', 'completed_at'], + 'maintenance_schedules' => ['uuid', 'public_id', '_key', 'company_uuid', 'subject_type', 'subject_uuid', 'status', 'next_due_at'], + 'custom_field_values' => ['uuid', 'company_uuid', 'custom_field_uuid', 'subject_uuid', 'subject_type', 'value', 'value_type'], + 'custom_fields' => ['uuid', 'company_uuid', 'category_uuid', 'subject_uuid', 'subject_type', 'name', 'label', 'type', 'for', 'component', 'options', 'required', 'editable', 'default_value', 'validation_rules', 'meta', 'description', 'help_text', 'order'], + 'categories' => ['uuid', 'public_id', '_key', 'company_uuid', 'owner_uuid', 'owner_type', 'parent_uuid', 'icon_file_uuid', 'internal_id', 'name', 'description', 'translations', 'meta', 'tags', 'icon', 'icon_color', 'slug', 'order', 'for', 'core_category'], + 'activity_log' => ['uuid', 'company_uuid', 'log_name', 'description', 'subject_type', 'subject_id', 'causer_type', 'causer_id', 'properties', 'event', 'batch_uuid'], + ]; + + foreach ($tables as $table => $columns) { + $schema->create($table, function ($blueprint) use ($columns) { + $blueprint->increments('id'); + foreach ($columns as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + $connection->table('companies')->insert(['uuid' => 'company-insp', 'public_id' => 'company_insp', 'name' => 'Inspection Co']); + $connection->table('companies')->insert(['uuid' => 'company-other', 'public_id' => 'company_other', 'name' => 'Someone Else']); + $connection->table('users')->insert(['uuid' => 'user-driver', 'public_id' => 'user_driver', 'company_uuid' => 'company-insp', 'name' => 'Dana Driver', 'email' => 'dana@example.com', 'phone' => '+15550001111', 'type' => 'user']); + $connection->table('users')->insert(['uuid' => 'user-admin', 'public_id' => 'user_admin', 'company_uuid' => 'company-insp', 'name' => 'Avery Admin', 'email' => 'avery@example.com', 'type' => 'user']); + $connection->table('vehicles')->insert(['uuid' => 'vehicle-1', 'public_id' => 'vehicle_one', 'company_uuid' => 'company-insp', 'name' => 'Truck 7', 'plate_number' => 'TRK-7', 'currency' => 'SGD']); + $connection->table('vehicles')->insert(['uuid' => 'vehicle-2', 'public_id' => 'vehicle_two', 'company_uuid' => 'company-insp', 'name' => 'Van 2', 'plate_number' => 'VAN-2']); + $connection->table('vehicles')->insert(['uuid' => 'vehicle-other', 'public_id' => 'vehicle_other', 'company_uuid' => 'company-other', 'name' => 'Not Ours']); + $connection->table('drivers')->insert(['uuid' => 'driver-1', 'public_id' => 'driver_one', 'company_uuid' => 'company-insp', 'user_uuid' => 'user-driver', 'vehicle_uuid' => 'vehicle-1', 'status' => 'active']); + $connection->table('drivers')->insert(['uuid' => 'driver-other', 'public_id' => 'driver_other', 'company_uuid' => 'company-other', 'user_uuid' => null, 'status' => 'active']); + + session(['company' => 'company-insp', 'user' => 'user-admin']); + + return $connection; +} + +function fleetOpsInspectionControllerForm(array $attributes = []): InspectionForm +{ + return InspectionForm::create(array_merge([ + 'company_uuid' => 'company-insp', + 'name' => 'Pre-trip DVIR', + 'type' => 'dvir', + 'status' => 'published', + 'published_at' => '2026-09-01 08:00:00', + 'items' => [ + ['key' => 'brakes', 'label' => 'Brakes', 'category' => 'Safety', 'severity' => 'critical'], + ['key' => 'lights', 'label' => 'Lights', 'category' => 'Safety', 'severity' => 'medium'], + ], + 'settings' => ['create_issue_on_failure' => true, 'create_work_order_on_failure' => true], + ], $attributes)); +} + +function fleetOpsInspectionControllerLink(InspectionForm $form, string $token, array $attributes = []): InspectionLink +{ + return InspectionLink::create(array_merge([ + 'company_uuid' => 'company-insp', + 'inspection_form_uuid' => $form->uuid, + 'driver_uuid' => 'driver-1', + 'vehicle_uuid' => 'vehicle-1', + 'created_by_uuid' => 'user-admin', + 'token_hash' => InspectionLink::hashToken($token), + 'status' => 'active', + 'single_use' => true, + ], $attributes)); +} + +function fleetOpsInspectionControllerBody(array $overrides = []): array +{ + return array_merge([ + 'odometer' => 120400, + 'item_results' => [ + ['item_key' => 'brakes', 'label' => 'Brakes', 'passed' => false, 'severity' => 'critical', 'comments' => 'Soft pedal', 'photos' => ['iVBORw0KGgoAAAANSUhEUg==']], + ['item_key' => 'lights', 'label' => 'Lights', 'passed' => true], + ], + ], $overrides); +} + +/** Runs a public-link call and hands back the refusal it aborted with, if any. */ +function fleetOpsInspectionControllerRefusal(callable $call): ?JsonResponse +{ + try { + $call(); + } catch (HttpResponseException $exception) { + return $exception->getResponse(); + } + + return null; +} + +/* + * A stored file's caption is humanized from its name by a core-api macro that + * is not registered in this harness. The same stand-in the field tests use. + */ +if (!Illuminate\Support\Str::hasMacro('humanize')) { + Illuminate\Support\Str::macro('humanize', fn ($value, $uppercase = true) => str_replace('_', ' ', Illuminate\Support\Str::snake((string) $value))); +} + +if (!function_exists('event')) { + /** + * The platform's event(), fired by core's File observer when a file is + * recorded. Nothing listens in this harness, so it dispatches nothing. + */ + function event(...$arguments) + { + return null; + } +} + +if (!function_exists('report')) { + /** + * The platform's report(), which this package's test bootstrap does not + * define: hands the exception to whichever handler is bound. + */ + function report($exception) + { + app(Illuminate\Contracts\Debug\ExceptionHandler::class)->report($exception); + } +} + +/** A route that names only its URI, which is all a resource reads to tell internal from public. */ +class FleetOpsInspectionControllerRouteFixture +{ + public function __construct(private string $uri) + { + } + + public function uri(): string + { + return $this->uri; + } +} + +/** Binds an internal request, so resources include what only the console is shown. */ +function fleetOpsInspectionControllerInternalRequest(string $method = 'GET', array $input = []): Request +{ + $uri = 'int/v1/inspection-forms'; + $request = Request::create('/' . $uri, $method, $input); + $request->setRouteResolver(fn () => new FleetOpsInspectionControllerRouteFixture($uri)); + app()->instance('request', $request); + + return $request; +} + +/** + * Swap in a container that answers environment(), which Utils::consoleUrl() + * asks when a link's address is built. Copied from the driver auth tests. + */ +function fleetOpsInspectionControllerContainer(): void +{ + $current = Illuminate\Container\Container::getInstance(); + if (method_exists($current, 'hasDebugModeEnabled')) { + return; + } + + $replacement = new class extends Illuminate\Container\Container { + public function environment(...$environments) + { + if (empty($environments)) { + return 'testing'; + } + + $checks = is_array($environments[0]) ? $environments[0] : $environments; + + return in_array('testing', $checks, true); + } + + public function hasDebugModeEnabled() + { + return true; + } + }; + + foreach (['bindings', 'instances', 'aliases', 'abstractAliases', 'resolved', 'extenders', 'tags', 'contextual', 'scopedInstances', 'reboundCallbacks', 'globalBeforeResolvingCallbacks', 'globalResolvingCallbacks', 'globalAfterResolvingCallbacks', 'beforeResolvingCallbacks', 'resolvingCallbacks', 'afterResolvingCallbacks'] as $property) { + if (!property_exists(Illuminate\Container\Container::class, $property)) { + continue; + } + $reflection = new ReflectionProperty(Illuminate\Container\Container::class, $property); + $reflection->setAccessible(true); + if ($reflection->isInitialized($current)) { + $reflection->setValue($replacement, $reflection->getValue($current)); + } + } + + Illuminate\Container\Container::setInstance($replacement); + Illuminate\Support\Facades\Facade::setFacadeApplication($replacement); +} + +/** + * Lets a link's PIN actually be sent inside the harness: the console's host, a + * mailer and an SMS service that record what they are handed, and a handler + * for report(). Each can be told to fail, through the object returned. + */ +function fleetOpsInspectionControllerDelivery(): object +{ + fleetOpsInspectionControllerContainer(); + config(['fleetbase.console.host' => 'console.test', 'fleetbase.console.secure' => true]); + + $fakes = new class { + public array $mail = []; + public array $sms = []; + public array $reported = []; + public mixed $to = null; + public ?Throwable $mailFails = null; + public ?Throwable $smsFails = null; + public ?array $smsAnswer = null; + }; + + Illuminate\Support\Facades\Mail::swap(new class($fakes) { + public function __construct(private object $fakes) + { + } + + public function to($users) + { + $this->fakes->to = $users; + + return $this; + } + + public function send($mailable) + { + if ($this->fakes->mailFails) { + throw $this->fakes->mailFails; + } + + $this->fakes->mail[] = $mailable; + + return null; + } + + public function __call($method, $arguments) + { + return $this; + } + }); + + app()->instance(Fleetbase\Services\SmsService::class, new class($fakes) extends Fleetbase\Services\SmsService { + public function __construct(private object $fakes) + { + } + + public function send(string $to, string $text, array $options = [], ?string $provider = null): array + { + if ($this->fakes->smsFails) { + throw $this->fakes->smsFails; + } + + $this->fakes->sms[] = ['to' => $to, 'text' => $text, 'options' => $options]; + + return $this->fakes->smsAnswer ?? ['success' => true, 'provider' => 'twilio']; + } + }); + + app()->instance(Illuminate\Contracts\Debug\ExceptionHandler::class, new class($fakes) { + public function __construct(private object $fakes) + { + } + + public function report(Throwable $e): void + { + $this->fakes->reported[] = $e; + } + + public function __call($method, $arguments) + { + return null; + } + }); + + return $fakes; +} + +/** A real disk under a temporary directory, public so a stored file has a URL. */ +function fleetOpsInspectionControllerDisk(): string +{ + $root = sys_get_temp_dir() . '/fleetops-inspection-uploads-' . bin2hex(random_bytes(4)); + config([ + 'filesystems.default' => 'public', + 'filesystems.disks.public' => ['driver' => 'local', 'root' => $root, 'url' => 'https://files.test/storage', 'visibility' => 'public'], + ]); + + $manager = new Illuminate\Filesystem\FilesystemManager(app()); + app()->instance('filesystem', $manager); + app()->instance(Illuminate\Contracts\Filesystem\Factory::class, $manager); + Illuminate\Support\Facades\Storage::clearResolvedInstances(); + Illuminate\Support\Facades\Storage::swap($manager); + + return $root; +} + +/** A one-pixel PNG, under whatever name the device gave it. */ +function fleetOpsInspectionControllerPhoto(string $clientName = 'photo.php'): Illuminate\Http\UploadedFile +{ + $path = tempnam(sys_get_temp_dir(), 'insp'); + file_put_contents($path, base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==')); + + return new Illuminate\Http\UploadedFile($path, $clientName, 'image/png', null, true); +} + +afterEach(function () { + Carbon::setTestNow(); +}); + +test('public inspection link refuses a missing, unknown, unpublished, invalid or spent token', function () { + fleetOpsInspectionControllerDatabase(); + $controller = new PublicInspectionController(); + $form = fleetOpsInspectionControllerForm(); + fleetOpsInspectionControllerLink($form, 'good-token'); + fleetOpsInspectionControllerLink($form, 'spent-token', ['used_at' => '2026-09-01 09:00:00']); + + $refusal = fn (array $query, ?string $id = null) => fleetOpsInspectionControllerRefusal( + fn () => $controller->show(Request::create('/public/inspections/forms/x', 'GET', $query), $id ?? $form->public_id) + ); + + expect($refusal([])->getStatusCode())->toBe(403) + ->and($refusal([])->getData(true))->toBe(['error' => 'Inspection token is required.']) + ->and($refusal(['token' => 'nope'])->getData(true))->toBe(['error' => 'Inspection link is invalid.']) + ->and($refusal(['token' => 'spent-token'])->getData(true))->toBe(['error' => 'Inspection link is expired, revoked, or already used.']); + + $draft = fleetOpsInspectionControllerForm(['status' => 'draft', 'published_at' => null]); + expect($refusal(['token' => 'good-token'], $draft->public_id)->getData(true))->toBe(['error' => 'This inspection form is not available.']); + + expect(fn () => $controller->show(Request::create('/public/inspections/forms/x', 'GET', ['token' => 'good-token']), 'inspection_form_missing')) + ->toThrow(ModelNotFoundException::class); +}); + +test('public inspection link shows the form with the identity it was minted for', function () { + fleetOpsInspectionControllerDatabase(); + Carbon::setTestNow('2026-09-09 08:00:00'); + $controller = new PublicInspectionController(); + $form = fleetOpsInspectionControllerForm(); + $link = fleetOpsInspectionControllerLink($form, 'good-token', ['expires_at' => '2026-09-10 08:00:00']); + + $payload = $controller->show(Request::create('/public/inspections/forms/x', 'GET', ['token' => 'good-token']), $form->uuid)->getData(true); + + expect($payload['form']['id'])->toBe($form->public_id) + ->and($payload['form']['items'])->toHaveCount(2) + ->and($payload['form']['is_published'])->toBeTrue() + ->and($payload['identity']['driver'])->toBe(['id' => 'driver_one', 'name' => 'Dana Driver']) + ->and($payload['identity']['vehicle'])->toBe(['id' => 'vehicle_one', 'name' => 'Truck 7', 'plate_number' => 'TRK-7']) + ->and($payload['identity']['expires_at'])->toStartWith('2026-09-10') + ->and($link->fresh()->last_viewed_at->toDateTimeString())->toBe('2026-09-09 08:00:00'); + + // A link minted for nobody in particular carries no identity. + fleetOpsInspectionControllerLink($form, 'anon-token', ['driver_uuid' => null, 'vehicle_uuid' => null]); + $anonymous = $controller->show(Request::create('/public/inspections/forms/x', 'GET', ['token' => 'anon-token']), $form->public_id)->getData(true); + expect($anonymous['identity'])->toBe(['assignee' => null, 'driver' => null, 'vehicle' => null, 'expires_at' => null]); +}); + +test('public inspection link files a submission and spends the link', function () { + fleetOpsInspectionControllerDatabase(); + Carbon::setTestNow('2026-09-09 08:30:00'); + $controller = new PublicInspectionController(); + $form = fleetOpsInspectionControllerForm(); + InspectionFormSync::convertLegacyItems($form); + $link = fleetOpsInspectionControllerLink($form, 'good-token'); + $server = ['REMOTE_ADDR' => '203.0.113.9', 'HTTP_USER_AGENT' => 'Safari']; + + // A link answers the form's fields. The first cut's flat checklist stores + // photo URLs as given, so it is refused, and refusing it spends nothing. + $flat = fleetOpsInspectionControllerRefusal(fn () => $controller->submit( + Request::create('/public/inspections/forms/x/submit', 'POST', fleetOpsInspectionControllerBody(['token' => 'good-token']), [], [], $server), + $form->public_id + )); + expect($flat->getStatusCode())->toBe(422) + ->and($flat->getData(true)['error'])->toContain('form fields') + ->and($link->fresh()->used_at)->toBeNull(); + + $request = Request::create('/public/inspections/forms/x/submit', 'POST', [ + 'token' => 'good-token', + 'odometer' => 120400, + 'signature' => ['name' => 'Dana Driver'], + 'custom_field_values' => [ + ['custom_field' => 'brakes', 'value_type' => 'object', 'value' => ['passed' => false, 'severity' => 'critical', 'comments' => 'Soft pedal', 'photos' => []]], + ['custom_field' => 'lights', 'value_type' => 'object', 'value' => ['passed' => true]], + ], + ], [], [], $server); + $payload = $controller->submit($request, $form->public_id)->getData(true); + + expect($payload['message'])->toBe('Inspection submitted.') + ->and($payload['submission']['source'])->toBe('public_link') + ->and($payload['submission']['status'])->toBe('submitted') + ->and($payload['submission']['result'])->toBe('failed') + ->and($payload['submission']['failed_items'])->toBe(1) + ->and($payload['submission']['meta']['inspection_link_id'])->toBe($link->public_id) + // Who typed their name, beside the account the link credits, and + // whether a PIN stood between the link and the form. + ->and($payload['submission']['meta']['completed_by_name'])->toBe('Dana Driver') + ->and($payload['submission']['meta']['pin_verified'])->toBeFalse() + ->and($payload['submission']['item_results'])->toHaveCount(2) + ->and($payload['submission']['issue']['id'])->toStartWith('issue_') + ->and($payload['submission']['work_order']['id'])->toStartWith('work_order_') + ->and($payload['submission']['driver']['id'])->toBe('driver_one') + ->and($payload['submission']['vehicle']['id'])->toBe('vehicle_one') + ->and($payload['submission']['form']['id'])->toBe($form->public_id); + + // Nobody is assigned, so the submission is credited to the driver's account. + $submission = InspectionSubmission::query()->first(); + expect($submission->submitted_by_uuid)->toBe('user-driver') + ->and($submission->driver_uuid)->toBe('driver-1') + ->and($submission->vehicle_uuid)->toBe('vehicle-1'); + + $spent = $link->fresh(); + expect($spent->used_at->toDateTimeString())->toBe('2026-09-09 08:30:00') + ->and($spent->used_ip)->toBe('203.0.113.9') + ->and($spent->used_user_agent)->toBe('Safari') + ->and($spent->isUsable())->toBeFalse(); + + // Spent is spent: the same token cannot file twice. + expect(fleetOpsInspectionControllerRefusal(fn () => $controller->submit($request, $form->public_id))->getStatusCode())->toBe(403); +}); + +test('internal inspection form controller publishes archives and mints links', function () { + fleetOpsInspectionControllerDatabase(); + Carbon::setTestNow('2026-09-09 09:00:00'); + $controller = new InspectionFormController(); + $form = fleetOpsInspectionControllerForm(['status' => 'draft', 'published_at' => null]); + + // A draft cannot be handed out. + $refused = $controller->generateLink(Request::create('/', 'POST'), $form->public_id); + expect($refused->getStatusCode())->toBe(422) + ->and($refused->getData(true)['error'])->toContain('must be published'); + + $published = $controller->publish($form->public_id)->getData(true); + expect($published['status'])->toBe('ok') + ->and($published['data']['status'])->toBe('published') + ->and($published['data']['is_published'])->toBeTrue(); + + $minted = $controller->generateLink(Request::create('/', 'POST', ['driver' => 'driver_one', 'vehicle' => 'vehicle-1', 'expires_at' => '2026-09-10 09:00:00', 'single_use' => false]), $form->uuid)->getData(true); + $link = InspectionLink::query()->first(); + + expect($minted['status'])->toBe('ok') + ->and($minted['link']['id'])->toBe($link->public_id) + ->and($minted['link']['path'])->toBe('/~/inspection?id=' . urlencode($form->public_id) . '&token=' . urlencode($minted['link']['token'])) + ->and(strlen($minted['link']['token']))->toBe(64) + ->and($link->token_hash)->toBe(InspectionLink::hashToken($minted['link']['token'])) + ->and($link->driver_uuid)->toBe('driver-1') + ->and($link->vehicle_uuid)->toBe('vehicle-1') + ->and($link->created_by_uuid)->toBe('user-admin') + ->and($link->single_use)->toBeFalse() + ->and($minted['link']['driver'])->toBe(['id' => 'driver_one', 'name' => 'Dana Driver']) + ->and($minted['link']['vehicle'])->toBe(['id' => 'vehicle_one', 'name' => 'Truck 7']) + ->and($minted['link']['expires_at'])->toStartWith('2026-09-10'); + + // Nobody named: a link anyone may use, single use by default. + $open = $controller->generateLink(Request::create('/', 'POST'), $form->public_id)->getData(true); + expect($open['link']['driver'])->toBeNull() + ->and($open['link']['vehicle'])->toBeNull() + ->and((bool) InspectionLink::query()->where('public_id', $open['link']['id'])->value('single_use'))->toBeTrue(); + + // A driver or vehicle from another company does not exist here. + expect(fn () => $controller->generateLink(Request::create('/', 'POST', ['driver' => 'driver_other']), $form->public_id))->toThrow(ModelNotFoundException::class) + ->and(fn () => $controller->generateLink(Request::create('/', 'POST', ['vehicle' => 'vehicle_other']), $form->public_id))->toThrow(ModelNotFoundException::class); + + $archived = $controller->archive($form->public_id)->getData(true); + expect($archived['message'])->toBe('Inspection form archived.') + ->and($archived['data']['status'])->toBe('archived'); + + session(['company' => 'company-other']); + expect(fn () => $controller->publish($form->public_id))->toThrow(ModelNotFoundException::class); +}); + +test('internal inspection submission controller syncs item results from the console form', function () { + fleetOpsInspectionControllerDatabase(); + $controller = new InspectionSubmissionController(); + $form = fleetOpsInspectionControllerForm(); + $submission = InspectionSubmission::create(['company_uuid' => 'company-insp', 'inspection_form_uuid' => $form->uuid, 'vehicle_uuid' => 'vehicle-1', 'driver_uuid' => 'driver-1', 'type' => 'dvir', 'status' => 'draft']); + + // Nothing sent: nothing touched. + $controller->onAfterCreate(Request::create('/', 'POST'), $submission, []); + expect(InspectionItemResult::query()->count())->toBe(0); + + // The console nests the payload under the resource name. + $controller->onAfterCreate(Request::create('/', 'POST', ['inspection_submission' => ['item_results' => [ + ['item_key' => 'brakes', 'label' => 'Brakes', 'passed' => false, 'severity' => 'critical'], + ['title' => 'Untitled item', 'status' => 'failed'], + ['label' => 'Horn'], + ]]]), $submission, []); + + $results = $submission->itemResults()->orderBy('id')->get(); + expect($results)->toHaveCount(3) + ->and($results[0]->status)->toBe('failed') + ->and($results[1]->label)->toBe('Untitled item') + ->and($results[1]->passed)->toBeFalse() + // No status and no flag: passed, as an unchecked box is. + ->and($results[2]->status)->toBe('passed') + ->and($results[2]->passed)->toBeTrue() + ->and($submission->fresh()->failed_items)->toBe(2) + ->and($submission->relationLoaded('itemResults'))->toBeTrue(); + + // An update matches by uuid, then item key, then label — and drops what + // the console no longer lists. + $controller->onAfterUpdate(Request::create('/', 'PUT', ['item_results' => [ + ['uuid' => $results[0]->uuid, 'item_key' => 'brakes', 'label' => 'Brakes', 'passed' => true], + ['label' => 'Horn', 'passed' => false, 'status' => 'failed', 'meta' => ['note' => 'weak']], + ]]), $submission->fresh(), []); + + $synced = $submission->fresh(); + expect($synced->itemResults()->count())->toBe(2) + ->and((bool) $synced->itemResults()->where('uuid', $results[0]->uuid)->value('passed'))->toBeTrue() + ->and($synced->itemResults()->where('label', 'Untitled item')->exists())->toBeFalse() + ->and($synced->itemResults()->where('label', 'Horn')->first()->meta['note'])->toBe('weak') + ->and($synced->failed_items)->toBe(1); + + $builder = InspectionSubmission::query(); + $controller->onFindRecord($builder, Request::create('/')); + expect(array_keys($builder->getEagerLoads()))->toContain('form', 'vehicle', 'driver', 'submittedBy', 'issue', 'workOrder', 'itemResults'); +}); + +test('internal inspection submission controller submits, raises follow-up and resolves', function () { + fleetOpsInspectionControllerDatabase(); + Carbon::setTestNow('2026-09-09 10:00:00'); + $controller = new InspectionSubmissionController(); + $form = fleetOpsInspectionControllerForm(); + + $clean = InspectionSubmission::create(['company_uuid' => 'company-insp', 'inspection_form_uuid' => $form->uuid, 'vehicle_uuid' => 'vehicle-1', 'driver_uuid' => 'driver-1', 'type' => 'dvir', 'status' => 'draft']); + InspectionItemResult::create(['company_uuid' => 'company-insp', 'inspection_submission_uuid' => $clean->uuid, 'label' => 'Brakes', 'passed' => true]); + + $submitted = $controller->submit($clean->public_id)->getData(true); + expect($submitted['message'])->toBe('Inspection submitted.') + ->and($submitted['data']['status'])->toBe('submitted') + ->and($submitted['data']['result'])->toBe('passed'); + + // Nothing failed, nothing to raise. + $noIssue = $controller->createIssue($clean->uuid)->getData(true); + expect($noIssue['message'])->toBe('No failed inspection items found.') + ->and($noIssue['issue'])->toBeNull(); + $noOrder = $controller->createWorkOrder($clean->uuid)->getData(true); + expect($noOrder['message'])->toBe('No failed inspection items found.') + ->and($noOrder['work_order'])->toBeNull(); + + $failed = InspectionSubmission::create(['company_uuid' => 'company-insp', 'inspection_form_uuid' => $form->uuid, 'vehicle_uuid' => 'vehicle-1', 'driver_uuid' => 'driver-1', 'submitted_by_uuid' => 'user-driver', 'type' => 'dvir', 'status' => 'draft']); + InspectionItemResult::create(['company_uuid' => 'company-insp', 'inspection_submission_uuid' => $failed->uuid, 'label' => 'Brakes', 'passed' => false, 'severity' => 'high']); + + $issued = $controller->createIssue($failed->public_id)->getData(true); + expect($issued['message'])->toBe('Issue created from failed inspection items.') + ->and($issued['issue']['title'])->toBe('Failed inspection: Truck 7') + ->and($issued['data']['issue']['uuid'])->toBe($issued['issue']['uuid']); + + $ordered = $controller->createWorkOrder($failed->public_id)->getData(true); + expect($ordered['message'])->toBe('Work order created from failed inspection items.') + ->and($ordered['work_order']['subject'])->toBe('Inspection repair: Truck 7') + ->and($ordered['work_order']['meta']['issue_uuid'])->toBe($issued['issue']['uuid']) + ->and(Issue::query()->count())->toBe(1) + ->and(WorkOrder::query()->count())->toBe(1); + + $resolved = $controller->resolve($failed->public_id)->getData(true); + expect($resolved['message'])->toBe('Inspection resolved.') + ->and($resolved['data']['status'])->toBe('resolved') + ->and($failed->fresh()->resolved_at->toDateTimeString())->toBe('2026-09-09 10:00:00'); + + expect(fn () => $controller->submit('inspection_submission_missing'))->toThrow(ModelNotFoundException::class); +}); + +test('driver inspection api lists only the published forms a vehicle can be inspected against', function () { + fleetOpsInspectionControllerDatabase(); + $controller = new InspectionController(); + + $wide = fleetOpsInspectionControllerForm(['name' => 'Fleet-wide pre-trip', 'type' => 'pre_trip', 'published_at' => '2026-09-01 08:00:00']); + $truck = fleetOpsInspectionControllerForm(['name' => 'Truck 7 lift gate', 'subject_type' => Vehicle::class, 'subject_uuid' => 'vehicle-1', 'published_at' => '2026-09-03 08:00:00']); + fleetOpsInspectionControllerForm(['name' => 'Van 2 only', 'subject_type' => Vehicle::class, 'subject_uuid' => 'vehicle-2', 'published_at' => '2026-09-02 08:00:00']); + fleetOpsInspectionControllerForm(['name' => 'Still a draft', 'status' => 'draft', 'published_at' => null]); + fleetOpsInspectionControllerForm(['name' => 'Says published, never was', 'status' => 'published', 'published_at' => null]); + fleetOpsInspectionControllerForm(['name' => 'Retired', 'status' => 'archived']); + fleetOpsInspectionControllerForm(['name' => 'Another company', 'company_uuid' => 'company-other']); + + $all = $controller->queryForms(Request::create('/v1/inspection-forms', 'GET'))->resolve(); + expect(array_column($all, 'name'))->toBe(['Truck 7 lift gate', 'Van 2 only', 'Fleet-wide pre-trip']) + ->and($all[0]['id'])->toBe($truck->public_id) + ->and($all[0])->not->toHaveKey('uuid'); + + $forTruck = $controller->queryForms(Request::create('/v1/inspection-forms', 'GET', ['vehicle' => 'vehicle_one']))->resolve(); + expect(array_column($forTruck, 'name'))->toBe(['Truck 7 lift gate', 'Fleet-wide pre-trip']); + + $preTrip = $controller->queryForms(Request::create('/v1/inspection-forms', 'GET', ['type' => 'pre_trip,post_trip', 'limit' => 1]))->resolve(); + expect(array_column($preTrip, 'id'))->toBe([$wide->public_id]); + + $capped = $controller->queryForms(Request::create('/v1/inspection-forms', 'GET', ['limit' => 2]))->resolve(); + expect($capped)->toHaveCount(2); + + expect($controller->queryForms(Request::create('/v1/inspection-forms', 'GET', ['vehicle' => 'vehicle_other']))->getStatusCode())->toBe(404); +}); + +test('driver inspection api shows a published form with its items and settings', function () { + fleetOpsInspectionControllerDatabase(); + $controller = new InspectionController(); + + $form = fleetOpsInspectionControllerForm(['subject_type' => Vehicle::class, 'subject_uuid' => 'vehicle-1', 'meta' => ['sla' => 'am']]); + $draft = fleetOpsInspectionControllerForm(['status' => 'draft', 'published_at' => null]); + $other = fleetOpsInspectionControllerForm(['company_uuid' => 'company-other']); + + $shown = $controller->findForm($form->public_id)->resolve(); + expect($shown['id'])->toBe($form->public_id) + ->and($shown['name'])->toBe('Pre-trip DVIR') + ->and($shown['type'])->toBe('dvir') + ->and($shown['items'])->toHaveCount(2) + ->and($shown['item_count'])->toBe(2) + ->and($shown['settings'])->toBe(['create_issue_on_failure' => true, 'create_work_order_on_failure' => true]) + ->and($shown['meta'])->toBe(['sla' => 'am']) + ->and($shown['is_published'])->toBeTrue() + ->and($shown['subject_name'])->toBe('Truck 7') + ->and($shown['subject']['id'])->toBe('vehicle_one') + ->and($shown['subject']['type'])->toBe('maintenance-subject-vehicle') + ->and($shown['subject']['subject_type'])->toBe('maintenance-subject-vehicle'); + + // By uuid as well as public id. + expect($controller->findForm($form->uuid)->resolve()['id'])->toBe($form->public_id); + + // A form bound to nothing has no subject to describe. + $wide = fleetOpsInspectionControllerForm(); + expect($controller->findForm($wide->public_id)->resolve()['subject'])->toBeNull(); + + // Draft, other company, or nothing at all: all the same 404. + expect($controller->findForm($draft->public_id)->getStatusCode())->toBe(404) + ->and($controller->findForm($other->public_id)->getStatusCode())->toBe(404) + ->and($controller->findForm('inspection_form_missing')->getStatusCode())->toBe(404) + ->and($controller->findForm('inspection_form_missing')->getData(true))->toBe(['error' => 'Inspection form resource not found.']); +}); + +test('driver inspection api files a submission and answers with its follow-up', function () { + fleetOpsInspectionControllerDatabase(); + Carbon::setTestNow('2026-09-09 07:30:00'); + $controller = new InspectionController(); + $form = fleetOpsInspectionControllerForm(); + + $body = fleetOpsInspectionControllerBody([ + 'inspection_form' => $form->public_id, + 'driver' => 'driver_one', + 'vehicle' => 'vehicle_two', + 'started_at' => '2026-09-09T07:10:00Z', + 'location' => ['latitude' => 1.35, 'longitude' => 103.82], + ]); + + $filed = $controller->submit(Request::create('/v1/inspections', 'POST', $body))->resolve(); + + expect($filed['id'])->toStartWith('inspection_submission_') + ->and($filed)->not->toHaveKey('uuid') + ->and($filed['source'])->toBe('navigator') + ->and($filed['status'])->toBe('submitted') + ->and($filed['result'])->toBe('failed') + ->and($filed['type'])->toBe('dvir') + ->and($filed['odometer'])->toBe(120400) + ->and($filed['total_items'])->toBe(2) + ->and($filed['failed_items'])->toBe(1) + ->and($filed['has_failures'])->toBeTrue() + ->and($filed['location'])->toBe(['latitude' => 1.35, 'longitude' => 103.82]) + ->and($filed['started_at']->toDateTimeString())->toBe('2026-09-09 07:10:00') + ->and($filed['submitted_at']->toDateTimeString())->toBe('2026-09-09 07:30:00') + ->and($filed['form']->resolve()['id'])->toBe($form->public_id) + ->and($filed['vehicle']->resolve()['id'])->toBe('vehicle_two') + ->and($filed['driver']->resolve()['id'])->toBe('driver_one') + ->and($filed['item_results']->resolve())->toHaveCount(2) + ->and($filed['item_results']->resolve()[0])->toMatchArray(['item_key' => 'brakes', 'passed' => false, 'status' => 'failed', 'comments' => 'Soft pedal', 'photos' => ['iVBORw0KGgoAAAANSUhEUg==']]) + ->and($filed['item_results']->resolve()[0]['submission_id'])->toBe($filed['id']) + ->and($filed['issue']->resolve()['id'])->toStartWith('issue_') + ->and($filed['work_order']->resolve()['id'])->toStartWith('work_order_'); + + $submission = InspectionSubmission::query()->first(); + expect($submission->submitted_by_uuid)->toBe('user-driver') + ->and($submission->driver_uuid)->toBe('driver-1') + ->and($submission->vehicle_uuid)->toBe('vehicle-2') + ->and($submission->meta)->toBe([]); + + // No vehicle named: the driver's own truck, and no start time: now. + $assigned = $controller->submit(Request::create('/v1/inspections', 'POST', fleetOpsInspectionControllerBody([ + 'inspection_form' => $form->uuid, + 'driver' => 'driver-1', + 'item_results' => [['label' => 'Horn', 'passed' => true]], + ])))->resolve(); + expect($assigned['vehicle']->resolve()['id'])->toBe('vehicle_one') + ->and($assigned['result'])->toBe('passed') + ->and($assigned['started_at']->toDateTimeString())->toBe('2026-09-09 07:30:00') + ->and($assigned['issue'])->toBeNull() + ->and($assigned['work_order'])->toBeNull(); +}); + +test('driver inspection api answers a replayed submit with the submission it already filed', function () { + fleetOpsInspectionControllerDatabase(); + $controller = new InspectionController(); + $form = fleetOpsInspectionControllerForm(); + $body = fleetOpsInspectionControllerBody(['inspection_form' => $form->public_id, 'driver' => 'driver_one']); + $replay = fn (string $key) => $controller->submit(Request::create('/v1/inspections', 'POST', $body, [], [], ['HTTP_IDEMPOTENCY_KEY' => $key]))->resolve(); + + $first = $replay('queued-7f3a'); + expect(InspectionSubmission::query()->count())->toBe(1) + ->and(InspectionSubmission::query()->first()->meta)->toBe(['idempotency_key' => 'queued-7f3a']); + + // The app lost the response and sent the same queued submit again. + $second = $replay('queued-7f3a'); + expect($second['id'])->toBe($first['id']) + ->and($second['item_results']->resolve())->toHaveCount(2) + ->and(InspectionSubmission::query()->count())->toBe(1) + ->and(Issue::query()->count())->toBe(1); + + // A different key is a different inspection, and the blank header is no key at all. + $third = $replay('queued-9c11'); + expect($third['id'])->not->toBe($first['id']) + ->and(InspectionSubmission::query()->count())->toBe(2); + $replay(' '); + expect(InspectionSubmission::query()->count())->toBe(3); +}); + +test('driver inspection api refuses a submit for a driver, form or vehicle it cannot find', function () { + fleetOpsInspectionControllerDatabase(); + $controller = new InspectionController(); + $form = fleetOpsInspectionControllerForm(); + $draft = fleetOpsInspectionControllerForm(['status' => 'draft', 'published_at' => null]); + $submit = fn (array $overrides) => $controller->submit(Request::create('/v1/inspections', 'POST', fleetOpsInspectionControllerBody(array_merge(['inspection_form' => $form->public_id, 'driver' => 'driver_one'], $overrides)))); + + expect($submit(['driver' => 'driver_other'])->getData(true))->toBe(['error' => 'Driver resource not found.']) + ->and($submit(['driver' => 'driver_other'])->getStatusCode())->toBe(404) + ->and($submit(['inspection_form' => $draft->public_id])->getData(true))->toBe(['error' => 'Inspection form resource not found.']) + ->and($submit(['vehicle' => 'vehicle_other'])->getData(true))->toBe(['error' => 'Vehicle resource not found.']) + ->and(InspectionSubmission::query()->count())->toBe(0); +}); + +test('driver inspection api lists and shows submissions with their filters', function () { + fleetOpsInspectionControllerDatabase(); + $controller = new InspectionController(); + $form = fleetOpsInspectionControllerForm(['settings' => []]); + $postTrip = fleetOpsInspectionControllerForm(['type' => 'post_trip', 'settings' => []]); + + $file = function (InspectionForm $form, string $vehicle, bool $passed, string $at) use ($controller) { + Carbon::setTestNow($at); + + return $controller->submit(Request::create('/v1/inspections', 'POST', [ + 'inspection_form' => $form->public_id, + 'driver' => 'driver_one', + 'vehicle' => $vehicle, + 'item_results' => [['label' => 'Brakes', 'passed' => $passed]], + ]))->resolve()['id']; + }; + + $oldest = $file($form, 'vehicle_one', true, '2026-09-07 08:00:00'); + $middle = $file($postTrip, 'vehicle_two', false, '2026-09-08 08:00:00'); + $newest = $file($form, 'vehicle_one', false, '2026-09-09 08:00:00'); + + // Someone else's inspection never shows, whatever the filter. + InspectionSubmission::create(['company_uuid' => 'company-other', 'inspection_form_uuid' => $form->uuid, 'driver_uuid' => 'driver-other', 'type' => 'dvir', 'status' => 'submitted', 'submitted_at' => '2026-09-10 08:00:00']); + + $ids = fn (Request $request) => array_column($controller->query($request)->resolve(), 'id'); + + expect($ids(Request::create('/v1/inspections', 'GET')))->toBe([$newest, $middle, $oldest]) + ->and($ids(Request::create('/v1/inspections', 'GET', ['driver' => 'driver_one', 'limit' => 2])))->toBe([$newest, $middle]) + ->and($ids(Request::create('/v1/inspections', 'GET', ['vehicle' => 'vehicle_two'])))->toBe([$middle]) + ->and($ids(Request::create('/v1/inspections', 'GET', ['type' => 'post_trip'])))->toBe([$middle]) + ->and($ids(Request::create('/v1/inspections', 'GET', ['result' => 'passed'])))->toBe([$oldest]) + ->and($ids(Request::create('/v1/inspections', 'GET', ['status' => 'resolved,needs_review'])))->toBe([]) + ->and($ids(Request::create('/v1/inspections', 'GET', ['limit' => 0])))->toBe([$newest, $middle, $oldest]); + + expect($controller->query(Request::create('/v1/inspections', 'GET', ['driver' => 'driver_other']))->getStatusCode())->toBe(404) + ->and($controller->query(Request::create('/v1/inspections', 'GET', ['vehicle' => 'vehicle_other']))->getStatusCode())->toBe(404); + + $shown = $controller->find($newest)->resolve(); + expect($shown['id'])->toBe($newest) + ->and($shown['item_results']->resolve())->toHaveCount(1) + ->and($shown['vehicle']->resolve()['id'])->toBe('vehicle_one') + ->and($shown['driver']->resolve()['id'])->toBe('driver_one') + ->and($shown['form']->resolve()['id'])->toBe($form->public_id) + ->and($shown['issue'])->toBeNull() + ->and($shown['work_order'])->toBeNull(); + + $byUuid = $controller->find(InspectionSubmission::query()->where('public_id', $oldest)->value('uuid'))->resolve(); + expect($byUuid['id'])->toBe($oldest); + + $foreign = InspectionSubmission::query()->where('company_uuid', 'company-other')->first(); + expect($controller->find($foreign->public_id)->getStatusCode())->toBe(404) + ->and($controller->find('inspection_submission_missing')->getData(true))->toBe(['error' => 'Inspection resource not found.']); + + $history = fn (string $vehicle, array $query = []) => array_column($controller->forVehicle(Request::create('/v1/vehicles/x/inspections', 'GET', $query), $vehicle)->resolve(), 'id'); + expect($history('vehicle_one'))->toBe([$newest, $oldest]) + ->and($history('vehicle-1', ['result' => 'failed']))->toBe([$newest]) + ->and($history('vehicle_one', ['limit' => 1]))->toBe([$newest]) + ->and($history('vehicle_two', ['type' => 'dvir']))->toBe([]) + ->and($controller->forVehicle(Request::create('/v1/vehicles/x/inspections', 'GET'), 'vehicle_other')->getStatusCode())->toBe(404); +}); + +test('inspection form resource keeps internal identifiers off the driver api', function () { + fleetOpsInspectionControllerDatabase(); + $form = fleetOpsInspectionControllerForm(['subject_type' => Vehicle::class, 'subject_uuid' => 'vehicle-1']); + + $resolved = (new InspectionFormResource($form->fresh()))->resolve(); + + expect($resolved['id'])->toBe($form->public_id) + ->and(array_keys($resolved))->not->toContain('uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'created_by_uuid', 'updated_by_uuid') + ->and($resolved['subject']['type'])->toBe('maintenance-subject-vehicle'); + + // A form bound to nothing, with the relation resolved as such: no subject + // to transform, and nothing to stamp a type onto. + $wide = fleetOpsInspectionControllerForm(); + $wide->setRelation('subject', null); + expect((new InspectionFormResource($wide))->resolve()['subject'])->toBeNull(); + + // `whenLoaded` answers a loaded-null relation itself, so the two helpers + // never see a missing subject through the resource. Reached directly, as + // the other morph resources are, so their guards are exercised. + $resource = new InspectionFormResource($wide); + $transform = new ReflectionMethod(InspectionFormResource::class, 'transformMorphResource'); + $stamp = new ReflectionMethod(InspectionFormResource::class, 'setSubjectType'); + $transform->setAccessible(true); + $stamp->setAccessible(true); + + expect($transform->invoke($resource, null))->toBeNull() + ->and($stamp->invoke($resource, null))->toBeNull() + ->and($stamp->invoke($resource, []))->toBe([]); +}); + +test('inspection link pin names its recipient, says why it cannot send, and builds its message', function () { + fleetOpsInspectionControllerDatabase(); + fleetOpsInspectionControllerDelivery(); + $form = fleetOpsInspectionControllerForm(); + + $forDriver = fleetOpsInspectionControllerLink($form, 'driver-token'); + $forAdmin = fleetOpsInspectionControllerLink($form, 'admin-token', ['assignee_uuid' => 'user-admin', 'driver_uuid' => null]); + $forNobody = fleetOpsInspectionControllerLink($form, 'open-token', ['driver_uuid' => null]); + + // The assignee first, else the driver's own account, else nobody. + $admin = Fleetbase\FleetOps\Support\InspectionLinkPin::recipientFor($forAdmin->fresh()); + $driver = Fleetbase\FleetOps\Support\InspectionLinkPin::recipientFor($forDriver->fresh()); + expect($admin->uuid)->toBe('user-admin') + ->and($driver->uuid)->toBe('user-driver') + ->and(Fleetbase\FleetOps\Support\InspectionLinkPin::recipientFor($forNobody->fresh()))->toBeNull(); + + $nameless = (new Fleetbase\Models\User())->forceFill(['name' => null, 'email' => null, 'phone' => null]); + expect(Fleetbase\FleetOps\Support\InspectionLinkPin::unavailableReason(null, 'email'))->toContain('Assign the link to someone') + ->and(Fleetbase\FleetOps\Support\InspectionLinkPin::unavailableReason($admin, 'sms'))->toBe('Avery Admin has no phone number to text the PIN to.') + ->and(Fleetbase\FleetOps\Support\InspectionLinkPin::unavailableReason($nameless, 'email'))->toBe('This person has no email address to send the PIN to.') + ->and(Fleetbase\FleetOps\Support\InspectionLinkPin::unavailableReason($driver, 'sms'))->toBeNull() + ->and(Fleetbase\FleetOps\Support\InspectionLinkPin::unavailableReason($driver, 'email'))->toBeNull(); + + // Enough of where it went to recognise, not enough to copy. + expect(Fleetbase\FleetOps\Support\InspectionLinkPin::maskEmail('dana@example.com'))->toBe('d•••@example.com') + ->and(Fleetbase\FleetOps\Support\InspectionLinkPin::maskPhone('+1 (555) 000-1111'))->toBe('•••1111'); + + // The text names the organisation, and carries the link when there is one. + expect(Fleetbase\FleetOps\Support\InspectionLinkPin::smsText($forDriver, '123456', 'https://console.test/x')) + ->toBe('Inspection Co: complete the Pre-trip DVIR inspection at https://console.test/x using PIN 123456. Do not share this PIN.') + ->and(Fleetbase\FleetOps\Support\InspectionLinkPin::smsText($forDriver, '123456')) + ->toBe('Inspection Co: your PIN for the Pre-trip DVIR inspection is 123456. Do not share this PIN.'); + + // A link's address is on the console's host; one minted before tokens were kept has none. + $kept = fleetOpsInspectionControllerLink($form, 'kept-token', ['token' => 'kept-token']); + expect(Fleetbase\FleetOps\Support\InspectionLinkPin::urlFor($kept->fresh()))->toBe('https://console.test/~/inspection?id=' . urlencode($form->public_id) . '&token=kept-token') + ->and(Fleetbase\FleetOps\Support\InspectionLinkPin::urlFor($forDriver->fresh()))->toBeNull(); +}); + +test('inspection link pin is emailed or texted, and says why when it is not', function () { + $connection = fleetOpsInspectionControllerDatabase(); + Carbon::setTestNow('2026-09-09 09:00:00'); + $fakes = fleetOpsInspectionControllerDelivery(); + $form = fleetOpsInspectionControllerForm(); + $link = fleetOpsInspectionControllerLink($form, 'good-token', ['token' => 'good-token']); + $send = fn (string $via) => Fleetbase\FleetOps\Support\InspectionLinkPin::send($link->fresh(), $via); + + // Nothing to send before the link has a PIN. + expect($send('email'))->toBe(['sent' => false, 'via' => 'email', 'to' => null, 'error' => 'This link has no PIN to send.']); + + $link->setPin('246810'); + $link->save(); + $url = 'https://console.test/~/inspection?id=' . urlencode($form->public_id) . '&token=good-token'; + + // By email: the link and the PIN, to the driver's account. + expect($send('email'))->toBe(['sent' => true, 'via' => 'email', 'to' => 'd•••@example.com', 'error' => null]) + ->and($fakes->mail)->toHaveCount(1) + ->and($fakes->to->uuid)->toBe('user-driver') + ->and($link->fresh()->pin_sent_via)->toBe('email') + ->and($link->fresh()->pin_sent_at->toDateTimeString())->toBe('2026-09-09 09:00:00'); + + $mail = $fakes->mail[0]; + expect($mail)->toBeInstanceOf(Fleetbase\FleetOps\Mail\InspectionLinkPinMail::class) + ->and($mail->envelope()->subject)->toBe('Complete the Pre-trip DVIR inspection'); + $content = $mail->content(); + expect($content->markdown)->toBe('fleetops::mail.inspection-link-pin') + ->and($content->with['pin'])->toBe('246810') + ->and($content->with['url'])->toBe($url) + ->and($content->with['recipient']->uuid)->toBe('user-driver') + ->and($content->with['form']->uuid)->toBe($form->uuid) + ->and($content->with['vehicle']->uuid)->toBe('vehicle-1') + ->and($content->with['sender']->uuid)->toBe('user-admin') + ->and($content->with['maxAttempts'])->toBe(InspectionLink::MAX_PIN_ATTEMPTS); + + // By SMS, from the organisation's alphanumeric sender when it has one. + $connection->table('companies')->where('uuid', 'company-insp')->update(['options' => json_encode(['alpha_numeric_sender_id_enabled' => true, 'alpha_numeric_sender_id' => 'InspCo'])]); + expect($send('sms'))->toBe(['sent' => true, 'via' => 'sms', 'to' => '•••1111', 'error' => null]) + ->and($fakes->sms[0]['to'])->toBe('+15550001111') + ->and($fakes->sms[0]['text'])->toBe('Inspection Co: complete the Pre-trip DVIR inspection at ' . $url . ' using PIN 246810. Do not share this PIN.') + ->and($fakes->sms[0]['options'])->toBe(['twilioParams' => ['from' => 'InspCo']]) + ->and($link->fresh()->pin_sent_via)->toBe('sms'); + + // Without the sender switched on, the provider's own number is used. + $connection->table('companies')->where('uuid', 'company-insp')->update(['options' => json_encode(['alpha_numeric_sender_id' => 'InspCo'])]); + $send('sms'); + expect($fakes->sms[1]['options'])->toBe([]); + + // A provider that refuses, and a delivery that fails in transit, are + // reported back rather than thrown. + $fakes->smsAnswer = ['success' => false, 'error' => 'Number is blocked']; + expect($send('sms'))->toBe(['sent' => false, 'via' => 'sms', 'to' => null, 'error' => 'The PIN could not be texted: Number is blocked']); + + $fakes->smsAnswer = null; + $fakes->smsFails = new RuntimeException('Twilio is down'); + expect($send('sms'))->toBe(['sent' => false, 'via' => 'sms', 'to' => null, 'error' => 'The PIN could not be sent: Twilio is down']) + ->and($fakes->reported)->toHaveCount(1); + + $fakes->mailFails = new RuntimeException('SMTP refused'); + expect($send('email')['error'])->toBe('The PIN could not be sent: SMTP refused'); + + // Someone with no phone cannot be texted. + $forAdmin = fleetOpsInspectionControllerLink($form, 'admin-token', ['assignee_uuid' => 'user-admin']); + $forAdmin->setPin('112233'); + $forAdmin->save(); + expect(Fleetbase\FleetOps\Support\InspectionLinkPin::send($forAdmin->fresh(), 'sms')['error'])->toBe('Avery Admin has no phone number to text the PIN to.'); + + // A link whose organisation is gone still sends, with no sender option. + $orphan = fleetOpsInspectionControllerLink($form, 'orphan-token', ['company_uuid' => 'company-gone']); + $orphan->setPin('998877'); + $orphan->save(); + $fakes->smsFails = null; + expect(Fleetbase\FleetOps\Support\InspectionLinkPin::send($orphan->fresh(), 'sms')['sent'])->toBeTrue() + ->and(end($fakes->sms)['options'])->toBe([]); +}); + +test('internal inspection form controller assigns links and sends their pin', function () { + $connection = fleetOpsInspectionControllerDatabase(); + Carbon::setTestNow('2026-09-09 09:00:00'); + $fakes = fleetOpsInspectionControllerDelivery(); + + foreach (['user-admin', 'user-driver'] as $user) { + $connection->table('company_users')->insert(['uuid' => 'cu-' . $user, 'company_uuid' => 'company-insp', 'user_uuid' => $user, 'status' => 'active']); + } + $connection->table('users')->insert(['uuid' => 'user-outsider', 'public_id' => 'user_outsider', 'company_uuid' => 'company-other', 'name' => 'Olly Outsider', 'email' => 'olly@example.com', 'type' => 'user']); + $connection->table('company_users')->insert(['uuid' => 'cu-outsider', 'company_uuid' => 'company-other', 'user_uuid' => 'user-outsider', 'status' => 'active']); + + $controller = new InspectionFormController(); + $form = fleetOpsInspectionControllerForm(); + $mint = fn (array $input) => $controller->generateLink(fleetOpsInspectionControllerInternalRequest('POST', $input), $form->public_id); + + // Assigned to someone in the organisation, and emailed the link and PIN. + $assigned = $mint(['assignee' => 'user_admin', 'pin_delivery' => 'email'])->getData(true); + $link = InspectionLink::query()->where('public_id', $assigned['link']['id'])->first(); + expect($assigned['link']['assignee'])->toBe(['id' => 'user_admin', 'name' => 'Avery Admin']) + ->and($assigned['link']['has_pin'])->toBeTrue() + ->and($assigned['link']['pin'])->toMatch('/^\d{6}$/') + ->and($link->pin)->toBe($assigned['link']['pin']) + ->and(password_verify($assigned['link']['pin'], $link->pin_hash))->toBeTrue() + ->and($assigned['link']['recipient'])->toBe(['name' => 'Avery Admin']) + ->and($assigned['link']['can_send_pin'])->toBe(['email' => true, 'sms' => false]) + ->and($assigned['link']['pin_sent_via'])->toBe('email') + ->and($assigned['pin_delivery'])->toBe(['sent' => true, 'via' => 'email', 'to' => 'a•••@example.com', 'error' => null]) + ->and($fakes->mail)->toHaveCount(1); + + // A delivery that cannot happen is refused before anything is minted. + $before = InspectionLink::query()->count(); + $nobody = $mint(['pin_delivery' => 'sms']); + $noPhone = $mint(['assignee' => 'user_admin', 'pin_delivery' => 'sms']); + expect($nobody->getStatusCode())->toBe(422) + ->and($nobody->getData(true)['error'])->toContain('Assign the link to someone') + ->and($noPhone->getStatusCode())->toBe(422) + ->and($noPhone->getData(true)['error'])->toBe('Avery Admin has no phone number to text the PIN to.') + ->and(InspectionLink::query()->count())->toBe($before); + + // Not sent at all: the PIN comes back to be shared by hand. + $byHand = $mint(['driver' => 'driver_one'])->getData(true); + expect($byHand['pin_delivery'])->toBeNull() + ->and($byHand['link']['pin'])->toMatch('/^\d{6}$/') + ->and($byHand['link']['recipient'])->toBe(['name' => 'Dana Driver']) + ->and($byHand['link']['can_send_pin'])->toBe(['email' => true, 'sms' => true]); + + // Nobody outside the organisation can be assigned. + expect(fn () => $mint(['assignee' => 'user_outsider']))->toThrow(ModelNotFoundException::class); + + // Sent again from the link list. + $send = fn (InspectionLink $to, string $via) => $controller->sendPin(fleetOpsInspectionControllerInternalRequest('POST', ['via' => $via]), $form->public_id, $to->public_id); + $driverLink = InspectionLink::query()->where('public_id', $byHand['link']['id'])->first(); + $resent = $send($driverLink, 'sms')->getData(true); + expect($resent['status'])->toBe('ok') + ->and($resent['message'])->toBe('PIN sent.') + ->and($resent['pin_delivery']['to'])->toBe('•••1111') + ->and($resent['link']['pin_sent_via'])->toBe('sms'); + + $fakes->mailFails = new RuntimeException('SMTP refused'); + $failed = $send($driverLink, 'email')->getData(true); + expect($failed['status'])->toBe('error') + ->and($failed['message'])->toBe('The PIN could not be sent: SMTP refused'); + + // Nobody who can receive it, or a link no longer in use, is refused. + expect($send($link, 'sms')->getStatusCode())->toBe(422); + $link->revoke(); + $revoked = $send($link->fresh(), 'email'); + expect($revoked->getStatusCode())->toBe(422) + ->and($revoked->getData(true)['error'])->toBe('Only an active link can have its PIN sent.'); +}); + +test('internal inspection form controller lists and revokes the links minted for a form', function () { + $connection = fleetOpsInspectionControllerDatabase(); + Carbon::setTestNow('2026-09-09 12:00:00'); + $controller = new InspectionFormController(); + $form = fleetOpsInspectionControllerForm(); + $other = fleetOpsInspectionControllerForm(['name' => 'Another form']); + + $active = fleetOpsInspectionControllerLink($form, 'active-token'); + $expired = fleetOpsInspectionControllerLink($form, 'expired-token', ['expires_at' => '2026-09-09 11:00:00']); + $used = fleetOpsInspectionControllerLink($form, 'used-token', ['used_at' => '2026-09-09 10:00:00']); + fleetOpsInspectionControllerLink($other, 'other-token'); + foreach ([[$active, '2026-09-09 09:00:00'], [$expired, '2026-09-09 08:00:00'], [$used, '2026-09-09 07:00:00']] as [$made, $at]) { + $connection->table('inspection_links')->where('uuid', $made->uuid)->update(['created_at' => $at]); + } + + // Newest first, only this form's, each saying whether it still works. + $listed = $controller->links(fleetOpsInspectionControllerInternalRequest('GET', ['limit' => 10]), $form->public_id)->getData(true)['links']; + expect(array_column($listed, 'id'))->toBe([$active->public_id, $expired->public_id, $used->public_id]) + ->and(array_column($listed, 'state'))->toBe(['active', 'expired', 'used']) + ->and($controller->links(fleetOpsInspectionControllerInternalRequest('GET', ['limit' => 1]), $form->public_id)->getData(true)['links'])->toHaveCount(1); + + // Revoked by its public id, or by the numeric id an older list handed out. + $revoked = $controller->revokeLink(Request::create('/', 'DELETE'), $form->public_id, $active->public_id)->getData(true); + expect($revoked['status'])->toBe('ok') + ->and($revoked['link']['state'])->toBe('revoked') + ->and($active->fresh()->status)->toBe('revoked'); + + $numericId = (string) $connection->table('inspection_links')->where('uuid', $expired->uuid)->value('id'); + $byNumber = $controller->revokeLink(Request::create('/', 'DELETE'), $form->public_id, $numericId)->getData(true); + expect($byNumber['link']['id'])->toBe($expired->public_id) + ->and($expired->fresh()->status)->toBe('revoked'); + + expect(fn () => $controller->revokeLink(Request::create('/', 'DELETE'), $form->public_id, 'inspection_link_missing'))->toThrow(ModelNotFoundException::class); +}); + +test('public inspection link asks for its pin, counts wrong ones, and locks', function () { + fleetOpsInspectionControllerDatabase(); + $controller = new PublicInspectionController(); + $form = fleetOpsInspectionControllerForm(); + $link = fleetOpsInspectionControllerLink($form, 'pin-token', ['assignee_uuid' => 'user-admin']); + $link->setPin('135790'); + $link->save(); + + $show = fn (array $server = [], array $query = []) => $controller->show( + Request::create('/public/inspections/forms/x', 'GET', array_merge(['token' => 'pin-token'], $query), [], [], $server), + $form->public_id + ); + + $missing = fleetOpsInspectionControllerRefusal(fn () => $show()); + expect($missing->getStatusCode())->toBe(403) + ->and($missing->getData(true))->toBe(['error' => 'Enter the PIN you were given with this link.', 'pin_required' => true]); + + $wrong = fleetOpsInspectionControllerRefusal(fn () => $show(['HTTP_X_INSPECTION_PIN' => '000000'])); + expect($wrong->getStatusCode())->toBe(403) + ->and($wrong->getData(true))->toBe(['error' => 'That PIN is not right.', 'pin_required' => true, 'attempts_left' => 4]); + + // The right PIN, as a header or a field, opens the form and clears the count. + $opened = $show(['HTTP_X_INSPECTION_PIN' => '135790'])->getData(true); + expect($opened['identity']['assignee'])->toBe(['id' => 'user_admin', 'name' => 'Avery Admin']) + ->and($link->fresh()->pin_attempts)->toBe(0) + ->and($show([], ['pin' => '135-790'])->getStatusCode())->toBe(200); + + // The fifth wrong PIN in a row locks the link, and it stays locked. + foreach (range(1, InspectionLink::MAX_PIN_ATTEMPTS - 1) as $attempt) { + fleetOpsInspectionControllerRefusal(fn () => $show(['HTTP_X_INSPECTION_PIN' => '000000'])); + } + $locked = fleetOpsInspectionControllerRefusal(fn () => $show(['HTTP_X_INSPECTION_PIN' => '000000'])); + expect($locked->getStatusCode())->toBe(403) + ->and($locked->getData(true)['locked'])->toBeTrue() + ->and($link->fresh()->status)->toBe('locked'); + + $stillLocked = fleetOpsInspectionControllerRefusal(fn () => $show(['HTTP_X_INSPECTION_PIN' => '135790'])); + expect($stillLocked->getStatusCode())->toBe(403) + ->and($stillLocked->getData(true)['locked'])->toBeTrue(); +}); + +test('public inspection link stores a photo through the link, and refuses what it cannot keep', function () { + $connection = fleetOpsInspectionControllerDatabase(); + $root = fleetOpsInspectionControllerDisk(); + $controller = new PublicInspectionController(); + $form = fleetOpsInspectionControllerForm(); + $link = fleetOpsInspectionControllerLink($form, 'good-token'); + $upload = fn (array $input = []) => $controller->upload( + Request::create('/public/inspections/forms/x/files', 'POST', array_merge(['token' => 'good-token'], $input), [], ['file' => fleetOpsInspectionControllerPhoto()]), + $form->public_id + ); + + // Named photo.php by the device, stored as the PNG its bytes say it is. + $stored = $upload(['type' => 'inspection_signature'])->getData(true)['file']; + $file = Fleetbase\Models\File::query()->where('public_id', $stored['id'])->first(); + expect($stored['filename'])->toBe('photo.php') + ->and($stored['content_type'])->toBe('image/png') + ->and($file->path)->toStartWith('inspections/links/' . $link->uuid . '/') + ->and($file->path)->toEndWith('.png') + ->and($stored['url'])->toBe('https://files.test/storage/' . $file->path) + ->and(is_file($root . '/' . $file->path))->toBeTrue() + ->and($file->company_uuid)->toBe('company-insp') + ->and($file->uploader_uuid)->toBe('user-driver') + ->and($file->type)->toBe('inspection_signature') + ->and(data_get($file->meta, 'inspection_link_uuid'))->toBe($link->uuid); + + // A disk that will not take the file says so, and nothing is recorded. + $files = Fleetbase\Models\File::query()->count(); + app()->instance(Illuminate\Contracts\Filesystem\Factory::class, new class { + public function disk($name = null) + { + return new class { + public function putFileAs($path, $file, $name = null, $options = []) + { + return false; + } + }; + } + }); + $failed = fleetOpsInspectionControllerRefusal(fn () => $upload()); + expect($failed->getStatusCode())->toBe(500) + ->and($failed->getData(true)['error'])->toBe('This photo could not be stored.') + ->and(Fleetbase\Models\File::query()->count())->toBe($files); + + // One link can take only so many files. + foreach (range(1, 40) as $n) { + $connection->table('files')->insert(['uuid' => 'cap-' . $n, 'public_id' => 'file_cap' . $n, 'company_uuid' => 'company-insp', 'meta' => json_encode(['inspection_link_uuid' => $link->uuid])]); + } + $capped = fleetOpsInspectionControllerRefusal(fn () => $upload()); + expect($capped->getStatusCode())->toBe(422) + ->and($capped->getData(true)['error'])->toBe('This inspection link has reached its upload limit.'); +}); + +test('public inspection link claims a single-use link once, and marks a reusable one used', function () { + $connection = fleetOpsInspectionControllerDatabase(); + Carbon::setTestNow('2026-09-09 08:30:00'); + $controller = new PublicInspectionController(); + $form = fleetOpsInspectionControllerForm(); + InspectionFormSync::convertLegacyItems($form); + $body = fn (string $token) => ['token' => $token, 'custom_field_values' => [ + ['custom_field' => 'brakes', 'value_type' => 'object', 'value' => ['passed' => true]], + ['custom_field' => 'lights', 'value_type' => 'object', 'value' => ['passed' => true]], + ]]; + + // A reusable link files, and is marked as used without being spent. + $reusable = fleetOpsInspectionControllerLink($form, 'reuse-token', ['single_use' => false]); + $controller->submit(Request::create('/x', 'POST', $body('reuse-token'), [], [], ['REMOTE_ADDR' => '198.51.100.4']), $form->public_id); + expect($reusable->fresh()->used_at->toDateTimeString())->toBe('2026-09-09 08:30:00') + ->and($reusable->fresh()->used_ip)->toBe('198.51.100.4') + ->and($reusable->fresh()->isUsable())->toBeTrue(); + + // Two submits at once: the other took the link between this one's check + // and its claim, so this one is refused and files nothing. + $raced = fleetOpsInspectionControllerLink($form, 'race-token'); + $taken = false; + InspectionLink::retrieved(function (InspectionLink $retrieved) use ($connection, $raced, &$taken) { + if (!$taken && $retrieved->uuid === $raced->uuid) { + $taken = true; + $connection->table('inspection_links')->where('uuid', $raced->uuid)->update(['used_at' => '2026-09-09 08:29:59']); + } + }); + $before = InspectionSubmission::query()->count(); + $refused = fleetOpsInspectionControllerRefusal(fn () => $controller->submit(Request::create('/x', 'POST', $body('race-token')), $form->public_id)); + expect($refused->getStatusCode())->toBe(409) + ->and($refused->getData(true)['error'])->toBe('This inspection link has already been used.') + ->and(InspectionSubmission::query()->count())->toBe($before); +}); + +test('public inspection routes answer in json whatever the client accepts', function () { + // The platform's fetch service asks for anything, which on its own would + // have a refused request redirected rather than answered in JSON. + $untouched = Request::create('/public/inspections/forms/x/submit', 'POST', [], [], [], ['HTTP_ACCEPT' => '*/*']); + $forced = Request::create('/public/inspections/forms/x/submit', 'POST', [], [], [], ['HTTP_ACCEPT' => '*/*']); + + $accept = (new Fleetbase\FleetOps\Http\Middleware\ForceJsonResponse())->handle($forced, fn (Request $forwarded) => $forwarded->headers->get('Accept')); + + expect($untouched->expectsJson())->toBeFalse() + ->and($accept)->toBe('application/json') + ->and($forced->expectsJson())->toBeTrue(); +}); diff --git a/server/tests/InspectionFieldContractsTest.php b/server/tests/InspectionFieldContractsTest.php new file mode 100644 index 000000000..e5464351f --- /dev/null +++ b/server/tests/InspectionFieldContractsTest.php @@ -0,0 +1,1004 @@ + str_replace('_', ' ', Illuminate\Support\Str::snake((string) $value))); +} + +if (!class_exists('Fleetbase\\Http\\Requests\\ExportRequest', false)) { + eval('namespace Fleetbase\\Http\\Requests; class ExportRequest extends \\Illuminate\\Http\\Request {}'); +} + +class FleetOpsInspectionExportRequestFake extends ExportRequest +{ +} + +/** + * The submission controller with the download intercepted: what is asserted + * is the sheet it asked for, not the workbook the spreadsheet library builds. + */ +class FleetOpsInspectionSubmissionControllerProbe extends InspectionSubmissionController +{ + public array $downloads = []; + + protected function downloadExport(InspectionExport $export, string $fileName) + { + $this->downloads[] = [$export, $fileName]; + + return ['download' => $fileName, 'headings' => $export->headings()]; + } +} + +/** Stands in for the route a resource asks whether it is answering. */ +class FleetOpsInspectionFieldRouteFixture +{ + public function __construct(private string $uri) + { + } + + public function uri(): string + { + return $this->uri; + } +} + +/** Binds the request a resource reads to decide public from internal. */ +function fleetOpsInspectionFieldRequest(bool $internal): Request +{ + $uri = $internal ? 'api/int/v1/fleet-ops/inspection-submissions' : 'v1/inspections'; + $request = Request::create('/' . $uri, 'GET'); + $request->setRouteResolver(fn () => new FleetOpsInspectionFieldRouteFixture($uri)); + app()->instance('request', $request); + + return $request; +} + +/** + * A form built from fields, and a submission that answers it, against an + * in-memory database. Every column is a nullable string: what is asserted + * here is what the models, the writer and the resources put in and take out. + */ +function fleetOpsInspectionFieldDatabase(): SQLiteConnection +{ + $pdo = new PDO('sqlite::memory:'); + + // A failed inspection raises an issue, and `issues.location` is spatial: + // MySQL answers with a 4-byte SRID followed by the geometry's WKB, which is + // what the spatial trait parses when the row is read back. + $asStoredPoint = function ($wkt, $srid = 0, $axisOrder = null) { + if (!preg_match('/POINT\s*\(\s*(-?[\d.]+)\s+(-?[\d.]+)\s*\)/i', (string) $wkt, $pair)) { + return $wkt; + } + + // WKB: little-endian marker, geometry type 1 (point), then x (lng) and y (lat). + return pack('V', (int) $srid) . pack('C', 1) . pack('V', 1) . pack('d', (float) $pair[1]) . pack('d', (float) $pair[2]); + }; + $pdo->sqliteCreateFunction('ST_PointFromText', $asStoredPoint); + $pdo->sqliteCreateFunction('ST_GeomFromText', $asStoredPoint); + + $connection = new SQLiteConnection($pdo); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + EloquentModel::setEventDispatcher(new Dispatcher()); + EloquentModel::clearBootedModels(); + + $root = sys_get_temp_dir() . '/fleetops-inspection-files'; + $config = new Repository([ + 'activitylog' => ['enabled' => false, 'default_auth_driver' => null, 'default_log_name' => 'default'], + 'api' => ['cache' => ['enabled' => false]], + // Not the `local` disk: a file on that one is answered through the + // foundation's `asset()` helper, which has no app to ask here. + 'filesystems' => [ + 'default' => 'uploads', + 'disks' => ['uploads' => ['driver' => 'local', 'root' => $root, 'url' => 'https://files.example.com']], + ], + ]); + app()->instance('config', $config); + app()->instance(Illuminate\Contracts\Config\Repository::class, $config); + app()->instance(Spatie\Activitylog\CauserResolver::class, new class extends Spatie\Activitylog\CauserResolver { + public function __construct() + { + } + + public function resolve(EloquentModel|int|string|null $subject = null): ?EloquentModel + { + return null; + } + }); + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $connection) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->connection; + } + + public function __call($method, $arguments) + { + return $this->connection->{$method}(...$arguments); + } + }); + Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + app()->instance('db.schema', $connection->getSchemaBuilder()); + app()->instance('responsecache', new class { + public function __call($method, $arguments) + { + return null; + } + }); + app()->instance('request', Request::create('/')); + + // Files are real here: the store writes base64 through the platform's + // File model, and what it wrote is what the resource resolves back. + app()->instance('filesystem', new FilesystemManager(app())); + Illuminate\Support\Facades\Storage::clearResolvedInstances(); + Illuminate\Support\Facades\Storage::swap(app('filesystem')); + + $schema = $connection->getSchemaBuilder(); + $tables = [ + 'inspection_forms' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'description', 'type', 'status', 'subject_type', 'subject_uuid', 'items', 'settings', 'meta', 'published_at', 'created_by_uuid', 'updated_by_uuid'], + 'inspection_submissions' => ['uuid', 'public_id', '_key', 'company_uuid', 'inspection_form_uuid', 'vehicle_uuid', 'driver_uuid', 'submitted_by_uuid', 'issue_uuid', 'work_order_uuid', 'type', 'status', 'result', 'source', 'odometer', 'engine_hours', 'total_items', 'failed_items', 'started_at', 'submitted_at', 'resolved_at', 'location', 'signature', 'attachments', 'meta', 'created_by_uuid', 'updated_by_uuid'], + 'inspection_item_results' => ['uuid', '_key', 'company_uuid', 'inspection_submission_uuid', 'issue_uuid', 'work_order_uuid', 'item_key', 'label', 'category', 'status', 'severity', 'passed', 'comments', 'photos', 'meta', 'created_by_uuid', 'updated_by_uuid'], + 'issues' => ['uuid', 'public_id', '_key', 'company_uuid', 'reported_by_uuid', 'assigned_to_uuid', 'vehicle_uuid', 'driver_uuid', 'order_uuid', 'issue_id', 'location', 'category', 'type', 'report', 'title', 'tags', 'priority', 'meta', 'resolved_at', 'status'], + 'work_orders' => ['uuid', 'public_id', '_key', 'company_uuid', 'schedule_uuid', 'code', 'subject', 'category', 'status', 'priority', 'target_type', 'target_uuid', 'assignee_type', 'assignee_uuid', 'opened_at', 'due_at', 'closed_at', 'instructions', 'checklist', 'currency', 'estimated_cost', 'approved_budget', 'actual_cost', 'cost_center', 'budget_code', 'meta', 'created_by_uuid', 'updated_by_uuid'], + 'vehicles' => ['uuid', 'public_id', 'internal_id', '_key', 'company_uuid', 'vendor_uuid', 'photo_uuid', 'name', 'make', 'model', 'year', 'trim', 'plate_number', 'vin', 'status', 'currency', 'slug', 'online', 'location'], + 'drivers' => ['uuid', 'public_id', 'internal_id', '_key', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'vendor_uuid', 'current_job_uuid', 'photo_uuid', 'status', 'online', 'location', 'slug'], + 'users' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'email', 'phone', 'avatar_uuid', 'type', 'status'], + 'companies' => ['uuid', 'public_id', '_key', 'name', 'owner_uuid'], + 'company_users' => ['uuid', '_key', 'company_uuid', 'user_uuid', 'role_uuid', 'status'], + 'settings' => ['key', 'value'], + 'files' => ['uuid', 'public_id', '_key', 'company_uuid', 'uploader_uuid', 'subject_uuid', 'subject_type', 'path', 'disk', 'bucket', 'folder', 'etag', 'meta', 'original_filename', 'type', 'content_type', 'file_size', 'slug', 'caption'], + 'vendors' => ['uuid', 'public_id', '_key', 'company_uuid', 'name'], + 'orders' => ['uuid', 'public_id', '_key', 'company_uuid', 'driver_assigned_uuid', 'status'], + 'positions' => ['uuid', 'public_id', '_key', 'company_uuid', 'subject_uuid', 'subject_type', 'coordinates'], + 'custom_field_values' => ['uuid', 'company_uuid', 'custom_field_uuid', 'subject_uuid', 'subject_type', 'value', 'value_type'], + 'custom_fields' => ['uuid', 'company_uuid', 'category_uuid', 'subject_uuid', 'subject_type', 'name', 'label', 'type', 'for', 'component', 'options', 'required', 'editable', 'default_value', 'validation_rules', 'meta', 'description', 'help_text', 'order'], + 'categories' => ['uuid', 'public_id', '_key', 'company_uuid', 'owner_uuid', 'owner_type', 'parent_uuid', 'icon_file_uuid', 'internal_id', 'name', 'description', 'translations', 'meta', 'tags', 'icon', 'icon_color', 'slug', 'order', 'for', 'core_category'], + 'activity_log' => ['uuid', 'company_uuid', 'log_name', 'description', 'subject_type', 'subject_id', 'causer_type', 'causer_id', 'properties', 'event', 'batch_uuid'], + ]; + + foreach ($tables as $table => $columns) { + $schema->create($table, function ($blueprint) use ($columns) { + $blueprint->increments('id'); + foreach ($columns as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + $connection->table('companies')->insert(['uuid' => 'company-insp', 'public_id' => 'company_insp', 'name' => 'Inspection Co']); + $connection->table('users')->insert(['uuid' => 'user-driver', 'public_id' => 'user_driver', 'company_uuid' => 'company-insp', 'name' => 'Dana Driver', 'email' => 'dana@example.com', 'type' => 'user']); + $connection->table('vehicles')->insert(['uuid' => 'vehicle-1', 'public_id' => 'vehicle_one', 'company_uuid' => 'company-insp', 'name' => 'Truck 7', 'plate_number' => 'TRK-7']); + $connection->table('drivers')->insert(['uuid' => 'driver-1', 'public_id' => 'driver_one', 'company_uuid' => 'company-insp', 'user_uuid' => 'user-driver', 'vehicle_uuid' => 'vehicle-1', 'status' => 'active']); + + session(['company' => 'company-insp', 'user' => 'user-driver']); + + return $connection; +} + +/** The draft the console builder posts: two groups of typed fields. */ +function fleetOpsInspectionFieldDraft(): array +{ + return [ + [ + 'name' => 'Exterior', + 'order' => 1, + 'meta' => ['grid_size' => 2], + 'fields' => [ + ['label' => 'Mirrors', 'name' => 'mirrors', 'type' => 'pass-fail', 'required' => true, 'order' => 1, 'meta' => ['severity' => 'medium', 'require_comment_on_fail' => true, 'unsafe_on_fail' => false]], + ['label' => 'Brakes', 'name' => 'brakes', 'type' => 'pass-fail', 'required' => true, 'order' => 2, 'meta' => ['severity' => 'critical', 'require_photo_on_fail' => true, 'require_comment_on_fail' => true, 'unsafe_on_fail' => true]], + ], + ], + [ + 'name' => 'Meter and sign-off', + 'order' => 2, + 'meta' => ['grid_size' => 1], + // `customFields` is the fliit builder's spelling; both are read. + 'customFields' => [ + ['label' => 'Odometer', 'type' => 'number', 'order' => 1, 'meta' => ['unit' => 'km', 'role' => 'odometer']], + ['label' => 'Notes', 'type' => 'textarea', 'order' => 2], + ['label' => 'Sign here', 'name' => 'signature', 'type' => 'signature', 'order' => 3], + ['label' => 'Tail lift photo', 'name' => 'tail_lift', 'type' => 'file-upload', 'order' => 4], + ['label' => 'Trailer attached', 'name' => 'trailer', 'type' => 'boolean', 'order' => 5], + ['label' => 'Fuel level', 'name' => 'fuel', 'type' => 'radio-button', 'options' => ['full', 'half'], 'order' => 6], + ], + ], + ]; +} + +function fleetOpsInspectionFieldForm(array $attributes = []): InspectionForm +{ + return InspectionForm::create(array_merge([ + 'company_uuid' => 'company-insp', + 'name' => 'Pre-trip DVIR', + 'type' => 'dvir', + 'status' => 'published', + 'published_at' => '2026-09-01 08:00:00', + 'settings' => ['create_issue_on_failure' => false, 'create_work_order_on_failure' => false], + ], $attributes)); +} + +function fleetOpsInspectionFieldSubmission(InspectionForm $form, array $attributes = []): InspectionSubmission +{ + return InspectionSubmission::create(array_merge([ + 'company_uuid' => 'company-insp', + 'inspection_form_uuid' => $form->uuid, + 'vehicle_uuid' => 'vehicle-1', + 'driver_uuid' => 'driver-1', + 'submitted_by_uuid' => 'user-driver', + 'type' => 'dvir', + 'status' => 'draft', + 'source' => 'navigator', + ], $attributes)); +} + +/** A one-pixel PNG, as the app sends one. */ +function fleetOpsInspectionFieldPhoto(): string +{ + return base64_encode(base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', true)); +} + +afterEach(function () { + Carbon::setTestNow(); +}); + +test('the form writer builds groups of typed fields and prunes what a later draft drops', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + + $written = InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + + expect($written['groups'])->toHaveCount(2) + ->and($written['fields'])->toHaveCount(8); + + $groups = $form->fieldGroups; + expect($groups->pluck('name')->all())->toBe(['Exterior', 'Meter and sign-off']) + ->and($groups->first()->for)->toBe(InspectionForm::GROUP_FOR) + ->and($groups->first()->owner_uuid)->toBe($form->uuid) + ->and($groups->first()->meta)->toBe(['grid_size' => 2]) + ->and($groups->first()->public_id)->toStartWith('category_') + ->and($groups->first()->slug)->toBe('exterior'); + + $fields = $form->fields->keyBy('name'); + expect($fields->keys()->sort()->values()->all())->toBe(['brakes', 'fuel', 'mirrors', 'notes', 'odometer', 'signature', 'tail-lift', 'trailer']) + ->and($fields['brakes']->for)->toBe(InspectionForm::FIELD_FOR) + ->and($fields['brakes']->subject_uuid)->toBe($form->uuid) + ->and($fields['brakes']->category_uuid)->toBe($groups->first()->uuid) + ->and($fields['brakes']->required)->toBeTrue() + ->and($fields['brakes']->editable)->toBeTrue() + ->and($fields['brakes']->component)->toBe('pass-fail') + // A field named only by its label takes a slug of the label. + ->and($fields['odometer']->label)->toBe('Odometer') + ->and($fields['odometer']->meta)->toBe(['unit' => 'km', 'role' => 'odometer']) + // `radio-button` is the type; the platform's component is named differently. + ->and($fields['fuel']->component)->toBe('radio-button-select') + ->and($fields['fuel']->options)->toBe(['full', 'half']); + + // A custom field the console's generic panel added to the form record has + // the same subject; the builder must not take it for one of its own. + CustomField::create(['company_uuid' => 'company-insp', 'subject_uuid' => $form->uuid, 'subject_type' => $form->getMorphClass(), 'name' => 'depot', 'label' => 'Depot', 'type' => 'text']); + + // A second post with the same uuids updates rather than duplicates, and + // what it no longer lists is deleted. + $draft = fleetOpsInspectionFieldDraft(); + $draft[0]['uuid'] = $groups->first()->uuid; + $draft[0]['name'] = 'Exterior walk-around'; + $draft[0]['fields'][0]['uuid'] = $fields['mirrors']->uuid; + $draft[0]['fields'][0]['label'] = 'Mirrors and glass'; + unset($draft[1]); + + InspectionFormSync::sync($form, $draft, true); + + $form->unsetRelation('fieldGroups'); + $form->unsetRelation('fields'); + expect($form->fieldGroups)->toHaveCount(1) + ->and($form->fieldGroups->first()->name)->toBe('Exterior walk-around') + ->and($form->fields)->toHaveCount(2) + ->and($form->fields->firstWhere('uuid', $fields['mirrors']->uuid)->label)->toBe('Mirrors and glass') + // Two inspection fields, plus the unrelated one, left where it was. + ->and(CustomField::query()->count())->toBe(3) + ->and(CustomField::query()->where('name', 'depot')->exists())->toBeTrue() + ->and(Category::query()->count())->toBe(1); +}); + +test('the form writer gives a nameless, typeless field somewhere to go', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + + InspectionFormSync::sync($form, [['fields' => [['type' => 'nonsense'], []]]]); + + $group = $form->fieldGroups->first(); + expect($group->name)->toBeNull() + ->and($group->order)->toBe('1') + ->and($form->fields->pluck('label')->all())->toBe(['Untitled field', 'Untitled field']) + ->and($form->fields->pluck('type')->all())->toBe(['input', 'input']) + ->and(InspectionFormSync::componentFor('signature'))->toBe('signature'); +}); + +test('a first-cut checklist becomes a Checklist group of pass-fail fields, once', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(['items' => [ + ['key' => 'brakes', 'label' => 'Brakes', 'category' => 'Safety', 'severity' => 'critical'], + ['title' => 'Lights', 'severity' => 'medium', 'required' => false, 'description' => 'All round'], + [], + ]]); + + expect(InspectionFormSync::convertLegacyItems($form))->toBe(3); + + $group = $form->fresh()->fieldGroups->first(); + expect($group->name)->toBe('Checklist') + ->and($group->meta)->toBe(['grid_size' => 1, 'converted_from_items' => true]); + + $fields = $form->fields()->get()->keyBy('name'); + expect($fields['brakes']->type)->toBe('pass-fail') + ->and($fields['brakes']->required)->toBeTrue() + ->and($fields['brakes']->meta['severity'])->toBe('critical') + ->and($fields['brakes']->meta['category'])->toBe('Safety') + ->and($fields['brakes']->meta['unsafe_on_fail'])->toBeTrue() + ->and($fields['lights']->required)->toBeFalse() + ->and($fields['lights']->description)->toBe('All round') + ->and($fields['lights']->meta['unsafe_on_fail'])->toBeFalse() + ->and($fields['item-3']->label)->toBe('Item 3'); + + // Idempotent: a form already built with fields is left alone, and so is a + // form with nothing to convert. + expect(InspectionFormSync::convertLegacyItems($form->fresh()))->toBe(0) + ->and(InspectionFormSync::convertLegacyItems(fleetOpsInspectionFieldForm()))->toBe(0) + ->and(CustomField::query()->count())->toBe(3); +}); + +test('a form answers with its groups in order, ungrouped fields last', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + + // A group and a field with no order of their own sort after the ones that + // have one, oldest first; a field belonging to no group is gathered up. + Category::query()->where('name', 'Exterior')->update(['order' => null]); + CustomField::query()->where('name', 'brakes')->update(['category_uuid' => null, 'order' => null]); + + $form->unsetRelation('fieldGroups'); + $form->unsetRelation('fields'); + $groups = $form->grouped_fields; + + expect(collect($groups)->pluck('name')->all())->toBe(['Meter and sign-off', 'Exterior', 'Ungrouped']) + ->and($groups[2]->exists)->toBeFalse() + ->and($groups[2]->getRelation('fields')->pluck('name')->all())->toBe(['brakes']) + ->and($groups[1]->getRelation('fields')->pluck('name')->all())->toBe(['mirrors']) + ->and($form->item_count)->toBe(8); + + // The count falls back to the legacy checklist only while there are no fields. + $legacy = fleetOpsInspectionFieldForm(['items' => [['key' => 'a', 'label' => 'A']]]); + expect($legacy->item_count)->toBe(1); +}); + +test('the form resource answers grouped_fields for the driver and identifiers for the console', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + + $public = (new InspectionFormResource($form->fresh()))->toArray(fleetOpsInspectionFieldRequest(false)); + + expect($public['id'])->toBe($form->public_id) + ->and($public['grouped_fields'])->toHaveCount(2) + ->and($public['grouped_fields'][0]['name'])->toBe('Exterior') + ->and($public['grouped_fields'][0]['order'])->toBe(1) + ->and($public['grouped_fields'][0]['meta'])->toBe(['grid_size' => 2]) + ->and($public['grouped_fields'][0])->not->toHaveKey('company_uuid'); + + $field = $public['grouped_fields'][0]['fields'][1]; + expect($field['id'])->toBe($field['uuid']) + ->and($field['name'])->toBe('brakes') + ->and($field['label'])->toBe('Brakes') + ->and($field['type'])->toBe('pass-fail') + ->and($field['required'])->toBeTrue() + ->and($field['editable'])->toBeTrue() + ->and($field['options'])->toBe([]) + ->and($field['order'])->toBe(2) + ->and($field['meta']['severity'])->toBe('critical') + ->and($field)->not->toHaveKey('category_uuid'); + + // A field written straight into the table carries no component; the type names one. + CustomField::query()->where('name', 'brakes')->update(['component' => null, 'meta' => null, 'order' => null, 'editable' => null]); + $bare = (new InspectionFormResource($form->fresh()))->toArray(fleetOpsInspectionFieldRequest(false))['grouped_fields'][0]['fields']; + $bare = collect($bare)->firstWhere('name', 'brakes'); + expect($bare['component'])->toBe('pass-fail') + ->and($bare['meta'])->toEqual((object) []) + ->and($bare['order'])->toBeNull() + ->and($bare['editable'])->toBeTrue(); + + $request = fleetOpsInspectionFieldRequest(true); + $internal = (new InspectionFormResource($form->fresh()->load(['fieldGroups', 'fields'])))->toArray($request); + expect($internal['grouped_fields'][0]['company_uuid'])->toBe('company-insp') + ->and($internal['grouped_fields'][0]['for'])->toBe(InspectionForm::GROUP_FOR) + ->and($internal['field_groups'])->toHaveCount(2) + ->and($internal['field_groups'][0])->not->toHaveKey('fields') + ->and($internal['fields'])->toHaveCount(8) + ->and($internal['fields'][0]['subject_uuid'])->toBe($form->uuid) + ->and($internal['fields'][0]['for'])->toBe(InspectionForm::FIELD_FOR); + + // A group with no fields loaded answers with none rather than reaching for them. + $orphan = Category::query()->where('name', 'Exterior')->first(); + expect(InspectionFormResource::groupToArray($orphan, false)['fields'])->toBe([]); +}); + +test('the file store turns what a driver sends into a platform file', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + $submission = fleetOpsInspectionFieldSubmission($form); + + $stored = InspectionFileStore::normalize(fleetOpsInspectionFieldPhoto(), $submission, InspectionFileStore::TYPE_PHOTO, 'user-driver'); + expect($stored)->toStartWith('file:'); + + $file = InspectionFileStore::resolve($stored); + expect($file)->toBeInstanceOf(File::class) + ->and($file->subject_uuid)->toBe($submission->uuid) + ->and($file->type)->toBe('inspection_photo') + ->and($file->company_uuid)->toBe('company-insp') + ->and($file->uploader_uuid)->toBe('user-driver') + ->and($file->content_type)->toBe('image/png') + ->and($file->path)->toStartWith('inspections/' . $submission->uuid . '/'); + + // A URL, or anything that is not a string at all, is left alone. A + // reference is kept only for a file the submission may use; one that + // names no file in its company is dropped rather than kept as given, + // because a kept reference is what the submission resource hands out. + expect(InspectionFileStore::normalize($stored, $submission))->toBe($stored) + ->and(InspectionFileStore::normalize('https://cdn.example.com/a.jpg', $submission))->toBe('https://cdn.example.com/a.jpg') + ->and(InspectionFileStore::normalize(' ', $submission))->toBe(' ') + ->and(InspectionFileStore::normalize(42, $submission))->toBe(42) + ->and(InspectionFileStore::normalize('not base64!', $submission))->toBe('not base64!') + ->and(InspectionFileStore::normalize($file->uuid, $submission))->toBe('file:' . $file->uuid) + ->and(InspectionFileStore::normalize($file->public_id, $submission))->toBe('file:' . $file->uuid) + ->and(InspectionFileStore::normalize('file_missing', $submission))->toBeNull() + // The console uploads a photo as soon as it is picked and keeps the + // reference the upload answered with, which names the file by its + // public id; that is rewritten to the uuid everything else reads. + ->and(InspectionFileStore::normalize('file:' . $file->public_id, $submission))->toBe('file:' . $file->uuid) + ->and(InspectionFileStore::normalize('file:file_missing', $submission))->toBeNull(); + + // A data URI carries its own content type; bare base64 is sniffed. + $jpeg = InspectionFileStore::resolve(InspectionFileStore::normalize('data:image/jpeg;base64,' . fleetOpsInspectionFieldPhoto(), $submission, InspectionFileStore::TYPE_SIGNATURE)); + expect($jpeg->content_type)->toBe('image/jpeg') + ->and($jpeg->type)->toBe('inspection_signature') + ->and($jpeg->path)->toEndWith('.jpg') + ->and($jpeg->uploader_uuid)->toBe('user-driver'); + + expect(InspectionFileStore::sniffContentType(base64_encode("\xFF\xD8\xFF" . str_repeat('a', 20))))->toBe('image/jpeg') + ->and(InspectionFileStore::sniffContentType(base64_encode('GIF89a' . str_repeat('a', 20))))->toBe('image/gif') + ->and(InspectionFileStore::sniffContentType(base64_encode('RIFF____WEBP' . str_repeat('a', 20))))->toBe('image/webp') + ->and(InspectionFileStore::sniffContentType(base64_encode('%PDF-1.4' . str_repeat('a', 20))))->toBe('application/pdf') + ->and(InspectionFileStore::sniffContentType(base64_encode(str_repeat('a', 24))))->toBe('image/png') + ->and(InspectionFileStore::extensionFor('image/gif'))->toBe('gif') + ->and(InspectionFileStore::extensionFor('image/webp'))->toBe('webp') + ->and(InspectionFileStore::extensionFor('application/pdf'))->toBe('pdf') + ->and(InspectionFileStore::extensionFor('image/svg+xml'))->toBe('svg') + ->and(InspectionFileStore::isBase64('data:image/png;base64,***'))->toBeFalse() + ->and(InspectionFileStore::isUrl('ftp://x'))->toBeFalse(); + + // A file uploaded before the submission existed is claimed by it. + $loose = File::create(['company_uuid' => 'company-insp', 'disk' => 'uploads', 'path' => 'loose.png', 'type' => 'inspection_photo']); + expect(InspectionFileStore::attachReferenced($submission, ['file:' . $loose->uuid, 'nonsense']))->toBe(0) + ->and(InspectionFileStore::attachReferenced($submission, [$loose->uuid]))->toBe(1) + ->and(InspectionFileStore::attachReferenced($submission, []))->toBe(0) + ->and($loose->fresh()->subject_uuid)->toBe($submission->uuid); + + expect(InspectionFileStore::referencedUuid('file:not-a-uuid'))->toBeNull() + ->and(InspectionFileStore::referencedUuid(null))->toBeNull() + ->and(InspectionFileStore::resolve('file:' . (string) Illuminate\Support\Str::uuid()))->toBeNull() + ->and(InspectionFileStore::project('https://cdn.example.com/a.jpg'))->toBe('https://cdn.example.com/a.jpg'); + + $projected = InspectionFileStore::project($stored); + expect($projected['id'])->toBe($file->public_id) + ->and($projected['content_type'])->toBe('image/png') + ->and($projected)->toHaveKeys(['id', 'url', 'filename', 'content_type']); +}); + +test('a submission answers a form of fields, and the item results follow', function () { + fleetOpsInspectionFieldDatabase(); + Carbon::setTestNow('2026-09-10 07:00:00'); + $form = fleetOpsInspectionFieldForm(); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + $fields = $form->fields->keyBy('name'); + $submission = fleetOpsInspectionFieldSubmission($form); + + InspectionSubmitter::applyCustomFieldValues($submission, [ + ['custom_field' => $fields['mirrors']->uuid, 'value_type' => 'object', 'value' => ['passed' => true, 'not_applicable' => false, 'severity' => null, 'comments' => null, 'photos' => [], 'unsafe' => false]], + ['custom_field' => $fields['brakes']->uuid, 'value_type' => 'object', 'value' => ['passed' => false, 'severity' => 'Critical', 'comments' => 'Soft pedal', 'photos' => [fleetOpsInspectionFieldPhoto()], 'unsafe' => true]], + // Named by its slug rather than its uuid, as an older build may. + ['custom_field' => 'odometer', 'value_type' => 'number', 'value' => '112480'], + ['custom_field' => $fields['notes']->uuid, 'value_type' => 'text', 'value' => 'Nearside mirror scuffed'], + ['custom_field' => $fields['signature']->uuid, 'value_type' => 'file', 'value' => fleetOpsInspectionFieldPhoto()], + ['custom_field' => $fields['trailer']->uuid, 'value_type' => 'boolean', 'value' => 'true'], + ['custom_field' => $fields['fuel']->uuid, 'value' => ['full']], + ], 'user-driver'); + + $submission = $submission->fresh(['itemResults', 'customFieldValues.customField', 'files']); + + expect($submission->customFieldValues)->toHaveCount(7) + ->and($submission->total_items)->toBe(2) + ->and($submission->failed_items)->toBe(1) + ->and($submission->result)->toBe('failed') + ->and($submission->status)->toBe('submitted') + ->and($submission->meta['unsafe'])->toBeTrue() + ->and($submission->files)->toHaveCount(2); + + $results = $submission->itemResults->keyBy('item_key'); + expect($results->keys()->sort()->values()->all())->toBe(['brakes', 'mirrors']) + ->and($results['brakes']->label)->toBe('Brakes') + ->and($results['brakes']->category)->toBe('Exterior') + ->and($results['brakes']->status)->toBe('failed') + ->and($results['brakes']->severity)->toBe('critical') + ->and($results['brakes']->passed)->toBeFalse() + ->and($results['brakes']->comments)->toBe('Soft pedal') + ->and($results['brakes']->photos[0])->toStartWith('file:') + ->and($results['brakes']->meta['unsafe'])->toBeTrue() + ->and($results['brakes']->meta['custom_field_uuid'])->toBe($fields['brakes']->uuid) + ->and($results['mirrors']->status)->toBe('passed') + ->and($results['mirrors']->severity)->toBeNull(); + + // The value column is a string, so a meter reading is stored as one; the + // resource is where it becomes a number again. + $odometer = $submission->customFieldValues->first(fn ($value) => $value->custom_field_uuid === $fields['odometer']->uuid); + expect($odometer->value)->toBe('112480') + ->and($odometer->value_type)->toBe('number'); + + // Answering again with the brakes passing and the mirrors not applicable + // rewrites the results rather than adding to them. + InspectionSubmitter::applyCustomFieldValues($submission, [ + ['custom_field' => $fields['mirrors']->uuid, 'value' => ['passed' => true, 'not_applicable' => true]], + ['custom_field' => $fields['brakes']->uuid, 'value' => ['passed' => true]], + ]); + + $submission = $submission->fresh(['itemResults']); + $results = $submission->itemResults->keyBy('item_key'); + expect($submission->itemResults)->toHaveCount(2) + ->and($results['mirrors']->status)->toBe('not_applicable') + ->and($results['mirrors']->passed)->toBeTrue() + ->and($results['brakes']->status)->toBe('passed') + ->and($results['brakes']->severity)->toBeNull() + ->and($submission->meta['unsafe'])->toBeFalse() + ->and($submission->result)->toBe('passed'); + + // Clearing an answer is not an answer: the value row goes rather than a + // null being written to a column that will not take one. + InspectionSubmitter::applyCustomFieldValues($submission, [ + ['custom_field' => $fields['notes']->uuid, 'value' => null], + ['custom_field' => $fields['odometer']->uuid, 'value' => ''], + ]); + expect($submission->fresh()->customFieldValues()->where('custom_field_uuid', $fields['notes']->uuid)->count())->toBe(0) + ->and($submission->fresh()->customFieldValues()->where('custom_field_uuid', $fields['odometer']->uuid)->count())->toBe(0); + + // An answer that is taken away — the console clearing a field — takes its + // result row with it; a row written any other way is left alone. + $submission->customFieldValues()->where('custom_field_uuid', $fields['mirrors']->uuid)->delete(); + $submission->itemResults()->create(['company_uuid' => 'company-insp', 'item_key' => 'hand-written', 'label' => 'Hand written', 'passed' => true]); + expect($submission->fresh()->syncItemResultsFromCustomFieldValues())->toBe(1) + ->and($submission->fresh()->itemResults()->pluck('item_key')->sort()->values()->all())->toBe(['brakes', 'hand-written']); +}); + +test('a submission refuses an answer the form will not accept', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + $fields = $form->fields->keyBy('name'); + $submission = fleetOpsInspectionFieldSubmission($form); + + $refusal = null; + try { + InspectionSubmitter::applyCustomFieldValues($submission, [ + ['custom_field' => 'not-a-field-of-this-form', 'value' => 1], + ['custom_field' => $fields['brakes']->uuid, 'value' => ['passed' => false]], + ['value' => 'no field at all'], + ]); + } catch (ValidationException $exception) { + $refusal = $exception->errors(); + } + + expect($refusal['custom_field_values.0.custom_field'][0])->toContain('not-a-field-of-this-form') + ->and($refusal['custom_field_values.1.value'])->toBe([ + 'A comment is required when "Brakes" fails.', + 'A photo is required when "Brakes" fails.', + ]) + ->and($refusal['custom_field_values.2.custom_field'][0])->toContain('"?"') + // Nothing was written, and no photo was stored on the way to refusing. + ->and($submission->customFieldValues()->count())->toBe(0) + ->and(File::query()->count())->toBe(0); +}); + +test('a pass-fail answer is read whatever shape it arrives in', function () { + expect(InspectionSubmitter::passFailAnswer(['passed' => false, 'severity' => 'High', 'comments' => 'x', 'photos' => ['a'], 'unsafe' => true])) + ->toBe(['passed' => false, 'not_applicable' => false, 'severity' => 'high', 'comments' => 'x', 'photos' => ['a'], 'unsafe' => true]) + ->and(InspectionSubmitter::passFailAnswer('{"pass":false}')['passed'])->toBeFalse() + ->and(InspectionSubmitter::passFailAnswer('{not json')['passed'])->toBeTrue() + ->and(InspectionSubmitter::passFailAnswer('fail')['passed'])->toBeFalse() + ->and(InspectionSubmitter::passFailAnswer('pass')['passed'])->toBeTrue() + ->and(InspectionSubmitter::passFailAnswer(false)['passed'])->toBeFalse() + ->and(InspectionSubmitter::passFailAnswer(['passed' => null])['not_applicable'])->toBeTrue() + ->and(InspectionSubmitter::passFailAnswer(['na' => true])['not_applicable'])->toBeTrue() + ->and(InspectionSubmitter::passFailAnswer(['severity' => ''])['severity'])->toBeNull(); +}); + +test('the submission resource answers the answers, with the files resolved', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + $fields = $form->fields->keyBy('name'); + $submission = fleetOpsInspectionFieldSubmission($form); + + InspectionSubmitter::applyCustomFieldValues($submission, [ + ['custom_field' => $fields['brakes']->uuid, 'value' => ['passed' => false, 'severity' => 'critical', 'comments' => 'Soft pedal', 'photos' => [fleetOpsInspectionFieldPhoto()]]], + ['custom_field' => $fields['signature']->uuid, 'value_type' => 'file', 'value' => fleetOpsInspectionFieldPhoto()], + ['custom_field' => $fields['notes']->uuid, 'value' => 'Nothing else to report'], + ['custom_field' => $fields['odometer']->uuid, 'value_type' => 'number', 'value' => '112480'], + ['custom_field' => $fields['trailer']->uuid, 'value' => 'true'], + ], 'user-driver'); + + $loaded = $submission->fresh(['itemResults', 'customFieldValues.customField', 'files']); + $public = (new InspectionSubmissionResource($loaded))->toArray(fleetOpsInspectionFieldRequest(false)); + + $values = collect($public['custom_field_values'])->keyBy('name'); + expect($public['custom_field_values'])->toHaveCount(5) + ->and($values['brakes']['custom_field'])->toBe($fields['brakes']->uuid) + ->and($values['brakes']['label'])->toBe('Brakes') + ->and($values['brakes']['type'])->toBe('pass-fail') + ->and($values['brakes']['value']['passed'])->toBeFalse() + ->and($values['brakes']['value']['comments'])->toBe('Soft pedal') + ->and($values['brakes']['value']['photos'][0])->toHaveKeys(['id', 'url', 'filename', 'content_type']) + ->and($values['signature']['value'])->toHaveKeys(['id', 'url', 'filename', 'content_type']) + ->and($values['notes']['value'])->toBe('Nothing else to report') + // A value column is a string; the resource hands back the number and + // the boolean the app wrote. + ->and($values['odometer']['value'])->toBe(112480) + ->and($values['trailer']['value'])->toBeTrue() + ->and($values['brakes'])->not->toHaveKey('uuid'); + + expect($public['files'])->toHaveCount(2) + ->and($public['files'][0])->toHaveKeys(['id', 'uuid', 'url', 'original_filename', 'content_type', 'type']) + ->and($public['item_results'])->toHaveCount(1); + + $internal = (new InspectionSubmissionResource($loaded))->toArray(fleetOpsInspectionFieldRequest(true)); + $brakes = collect($internal['custom_field_values'])->firstWhere('name', 'brakes'); + expect($brakes['uuid'])->not->toBeNull() + ->and($brakes['category_uuid'])->toBe($fields['brakes']->category_uuid) + ->and($brakes['order'])->toBe(2) + ->and($brakes['meta']['severity'])->toBe('critical'); + + // A submission whose answers were never loaded says nothing about them. + $bare = (new InspectionSubmissionResource(new InspectionSubmission()))->toArray(fleetOpsInspectionFieldRequest(false)); + expect($bare['custom_field_values'])->toBe([]) + ->and($bare['files'])->toBe([]); +}); + +test('the submission resource survives a value whose field is gone or whose object is not one', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + $fields = $form->fields->keyBy('name'); + $submission = fleetOpsInspectionFieldSubmission($form); + + $submission->syncCustomFieldValues([ + ['custom_field_uuid' => $fields['notes']->uuid, 'value' => 'not json', 'value_type' => 'object'], + ['custom_field_uuid' => (string) Illuminate\Support\Str::uuid(), 'value' => 'orphan', 'value_type' => 'text'], + ]); + + $loaded = $submission->fresh(['customFieldValues.customField']); + $payload = (new InspectionSubmissionResource($loaded))->toArray(fleetOpsInspectionFieldRequest(true)); + $rows = collect($payload['custom_field_values']); + + expect($rows)->toHaveCount(2) + ->and($rows->firstWhere('name', 'notes')['value'])->toBe('not json') + ->and($rows->last()['label'])->toBeNull() + ->and($rows->last()['type'])->toBe('text') + ->and($rows->last()['value'])->toBe('orphan') + ->and($rows->last()['meta'])->toEqual((object) []); + + expect($submission->fresh()->referencedFileUuids())->toBe([]); +}); + +test('the inspection export names the defects and whether the truck was parked', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + $fields = $form->fields->keyBy('name'); + + $failed = fleetOpsInspectionFieldSubmission($form, ['odometer' => 112480, 'engine_hours' => 3100]); + InspectionSubmitter::applyCustomFieldValues($failed, [ + ['custom_field' => $fields['mirrors']->uuid, 'value' => ['passed' => true]], + ['custom_field' => $fields['brakes']->uuid, 'value' => ['passed' => false, 'comments' => 'Soft pedal', 'photos' => [fleetOpsInspectionFieldPhoto()]]], + ]); + $passed = fleetOpsInspectionFieldSubmission($form); + InspectionSubmitter::applyCustomFieldValues($passed, [ + ['custom_field' => $fields['mirrors']->uuid, 'value' => ['passed' => true]], + ]); + + $export = new InspectionExport(); + $rows = $export->collection(); + expect($rows)->toHaveCount(2) + ->and($export->headings())->toHaveCount(20) + ->and($export->columnFormats())->toHaveKeys(['Q', 'R', 'S', 'T']); + + $mapped = collect($rows)->map(fn ($row) => $export->map($row))->keyBy(0); + $row = $mapped[$failed->public_id]; + expect($row[1])->toBe('Pre-trip DVIR') + ->and($row[2])->toBe('Truck 7') + ->and($row[3])->toBe('Dana Driver') + ->and($row[6])->toBe('failed') + ->and($row[8])->toBe(112480) + ->and($row[9])->toBe(3100) + ->and($row[11])->toBe(1) + ->and($row[12])->toBe('Brakes') + ->and($row[13])->toBe('Yes') + ->and($mapped[$passed->public_id][12])->toBe('') + ->and($mapped[$passed->public_id][13])->toBe('No'); + + // A selection narrows the sheet to the rows the console ticked. + expect((new InspectionExport([$failed->uuid]))->collection())->toHaveCount(1); +}); + +test('the internal form controller writes the builder draft and reads the structure back', function () { + fleetOpsInspectionFieldDatabase(); + $controller = new InspectionFormController(); + $form = fleetOpsInspectionFieldForm(); + + // The builder posts the whole form under the record it is saving. + $controller->onAfterCreate(Request::create('/int/v1/inspection-forms', 'POST', ['inspection_form' => ['field_groups' => fleetOpsInspectionFieldDraft()]]), $form); + expect($form->fieldGroups)->toHaveCount(2) + ->and($form->fields)->toHaveCount(8); + + // A save that mentions no structure leaves the structure alone: publishing + // a form must not empty it. + $controller->onAfterUpdate(Request::create('/int/v1/inspection-forms/x', 'PUT', ['inspection_form' => ['status' => 'published']]), $form); + expect($form->fresh()->fields()->count())->toBe(8); + + // fliit's builder posts the same thing under `draft`, and a form authored + // there still saves; what it drops is pruned. + $controller->onAfterUpdate(Request::create('/int/v1/inspection-forms/x', 'PUT', ['inspection_form' => ['draft' => [['name' => 'Only group', 'fields' => [['label' => 'One', 'type' => 'pass-fail']]]]]]), $form); + expect($form->fresh()->fields()->count())->toBe(1) + ->and($form->fresh()->fieldGroups()->count())->toBe(1); + + $find = InspectionForm::query(); + $controller->onFindRecord($find, Request::create('/')); + $query = InspectionForm::query(); + $controller->onQueryRecord($query, Request::create('/')); + expect(array_keys($find->getEagerLoads()))->toContain('fieldGroups', 'fields') + ->and(array_keys($query->getEagerLoads()))->toContain('fieldGroups', 'fields') + ->and($find->first()->relationLoaded('fields'))->toBeTrue(); +}); + +test('the internal submission controller records answers, item results and an export', function () { + fleetOpsInspectionFieldDatabase(); + $controller = new FleetOpsInspectionSubmissionControllerProbe(); + $form = fleetOpsInspectionFieldForm(); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + $fields = $form->fields->keyBy('name'); + $submission = fleetOpsInspectionFieldSubmission($form); + + $controller->onAfterCreate(Request::create('/int/v1/inspection-submissions', 'POST', ['inspection_submission' => ['custom_field_values' => [ + ['custom_field' => $fields['mirrors']->uuid, 'value' => ['passed' => false, 'comments' => 'Cracked']], + ]]]), $submission, []); + + expect($submission->itemResults->pluck('item_key')->all())->toBe(['mirrors']) + ->and($submission->itemResults->first()->comments)->toBe('Cracked') + ->and($submission->relationLoaded('files'))->toBeTrue(); + + // The flat spelling is read too, and answering again rewrites the row. + $controller->onAfterUpdate(Request::create('/int/v1/inspection-submissions/x', 'PUT', ['custom_field_values' => [ + ['custom_field' => $fields['mirrors']->uuid, 'value' => ['passed' => true]], + ]]), $submission, []); + expect($submission->fresh()->itemResults->first()->passed)->toBeTrue(); + + // A submission against a legacy checklist still posts results directly. + $controller->onAfterUpdate(Request::create('/int/v1/inspection-submissions/x', 'PUT', ['inspection_submission' => ['item_results' => [ + ['item_key' => 'hand-written', 'label' => 'Hand written', 'passed' => false], + ]]]), $submission, []); + expect($submission->fresh()->itemResults->pluck('item_key')->all())->toBe(['hand-written']); + + $query = InspectionSubmission::query(); + $controller->onQueryRecord($query, Request::create('/')); + expect(array_keys($query->getEagerLoads()))->toContain('form', 'vehicle', 'driver', 'itemResults'); + + $response = $controller->export(FleetOpsInspectionExportRequestFake::create('/int/v1/inspection-submissions/export', 'POST', ['format' => 'csv', 'selections' => ['a', 'b']])); + expect($response['download'])->toMatch('/^inspections-[0-9-]+\.csv$/') + ->and($response['headings'])->toContain('Failed Items') + ->and($controller->downloads[0][0])->toBeInstanceOf(InspectionExport::class); + + // No format and no selection: the whole sheet, as a workbook. + expect($controller->export(FleetOpsInspectionExportRequestFake::create('/int/v1/inspection-submissions/export', 'POST'))['download'])->toEndWith('.xlsx'); +}); + +test('the submitter takes the body the driver app builds, whole', function () { + fleetOpsInspectionFieldDatabase(); + Carbon::setTestNow('2026-09-10 07:30:00'); + $form = fleetOpsInspectionFieldForm(); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + $fields = $form->fields->keyBy('name'); + $photo = fleetOpsInspectionFieldPhoto(); + + // Exactly what `buildSubmission` in the app's useInspections.ts emits: + // both bodies, every pass-fail answer carrying `not_applicable` and + // `unsafe`, photos and the signature as bare base64. + $submission = InspectionSubmitter::submit($form, [ + 'odometer' => 112480, + 'engine_hours' => null, + 'custom_field_values' => [ + ['custom_field' => $fields['brakes']->uuid, 'value_type' => 'object', 'value' => ['passed' => false, 'not_applicable' => false, 'severity' => 'critical', 'comments' => 'Soft pedal', 'photos' => [$photo], 'unsafe' => true]], + ['custom_field' => $fields['mirrors']->uuid, 'value_type' => 'object', 'value' => ['passed' => true, 'not_applicable' => true, 'severity' => null, 'comments' => null, 'photos' => [], 'unsafe' => false]], + ['custom_field' => $fields['odometer']->uuid, 'value_type' => 'number', 'value' => 112480], + ['custom_field' => $fields['signature']->uuid, 'value_type' => 'file', 'value' => $photo], + ['custom_field' => $fields['notes']->uuid, 'value_type' => 'text', 'value' => 'Nearside mirror scuffed'], + ], + 'item_results' => [ + ['item_key' => 'brakes', 'label' => 'Brakes', 'category' => 'Exterior', 'status' => 'failed', 'severity' => 'critical', 'passed' => false, 'comments' => 'Soft pedal', 'photos' => [$photo]], + ['item_key' => 'mirrors', 'label' => 'Mirrors', 'category' => 'Exterior', 'status' => 'not_applicable', 'severity' => null, 'passed' => true, 'comments' => null, 'photos' => []], + ], + 'location' => ['latitude' => 1.3521, 'longitude' => 103.8198], + 'signature' => ['image' => $photo, 'signed_at' => '2026-09-10T07:30:00Z'], + 'meta' => ['source_app' => 'navigator', 'unsafe' => true], + ], [ + 'driver_uuid' => 'driver-1', + 'vehicle_uuid' => 'vehicle-1', + 'submitted_by_uuid' => 'user-driver', + 'source' => 'navigator', + ]); + + $submission = $submission->fresh(['itemResults', 'customFieldValues.customField', 'files']); + $results = $submission->itemResults->keyBy('item_key'); + + expect($submission->customFieldValues)->toHaveCount(5) + // The field values won; the duplicated `item_results` were ignored, + // so there is one row per pass-fail field and no more. + ->and($submission->itemResults)->toHaveCount(2) + ->and($results['brakes']->status)->toBe('failed') + ->and($results['brakes']->photos[0])->toStartWith('file:') + ->and($results['mirrors']->status)->toBe('not_applicable') + ->and($results['mirrors']->passed)->toBeTrue() + ->and($submission->total_items)->toBe(2) + ->and($submission->failed_items)->toBe(1) + ->and($submission->result)->toBe('failed') + ->and($submission->meta['unsafe'])->toBeTrue() + ->and($submission->odometer)->toBe(112480) + ->and($submission->files)->toHaveCount(2) + ->and($submission->source)->toBe('navigator'); +}); + +test('the submitter takes field answers straight from a submit body', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(['settings' => ['create_issue_on_failure' => true, 'create_work_order_on_failure' => true]]); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + $fields = $form->fields->keyBy('name'); + + $submission = InspectionSubmitter::submit($form, [ + 'odometer' => 112480, + 'custom_field_values' => [ + ['custom_field' => $fields['mirrors']->uuid, 'value' => ['passed' => false, 'comments' => 'Cracked', 'severity' => 'high']], + ], + // Sent alongside by the app; the field values win and these are ignored. + 'item_results' => [['item_key' => 'mirrors', 'label' => 'Mirrors', 'passed' => false]], + ], ['driver_uuid' => 'driver-1', 'vehicle_uuid' => 'vehicle-1', 'submitted_by_uuid' => 'user-driver']); + + expect($submission->itemResults()->count())->toBe(1) + ->and($submission->itemResults()->first()->severity)->toBe('high') + ->and($submission->fresh()->result)->toBe('failed') + ->and($submission->fresh()->issue_uuid)->not->toBeNull() + ->and($submission->fresh()->work_order_uuid)->not->toBeNull(); +}); + +test('a photo that cannot be stored is left as it arrived', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + $submission = fleetOpsInspectionFieldSubmission($form); + + // A disk that cannot be written to: the store gives back what it was given + // rather than losing the answer along with the photo. + $readOnly = sys_get_temp_dir() . '/fleetops-inspection-readonly-' . bin2hex(random_bytes(6)); + $folder = $readOnly . '/inspections/' . $submission->uuid; + if (!is_dir($folder)) { + mkdir($folder, 0755, true); + } + chmod($folder, 0555); + app('config')->set('filesystems.disks.broken', ['driver' => 'local', 'root' => $readOnly, 'throw' => false]); + app('config')->set('filesystems.default', 'broken'); + + $photo = fleetOpsInspectionFieldPhoto(); + $left = InspectionFileStore::normalize($photo, $submission); + chmod($folder, 0755); + + expect($left)->toBe($photo) + ->and(File::query()->count())->toBe(0); +}); + +test('the file store keeps only references a submission may use', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + + // Another company's file, not yet attached to anything. + $foreign = File::create(['company_uuid' => 'company-other', 'disk' => 'uploads', 'path' => 'foreign.png', 'type' => 'inspection_photo']); + // This company's file, uploaded through a public link. + $viaLink = File::create(['company_uuid' => 'company-insp', 'disk' => 'uploads', 'path' => 'link.png', 'type' => 'inspection_photo', 'meta' => ['inspection_link_uuid' => 'link-one']]); + // This company's file, uploaded in the console. + $console = File::create(['company_uuid' => 'company-insp', 'disk' => 'uploads', 'path' => 'console.png', 'type' => 'inspection_photo']); + + // Through the console or the app: the company's own files, and nothing + // else. Another company's file is neither kept nor claimed, whether it is + // named by uuid or by public id. + $consoleSubmission = fleetOpsInspectionFieldSubmission($form); + expect(InspectionFileStore::normalize('file:' . $foreign->uuid, $consoleSubmission))->toBeNull() + ->and(InspectionFileStore::normalize($foreign->public_id, $consoleSubmission))->toBeNull() + ->and(InspectionFileStore::normalize('file:' . $console->public_id, $consoleSubmission))->toBe('file:' . $console->uuid) + ->and(InspectionFileStore::attachReferenced($consoleSubmission, [$foreign->uuid]))->toBe(0) + ->and($foreign->fresh()->subject_uuid)->toBeNull(); + + // Through a public link: only files uploaded through that same link. + $linked = fleetOpsInspectionFieldSubmission($form, ['source' => 'public_link', 'meta' => ['inspection_link_uuid' => 'link-one']]); + expect(InspectionFileStore::normalize('file:' . $viaLink->public_id, $linked))->toBe('file:' . $viaLink->uuid) + ->and(InspectionFileStore::attachReferenced($linked, [$console->uuid, $viaLink->uuid]))->toBe(1) + ->and($viaLink->fresh()->subject_uuid)->toBe($linked->uuid) + ->and($console->fresh()->subject_uuid)->toBeNull(); + + // A link submission naming any other file, or an outside URL, is refused + // rather than silently losing a photo it named. + expect(fn () => InspectionFileStore::normalize('file:' . $console->uuid, $linked))->toThrow(ValidationException::class) + ->and(fn () => InspectionFileStore::normalize('https://cdn.example.com/a.jpg', $linked))->toThrow(ValidationException::class); +}); + +test('groups and fields sort the way the builder laid them out', function () { + $ordered = new Category(['name' => 'first']); + $unordered = new Category(['name' => 'later']); + $older = new Category(['name' => 'older']); + $ordered->order = 2; + $older->created_at = '2026-01-01 00:00:00'; + $unordered->created_at = '2026-06-01 00:00:00'; + + expect(InspectionForm::sortByOrder(collect([$unordered, $ordered, $older]))->pluck('name')->all()) + ->toBe(['first', 'older', 'later']) + ->and(InspectionForm::sortByOrder(collect([$ordered, $unordered]))->pluck('name')->all()) + ->toBe(['first', 'later']) + ->and(InspectionForm::sortByOrder(collect([$older, $unordered]))->pluck('name')->all()) + ->toBe(['older', 'later']); +}); diff --git a/server/tests/InspectionModelContractsTest.php b/server/tests/InspectionModelContractsTest.php new file mode 100644 index 000000000..d43d69262 --- /dev/null +++ b/server/tests/InspectionModelContractsTest.php @@ -0,0 +1,725 @@ +sqliteCreateFunction('ST_PointFromText', $asStoredPoint); + $pdo->sqliteCreateFunction('ST_GeomFromText', $asStoredPoint); + $connection = new SQLiteConnection($pdo); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + EloquentModel::setEventDispatcher(new Dispatcher()); + EloquentModel::clearBootedModels(); + + $config = new Repository([ + 'activitylog' => ['enabled' => false, 'default_auth_driver' => null, 'default_log_name' => 'default'], + 'api' => ['cache' => ['enabled' => false]], + 'filesystems' => ['default' => 'local'], + ]); + app()->instance('config', $config); + app()->instance(Illuminate\Contracts\Config\Repository::class, $config); + app()->instance(Spatie\Activitylog\CauserResolver::class, new class extends Spatie\Activitylog\CauserResolver { + public function __construct() + { + } + + public function resolve(EloquentModel|int|string|null $subject = null): ?EloquentModel + { + return null; + } + }); + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $connection) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->connection; + } + + public function __call($method, $arguments) + { + return $this->connection->{$method}(...$arguments); + } + }); + Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + app()->instance('db.schema', $connection->getSchemaBuilder()); + // The link's token and PIN are stored with the `encrypted` cast, which + // resolves the container's encrypter. A reversible stand-in is enough to + // show a value goes in encrypted and comes back as it was. + $encrypter = new class { + // Eloquent's `encrypted` cast calls encrypt($value, false) and + // decrypt($value, false); the string variants are here for anything + // that goes through Crypt::encryptString() instead. + public function encrypt($value, $serialize = true) + { + return 'enc:' . base64_encode($serialize ? serialize($value) : (string) $value); + } + + public function decrypt($value, $unserialize = true) + { + if (!is_string($value) || !str_starts_with($value, 'enc:')) { + throw new RuntimeException('Unable to decrypt.'); + } + + $decoded = base64_decode(substr($value, 4), true); + + return $unserialize ? unserialize($decoded) : $decoded; + } + + public function encryptString($value) + { + return $this->encrypt($value, false); + } + + public function decryptString($value) + { + return $this->decrypt($value, false); + } + }; + app()->instance('encrypter', $encrypter); + Illuminate\Support\Facades\Crypt::clearResolvedInstance('encrypter'); + EloquentModel::encryptUsing($encrypter); + app()->instance('responsecache', new class { + public function __call($method, $arguments) + { + return null; + } + }); + app()->instance('request', Request::create('/')); + + $schema = $connection->getSchemaBuilder(); + $tables = [ + 'inspection_forms' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'description', 'type', 'status', 'subject_type', 'subject_uuid', 'items', 'settings', 'meta', 'published_at', 'created_by_uuid', 'updated_by_uuid'], + 'inspection_links' => ['uuid', 'public_id', '_key', 'company_uuid', 'inspection_form_uuid', 'driver_uuid', 'vehicle_uuid', 'assignee_uuid', 'created_by_uuid', 'token_hash', 'token', 'pin_hash', 'pin', 'pin_attempts', 'pin_sent_via', 'pin_sent_at', 'status', 'single_use', 'expires_at', 'last_viewed_at', 'used_at', 'used_ip', 'used_user_agent', 'meta'], + 'inspection_submissions' => ['uuid', 'public_id', '_key', 'company_uuid', 'inspection_form_uuid', 'vehicle_uuid', 'driver_uuid', 'submitted_by_uuid', 'issue_uuid', 'work_order_uuid', 'type', 'status', 'result', 'source', 'odometer', 'engine_hours', 'total_items', 'failed_items', 'started_at', 'submitted_at', 'resolved_at', 'location', 'signature', 'attachments', 'meta', 'created_by_uuid', 'updated_by_uuid'], + 'inspection_item_results' => ['uuid', '_key', 'company_uuid', 'inspection_submission_uuid', 'issue_uuid', 'work_order_uuid', 'item_key', 'label', 'category', 'status', 'severity', 'passed', 'comments', 'photos', 'meta', 'created_by_uuid', 'updated_by_uuid'], + 'issues' => ['uuid', 'public_id', '_key', 'company_uuid', 'reported_by_uuid', 'assigned_to_uuid', 'vehicle_uuid', 'driver_uuid', 'order_uuid', 'issue_id', 'location', 'category', 'type', 'report', 'title', 'tags', 'priority', 'meta', 'resolved_at', 'status'], + 'work_orders' => ['uuid', 'public_id', '_key', 'company_uuid', 'schedule_uuid', 'code', 'subject', 'category', 'status', 'priority', 'target_type', 'target_uuid', 'assignee_type', 'assignee_uuid', 'opened_at', 'due_at', 'closed_at', 'instructions', 'checklist', 'currency', 'estimated_cost', 'approved_budget', 'actual_cost', 'cost_center', 'budget_code', 'meta', 'created_by_uuid', 'updated_by_uuid'], + 'vehicles' => ['uuid', 'public_id', 'internal_id', '_key', 'company_uuid', 'vendor_uuid', 'photo_uuid', 'name', 'make', 'model', 'year', 'trim', 'plate_number', 'vin', 'status', 'currency', 'slug', 'online', 'location'], + 'drivers' => ['uuid', 'public_id', 'internal_id', '_key', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'vendor_uuid', 'current_job_uuid', 'photo_uuid', 'status', 'online', 'location', 'slug'], + 'users' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'email', 'phone', 'avatar_uuid', 'type', 'status'], + 'companies' => ['uuid', 'public_id', '_key', 'name', 'owner_uuid'], + 'company_users' => ['uuid', '_key', 'company_uuid', 'user_uuid', 'role_uuid', 'status'], + 'settings' => ['key', 'value'], + 'files' => ['uuid', 'public_id', '_key', 'company_uuid', 'uploader_uuid', 'subject_uuid', 'subject_type', 'path', 'disk', 'bucket', 'folder', 'etag', 'meta', 'original_filename', 'type', 'content_type', 'file_size', 'slug', 'caption'], + 'vendors' => ['uuid', 'public_id', '_key', 'company_uuid', 'name'], + 'orders' => ['uuid', 'public_id', '_key', 'company_uuid', 'driver_assigned_uuid', 'status'], + 'positions' => ['uuid', 'public_id', '_key', 'company_uuid', 'subject_uuid', 'subject_type', 'coordinates'], + 'maintenances' => ['uuid', 'public_id', '_key', 'company_uuid', 'maintainable_type', 'maintainable_uuid', 'status', 'completed_at'], + 'maintenance_schedules' => ['uuid', 'public_id', '_key', 'company_uuid', 'subject_type', 'subject_uuid', 'status', 'next_due_at'], + 'custom_field_values' => ['uuid', 'company_uuid', 'custom_field_uuid', 'subject_uuid', 'subject_type', 'value', 'value_type'], + 'custom_fields' => ['uuid', 'company_uuid', 'category_uuid', 'subject_uuid', 'subject_type', 'name', 'label', 'type', 'for', 'component', 'options', 'required', 'editable', 'default_value', 'validation_rules', 'meta', 'description', 'help_text', 'order'], + 'categories' => ['uuid', 'public_id', '_key', 'company_uuid', 'owner_uuid', 'owner_type', 'parent_uuid', 'icon_file_uuid', 'internal_id', 'name', 'description', 'translations', 'meta', 'tags', 'icon', 'icon_color', 'slug', 'order', 'for', 'core_category'], + 'activity_log' => ['uuid', 'company_uuid', 'log_name', 'description', 'subject_type', 'subject_id', 'causer_type', 'causer_id', 'properties', 'event', 'batch_uuid'], + ]; + + foreach ($tables as $table => $columns) { + $schema->create($table, function ($blueprint) use ($columns) { + $blueprint->increments('id'); + foreach ($columns as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + $connection->table('companies')->insert(['uuid' => 'company-insp', 'public_id' => 'company_insp', 'name' => 'Inspection Co']); + $connection->table('users')->insert(['uuid' => 'user-driver', 'public_id' => 'user_driver', 'company_uuid' => 'company-insp', 'name' => 'Dana Driver', 'email' => 'dana@example.com', 'phone' => '+15550001111', 'type' => 'user']); + $connection->table('users')->insert(['uuid' => 'user-admin', 'public_id' => 'user_admin', 'company_uuid' => 'company-insp', 'name' => 'Avery Admin', 'email' => 'avery@example.com', 'type' => 'user']); + $connection->table('vehicles')->insert(['uuid' => 'vehicle-1', 'public_id' => 'vehicle_one', 'company_uuid' => 'company-insp', 'name' => 'Truck 7', 'plate_number' => 'TRK-7', 'currency' => 'SGD']); + $connection->table('vehicles')->insert(['uuid' => 'vehicle-2', 'public_id' => 'vehicle_two', 'company_uuid' => 'company-insp', 'make' => 'Isuzu', 'model' => 'NPR', 'year' => '2022', 'plate_number' => 'TRK-2']); + $connection->table('drivers')->insert([ + ['uuid' => 'driver-1', 'public_id' => 'driver_one', 'company_uuid' => 'company-insp', 'user_uuid' => 'user-driver', 'vehicle_uuid' => 'vehicle-1', 'status' => 'active'], + ]); + + session(['company' => 'company-insp', 'user' => 'user-admin']); + + return $connection; +} + +function fleetOpsInspectionModelForm(array $attributes = []): InspectionForm +{ + return InspectionForm::create(array_merge([ + 'company_uuid' => 'company-insp', + 'name' => 'Pre-trip DVIR', + 'type' => 'dvir', + 'status' => 'draft', + 'items' => [ + ['key' => 'brakes', 'label' => 'Brakes', 'category' => 'Safety', 'severity' => 'critical'], + ['key' => 'lights', 'label' => 'Lights', 'category' => 'Safety', 'severity' => 'medium'], + ], + 'settings' => ['create_issue_on_failure' => true, 'create_work_order_on_failure' => true], + 'created_by_uuid' => 'user-admin', + 'updated_by_uuid' => 'user-admin', + ], $attributes)); +} + +function fleetOpsInspectionModelSubmission(InspectionForm $form, array $items, array $attributes = []): InspectionSubmission +{ + $submission = InspectionSubmission::create(array_merge([ + 'company_uuid' => 'company-insp', + 'inspection_form_uuid' => $form->uuid, + 'vehicle_uuid' => 'vehicle-1', + 'driver_uuid' => 'driver-1', + 'submitted_by_uuid' => 'user-driver', + 'type' => 'dvir', + 'status' => 'draft', + 'source' => 'test', + ], $attributes)); + + foreach ($items as $item) { + InspectionItemResult::create(array_merge([ + 'company_uuid' => 'company-insp', + 'inspection_submission_uuid' => $submission->uuid, + ], $item)); + } + + return $submission; +} + +afterEach(function () { + Carbon::setTestNow(); +}); + +test('inspection form knows when it is published and can be archived', function () { + fleetOpsInspectionModelDatabase(); + + $form = fleetOpsInspectionModelForm(); + + // A draft is not published, and neither is a form whose status says so + // without the timestamp to prove it. + expect($form->is_published)->toBeFalse() + ->and($form->item_count)->toBe(2) + ->and($form->public_id)->toStartWith('inspection_form_') + ->and($form->getActivitylogOptions())->toBeInstanceOf(LogOptions::class); + + $form->update(['status' => 'published']); + expect($form->fresh()->is_published)->toBeFalse(); + + Carbon::setTestNow('2026-09-09 08:00:00'); + expect($form->publish())->toBeTrue(); + + $published = $form->fresh(); + expect($published->is_published)->toBeTrue() + ->and($published->published_at->toDateTimeString())->toBe('2026-09-09 08:00:00'); + + // Publishing again keeps the original publication date. + Carbon::setTestNow('2026-09-10 08:00:00'); + $published->publish(); + expect($published->fresh()->published_at->toDateTimeString())->toBe('2026-09-09 08:00:00'); + + expect($published->archive())->toBeTrue() + ->and($published->fresh()->status)->toBe('archived') + ->and($published->fresh()->is_published)->toBeFalse(); +}); + +test('inspection form names its subject and exposes its relations', function () { + fleetOpsInspectionModelDatabase(); + + $bound = fleetOpsInspectionModelForm(['subject_type' => Vehicle::class, 'subject_uuid' => 'vehicle-1']); + $wide = fleetOpsInspectionModelForm(['name' => 'Fleet-wide']); + + expect($bound->fresh()->subject_name)->toBe('Truck 7') + ->and($wide->fresh()->subject_name)->toBeNull() + ->and($bound->subject())->toBeInstanceOf(MorphTo::class) + ->and($bound->submissions())->toBeInstanceOf(HasMany::class) + ->and($bound->createdBy())->toBeInstanceOf(BelongsTo::class) + ->and($bound->updatedBy())->toBeInstanceOf(BelongsTo::class) + ->and($bound->fresh()->createdBy->name)->toBe('Avery Admin'); + + $submission = fleetOpsInspectionModelSubmission($bound, []); + expect($bound->submissions()->count())->toBe(1) + ->and($bound->submissions->first()->uuid)->toBe($submission->uuid); +}); + +test('inspection submission counts its results and names what it belongs to', function () { + fleetOpsInspectionModelDatabase(); + + $form = fleetOpsInspectionModelForm(); + $submission = fleetOpsInspectionModelSubmission($form, [ + ['label' => 'Brakes', 'passed' => true], + ['label' => 'Lights', 'passed' => false, 'severity' => 'medium'], + ['label' => 'Horn', 'passed' => false], + ]); + + expect($submission->form())->toBeInstanceOf(BelongsTo::class) + ->and($submission->vehicle())->toBeInstanceOf(BelongsTo::class) + ->and($submission->driver())->toBeInstanceOf(BelongsTo::class) + ->and($submission->submittedBy())->toBeInstanceOf(BelongsTo::class) + ->and($submission->issue())->toBeInstanceOf(BelongsTo::class) + ->and($submission->workOrder())->toBeInstanceOf(BelongsTo::class) + ->and($submission->itemResults())->toBeInstanceOf(HasMany::class) + ->and($submission->failedItemResults())->toBeInstanceOf(HasMany::class) + ->and($submission->getActivitylogOptions())->toBeInstanceOf(LogOptions::class) + ->and($submission->public_id)->toStartWith('inspection_submission_'); + + // Nothing counted yet: a fresh draft has no failures on record. + expect($submission->has_failures)->toBeFalse(); + + Carbon::setTestNow('2026-09-09 09:30:00'); + expect($submission->syncResultCounts())->toBeTrue(); + + $synced = $submission->fresh(); + expect($synced->total_items)->toBe(3) + ->and($synced->failed_items)->toBe(2) + ->and($synced->result)->toBe('failed') + ->and($synced->status)->toBe('submitted') + ->and($synced->submitted_at->toDateTimeString())->toBe('2026-09-09 09:30:00') + ->and($synced->has_failures)->toBeTrue() + ->and($synced->form_name)->toBe('Pre-trip DVIR') + ->and($synced->vehicle_name)->toBe('Truck 7') + ->and($synced->driver_name)->toBe('Dana Driver') + ->and($synced->submittedBy->name)->toBe('Dana Driver'); + + // A second sync keeps the first submitted_at and leaves a non-draft status alone. + $synced->update(['status' => 'needs_review']); + Carbon::setTestNow('2026-09-09 11:00:00'); + $synced->syncResultCounts(); + expect($synced->fresh()->status)->toBe('needs_review') + ->and($synced->fresh()->submitted_at->toDateTimeString())->toBe('2026-09-09 09:30:00'); + + // `result` alone is enough to count as failed, for rows written before counts existed. + $legacy = fleetOpsInspectionModelSubmission($form, [], ['result' => 'failed', 'failed_items' => 0]); + expect($legacy->has_failures)->toBeTrue(); + + // A vehicle with no name is named from its make and model, and a display + // name is what the submission reports. + $unnamed = fleetOpsInspectionModelSubmission($form, [], ['vehicle_uuid' => 'vehicle-2']); + expect($unnamed->fresh()->vehicle_name)->toBe('2022 Isuzu NPR'); +}); + +test('inspection submission falls back to its column defaults when sent null', function () { + fleetOpsInspectionModelDatabase(); + + $form = fleetOpsInspectionModelForm(); + + // The console's model sends every attribute, nulls included; an explicit + // null would override the column default and the insert would be refused. + $submission = InspectionSubmission::create([ + 'company_uuid' => 'company-insp', + 'inspection_form_uuid' => $form->uuid, + 'type' => null, + 'status' => null, + 'total_items' => null, + 'failed_items' => null, + ])->fresh(); + + expect($submission->type)->toBe('dvir') + ->and($submission->status)->toBe('draft') + ->and($submission->total_items)->toBe(0) + ->and($submission->failed_items)->toBe(0); +}); + +test('inspection submission ranks failures by severity', function () { + fleetOpsInspectionModelDatabase(); + + $form = fleetOpsInspectionModelForm(); + + $clean = fleetOpsInspectionModelSubmission($form, [['label' => 'Brakes', 'passed' => true]]); + $clean->syncResultCounts(); + expect($clean->fresh()->highestFailureSeverity())->toBe('low'); + + // Failed without a severity is still a failure, and defaults high rather + // than being quietly filed as low. + $unranked = fleetOpsInspectionModelSubmission($form, [['label' => 'Horn', 'passed' => false]]); + $unranked->syncResultCounts(); + expect($unranked->fresh()->highestFailureSeverity())->toBe('high'); + + $mixed = fleetOpsInspectionModelSubmission($form, [ + ['label' => 'Lights', 'passed' => false, 'severity' => 'Medium'], + ['label' => 'Mirror', 'passed' => false, 'severity' => 'low'], + ['label' => 'Brakes', 'passed' => false, 'severity' => 'CRITICAL'], + ['label' => 'Tyres', 'passed' => true, 'severity' => 'critical'], + ]); + $mixed->syncResultCounts(); + expect($mixed->fresh()->highestFailureSeverity())->toBe('critical'); + + $medium = fleetOpsInspectionModelSubmission($form, [ + ['label' => 'Lights', 'passed' => false, 'severity' => 'medium'], + ['label' => 'Mirror', 'passed' => false, 'severity' => 'low'], + ]); + $medium->syncResultCounts(); + expect($medium->fresh()->highestFailureSeverity())->toBe('medium'); +}); + +test('inspection submission raises an issue from its failed items once', function () { + $connection = fleetOpsInspectionModelDatabase(); + + $form = fleetOpsInspectionModelForm(); + + $clean = fleetOpsInspectionModelSubmission($form, [['label' => 'Brakes', 'passed' => true]]); + $clean->syncResultCounts(); + expect($clean->fresh()->createIssueFromFailures())->toBeNull() + ->and(Issue::query()->count())->toBe(0); + + $failed = fleetOpsInspectionModelSubmission($form, [ + ['label' => 'Brakes', 'passed' => false, 'severity' => 'critical', 'item_key' => 'brakes'], + ['label' => 'Lights', 'passed' => false, 'severity' => 'medium', 'item_key' => 'lights'], + ['label' => 'Horn', 'passed' => true], + ], ['location' => ['latitude' => 1.3521, 'longitude' => 103.8198]]); + $failed->syncResultCounts(); + $failed = $failed->fresh(); + + $issue = $failed->createIssueFromFailures(); + + expect($issue)->toBeInstanceOf(Issue::class) + ->and($issue->title)->toBe('Failed inspection: Truck 7') + ->and($issue->report)->toBe('Failed items: Brakes, Lights') + ->and($issue->priority)->toBe('critical') + ->and($issue->type)->toBe('inspection') + ->and($issue->category)->toBe('inspection_failed') + ->and($issue->status)->toBe('pending') + ->and($issue->reported_by_uuid)->toBe('user-driver') + ->and($issue->vehicle_uuid)->toBe('vehicle-1') + ->and($issue->driver_uuid)->toBe('driver-1') + // `issues.location` has no default: an issue raised without one is + // refused by the database, so it takes where the inspection was filed. + ->and($issue->location)->toBeInstanceOf(Point::class) + ->and($issue->location->getLat())->toEqual(1.3521) + ->and($issue->location->getLng())->toEqual(103.8198) + ->and($issue->meta['inspection_submission_uuid'])->toBe($failed->uuid) + ->and($issue->meta['failed_items'])->toBe(['Brakes', 'Lights']) + ->and($failed->fresh()->issue_uuid)->toBe($issue->uuid); + + // Asking again hands back the same issue rather than filing a duplicate. + $again = $failed->fresh()->createIssueFromFailures(); + expect($again->uuid)->toBe($issue->uuid) + ->and(Issue::query()->count())->toBe(1); + + // No vehicle to name: the submission's own id stands in. + $unassigned = fleetOpsInspectionModelSubmission($form, [['label' => 'Horn', 'passed' => false]], ['vehicle_uuid' => null]); + $unassigned->syncResultCounts(); + $orphanIssue = $unassigned->fresh()->createIssueFromFailures(); + expect($orphanIssue->title)->toBe('Failed inspection: ' . $unassigned->public_id) + ->and($orphanIssue->report)->toBe('Failed items: Horn') + // Nowhere to take a position from: an empty point, which the column takes. + ->and($orphanIssue->location)->toBeInstanceOf(Point::class) + ->and($orphanIssue->location->getLat())->toEqual(0.0) + ->and($orphanIssue->location->getLng())->toEqual(0.0); + + // The vehicle's last known position stands in when the submission has none, + // stored the way MySQL keeps it so the model reads it back as a point. + $connection->table('vehicles')->where('uuid', 'vehicle-1')->update([ + 'location' => pack('V', 0) . pack('C', 1) . pack('V', 1) . pack('d', 103.9915) . pack('d', 1.3644), + ]); + + $atVehicle = fleetOpsInspectionModelSubmission($form, [['label' => 'Horn', 'passed' => false]]); + $atVehicle->syncResultCounts(); + $atVehicleIssue = $atVehicle->fresh()->createIssueFromFailures(); + + expect($atVehicleIssue->location)->toBeInstanceOf(Point::class) + ->and(round($atVehicleIssue->location->getLat(), 4))->toEqual(1.3644) + ->and(round($atVehicleIssue->location->getLng(), 4))->toEqual(103.9915); +}); + +test('inspection submission opens a work order with a checklist of the failed items', function () { + fleetOpsInspectionModelDatabase(); + Carbon::setTestNow('2026-09-09 10:00:00'); + + $form = fleetOpsInspectionModelForm(); + + $clean = fleetOpsInspectionModelSubmission($form, [['label' => 'Brakes', 'passed' => true]]); + $clean->syncResultCounts(); + expect($clean->fresh()->createWorkOrderFromFailures())->toBeNull() + ->and(WorkOrder::query()->count())->toBe(0); + + $critical = fleetOpsInspectionModelSubmission($form, [ + ['label' => 'Brakes', 'passed' => false, 'severity' => 'critical', 'item_key' => 'brakes'], + ['label' => 'Horn', 'passed' => true], + ]); + $critical->syncResultCounts(); + $critical->fresh()->createIssueFromFailures(); + $critical = $critical->fresh(); + + $workOrder = $critical->createWorkOrderFromFailures(); + + expect($workOrder)->toBeInstanceOf(WorkOrder::class) + ->and($workOrder->subject)->toBe('Inspection repair: Truck 7') + ->and($workOrder->status)->toBe('open') + ->and($workOrder->priority)->toBe('critical') + ->and($workOrder->target_type)->toBe(Vehicle::class) + ->and($workOrder->target_uuid)->toBe('vehicle-1') + ->and($workOrder->currency)->toBe('SGD') + ->and($workOrder->created_by_uuid)->toBe('user-driver') + // Critical is due tomorrow; anything else gets a week. + ->and($workOrder->due_at->toDateTimeString())->toBe('2026-09-10 10:00:00') + ->and($workOrder->checklist)->toHaveCount(1) + ->and($workOrder->checklist[0])->toMatchArray(['title' => 'Brakes', 'item_key' => 'brakes', 'severity' => 'critical', 'required' => true, 'completed' => false, 'source' => 'inspection']) + ->and($workOrder->meta['issue_uuid'])->toBe($critical->issue_uuid) + ->and($critical->fresh()->work_order_uuid)->toBe($workOrder->uuid); + + // The failed items now point at the work order; the passed one does not. + expect($critical->itemResults()->where('label', 'Brakes')->value('work_order_uuid'))->toBe($workOrder->uuid) + ->and($critical->itemResults()->where('label', 'Horn')->value('work_order_uuid'))->toBeNull(); + + $again = $critical->fresh()->createWorkOrderFromFailures(); + expect($again->uuid)->toBe($workOrder->uuid) + ->and(WorkOrder::query()->count())->toBe(1); + + $routine = fleetOpsInspectionModelSubmission($form, [['label' => 'Lights', 'passed' => false, 'severity' => 'medium']], ['vehicle_uuid' => null]); + $routine->syncResultCounts(); + $routineOrder = $routine->fresh()->createWorkOrderFromFailures(); + expect($routineOrder->due_at->toDateTimeString())->toBe('2026-09-16 10:00:00') + ->and($routineOrder->target_type)->toBeNull() + ->and($routineOrder->subject)->toBe('Inspection repair: ' . $routine->public_id); +}); + +test('inspection item result belongs to its submission and follow-up', function () { + fleetOpsInspectionModelDatabase(); + + $form = fleetOpsInspectionModelForm(); + $submission = fleetOpsInspectionModelSubmission($form, [ + ['label' => 'Brakes', 'passed' => false, 'photos' => ['data:image/png;base64,iVBORw0KGgo='], 'meta' => ['note' => 'squeal'], 'created_by_uuid' => 'user-driver'], + ]); + + $result = $submission->itemResults()->first(); + + expect($result->submission())->toBeInstanceOf(BelongsTo::class) + ->and($result->issue())->toBeInstanceOf(BelongsTo::class) + ->and($result->workOrder())->toBeInstanceOf(BelongsTo::class) + ->and($result->createdBy())->toBeInstanceOf(BelongsTo::class) + ->and($result->getActivitylogOptions())->toBeInstanceOf(LogOptions::class) + ->and($result->submission_id)->toBe($submission->public_id) + ->and($result->passed)->toBeFalse() + ->and($result->photos)->toBe(['data:image/png;base64,iVBORw0KGgo=']) + ->and($result->meta['note'])->toBe('squeal') + ->and($result->createdBy->name)->toBe('Dana Driver'); + + $orphan = new InspectionItemResult(); + expect($orphan->submission_id)->toBeNull(); +}); + +test('inspection link is usable only while active, unexpired and unused', function () { + fleetOpsInspectionModelDatabase(); + Carbon::setTestNow('2026-09-09 12:00:00'); + + $token = InspectionLink::generateToken(); + expect(strlen($token))->toBe(64) + ->and(InspectionLink::hashToken($token))->toBe(hash('sha256', $token)) + ->and(InspectionLink::generateToken())->not->toBe($token); + + $form = fleetOpsInspectionModelForm(); + $make = fn (array $attributes = []) => InspectionLink::create(array_merge([ + 'company_uuid' => 'company-insp', + 'inspection_form_uuid' => $form->uuid, + 'driver_uuid' => 'driver-1', + 'vehicle_uuid' => 'vehicle-1', + 'created_by_uuid' => 'user-admin', + 'token_hash' => InspectionLink::hashToken(InspectionLink::generateToken()), + 'status' => 'active', + 'single_use' => true, + ], $attributes)); + + $link = $make(); + expect($link->isUsable())->toBeTrue() + ->and($link->public_id)->toStartWith('inspection_link_') + ->and($link->form())->toBeInstanceOf(BelongsTo::class) + ->and($link->driver())->toBeInstanceOf(BelongsTo::class) + ->and($link->vehicle())->toBeInstanceOf(BelongsTo::class) + ->and($link->createdBy())->toBeInstanceOf(BelongsTo::class); + + $loaded = $link->fresh(); + expect($loaded->form->name)->toBe('Pre-trip DVIR') + ->and($loaded->driver->uuid)->toBe('driver-1') + ->and($loaded->vehicle->name)->toBe('Truck 7') + ->and($loaded->createdBy->name)->toBe('Avery Admin'); + + expect($make(['status' => 'revoked'])->isUsable())->toBeFalse() + ->and($make(['expires_at' => '2026-09-09 11:59:59'])->isUsable())->toBeFalse() + ->and($make(['expires_at' => '2026-09-09 12:00:01'])->isUsable())->toBeTrue() + ->and($make(['used_at' => '2026-09-09 11:00:00'])->isUsable())->toBeFalse() + // A reusable link stays open after it has been used. + ->and($make(['single_use' => false, 'used_at' => '2026-09-09 11:00:00'])->isUsable())->toBeTrue(); + + $link->markViewed(); + expect($link->fresh()->last_viewed_at->toDateTimeString())->toBe('2026-09-09 12:00:00'); + + $link->markUsed('203.0.113.9', 'NavigatorApp/3.0'); + $used = $link->fresh(); + expect($used->used_at->toDateTimeString())->toBe('2026-09-09 12:00:00') + ->and($used->used_ip)->toBe('203.0.113.9') + ->and($used->used_user_agent)->toBe('NavigatorApp/3.0') + ->and($used->isUsable())->toBeFalse(); +}); + +test('inspection link asks for its PIN, counts wrong ones, and locks after too many', function () { + fleetOpsInspectionModelDatabase(); + + $form = fleetOpsInspectionModelForm(); + $link = InspectionLink::create([ + 'company_uuid' => 'company-insp', + 'inspection_form_uuid' => $form->uuid, + 'created_by_uuid' => 'user-admin', + 'token_hash' => InspectionLink::hashToken(InspectionLink::generateToken()), + 'status' => 'active', + 'single_use' => true, + ]); + + // A link minted before PINs existed asks for none. + expect($link->hasPin())->toBeFalse() + ->and($link->verifyPin(null))->toBe('ok'); + + $pin = InspectionLink::generatePin(); + expect($pin)->toMatch('/^\d{6}$/'); + + $link->setPin($pin); + $link->save(); + $wrong = $pin === '000000' ? '111111' : '000000'; + + expect($link->fresh()->pin)->toBe($pin) + ->and($link->verifyPin(''))->toBe('missing') + ->and($link->verifyPin($wrong))->toBe('wrong') + ->and($link->fresh()->pin_attempts)->toBe(1) + ->and($link->pinAttemptsLeft())->toBe(InspectionLink::MAX_PIN_ATTEMPTS - 1) + // Spaces and dashes typed with the PIN do not count against it. + ->and($link->verifyPin(substr($pin, 0, 3) . ' ' . substr($pin, 3)))->toBe('ok') + ->and($link->fresh()->pin_attempts)->toBe(0); + + foreach (range(1, InspectionLink::MAX_PIN_ATTEMPTS - 1) as $attempt) { + expect($link->verifyPin($wrong))->toBe('wrong'); + } + + expect($link->verifyPin($wrong))->toBe('locked') + ->and($link->state)->toBe('locked') + ->and($link->isUsable())->toBeFalse() + // Once locked, even the right PIN is refused. + ->and($link->verifyPin($pin))->toBe('locked'); +}); + +test('inspection submitter records a submission and the follow-up the form asks for', function () { + fleetOpsInspectionModelDatabase(); + Carbon::setTestNow('2026-09-09 07:00:00'); + + $rules = InspectionSubmitter::rules(); + expect($rules['item_results'])->toBe('required_without:custom_field_values|array') + ->and($rules['custom_field_values'])->toBe('required_without:item_results|array') + ->and($rules['item_results.*.passed'])->toBe('required|boolean') + ->and($rules['item_results.*.photos.*'][1])->toBeInstanceOf(Base64OrUrl::class); + + $form = fleetOpsInspectionModelForm(); + + $submission = InspectionSubmitter::submit($form, [ + 'odometer' => 120400, + 'engine_hours' => 3100, + 'location' => ['latitude' => 1.35, 'longitude' => 103.82], + 'signature' => ['data' => 'sig'], + 'attachments' => ['file_one'], + 'item_results' => [ + ['item_key' => 'brakes', 'label' => 'Brakes', 'category' => 'Safety', 'passed' => false, 'severity' => 'critical', 'comments' => 'Soft pedal', 'photos' => ['https://cdn.example.com/brakes.jpg']], + // No explicit status: derived from `passed`. + ['item_key' => 'lights', 'label' => 'Lights', 'passed' => true], + ], + ], [ + 'driver_uuid' => 'driver-1', + 'vehicle_uuid' => 'vehicle-1', + 'submitted_by_uuid' => 'user-driver', + 'source' => 'navigator', + 'started_at' => Carbon::parse('2026-09-09 06:45:00'), + 'meta' => ['idempotency_key' => 'abc'], + ]); + + $submission = $submission->fresh(['itemResults', 'issue', 'workOrder']); + + expect($submission->status)->toBe('submitted') + ->and($submission->source)->toBe('navigator') + ->and($submission->type)->toBe('dvir') + ->and($submission->odometer)->toBe(120400) + ->and($submission->engine_hours)->toBe(3100) + ->and($submission->started_at->toDateTimeString())->toBe('2026-09-09 06:45:00') + ->and($submission->submitted_at->toDateTimeString())->toBe('2026-09-09 07:00:00') + ->and($submission->location)->toBe(['latitude' => 1.35, 'longitude' => 103.82]) + ->and($submission->attachments)->toBe(['file_one']) + ->and($submission->meta['idempotency_key'])->toBe('abc') + ->and($submission->total_items)->toBe(2) + ->and($submission->failed_items)->toBe(1) + ->and($submission->result)->toBe('failed') + ->and($submission->itemResults->pluck('status', 'item_key')->all())->toBe(['brakes' => 'failed', 'lights' => 'passed']) + ->and($submission->itemResults->firstWhere('item_key', 'brakes')->photos)->toBe(['https://cdn.example.com/brakes.jpg']) + ->and($submission->issue)->toBeInstanceOf(Issue::class) + ->and($submission->workOrder)->toBeInstanceOf(WorkOrder::class) + ->and($submission->workOrder->meta['issue_uuid'])->toBe($submission->issue->uuid); + + // A form that asks for nothing gets nothing, and a form with no type is a DVIR. + $quiet = fleetOpsInspectionModelForm(['settings' => [], 'type' => null]); + $clean = InspectionSubmitter::submit($quiet, [ + 'item_results' => [['label' => 'Brakes', 'passed' => false, 'status' => 'failed']], + ]); + + expect($clean->fresh()->type)->toBe('dvir') + ->and($clean->fresh()->started_at->toDateTimeString())->toBe('2026-09-09 07:00:00') + ->and($clean->fresh()->issue_uuid)->toBeNull() + ->and($clean->fresh()->work_order_uuid)->toBeNull() + ->and(Issue::query()->count())->toBe(1) + ->and(WorkOrder::query()->count())->toBe(1); + + // Issue only, and no failures to raise it from. + $issueOnly = fleetOpsInspectionModelForm(['settings' => ['create_issue_on_failure' => true]]); + $passed = InspectionSubmitter::submit($issueOnly, ['item_results' => [['label' => 'Brakes', 'passed' => true]]]); + expect($passed->fresh()->result)->toBe('passed') + ->and(Issue::query()->count())->toBe(1); +}); + +test('base64 or url rule accepts what the driver app sends for a photo', function () { + $rule = new Base64OrUrl(); + + expect($rule->passes('photos.0', 'https://cdn.example.com/photo.jpg'))->toBeTrue() + ->and($rule->passes('photos.0', 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAI'))->toBeTrue() + ->and($rule->passes('photos.0', "iVBORw0KGgo\nAAAANSUhEUg=="))->toBeTrue() + ->and($rule->passes('photos.0', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=='))->toBeTrue() + ->and($rule->passes('photos.0', 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='))->toBeTrue() + ->and($rule->passes('photos.0', ''))->toBeFalse() + ->and($rule->passes('photos.0', ' '))->toBeFalse() + ->and($rule->passes('photos.0', ['not', 'a', 'string']))->toBeFalse() + ->and($rule->passes('photos.0', 42))->toBeFalse() + ->and($rule->passes('photos.0', 'not base64!'))->toBeFalse() + ->and($rule->passes('photos.0', 'data:image/png;base64,'))->toBeFalse() + ->and($rule->passes('photos.0', 'data:image/png;base64,***'))->toBeFalse() + ->and($rule->message())->toContain(':attribute'); +}); diff --git a/server/tests/InspectionSubmitterRulesTest.php b/server/tests/InspectionSubmitterRulesTest.php new file mode 100644 index 000000000..2fa092a87 --- /dev/null +++ b/server/tests/InspectionSubmitterRulesTest.php @@ -0,0 +1,35 @@ +toHaveKeys([ + 'custom_field_values', + 'custom_field_values.*.custom_field', + 'custom_field_values.*.custom_field_uuid', + 'custom_field_values.*.value', + 'custom_field_values.*.value_type', + ]); + + // An empty answer is still an answer (it clears the field), so the value + // is allowed to be null rather than required. + expect($rules['custom_field_values.*.value'])->toBe('nullable') + ->and($rules['custom_field_values.*.custom_field_uuid'])->toContain('nullable'); + + // Every key of a flat item result has a rule too, so none of them is dropped. + foreach (['item_key', 'label', 'category', 'status', 'severity', 'passed', 'comments', 'photos'] as $key) { + expect($rules)->toHaveKey('item_results.*.' . $key); + } +}); diff --git a/server/tests/ObserverContractsTest.php b/server/tests/ObserverContractsTest.php index ff2a6d8ae..aef25a1de 100644 --- a/server/tests/ObserverContractsTest.php +++ b/server/tests/ObserverContractsTest.php @@ -744,7 +744,7 @@ protected function resolveOrder(PurchaseRate $purchaseRate): ?Order 'maintainable_type' => 'fleet-ops:vehicle', 'maintainable_uuid' => 'vehicle-uuid', 'type' => 'scheduled', - 'status' => 'done', + 'status' => 'completed', 'priority' => 'high', 'performed_by_type' => 'fleet-ops:contact', 'performed_by_uuid' => 'assignee-uuid', diff --git a/server/tests/ReportSchemaContractsTest.php b/server/tests/ReportSchemaContractsTest.php index 9b8b07561..b1465b71c 100644 --- a/server/tests/ReportSchemaContractsTest.php +++ b/server/tests/ReportSchemaContractsTest.php @@ -219,6 +219,9 @@ function fleetOpsReportRelationship(Table|Relationship $container, string $name) 'contacts', 'vendors', 'fuel_reports', + 'work_orders', + 'maintenances', + 'inspection_submissions', ]); $orders = $tables['orders']; @@ -248,7 +251,18 @@ function fleetOpsReportRelationship(Table|Relationship $container, string $name) ->and(fleetOpsReportTableMeta($tables['places'], 'category'))->toBe('Geography') ->and(fleetOpsReportTableMeta($tables['contacts'], 'category'))->toBe('CRM') ->and(fleetOpsReportTableMeta($tables['vendors'], 'category'))->toBe('CRM') - ->and(fleetOpsReportTableMeta($tables['fuel_reports'], 'category'))->toBe('Operations'); + ->and(fleetOpsReportTableMeta($tables['fuel_reports'], 'category'))->toBe('Operations') + ->and(fleetOpsReportTableMeta($tables['work_orders'], 'category'))->toBe('Maintenance') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['work_orders'], 'vehicle_target')))->toBe('vehicles') + ->and(fleetOpsReportColumnAggregate(fleetOpsReportColumn($tables['work_orders'], 'total_actual_cost')))->toBe('sum') + ->and(fleetOpsReportTableMeta($tables['maintenances'], 'label'))->toBe('Maintenance History') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['maintenances'], 'work_order')))->toBe('work_orders') + ->and(fleetOpsReportTableMeta($tables['inspection_submissions'], 'label'))->toBe('Inspections') + ->and(fleetOpsReportTableMeta($tables['inspection_submissions'], 'category'))->toBe('Maintenance') + ->and(fleetOpsReportColumnFlag(fleetOpsReportColumn($tables['inspection_submissions'], 'result'), 'aggregatable'))->toBeTrue() + ->and(fleetOpsReportColumnAggregate(fleetOpsReportColumn($tables['inspection_submissions'], 'total_failed_items')))->toBe('sum') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['inspection_submissions'], 'inspection_form')))->toBe('inspection_forms') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['inspection_submissions'], 'work_order')))->toBe('work_orders'); }); test('fleetops report schema transformers normalize labels booleans distances and money', function () { diff --git a/server/tests/RouteRegistrationExecutionTest.php b/server/tests/RouteRegistrationExecutionTest.php index 35e90c5f2..803c50f7b 100644 --- a/server/tests/RouteRegistrationExecutionTest.php +++ b/server/tests/RouteRegistrationExecutionTest.php @@ -1,6 +1,35 @@ recorder->routes[$this->index]['middleware'] = array_merge( + $this->recorder->routes[$this->index]['middleware'] ?? [], + (array) $middleware, + ); + + return $this; + } + + public function __call(string $method, array $arguments): self + { + return $this; + } +} class FleetOpsRouteRecorder { @@ -49,47 +78,69 @@ public function group(array|callable $attributes, ?callable $callback = null): s return $this; } - public function get(string $uri, string|array $action): void + public function get(string $uri, string|array $action): FleetOpsRecordedRoute { - $this->record('GET', $uri, $action); + return $this->record('GET', $uri, $action); } - public function post(string $uri, string|array $action): void + public function post(string $uri, string|array $action): FleetOpsRecordedRoute { - $this->record('POST', $uri, $action); + return $this->record('POST', $uri, $action); } - public function put(string $uri, string|array $action): void + public function put(string $uri, string|array $action): FleetOpsRecordedRoute { - $this->record('PUT', $uri, $action); + return $this->record('PUT', $uri, $action); } - public function patch(string $uri, string|array $action): void + public function patch(string $uri, string|array $action): FleetOpsRecordedRoute { - $this->record('PATCH', $uri, $action); + return $this->record('PATCH', $uri, $action); } - public function delete(string $uri, string|array $action): void + public function delete(string $uri, string|array $action): FleetOpsRecordedRoute { - $this->record('DELETE', $uri, $action); + return $this->record('DELETE', $uri, $action); } - public function any(string $uri, string|array $action): void + public function any(string $uri, string|array $action): FleetOpsRecordedRoute { - $this->record('ANY', $uri, $action); + return $this->record('ANY', $uri, $action); } - public function match(array $methods, string $uri, string|array $action): void + public function match(array $methods, string $uri, string|array $action): FleetOpsRecordedRoute { - $this->record(implode('|', array_map('strtoupper', $methods)), $uri, $action); + return $this->record(implode('|', array_map('strtoupper', $methods)), $uri, $action); } - public function fleetbaseRoutes(string $resource): void + /** + * The platform's resource routes, and the extra routes a resource declares + * in its callback. The callback is run inside a group prefixed with the + * resource, as the platform's macro does, and handed a `$controller` that + * names the action the way the macro would, so those routes are recorded + * rather than silently skipped. + */ + public function fleetbaseRoutes(string $resource, ?callable $callback = null): FleetOpsRecordedRoute { - $this->record('FLEETBASE', $resource, 'fleetbaseRoutes'); + $route = $this->record('FLEETBASE', $resource, 'fleetbaseRoutes'); + + if ($callback) { + $controllerName = Str::studly(Str::singular($resource)) . 'Controller'; + $this->group(['prefix' => $resource], function ($router) use ($callback, $controllerName) { + $callback($router, fn (string $method) => $controllerName . '@' . $method); + }); + } + + return $route; + } + + /** Router methods the recorder does not model are accepted and ignored. */ + public function __call(string $method, array $arguments): self + { + return $this; } - private function record(string $method, string $uri, string|array $action): void + private function record(string $method, string $uri, string|array $action): FleetOpsRecordedRoute { $prefixes = array_values(array_filter(array_map( fn (array $group) => $group['prefix'] ?? null, @@ -102,6 +153,8 @@ private function record(string $method, string $uri, string|array $action): void 'action' => $action, 'groups' => $this->stack, ]; + + return new FleetOpsRecordedRoute($this, array_key_last($this->routes)); } } @@ -139,7 +192,37 @@ function fleetOpsRecordedRoutes(): FleetOpsRouteRecorder ->toContain('v1/fuel-transactions/{id}/match-vehicle') ->toContain('int/v1/fleet-ops/analytics/operations-pulse') ->toContain('int/v1/fleet-ops/metrics/{slug}') - ->toContain('int/v1/fleet-ops/hubs/resources'); + ->toContain('int/v1/fleet-ops/hubs/resources') + ->toContain('public/inspections/forms/{id}') + ->toContain('public/inspections/forms/{id}/submit') + ->toContain('public/inspections/forms/{id}/files'); + + // A link's PIN can be sent again from the console. + $sendPin = array_values(array_filter($recorder->routes, fn (array $route) => str_ends_with($route['uri'], 'inspection-forms/{id}/links/{linkId}/send-pin'))); + expect($sendPin)->toHaveCount(1) + ->and($sendPin[0]['method'])->toBe('POST') + ->and($sendPin[0]['action'])->toBe('InspectionFormController@sendPin'); + + // Every inspection route answers in JSON, so a refused request is a 422 + // rather than a redirect: the driver API, the vehicle history and the + // console's form and submission routes. + $json = Fleetbase\FleetOps\Http\Middleware\ForceJsonResponse::class; + $inGroup = fn (array $route) => in_array($json, array_merge(...array_map(fn ($group) => (array) ($group['middleware'] ?? []), $route['groups'])), true) + || in_array($json, (array) ($route['middleware'] ?? []), true); + foreach (['InspectionController@findForm', 'InspectionController@submit', 'InspectionController@forVehicle'] as $action) { + $route = collect($recorder->routes)->first(fn (array $route) => $route['action'] === $action); + expect($route)->not->toBeNull() + ->and($inGroup($route))->toBeTrue(); + } + $console = array_filter($recorder->routes, fn (array $route) => str_contains($route['uri'], 'inspection-forms/{id}/generate-link') || str_contains($route['uri'], 'inspection-submissions/{id}/submit')); + expect($console)->toHaveCount(2); + foreach ($console as $route) { + expect($inGroup($route))->toBeTrue(); + } + + // Uploads through a link have a tighter limit of their own, on top of the group's. + $upload = collect($recorder->routes)->firstWhere('uri', 'public/inspections/forms/{id}/files'); + expect($upload['middleware'] ?? [])->toBe(['throttle:20,1,inspection-upload']); }); test('fleetops route file wires route groups with expected middleware and namespaces', function () { @@ -156,6 +239,14 @@ function fleetOpsRecordedRoutes(): FleetOpsRouteRecorder 'namespace' => 'Api\v1', ]); + // The public inspection routes answer in JSON whatever the client asks + // for, and are rate limited per address. + expect($recorder->groups)->toContainEqual([ + 'prefix' => 'public', + 'namespace' => 'Public', + 'middleware' => [Fleetbase\FleetOps\Http\Middleware\ForceJsonResponse::class, 'throttle:60,1,inspection-public'], + ]); + expect($recorder->groups)->toContainEqual([ 'prefix' => 'int', 'namespace' => 'Internal', diff --git a/server/tests/Unit/Http/Resources/ManifestResourceTest.php b/server/tests/Unit/Http/Resources/ManifestResourceTest.php index 6878184c6..547d1bb2f 100644 --- a/server/tests/Unit/Http/Resources/ManifestResourceTest.php +++ b/server/tests/Unit/Http/Resources/ManifestResourceTest.php @@ -64,7 +64,7 @@ public function getVehicleNameAttribute(): ?string } test('manifest resource publishes what a driver needs to run a route', function () { - $request = Request::create('/v1/manifests/manifest_public', 'GET'); + $request = Request::create('/v1/manifests/manifest_public', 'GET'); FleetOpsSupportRequestState::$request = $request; $manifest = new FleetOpsManifestFake(); @@ -94,7 +94,7 @@ public function getVehicleNameAttribute(): ?string test('manifest resource omits stops when they were not loaded, so a list stays a list', function () { // A driver's manifest list on a busy fleet must not drag every stop of // every route along with it. - $request = Request::create('/v1/drivers/driver_public/manifests', 'GET'); + $request = Request::create('/v1/drivers/driver_public/manifests', 'GET'); FleetOpsSupportRequestState::$request = $request; $manifest = new FleetOpsManifestFake(); @@ -106,7 +106,7 @@ public function getVehicleNameAttribute(): ?string }); test('manifest stop resource carries the sequence a re-sequence rewrites', function () { - $request = Request::create('/v1/manifest-stops/stop_public', 'GET'); + $request = Request::create('/v1/manifest-stops/stop_public', 'GET'); FleetOpsSupportRequestState::$request = $request; $stop = new ManifestStop(); diff --git a/translations/ar-ae.yml b/translations/ar-ae.yml index d377b2870..066c9fd0f 100644 --- a/translations/ar-ae.yml +++ b/translations/ar-ae.yml @@ -2,6 +2,8 @@ menu: fuel-providers: تكاملات الوقود fuel-transactions: معاملات الوقود trailers: المقطورات + inspection-forms: 'نماذج الفحص' + inspections: 'الفحوصات' trailer: navigation-description: إدارة الأصول المقطورة ووصلات القطر والمعدات والتتبع عن بُعد. diff --git a/translations/bg-bg.yaml b/translations/bg-bg.yaml index d211080a5..25913ea11 100644 --- a/translations/bg-bg.yaml +++ b/translations/bg-bg.yaml @@ -73,6 +73,8 @@ menu: issues: Проблеми maintenance: Поддръжка work-orders: Работни поръчки + inspection-forms: 'Формуляри за инспекция' + inspections: 'Инспекции' equipment: Оборудване parts: Части connectivity: Свързаност diff --git a/translations/en-us.yaml b/translations/en-us.yaml index ade8bc7e9..8babe2bc1 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -86,6 +86,8 @@ menu: maintenance: Maintenance schedules: Schedules work-orders: Work Orders + inspection-forms: 'Inspection Forms' + inspections: 'Inspections' equipment: Equipment parts: Parts maintenance-history: Maintenance History @@ -2746,3 +2748,266 @@ orchestrator: stop-type-dropoff: Dropoff pod-required: POD no-pod: No POD + +inspection: + form: + overview: Overview + details: Form Details + name: Name + name-placeholder: Daily vehicle inspection + type: Type + status: Status + description: Description + description-placeholder: What this inspection covers + builder: Form Builder + fields: Fields + published: Published + structure: Form Structure + loading-structure: Loading form structure... + no-structure: This form has no field groups yet. + settings: Settings + type-placeholder: Select the kind of inspection this form is + status-placeholder: Select a status + setting-create-issue: Open a defect record when an item fails + setting-create-issue-help: A failed inspection files an issue against the vehicle, so the failure has somewhere to live. + setting-create-work-order: Open a work order when an item fails + setting-create-work-order-help: The failed items become a work order checklist for the workshop. Needs workshops enabled. + setting-require-signature: Require the driver to sign + setting-require-signature-help: The driver app will not submit without a signature. Add a signature field to the form instead if you want it in a particular place. + legacy-checklist: Legacy Checklist + legacy-checklist-help: The first cut's checklist, kept read-only. It is migrated into a "Checklist" group of pass/fail fields. + create: Create inspection form + created: Inspection form created. + updated: Inspection form updated. + builder: + help: Group the things a driver checks, then add a field for each one. + loading: Loading form builder... + new-group: New field group + new-field: New field + edit-field: 'Edit field: {label}' + save-field: Save field + untitled-group: Untitled group + untitled-field: Untitled field + group-name: Group name + group-name-placeholder: Exterior + group-description: Group description + group-description-placeholder: What this group covers + grid-size: Columns + move-up: Move up + move-down: Move down + no-fields: No fields in this group yet. + empty-title: No field groups + empty-description: Add your first field group to start building this form. + delete: Delete + delete-group-title: Delete this field group? + delete-group-body: Deleting this group deletes every field inside it. This cannot be undone once the form is saved. + delete-field-title: Delete this field? + delete-field-body: Deleting this field also deletes every answer already filed against it. This cannot be undone once the form is saved. + field: + label: Field label + label-placeholder: Brake lights + name: Field name + name-help: The machine name a submission refers to this field by. It follows the label until you set it yourself. + name-placeholder: brake-lights + type: Field type + description: Field description + description-placeholder: What to check, and what a pass looks like + help-text: Field help text + help-text-placeholder: Shown beside the field while it is answered + required: Field is required + editable: Field is editable + options: Field options + no-options: No options yet. + option-placeholder: Add an option + add-option: Add + unit: Unit + unit-help: Shown beside the number, for example km or hours. + unit-placeholder: km + odometer-role: This is the odometer reading + odometer-role-help: The answer is copied to the inspection's own odometer column. + on-fail: On fail + on-fail-help: What a failed answer means, and what the inspector must supply before it is accepted. + default-severity: Default severity + require-photo-on-fail: Require a photo on fail + require-comment-on-fail: Require a comment on fail + unsafe-on-fail: Mark the vehicle unsafe on fail + unsafe-on-fail-help: A failed answer defaults to unsafe, which is what an out-of-service defect is. + instructions: Instructions + instructions-placeholder: How to check this, shown to the driver + column-span: Column span + field-type: + pass-fail: Pass / Fail + input: Text + textarea: Long text + number: Number + select: Select + radio-button: Radio buttons + boolean: Yes / No + date-picker: Date + date-time-input: Date and time + file-upload: Photo or file + signature: Signature + severity: + low: Low + medium: Medium + high: High + critical: Critical + answer: + pass: Pass + fail: Fail + not-applicable: N/A + unanswered: Unanswered + severity: Severity + unsafe: Unsafe to operate + comments: Comments + comments-placeholder: What is wrong, and what it needs + photos: Photos + add-photo: Add photo + upload-photo: Upload photo + no-photo: No photo attached. + upload-signature: Upload signature + no-signature: No signature captured. + select-placeholder: Choose an answer + note-placeholder: Add a note + text-placeholder: Type an answer + no-options: This field has no answers to choose from. + uploads-unavailable: Can be added from the console or the driver app. + comments-required: Say what is wrong, and what it needs + comment-required: Comment mandatory on fail + photo-required: Photo mandatory on fail + comment-and-photo-required: Comment + photo mandatory on fail + record: + overview: Overview + inspection: Inspection + details: Inspection Details + form: Form + select-form: Select inspection form + status: Status + result: Result + vehicle: Vehicle + select-vehicle: Select vehicle + driver: Driver + select-driver: Select driver + odometer: Odometer + odometer-placeholder: Current odometer + engine-hours: Engine Hours + engine-hours-placeholder: Current engine hours + answers: Answers + loading-form: Loading inspection form... + loading-answers: Loading answers... + form-has-no-fields: This form has no fields yet. Build it on the inspection form screen before filling it in. + summary: Summary + failed-of: '{failed} failed of {total} pass/fail checks' + metadata: Metadata + submitted: Submitted + submitted-by: Submitted By + via-link: Through a public link + via-link-pin: Through a public link, PIN verified + signed-as: 'Signed as "{name}"' + name-unverified: Name as typed, not a signed-in account + resolved: Resolved + item-results: Item Results + no-item-results: No item results recorded. + follow-up: Follow Up + linked-issue: Linked Issue + linked-work-order: Linked Work Order + photos: Photos + loading-photos: Loading photos... + audit: Audit + create: Create inspection + saved: Inspection saved. + updated: Inspection updated. + form-help: The published form this inspection is filled in from. + choose-a-form: Choose an inspection form to begin. + group-has-no-fields: This section has no checks. + outstanding: still to answer + section-outstanding: "{count, plural, one {# outstanding} other {# outstanding}}" + outstanding-field: "{label} is required before submitting" + review: "Review →" + jump-to: "Jump to →" + link: + public-links: Public Links + existing: Generated Links + modal-help: "Generate a single-use link for {form}. Whoever opens it enters its PIN, then fills in this published form without signing in." + loading: Loading links... + none: No links have been generated for this form yet. + load-failed: These links could not be loaded. + copy: Copy + copied: Inspection link copied. + revoke: Revoke + revoked: Inspection link revoked. + generated: Generated + generated-toast: Inspection link generated. + publish-first: Publish this inspection form before generating a public link. + expires: Expires + no-expiry: No expiry + used: Used + viewed: Last opened + never-opened: Never opened + unassigned: Anyone with the link and its PIN + url-not-kept: This link was generated before links were kept, so its URL cannot be shown again. + state-active: Active + state-expired: Expired + state-used: Used + state-revoked: Revoked + state-locked: Locked + expires-at: Expires at + expires-help: Links expire 72 hours after they are generated unless you choose a different time. + assign-to: Assign to + assign-to-help: Optional. Anyone in your organisation can complete an inspection; the submission is credited to them. + select-assignee: Select a user + pin: PIN + pin-delivery: Send the link and PIN + pin-delivery-none: Do not send it, I will share it myself + pin-delivery-email: By email + pin-delivery-sms: By text message + pin-delivery-help: "Sent to {name}, with the link to open and the PIN to enter." + pin-delivery-no-recipient: Assign the link to someone, or pick a driver, to send it to them. Otherwise share the link and PIN yourself; both are shown once it is generated. + generated-title: Link generated + generated-help: The link is copied to your clipboard. Whoever opens it needs the PIN too. + copied-pin: PIN copied. + email-pin: Email link + text-pin: Text link + pin-sent-email: "Link and PIN emailed to {to}." + pin-sent-sms: "Link and PIN texted to {to}." + pin-not-sent: "The link and PIN were not sent: {reason}" + pin-sent-by: "{via, select, email {Emailed} sms {Texted} other {Sent}}" + wrong-pins: "{count, plural, one {# wrong PIN} other {# wrong PINs}}" + no-pin: No PIN. This link was generated before links had PINs. + public: + submitted-title: Inspection submitted + submitted-body: Thank you. This inspection has been filed and the link is now closed. + unavailable-title: This inspection is not available + sign-off: Sign off + your-name: Your name + your-name-help: Recorded against this inspection as who filled it in. + your-name-placeholder: Full name + for: For + pin-title: Enter your PIN + pin-help: Enter the 6-digit PIN you were given for this inspection. + pin-label: PIN + pin-continue: Continue + pin-wrong: "That PIN is not right. {count, plural, one {# attempt} other {# attempts}} left before this link locks." + submit: Submit inspection + blocked-required: "{count, plural, one {# required answer is missing} other {# required answers are missing}}" + blocked-defects: "{count, plural, one {# failure still needs a comment or a photo} other {# failures still need a comment or a photo}}" + flyout: + title: "Defect: {label}" + done: Done + close: Close + defect: + edit: Edit defect + photos: "{count, plural, one {# photo} other {# photos}}" + comment: Comment + needs-comment: Needs a comment + needs-photo: Needs a photo + needs-both: Needs a comment and a photo + no-evidence: No comment or photo yet + tray: + title: Defects + review: Review +select-option: + vehicle: + vin: VIN + serial_number: S/N + call_sign: Call sign diff --git a/translations/es-pa.yaml b/translations/es-pa.yaml index 67977f8f6..74f529137 100644 --- a/translations/es-pa.yaml +++ b/translations/es-pa.yaml @@ -70,6 +70,8 @@ menu: issues: Asuntos maintenance: Mantenimiento work-orders: Órdenes de trabajo + inspection-forms: 'Formularios de inspección' + inspections: 'Inspecciones' equipment: Equipo parts: Partes connectivity: Conectividad diff --git a/translations/fr-fr.yaml b/translations/fr-fr.yaml index 7697ceda0..5da3f81d1 100644 --- a/translations/fr-fr.yaml +++ b/translations/fr-fr.yaml @@ -73,6 +73,8 @@ menu: issues: Problèmes maintenance: Maintenance work-orders: Ordres de travail + inspection-forms: "Formulaires d'inspection" + inspections: "Inspections" equipment: Équipement parts: Pièces connectivity: Connectivité diff --git a/translations/mn-mn.yaml b/translations/mn-mn.yaml index 461cd39d6..ecbb21d95 100644 --- a/translations/mn-mn.yaml +++ b/translations/mn-mn.yaml @@ -73,6 +73,8 @@ menu: issues: Асуудлууд maintenance: Үйлчилгээ work-orders: Ажлын захиалгууд + inspection-forms: 'Үзлэгийн маягтууд' + inspections: 'Үзлэгүүд' equipment: Тоног төхөөрөмж parts: Сэлбэгүүд connectivity: Холболт diff --git a/translations/pt-br.yaml b/translations/pt-br.yaml index a81180389..3e41da195 100644 --- a/translations/pt-br.yaml +++ b/translations/pt-br.yaml @@ -73,6 +73,8 @@ menu: issues: Problemas maintenance: Manutenção work-orders: Ordens de Serviço + inspection-forms: 'Formulários de inspeção' + inspections: 'Inspeções' equipment: Equipamentos parts: Peças connectivity: Conectividade diff --git a/translations/ru-ru.yaml b/translations/ru-ru.yaml index 337f7c229..3243236e1 100644 --- a/translations/ru-ru.yaml +++ b/translations/ru-ru.yaml @@ -73,6 +73,8 @@ menu: issues: Проблемы maintenance: Обслуживание work-orders: Рабочие заказы + inspection-forms: 'Формы осмотра' + inspections: 'Осмотры' equipment: Оборудование parts: Запчасти connectivity: Подключение diff --git a/translations/vi-vn.yaml b/translations/vi-vn.yaml index 5d86b0b45..8a4820e33 100644 --- a/translations/vi-vn.yaml +++ b/translations/vi-vn.yaml @@ -2,6 +2,8 @@ menu: fuel-providers: Tích hợp Nhiên liệu fuel-transactions: Giao dịch nhiên liệu trailers: Rơ-moóc + inspection-forms: 'Biểu mẫu kiểm tra' + inspections: 'Kiểm tra' resource: trailer: Rơ-moóc