diff --git a/RELEASE.md b/RELEASE.md index af8b70063..820f96c41 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,15 +1,20 @@ -> v0.6.65 ~ "First-class trailer operations" +> v0.6.66 ~ "Inspections" --- ## What's New -- **Trailers become a first-class Fleet-Ops resource.** Create and manage trailers independently, attach them to vehicles, connect equipment and telematics devices, and review operational, maintenance, location, and towing details from dedicated Trailer screens. -- **Vehicle and equipment relationships are easier to understand.** Vehicle details expose attached trailers and equipment, while Trailer details show the current towing vehicle and installed equipment. -- **Trailer APIs support complete integrations.** Internal and public endpoints cover trailer lifecycle, filtering, towing connections, equipment, devices, telemetry, imports, and exports. +- **Inspection forms are built from typed fields.** A form is groups of fields laid out on a grid: pass/fail checks with severity and on-fail settings, text, numbers, selections, dates, photo uploads and signatures. Forms are built and published from a form builder in the console. +- **One inspection sheet everywhere.** The same sheet is used to fill in an inspection in the console, to read one back, and on a public link. A failed check opens its severity, unsafe flag, comment and photos in place, and a defects tray summarises what needs attention. It works on phones and tablets. +- **Drivers file inspections through the API.** The `v1` inspection endpoints list published forms, file an inspection against one, and read back submissions and a vehicle's history, for the Navigator app and other integrations. +- **Inspection links for anyone in the organisation.** A link can be assigned to any user, protected by a six-digit PIN, and emailed or texted to them. A failed inspection can raise an issue and open a work order, and every submission records who filed it. + +--- +## Fixes +- Place, zone and service area details, and the place and point map modals, no longer fail to render with "A resolved helper cannot be passed as a named argument". --- ## Testing -- Added frontend, backend, API-contract, attachment, telematics, spatial-data, permission, and serialization coverage for first-class trailers. -- Expanded the Fleetbase Postman collection and documentation alongside the Fleet-Ops implementation. +- Model and controller contract tests cover inspection forms, submissions, links and the PIN lockout. +- The Fleetbase Postman collection documents the `v1` inspection endpoints. --- ## Need help? diff --git a/addon/components/inspection-field/form.hbs b/addon/components/inspection-field/form.hbs new file mode 100644 index 000000000..94342955d --- /dev/null +++ b/addon/components/inspection-field/form.hbs @@ -0,0 +1,103 @@ +
+ + + + + + + + + + + + + + + + + + + + +
+ + +
+ + {{#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..66ad416d7 --- /dev/null +++ b/addon/components/inspection-field/value.hbs @@ -0,0 +1,90 @@ +{{! + One stored answer, read-only. + + Every answer keeps the column the form gave it: a pass or a fail is a + coloured card in its cell, and only a note, an upload or a signature — + which need the room — spans the grid. +}} +{{#if this.isResult}} + {{! Pass, fail and N/A share one card, coloured by the answer, sitting in + the column the form gave it. A fail carries its severity, its unsafe + flag, the comment and the photos beneath the head. }} +
+
+ {{t this.resultLabel}} + {{this.label}} +
+ {{#if this.hasResultDetail}} +
+ {{#if this.hasResultMeta}} +
+ {{#if this.severityLabel}}{{t this.severityLabel}}{{/if}} + {{#if this.answer.unsafe}}{{t "inspection.answer.unsafe"}}{{/if}} +
+ {{/if}} + {{#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}} +
+ {{#if this.isAnswered}} +

{{@value}}

+ {{else}} + {{t "inspection.answer.unanswered"}} + {{/if}} +
+ {{/if}} +
+ +{{else}} +
+ {{this.label}} +
+ {{#if this.isBoolean}} + {{if this.booleanValue (t "common.yes") (t "common.no")}} + {{else}} + {{#if this.isAnswered}} + {{@value}} + {{#if this.meta.unit}}{{this.meta.unit}}{{/if}} + {{else}} + {{t "inspection.answer.unanswered"}} + {{/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..b1790a2f4 --- /dev/null +++ b/addon/components/inspection-field/value.js @@ -0,0 +1,158 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { INSPECTION_SEVERITIES } from '../../utils/inspection-field-types'; +import { answerState, ROOMY_FIELD_TYPES } from '../../utils/inspection-answers'; + +/** + * One stored answer, read-only — what the record's Overview shows. + * + * The submission resource hands the console a value already projected: a file + * value resolved to `{ id, url, filename, content_type }`, and a pass-fail + * answer as an object with its photos resolved the same way. This renders + * that, whichever shape the value arrived in; it never fetches and never + * writes. + */ +export default class InspectionFieldValueComponent extends Component { + @service intl; + + get field() { + return this.args.field ?? {}; + } + + get meta() { + const meta = this.field.meta; + return meta && typeof meta === 'object' ? meta : {}; + } + + get label() { + return this.field.label || this.field.name || this.intl.t('inspection.builder.untitled-field'); + } + + get answerState() { + return answerState(this.field, this.args.value); + } + + /** Pass, fail and N/A all read as the same card. */ + get isResult() { + return this.isPassFail; + } + + /** A fail shows what it was marked with; a pass has nothing to add. */ + get hasResultMeta() { + return this.answerState === 'fail' && Boolean(this.severityLabel || this.answer.unsafe); + } + + get hasResultDetail() { + return this.hasResultMeta || Boolean(this.answer.comments) || this.photos.length > 0; + } + + get isDefect() { + return this.isPassFail && this.answerState === 'fail'; + } + + /** Whether anything was actually answered, so an empty one can say so. */ + get isAnswered() { + const value = this.args.value; + + return value !== null && value !== undefined && String(value).trim() !== ''; + } + + get isRoomy() { + return ROOMY_FIELD_TYPES.includes(this.field.type); + } + + get isStackedBand() { + return this.field.type === 'textarea'; + } + + get isTargeted() { + return Boolean(this.field.uuid) && this.args.targetId === this.field.uuid; + } + + /** A failure's comment and photos, shown only when there is something to show. */ + get hasDefectDetail() { + return this.isPassFail && (Boolean(this.answer.comments) || this.photos.length > 0); + } + + get isPassFail() { + return this.field.type === 'pass-fail'; + } + + get isFile() { + return this.field.type === 'file-upload' || this.field.type === 'signature'; + } + + get isBoolean() { + return this.field.type === 'boolean'; + } + + get answer() { + const value = this.args.value; + if (value && typeof value === 'object' && !Array.isArray(value)) { + return { ...value, photos: Array.isArray(value.photos) ? value.photos : [] }; + } + + if (typeof value === 'boolean') { + return { passed: value, not_applicable: false, photos: [] }; + } + + return { passed: null, not_applicable: false, photos: [] }; + } + + get resultLabel() { + const answer = this.answer; + if (answer.not_applicable === true) { + return 'inspection.answer.not-applicable'; + } + + if (answer.passed === false) { + return 'inspection.answer.fail'; + } + + if (answer.passed === true) { + return 'inspection.answer.pass'; + } + + return 'inspection.answer.unanswered'; + } + + get resultStatus() { + const answer = this.answer; + if (answer.not_applicable === true) { + return 'info'; + } + + return answer.passed === false ? 'failed' : 'passed'; + } + + /** The severity's own label, or the raw value when it is not one of ours. */ + get severityLabel() { + const severity = this.answer.severity; + return INSPECTION_SEVERITIES.includes(severity) ? `inspection.severity.${severity}` : null; + } + + get photos() { + return this.answer.photos.map((photo) => this.#describeFile(photo)); + } + + get file() { + return this.#describeFile(this.args.value); + } + + get booleanValue() { + const value = this.args.value; + return value === true || value === 'true' || value === 1 || value === '1'; + } + + #describeFile(value) { + if (value && typeof value === 'object') { + return { reference: value.id, url: value.url, filename: value.filename }; + } + + if (typeof value !== 'string' || value === '') { + return { reference: null, url: null, filename: null }; + } + + return { reference: value, url: value.startsWith('http') ? value : null, filename: null }; + } +} diff --git a/addon/components/inspection-flyout.hbs b/addon/components/inspection-flyout.hbs new file mode 100644 index 000000000..b525a2d26 --- /dev/null +++ b/addon/components/inspection-flyout.hbs @@ -0,0 +1,34 @@ +{{#if this.mount}} + {{#in-element this.mount insertBefore=null}} + {{#if this.isSheet}} + + {{/if}} + + {{/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..7574a80d3 --- /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/details/submissions.hbs b/addon/components/inspection-form/details/submissions.hbs new file mode 100644 index 000000000..4c0c37184 --- /dev/null +++ b/addon/components/inspection-form/details/submissions.hbs @@ -0,0 +1,49 @@ +
+ {{#if this.loadSubmissions.isRunning}} +
+ +
+ {{else if this.submissions.length}} +
+ {{#each this.submissions as |submission|}} + + {{/each}} +
+ {{else}} +
+ +

{{t "inspection.form.no-submissions"}}

+

{{t "inspection.form.no-submissions-description"}}

+
+ {{/if}} +
diff --git a/addon/components/inspection-form/details/submissions.js b/addon/components/inspection-form/details/submissions.js new file mode 100644 index 000000000..67292f9fd --- /dev/null +++ b/addon/components/inspection-form/details/submissions.js @@ -0,0 +1,42 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; + +/** + * What has been filed against this form, newest first. + * + * The mirror of the vehicle's Inspections tab: there you ask what happened to + * one truck, here what a form has collected. Every row shares the form, so the + * vehicle leads instead and the form name is left out entirely. + */ +export default class InspectionFormDetailsSubmissionsComponent extends Component { + @service inspectionSubmissionActions; + @service notifications; + @service store; + @tracked submissions = []; + + get form() { + return this.args.resource ?? this.args.form; + } + + constructor() { + super(...arguments); + this.loadSubmissions.perform(); + } + + @task *loadSubmissions() { + try { + // `inspection_form_uuid` is fillable, which is what makes the index + // filter bind to it — the same route `vehicle_uuid` takes. + const submissions = yield this.store.query('inspection-submission', { + inspection_form_uuid: this.form.uuid ?? this.form.id, + sort: '-created_at', + }); + + this.submissions = Array.from(submissions ?? []); + } catch (error) { + this.notifications.serverError(error); + } + } +} diff --git a/addon/components/inspection-form/form.hbs b/addon/components/inspection-form/form.hbs new file mode 100644 index 000000000..47cce3609 --- /dev/null +++ b/addon/components/inspection-form/form.hbs @@ -0,0 +1,93 @@ +
+ +
+ + + + +
+ +
+
{{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..d4b783b03 --- /dev/null +++ b/addon/components/inspection-submission/details.hbs @@ -0,0 +1,154 @@ +
+ +
+
+
{{t "inspection.record.inspection"}}
+ {{n-a @resource.public_id}} +
+
+
{{t "inspection.record.result"}}
+
{{smart-humanize @resource.result}}
+
+
+
{{t "inspection.record.form"}}
+ {{#if @resource.form.public_id}} + + {{else}} +
{{n-a (or @resource.form.name @resource.form_name)}}
+ {{/if}} +
+
+
{{t "inspection.record.status"}}
+
{{smart-humanize @resource.status}}
+
+
+
{{t "inspection.record.vehicle"}}
+
+
+
+
{{t "inspection.record.driver"}}
+
+
+
+
{{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}} + + {{! Derived from the pass/fail answers above, so it is shown only for a + submission filed before typed fields, which has nothing else to read. }} + {{#unless this.hasAnswers}} + +
+ {{#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}} +
+
+ {{/unless}} + + + {{! A bare id was a dead end: each follow-up says what it is and opens it. }} +
+ {{#if @resource.issue}} + + {{else}} +
+
{{t "inspection.record.linked-issue"}}
+
{{n-a @resource.issue_uuid}}
+
+ {{/if}} + + {{#if @resource.work_order}} + + {{else}} +
+
{{t "inspection.record.linked-work-order"}}
+
{{n-a @resource.work_order_uuid}}
+
+ {{/if}} +
+
+ + + +
diff --git a/addon/components/inspection-submission/details.js b/addon/components/inspection-submission/details.js new file mode 100644 index 000000000..a1bdd38d4 --- /dev/null +++ b/addon/components/inspection-submission/details.js @@ -0,0 +1,130 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { debug } from '@ember/debug'; +import { next } from '@ember/runloop'; +import { task } from 'ember-concurrency'; + +/** + * The Overview tab of an inspection record. + * + * The answers are read from the submission's own payload — the resource + * projects them with each field's identity and every file reference resolved — + * and laid out against the form's groups, so the record reads the way the form + * was built. Nothing is written here. + */ +export default class InspectionSubmissionDetailsComponent extends Component { + @service inspectionFormActions; + @service inspectionSubmissionActions; + @service intl; + @service hostRouter; + @service vehicleActions; + @service driverActions; + + @tracked groups = []; + @tracked values = {}; + + constructor() { + super(...arguments); + next(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.load.perform(); + }); + } + + /** + * Who filed it: the account it is credited to — whoever signed in to the + * console, or whoever a public link was for — or else the name typed on + * the link, when the link was for nobody in particular. + */ + get submitter() { + const submission = this.args.resource; + return submission?.submitted_by?.name ?? submission?.meta?.completed_by_name ?? null; + } + + /** + * How that was established, for a submission that came through a public + * link: whether a PIN stood in the way, and what name was typed when it + * differs from the account or there is no account to check it against. + */ + get submitterNote() { + const submission = this.args.resource; + if (submission?.source !== 'public_link') { + return null; + } + + const typed = (submission.meta?.completed_by_name ?? '').trim(); + const account = (submission.submitted_by?.name ?? '').trim(); + const notes = [this.intl.t(submission.meta?.pin_verified ? 'inspection.record.via-link-pin' : 'inspection.record.via-link')]; + + if (typed && account && typed.toLowerCase() !== account.toLowerCase()) { + notes.push(this.intl.t('inspection.record.signed-as', { name: typed })); + } else if (typed && !account) { + notes.push(this.intl.t('inspection.record.name-unverified')); + } + + return notes.join(' · '); + } + + /** Follow-ups open where they live, the way the rest of the console navigates. */ + @action openForm() { + return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index.details', this.args.resource.form); + } + + /** + * A linked record opens beside what you were reading, which is what the + * console does everywhere else; the route transition is the fallback for + * a resource whose panel is not registered. + */ + @action viewVehicle() { + const vehicle = this.args.resource?.vehicle; + if (!vehicle) { + return; + } + + return this.vehicleActions.panel?.view ? this.vehicleActions.panel.view(vehicle) : this.vehicleActions.transition.view(vehicle); + } + + @action viewDriver() { + const driver = this.args.resource?.driver; + if (!driver) { + return; + } + + return this.driverActions.panel?.view ? this.driverActions.panel.view(driver) : this.driverActions.transition.view(driver); + } + + @action openIssue() { + return this.hostRouter.transitionTo('console.fleet-ops.management.issues.index.details', this.args.resource.issue); + } + + @action openWorkOrder() { + return this.hostRouter.transitionTo('console.fleet-ops.maintenance.work-orders.index.details', this.args.resource.work_order); + } + + get hasAnswers() { + return this.groups.some((group) => (group.fields ?? []).length > 0); + } + + @task *load() { + const submission = this.args.resource; + if (!submission?.id) { + return; + } + + try { + this.values = yield this.inspectionSubmissionActions.loadAnswers(submission); + + const form = submission.form; + if (form?.id) { + this.groups = yield this.inspectionFormActions.loadStructure(form); + } + } catch (error) { + debug('Unable to load inspection answers: ' + error.message); + } + } +} diff --git a/addon/components/inspection-submission/form.hbs b/addon/components/inspection-submission/form.hbs new file mode 100644 index 000000000..47f33ef62 --- /dev/null +++ b/addon/components/inspection-submission/form.hbs @@ -0,0 +1,111 @@ +{{! + An inspection being filled in, in the console. + + The record's own details stay in a content panel, like every other + resource form in the console. Only the selected form's field groups are + rendered differently, as an `inspection-sheet` — the same sheet a public + link renders, so the two cannot drift. +}} +
+ +
+ + + {{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-follow-up.hbs b/addon/components/modals/inspection-follow-up.hbs new file mode 100644 index 000000000..b28310bd0 --- /dev/null +++ b/addon/components/modals/inspection-follow-up.hbs @@ -0,0 +1,31 @@ + + + diff --git a/addon/components/modals/inspection-follow-up.js b/addon/components/modals/inspection-follow-up.js new file mode 100644 index 000000000..b9da7c0d5 --- /dev/null +++ b/addon/components/modals/inspection-follow-up.js @@ -0,0 +1,39 @@ +import Component from '@glimmer/component'; + +/** + * What an action is about to do to an inspection, shown before it happens. + * + * Creating an issue, creating a work order and resolving all used to fire on + * click: the first a dispatcher knew of it was a toast and a new id. Each one + * now states what it will make, from which failed items, and — where it + * matters — what else it does on the way. + */ +export default class ModalsInspectionFollowUpComponent extends Component { + get submission() { + return this.args.options.submission; + } + + /** The failures the follow-up is built from, worst first. */ + get failures() { + const order = { critical: 0, high: 1, medium: 2, low: 3 }; + const results = this.submission?.item_results ?? []; + + return results + .filter((item) => item.passed === false) + .slice() + .sort((a, b) => (order[a.severity] ?? 9) - (order[b.severity] ?? 9)); + } + + get unsafeCount() { + return this.failures.filter((item) => item.meta?.unsafe || item.severity === 'critical').length; + } + + get highestSeverity() { + return this.failures[0]?.severity ?? null; + } + + /** Nothing failed, so there is nothing to raise: the server would say so after the fact. */ + get hasNothingToDo() { + return this.args.options.kind !== 'resolve' && this.failures.length === 0; + } +} diff --git a/addon/components/modals/inspection-link.hbs b/addon/components/modals/inspection-link.hbs new file mode 100644 index 000000000..489b052d5 --- /dev/null +++ b/addon/components/modals/inspection-link.hbs @@ -0,0 +1,100 @@ + + + diff --git a/addon/components/modals/inspection-link.js b/addon/components/modals/inspection-link.js new file mode 100644 index 000000000..1ac7ad70d --- /dev/null +++ b/addon/components/modals/inspection-link.js @@ -0,0 +1,83 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { action, get, set } from '@ember/object'; +import copyToClipboard from '@fleetbase/ember-core/utils/copy-to-clipboard'; +import { toDatetimeLocal } from '../../services/inspection-form-actions'; + +export default class ModalsInspectionLinkComponent extends Component { + @service intl; + @service notifications; + + get formState() { + return this.args.options.formState; + } + + /** A link cannot be made to expire in the past; the server refuses it too. */ + get minExpiry() { + return toDatetimeLocal(new Date()); + } + + /** + * Who the PIN would be sent to: whoever the link is assigned to, or else + * the driver's own account. The server makes the same choice. + * + * Read with `get`: the form state is a plain object changed with `set`, + * and a native read of it is not tracked, so a getter reading it directly + * never recomputed and email and SMS stayed disabled after a pick. + */ + get recipientName() { + const assignee = get(this.formState, 'assignee'); + const driver = get(this.formState, 'driver'); + return assignee?.name ?? driver?.name ?? null; + } + + get deliveryHelp() { + const name = this.recipientName; + return name ? this.intl.t('inspection.link.pin-delivery-help', { name }) : this.intl.t('inspection.link.pin-delivery-no-recipient'); + } + + /** With nobody left to send it to, the PIN goes back to being shared by hand. */ + keepDeliveryPossible() { + if (!this.recipientName && get(this.formState, 'pin_delivery') !== 'none') { + set(this.formState, 'pin_delivery', 'none'); + } + } + + @action assignAssignee(user) { + set(this.formState, 'assignee', user); + this.keepDeliveryPossible(); + } + + @action assignDriver(driver) { + set(this.formState, 'driver', driver); + this.keepDeliveryPossible(); + } + + @action assignVehicle(vehicle) { + set(this.formState, 'vehicle', vehicle); + } + + @action updateExpiry(event) { + set(this.formState, 'expires_at', event.target.value); + } + + @action setPinDelivery(event) { + set(this.formState, 'pin_delivery', event.target.value); + } + + @action copyLink() { + const url = this.formState.generated?.url; + if (url) { + copyToClipboard(url); + this.notifications.success(this.intl.t('inspection.link.copied')); + } + } + + @action copyPin() { + const pin = this.formState.generated?.pin; + if (pin) { + copyToClipboard(pin); + this.notifications.success(this.intl.t('inspection.link.copied-pin')); + } + } +} diff --git a/addon/components/modals/place-details.hbs b/addon/components/modals/place-details.hbs index 6be51d8a1..ca2f96b78 100644 --- a/addon/components/modals/place-details.hbs +++ b/addon/components/modals/place-details.hbs @@ -3,7 +3,7 @@
- + diff --git a/addon/components/modals/point-map.hbs b/addon/components/modals/point-map.hbs index 308f5e2b7..b756dcf5c 100644 --- a/addon/components/modals/point-map.hbs +++ b/addon/components/modals/point-map.hbs @@ -1,7 +1,7 @@
- + diff --git a/addon/components/place/details.hbs b/addon/components/place/details.hbs index cb2d9e7e4..d2d2a085d 100644 --- a/addon/components/place/details.hbs +++ b/addon/components/place/details.hbs @@ -75,7 +75,7 @@ @zoomControl={{false}} as |layers| > - + +
+ {{#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/service-area/details.hbs b/addon/components/service-area/details.hbs index 3068c00e7..ff3b2ab57 100644 --- a/addon/components/service-area/details.hbs +++ b/addon/components/service-area/details.hbs @@ -27,7 +27,7 @@ @zoomControl={{false}} as |layers| > - + + + {{#if @value}} + {{or (get-fleet-ops-option-label @column.optionsKey @value) (smart-humanize @value)}} + {{else}} + - + {{/if}} + +
diff --git a/addon/components/vehicle/details/inspections.hbs b/addon/components/vehicle/details/inspections.hbs new file mode 100644 index 000000000..4eeedb158 --- /dev/null +++ b/addon/components/vehicle/details/inspections.hbs @@ -0,0 +1,49 @@ +
+ {{#if this.loadInspections.isRunning}} +
+ +
+ {{else if this.inspections.length}} +
+ {{#each this.inspections as |inspection|}} + + {{/each}} +
+ {{else}} +
+ +

{{t "vehicle.empty.inspections"}}

+

{{t "vehicle.empty.inspections-description"}}

+
+ {{/if}} +
diff --git a/addon/components/vehicle/details/inspections.js b/addon/components/vehicle/details/inspections.js new file mode 100644 index 000000000..46d1e75e7 --- /dev/null +++ b/addon/components/vehicle/details/inspections.js @@ -0,0 +1,44 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { task } from 'ember-concurrency'; + +/** + * A vehicle's inspection history, newest first. + * + * The same submissions the Inspections screen lists, narrowed to this truck: + * what was filed against it, what failed, and a way into the record. Filing an + * inspection is a driver's job — through the app or a public link — so nothing + * is created from here. + */ +export default class VehicleDetailsInspectionsComponent extends Component { + @service inspectionSubmissionActions; + @service notifications; + @service store; + @tracked inspections = []; + + get vehicle() { + return this.args.resource ?? this.args.vehicle; + } + + constructor() { + super(...arguments); + this.loadInspections.perform(); + } + + @task *loadInspections() { + try { + // `vehicle` is neither a column nor searchable on this model, so it + // is dropped silently; `vehicle_uuid` is fillable and is what the + // index filter actually matches on. + const inspections = yield this.store.query('inspection-submission', { + vehicle_uuid: this.vehicle.id, + sort: '-created_at', + }); + + this.inspections = Array.from(inspections ?? []); + } catch (error) { + this.notifications.serverError(error); + } + } +} diff --git a/addon/components/vehicle/details/maintenance-history.hbs b/addon/components/vehicle/details/maintenance-history.hbs index 12df509f9..7326759e4 100644 --- a/addon/components/vehicle/details/maintenance-history.hbs +++ b/addon/components/vehicle/details/maintenance-history.hbs @@ -1,5 +1,5 @@
- {{#if this.maintenanceHistory.isRunning}} + {{#if this.loadMaintenanceHistory.isRunning}}
diff --git a/addon/components/vehicle/details/schedules.hbs b/addon/components/vehicle/details/schedules.hbs index 859a72873..42ea9e16a 100644 --- a/addon/components/vehicle/details/schedules.hbs +++ b/addon/components/vehicle/details/schedules.hbs @@ -1,5 +1,5 @@
- {{#if this.schedules.isRunning}} + {{#if this.loadSchedules.isRunning}}
@@ -21,7 +21,7 @@ {{schedule.name}} {{schedule.interval_value}} {{schedule.interval_unit}} {{n-a (format-date-fns schedule.next_due_date "dd MMM yyyy")}} - + {{smart-humanize schedule.status}}