From 14d80d14ac581077f7968dcfc251bcc6642f0a3a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 17 Jun 2026 12:51:04 +0800 Subject: [PATCH 01/44] Add inspection maintenance platform foundation --- addon/components/inspection-form/details.hbs | 53 ++++ addon/components/inspection-form/form.hbs | 59 ++++ addon/components/inspection-form/form.js | 58 ++++ .../inspection-submission/details.hbs | 86 ++++++ .../components/inspection-submission/form.hbs | 111 ++++++++ .../components/inspection-submission/form.js | 102 +++++++ addon/components/layout/fleet-ops-sidebar.js | 2 + .../maintenance/inspection-forms/index.js | 101 +++++++ .../inspection-forms/index/details.js | 32 +++ .../inspection-forms/index/edit.js | 28 ++ .../maintenance/inspection-forms/index/new.js | 33 +++ .../inspection-submissions/index.js | 86 ++++++ .../inspection-submissions/index/details.js | 42 +++ .../inspection-submissions/index/edit.js | 28 ++ .../inspection-submissions/index/new.js | 33 +++ addon/extension.js | 4 + addon/models/inspection-form.js | 35 +++ addon/models/inspection-item-result.js | 18 ++ addon/models/inspection-submission.js | 52 ++++ addon/routes.js | 20 ++ addon/routes/maintenance/inspection-forms.js | 3 + .../maintenance/inspection-forms/index.js | 23 ++ .../inspection-forms/index/details.js | 18 ++ .../inspection-forms/index/details/index.js | 3 + .../inspection-forms/index/edit.js | 18 ++ .../maintenance/inspection-forms/index/new.js | 3 + .../maintenance/inspection-submissions.js | 3 + .../inspection-submissions/index.js | 25 ++ .../inspection-submissions/index/details.js | 18 ++ .../index/details/index.js | 3 + .../inspection-submissions/index/edit.js | 18 ++ .../inspection-submissions/index/new.js | 3 + addon/services/inspection-form-actions.js | 51 ++++ .../services/inspection-submission-actions.js | 52 ++++ .../maintenance/inspection-forms.hbs | 1 + .../maintenance/inspection-forms/index.hbs | 28 ++ .../inspection-forms/index/details.hbs | 14 + .../inspection-forms/index/details/index.hbs | 1 + .../inspection-forms/index/edit.hbs | 11 + .../inspection-forms/index/new.hbs | 11 + .../maintenance/inspection-submissions.hbs | 1 + .../inspection-submissions/index.hbs | 28 ++ .../inspection-submissions/index/details.hbs | 14 + .../index/details/index.hbs | 1 + .../inspection-submissions/index/edit.hbs | 11 + .../inspection-submissions/index/new.hbs | 11 + app/components/inspection-form/details.js | 1 + app/components/inspection-form/form.js | 1 + .../inspection-submission/details.js | 1 + app/components/inspection-submission/form.js | 1 + .../maintenance/inspection-forms/index.js | 1 + .../inspection-forms/index/details.js | 1 + .../inspection-forms/index/edit.js | 1 + .../maintenance/inspection-forms/index/new.js | 1 + .../inspection-submissions/index.js | 1 + .../inspection-submissions/index/details.js | 1 + .../inspection-submissions/index/edit.js | 1 + .../inspection-submissions/index/new.js | 1 + app/routes/maintenance/inspection-forms.js | 1 + .../maintenance/inspection-forms/index.js | 1 + .../inspection-forms/index/details.js | 1 + .../inspection-forms/index/details/index.js | 1 + .../inspection-forms/index/edit.js | 1 + .../maintenance/inspection-forms/index/new.js | 1 + .../maintenance/inspection-submissions.js | 1 + .../inspection-submissions/index.js | 1 + .../inspection-submissions/index/details.js | 1 + .../index/details/index.js | 1 + .../inspection-submissions/index/edit.js | 1 + .../inspection-submissions/index/new.js | 1 + app/services/inspection-form-actions.js | 1 + app/services/inspection-submission-actions.js | 1 + app/templates/maintenance/inspection-forms.js | 1 + .../maintenance/inspection-forms/index.js | 1 + .../inspection-forms/index/details.js | 1 + .../inspection-forms/index/details/index.js | 1 + .../inspection-forms/index/edit.js | 1 + .../maintenance/inspection-forms/index/new.js | 1 + .../maintenance/inspection-submissions.js | 1 + .../inspection-submissions/index.js | 1 + .../inspection-submissions/index/details.js | 1 + .../index/details/index.js | 1 + .../inspection-submissions/index/edit.js | 1 + .../inspection-submissions/index/new.js | 1 + ..._06_17_000001_create_inspection_tables.php | 124 +++++++++ server/src/Auth/Schemas/FleetOps.php | 12 + .../Controllers/Internal/v1/HubController.php | 31 ++- .../Internal/v1/InspectionFormController.php | 47 ++++ .../v1/InspectionSubmissionController.php | 154 +++++++++++ .../src/Http/Resources/v1/InspectionForm.php | 75 ++++++ .../Resources/v1/InspectionItemResult.php | 41 +++ .../Resources/v1/InspectionSubmission.php | 62 +++++ server/src/Models/InspectionForm.php | 122 +++++++++ server/src/Models/InspectionItemResult.php | 90 +++++++ server/src/Models/InspectionSubmission.php | 255 ++++++++++++++++++ server/src/Models/WorkOrder.php | 3 + server/src/Observers/WorkOrderObserver.php | 2 +- .../Reporting/FleetOpsReportSchema.php | 179 ++++++++++++ server/src/routes.php | 10 + 99 files changed, 2597 insertions(+), 4 deletions(-) create mode 100644 addon/components/inspection-form/details.hbs create mode 100644 addon/components/inspection-form/form.hbs create mode 100644 addon/components/inspection-form/form.js create mode 100644 addon/components/inspection-submission/details.hbs create mode 100644 addon/components/inspection-submission/form.hbs create mode 100644 addon/components/inspection-submission/form.js create mode 100644 addon/controllers/maintenance/inspection-forms/index.js create mode 100644 addon/controllers/maintenance/inspection-forms/index/details.js create mode 100644 addon/controllers/maintenance/inspection-forms/index/edit.js create mode 100644 addon/controllers/maintenance/inspection-forms/index/new.js create mode 100644 addon/controllers/maintenance/inspection-submissions/index.js create mode 100644 addon/controllers/maintenance/inspection-submissions/index/details.js create mode 100644 addon/controllers/maintenance/inspection-submissions/index/edit.js create mode 100644 addon/controllers/maintenance/inspection-submissions/index/new.js create mode 100644 addon/models/inspection-form.js create mode 100644 addon/models/inspection-item-result.js create mode 100644 addon/models/inspection-submission.js create mode 100644 addon/routes/maintenance/inspection-forms.js create mode 100644 addon/routes/maintenance/inspection-forms/index.js create mode 100644 addon/routes/maintenance/inspection-forms/index/details.js create mode 100644 addon/routes/maintenance/inspection-forms/index/details/index.js create mode 100644 addon/routes/maintenance/inspection-forms/index/edit.js create mode 100644 addon/routes/maintenance/inspection-forms/index/new.js create mode 100644 addon/routes/maintenance/inspection-submissions.js create mode 100644 addon/routes/maintenance/inspection-submissions/index.js create mode 100644 addon/routes/maintenance/inspection-submissions/index/details.js create mode 100644 addon/routes/maintenance/inspection-submissions/index/details/index.js create mode 100644 addon/routes/maintenance/inspection-submissions/index/edit.js create mode 100644 addon/routes/maintenance/inspection-submissions/index/new.js create mode 100644 addon/services/inspection-form-actions.js create mode 100644 addon/services/inspection-submission-actions.js create mode 100644 addon/templates/maintenance/inspection-forms.hbs create mode 100644 addon/templates/maintenance/inspection-forms/index.hbs create mode 100644 addon/templates/maintenance/inspection-forms/index/details.hbs create mode 100644 addon/templates/maintenance/inspection-forms/index/details/index.hbs create mode 100644 addon/templates/maintenance/inspection-forms/index/edit.hbs create mode 100644 addon/templates/maintenance/inspection-forms/index/new.hbs create mode 100644 addon/templates/maintenance/inspection-submissions.hbs create mode 100644 addon/templates/maintenance/inspection-submissions/index.hbs create mode 100644 addon/templates/maintenance/inspection-submissions/index/details.hbs create mode 100644 addon/templates/maintenance/inspection-submissions/index/details/index.hbs create mode 100644 addon/templates/maintenance/inspection-submissions/index/edit.hbs create mode 100644 addon/templates/maintenance/inspection-submissions/index/new.hbs create mode 100644 app/components/inspection-form/details.js create mode 100644 app/components/inspection-form/form.js create mode 100644 app/components/inspection-submission/details.js create mode 100644 app/components/inspection-submission/form.js create mode 100644 app/controllers/maintenance/inspection-forms/index.js create mode 100644 app/controllers/maintenance/inspection-forms/index/details.js create mode 100644 app/controllers/maintenance/inspection-forms/index/edit.js create mode 100644 app/controllers/maintenance/inspection-forms/index/new.js create mode 100644 app/controllers/maintenance/inspection-submissions/index.js create mode 100644 app/controllers/maintenance/inspection-submissions/index/details.js create mode 100644 app/controllers/maintenance/inspection-submissions/index/edit.js create mode 100644 app/controllers/maintenance/inspection-submissions/index/new.js create mode 100644 app/routes/maintenance/inspection-forms.js create mode 100644 app/routes/maintenance/inspection-forms/index.js create mode 100644 app/routes/maintenance/inspection-forms/index/details.js create mode 100644 app/routes/maintenance/inspection-forms/index/details/index.js create mode 100644 app/routes/maintenance/inspection-forms/index/edit.js create mode 100644 app/routes/maintenance/inspection-forms/index/new.js create mode 100644 app/routes/maintenance/inspection-submissions.js create mode 100644 app/routes/maintenance/inspection-submissions/index.js create mode 100644 app/routes/maintenance/inspection-submissions/index/details.js create mode 100644 app/routes/maintenance/inspection-submissions/index/details/index.js create mode 100644 app/routes/maintenance/inspection-submissions/index/edit.js create mode 100644 app/routes/maintenance/inspection-submissions/index/new.js create mode 100644 app/services/inspection-form-actions.js create mode 100644 app/services/inspection-submission-actions.js create mode 100644 app/templates/maintenance/inspection-forms.js create mode 100644 app/templates/maintenance/inspection-forms/index.js create mode 100644 app/templates/maintenance/inspection-forms/index/details.js create mode 100644 app/templates/maintenance/inspection-forms/index/details/index.js create mode 100644 app/templates/maintenance/inspection-forms/index/edit.js create mode 100644 app/templates/maintenance/inspection-forms/index/new.js create mode 100644 app/templates/maintenance/inspection-submissions.js create mode 100644 app/templates/maintenance/inspection-submissions/index.js create mode 100644 app/templates/maintenance/inspection-submissions/index/details.js create mode 100644 app/templates/maintenance/inspection-submissions/index/details/index.js create mode 100644 app/templates/maintenance/inspection-submissions/index/edit.js create mode 100644 app/templates/maintenance/inspection-submissions/index/new.js create mode 100644 server/migrations/2026_06_17_000001_create_inspection_tables.php create mode 100644 server/src/Http/Controllers/Internal/v1/InspectionFormController.php create mode 100644 server/src/Http/Controllers/Internal/v1/InspectionSubmissionController.php create mode 100644 server/src/Http/Resources/v1/InspectionForm.php create mode 100644 server/src/Http/Resources/v1/InspectionItemResult.php create mode 100644 server/src/Http/Resources/v1/InspectionSubmission.php create mode 100644 server/src/Models/InspectionForm.php create mode 100644 server/src/Models/InspectionItemResult.php create mode 100644 server/src/Models/InspectionSubmission.php diff --git a/addon/components/inspection-form/details.hbs b/addon/components/inspection-form/details.hbs new file mode 100644 index 000000000..6770da9ff --- /dev/null +++ b/addon/components/inspection-form/details.hbs @@ -0,0 +1,53 @@ +
+ +
+
+
Name
+
{{n-a @resource.name}}
+
+
+
Status
+
{{smart-humanize @resource.status}}
+
+
+
Type
+
{{smart-humanize @resource.type}}
+
+
+
Frequency
+
{{smart-humanize @resource.frequency}}
+
+
+
Items
+
{{@resource.item_count}}
+
+
+
Published
+
{{n-a (format-date-fns @resource.published_at "dd MMM yyyy, HH:mm")}}
+
+
+
Description
+
{{n-a @resource.description}}
+
+
+
+ + +
+ {{#each @resource.items as |item|}} +
+
+
{{n-a item.label}}
+
{{n-a item.category}}
+
+ {{smart-humanize item.severity}} +
+ {{else}} +
No checklist items configured.
+ {{/each}} +
+
+ + + +
diff --git a/addon/components/inspection-form/form.hbs b/addon/components/inspection-form/form.hbs new file mode 100644 index 000000000..ed8f8cec6 --- /dev/null +++ b/addon/components/inspection-form/form.hbs @@ -0,0 +1,59 @@ +
+ +
+ + + + + + {{smart-humanize type}} + + + + + {{smart-humanize status}} + + + + + {{smart-humanize frequency}} + + + +
-
{{/each}}
- {{@resource.failed_items}} + {{this.failedCount}} failed of - {{@resource.total_items}} + {{this.totalCount}} items
-
diff --git a/addon/components/inspection-submission/form.js b/addon/components/inspection-submission/form.js index 4bd97648e..389c6f5dd 100644 --- a/addon/components/inspection-submission/form.js +++ b/addon/components/inspection-submission/form.js @@ -1,28 +1,40 @@ import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; const STATUS_OPTIONS = ['draft', 'submitted', 'needs_review', 'resolved']; const RESULT_OPTIONS = ['passed', 'failed']; const SEVERITY_OPTIONS = ['low', 'medium', 'high', 'critical']; +/** + * Checklist results editor for a console-authored inspection. + * + * `@resource.item_results` is the single source of truth. Counts and the + * result are derived for display and written to the model only when the + * results change from an action — never in the constructor, which is what + * tripped Glimmer's "already used in the same computation" assertion. + */ export default class InspectionSubmissionFormComponent extends Component { statusOptions = STATUS_OPTIONS; resultOptions = RESULT_OPTIONS; severityOptions = SEVERITY_OPTIONS; - @tracked itemResults = []; + get itemResults() { + const results = this.args.resource?.item_results; + return Array.isArray(results) ? results : []; + } + + get failedCount() { + return this.itemResults.filter((item) => item.passed === false).length; + } - constructor(owner, args) { - super(owner, args); - this.itemResults = [...(args.resource?.item_results ?? [])]; - this.syncResults(); + get totalCount() { + return this.itemResults.length; } - syncResults() { - this.args.resource.item_results = this.itemResults; - const failed = this.itemResults.filter((item) => item.passed === false).length; - this.args.resource.total_items = this.itemResults.length; + setResults(results) { + const failed = results.filter((item) => item.passed === false).length; + this.args.resource.item_results = results; + this.args.resource.total_items = results.length; this.args.resource.failed_items = failed; this.args.resource.result = failed > 0 ? 'failed' : 'passed'; } @@ -33,17 +45,18 @@ export default class InspectionSubmissionFormComponent extends Component { return; } - this.itemResults = items.map((item, index) => ({ - item_key: item.key || `item_${index + 1}`, - label: item.label, - category: item.category, - severity: item.severity || 'medium', - status: 'passed', - passed: true, - comments: '', - photos: [], - })); - this.syncResults(); + this.setResults( + items.map((item, index) => ({ + item_key: item.key || `item_${index + 1}`, + label: item.label, + category: item.category, + severity: item.severity || 'medium', + status: 'passed', + passed: true, + comments: '', + photos: [], + })) + ); } @action assignForm(form) { @@ -61,42 +74,49 @@ export default class InspectionSubmissionFormComponent extends Component { } @action addResult() { - this.itemResults = [ - ...this.itemResults, + const results = this.itemResults; + this.setResults([ + ...results, { - item_key: `custom_${this.itemResults.length + 1}`, + item_key: `custom_${results.length + 1}`, label: '', category: '', severity: 'medium', status: 'passed', passed: true, comments: '', + photos: [], }, - ]; - this.syncResults(); + ]); } @action removeResult(index) { - this.itemResults = this.itemResults.filter((_, itemIndex) => itemIndex !== index); - this.syncResults(); + this.setResults(this.itemResults.filter((_, itemIndex) => itemIndex !== index)); } + /** For selects, which hand over the chosen value. */ @action updateResult(index, key, value) { - this.itemResults = this.itemResults.map((item, itemIndex) => { - if (itemIndex !== index) { - return item; - } - - const next = { ...item, [key]: value }; - if (key === 'status') { - next.passed = value !== 'failed'; - } - if (key === 'passed') { - next.status = value ? 'passed' : 'failed'; - } - - return next; - }); - this.syncResults(); + this.setResults( + this.itemResults.map((item, itemIndex) => { + if (itemIndex !== index) { + return item; + } + + const next = { ...item, [key]: value }; + if (key === 'status') { + next.passed = value !== 'failed'; + } + if (key === 'passed') { + next.status = value ? 'passed' : 'failed'; + } + + return next; + }) + ); + } + + /** For text inputs, which hand over the DOM event. */ + @action updateResultField(index, key, event) { + this.updateResult(index, key, event.target.value); } } diff --git a/addon/components/layout/fleet-ops-sidebar.js b/addon/components/layout/fleet-ops-sidebar.js index 35f782a08..fb678f5bd 100644 --- a/addon/components/layout/fleet-ops-sidebar.js +++ b/addon/components/layout/fleet-ops-sidebar.js @@ -160,8 +160,8 @@ export default class LayoutFleetOpsSidebarComponent extends Component { 'service readiness', 'maintenance control panel', ]), - this.createItem('Inspection Forms', 'clipboard-check', 'maintenance.inspection-forms', 'fleet-ops list inspection-form', 'fleet-ops see inspection-form'), - this.createItem('Inspections', 'list-check', 'maintenance.inspection-submissions', 'fleet-ops list inspection-submission', 'fleet-ops see inspection-submission'), + 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/translations/ar-ae.yml b/translations/ar-ae.yml index d377b2870..066c9fd0f 100644 --- a/translations/ar-ae.yml +++ b/translations/ar-ae.yml @@ -2,6 +2,8 @@ menu: fuel-providers: تكاملات الوقود fuel-transactions: معاملات الوقود trailers: المقطورات + inspection-forms: 'نماذج الفحص' + inspections: 'الفحوصات' trailer: navigation-description: إدارة الأصول المقطورة ووصلات القطر والمعدات والتتبع عن بُعد. diff --git a/translations/bg-bg.yaml b/translations/bg-bg.yaml index d211080a5..25913ea11 100644 --- a/translations/bg-bg.yaml +++ b/translations/bg-bg.yaml @@ -73,6 +73,8 @@ menu: issues: Проблеми maintenance: Поддръжка work-orders: Работни поръчки + inspection-forms: 'Формуляри за инспекция' + inspections: 'Инспекции' equipment: Оборудване parts: Части connectivity: Свързаност diff --git a/translations/en-us.yaml b/translations/en-us.yaml index ade8bc7e9..42f1c9ebf 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -86,6 +86,8 @@ menu: maintenance: Maintenance schedules: Schedules work-orders: Work Orders + inspection-forms: 'Inspection Forms' + inspections: 'Inspections' equipment: Equipment parts: Parts maintenance-history: Maintenance History diff --git a/translations/es-pa.yaml b/translations/es-pa.yaml index 67977f8f6..74f529137 100644 --- a/translations/es-pa.yaml +++ b/translations/es-pa.yaml @@ -70,6 +70,8 @@ menu: issues: Asuntos maintenance: Mantenimiento work-orders: Órdenes de trabajo + inspection-forms: 'Formularios de inspección' + inspections: 'Inspecciones' equipment: Equipo parts: Partes connectivity: Conectividad diff --git a/translations/fr-fr.yaml b/translations/fr-fr.yaml index 7697ceda0..5da3f81d1 100644 --- a/translations/fr-fr.yaml +++ b/translations/fr-fr.yaml @@ -73,6 +73,8 @@ menu: issues: Problèmes maintenance: Maintenance work-orders: Ordres de travail + inspection-forms: "Formulaires d'inspection" + inspections: "Inspections" equipment: Équipement parts: Pièces connectivity: Connectivité diff --git a/translations/mn-mn.yaml b/translations/mn-mn.yaml index 461cd39d6..ecbb21d95 100644 --- a/translations/mn-mn.yaml +++ b/translations/mn-mn.yaml @@ -73,6 +73,8 @@ menu: issues: Асуудлууд maintenance: Үйлчилгээ work-orders: Ажлын захиалгууд + inspection-forms: 'Үзлэгийн маягтууд' + inspections: 'Үзлэгүүд' equipment: Тоног төхөөрөмж parts: Сэлбэгүүд connectivity: Холболт diff --git a/translations/pt-br.yaml b/translations/pt-br.yaml index a81180389..3e41da195 100644 --- a/translations/pt-br.yaml +++ b/translations/pt-br.yaml @@ -73,6 +73,8 @@ menu: issues: Problemas maintenance: Manutenção work-orders: Ordens de Serviço + inspection-forms: 'Formulários de inspeção' + inspections: 'Inspeções' equipment: Equipamentos parts: Peças connectivity: Conectividade diff --git a/translations/ru-ru.yaml b/translations/ru-ru.yaml index 337f7c229..3243236e1 100644 --- a/translations/ru-ru.yaml +++ b/translations/ru-ru.yaml @@ -73,6 +73,8 @@ menu: issues: Проблемы maintenance: Обслуживание work-orders: Рабочие заказы + inspection-forms: 'Формы осмотра' + inspections: 'Осмотры' equipment: Оборудование parts: Запчасти connectivity: Подключение diff --git a/translations/vi-vn.yaml b/translations/vi-vn.yaml index 5d86b0b45..8a4820e33 100644 --- a/translations/vi-vn.yaml +++ b/translations/vi-vn.yaml @@ -2,6 +2,8 @@ menu: fuel-providers: Tích hợp Nhiên liệu fuel-transactions: Giao dịch nhiên liệu trailers: Rơ-moóc + inspection-forms: 'Biểu mẫu kiểm tra' + inspections: 'Kiểm tra' resource: trailer: Rơ-moóc From 73bfed65977941cd70d5f4e1083b7315d8f43412 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 12:29:29 +0800 Subject: [PATCH 06/44] Model an inspection form as groups of typed fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut stored a form as a JSON list of pass/fail items. That is a checklist. Fleetio parity — and the fliit module that already has it — wants groups of typed fields, built from the platform's own custom-field system, so an inspection is a form filled in rather than a list ticked. A form's groups are platform categories owned by the form; its fields are custom fields whose subject is the form, filed under `fleetops_inspection_form` so a field the console's generic panel adds to the form record is not mistaken for one a driver answers. Both come back as `grouped_fields`, which is what the app codes against. A submission answers through custom-field values. `inspection_item_results` stays: issues, work orders and the history are built from it, so every pass-fail value is mirrored into a result row keyed the way the app keys it. "Not applicable" is carried through as its own status rather than flattened into a pass. Photos and signatures arrive as base64 — the app is offline-first and cannot upload first — and are stored as platform files, leaving `file:` as the value, which is the platform's convention. The submission resource answers `custom_field_values` with each field's identity beside its answer and every file reference resolved, plus the `files` the inspection carries. The rules a field insists on when it fails are checked before any photo is stored, so a refusal leaves nothing behind. The tests' in-memory schema gains `categories`, the real `custom_fields` columns and the file columns the projection reads. --- .../Controllers/Api/v1/GeofenceController.php | 2 +- .../Api/v1/InspectionController.php | 7 +- .../Controllers/Internal/v1/HubController.php | 20 +- .../Internal/v1/InspectionFormController.php | 16 +- .../v1/InspectionSubmissionController.php | 44 ++-- .../src/Http/Resources/v1/InspectionForm.php | 89 ++++++- .../Resources/v1/InspectionSubmission.php | 92 ++++++- server/src/Models/InspectionForm.php | 131 ++++++++- server/src/Models/InspectionItemResult.php | 10 +- server/src/Models/InspectionLink.php | 4 +- server/src/Models/InspectionSubmission.php | 169 ++++++++++-- server/src/Support/InspectionFileStore.php | 202 ++++++++++++++ server/src/Support/InspectionFormSync.php | 225 ++++++++++++++++ server/src/Support/InspectionSubmitter.php | 248 +++++++++++++++--- .../ApiManifestControllerContractsTest.php | 18 +- .../Api/GeofenceControllerContractsTest.php | 8 +- .../InspectionControllerContractsTest.php | 5 +- server/tests/InspectionModelContractsTest.php | 8 +- .../Http/Resources/ManifestResourceTest.php | 6 +- 19 files changed, 1165 insertions(+), 139 deletions(-) create mode 100644 server/src/Support/InspectionFileStore.php create mode 100644 server/src/Support/InspectionFormSync.php diff --git a/server/src/Http/Controllers/Api/v1/GeofenceController.php b/server/src/Http/Controllers/Api/v1/GeofenceController.php index 433fdd62f..385b72e60 100644 --- a/server/src/Http/Controllers/Api/v1/GeofenceController.php +++ b/server/src/Http/Controllers/Api/v1/GeofenceController.php @@ -2,9 +2,9 @@ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1; +use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\GeofenceEventLog; use Fleetbase\FleetOps\Support\Utils; -use Fleetbase\FleetOps\Models\Driver; use Fleetbase\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; diff --git a/server/src/Http/Controllers/Api/v1/InspectionController.php b/server/src/Http/Controllers/Api/v1/InspectionController.php index f6d374f4f..00698f67c 100644 --- a/server/src/Http/Controllers/Api/v1/InspectionController.php +++ b/server/src/Http/Controllers/Api/v1/InspectionController.php @@ -34,7 +34,7 @@ class InspectionController extends Controller * The relations a submission is answered with, so the app never has to * make a second request to learn what its own submission produced. */ - protected const SUBMISSION_RELATIONS = ['form', 'vehicle', 'driver', 'itemResults', 'issue', 'workOrder']; + protected const SUBMISSION_RELATIONS = ['form', 'vehicle', 'driver', 'itemResults', 'issue', 'workOrder', 'customFieldValues.customField', 'files']; /** * GET /v1/inspection-forms — the published forms a driver may fill in. @@ -240,13 +240,16 @@ protected static function publishedForms() return InspectionForm::where('company_uuid', session('company')) ->where('status', 'published') ->whereNotNull('published_at') + // The form's structure is the point of the read; loading it with + // the forms keeps a listing to two queries instead of two a form. + ->with(['fieldGroups', 'fields']) ->orderBy('published_at', 'desc'); } protected static function submissions() { return InspectionSubmission::where('company_uuid', session('company')) - ->with(['form', 'vehicle', 'driver']) + ->with(['form', 'vehicle', 'driver', 'customFieldValues.customField', 'files']) ->orderBy('submitted_at', 'desc') ->orderBy('created_at', 'desc'); } diff --git a/server/src/Http/Controllers/Internal/v1/HubController.php b/server/src/Http/Controllers/Internal/v1/HubController.php index 7c3e9ece8..d1a7c9866 100644 --- a/server/src/Http/Controllers/Internal/v1/HubController.php +++ b/server/src/Http/Controllers/Internal/v1/HubController.php @@ -114,17 +114,17 @@ public function maintenance(Request $request) $now = Carbon::now(); $next7 = Carbon::now()->addDays(7); - $overdueSchedules = $this->count(MaintenanceSchedule::query()->where('status', 'active')->whereNotNull('next_due_date')->where('next_due_date', '<', $now), $company); - $upcomingSchedules = $this->count(MaintenanceSchedule::query()->where('status', 'active')->whereBetween('next_due_date', [$now, $next7]), $company); - $openWorkOrders = $this->count(WorkOrder::query()->whereIn('status', ['open', 'in_progress']), $company); - $overdueWorkOrders = $this->count(WorkOrder::query()->whereNotIn('status', ['closed', 'canceled'])->whereNotNull('due_at')->where('due_at', '<', $now), $company); - $openMaintenance = $this->count(Maintenance::query()->whereNotIn('status', ['completed', 'canceled']), $company); - $highPriorityMaintenance = $this->count(Maintenance::query()->whereNotIn('status', ['completed', 'canceled'])->whereIn('priority', ['high', 'urgent', 'critical']), $company); + $overdueSchedules = $this->count(MaintenanceSchedule::query()->where('status', 'active')->whereNotNull('next_due_date')->where('next_due_date', '<', $now), $company); + $upcomingSchedules = $this->count(MaintenanceSchedule::query()->where('status', 'active')->whereBetween('next_due_date', [$now, $next7]), $company); + $openWorkOrders = $this->count(WorkOrder::query()->whereIn('status', ['open', 'in_progress']), $company); + $overdueWorkOrders = $this->count(WorkOrder::query()->whereNotIn('status', ['closed', 'canceled'])->whereNotNull('due_at')->where('due_at', '<', $now), $company); + $openMaintenance = $this->count(Maintenance::query()->whereNotIn('status', ['completed', 'canceled']), $company); + $highPriorityMaintenance = $this->count(Maintenance::query()->whereNotIn('status', ['completed', 'canceled'])->whereIn('priority', ['high', 'urgent', 'critical']), $company); $publishedInspectionForms = $this->count(InspectionForm::query()->where('status', 'published'), $company); - $failedInspections = $this->count(InspectionSubmission::query()->whereIn('status', ['submitted', 'needs_review'])->where('result', 'failed'), $company); - $unresolvedInspections = $this->count(InspectionSubmission::query()->where('result', 'failed')->whereNull('resolved_at'), $company); - $lowStockParts = $this->count(Part::query()->where('quantity_on_hand', '>', 0)->where('quantity_on_hand', '<=', 5), $company); - $equipment = $this->count(Equipment::query(), $company); + $failedInspections = $this->count(InspectionSubmission::query()->whereIn('status', ['submitted', 'needs_review'])->where('result', 'failed'), $company); + $unresolvedInspections = $this->count(InspectionSubmission::query()->where('result', 'failed')->whereNull('resolved_at'), $company); + $lowStockParts = $this->count(Part::query()->where('quantity_on_hand', '>', 0)->where('quantity_on_hand', '<=', 5), $company); + $equipment = $this->count(Equipment::query(), $company); return response()->json([ 'kpis' => [ diff --git a/server/src/Http/Controllers/Internal/v1/InspectionFormController.php b/server/src/Http/Controllers/Internal/v1/InspectionFormController.php index 295b9b951..421d1e644 100644 --- a/server/src/Http/Controllers/Internal/v1/InspectionFormController.php +++ b/server/src/Http/Controllers/Internal/v1/InspectionFormController.php @@ -27,9 +27,9 @@ public function publish(string $id): JsonResponse $form->publish(); return response()->json([ - 'status' => 'ok', + 'status' => 'ok', 'message' => 'Inspection form published.', - 'data' => $form->fresh(), + 'data' => $form->fresh(), ]); } @@ -41,9 +41,9 @@ public function archive(string $id): JsonResponse $form->archive(); return response()->json([ - 'status' => 'ok', + 'status' => 'ok', 'message' => 'Inspection form archived.', - 'data' => $form->fresh(), + 'data' => $form->fresh(), ]); } @@ -65,9 +65,9 @@ public function generateLink(Request $request, string $id): JsonResponse 'single_use' => 'nullable|boolean', ]); - $driver = $this->resolveDriver(data_get($validated, 'driver')); + $driver = $this->resolveDriver(data_get($validated, 'driver')); $vehicle = $this->resolveVehicle(data_get($validated, 'vehicle')); - $token = InspectionLink::generateToken(); + $token = InspectionLink::generateToken(); $link = InspectionLink::create([ 'company_uuid' => $form->company_uuid, @@ -84,9 +84,9 @@ public function generateLink(Request $request, string $id): JsonResponse $path = '/inspection?id=' . urlencode($form->public_id ?? $form->uuid) . '&token=' . urlencode($token); return response()->json([ - 'status' => 'ok', + 'status' => 'ok', 'message' => 'Inspection link generated.', - 'link' => [ + 'link' => [ 'id' => $link->public_id, 'path' => $path, 'token' => $token, diff --git a/server/src/Http/Controllers/Internal/v1/InspectionSubmissionController.php b/server/src/Http/Controllers/Internal/v1/InspectionSubmissionController.php index 038953edc..a8001e688 100644 --- a/server/src/Http/Controllers/Internal/v1/InspectionSubmissionController.php +++ b/server/src/Http/Controllers/Internal/v1/InspectionSubmissionController.php @@ -44,9 +44,9 @@ public function submit(string $id): JsonResponse $submission->syncResultCounts(); return response()->json([ - 'status' => 'ok', + 'status' => 'ok', 'message' => 'Inspection submitted.', - 'data' => $submission->fresh(['itemResults', 'vehicle', 'driver', 'form']), + 'data' => $submission->fresh(['itemResults', 'vehicle', 'driver', 'form']), ]); } @@ -61,10 +61,10 @@ public function createIssue(string $id): JsonResponse $issue = $submission->createIssueFromFailures(); return response()->json([ - 'status' => 'ok', + 'status' => 'ok', 'message' => $issue ? 'Issue created from failed inspection items.' : 'No failed inspection items found.', - 'issue' => $issue, - 'data' => $submission->fresh(['itemResults', 'issue']), + 'issue' => $issue, + 'data' => $submission->fresh(['itemResults', 'issue']), ]); } @@ -80,10 +80,10 @@ public function createWorkOrder(string $id): JsonResponse $workOrder = $submission->createWorkOrderFromFailures(); return response()->json([ - 'status' => 'ok', - 'message' => $workOrder ? 'Work order created from failed inspection items.' : 'No failed inspection items found.', + 'status' => 'ok', + 'message' => $workOrder ? 'Work order created from failed inspection items.' : 'No failed inspection items found.', 'work_order' => $workOrder, - 'data' => $submission->fresh(['itemResults', 'issue', 'workOrder']), + 'data' => $submission->fresh(['itemResults', 'issue', 'workOrder']), ]); } @@ -94,14 +94,14 @@ public function resolve(string $id): JsonResponse ->firstOrFail(); $submission->update([ - 'status' => 'resolved', + 'status' => 'resolved', 'resolved_at' => now(), ]); return response()->json([ - 'status' => 'ok', + 'status' => 'ok', 'message' => 'Inspection resolved.', - 'data' => $submission->fresh(['itemResults', 'issue', 'workOrder']), + 'data' => $submission->fresh(['itemResults', 'issue', 'workOrder']), ]); } @@ -114,19 +114,19 @@ protected function syncItemResultsFromRequest(Request $request, InspectionSubmis $seen = []; foreach ($items as $item) { - $uuid = data_get($item, 'uuid'); + $uuid = data_get($item, 'uuid'); $payload = [ - 'company_uuid' => $submission->company_uuid, + 'company_uuid' => $submission->company_uuid, 'inspection_submission_uuid' => $submission->uuid, - 'item_key' => data_get($item, 'item_key'), - 'label' => data_get($item, 'label', data_get($item, 'title', 'Inspection item')), - 'category' => data_get($item, 'category'), - 'status' => data_get($item, 'status', data_get($item, 'passed') === false ? 'failed' : 'passed'), - 'severity' => data_get($item, 'severity'), - 'passed' => (bool) data_get($item, 'passed', data_get($item, 'status') !== 'failed'), - 'comments' => data_get($item, 'comments'), - 'photos' => data_get($item, 'photos'), - 'meta' => data_get($item, 'meta'), + 'item_key' => data_get($item, 'item_key'), + 'label' => data_get($item, 'label', data_get($item, 'title', 'Inspection item')), + 'category' => data_get($item, 'category'), + 'status' => data_get($item, 'status', data_get($item, 'passed') === false ? 'failed' : 'passed'), + 'severity' => data_get($item, 'severity'), + 'passed' => (bool) data_get($item, 'passed', data_get($item, 'status') !== 'failed'), + 'comments' => data_get($item, 'comments'), + 'photos' => data_get($item, 'photos'), + 'meta' => data_get($item, 'meta'), ]; $lookup = [ diff --git a/server/src/Http/Resources/v1/InspectionForm.php b/server/src/Http/Resources/v1/InspectionForm.php index 90409a521..709044707 100644 --- a/server/src/Http/Resources/v1/InspectionForm.php +++ b/server/src/Http/Resources/v1/InspectionForm.php @@ -4,6 +4,8 @@ use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Resources\FleetbaseResource; +use Fleetbase\Models\Category; +use Fleetbase\Models\CustomField; use Fleetbase\Support\Http; use Illuminate\Support\Str; @@ -18,15 +20,17 @@ class InspectionForm extends FleetbaseResource */ public function toArray($request) { + $internal = Http::isInternalRequest(); + return $this->withCustomFields([ - 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), - 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), - 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), - 'company_uuid' => $this->when(Http::isInternalRequest(), $this->company_uuid), - 'created_by_uuid'=> $this->when(Http::isInternalRequest(), $this->created_by_uuid), - 'updated_by_uuid'=> $this->when(Http::isInternalRequest(), $this->updated_by_uuid), - 'subject_uuid' => $this->when(Http::isInternalRequest(), $this->subject_uuid), - 'subject_type' => $this->when(Http::isInternalRequest(), $this->subject_type ? Utils::toEmberResourceType($this->subject_type) : null), + 'id' => $this->when($internal, $this->id, $this->public_id), + 'uuid' => $this->when($internal, $this->uuid), + 'public_id' => $this->when($internal, $this->public_id), + 'company_uuid' => $this->when($internal, $this->company_uuid), + 'created_by_uuid'=> $this->when($internal, $this->created_by_uuid), + 'updated_by_uuid'=> $this->when($internal, $this->updated_by_uuid), + 'subject_uuid' => $this->when($internal, $this->subject_uuid), + 'subject_type' => $this->when($internal, $this->subject_type ? Utils::toEmberResourceType($this->subject_type) : null), 'subject' => $this->whenLoaded('subject', fn () => $this->setSubjectType($this->transformMorphResource($this->subject))), 'name' => $this->name, 'description' => $this->description, @@ -34,6 +38,9 @@ public function toArray($request) 'status' => $this->status, 'frequency' => $this->frequency, 'items' => data_get($this, 'items', []), + 'grouped_fields' => array_map(fn (Category $group) => static::groupToArray($group, $internal), $this->grouped_fields), + 'field_groups' => $this->whenLoaded('fieldGroups', fn () => $this->fieldGroups->map(fn (Category $group) => static::groupToArray($group, $internal, false))->values()->all()), + 'fields' => $this->whenLoaded('fields', fn () => $this->fields->map(fn (CustomField $field) => static::fieldToArray($field, $internal))->values()->all()), 'settings' => data_get($this, 'settings', (object) []), 'meta' => data_get($this, 'meta', (object) []), 'subject_name' => $this->subject_name, @@ -45,6 +52,72 @@ public function toArray($request) ]); } + /** + * A field group as the API answers it. The console gets the identifiers it + * addresses the category by; the driver gets what it renders. + */ + public static function groupToArray(Category $group, bool $internal, bool $withFields = true): array + { + $data = [ + 'id' => $internal ? $group->uuid : ($group->public_id ?? $group->uuid), + 'uuid' => $group->uuid, + 'name' => $group->name, + 'description' => $group->description, + 'order' => $group->order === null ? null : (int) $group->order, + 'meta' => is_array($group->meta) && !empty($group->meta) ? $group->meta : (object) [], + ]; + + if ($internal) { + $data['public_id'] = $group->public_id; + $data['company_uuid'] = $group->company_uuid; + $data['owner_uuid'] = $group->owner_uuid; + $data['owner_type'] = $group->owner_type; + $data['for'] = $group->for; + } + + if ($withFields) { + $fields = $group->relationLoaded('fields') ? $group->getRelation('fields') : collect(); + $data['fields'] = collect($fields)->map(fn (CustomField $field) => static::fieldToArray($field, $internal))->values()->all(); + } + + return $data; + } + + /** + * A field as the API answers it. A custom field has no public id, so the + * uuid is the id on both sides; it is what a submit names the field by. + */ + public static function fieldToArray(CustomField $field, bool $internal): array + { + $data = [ + 'id' => $field->uuid, + 'uuid' => $field->uuid, + 'name' => $field->name, + 'label' => $field->label, + 'description' => $field->description, + 'help_text' => $field->help_text, + 'type' => $field->type, + 'component' => $field->component, + 'required' => (bool) $field->required, + 'editable' => $field->editable === null ? true : (bool) $field->editable, + 'options' => is_array($field->options) ? array_values($field->options) : [], + 'order' => $field->order === null ? null : (int) $field->order, + 'meta' => is_array($field->meta) && !empty($field->meta) ? $field->meta : (object) [], + ]; + + if ($internal) { + $data['company_uuid'] = $field->company_uuid; + $data['category_uuid'] = $field->category_uuid; + $data['subject_uuid'] = $field->subject_uuid; + $data['subject_type'] = $field->subject_type; + $data['for'] = $field->for; + $data['default_value'] = $field->default_value; + $data['validation_rules'] = $field->validation_rules; + } + + return $data; + } + protected function setSubjectType(?array $resolved): ?array { if (empty($resolved)) { diff --git a/server/src/Http/Resources/v1/InspectionSubmission.php b/server/src/Http/Resources/v1/InspectionSubmission.php index e19aa68ed..8b8a284e0 100644 --- a/server/src/Http/Resources/v1/InspectionSubmission.php +++ b/server/src/Http/Resources/v1/InspectionSubmission.php @@ -2,9 +2,12 @@ namespace Fleetbase\FleetOps\Http\Resources\v1; +use Fleetbase\FleetOps\Support\InspectionFileStore; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Resources\FleetbaseResource; use Fleetbase\Http\Resources\User; +use Fleetbase\Models\CustomFieldValue; +use Fleetbase\Models\File; use Fleetbase\Support\Http; class InspectionSubmission extends FleetbaseResource @@ -18,7 +21,7 @@ class InspectionSubmission extends FleetbaseResource */ public function toArray($request) { - return $this->withCustomFields([ + $data = $this->withCustomFields([ 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), @@ -58,5 +61,92 @@ public function toArray($request) 'updated_at' => $this->updated_at, 'created_at' => $this->created_at, ]); + + // The platform's own `withCustomFields` puts the raw value models + // under `custom_field_values` for the console. What both consoles and + // the app want is the field's identity beside its answer, with file + // references resolved, so that projection replaces it. + $data['custom_field_values'] = $this->projectCustomFieldValues(); + $data['files'] = $this->projectFiles(); + + return $data; + } + + /** + * The answers, as the app and the console read them: which field, what it + * is called, its type, and the value with every `file:` resolved to + * something fetchable. + */ + protected function projectCustomFieldValues(): array + { + if (!$this->resource || !$this->resource->relationLoaded('customFieldValues')) { + return []; + } + + $internal = Http::isInternalRequest(); + + return $this->resource->customFieldValues->map(function (CustomFieldValue $value) use ($internal) { + $field = $value->customField; + $row = [ + 'custom_field' => $value->custom_field_uuid, + 'name' => $field?->name, + 'label' => $field?->label ?? $value->custom_field_label, + 'type' => $field?->type ?? $value->value_type, + 'value_type' => $value->value_type, + 'value' => static::projectValue($value), + ]; + + if ($internal) { + $row['uuid'] = $value->uuid; + $row['category_uuid'] = $field?->category_uuid; + $row['order'] = $field?->order === null ? null : (int) $field->order; + $row['meta'] = is_array($field?->meta) && !empty($field->meta) ? $field->meta : (object) []; + } + + return $row; + })->values()->all(); + } + + /** + * One stored value, with file references resolved. A pass-fail answer + * carries its photos inside it, so those are resolved too. + */ + protected static function projectValue(CustomFieldValue $value): mixed + { + $raw = $value->getRawOriginal('value'); + + if (in_array($value->value_type, ['object', 'array'], true)) { + $decoded = is_string($raw) ? json_decode($raw, true) : $raw; + if (!is_array($decoded)) { + return $decoded; + } + + if (isset($decoded['photos']) && is_array($decoded['photos'])) { + $decoded['photos'] = array_values(array_map(fn ($photo) => InspectionFileStore::project($photo), $decoded['photos'])); + } + + return $decoded; + } + + return InspectionFileStore::project($raw); + } + + /** The photos and signatures filed with the inspection. */ + protected function projectFiles(): array + { + if (!$this->resource || !$this->resource->relationLoaded('files')) { + return []; + } + + return $this->resource->files->map(fn (File $file) => [ + 'id' => $file->public_id ?? $file->uuid, + 'uuid' => $file->uuid, + 'url' => $file->url, + 'original_filename' => $file->original_filename, + 'content_type' => $file->content_type, + 'type' => $file->type, + 'caption' => $file->caption, + 'created_at' => $file->created_at, + ])->values()->all(); } } diff --git a/server/src/Models/InspectionForm.php b/server/src/Models/InspectionForm.php index c9fc8f7db..1d3a9eadd 100644 --- a/server/src/Models/InspectionForm.php +++ b/server/src/Models/InspectionForm.php @@ -4,6 +4,8 @@ use Fleetbase\Casts\Json; use Fleetbase\Casts\PolymorphicType; +use Fleetbase\Models\Category; +use Fleetbase\Models\CustomField; use Fleetbase\Models\Model; use Fleetbase\Models\User; use Fleetbase\Traits\HasApiModelBehavior; @@ -16,6 +18,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Support\Collection; use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\Traits\LogsActivity; @@ -30,10 +33,35 @@ class InspectionForm extends Model use Searchable; use HasCustomFields; - protected $table = 'inspection_forms'; - protected $publicIdType = 'inspection_form'; + /** + * What a field built for an inspection form is filed under, so the + * platform's custom-field listings can tell an inspection field from a + * field added to the form record itself. + */ + public const FIELD_FOR = 'fleetops_inspection_form'; + + /** The category kind a form's field groups are stored as. */ + public const GROUP_FOR = 'custom_field_group'; + + /** The field types an inspection form may be built from. */ + public const FIELD_TYPES = [ + 'pass-fail', + 'input', + 'textarea', + 'number', + 'select', + 'radio-button', + 'boolean', + 'date-picker', + 'date-time-input', + 'file-upload', + 'signature', + ]; + + protected $table = 'inspection_forms'; + protected $publicIdType = 'inspection_form'; protected $searchableColumns = ['name', 'description', 'type', 'public_id']; - protected $filterParams = ['status', 'type', 'frequency', 'subject_type', 'subject_uuid']; + protected $filterParams = ['status', 'type', 'frequency', 'subject_type', 'subject_uuid']; protected $fillable = [ 'company_uuid', @@ -61,10 +89,10 @@ class InspectionForm extends Model ]; protected $appends = ['subject_name', 'item_count', 'is_published']; - protected $with = ['subject']; + protected $with = ['subject']; - protected static $logName = 'inspection_form'; - protected static $logAttributes = '*'; + protected static $logName = 'inspection_form'; + protected static $logAttributes = '*'; protected static $submitEmptyLogs = false; public function getActivitylogOptions(): LogOptions @@ -92,13 +120,104 @@ public function updatedBy(): BelongsTo return $this->belongsTo(User::class, 'updated_by_uuid', 'uuid'); } + /** + * The groups the form's fields are laid out in: platform categories owned + * by the form, in the order the builder put them. + */ + public function fieldGroups(): HasMany + { + return $this->hasMany(Category::class, 'owner_uuid', 'uuid') + ->where('for', static::GROUP_FOR) + ->orderByRaw('COALESCE(`order`, 999999), created_at'); + } + + /** + * The fields a driver fills in: platform custom fields whose subject is + * the form, filed under the inspection kind so a field attached to the + * form record by the console's generic custom-field panel is not one. + */ + public function fields(): HasMany + { + return $this->hasMany(CustomField::class, 'subject_uuid', 'uuid') + ->where('for', static::FIELD_FOR) + ->orderByRaw('COALESCE(`order`, 999999), created_at'); + } + + /** + * The form as the driver sees it: every group with its fields inside, + * both sorted by `order` then creation, with the fields that belong to + * no group gathered into an "Ungrouped" tail. + * + * @return Category[] + */ + public function getGroupedFieldsAttribute(): array + { + $this->loadMissing(['fieldGroups', 'fields']); + + $fieldsByGroup = $this->fields->groupBy(fn (CustomField $field) => $field->category_uuid ?: '_ungrouped'); + + $grouped = static::sortByOrder($this->fieldGroups)->map(function (Category $group) use ($fieldsByGroup) { + $group->setRelation('fields', static::sortByOrder($fieldsByGroup->get($group->uuid, collect()))); + + return $group; + }); + + if ($fieldsByGroup->has('_ungrouped')) { + $ungrouped = new Category([ + 'name' => 'Ungrouped', + 'for' => static::GROUP_FOR, + 'owner_uuid' => $this->uuid, + ]); + $ungrouped->exists = false; + $ungrouped->setRelation('fields', static::sortByOrder($fieldsByGroup->get('_ungrouped'))); + $grouped->push($ungrouped); + } + + return $grouped->values()->all(); + } + + /** + * Sorts groups or fields the way the builder laid them out: an explicit + * `order` first (lowest first), then anything without one in creation order. + */ + public static function sortByOrder(Collection $items): Collection + { + return $items->sort(function ($a, $b) { + $aOrder = $a->order === null ? null : (int) $a->order; + $bOrder = $b->order === null ? null : (int) $b->order; + + if ($aOrder !== null && $bOrder !== null && $aOrder !== $bOrder) { + return $aOrder <=> $bOrder; + } + + if ($aOrder !== null && $bOrder === null) { + return -1; + } + + if ($aOrder === null && $bOrder !== null) { + return 1; + } + + return (string) ($a->created_at ?? '') <=> (string) ($b->created_at ?? ''); + })->values(); + } + public function getSubjectNameAttribute(): ?string { return $this->subject?->name ?? $this->subject?->display_name ?? $this->subject?->public_id; } + /** + * How many things a driver answers. Fields once the form has been built + * with them; the legacy checklist while it still is one. + */ public function getItemCountAttribute(): int { + $fields = $this->relationLoaded('fields') ? $this->fields->count() : $this->fields()->count(); + if ($fields > 0) { + return $fields; + } + return count($this->items ?? []); } diff --git a/server/src/Models/InspectionItemResult.php b/server/src/Models/InspectionItemResult.php index 45e5a454c..0700c5b9f 100644 --- a/server/src/Models/InspectionItemResult.php +++ b/server/src/Models/InspectionItemResult.php @@ -23,9 +23,9 @@ class InspectionItemResult extends Model use HasMetaAttributes; use Searchable; - protected $table = 'inspection_item_results'; + protected $table = 'inspection_item_results'; protected $searchableColumns = ['label', 'category', 'comments']; - protected $filterParams = ['status', 'severity', 'passed', 'inspection_submission_uuid', 'issue_uuid', 'work_order_uuid']; + protected $filterParams = ['status', 'severity', 'passed', 'inspection_submission_uuid', 'issue_uuid', 'work_order_uuid']; protected $fillable = [ 'company_uuid', @@ -52,10 +52,10 @@ class InspectionItemResult extends Model ]; protected $appends = ['submission_id']; - protected $with = []; + protected $with = []; - protected static $logName = 'inspection_item_result'; - protected static $logAttributes = '*'; + protected static $logName = 'inspection_item_result'; + protected static $logAttributes = '*'; protected static $submitEmptyLogs = false; public function getActivitylogOptions(): LogOptions diff --git a/server/src/Models/InspectionLink.php b/server/src/Models/InspectionLink.php index 619ebb3c1..fd3415c7d 100644 --- a/server/src/Models/InspectionLink.php +++ b/server/src/Models/InspectionLink.php @@ -8,8 +8,8 @@ use Fleetbase\Traits\HasMetaAttributes; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; -use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Str; class InspectionLink extends Model @@ -19,7 +19,7 @@ class InspectionLink extends Model use HasMetaAttributes; use SoftDeletes; - protected $table = 'inspection_links'; + protected $table = 'inspection_links'; protected $publicIdType = 'inspection_link'; protected $fillable = [ diff --git a/server/src/Models/InspectionSubmission.php b/server/src/Models/InspectionSubmission.php index e78a8a405..f8defe4c2 100644 --- a/server/src/Models/InspectionSubmission.php +++ b/server/src/Models/InspectionSubmission.php @@ -3,6 +3,11 @@ namespace Fleetbase\FleetOps\Models; use Fleetbase\Casts\Json; +use Fleetbase\FleetOps\Support\InspectionFileStore; +use Fleetbase\Models\Category; +use Fleetbase\Models\CustomField; +use Fleetbase\Models\CustomFieldValue; +use Fleetbase\Models\File; use Fleetbase\Models\Model; use Fleetbase\Models\User; use Fleetbase\Traits\HasApiModelBehavior; @@ -29,10 +34,10 @@ class InspectionSubmission extends Model use Searchable; use HasCustomFields; - protected $table = 'inspection_submissions'; - protected $publicIdType = 'inspection_submission'; + protected $table = 'inspection_submissions'; + protected $publicIdType = 'inspection_submission'; protected $searchableColumns = ['public_id', 'type', 'status', 'result', 'vehicle.name', 'driver.name']; - protected $filterParams = ['status', 'result', 'type', 'source', 'vehicle', 'driver', 'inspection_form_uuid']; + protected $filterParams = ['status', 'result', 'type', 'source', 'vehicle', 'driver', 'inspection_form_uuid']; protected $fillable = [ 'company_uuid', @@ -76,10 +81,10 @@ class InspectionSubmission extends Model ]; protected $appends = ['form_name', 'vehicle_name', 'driver_name', 'has_failures']; - protected $with = ['form', 'vehicle', 'driver']; + protected $with = ['form', 'vehicle', 'driver']; - protected static $logName = 'inspection_submission'; - protected static $logAttributes = '*'; + protected static $logName = 'inspection_submission'; + protected static $logAttributes = '*'; protected static $submitEmptyLogs = false; public function getActivitylogOptions(): LogOptions @@ -127,6 +132,128 @@ public function failedItemResults(): HasMany return $this->itemResults()->where('passed', false); } + /** + * The photos and signatures filed with the inspection: platform files + * whose subject is the submission, however they arrived — inside the + * driver's submit body, or dropped on the console's Photos panel. + */ + public function files(): HasMany + { + return $this->hasMany(File::class, 'subject_uuid', 'uuid')->orderBy('created_at'); + } + + /** + * Every `pass-fail` answer, as the row the rest of the platform reads. + * + * Issues, work orders and the history are all built from + * `inspection_item_results`; a form built from fields answers through + * custom-field values instead, so each pass-fail value is mirrored into + * a result row keyed the way the app keys it — the field's name, or its + * uuid when it has none. Rows for pass-fail fields the submission no + * longer answers are dropped; rows written any other way (a legacy + * checklist, the console's results editor) are left alone. + * + * @return int the number of result rows derived + */ + public function syncItemResultsFromCustomFieldValues(): int + { + $this->unsetRelation('customFieldValues'); + $this->load('customFieldValues.customField'); + + $answered = $this->customFieldValues->filter(fn (CustomFieldValue $value) => $value->customField?->type === 'pass-fail'); + $groups = Category::query()->whereIn('uuid', $answered->map(fn (CustomFieldValue $value) => $value->customField->category_uuid)->filter()->unique()->values())->pluck('name', 'uuid'); + $unsafe = false; + $kept = []; + + foreach ($answered as $value) { + /** @var CustomField $field */ + $field = $value->customField; + $answer = is_array($value->value) ? $value->value : []; + $notApplicable = filter_var($answer['not_applicable'] ?? false, FILTER_VALIDATE_BOOLEAN); + $passed = $notApplicable || filter_var($answer['passed'] ?? true, FILTER_VALIDATE_BOOLEAN); + $failed = !$passed; + $isUnsafe = $failed && filter_var($answer['unsafe'] ?? data_get($field->meta, 'unsafe_on_fail', false), FILTER_VALIDATE_BOOLEAN); + $unsafe = $unsafe || $isUnsafe; + $itemKey = static::itemKeyFor($field); + + InspectionItemResult::updateOrCreate([ + 'inspection_submission_uuid' => $this->uuid, + 'item_key' => $itemKey, + ], [ + 'company_uuid' => $this->company_uuid, + 'label' => $field->label ?? $field->name, + 'category' => $groups->get($field->category_uuid) ?? data_get($field->meta, 'category'), + 'status' => $notApplicable ? 'not_applicable' : ($passed ? 'passed' : 'failed'), + 'severity' => $failed ? ($answer['severity'] ?? data_get($field->meta, 'severity')) : null, + 'passed' => $passed, + 'comments' => $answer['comments'] ?? null, + 'photos' => array_values(array_filter((array) ($answer['photos'] ?? []), 'is_string')), + 'meta' => [ + 'custom_field_uuid' => $field->uuid, + 'not_applicable' => $notApplicable, + 'unsafe' => $isUnsafe, + ], + ]); + + $kept[] = $itemKey; + } + + $stale = CustomField::query() + ->where('subject_uuid', $this->inspection_form_uuid) + ->where('for', InspectionForm::FIELD_FOR) + ->where('type', 'pass-fail') + ->get() + ->map(fn (CustomField $field) => static::itemKeyFor($field)) + ->diff($kept) + ->values(); + if ($stale->isNotEmpty()) { + $this->itemResults()->whereIn('item_key', $stale)->delete(); + } + + $meta = $this->meta ?? []; + if (($meta['unsafe'] ?? null) !== $unsafe) { + $this->update(['meta' => array_merge($meta, ['unsafe' => $unsafe])]); + } + + InspectionFileStore::attachReferenced($this, $this->referencedFileUuids()); + $this->unsetRelation('itemResults'); + + return count($kept); + } + + /** + * The key a pass-fail field's result row is filed under. The app derives + * the same key (`field.name ?? field.id`), so a result the app built and + * one the server derived name the same item. + */ + public static function itemKeyFor(CustomField $field): string + { + return $field->name ?: $field->uuid; + } + + /** + * Every `file:` the submission's values point at: file and + * signature values, and the photos inside a failed pass-fail answer. + * + * @return string[] + */ + public function referencedFileUuids(): array + { + $uuids = []; + foreach ($this->customFieldValues as $value) { + $raw = $value->getRawOriginal('value'); + $candidates = is_array($value->value) ? (array) ($value->value['photos'] ?? []) : [$raw]; + foreach ($candidates as $candidate) { + $uuid = InspectionFileStore::referencedUuid($candidate); + if ($uuid) { + $uuids[] = $uuid; + } + } + } + + return array_values(array_unique($uuids)); + } + public function getFormNameAttribute(): ?string { return $this->form?->name; @@ -149,7 +276,7 @@ public function getHasFailuresAttribute(): bool public function syncResultCounts(): bool { - $total = $this->itemResults()->count(); + $total = $this->itemResults()->count(); $failed = $this->failedItemResults()->count(); return $this->update([ @@ -168,7 +295,7 @@ public function createIssueFromFailures(): ?Issue } $failedLabels = $this->failedItemResults()->limit(6)->pluck('label')->filter()->values()->all(); - $issue = Issue::create([ + $issue = Issue::create([ 'company_uuid' => $this->company_uuid, 'reported_by_uuid' => $this->submitted_by_uuid, 'vehicle_uuid' => $this->vehicle_uuid, @@ -199,7 +326,7 @@ public function createWorkOrderFromFailures(): ?WorkOrder } $failedItems = $this->failedItemResults()->get(); - $checklist = $failedItems->map(fn (InspectionItemResult $item) => [ + $checklist = $failedItems->map(fn (InspectionItemResult $item) => [ 'title' => $item->label, 'required' => true, 'completed' => false, @@ -210,19 +337,19 @@ public function createWorkOrderFromFailures(): ?WorkOrder ])->values()->all(); $workOrder = WorkOrder::create([ - 'company_uuid' => $this->company_uuid, - 'subject' => 'Inspection repair: ' . ($this->vehicle_name ?? $this->public_id), - 'status' => 'open', - 'priority' => $this->highestFailureSeverity(), - 'target_type' => $this->vehicle_uuid ? Vehicle::class : null, - 'target_uuid' => $this->vehicle_uuid, - 'opened_at' => now(), - 'due_at' => now()->addDays($this->highestFailureSeverity() === 'critical' ? 1 : 7), - 'instructions' => 'Resolve failed inspection items and record completion details.', - 'checklist' => $checklist, - 'currency' => $this->vehicle?->currency, + 'company_uuid' => $this->company_uuid, + 'subject' => 'Inspection repair: ' . ($this->vehicle_name ?? $this->public_id), + 'status' => 'open', + 'priority' => $this->highestFailureSeverity(), + 'target_type' => $this->vehicle_uuid ? Vehicle::class : null, + 'target_uuid' => $this->vehicle_uuid, + 'opened_at' => now(), + 'due_at' => now()->addDays($this->highestFailureSeverity() === 'critical' ? 1 : 7), + 'instructions' => 'Resolve failed inspection items and record completion details.', + 'checklist' => $checklist, + 'currency' => $this->vehicle?->currency, 'created_by_uuid' => $this->submitted_by_uuid, - 'meta' => [ + 'meta' => [ 'source' => 'inspection', 'inspection_submission_uuid' => $this->uuid, 'inspection_submission_id' => $this->public_id, diff --git a/server/src/Support/InspectionFileStore.php b/server/src/Support/InspectionFileStore.php new file mode 100644 index 000000000..3a9b58ac8 --- /dev/null +++ b/server/src/Support/InspectionFileStore.php @@ -0,0 +1,202 @@ +`, which + * is what every value leaves here as — a URL, or a reference to a file that + * already exists, is kept as it came. + */ +class InspectionFileStore +{ + public const TYPE_PHOTO = 'inspection_photo'; + public const TYPE_SIGNATURE = 'inspection_signature'; + + /** + * Normalizes one file value. Returns the value unchanged when it is not a + * string, is empty, or is something other than base64 or a file reference. + */ + public static function normalize(mixed $value, InspectionSubmission $submission, string $type = self::TYPE_PHOTO, ?string $uploaderUuid = null): mixed + { + if (!is_string($value) || trim($value) === '') { + return $value; + } + + $value = trim($value); + + if (Str::startsWith($value, 'file:') || static::isUrl($value)) { + return $value; + } + + if (Str::isUuid($value)) { + return 'file:' . $value; + } + + if (Str::startsWith($value, 'file_')) { + $file = File::query()->where('public_id', $value)->first(); + + return $file ? 'file:' . $file->uuid : $value; + } + + if (static::isBase64($value)) { + $file = static::store($value, $submission, $type, $uploaderUuid); + + return $file ? 'file:' . $file->uuid : $value; + } + + return $value; + } + + /** + * Stores a base64 payload — bare or as a data URI — as a file that belongs + * to the submission, under `inspections//`. + */ + public static function store(string $base64, InspectionSubmission $submission, string $type = self::TYPE_PHOTO, ?string $uploaderUuid = null): ?File + { + $contentType = null; + $data = $base64; + + if (preg_match('/^data:(?[^;]+);base64,(?.+)$/s', $base64, $matches)) { + $contentType = $matches['content_type']; + $data = $matches['data']; + } + + $data = preg_replace('/\s+/', '', $data); + $contentType = $contentType ?? static::sniffContentType($data); + $extension = static::extensionFor($contentType); + $fileName = Str::lower(Str::random(20)) . '.' . $extension; + + $file = File::createFromBase64($data, $fileName, 'inspections/' . $submission->uuid, $type, $contentType); + if (!$file instanceof File) { + return null; + } + + $file->company_uuid = $submission->company_uuid; + $file->uploader_uuid = $uploaderUuid ?? $submission->submitted_by_uuid ?? $file->uploader_uuid; + $file->setSubject($submission, $type); + + return $file; + } + + /** + * Files referenced by `file:` values that were uploaded before the + * submission existed — the console uploads a photo as soon as it is picked + * — are claimed by the submission so its Photos panel lists them. + * + * @param string[] $uuids + */ + public static function attachReferenced(InspectionSubmission $submission, array $uuids): int + { + $uuids = array_values(array_unique(array_filter($uuids, 'is_string'))); + if (empty($uuids)) { + return 0; + } + + return File::query() + ->whereIn('uuid', $uuids) + ->whereNull('subject_uuid') + ->update(['subject_uuid' => $submission->uuid, 'subject_type' => $submission->getMorphClass()]); + } + + /** The uuid a `file:` value points at, or null for anything else. */ + public static function referencedUuid(mixed $value): ?string + { + if (!is_string($value) || !Str::startsWith($value, 'file:')) { + return null; + } + + $uuid = substr($value, 5); + + return Str::isUuid($uuid) ? $uuid : null; + } + + /** Resolves a `file:` value to its file, or null. */ + public static function resolve(mixed $value): ?File + { + $uuid = static::referencedUuid($value); + + return $uuid ? File::query()->where('uuid', $uuid)->first() : null; + } + + /** + * What the driver API answers for a file value: the file's id and where to + * fetch it, or the value as it was stored when it is a URL. + */ + public static function project(mixed $value): mixed + { + $file = static::resolve($value); + if (!$file) { + return $value; + } + + return [ + 'id' => $file->public_id, + 'url' => $file->url, + 'filename' => $file->original_filename, + 'content_type' => $file->content_type, + ]; + } + + public static function isUrl(string $value): bool + { + return (bool) preg_match('#^https?://#i', $value); + } + + /** Bare base64 of at least a few bytes: long enough not to be a word, and only base64 characters. */ + public static function isBase64(string $value): bool + { + if (Str::startsWith($value, 'data:')) { + return (bool) preg_match('/^data:[^;]+;base64,[A-Za-z0-9+\/=\s]+$/s', $value); + } + + $stripped = preg_replace('/\s+/', '', $value); + + return strlen($stripped) >= 16 + && strlen($stripped) % 4 === 0 + && (bool) preg_match('/^[A-Za-z0-9+\/]+={0,2}$/', $stripped); + } + + /** The image type from the decoded bytes' signature; PNG when it is not a known image. */ + public static function sniffContentType(string $base64): string + { + $bytes = (string) base64_decode(substr($base64, 0, 32), true); + + if (str_starts_with($bytes, "\xFF\xD8\xFF")) { + return 'image/jpeg'; + } + + if (str_starts_with($bytes, 'GIF8')) { + return 'image/gif'; + } + + if (str_starts_with($bytes, 'RIFF') && substr($bytes, 8, 4) === 'WEBP') { + return 'image/webp'; + } + + if (str_starts_with($bytes, '%PDF')) { + return 'application/pdf'; + } + + return 'image/png'; + } + + public static function extensionFor(string $contentType): string + { + return match (strtolower($contentType)) { + 'image/jpeg', 'image/jpg' => 'jpg', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + 'application/pdf' => 'pdf', + 'image/svg+xml' => 'svg', + default => 'png', + }; + } +} diff --git a/server/src/Support/InspectionFormSync.php b/server/src/Support/InspectionFormSync.php new file mode 100644 index 000000000..83a5468ff --- /dev/null +++ b/server/src/Support/InspectionFormSync.php @@ -0,0 +1,225 @@ +where(['owner_uuid' => $form->uuid, 'for' => InspectionForm::GROUP_FOR]) + ->get() + ->keyBy('uuid'); + $existingFields = CustomField::query() + ->where('subject_uuid', $form->uuid) + ->get() + ->keyBy('uuid'); + + $keptGroups = []; + $keptFields = []; + $now = now(); + + foreach (array_values($draft) as $groupIndex => $groupData) { + $groupUuid = Arr::get($groupData, $primaryKey); + $group = $groupUuid ? $existingGroups->get($groupUuid) : null; + $groupUuid = $group?->uuid ?? ($groupUuid ?: (string) Str::uuid()); + $groupAttrs = static::normalizeRow(Arr::only($groupData, static::GROUP_COLUMNS), $groupIndex); + + if ($group) { + DB::table('categories')->where('uuid', $groupUuid)->update(array_merge($groupAttrs, ['updated_at' => $now])); + } else { + DB::table('categories')->insert(array_merge($groupAttrs, [ + 'uuid' => $groupUuid, + 'public_id' => 'category_' . Str::lower(Str::random(14)), + 'company_uuid' => $form->company_uuid, + 'owner_uuid' => $form->uuid, + 'owner_type' => $form->getMorphClass(), + 'for' => InspectionForm::GROUP_FOR, + 'slug' => Str::slug((string) ($groupAttrs['name'] ?? 'group')), + 'created_at' => $now, + 'updated_at' => $now, + ])); + } + + $keptGroups[] = $groupUuid; + + $fields = Arr::get($groupData, 'fields', Arr::get($groupData, 'customFields', [])); + foreach (array_values(is_array($fields) ? $fields : []) as $fieldIndex => $fieldData) { + $fieldUuid = Arr::get($fieldData, $primaryKey); + $field = $fieldUuid ? $existingFields->get($fieldUuid) : null; + $fieldUuid = $field?->uuid ?? ($fieldUuid ?: (string) Str::uuid()); + $fieldAttrs = static::normalizeField(Arr::only($fieldData, static::FIELD_COLUMNS), $fieldIndex); + + if ($field) { + DB::table('custom_fields')->where('uuid', $fieldUuid)->update(array_merge($fieldAttrs, ['category_uuid' => $groupUuid, 'updated_at' => $now])); + } else { + DB::table('custom_fields')->insert(array_merge($fieldAttrs, [ + 'uuid' => $fieldUuid, + 'company_uuid' => $form->company_uuid, + 'category_uuid' => $groupUuid, + 'subject_uuid' => $form->uuid, + 'subject_type' => $form->getMorphClass(), + 'created_at' => $now, + 'updated_at' => $now, + ])); + } + + $keptFields[] = $fieldUuid; + } + } + + if ($pruneMissing) { + static::prune($form, $existingGroups, $keptGroups, $existingFields, $keptFields); + } + + $form->unsetRelation('fieldGroups'); + $form->unsetRelation('fields'); + $form->load(['fieldGroups', 'fields']); + + return ['groups' => $form->fieldGroups->all(), 'fields' => $form->fields->all()]; + } + + /** + * Converts the first cut's `items` checklist into a "Checklist" group of + * pass-fail fields. Skips forms that have already been built with fields, + * and forms with nothing to convert, so it can be run any number of times. + * + * @return int the number of fields written + */ + public static function convertLegacyItems(InspectionForm $form): int + { + $items = array_values(array_filter(is_array($form->items) ? $form->items : [], 'is_array')); + if (empty($items) || $form->fields()->count() > 0) { + return 0; + } + + $fields = []; + foreach ($items as $index => $item) { + $label = trim((string) ($item['label'] ?? $item['title'] ?? '')); + $label = $label === '' ? 'Item ' . ($index + 1) : $label; + $fields[] = [ + 'label' => $label, + 'name' => Str::slug((string) ($item['key'] ?? $label)), + 'type' => 'pass-fail', + 'required' => (bool) ($item['required'] ?? true), + 'description' => $item['description'] ?? null, + 'order' => $index + 1, + 'meta' => [ + 'severity' => $item['severity'] ?? 'medium', + 'category' => $item['category'] ?? null, + 'require_photo_on_fail' => false, + 'require_comment_on_fail' => false, + 'unsafe_on_fail' => ($item['severity'] ?? null) === 'critical', + ], + ]; + } + + $synced = static::sync($form, [[ + 'name' => 'Checklist', + 'order' => 1, + 'meta' => ['grid_size' => 1, 'converted_from_items' => true], + 'fields' => $fields, + ]]); + + return count($synced['fields']); + } + + /** Group attributes as the categories table stores them. */ + protected static function normalizeRow(array $attributes, int $index): array + { + if (!array_key_exists('order', $attributes) || $attributes['order'] === null) { + $attributes['order'] = $index + 1; + } + + foreach ($attributes as $key => $value) { + if (is_array($value) || is_object($value)) { + $attributes[$key] = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + } + + return $attributes; + } + + /** Field attributes as the custom_fields table stores them. */ + protected static function normalizeField(array $attributes, int $index): array + { + $label = trim((string) ($attributes['label'] ?? '')); + if ($label === '') { + $label = 'Untitled field'; + } + + $attributes['label'] = $label; + $attributes['name'] = Str::slug((string) (($attributes['name'] ?? '') ?: $label)); + $attributes['type'] = in_array($attributes['type'] ?? null, InspectionForm::FIELD_TYPES, true) ? $attributes['type'] : 'input'; + $attributes['for'] = InspectionForm::FIELD_FOR; + + if (empty($attributes['component'])) { + $attributes['component'] = static::componentFor($attributes['type']); + } + + foreach (['required', 'editable'] as $flag) { + $attributes[$flag] = (int) filter_var($attributes[$flag] ?? ($flag === 'editable'), FILTER_VALIDATE_BOOLEAN); + } + + return static::normalizeRow($attributes, $index); + } + + /** + * The console component that edits a field type. The platform's own + * types keep the platform's components; the inspection-only types are + * rendered by FleetOps and named after themselves. + */ + public static function componentFor(string $type): string + { + return $type === 'radio-button' ? 'radio-button-select' : $type; + } + + protected static function prune(InspectionForm $form, Collection $existingGroups, array $keptGroups, Collection $existingFields, array $keptFields): void + { + $groupsToDelete = $existingGroups->keys()->diff($keptGroups)->values(); + if ($groupsToDelete->isNotEmpty()) { + CustomField::query()->where('subject_uuid', $form->uuid)->whereIn('category_uuid', $groupsToDelete)->delete(); + Category::query()->whereIn('uuid', $groupsToDelete)->delete(); + } + + $fieldsToDelete = $existingFields->keys()->diff($keptFields)->values(); + if ($fieldsToDelete->isNotEmpty()) { + CustomField::query()->whereIn('uuid', $fieldsToDelete)->delete(); + } + } +} diff --git a/server/src/Support/InspectionSubmitter.php b/server/src/Support/InspectionSubmitter.php index 2bf6fcf36..f3a81b458 100644 --- a/server/src/Support/InspectionSubmitter.php +++ b/server/src/Support/InspectionSubmitter.php @@ -6,15 +6,27 @@ use Fleetbase\FleetOps\Models\InspectionItemResult; use Fleetbase\FleetOps\Models\InspectionSubmission; use Fleetbase\FleetOps\Rules\Base64OrUrl; +use Fleetbase\Models\CustomField; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; /** * Records an inspection against a published form. * * Two doors lead here — the tokenised public link and the driver API — and - * they must agree on what a submission is: one row, one item result per item, - * the pass/fail counts, and whatever follow-up the form's settings ask for. - * The doors differ only in who they say submitted it, which is what the - * `$attributes` argument carries. + * they must agree on what a submission is: one row, the answers, the pass/fail + * counts, and whatever follow-up the form's settings ask for. The doors differ + * only in who they say submitted it, which is what the `$attributes` argument + * carries. + * + * A form built from fields is answered with `custom_field_values`: one value + * per field, stored through the platform's custom-field values, with every + * `pass-fail` answer mirrored into an item result. The first cut's + * `item_results` body is still accepted for the public link and older app + * builds; when both arrive the field values win and the duplicated results + * are ignored. */ class InspectionSubmitter { @@ -24,21 +36,24 @@ class InspectionSubmitter public static function rules(): array { return [ - 'odometer' => 'nullable|integer|min:0', - 'engine_hours' => 'nullable|integer|min:0', - 'item_results' => 'required|array|min:1', - 'item_results.*.item_key' => 'nullable|string|max:191', - 'item_results.*.label' => 'required|string|max:255', - 'item_results.*.category' => 'nullable|string|max:191', - 'item_results.*.status' => 'nullable|string|max:50', - 'item_results.*.severity' => 'nullable|string|max:50', - 'item_results.*.passed' => 'required|boolean', - 'item_results.*.comments' => 'nullable|string|max:2000', - 'item_results.*.photos' => 'nullable|array', - 'item_results.*.photos.*' => ['string', new Base64OrUrl()], - 'location' => 'nullable|array', - 'signature' => 'nullable|array', - 'attachments' => 'nullable|array', + 'odometer' => 'nullable|integer|min:0', + 'engine_hours' => 'nullable|integer|min:0', + 'custom_field_values' => 'required_without:item_results|array', + 'custom_field_values.*.custom_field' => 'required_without:custom_field_values.*.custom_field_uuid|string|max:191', + 'custom_field_values.*.value_type' => 'nullable|string|max:50', + 'item_results' => 'required_without:custom_field_values|array', + 'item_results.*.item_key' => 'nullable|string|max:191', + 'item_results.*.label' => 'required|string|max:255', + 'item_results.*.category' => 'nullable|string|max:191', + 'item_results.*.status' => 'nullable|string|max:50', + 'item_results.*.severity' => 'nullable|string|max:50', + 'item_results.*.passed' => 'required|boolean', + 'item_results.*.comments' => 'nullable|string|max:2000', + 'item_results.*.photos' => 'nullable|array', + 'item_results.*.photos.*' => ['string', new Base64OrUrl()], + 'location' => 'nullable|array', + 'signature' => 'nullable|array', + 'attachments' => 'nullable|array', ]; } @@ -63,19 +78,24 @@ public static function submit(InspectionForm $form, array $validated, array $att 'attachments' => data_get($validated, 'attachments'), ], $attributes)); - foreach ($validated['item_results'] as $item) { - InspectionItemResult::create([ - 'company_uuid' => $form->company_uuid, - 'inspection_submission_uuid' => $submission->uuid, - 'item_key' => data_get($item, 'item_key'), - 'label' => data_get($item, 'label'), - 'category' => data_get($item, 'category'), - 'status' => data_get($item, 'status', data_get($item, 'passed') ? 'passed' : 'failed'), - 'severity' => data_get($item, 'severity'), - 'passed' => (bool) data_get($item, 'passed'), - 'comments' => data_get($item, 'comments'), - 'photos' => data_get($item, 'photos'), - ]); + $values = data_get($validated, 'custom_field_values'); + if (is_array($values) && !empty($values)) { + static::applyCustomFieldValues($submission, $values, $submission->submitted_by_uuid); + } else { + foreach ((array) data_get($validated, 'item_results', []) as $item) { + InspectionItemResult::create([ + 'company_uuid' => $form->company_uuid, + 'inspection_submission_uuid' => $submission->uuid, + 'item_key' => data_get($item, 'item_key'), + 'label' => data_get($item, 'label'), + 'category' => data_get($item, 'category'), + 'status' => data_get($item, 'status', data_get($item, 'passed') ? 'passed' : 'failed'), + 'severity' => data_get($item, 'severity'), + 'passed' => (bool) data_get($item, 'passed'), + 'comments' => data_get($item, 'comments'), + 'photos' => data_get($item, 'photos'), + ]); + } } $submission->syncResultCounts(); @@ -90,4 +110,168 @@ public static function submit(InspectionForm $form, array $validated, array $att return $submission; } + + /** + * Stores a set of answers against the submission's form. + * + * Each row names a field of the form by uuid (or name), carries a value + * and, optionally, a value type. A field that does not belong to the form, + * or a failed pass-fail answer missing the comment or photo the field + * insists on, refuses the whole set with a 422 so nothing half-filled is + * written. Base64 photos and signatures become platform files on the way + * in; when the answers are in, the item results and the counts follow. + * + * @param array $rows [{custom_field|custom_field_uuid, value, value_type}] + * @param string|null $uploaderUuid the user a stored photo or signature is credited to + * + * @throws ValidationException + */ + public static function applyCustomFieldValues(InspectionSubmission $submission, array $rows, ?string $uploaderUuid = null): array + { + $fields = CustomField::query()->where('subject_uuid', $submission->inspection_form_uuid)->where('for', InspectionForm::FIELD_FOR)->get(); + $payload = []; + $errors = []; + + foreach (array_values($rows) as $index => $row) { + $key = Arr::get($row, 'custom_field', Arr::get($row, 'custom_field_uuid')); + $field = static::findField($fields, $key); + if (!$field) { + $errors["custom_field_values.{$index}.custom_field"] = ['The field "' . (is_scalar($key) ? $key : '?') . '" does not belong to this inspection form.']; + continue; + } + + [$value, $valueType, $fieldErrors] = static::normalizeValue($field, Arr::get($row, 'value'), Arr::get($row, 'value_type'), $submission, $uploaderUuid); + foreach ($fieldErrors as $message) { + $errors["custom_field_values.{$index}.value"][] = $message; + } + + $payload[] = [ + 'custom_field_uuid' => $field->uuid, + 'value' => $value, + 'value_type' => $valueType, + ]; + } + + if (!empty($errors)) { + throw ValidationException::withMessages($errors); + } + + $summary = $submission->syncCustomFieldValues($payload); + $submission->syncItemResultsFromCustomFieldValues(); + $submission->syncResultCounts(); + + return $summary; + } + + /** Finds a field of the form by uuid or, failing that, by its name. */ + protected static function findField(Collection $fields, mixed $key): ?CustomField + { + if (!is_string($key) || $key === '') { + return null; + } + + return $fields->first(fn (CustomField $field) => $field->uuid === $key) + ?? $fields->first(fn (CustomField $field) => $field->name === $key); + } + + /** + * The value as it is stored, its type, and anything wrong with it. + * + * @return array{0: mixed, 1: string, 2: string[]} + */ + protected static function normalizeValue(CustomField $field, mixed $value, mixed $valueType, InspectionSubmission $submission, ?string $uploaderUuid): array + { + $errors = []; + + switch ($field->type) { + case 'pass-fail': + $answer = static::passFailAnswer($value); + $failed = !$answer['not_applicable'] && !filter_var($answer['passed'], FILTER_VALIDATE_BOOLEAN); + $meta = is_array($field->meta) ? $field->meta : []; + $photos = array_values(array_filter($answer['photos'], 'is_string')); + + // What the field insists on is checked before anything is + // stored, so a refused answer leaves no orphan photo behind. + if ($failed) { + if (data_get($meta, 'require_comment_on_fail') && trim((string) $answer['comments']) === '') { + $errors[] = 'A comment is required when "' . $field->label . '" fails.'; + } + if (data_get($meta, 'require_photo_on_fail') && empty($photos)) { + $errors[] = 'A photo is required when "' . $field->label . '" fails.'; + } + } + + if (!empty($errors)) { + return [$answer, 'object', $errors]; + } + + $answer['photos'] = array_values(array_map( + fn ($photo) => InspectionFileStore::normalize($photo, $submission, InspectionFileStore::TYPE_PHOTO, $uploaderUuid), + $photos + )); + + if ($failed) { + $answer['severity'] = $answer['severity'] ?? data_get($meta, 'severity'); + $answer['unsafe'] = filter_var($answer['unsafe'] ?? data_get($meta, 'unsafe_on_fail', false), FILTER_VALIDATE_BOOLEAN); + } else { + $answer['severity'] = null; + $answer['unsafe'] = false; + } + + return [$answer, 'object', $errors]; + + case 'file-upload': + case 'signature': + $type = $field->type === 'signature' ? InspectionFileStore::TYPE_SIGNATURE : InspectionFileStore::TYPE_PHOTO; + + return [InspectionFileStore::normalize($value, $submission, $type, $uploaderUuid), 'file', $errors]; + + case 'number': + return [$value === null || $value === '' ? null : (is_numeric($value) ? $value + 0 : $value), 'number', $errors]; + + case 'boolean': + return [filter_var($value, FILTER_VALIDATE_BOOLEAN), 'boolean', $errors]; + + default: + if (is_array($value)) { + return [$value, 'array', $errors]; + } + + return [$value, is_string($valueType) && $valueType !== '' ? $valueType : 'text', $errors]; + } + } + + /** + * A pass-fail answer in one shape, whatever the client sent: an object, + * its JSON, a bare boolean, or the words "pass" / "fail". + * + * "Not applicable" is an answer of its own — the app sends it as + * `passed: true, not_applicable: true`, and it must not read as a pass + * once it is a result row, or a tail lift a rigid does not have would + * count towards the vehicle's clean record. + */ + public static function passFailAnswer(mixed $value): array + { + if (is_string($value) && Str::startsWith(trim($value), '{')) { + $decoded = json_decode($value, true); + $value = is_array($decoded) ? $decoded : $value; + } + + if (!is_array($value)) { + $passed = is_string($value) ? !in_array(Str::lower(trim($value)), ['fail', 'failed', 'false', '0', 'no'], true) : (bool) $value; + $value = ['passed' => $passed]; + } + + $notApplicable = filter_var($value['not_applicable'] ?? $value['na'] ?? false, FILTER_VALIDATE_BOOLEAN) + || ($value['passed'] ?? $value['pass'] ?? true) === null; + + return [ + 'passed' => $notApplicable ? true : filter_var($value['passed'] ?? $value['pass'] ?? true, FILTER_VALIDATE_BOOLEAN), + 'not_applicable' => $notApplicable, + 'severity' => isset($value['severity']) ? (Str::slug((string) $value['severity']) ?: null) : null, + 'comments' => isset($value['comments']) ? (string) $value['comments'] : null, + 'photos' => is_array($value['photos'] ?? null) ? $value['photos'] : [], + 'unsafe' => $value['unsafe'] ?? null, + ]; + } } diff --git a/server/tests/ApiManifestControllerContractsTest.php b/server/tests/ApiManifestControllerContractsTest.php index f94da2f79..8f31651ac 100644 --- a/server/tests/ApiManifestControllerContractsTest.php +++ b/server/tests/ApiManifestControllerContractsTest.php @@ -84,8 +84,8 @@ public function json(mixed $payload = null, int $status = 200): FleetOpsTestResp /** A stop that records what was done to it instead of touching a database. */ class FleetOpsManifestStopFake extends ManifestStop { - public array $marks = []; - public array $updates = []; + public array $marks = []; + public array $updates = []; public ?object $placeForTest = null; public function markArrived(): ManifestStop @@ -148,7 +148,7 @@ public function relationLoaded($key): bool /** A manifest whose stops and appended counts are supplied, not queried. */ class FleetOpsManifestFakeRecord extends Manifest { - public $stopsForTest = null; + public $stopsForTest; public function getCompletedStopsAttribute(): int { @@ -263,7 +263,7 @@ public function limit(int $limit): self return $this; } - public function get(): \Illuminate\Support\Collection + public function get(): Illuminate\Support\Collection { return collect($this->results); } @@ -451,7 +451,7 @@ function fleetopsManifestStop(string $publicId, string $status, int $sequence, ? $manifest = new FleetOpsManifestFakeRecord(); $manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a'], true); - $manifest->stopsForTest = collect([$done, $far, $near, $mid]); + $manifest->stopsForTest = collect([$done, $far, $near, $mid]); FleetOpsManifestControllerProbe::$manifest = $manifest; FleetOpsManifestControllerProbe::$distances = [ @@ -482,8 +482,8 @@ function fleetopsManifestStop(string $publicId, string $status, int $sequence, ? $manifest = new FleetOpsManifestFakeRecord(); $manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a'], true); - $manifest->stopsForTest = collect([$a, $b, $c]); - FleetOpsManifestControllerProbe::$manifest = $manifest; + $manifest->stopsForTest = collect([$a, $b, $c]); + FleetOpsManifestControllerProbe::$manifest = $manifest; FleetOpsManifestControllerProbe::$distances = [ '1.3,103.8->1.3,103.8' => 0.0, '1.3,103.8->1.31,103.81' => 1500.0, @@ -506,7 +506,7 @@ function fleetopsManifestStop(string $publicId, string $status, int $sequence, ? $manifest = new FleetOpsManifestFakeRecord(); $manifest->setRawAttributes(['uuid' => 'm-uuid', 'public_id' => 'manifest_a'], true); - $manifest->stopsForTest = collect([$a, $b, $c]); + $manifest->stopsForTest = collect([$a, $b, $c]); FleetOpsManifestControllerProbe::$manifest = $manifest; $controller = new FleetOpsManifestControllerProbe(); @@ -585,7 +585,7 @@ public static function callManifestsFor(Driver $driver) $reached = function (callable $lookup): bool { try { $lookup(); - } catch (\Throwable $e) { + } catch (Throwable $e) { return true; } diff --git a/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php b/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php index a1b90fd94..8ea039c47 100644 --- a/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +++ b/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php @@ -184,10 +184,10 @@ class FleetOpsGeofenceControllerFake extends GeofenceController public ?FleetOpsGeofenceQueryFake $nextEventQuery = null; public array $serializedEvents = []; - public ?Fleetbase\FleetOps\Models\Driver $historyDriver = null; + public ?Driver $historyDriver = null; public array $historyDriverLookups = []; - protected function findDriverForHistory(string $driverId): ?Fleetbase\FleetOps\Models\Driver + protected function findDriverForHistory(string $driverId): ?Driver { $this->historyDriverLookups[] = $driverId; @@ -364,7 +364,7 @@ function fleetopsGeofenceEvent(array $attributes): GeofenceEventLog // The endpoint is addressed by public_id and resolves the driver itself; the query // still filters on the driver's uuid. - $historyDriver = new Fleetbase\FleetOps\Models\Driver(); + $historyDriver = new Driver(); $historyDriver->setRawAttributes(['uuid' => 'driver-9', 'public_id' => 'driver_public9'], true); $controller->historyDriver = $historyDriver; @@ -384,7 +384,7 @@ function fleetopsGeofenceEvent(array $attributes): GeofenceEventLog test('driver history answers 404 when the driver does not resolve', function () { // The endpoint's own not-found branch: findDriverForHistory returns null and the // request must not proceed to a query keyed on nothing. - $controller = new FleetOpsGeofenceControllerFake(); + $controller = new FleetOpsGeofenceControllerFake(); $controller->historyDriver = null; $response = $controller->driverHistory(Request::create('/geofences/driver/driver_missing/history', 'GET'), 'driver_missing'); diff --git a/server/tests/InspectionControllerContractsTest.php b/server/tests/InspectionControllerContractsTest.php index 9191c9abb..561132438 100644 --- a/server/tests/InspectionControllerContractsTest.php +++ b/server/tests/InspectionControllerContractsTest.php @@ -109,14 +109,15 @@ public function __call($method, $arguments) 'companies' => ['uuid', 'public_id', '_key', 'name', 'owner_uuid'], 'company_users' => ['uuid', '_key', 'company_uuid', 'user_uuid', 'role_uuid', 'status'], 'settings' => ['key', 'value'], - 'files' => ['uuid', 'public_id', '_key', 'company_uuid', 'subject_uuid', 'subject_type', 'path', 'disk', 'type'], + 'files' => ['uuid', 'public_id', '_key', 'company_uuid', 'uploader_uuid', 'subject_uuid', 'subject_type', 'path', 'disk', 'bucket', 'folder', 'etag', 'meta', 'original_filename', 'type', 'content_type', 'file_size', 'slug', 'caption'], 'vendors' => ['uuid', 'public_id', '_key', 'company_uuid', 'name'], 'orders' => ['uuid', 'public_id', '_key', 'company_uuid', 'driver_assigned_uuid', 'status'], 'positions' => ['uuid', 'public_id', '_key', 'company_uuid', 'subject_uuid', 'subject_type', 'coordinates'], 'maintenances' => ['uuid', 'public_id', '_key', 'company_uuid', 'maintainable_type', 'maintainable_uuid', 'status', 'completed_at'], 'maintenance_schedules' => ['uuid', 'public_id', '_key', 'company_uuid', 'subject_type', 'subject_uuid', 'status', 'next_due_at'], 'custom_field_values' => ['uuid', 'company_uuid', 'custom_field_uuid', 'subject_uuid', 'subject_type', 'value', 'value_type'], - 'custom_fields' => ['uuid', 'company_uuid', 'label', 'name', 'type'], + 'custom_fields' => ['uuid', 'company_uuid', 'category_uuid', 'subject_uuid', 'subject_type', 'name', 'label', 'type', 'for', 'component', 'options', 'required', 'editable', 'default_value', 'validation_rules', 'meta', 'description', 'help_text', 'order'], + 'categories' => ['uuid', 'public_id', '_key', 'company_uuid', 'owner_uuid', 'owner_type', 'parent_uuid', 'icon_file_uuid', 'internal_id', 'name', 'description', 'translations', 'meta', 'tags', 'icon', 'icon_color', 'slug', 'order', 'for', 'core_category'], 'activity_log' => ['uuid', 'company_uuid', 'log_name', 'description', 'subject_type', 'subject_id', 'causer_type', 'causer_id', 'properties', 'event', 'batch_uuid'], ]; diff --git a/server/tests/InspectionModelContractsTest.php b/server/tests/InspectionModelContractsTest.php index b3d4d4dca..5fded0281 100644 --- a/server/tests/InspectionModelContractsTest.php +++ b/server/tests/InspectionModelContractsTest.php @@ -99,14 +99,15 @@ public function __call($method, $arguments) 'companies' => ['uuid', 'public_id', '_key', 'name', 'owner_uuid'], 'company_users' => ['uuid', '_key', 'company_uuid', 'user_uuid', 'role_uuid', 'status'], 'settings' => ['key', 'value'], - 'files' => ['uuid', 'public_id', '_key', 'company_uuid', 'subject_uuid', 'subject_type', 'path', 'disk', 'type'], + 'files' => ['uuid', 'public_id', '_key', 'company_uuid', 'uploader_uuid', 'subject_uuid', 'subject_type', 'path', 'disk', 'bucket', 'folder', 'etag', 'meta', 'original_filename', 'type', 'content_type', 'file_size', 'slug', 'caption'], 'vendors' => ['uuid', 'public_id', '_key', 'company_uuid', 'name'], 'orders' => ['uuid', 'public_id', '_key', 'company_uuid', 'driver_assigned_uuid', 'status'], 'positions' => ['uuid', 'public_id', '_key', 'company_uuid', 'subject_uuid', 'subject_type', 'coordinates'], 'maintenances' => ['uuid', 'public_id', '_key', 'company_uuid', 'maintainable_type', 'maintainable_uuid', 'status', 'completed_at'], 'maintenance_schedules' => ['uuid', 'public_id', '_key', 'company_uuid', 'subject_type', 'subject_uuid', 'status', 'next_due_at'], 'custom_field_values' => ['uuid', 'company_uuid', 'custom_field_uuid', 'subject_uuid', 'subject_type', 'value', 'value_type'], - 'custom_fields' => ['uuid', 'company_uuid', 'label', 'name', 'type'], + 'custom_fields' => ['uuid', 'company_uuid', 'category_uuid', 'subject_uuid', 'subject_type', 'name', 'label', 'type', 'for', 'component', 'options', 'required', 'editable', 'default_value', 'validation_rules', 'meta', 'description', 'help_text', 'order'], + 'categories' => ['uuid', 'public_id', '_key', 'company_uuid', 'owner_uuid', 'owner_type', 'parent_uuid', 'icon_file_uuid', 'internal_id', 'name', 'description', 'translations', 'meta', 'tags', 'icon', 'icon_color', 'slug', 'order', 'for', 'core_category'], 'activity_log' => ['uuid', 'company_uuid', 'log_name', 'description', 'subject_type', 'subject_id', 'causer_type', 'causer_id', 'properties', 'event', 'batch_uuid'], ]; @@ -500,7 +501,8 @@ function fleetOpsInspectionModelSubmission(InspectionForm $form, array $items, a Carbon::setTestNow('2026-09-09 07:00:00'); $rules = InspectionSubmitter::rules(); - expect($rules['item_results'])->toBe('required|array|min:1') + expect($rules['item_results'])->toBe('required_without:custom_field_values|array') + ->and($rules['custom_field_values'])->toBe('required_without:item_results|array') ->and($rules['item_results.*.passed'])->toBe('required|boolean') ->and($rules['item_results.*.photos.*'][1])->toBeInstanceOf(Base64OrUrl::class); diff --git a/server/tests/Unit/Http/Resources/ManifestResourceTest.php b/server/tests/Unit/Http/Resources/ManifestResourceTest.php index 6878184c6..547d1bb2f 100644 --- a/server/tests/Unit/Http/Resources/ManifestResourceTest.php +++ b/server/tests/Unit/Http/Resources/ManifestResourceTest.php @@ -64,7 +64,7 @@ public function getVehicleNameAttribute(): ?string } test('manifest resource publishes what a driver needs to run a route', function () { - $request = Request::create('/v1/manifests/manifest_public', 'GET'); + $request = Request::create('/v1/manifests/manifest_public', 'GET'); FleetOpsSupportRequestState::$request = $request; $manifest = new FleetOpsManifestFake(); @@ -94,7 +94,7 @@ public function getVehicleNameAttribute(): ?string test('manifest resource omits stops when they were not loaded, so a list stays a list', function () { // A driver's manifest list on a busy fleet must not drag every stop of // every route along with it. - $request = Request::create('/v1/drivers/driver_public/manifests', 'GET'); + $request = Request::create('/v1/drivers/driver_public/manifests', 'GET'); FleetOpsSupportRequestState::$request = $request; $manifest = new FleetOpsManifestFake(); @@ -106,7 +106,7 @@ public function getVehicleNameAttribute(): ?string }); test('manifest stop resource carries the sequence a re-sequence rewrites', function () { - $request = Request::create('/v1/manifest-stops/stop_public', 'GET'); + $request = Request::create('/v1/manifest-stops/stop_public', 'GET'); FleetOpsSupportRequestState::$request = $request; $stop = new ManifestStop(); From 94073a8b41d12e5100277d710aa0611726b7a987 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 12:39:42 +0800 Subject: [PATCH 07/44] Answer, export and migrate inspections built from fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console writes a form's structure by posting the whole draft — the builder lays a form out before the record exists, so the structure has to arrive with the save that creates it. `InspectionFormController` takes it under `inspection_form.field_groups` (and fliit's `draft`, so a form authored there still saves) and prunes what the post no longer lists. `InspectionSubmissionController` accepts `custom_field_values` on the same terms as the driver API, so the console and the app write the same rows, and still accepts `item_results` for a legacy checklist. Two bugs found on the way. `Request::array()` is a core-api macro taking exactly one argument, so every `$request->array($a, $request->array($b))` in this controller silently discarded its fallback and the flat spelling never worked; both reads now go through a helper that tries each key. And a field written straight into the table by a seed carries no `component`, so the resource names one from the type. `InspectionExport` gives compliance the spreadsheet it asks for: one row per submission, defects named, follow-up and out-of-service called out, behind the usual `export` route. The migration folds a first cut's `items` into a "Checklist" group of pass-fail fields, skipping any form already built with fields so it can be run again safely. --- ..._inspection_form_items_to_field_groups.php | 44 ++++++ server/src/Exports/InspectionExport.php | 125 ++++++++++++++++++ .../Internal/v1/InspectionFormController.php | 57 ++++++++ .../v1/InspectionSubmissionController.php | 80 ++++++++++- .../src/Http/Resources/v1/InspectionForm.php | 5 +- .../src/Providers/FleetOpsServiceProvider.php | 5 + server/src/routes.php | 1 + 7 files changed, 310 insertions(+), 7 deletions(-) create mode 100644 server/migrations/2026_09_10_000001_convert_inspection_form_items_to_field_groups.php create mode 100644 server/src/Exports/InspectionExport.php diff --git a/server/migrations/2026_09_10_000001_convert_inspection_form_items_to_field_groups.php b/server/migrations/2026_09_10_000001_convert_inspection_form_items_to_field_groups.php new file mode 100644 index 000000000..03c8fbb3f --- /dev/null +++ b/server/migrations/2026_09_10_000001_convert_inspection_form_items_to_field_groups.php @@ -0,0 +1,44 @@ +whereNotNull('items') + ->orderBy('id') + ->chunkById(100, function ($forms) { + foreach ($forms as $form) { + InspectionFormSync::convertLegacyItems($form); + } + }); + } + + public function down(): void + { + // Nothing to undo: `items` was never removed, and the fields written + // here may have been edited since. + } +}; diff --git a/server/src/Exports/InspectionExport.php b/server/src/Exports/InspectionExport.php new file mode 100644 index 000000000..90357ab8a --- /dev/null +++ b/server/src/Exports/InspectionExport.php @@ -0,0 +1,125 @@ +selections = $selections; + } + + public function map($submission): array + { + return [ + $submission->public_id, + $submission->form_name, + $submission->vehicle_name, + $submission->driver_name, + $submission->type, + $submission->status, + $submission->result, + $submission->source, + $submission->odometer, + $submission->engine_hours, + $submission->total_items, + $submission->failed_items, + static::failedLabels($submission), + static::unsafeLabel($submission), + $submission->issue?->public_id, + $submission->workOrder?->public_id, + $submission->started_at, + $submission->submitted_at, + $submission->resolved_at, + $submission->created_at, + ]; + } + + public function headings(): array + { + return [ + 'ID', + 'Form', + 'Vehicle', + 'Driver', + 'Type', + 'Status', + 'Result', + 'Source', + 'Odometer', + 'Engine Hours', + 'Items', + 'Defects', + 'Failed Items', + 'Unsafe', + 'Issue', + 'Work Order', + 'Started', + 'Submitted', + 'Resolved', + 'Date Created', + ]; + } + + public function columnFormats(): array + { + return [ + 'Q' => NumberFormat::FORMAT_DATE_DDMMYYYY, + 'R' => NumberFormat::FORMAT_DATE_DDMMYYYY, + 'S' => NumberFormat::FORMAT_DATE_DDMMYYYY, + 'T' => NumberFormat::FORMAT_DATE_DDMMYYYY, + ]; + } + + /** The defects, named, so the spreadsheet says what actually failed. */ + public static function failedLabels(InspectionSubmission $submission): string + { + return $submission->itemResults + ->filter(fn ($result) => !$result->passed) + ->map(fn ($result) => trim((string) $result->label)) + ->filter() + ->implode(', '); + } + + /** Whether the inspection took the vehicle out of service. */ + public static function unsafeLabel(InspectionSubmission $submission): string + { + $unsafe = filter_var(data_get($submission->meta, 'unsafe', false), FILTER_VALIDATE_BOOLEAN) + || $submission->itemResults->contains(fn ($result) => filter_var(data_get($result->meta, 'unsafe', false), FILTER_VALIDATE_BOOLEAN)); + + return $unsafe ? 'Yes' : 'No'; + } + + /** + * @return \Illuminate\Support\Collection + */ + public function collection() + { + $query = InspectionSubmission::where('company_uuid', session('company')) + ->with(['form', 'vehicle', 'driver', 'itemResults', 'issue', 'workOrder']); + + if ($this->selections) { + $query->whereIn('uuid', $this->selections); + } + + return $query->get(); + } +} diff --git a/server/src/Http/Controllers/Internal/v1/InspectionFormController.php b/server/src/Http/Controllers/Internal/v1/InspectionFormController.php index 421d1e644..49233dffe 100644 --- a/server/src/Http/Controllers/Internal/v1/InspectionFormController.php +++ b/server/src/Http/Controllers/Internal/v1/InspectionFormController.php @@ -7,6 +7,7 @@ use Fleetbase\FleetOps\Models\InspectionForm; use Fleetbase\FleetOps\Models\InspectionLink; use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Support\InspectionFormSync; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -19,6 +20,62 @@ class InspectionFormController extends FleetOpsController */ public $resource = 'inspection-form'; + /** + * The builder lays a form out before the form record exists, so the whole + * structure arrives with the save that creates it and is written in one + * go. `field_groups` is what this console posts; `draft` is what the fliit + * builder posts, and is accepted so a form authored there still saves. + * + * A save that mentions no structure at all leaves the structure alone — + * publishing a form, or renaming it, must not empty it. + */ + public function onAfterCreate(Request $request, InspectionForm $inspectionForm): void + { + $this->syncStructureFromRequest($request, $inspectionForm); + } + + public function onAfterUpdate(Request $request, InspectionForm $inspectionForm): void + { + $this->syncStructureFromRequest($request, $inspectionForm); + } + + /** The console edits the structure, so a read has to carry it. */ + public function onFindRecord($builder, $request): void + { + $builder->with(['fieldGroups', 'fields']); + } + + public function onQueryRecord($builder, $request): void + { + $builder->with(['fieldGroups', 'fields']); + } + + /** + * Writes the posted structure, pruning what the post no longer lists — + * the builder always posts the whole form, so a field it dropped is a + * field the author deleted. + */ + protected function syncStructureFromRequest(Request $request, InspectionForm $form): void + { + $draft = null; + foreach (['inspection_form.field_groups', 'field_groups', 'inspection_form.draft', 'draft'] as $key) { + $posted = $request->input($key); + if (is_array($posted) && !empty($posted)) { + $draft = $posted; + break; + } + } + + if ($draft === null) { + return; + } + + InspectionFormSync::sync($form, $draft, true); + $form->unsetRelation('fieldGroups'); + $form->unsetRelation('fields'); + $form->load(['fieldGroups', 'fields']); + } + public function publish(string $id): JsonResponse { $form = $this->resolveForm($id) diff --git a/server/src/Http/Controllers/Internal/v1/InspectionSubmissionController.php b/server/src/Http/Controllers/Internal/v1/InspectionSubmissionController.php index a8001e688..6a7255ff5 100644 --- a/server/src/Http/Controllers/Internal/v1/InspectionSubmissionController.php +++ b/server/src/Http/Controllers/Internal/v1/InspectionSubmissionController.php @@ -2,11 +2,15 @@ namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1; +use Fleetbase\FleetOps\Exports\InspectionExport; use Fleetbase\FleetOps\Http\Controllers\FleetOpsController; use Fleetbase\FleetOps\Models\InspectionItemResult; use Fleetbase\FleetOps\Models\InspectionSubmission; +use Fleetbase\FleetOps\Support\InspectionSubmitter; +use Fleetbase\Http\Requests\ExportRequest; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Str; class InspectionSubmissionController extends FleetOpsController { @@ -17,21 +21,85 @@ class InspectionSubmissionController extends FleetOpsController */ public $resource = 'inspection-submission'; + /** What a submission has to carry for the console record to render. */ + protected const RELATIONS = ['form', 'vehicle', 'driver', 'itemResults', 'customFieldValues.customField', 'files']; + public function onAfterCreate($request, InspectionSubmission $record, array $input): void { - $this->syncItemResultsFromRequest($request, $record); - $record->load(['form', 'vehicle', 'driver', 'itemResults']); + $this->syncAnswersFromRequest($request, $record); + $record->load(static::RELATIONS); } public function onAfterUpdate($request, InspectionSubmission $record, array $input): void { - $this->syncItemResultsFromRequest($request, $record); - $record->load(['form', 'vehicle', 'driver', 'itemResults']); + $this->syncAnswersFromRequest($request, $record); + $record->load(static::RELATIONS); } public function onFindRecord($builder, $request): void { - $builder->with(['form', 'vehicle', 'driver', 'submittedBy', 'issue', 'workOrder', 'itemResults']); + $builder->with(array_merge(['submittedBy', 'issue', 'workOrder'], static::RELATIONS)); + } + + public function onQueryRecord($builder, $request): void + { + $builder->with(['form', 'vehicle', 'driver', 'itemResults']); + } + + /** + * The answers, then the results derived from them. + * + * A form built from fields is answered with `custom_field_values`, exactly + * as the driver API answers it, so the console and the app write the same + * rows; the submitter stores any photo or signature and mirrors every + * pass-fail answer into an item result. A submission against a legacy + * checklist still posts `item_results` directly, and that door stays open. + */ + protected function syncAnswersFromRequest(Request $request, InspectionSubmission $submission): void + { + $values = static::arrayInput($request, 'inspection_submission.custom_field_values', 'custom_field_values'); + if (!empty($values)) { + InspectionSubmitter::applyCustomFieldValues($submission, $values, session('user')); + + return; + } + + $this->syncItemResultsFromRequest($request, $submission); + } + + /** + * The first of the given keys that carries a list. + * + * The console posts a record under its resource name and the app posts it + * flat, so both spellings are read. `Request::array()` arrived in Laravel + * 11 and this runs on 10, where calling it is a BadMethodCallException. + * + * @return array + */ + protected static function arrayInput(Request $request, string ...$keys): array + { + foreach ($keys as $key) { + $value = $request->input($key); + if (is_array($value) && !empty($value)) { + return $value; + } + } + + return []; + } + + /** + * Export inspections to excel or csv. + * + * @return \Illuminate\Http\Response + */ + public function export(ExportRequest $request) + { + $format = $request->input('format', 'xlsx'); + $selections = static::arrayInput($request, 'selections'); + $fileName = trim(Str::slug('inspections-' . date('Y-m-d-H:i')) . '.' . $format); + + return $this->downloadExport(new InspectionExport($selections), $fileName); } public function submit(string $id): JsonResponse @@ -107,7 +175,7 @@ public function resolve(string $id): JsonResponse protected function syncItemResultsFromRequest(Request $request, InspectionSubmission $submission): void { - $items = $request->array('inspection_submission.item_results', $request->array('item_results')); + $items = static::arrayInput($request, 'inspection_submission.item_results', 'item_results'); if (empty($items)) { return; } diff --git a/server/src/Http/Resources/v1/InspectionForm.php b/server/src/Http/Resources/v1/InspectionForm.php index 709044707..8c6dd1097 100644 --- a/server/src/Http/Resources/v1/InspectionForm.php +++ b/server/src/Http/Resources/v1/InspectionForm.php @@ -2,6 +2,7 @@ namespace Fleetbase\FleetOps\Http\Resources\v1; +use Fleetbase\FleetOps\Support\InspectionFormSync; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Resources\FleetbaseResource; use Fleetbase\Models\Category; @@ -97,7 +98,9 @@ public static function fieldToArray(CustomField $field, bool $internal): array 'description' => $field->description, 'help_text' => $field->help_text, 'type' => $field->type, - 'component' => $field->component, + // A field written straight into the table — by a seed, or by an + // older builder — may carry no component. The type names its own. + 'component' => $field->component ?: InspectionFormSync::componentFor((string) $field->type), 'required' => (bool) $field->required, 'editable' => $field->editable === null ? true : (bool) $field->editable, 'options' => is_array($field->options) ? array_values($field->options) : [], diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index d150a1132..613c74578 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -218,6 +218,11 @@ public function registerMorphMap(): void '\\Fleetbase\\Models\\Vehicle' => \Fleetbase\FleetOps\Models\Vehicle::class, 'fleet-ops:vehicle' => \Fleetbase\FleetOps\Models\Vehicle::class, 'fleet-ops:trailer' => \Fleetbase\FleetOps\Models\Trailer::class, + // A photo or signature filed with an inspection is a platform file + // whose subject is the submission. The console names that subject + // the way Ember names it, so the alias has to resolve here. + 'fleet-ops:inspection-submission' => \Fleetbase\FleetOps\Models\InspectionSubmission::class, + 'fleet-ops:inspection-form' => \Fleetbase\FleetOps\Models\InspectionForm::class, ]); } diff --git a/server/src/routes.php b/server/src/routes.php index 390ee07c9..17265c783 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -667,6 +667,7 @@ function ($router, $controller) { $router->post('{id}/generate-link', $controller('generateLink')); }); $router->fleetbaseRoutes('inspection-submissions', function ($router, $controller) { + $router->match(['get', 'post'], 'export', $controller('export')); $router->post('{id}/submit', $controller('submit')); $router->post('{id}/create-issue', $controller('createIssue')); $router->post('{id}/create-work-order', $controller('createWorkOrder')); From a29b250835c662e80323db205dc652325f902585 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 12:52:00 +0800 Subject: [PATCH 08/44] Cover the second cut: the form, the answers, the files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every new line, in the harness's own style: an in-memory database with the platform's `categories`, `custom_fields` and `custom_field_values` beside the inspection tables, and a real local filesystem so the file store can be watched turning a driver's base64 into a platform file and the resource resolving it back. What is pinned here is the contract the app codes against: `grouped_fields` with a field's id, type, options and meta; `custom_field_values` accepted by uuid or by name; a pass-fail answer read whatever shape it arrives in, "not applicable" kept as its own status; the comment and photo a field insists on refusing the whole set before any file is written; a meter and a boolean answered back as a number and a boolean rather than the strings the value column stores. One behaviour changed to match: a number value now leaves the resource as a number. The `custom_field_values` guard in the submission resource went — `withCustomFields()` has always loaded that relation, so it was dead. --- .../Resources/v1/InspectionSubmission.php | 18 +- server/src/Support/InspectionSubmitter.php | 2 +- server/tests/InspectionFieldContractsTest.php | 877 ++++++++++++++++++ 3 files changed, 891 insertions(+), 6 deletions(-) create mode 100644 server/tests/InspectionFieldContractsTest.php diff --git a/server/src/Http/Resources/v1/InspectionSubmission.php b/server/src/Http/Resources/v1/InspectionSubmission.php index 8b8a284e0..f7fefadf9 100644 --- a/server/src/Http/Resources/v1/InspectionSubmission.php +++ b/server/src/Http/Resources/v1/InspectionSubmission.php @@ -79,13 +79,11 @@ public function toArray($request) */ protected function projectCustomFieldValues(): array { - if (!$this->resource || !$this->resource->relationLoaded('customFieldValues')) { - return []; - } - + // `withCustomFields()` has already loaded the values and the fields + // they answer, so there is nothing to guard against here. $internal = Http::isInternalRequest(); - return $this->resource->customFieldValues->map(function (CustomFieldValue $value) use ($internal) { + return collect($this->resource?->customFieldValues)->map(function (CustomFieldValue $value) use ($internal) { $field = $value->customField; $row = [ 'custom_field' => $value->custom_field_uuid, @@ -128,6 +126,16 @@ protected static function projectValue(CustomFieldValue $value): mixed return $decoded; } + // A value column is a string, so a meter reading comes back as one. + // The app compares and charts these; hand it the number it wrote. + if ($value->value_type === 'number') { + return is_numeric($raw) ? $raw + 0 : $raw; + } + + if ($value->value_type === 'boolean') { + return filter_var($raw, FILTER_VALIDATE_BOOLEAN); + } + return InspectionFileStore::project($raw); } diff --git a/server/src/Support/InspectionSubmitter.php b/server/src/Support/InspectionSubmitter.php index f3a81b458..98790d652 100644 --- a/server/src/Support/InspectionSubmitter.php +++ b/server/src/Support/InspectionSubmitter.php @@ -263,7 +263,7 @@ public static function passFailAnswer(mixed $value): array } $notApplicable = filter_var($value['not_applicable'] ?? $value['na'] ?? false, FILTER_VALIDATE_BOOLEAN) - || ($value['passed'] ?? $value['pass'] ?? true) === null; + || (array_key_exists('passed', $value) && $value['passed'] === null); return [ 'passed' => $notApplicable ? true : filter_var($value['passed'] ?? $value['pass'] ?? true, FILTER_VALIDATE_BOOLEAN), diff --git a/server/tests/InspectionFieldContractsTest.php b/server/tests/InspectionFieldContractsTest.php new file mode 100644 index 000000000..7af94bea9 --- /dev/null +++ b/server/tests/InspectionFieldContractsTest.php @@ -0,0 +1,877 @@ + str_replace('_', ' ', Illuminate\Support\Str::snake((string) $value))); +} + +if (!class_exists('Fleetbase\\Http\\Requests\\ExportRequest', false)) { + eval('namespace Fleetbase\\Http\\Requests; class ExportRequest extends \\Illuminate\\Http\\Request {}'); +} + +class FleetOpsInspectionExportRequestFake extends ExportRequest +{ +} + +/** + * The submission controller with the download intercepted: what is asserted + * is the sheet it asked for, not the workbook the spreadsheet library builds. + */ +class FleetOpsInspectionSubmissionControllerProbe extends InspectionSubmissionController +{ + public array $downloads = []; + + protected function downloadExport(InspectionExport $export, string $fileName) + { + $this->downloads[] = [$export, $fileName]; + + return ['download' => $fileName, 'headings' => $export->headings()]; + } +} + +/** Stands in for the route a resource asks whether it is answering. */ +class FleetOpsInspectionFieldRouteFixture +{ + public function __construct(private string $uri) + { + } + + public function uri(): string + { + return $this->uri; + } +} + +/** Binds the request a resource reads to decide public from internal. */ +function fleetOpsInspectionFieldRequest(bool $internal): Request +{ + $uri = $internal ? 'api/int/v1/fleet-ops/inspection-submissions' : 'v1/inspections'; + $request = Request::create('/' . $uri, 'GET'); + $request->setRouteResolver(fn () => new FleetOpsInspectionFieldRouteFixture($uri)); + app()->instance('request', $request); + + return $request; +} + +/** + * A form built from fields, and a submission that answers it, against an + * in-memory database. Every column is a nullable string: what is asserted + * here is what the models, the writer and the resources put in and take out. + */ +function fleetOpsInspectionFieldDatabase(): SQLiteConnection +{ + $pdo = new PDO('sqlite::memory:'); + $connection = new SQLiteConnection($pdo); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + EloquentModel::setEventDispatcher(new Dispatcher()); + EloquentModel::clearBootedModels(); + + $root = sys_get_temp_dir() . '/fleetops-inspection-files'; + $config = new Repository([ + 'activitylog' => ['enabled' => false, 'default_auth_driver' => null, 'default_log_name' => 'default'], + 'api' => ['cache' => ['enabled' => false]], + // Not the `local` disk: a file on that one is answered through the + // foundation's `asset()` helper, which has no app to ask here. + 'filesystems' => [ + 'default' => 'uploads', + 'disks' => ['uploads' => ['driver' => 'local', 'root' => $root, 'url' => 'https://files.example.com']], + ], + ]); + app()->instance('config', $config); + app()->instance(Illuminate\Contracts\Config\Repository::class, $config); + app()->instance(Spatie\Activitylog\CauserResolver::class, new class extends Spatie\Activitylog\CauserResolver { + public function __construct() + { + } + + public function resolve(EloquentModel|int|string|null $subject = null): ?EloquentModel + { + return null; + } + }); + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $connection) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->connection; + } + + public function __call($method, $arguments) + { + return $this->connection->{$method}(...$arguments); + } + }); + Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + app()->instance('db.schema', $connection->getSchemaBuilder()); + app()->instance('responsecache', new class { + public function __call($method, $arguments) + { + return null; + } + }); + app()->instance('request', Request::create('/')); + + // Files are real here: the store writes base64 through the platform's + // File model, and what it wrote is what the resource resolves back. + app()->instance('filesystem', new FilesystemManager(app())); + Illuminate\Support\Facades\Storage::clearResolvedInstances(); + Illuminate\Support\Facades\Storage::swap(app('filesystem')); + + $schema = $connection->getSchemaBuilder(); + $tables = [ + 'inspection_forms' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'description', 'type', 'status', 'frequency', 'subject_type', 'subject_uuid', 'items', 'settings', 'meta', 'published_at', 'created_by_uuid', 'updated_by_uuid'], + 'inspection_submissions' => ['uuid', 'public_id', '_key', 'company_uuid', 'inspection_form_uuid', 'vehicle_uuid', 'driver_uuid', 'submitted_by_uuid', 'issue_uuid', 'work_order_uuid', 'type', 'status', 'result', 'source', 'odometer', 'engine_hours', 'total_items', 'failed_items', 'started_at', 'submitted_at', 'resolved_at', 'location', 'signature', 'attachments', 'meta', 'created_by_uuid', 'updated_by_uuid'], + 'inspection_item_results' => ['uuid', '_key', 'company_uuid', 'inspection_submission_uuid', 'issue_uuid', 'work_order_uuid', 'item_key', 'label', 'category', 'status', 'severity', 'passed', 'comments', 'photos', 'meta', 'created_by_uuid', 'updated_by_uuid'], + 'issues' => ['uuid', 'public_id', '_key', 'company_uuid', 'reported_by_uuid', 'assigned_to_uuid', 'vehicle_uuid', 'driver_uuid', 'order_uuid', 'issue_id', 'location', 'category', 'type', 'report', 'title', 'tags', 'priority', 'meta', 'resolved_at', 'status'], + 'work_orders' => ['uuid', 'public_id', '_key', 'company_uuid', 'schedule_uuid', 'code', 'subject', 'category', 'status', 'priority', 'target_type', 'target_uuid', 'assignee_type', 'assignee_uuid', 'opened_at', 'due_at', 'closed_at', 'instructions', 'checklist', 'currency', 'estimated_cost', 'approved_budget', 'actual_cost', 'cost_center', 'budget_code', 'meta', 'created_by_uuid', 'updated_by_uuid'], + 'vehicles' => ['uuid', 'public_id', 'internal_id', '_key', 'company_uuid', 'vendor_uuid', 'photo_uuid', 'name', 'make', 'model', 'year', 'trim', 'plate_number', 'vin', 'status', 'currency', 'slug', 'online', 'location'], + 'drivers' => ['uuid', 'public_id', 'internal_id', '_key', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'vendor_uuid', 'current_job_uuid', 'photo_uuid', 'status', 'online', 'location', 'slug'], + 'users' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'email', 'phone', 'avatar_uuid', 'type', 'status'], + 'companies' => ['uuid', 'public_id', '_key', 'name', 'owner_uuid'], + 'company_users' => ['uuid', '_key', 'company_uuid', 'user_uuid', 'role_uuid', 'status'], + 'settings' => ['key', 'value'], + 'files' => ['uuid', 'public_id', '_key', 'company_uuid', 'uploader_uuid', 'subject_uuid', 'subject_type', 'path', 'disk', 'bucket', 'folder', 'etag', 'meta', 'original_filename', 'type', 'content_type', 'file_size', 'slug', 'caption'], + 'vendors' => ['uuid', 'public_id', '_key', 'company_uuid', 'name'], + 'orders' => ['uuid', 'public_id', '_key', 'company_uuid', 'driver_assigned_uuid', 'status'], + 'positions' => ['uuid', 'public_id', '_key', 'company_uuid', 'subject_uuid', 'subject_type', 'coordinates'], + 'custom_field_values' => ['uuid', 'company_uuid', 'custom_field_uuid', 'subject_uuid', 'subject_type', 'value', 'value_type'], + 'custom_fields' => ['uuid', 'company_uuid', 'category_uuid', 'subject_uuid', 'subject_type', 'name', 'label', 'type', 'for', 'component', 'options', 'required', 'editable', 'default_value', 'validation_rules', 'meta', 'description', 'help_text', 'order'], + 'categories' => ['uuid', 'public_id', '_key', 'company_uuid', 'owner_uuid', 'owner_type', 'parent_uuid', 'icon_file_uuid', 'internal_id', 'name', 'description', 'translations', 'meta', 'tags', 'icon', 'icon_color', 'slug', 'order', 'for', 'core_category'], + 'activity_log' => ['uuid', 'company_uuid', 'log_name', 'description', 'subject_type', 'subject_id', 'causer_type', 'causer_id', 'properties', 'event', 'batch_uuid'], + ]; + + foreach ($tables as $table => $columns) { + $schema->create($table, function ($blueprint) use ($columns) { + $blueprint->increments('id'); + foreach ($columns as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + $connection->table('companies')->insert(['uuid' => 'company-insp', 'public_id' => 'company_insp', 'name' => 'Inspection Co']); + $connection->table('users')->insert(['uuid' => 'user-driver', 'public_id' => 'user_driver', 'company_uuid' => 'company-insp', 'name' => 'Dana Driver', 'email' => 'dana@example.com', 'type' => 'user']); + $connection->table('vehicles')->insert(['uuid' => 'vehicle-1', 'public_id' => 'vehicle_one', 'company_uuid' => 'company-insp', 'name' => 'Truck 7', 'plate_number' => 'TRK-7']); + $connection->table('drivers')->insert(['uuid' => 'driver-1', 'public_id' => 'driver_one', 'company_uuid' => 'company-insp', 'user_uuid' => 'user-driver', 'vehicle_uuid' => 'vehicle-1', 'status' => 'active']); + + session(['company' => 'company-insp', 'user' => 'user-driver']); + + return $connection; +} + +/** The draft the console builder posts: two groups of typed fields. */ +function fleetOpsInspectionFieldDraft(): array +{ + return [ + [ + 'name' => 'Exterior', + 'order' => 1, + 'meta' => ['grid_size' => 2], + 'fields' => [ + ['label' => 'Mirrors', 'name' => 'mirrors', 'type' => 'pass-fail', 'required' => true, 'order' => 1, 'meta' => ['severity' => 'medium', 'require_comment_on_fail' => true, 'unsafe_on_fail' => false]], + ['label' => 'Brakes', 'name' => 'brakes', 'type' => 'pass-fail', 'required' => true, 'order' => 2, 'meta' => ['severity' => 'critical', 'require_photo_on_fail' => true, 'require_comment_on_fail' => true, 'unsafe_on_fail' => true]], + ], + ], + [ + 'name' => 'Meter and sign-off', + 'order' => 2, + 'meta' => ['grid_size' => 1], + // `customFields` is the fliit builder's spelling; both are read. + 'customFields' => [ + ['label' => 'Odometer', 'type' => 'number', 'order' => 1, 'meta' => ['unit' => 'km', 'role' => 'odometer']], + ['label' => 'Notes', 'type' => 'textarea', 'order' => 2], + ['label' => 'Sign here', 'name' => 'signature', 'type' => 'signature', 'order' => 3], + ['label' => 'Tail lift photo', 'name' => 'tail_lift', 'type' => 'file-upload', 'order' => 4], + ['label' => 'Trailer attached', 'name' => 'trailer', 'type' => 'boolean', 'order' => 5], + ['label' => 'Fuel level', 'name' => 'fuel', 'type' => 'radio-button', 'options' => ['full', 'half'], 'order' => 6], + ], + ], + ]; +} + +function fleetOpsInspectionFieldForm(array $attributes = []): InspectionForm +{ + return InspectionForm::create(array_merge([ + 'company_uuid' => 'company-insp', + 'name' => 'Pre-trip DVIR', + 'type' => 'dvir', + 'status' => 'published', + 'published_at' => '2026-09-01 08:00:00', + 'settings' => ['create_issue_on_failure' => false, 'create_work_order_on_failure' => false], + ], $attributes)); +} + +function fleetOpsInspectionFieldSubmission(InspectionForm $form, array $attributes = []): InspectionSubmission +{ + return InspectionSubmission::create(array_merge([ + 'company_uuid' => 'company-insp', + 'inspection_form_uuid' => $form->uuid, + 'vehicle_uuid' => 'vehicle-1', + 'driver_uuid' => 'driver-1', + 'submitted_by_uuid' => 'user-driver', + 'type' => 'dvir', + 'status' => 'draft', + 'source' => 'navigator', + ], $attributes)); +} + +/** A one-pixel PNG, as the app sends one. */ +function fleetOpsInspectionFieldPhoto(): string +{ + return base64_encode(base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', true)); +} + +afterEach(function () { + Carbon::setTestNow(); +}); + +test('the form writer builds groups of typed fields and prunes what a later draft drops', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + + $written = InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + + expect($written['groups'])->toHaveCount(2) + ->and($written['fields'])->toHaveCount(8); + + $groups = $form->fieldGroups; + expect($groups->pluck('name')->all())->toBe(['Exterior', 'Meter and sign-off']) + ->and($groups->first()->for)->toBe(InspectionForm::GROUP_FOR) + ->and($groups->first()->owner_uuid)->toBe($form->uuid) + ->and($groups->first()->meta)->toBe(['grid_size' => 2]) + ->and($groups->first()->public_id)->toStartWith('category_') + ->and($groups->first()->slug)->toBe('exterior'); + + $fields = $form->fields->keyBy('name'); + expect($fields->keys()->sort()->values()->all())->toBe(['brakes', 'fuel', 'mirrors', 'notes', 'odometer', 'signature', 'tail-lift', 'trailer']) + ->and($fields['brakes']->for)->toBe(InspectionForm::FIELD_FOR) + ->and($fields['brakes']->subject_uuid)->toBe($form->uuid) + ->and($fields['brakes']->category_uuid)->toBe($groups->first()->uuid) + ->and($fields['brakes']->required)->toBeTrue() + ->and($fields['brakes']->editable)->toBeTrue() + ->and($fields['brakes']->component)->toBe('pass-fail') + // A field named only by its label takes a slug of the label. + ->and($fields['odometer']->label)->toBe('Odometer') + ->and($fields['odometer']->meta)->toBe(['unit' => 'km', 'role' => 'odometer']) + // `radio-button` is the type; the platform's component is named differently. + ->and($fields['fuel']->component)->toBe('radio-button-select') + ->and($fields['fuel']->options)->toBe(['full', 'half']); + + // A second post with the same uuids updates rather than duplicates, and + // what it no longer lists is deleted. + $draft = fleetOpsInspectionFieldDraft(); + $draft[0]['uuid'] = $groups->first()->uuid; + $draft[0]['name'] = 'Exterior walk-around'; + $draft[0]['fields'][0]['uuid'] = $fields['mirrors']->uuid; + $draft[0]['fields'][0]['label'] = 'Mirrors and glass'; + unset($draft[1]); + + InspectionFormSync::sync($form, $draft, true); + + $form->unsetRelation('fieldGroups'); + $form->unsetRelation('fields'); + expect($form->fieldGroups)->toHaveCount(1) + ->and($form->fieldGroups->first()->name)->toBe('Exterior walk-around') + ->and($form->fields)->toHaveCount(2) + ->and($form->fields->firstWhere('uuid', $fields['mirrors']->uuid)->label)->toBe('Mirrors and glass') + ->and(CustomField::query()->count())->toBe(2) + ->and(Category::query()->count())->toBe(1); +}); + +test('the form writer gives a nameless, typeless field somewhere to go', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + + InspectionFormSync::sync($form, [['fields' => [['type' => 'nonsense'], []]]]); + + $group = $form->fieldGroups->first(); + expect($group->name)->toBeNull() + ->and($group->order)->toBe('1') + ->and($form->fields->pluck('label')->all())->toBe(['Untitled field', 'Untitled field']) + ->and($form->fields->pluck('type')->all())->toBe(['input', 'input']) + ->and(InspectionFormSync::componentFor('signature'))->toBe('signature'); +}); + +test('a first-cut checklist becomes a Checklist group of pass-fail fields, once', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(['items' => [ + ['key' => 'brakes', 'label' => 'Brakes', 'category' => 'Safety', 'severity' => 'critical'], + ['title' => 'Lights', 'severity' => 'medium', 'required' => false, 'description' => 'All round'], + [], + ]]); + + expect(InspectionFormSync::convertLegacyItems($form))->toBe(3); + + $group = $form->fresh()->fieldGroups->first(); + expect($group->name)->toBe('Checklist') + ->and($group->meta)->toBe(['grid_size' => 1, 'converted_from_items' => true]); + + $fields = $form->fields()->get()->keyBy('name'); + expect($fields['brakes']->type)->toBe('pass-fail') + ->and($fields['brakes']->required)->toBeTrue() + ->and($fields['brakes']->meta['severity'])->toBe('critical') + ->and($fields['brakes']->meta['category'])->toBe('Safety') + ->and($fields['brakes']->meta['unsafe_on_fail'])->toBeTrue() + ->and($fields['lights']->required)->toBeFalse() + ->and($fields['lights']->description)->toBe('All round') + ->and($fields['lights']->meta['unsafe_on_fail'])->toBeFalse() + ->and($fields['item-3']->label)->toBe('Item 3'); + + // Idempotent: a form already built with fields is left alone, and so is a + // form with nothing to convert. + expect(InspectionFormSync::convertLegacyItems($form->fresh()))->toBe(0) + ->and(InspectionFormSync::convertLegacyItems(fleetOpsInspectionFieldForm()))->toBe(0) + ->and(CustomField::query()->count())->toBe(3); +}); + +test('a form answers with its groups in order, ungrouped fields last', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + + // A group and a field with no order of their own sort after the ones that + // have one, oldest first; a field belonging to no group is gathered up. + Category::query()->where('name', 'Exterior')->update(['order' => null]); + CustomField::query()->where('name', 'brakes')->update(['category_uuid' => null, 'order' => null]); + + $form->unsetRelation('fieldGroups'); + $form->unsetRelation('fields'); + $groups = $form->grouped_fields; + + expect(collect($groups)->pluck('name')->all())->toBe(['Meter and sign-off', 'Exterior', 'Ungrouped']) + ->and($groups[2]->exists)->toBeFalse() + ->and($groups[2]->getRelation('fields')->pluck('name')->all())->toBe(['brakes']) + ->and($groups[1]->getRelation('fields')->pluck('name')->all())->toBe(['mirrors']) + ->and($form->item_count)->toBe(8); + + // The count falls back to the legacy checklist only while there are no fields. + $legacy = fleetOpsInspectionFieldForm(['items' => [['key' => 'a', 'label' => 'A']]]); + expect($legacy->item_count)->toBe(1); +}); + +test('the form resource answers grouped_fields for the driver and identifiers for the console', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + + $public = (new InspectionFormResource($form->fresh()))->toArray(fleetOpsInspectionFieldRequest(false)); + + expect($public['id'])->toBe($form->public_id) + ->and($public['grouped_fields'])->toHaveCount(2) + ->and($public['grouped_fields'][0]['name'])->toBe('Exterior') + ->and($public['grouped_fields'][0]['order'])->toBe(1) + ->and($public['grouped_fields'][0]['meta'])->toBe(['grid_size' => 2]) + ->and($public['grouped_fields'][0])->not->toHaveKey('company_uuid'); + + $field = $public['grouped_fields'][0]['fields'][1]; + expect($field['id'])->toBe($field['uuid']) + ->and($field['name'])->toBe('brakes') + ->and($field['label'])->toBe('Brakes') + ->and($field['type'])->toBe('pass-fail') + ->and($field['required'])->toBeTrue() + ->and($field['editable'])->toBeTrue() + ->and($field['options'])->toBe([]) + ->and($field['order'])->toBe(2) + ->and($field['meta']['severity'])->toBe('critical') + ->and($field)->not->toHaveKey('category_uuid'); + + // A field written straight into the table carries no component; the type names one. + CustomField::query()->where('name', 'brakes')->update(['component' => null, 'meta' => null, 'order' => null, 'editable' => null]); + $bare = (new InspectionFormResource($form->fresh()))->toArray(fleetOpsInspectionFieldRequest(false))['grouped_fields'][0]['fields']; + $bare = collect($bare)->firstWhere('name', 'brakes'); + expect($bare['component'])->toBe('pass-fail') + ->and($bare['meta'])->toEqual((object) []) + ->and($bare['order'])->toBeNull() + ->and($bare['editable'])->toBeTrue(); + + $request = fleetOpsInspectionFieldRequest(true); + $internal = (new InspectionFormResource($form->fresh()->load(['fieldGroups', 'fields'])))->toArray($request); + expect($internal['grouped_fields'][0]['company_uuid'])->toBe('company-insp') + ->and($internal['grouped_fields'][0]['for'])->toBe(InspectionForm::GROUP_FOR) + ->and($internal['field_groups'])->toHaveCount(2) + ->and($internal['field_groups'][0])->not->toHaveKey('fields') + ->and($internal['fields'])->toHaveCount(8) + ->and($internal['fields'][0]['subject_uuid'])->toBe($form->uuid) + ->and($internal['fields'][0]['for'])->toBe(InspectionForm::FIELD_FOR); + + // A group with no fields loaded answers with none rather than reaching for them. + $orphan = Category::query()->where('name', 'Exterior')->first(); + expect(InspectionFormResource::groupToArray($orphan, false)['fields'])->toBe([]); +}); + +test('the file store turns what a driver sends into a platform file', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + $submission = fleetOpsInspectionFieldSubmission($form); + + $stored = InspectionFileStore::normalize(fleetOpsInspectionFieldPhoto(), $submission, InspectionFileStore::TYPE_PHOTO, 'user-driver'); + expect($stored)->toStartWith('file:'); + + $file = InspectionFileStore::resolve($stored); + expect($file)->toBeInstanceOf(File::class) + ->and($file->subject_uuid)->toBe($submission->uuid) + ->and($file->type)->toBe('inspection_photo') + ->and($file->company_uuid)->toBe('company-insp') + ->and($file->uploader_uuid)->toBe('user-driver') + ->and($file->content_type)->toBe('image/png') + ->and($file->path)->toStartWith('inspections/' . $submission->uuid . '/'); + + // Anything already a reference, a URL or not a string at all is left alone. + expect(InspectionFileStore::normalize($stored, $submission))->toBe($stored) + ->and(InspectionFileStore::normalize('https://cdn.example.com/a.jpg', $submission))->toBe('https://cdn.example.com/a.jpg') + ->and(InspectionFileStore::normalize(' ', $submission))->toBe(' ') + ->and(InspectionFileStore::normalize(42, $submission))->toBe(42) + ->and(InspectionFileStore::normalize('not base64!', $submission))->toBe('not base64!') + ->and(InspectionFileStore::normalize($file->uuid, $submission))->toBe('file:' . $file->uuid) + ->and(InspectionFileStore::normalize($file->public_id, $submission))->toBe('file:' . $file->uuid) + ->and(InspectionFileStore::normalize('file_missing', $submission))->toBe('file_missing'); + + // A data URI carries its own content type; bare base64 is sniffed. + $jpeg = InspectionFileStore::resolve(InspectionFileStore::normalize('data:image/jpeg;base64,' . fleetOpsInspectionFieldPhoto(), $submission, InspectionFileStore::TYPE_SIGNATURE)); + expect($jpeg->content_type)->toBe('image/jpeg') + ->and($jpeg->type)->toBe('inspection_signature') + ->and($jpeg->path)->toEndWith('.jpg') + ->and($jpeg->uploader_uuid)->toBe('user-driver'); + + expect(InspectionFileStore::sniffContentType(base64_encode("\xFF\xD8\xFF" . str_repeat('a', 20))))->toBe('image/jpeg') + ->and(InspectionFileStore::sniffContentType(base64_encode('GIF89a' . str_repeat('a', 20))))->toBe('image/gif') + ->and(InspectionFileStore::sniffContentType(base64_encode('RIFF____WEBP' . str_repeat('a', 20))))->toBe('image/webp') + ->and(InspectionFileStore::sniffContentType(base64_encode('%PDF-1.4' . str_repeat('a', 20))))->toBe('application/pdf') + ->and(InspectionFileStore::sniffContentType(base64_encode(str_repeat('a', 24))))->toBe('image/png') + ->and(InspectionFileStore::extensionFor('image/gif'))->toBe('gif') + ->and(InspectionFileStore::extensionFor('image/webp'))->toBe('webp') + ->and(InspectionFileStore::extensionFor('application/pdf'))->toBe('pdf') + ->and(InspectionFileStore::extensionFor('image/svg+xml'))->toBe('svg') + ->and(InspectionFileStore::isBase64('data:image/png;base64,***'))->toBeFalse() + ->and(InspectionFileStore::isUrl('ftp://x'))->toBeFalse(); + + // A file uploaded before the submission existed is claimed by it. + $loose = File::create(['company_uuid' => 'company-insp', 'disk' => 'uploads', 'path' => 'loose.png', 'type' => 'inspection_photo']); + expect(InspectionFileStore::attachReferenced($submission, ['file:' . $loose->uuid, 'nonsense']))->toBe(0) + ->and(InspectionFileStore::attachReferenced($submission, [$loose->uuid]))->toBe(1) + ->and(InspectionFileStore::attachReferenced($submission, []))->toBe(0) + ->and($loose->fresh()->subject_uuid)->toBe($submission->uuid); + + expect(InspectionFileStore::referencedUuid('file:not-a-uuid'))->toBeNull() + ->and(InspectionFileStore::referencedUuid(null))->toBeNull() + ->and(InspectionFileStore::resolve('file:' . (string) Illuminate\Support\Str::uuid()))->toBeNull() + ->and(InspectionFileStore::project('https://cdn.example.com/a.jpg'))->toBe('https://cdn.example.com/a.jpg'); + + $projected = InspectionFileStore::project($stored); + expect($projected['id'])->toBe($file->public_id) + ->and($projected['content_type'])->toBe('image/png') + ->and($projected)->toHaveKeys(['id', 'url', 'filename', 'content_type']); +}); + +test('a submission answers a form of fields, and the item results follow', function () { + fleetOpsInspectionFieldDatabase(); + Carbon::setTestNow('2026-09-10 07:00:00'); + $form = fleetOpsInspectionFieldForm(); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + $fields = $form->fields->keyBy('name'); + $submission = fleetOpsInspectionFieldSubmission($form); + + InspectionSubmitter::applyCustomFieldValues($submission, [ + ['custom_field' => $fields['mirrors']->uuid, 'value_type' => 'object', 'value' => ['passed' => true, 'not_applicable' => false, 'severity' => null, 'comments' => null, 'photos' => [], 'unsafe' => false]], + ['custom_field' => $fields['brakes']->uuid, 'value_type' => 'object', 'value' => ['passed' => false, 'severity' => 'Critical', 'comments' => 'Soft pedal', 'photos' => [fleetOpsInspectionFieldPhoto()], 'unsafe' => true]], + // Named by its slug rather than its uuid, as an older build may. + ['custom_field' => 'odometer', 'value_type' => 'number', 'value' => '112480'], + ['custom_field' => $fields['notes']->uuid, 'value_type' => 'text', 'value' => 'Nearside mirror scuffed'], + ['custom_field' => $fields['signature']->uuid, 'value_type' => 'file', 'value' => fleetOpsInspectionFieldPhoto()], + ['custom_field' => $fields['trailer']->uuid, 'value_type' => 'boolean', 'value' => 'true'], + ['custom_field' => $fields['fuel']->uuid, 'value' => ['full']], + ], 'user-driver'); + + $submission = $submission->fresh(['itemResults', 'customFieldValues.customField', 'files']); + + expect($submission->customFieldValues)->toHaveCount(7) + ->and($submission->total_items)->toBe(2) + ->and($submission->failed_items)->toBe(1) + ->and($submission->result)->toBe('failed') + ->and($submission->status)->toBe('submitted') + ->and($submission->meta['unsafe'])->toBeTrue() + ->and($submission->files)->toHaveCount(2); + + $results = $submission->itemResults->keyBy('item_key'); + expect($results->keys()->sort()->values()->all())->toBe(['brakes', 'mirrors']) + ->and($results['brakes']->label)->toBe('Brakes') + ->and($results['brakes']->category)->toBe('Exterior') + ->and($results['brakes']->status)->toBe('failed') + ->and($results['brakes']->severity)->toBe('critical') + ->and($results['brakes']->passed)->toBeFalse() + ->and($results['brakes']->comments)->toBe('Soft pedal') + ->and($results['brakes']->photos[0])->toStartWith('file:') + ->and($results['brakes']->meta['unsafe'])->toBeTrue() + ->and($results['brakes']->meta['custom_field_uuid'])->toBe($fields['brakes']->uuid) + ->and($results['mirrors']->status)->toBe('passed') + ->and($results['mirrors']->severity)->toBeNull(); + + // The value column is a string, so a meter reading is stored as one; the + // resource is where it becomes a number again. + $odometer = $submission->customFieldValues->first(fn ($value) => $value->custom_field_uuid === $fields['odometer']->uuid); + expect($odometer->value)->toBe('112480') + ->and($odometer->value_type)->toBe('number'); + + // Answering again with the brakes passing and the mirrors not applicable + // rewrites the results rather than adding to them. + InspectionSubmitter::applyCustomFieldValues($submission, [ + ['custom_field' => $fields['mirrors']->uuid, 'value' => ['passed' => true, 'not_applicable' => true]], + ['custom_field' => $fields['brakes']->uuid, 'value' => ['passed' => true]], + ]); + + $submission = $submission->fresh(['itemResults']); + $results = $submission->itemResults->keyBy('item_key'); + expect($submission->itemResults)->toHaveCount(2) + ->and($results['mirrors']->status)->toBe('not_applicable') + ->and($results['mirrors']->passed)->toBeTrue() + ->and($results['brakes']->status)->toBe('passed') + ->and($results['brakes']->severity)->toBeNull() + ->and($submission->meta['unsafe'])->toBeFalse() + ->and($submission->result)->toBe('passed'); + + // An answer that is taken away — the console clearing a field — takes its + // result row with it; a row written any other way is left alone. + $submission->customFieldValues()->where('custom_field_uuid', $fields['mirrors']->uuid)->delete(); + $submission->itemResults()->create(['company_uuid' => 'company-insp', 'item_key' => 'hand-written', 'label' => 'Hand written', 'passed' => true]); + expect($submission->fresh()->syncItemResultsFromCustomFieldValues())->toBe(1) + ->and($submission->fresh()->itemResults()->pluck('item_key')->sort()->values()->all())->toBe(['brakes', 'hand-written']); +}); + +test('a submission refuses an answer the form will not accept', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + $fields = $form->fields->keyBy('name'); + $submission = fleetOpsInspectionFieldSubmission($form); + + $refusal = null; + try { + InspectionSubmitter::applyCustomFieldValues($submission, [ + ['custom_field' => 'not-a-field-of-this-form', 'value' => 1], + ['custom_field' => $fields['brakes']->uuid, 'value' => ['passed' => false]], + ['value' => 'no field at all'], + ]); + } catch (ValidationException $exception) { + $refusal = $exception->errors(); + } + + expect($refusal['custom_field_values.0.custom_field'][0])->toContain('not-a-field-of-this-form') + ->and($refusal['custom_field_values.1.value'])->toBe([ + 'A comment is required when "Brakes" fails.', + 'A photo is required when "Brakes" fails.', + ]) + ->and($refusal['custom_field_values.2.custom_field'][0])->toContain('"?"') + // Nothing was written, and no photo was stored on the way to refusing. + ->and($submission->customFieldValues()->count())->toBe(0) + ->and(File::query()->count())->toBe(0); +}); + +test('a pass-fail answer is read whatever shape it arrives in', function () { + expect(InspectionSubmitter::passFailAnswer(['passed' => false, 'severity' => 'High', 'comments' => 'x', 'photos' => ['a'], 'unsafe' => true])) + ->toBe(['passed' => false, 'not_applicable' => false, 'severity' => 'high', 'comments' => 'x', 'photos' => ['a'], 'unsafe' => true]) + ->and(InspectionSubmitter::passFailAnswer('{"pass":false}')['passed'])->toBeFalse() + ->and(InspectionSubmitter::passFailAnswer('{not json')['passed'])->toBeTrue() + ->and(InspectionSubmitter::passFailAnswer('fail')['passed'])->toBeFalse() + ->and(InspectionSubmitter::passFailAnswer('pass')['passed'])->toBeTrue() + ->and(InspectionSubmitter::passFailAnswer(false)['passed'])->toBeFalse() + ->and(InspectionSubmitter::passFailAnswer(['passed' => null])['not_applicable'])->toBeTrue() + ->and(InspectionSubmitter::passFailAnswer(['na' => true])['not_applicable'])->toBeTrue() + ->and(InspectionSubmitter::passFailAnswer(['severity' => ''])['severity'])->toBeNull(); +}); + +test('the submission resource answers the answers, with the files resolved', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + $fields = $form->fields->keyBy('name'); + $submission = fleetOpsInspectionFieldSubmission($form); + + InspectionSubmitter::applyCustomFieldValues($submission, [ + ['custom_field' => $fields['brakes']->uuid, 'value' => ['passed' => false, 'severity' => 'critical', 'comments' => 'Soft pedal', 'photos' => [fleetOpsInspectionFieldPhoto()]]], + ['custom_field' => $fields['signature']->uuid, 'value_type' => 'file', 'value' => fleetOpsInspectionFieldPhoto()], + ['custom_field' => $fields['notes']->uuid, 'value' => 'Nothing else to report'], + ['custom_field' => $fields['odometer']->uuid, 'value_type' => 'number', 'value' => '112480'], + ['custom_field' => $fields['trailer']->uuid, 'value' => 'true'], + ], 'user-driver'); + + $loaded = $submission->fresh(['itemResults', 'customFieldValues.customField', 'files']); + $public = (new InspectionSubmissionResource($loaded))->toArray(fleetOpsInspectionFieldRequest(false)); + + $values = collect($public['custom_field_values'])->keyBy('name'); + expect($public['custom_field_values'])->toHaveCount(5) + ->and($values['brakes']['custom_field'])->toBe($fields['brakes']->uuid) + ->and($values['brakes']['label'])->toBe('Brakes') + ->and($values['brakes']['type'])->toBe('pass-fail') + ->and($values['brakes']['value']['passed'])->toBeFalse() + ->and($values['brakes']['value']['comments'])->toBe('Soft pedal') + ->and($values['brakes']['value']['photos'][0])->toHaveKeys(['id', 'url', 'filename', 'content_type']) + ->and($values['signature']['value'])->toHaveKeys(['id', 'url', 'filename', 'content_type']) + ->and($values['notes']['value'])->toBe('Nothing else to report') + // A value column is a string; the resource hands back the number and + // the boolean the app wrote. + ->and($values['odometer']['value'])->toBe(112480) + ->and($values['trailer']['value'])->toBeTrue() + ->and($values['brakes'])->not->toHaveKey('uuid'); + + expect($public['files'])->toHaveCount(2) + ->and($public['files'][0])->toHaveKeys(['id', 'uuid', 'url', 'original_filename', 'content_type', 'type']) + ->and($public['item_results'])->toHaveCount(1); + + $internal = (new InspectionSubmissionResource($loaded))->toArray(fleetOpsInspectionFieldRequest(true)); + $brakes = collect($internal['custom_field_values'])->firstWhere('name', 'brakes'); + expect($brakes['uuid'])->not->toBeNull() + ->and($brakes['category_uuid'])->toBe($fields['brakes']->category_uuid) + ->and($brakes['order'])->toBe(2) + ->and($brakes['meta']['severity'])->toBe('critical'); + + // A submission whose answers were never loaded says nothing about them. + $bare = (new InspectionSubmissionResource(new InspectionSubmission()))->toArray(fleetOpsInspectionFieldRequest(false)); + expect($bare['custom_field_values'])->toBe([]) + ->and($bare['files'])->toBe([]); +}); + +test('the submission resource survives a value whose field is gone or whose object is not one', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + $fields = $form->fields->keyBy('name'); + $submission = fleetOpsInspectionFieldSubmission($form); + + $submission->syncCustomFieldValues([ + ['custom_field_uuid' => $fields['notes']->uuid, 'value' => 'not json', 'value_type' => 'object'], + ['custom_field_uuid' => (string) Illuminate\Support\Str::uuid(), 'value' => 'orphan', 'value_type' => 'text'], + ]); + + $loaded = $submission->fresh(['customFieldValues.customField']); + $payload = (new InspectionSubmissionResource($loaded))->toArray(fleetOpsInspectionFieldRequest(true)); + $rows = collect($payload['custom_field_values']); + + expect($rows)->toHaveCount(2) + ->and($rows->firstWhere('name', 'notes')['value'])->toBe('not json') + ->and($rows->last()['label'])->toBeNull() + ->and($rows->last()['type'])->toBe('text') + ->and($rows->last()['value'])->toBe('orphan') + ->and($rows->last()['meta'])->toEqual((object) []); + + expect($submission->fresh()->referencedFileUuids())->toBe([]); +}); + +test('the inspection export names the defects and whether the truck was parked', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + $fields = $form->fields->keyBy('name'); + + $failed = fleetOpsInspectionFieldSubmission($form, ['odometer' => 112480, 'engine_hours' => 3100]); + InspectionSubmitter::applyCustomFieldValues($failed, [ + ['custom_field' => $fields['mirrors']->uuid, 'value' => ['passed' => true]], + ['custom_field' => $fields['brakes']->uuid, 'value' => ['passed' => false, 'comments' => 'Soft pedal', 'photos' => [fleetOpsInspectionFieldPhoto()]]], + ]); + $passed = fleetOpsInspectionFieldSubmission($form); + InspectionSubmitter::applyCustomFieldValues($passed, [ + ['custom_field' => $fields['mirrors']->uuid, 'value' => ['passed' => true]], + ]); + + $export = new InspectionExport(); + $rows = $export->collection(); + expect($rows)->toHaveCount(2) + ->and($export->headings())->toHaveCount(20) + ->and($export->columnFormats())->toHaveKeys(['Q', 'R', 'S', 'T']); + + $mapped = collect($rows)->map(fn ($row) => $export->map($row))->keyBy(0); + $row = $mapped[$failed->public_id]; + expect($row[1])->toBe('Pre-trip DVIR') + ->and($row[2])->toBe('Truck 7') + ->and($row[3])->toBe('Dana Driver') + ->and($row[6])->toBe('failed') + ->and($row[8])->toBe(112480) + ->and($row[9])->toBe(3100) + ->and($row[11])->toBe(1) + ->and($row[12])->toBe('Brakes') + ->and($row[13])->toBe('Yes') + ->and($mapped[$passed->public_id][12])->toBe('') + ->and($mapped[$passed->public_id][13])->toBe('No'); + + // A selection narrows the sheet to the rows the console ticked. + expect((new InspectionExport([$failed->uuid]))->collection())->toHaveCount(1); +}); + +test('the internal form controller writes the builder draft and reads the structure back', function () { + fleetOpsInspectionFieldDatabase(); + $controller = new InspectionFormController(); + $form = fleetOpsInspectionFieldForm(); + + // The builder posts the whole form under the record it is saving. + $controller->onAfterCreate(Request::create('/int/v1/inspection-forms', 'POST', ['inspection_form' => ['field_groups' => fleetOpsInspectionFieldDraft()]]), $form); + expect($form->fieldGroups)->toHaveCount(2) + ->and($form->fields)->toHaveCount(8); + + // A save that mentions no structure leaves the structure alone: publishing + // a form must not empty it. + $controller->onAfterUpdate(Request::create('/int/v1/inspection-forms/x', 'PUT', ['inspection_form' => ['status' => 'published']]), $form); + expect($form->fresh()->fields()->count())->toBe(8); + + // fliit's builder posts the same thing under `draft`, and a form authored + // there still saves; what it drops is pruned. + $controller->onAfterUpdate(Request::create('/int/v1/inspection-forms/x', 'PUT', ['inspection_form' => ['draft' => [['name' => 'Only group', 'fields' => [['label' => 'One', 'type' => 'pass-fail']]]]]]), $form); + expect($form->fresh()->fields()->count())->toBe(1) + ->and($form->fresh()->fieldGroups()->count())->toBe(1); + + $find = InspectionForm::query(); + $controller->onFindRecord($find, Request::create('/')); + $query = InspectionForm::query(); + $controller->onQueryRecord($query, Request::create('/')); + expect(array_keys($find->getEagerLoads()))->toContain('fieldGroups', 'fields') + ->and(array_keys($query->getEagerLoads()))->toContain('fieldGroups', 'fields') + ->and($find->first()->relationLoaded('fields'))->toBeTrue(); +}); + +test('the internal submission controller records answers, item results and an export', function () { + fleetOpsInspectionFieldDatabase(); + $controller = new FleetOpsInspectionSubmissionControllerProbe(); + $form = fleetOpsInspectionFieldForm(); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + $fields = $form->fields->keyBy('name'); + $submission = fleetOpsInspectionFieldSubmission($form); + + $controller->onAfterCreate(Request::create('/int/v1/inspection-submissions', 'POST', ['inspection_submission' => ['custom_field_values' => [ + ['custom_field' => $fields['mirrors']->uuid, 'value' => ['passed' => false, 'comments' => 'Cracked']], + ]]]), $submission, []); + + expect($submission->itemResults->pluck('item_key')->all())->toBe(['mirrors']) + ->and($submission->itemResults->first()->comments)->toBe('Cracked') + ->and($submission->relationLoaded('files'))->toBeTrue(); + + // The flat spelling is read too, and answering again rewrites the row. + $controller->onAfterUpdate(Request::create('/int/v1/inspection-submissions/x', 'PUT', ['custom_field_values' => [ + ['custom_field' => $fields['mirrors']->uuid, 'value' => ['passed' => true]], + ]]), $submission, []); + expect($submission->fresh()->itemResults->first()->passed)->toBeTrue(); + + // A submission against a legacy checklist still posts results directly. + $controller->onAfterUpdate(Request::create('/int/v1/inspection-submissions/x', 'PUT', ['inspection_submission' => ['item_results' => [ + ['item_key' => 'hand-written', 'label' => 'Hand written', 'passed' => false], + ]]]), $submission, []); + expect($submission->fresh()->itemResults->pluck('item_key')->all())->toBe(['hand-written']); + + $query = InspectionSubmission::query(); + $controller->onQueryRecord($query, Request::create('/')); + expect(array_keys($query->getEagerLoads()))->toContain('form', 'vehicle', 'driver', 'itemResults'); + + $response = $controller->export(FleetOpsInspectionExportRequestFake::create('/int/v1/inspection-submissions/export', 'POST', ['format' => 'csv', 'selections' => ['a', 'b']])); + expect($response['download'])->toMatch('/^inspections-[0-9-]+\.csv$/') + ->and($response['headings'])->toContain('Failed Items') + ->and($controller->downloads[0][0])->toBeInstanceOf(InspectionExport::class); + + // No format and no selection: the whole sheet, as a workbook. + expect($controller->export(FleetOpsInspectionExportRequestFake::create('/int/v1/inspection-submissions/export', 'POST'))['download'])->toEndWith('.xlsx'); +}); + +test('the submitter takes field answers straight from a submit body', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(['settings' => ['create_issue_on_failure' => true, 'create_work_order_on_failure' => true]]); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + $fields = $form->fields->keyBy('name'); + + $submission = InspectionSubmitter::submit($form, [ + 'odometer' => 112480, + 'custom_field_values' => [ + ['custom_field' => $fields['mirrors']->uuid, 'value' => ['passed' => false, 'comments' => 'Cracked', 'severity' => 'high']], + ], + // Sent alongside by the app; the field values win and these are ignored. + 'item_results' => [['item_key' => 'mirrors', 'label' => 'Mirrors', 'passed' => false]], + ], ['driver_uuid' => 'driver-1', 'vehicle_uuid' => 'vehicle-1', 'submitted_by_uuid' => 'user-driver']); + + expect($submission->itemResults()->count())->toBe(1) + ->and($submission->itemResults()->first()->severity)->toBe('high') + ->and($submission->fresh()->result)->toBe('failed') + ->and($submission->fresh()->issue_uuid)->not->toBeNull() + ->and($submission->fresh()->work_order_uuid)->not->toBeNull(); +}); + +test('a photo that cannot be stored is left as it arrived', function () { + fleetOpsInspectionFieldDatabase(); + $form = fleetOpsInspectionFieldForm(); + $submission = fleetOpsInspectionFieldSubmission($form); + + // A disk that cannot be written to: the store gives back what it was given + // rather than losing the answer along with the photo. + $readOnly = sys_get_temp_dir() . '/fleetops-inspection-readonly-' . bin2hex(random_bytes(6)); + $folder = $readOnly . '/inspections/' . $submission->uuid; + if (!is_dir($folder)) { + mkdir($folder, 0755, true); + } + chmod($folder, 0555); + app('config')->set('filesystems.disks.broken', ['driver' => 'local', 'root' => $readOnly, 'throw' => false]); + app('config')->set('filesystems.default', 'broken'); + + $photo = fleetOpsInspectionFieldPhoto(); + $left = InspectionFileStore::normalize($photo, $submission); + chmod($folder, 0755); + + expect($left)->toBe($photo) + ->and(File::query()->count())->toBe(0); +}); + +test('groups and fields sort the way the builder laid them out', function () { + $ordered = new Category(['name' => 'first']); + $unordered = new Category(['name' => 'later']); + $older = new Category(['name' => 'older']); + $ordered->order = 2; + $older->created_at = '2026-01-01 00:00:00'; + $unordered->created_at = '2026-06-01 00:00:00'; + + expect(InspectionForm::sortByOrder(collect([$unordered, $ordered, $older]))->pluck('name')->all()) + ->toBe(['first', 'older', 'later']) + ->and(InspectionForm::sortByOrder(collect([$ordered, $unordered]))->pluck('name')->all()) + ->toBe(['first', 'later']) + ->and(InspectionForm::sortByOrder(collect([$older, $unordered]))->pluck('name')->all()) + ->toBe(['older', 'later']); +}); From b5efd42c39f8cdd4542eb294062f8f2a0e63a22b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 13:06:52 +0800 Subject: [PATCH 09/44] Build the form, answer it, and show what came back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console side of the second cut. A form is built in `inspection-form/builder`: field groups with a grid size, fields added, edited, reordered and deleted through a FleetOps field editor that offers the inspection type list and, for `pass-fail`, the On fail section the driver app reads — default severity, whether a photo or a comment is required, whether the defect parks the truck, and the instructions the driver sees. A field is answered through `inspection-field/input`. `pass-fail`, `signature` and the inspection flavour of `file-upload` are FleetOps' own, as are `textarea`, `number` and `boolean` — the platform's type map has no component for those three. Everything else is handed to the platform's `custom-field/input`. The record gets Overview, Photos and Audit tabs; Photos is `ModelMultiFileUpload` against `inspection_photo`, Audit the platform's `ActivityLog`. The builder holds the structure as a draft of plain objects, so a form can be laid out before the record exists and the whole thing is posted with the save. It has to be posted separately: `inspection-form` and `inspection-submission` live in `@fleetbase/fleetops-data` and declare no attribute for the structure or the answers, so Ember Data drops both in both directions. Until that package gains them, the structure and the answers ride their own request to the internal endpoint, which is also the only spelling the server reads. Glimmer discipline throughout: nothing writes to `@resource` during render, text inputs update from the DOM event, and every control honours `cannot-write`. --- addon/components/inspection-field/form.hbs | 101 +++++++ addon/components/inspection-field/form.js | 172 +++++++++++ addon/components/inspection-field/input.hbs | 149 ++++++++++ addon/components/inspection-field/input.js | 270 ++++++++++++++++++ addon/components/inspection-field/value.hbs | 44 +++ addon/components/inspection-field/value.js | 102 +++++++ addon/components/inspection-form/builder.hbs | 82 ++++++ addon/components/inspection-form/builder.js | 169 +++++++++++ addon/components/inspection-form/details.hbs | 84 ++++-- addon/components/inspection-form/details.js | 42 +++ addon/components/inspection-form/form.hbs | 78 +++-- addon/components/inspection-form/form.js | 64 ++--- .../inspection-submission/details.hbs | 58 ++-- .../inspection-submission/details.js | 49 ++++ .../components/inspection-submission/form.hbs | 97 +++---- .../components/inspection-submission/form.js | 226 +++++++++------ .../inspection-submission/photos.hbs | 20 ++ .../inspection-submission/photos.js | 57 ++++ addon/components/modals/inspection-field.hbs | 5 + addon/components/modals/inspection-field.js | 20 ++ .../inspection-forms/index/edit.js | 13 +- .../maintenance/inspection-forms/index/new.js | 17 +- .../inspection-submissions/index/details.js | 9 + .../inspection-submissions/index/edit.js | 13 +- .../inspection-submissions/index/new.js | 17 +- addon/routes.js | 2 + .../index/details/audit.js | 8 + .../index/details/photos.js | 8 + addon/services/inspection-form-actions.js | 37 +++ .../services/inspection-submission-actions.js | 49 ++++ .../inspection-forms/index/details.hbs | 2 +- .../inspection-forms/index/edit.hbs | 4 +- .../inspection-forms/index/new.hbs | 4 +- .../inspection-submissions/index/details.hbs | 4 +- .../index/details/audit.hbs | 1 + .../index/details/photos.hbs | 1 + .../inspection-submissions/index/edit.hbs | 4 +- .../inspection-submissions/index/new.hbs | 4 +- addon/utils/inspection-field-types.js | 72 +++++ addon/utils/inspection-form-structure.js | 144 ++++++++++ app/components/inspection-field/form.js | 1 + app/components/inspection-field/input.js | 1 + app/components/inspection-field/value.js | 1 + app/components/inspection-form/builder.js | 1 + .../inspection-submission/photos.js | 1 + app/components/modals/inspection-field.js | 1 + .../index/details/audit.js | 1 + .../index/details/photos.js | 1 + .../index/details/audit.js | 1 + .../index/details/photos.js | 1 + app/utils/inspection-field-types.js | 2 + app/utils/inspection-form-structure.js | 1 + translations/en-us.yaml | 148 ++++++++++ 53 files changed, 2187 insertions(+), 276 deletions(-) create mode 100644 addon/components/inspection-field/form.hbs create mode 100644 addon/components/inspection-field/form.js create mode 100644 addon/components/inspection-field/input.hbs create mode 100644 addon/components/inspection-field/input.js create mode 100644 addon/components/inspection-field/value.hbs create mode 100644 addon/components/inspection-field/value.js create mode 100644 addon/components/inspection-form/builder.hbs create mode 100644 addon/components/inspection-form/builder.js create mode 100644 addon/components/inspection-form/details.js create mode 100644 addon/components/inspection-submission/details.js create mode 100644 addon/components/inspection-submission/photos.hbs create mode 100644 addon/components/inspection-submission/photos.js create mode 100644 addon/components/modals/inspection-field.hbs create mode 100644 addon/components/modals/inspection-field.js create mode 100644 addon/routes/maintenance/inspection-submissions/index/details/audit.js create mode 100644 addon/routes/maintenance/inspection-submissions/index/details/photos.js create mode 100644 addon/templates/maintenance/inspection-submissions/index/details/audit.hbs create mode 100644 addon/templates/maintenance/inspection-submissions/index/details/photos.hbs create mode 100644 addon/utils/inspection-field-types.js create mode 100644 addon/utils/inspection-form-structure.js create mode 100644 app/components/inspection-field/form.js create mode 100644 app/components/inspection-field/input.js create mode 100644 app/components/inspection-field/value.js create mode 100644 app/components/inspection-form/builder.js create mode 100644 app/components/inspection-submission/photos.js create mode 100644 app/components/modals/inspection-field.js create mode 100644 app/routes/maintenance/inspection-submissions/index/details/audit.js create mode 100644 app/routes/maintenance/inspection-submissions/index/details/photos.js create mode 100644 app/templates/maintenance/inspection-submissions/index/details/audit.js create mode 100644 app/templates/maintenance/inspection-submissions/index/details/photos.js create mode 100644 app/utils/inspection-field-types.js create mode 100644 app/utils/inspection-form-structure.js diff --git a/addon/components/inspection-field/form.hbs b/addon/components/inspection-field/form.hbs new file mode 100644 index 000000000..cb9e8d12d --- /dev/null +++ b/addon/components/inspection-field/form.hbs @@ -0,0 +1,101 @@ +
+ + + + + + + + + + + + + + + + + + + + +
+ + +
+ + {{#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..3c94a663f --- /dev/null +++ b/addon/components/inspection-field/form.js @@ -0,0 +1,172 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { dasherize } from '@ember/string'; +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]; + + get field() { + return this.args.field ?? {}; + } + + 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) { + if (typeof this.args.onChange === 'function') { + this.args.onChange({ ...this.field, ...attributes }); + } + } + + changeMeta(attributes) { + this.change({ meta: { ...this.meta, ...attributes } }); + } + + /** + * The label names the field; the machine name follows it until the author + * types one of their own, matching the platform's editor. + */ + @action setLabel(event) { + const label = event.target.value; + const derived = dasherize((this.field.label ?? '').trim().toLowerCase()); + const current = (this.field.name ?? '').trim(); + const follows = current === '' || current === derived; + + this.change({ + label, + name: follows ? dasherize(label.trim().toLowerCase()) : current, + }); + } + + @action setName(event) { + this.change({ name: dasherize(event.target.value.trim().toLowerCase()) }); + } + + @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..2ed795e06 --- /dev/null +++ b/addon/components/inspection-field/input.hbs @@ -0,0 +1,149 @@ +
+ {{#if (eq this.field.type "pass-fail")}} +
+
+
+
+ {{this.field.label}} + {{#if this.field.required}}*{{/if}} +
+ {{#if this.field.description}} +
{{this.field.description}}
+ {{/if}} + {{#if this.meta.instructions}} +
{{this.meta.instructions}}
+ {{/if}} +
+
+
+
+ + {{#if this.isFailed}} +
+ + + {{t (concat "inspection.severity." severity)}} + + + + + + + + +
+ +
+
+ {{t "inspection.answer.photos"}} + {{#if this.requiresPhoto}}*{{/if}} +
+
+ {{#each this.answerPhotos as |photo index|}} +
+ {{#if photo.url}} + {{or + {{else}} +
+ {{/if}} + {{#unless @disabled}} +
+ {{/each}} + {{#unless @disabled}} + + + {{t "inspection.answer.add-photo"}} + + + {{/unless}} + {{#if this.uploadProgress}} + {{round this.uploadProgress.progress}}% + {{/if}} +
+
+ {{/if}} +
+ {{else if (eq this.field.type "signature")}} + + {{#if this.file.url}} + {{this.field.label}} + {{else if this.file.reference}} +
{{this.file.reference}}
+ {{else}} +
{{t "inspection.answer.no-signature"}}
+ {{/if}} + {{#unless @disabled}} +
+ + + {{t "inspection.answer.upload-signature"}} + + + {{#if this.file.reference}} +
+ {{/unless}} +
+ {{else if (eq this.field.type "file-upload")}} + + {{#if this.file.url}} + {{or + {{else if this.file.reference}} +
{{this.file.reference}}
+ {{else}} +
{{t "inspection.answer.no-photo"}}
+ {{/if}} + {{#unless @disabled}} +
+ + + {{t "inspection.answer.upload-photo"}} + + + {{#if this.file.reference}} +
+ {{/unless}} +
+ {{else if (eq this.field.type "textarea")}} + + + + {{else if (eq this.field.type "number")}} + +
+ + {{#if this.meta.unit}} + {{this.meta.unit}} + {{/if}} +
+
+ {{else if (eq this.field.type "boolean")}} + + + + {{else}} + + {{/if}} +
diff --git a/addon/components/inspection-field/input.js b/addon/components/inspection-field/input.js new file mode 100644 index 000000000..8f7671477 --- /dev/null +++ b/addon/components/inspection-field/input.js @@ -0,0 +1,270 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { componentForFieldType, INSPECTION_SEVERITIES } from '../../utils/inspection-field-types'; + +const PASS_FAIL_DEFAULT = { passed: true, not_applicable: false, severity: null, comments: '', photos: [], unsafe: false }; + +/** + * One inspection field, being answered. + * + * `pass-fail`, `signature` and the inspection flavour of `file-upload` are + * FleetOps' own and are rendered here; `textarea`, `number` and `boolean` are + * rendered here too, because the platform's custom-field type map has no + * component for them. Everything else is handed to the platform's + * `custom-field/input`, which is given a read-only view of this field's value + * so an edit opens with what was answered before. + * + * 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; + @tracked uploadProgress = null; + + /** Freshly uploaded files, so a photo can be shown before it is saved. */ + @tracked previews = {}; + + severityOptions = INSPECTION_SEVERITIES; + + constructor() { + super(...arguments); + + const field = this.args.field ?? {}; + const id = field.uuid ?? field.id; + + // The platform's input reads both the field and its current value off + // objects it expects to be models. A plain field with an `id`, and a + // subject that answers `get('custom_field_values')`, is all it touches. + this.delegatedField = { ...field, id, component: componentForFieldType(field.type) }; + this.delegatedSubject = { + get: (key) => (key === 'custom_field_values' ? [{ custom_field_uuid: id, value: this.args.value ?? null }] : undefined), + }; + } + + get field() { + return this.args.field ?? {}; + } + + get meta() { + const meta = this.field.meta; + return meta && typeof meta === 'object' ? meta : {}; + } + + get colSpanClass() { + const colSpan = this.meta.colSpan; + return colSpan ? `col-span-${colSpan}` : ''; + } + + // ---------- 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 isPassed() { + const answer = this.answer; + return answer.passed === true && answer.not_applicable !== true; + } + + get isFailed() { + const answer = this.answer; + return answer.passed === false && answer.not_applicable !== true; + } + + get isNotApplicable() { + return this.answer.not_applicable === true; + } + + get severity() { + return this.answer.severity ?? this.meta.severity ?? 'medium'; + } + + /** 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.isFailed && this.meta.require_comment_on_fail === true; + } + + get requiresPhoto() { + return this.isFailed && 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 setDelegatedValue(value) { + this.emit(value); + } + + @action markPassed() { + this.emit({ ...this.answer, passed: true, not_applicable: false, severity: null, unsafe: false }); + } + + @action markFailed() { + 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), + }); + } + + @action markNotApplicable() { + this.emit({ ...this.answer, passed: true, not_applicable: true, severity: null, unsafe: false }); + } + + @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; + + return this.fetch.uploadFile.perform( + file, + { + path: `uploads/inspections/${this.field.uuid ?? 'field'}`, + type, + ...this.#subjectParams(), + }, + (uploaded) => { + this.uploadProgress = null; + this.previews = { ...this.previews, [`file:${uploaded.id}`]: { url: uploaded.url, filename: uploaded.original_filename ?? uploaded.filename } }; + onUploaded(uploaded); + }, + () => { + this.uploadProgress = null; + if (file.queue && typeof file.queue.remove === 'function') { + file.queue.remove(file); + } + } + ); + } + + #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..04b047e5b --- /dev/null +++ b/addon/components/inspection-field/value.hbs @@ -0,0 +1,44 @@ +
+
{{this.field.label}}
+
+ {{#if this.isPassFail}} +
+ {{t this.resultLabel}} + {{#if this.answer.severity}} + {{t (concat "inspection.severity." this.answer.severity)}} + {{/if}} + {{#if this.answer.unsafe}} + {{t "inspection.answer.unsafe"}} + {{/if}} +
+ {{#if this.answer.comments}} +
{{this.answer.comments}}
+ {{/if}} + {{#if this.photos.length}} +
+ {{#each this.photos as |photo|}} + {{#if photo.url}} + + {{or + + {{else}} +
{{photo.reference}}
+ {{/if}} + {{/each}} +
+ {{/if}} + {{else if this.isFile}} + {{#if this.file.url}} + + {{or + + {{else}} + {{n-a this.file.reference}} + {{/if}} + {{else if this.isBoolean}} + {{if this.booleanValue (t "common.yes") (t "common.no")}} + {{else}} + {{n-a @value}} + {{/if}} +
+
diff --git a/addon/components/inspection-field/value.js b/addon/components/inspection-field/value.js new file mode 100644 index 000000000..8a67bd1af --- /dev/null +++ b/addon/components/inspection-field/value.js @@ -0,0 +1,102 @@ +import Component from '@glimmer/component'; + +/** + * 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 { + get field() { + return this.args.field ?? {}; + } + + get meta() { + const meta = this.field.meta; + return meta && typeof meta === 'object' ? meta : {}; + } + + get colSpanClass() { + const colSpan = this.meta.colSpan; + return colSpan ? `col-span-${colSpan}` : ''; + } + + get isPassFail() { + return this.field.type === 'pass-fail'; + } + + get isFile() { + return this.field.type === 'file-upload' || this.field.type === 'signature'; + } + + get isBoolean() { + return this.field.type === 'boolean'; + } + + get answer() { + const value = this.args.value; + if (value && typeof value === 'object' && !Array.isArray(value)) { + return { ...value, photos: Array.isArray(value.photos) ? value.photos : [] }; + } + + if (typeof value === 'boolean') { + return { passed: value, not_applicable: false, photos: [] }; + } + + return { passed: null, not_applicable: false, photos: [] }; + } + + get resultLabel() { + const answer = this.answer; + if (answer.not_applicable === true) { + return 'inspection.answer.not-applicable'; + } + + if (answer.passed === false) { + return 'inspection.answer.fail'; + } + + if (answer.passed === true) { + return 'inspection.answer.pass'; + } + + return 'inspection.answer.unanswered'; + } + + get resultStatus() { + const answer = this.answer; + if (answer.not_applicable === true) { + return 'info'; + } + + return answer.passed === false ? 'danger' : 'success'; + } + + 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-form/builder.hbs b/addon/components/inspection-form/builder.hbs new file mode 100644 index 000000000..35e4fda9d --- /dev/null +++ b/addon/components/inspection-form/builder.hbs @@ -0,0 +1,82 @@ +
+
+
{{t "inspection.builder.help"}}
+
+ + {{#if this.load.isRunning}} +
+ +
+ {{else}} +
+ {{#each this.groups as |group groupIndex|}} + +
+ + + + + + +
+ +
+
+
+
+
+
+ +
+ {{#each group.fields as |field fieldIndex|}} +
+
+
{{or field.label (t "inspection.builder.untitled-field")}}
+
+ {{field.type}} +
+ {{#if field.required}} + * + {{/if}} +
+
+
+
+ {{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..3e793a510 --- /dev/null +++ b/addon/components/inspection-form/builder.js @@ -0,0 +1,169 @@ +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 { 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 builder holds the + * whole structure as a draft of plain objects and hands it up through + * `@onChange`; the controller posts it with the save that creates or updates + * the form, and `InspectionFormSync` writes it in one go. Plain objects rather + * than Ember Data records because the `inspection-form` model belongs to + * `@fleetbase/fleetops-data` and declares no structure attribute. + * + * 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 notifications; + @service intl; + + @tracked groups = []; + + gridSizeOptions = [1, 2, 3]; + + 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() { + if (this.isDraft) { + return; + } + + try { + this.groups = yield this.inspectionFormActions.loadStructure(this.args.resource); + } catch (error) { + this.notifications.serverError(error); + } + } + + /** The one place the draft is written, and the one place it is announced. */ + write(groups) { + this.groups = 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))); + } + + @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); + } + + @action editField(group, field, isNew = false) { + const state = { field }; + + this.modalsManager.show('modals/inspection-field', { + title: isNew ? this.intl.t('inspection.builder.new-field') : this.intl.t('inspection.builder.edit-field', { label: field.label }), + acceptButtonText: this.intl.t('inspection.builder.save-field'), + acceptButtonIcon: 'check', + acceptButtonIconPrefix: 'fas', + declineButtonIcon: 'times', + declineButtonIconPrefix: 'fas', + state, + disabled: this.args.disabled, + confirm: (modal) => { + this.applyField(group, state.field, isNew); + modal.done(); + }, + }); + } + + 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 index 6770da9ff..fb9ced19f 100644 --- a/addon/components/inspection-form/details.hbs +++ b/addon/components/inspection-form/details.hbs @@ -1,53 +1,93 @@
- +
-
Name
+
{{t "inspection.form.name"}}
{{n-a @resource.name}}
-
Status
+
{{t "inspection.form.status"}}
{{smart-humanize @resource.status}}
-
Type
+
{{t "inspection.form.type"}}
{{smart-humanize @resource.type}}
-
Frequency
+
{{t "inspection.form.frequency"}}
{{smart-humanize @resource.frequency}}
-
Items
-
{{@resource.item_count}}
+
{{t "inspection.form.fields"}}
+
{{this.fieldCount}}
-
Published
+
{{t "inspection.form.published"}}
{{n-a (format-date-fns @resource.published_at "dd MMM yyyy, HH:mm")}}
-
Description
+
{{t "inspection.form.description"}}
{{n-a @resource.description}}
- -
- {{#each @resource.items as |item|}} -
-
-
{{n-a item.label}}
-
{{n-a item.category}}
+ + {{#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)}} + {{t (concat "inspection.severity." field.meta.severity)}} + {{/if}} + {{field.type}} +
+
+ {{else}} +
{{t "inspection.builder.no-fields"}}
+ {{/each}} +
- {{smart-humanize item.severity}} -
- {{else}} -
No checklist items configured.
- {{/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..bc55b0748 --- /dev/null +++ b/addon/components/inspection-form/details.js @@ -0,0 +1,42 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { debug } from '@ember/debug'; +import { next } from '@ember/runloop'; +import { task } from 'ember-concurrency'; + +/** + * 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(() => this.load.perform()); + } + + get fieldCount() { + return this.groups.reduce((count, group) => count + (group.fields?.length ?? 0), 0); + } + + get legacyItems() { + const items = this.args.resource?.items; + return Array.isArray(items) ? items : []; + } + + @task *load() { + if (!this.args.resource?.id) { + return; + } + + try { + this.groups = yield this.inspectionFormActions.loadStructure(this.args.resource); + } catch (error) { + debug('Unable to load inspection form structure: ' + error.message); + } + } +} diff --git a/addon/components/inspection-form/form.hbs b/addon/components/inspection-form/form.hbs index 67ade516a..b0c4ef2c5 100644 --- a/addon/components/inspection-form/form.hbs +++ b/addon/components/inspection-form/form.hbs @@ -1,67 +1,53 @@
- +
- - + + - - + + {{smart-humanize type}} - - + + {{smart-humanize status}} - - + + {{smart-humanize frequency}} - -
- -
- {{#each this.items as |item index|}} -
-
- - - - - - - - - {{smart-humanize severity}} - - - - - -
-
- -
-
- {{else}} -
No checklist items yet. Add the things a driver must check.
- {{/each}} -
+ + - - + {{#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/form.js b/addon/components/inspection-form/form.js index ca287bbec..e3da137b9 100644 --- a/addon/components/inspection-form/form.js +++ b/addon/components/inspection-form/form.js @@ -4,69 +4,55 @@ import { action } from '@ember/object'; const TYPE_OPTIONS = ['dvir', 'safety', 'compliance', 'maintenance', 'pre_trip', 'post_trip']; const STATUS_OPTIONS = ['draft', 'published', 'archived']; const FREQUENCY_OPTIONS = ['daily', 'weekly', 'monthly', 'pre_trip', 'post_trip', 'ad_hoc']; -const SEVERITY_OPTIONS = ['low', 'medium', 'high', 'critical']; /** - * Checklist editor for an inspection form. + * The inspection form screen: what the form is, and what it is built from. * - * The model's `items` attribute is the single source of truth; the component - * holds no copy of it. Every change builds a new array and assigns it to the - * model from an action, never during render — the previous version wrote to - * `@resource.items` in the constructor, which Glimmer refuses ("attempted to - * update `items` ... already used in the same computation") because the - * template had already read the attribute in the same render pass. + * 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 { typeOptions = TYPE_OPTIONS; statusOptions = STATUS_OPTIONS; frequencyOptions = FREQUENCY_OPTIONS; - severityOptions = SEVERITY_OPTIONS; - get items() { + /** 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 : []; } - setItems(items) { - this.args.resource.items = items; + @action setName(event) { + this.args.resource.name = event.target.value; } - @action addItem() { - const items = this.items; - this.setItems([ - ...items, - { - key: `item_${items.length + 1}`, - label: '', - category: '', - required: true, - severity: 'medium', - }, - ]); + @action setDescription(event) { + this.args.resource.description = event.target.value; } - @action removeItem(index) { - this.setItems(this.items.filter((_, itemIndex) => itemIndex !== index)); + @action setType(type) { + this.args.resource.type = type; } - /** For selects, which hand over the chosen value. */ - @action updateItem(index, key, value) { - this.setItems(this.items.map((item, itemIndex) => (itemIndex === index ? { ...item, [key]: value } : item))); + @action setStatus(status) { + this.args.resource.status = status; } - /** For text inputs, which hand over the DOM event. */ - @action updateItemField(index, key, event) { - this.updateItem(index, key, event.target.value); + @action setFrequency(frequency) { + this.args.resource.frequency = frequency; } - @action toggleItemRequired(index, event) { - this.updateItem(index, 'required', event.target.checked); + @action setStructure(groups) { + if (typeof this.args.onStructureChange === 'function') { + this.args.onStructureChange(groups); + } } - @action setSetting(key, value) { - this.args.resource.settings = { - ...(this.args.resource.settings ?? {}), - [key]: value, - }; + @action setSettings(settings) { + this.args.resource.settings = settings; } } diff --git a/addon/components/inspection-submission/details.hbs b/addon/components/inspection-submission/details.hbs index a5ecd9304..432fac426 100644 --- a/addon/components/inspection-submission/details.hbs +++ b/addon/components/inspection-submission/details.hbs @@ -1,50 +1,70 @@
- +
-
Inspection
+
{{t "inspection.record.inspection"}}
{{n-a @resource.public_id}}
-
Result
+
{{t "inspection.record.result"}}
{{smart-humanize @resource.result}}
-
Form
+
{{t "inspection.record.form"}}
{{n-a (or @resource.form.name @resource.form_name)}}
-
Status
+
{{t "inspection.record.status"}}
{{smart-humanize @resource.status}}
-
Vehicle
+
{{t "inspection.record.vehicle"}}
{{n-a (or @resource.vehicle.displayName @resource.vehicle_name)}}
-
Driver
+
{{t "inspection.record.driver"}}
{{n-a (or @resource.driver.name @resource.driver_name)}}
-
Odometer
+
{{t "inspection.record.odometer"}}
{{n-a @resource.odometer}}
-
Engine Hours
+
{{t "inspection.record.engine-hours"}}
{{n-a @resource.engine_hours}}
-
Submitted
+
{{t "inspection.record.submitted"}}
{{n-a (format-date-fns @resource.submitted_at "dd MMM yyyy, HH:mm")}}
-
Resolved
+
{{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}} + {{#each this.groups as |group|}} + {{#if group.fields.length}} + +
+ {{#each group.fields as |field|}} + + {{/each}} +
+
+ {{/if}} + {{/each}} + {{/if}} + +
{{#each @resource.item_results as |item|}}
@@ -57,25 +77,27 @@ {{/if}}
- {{if item.passed "Passed" "Failed"}} - {{smart-humanize item.severity}} + {{if item.passed (t "inspection.answer.pass") (t "inspection.answer.fail")}} + {{#if item.severity}} + {{smart-humanize item.severity}} + {{/if}}
{{else}} -
No item results recorded.
+
{{t "inspection.record.no-item-results"}}
{{/each}}
- +
-
Linked Issue
+
{{t "inspection.record.linked-issue"}}
{{n-a (or @resource.issue.public_id @resource.issue_uuid)}}
-
Linked Work Order
+
{{t "inspection.record.linked-work-order"}}
{{n-a (or @resource.work_order.public_id @resource.work_order_uuid)}}
diff --git a/addon/components/inspection-submission/details.js b/addon/components/inspection-submission/details.js new file mode 100644 index 000000000..3b85c40ca --- /dev/null +++ b/addon/components/inspection-submission/details.js @@ -0,0 +1,49 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { debug } from '@ember/debug'; +import { next } from '@ember/runloop'; +import { task } from 'ember-concurrency'; + +/** + * The Overview tab of an inspection record. + * + * The answers are read from the submission's own payload — the resource + * projects them with each field's identity and every file reference resolved — + * and laid out against the form's groups, so the record reads the way the form + * was built. Nothing is written here. + */ +export default class InspectionSubmissionDetailsComponent extends Component { + @service inspectionFormActions; + @service inspectionSubmissionActions; + + @tracked groups = []; + @tracked values = {}; + + constructor() { + super(...arguments); + next(() => this.load.perform()); + } + + 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 index c480096a0..f745753dc 100644 --- a/addon/components/inspection-submission/form.hbs +++ b/addon/components/inspection-submission/form.hbs @@ -1,12 +1,12 @@
- +
- + - - + + {{smart-humanize status}} - + - + - - + + - - + +
- -
- {{#each this.itemResults as |item index|}} -
-
- - - - - - - - - {{smart-humanize result}} - - - - - {{smart-humanize severity}} - - - - - -
-
-
+ {{#if this.load.isRunning}} + +
+ +
+
+ {{else if this.hasStructure}} + {{#each this.groups as |group|}} + + {{#if group.description}} +
{{group.description}}
+ {{/if}} +
+ {{#each group.fields as |field|}} + + {{/each}}
- {{/each}} -
-
- {{this.failedCount}} - failed of - {{this.totalCount}} - items + + {{/each}} + + {{#if this.passFailCount}} + +
+ {{t "inspection.record.failed-of" failed=this.failedCount total=this.passFailCount}}
-
-
-
+ + {{/if}} + {{else if @resource.form}} + +
{{t "inspection.record.form-has-no-fields"}}
+
+ {{/if}} - + diff --git a/addon/components/inspection-submission/form.js b/addon/components/inspection-submission/form.js index 389c6f5dd..1aab63e42 100644 --- a/addon/components/inspection-submission/form.js +++ b/addon/components/inspection-submission/form.js @@ -1,68 +1,161 @@ 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 { valueTypeForFieldType } from '../../utils/inspection-field-types'; const STATUS_OPTIONS = ['draft', 'submitted', 'needs_review', 'resolved']; -const RESULT_OPTIONS = ['passed', 'failed']; -const SEVERITY_OPTIONS = ['low', 'medium', 'high', 'critical']; /** - * Checklist results editor for a console-authored inspection. + * An inspection being filled in. * - * `@resource.item_results` is the single source of truth. Counts and the - * result are derived for display and written to the model only when the - * results change from an action — never in the constructor, which is what - * tripped Glimmer's "already used in the same computation" assertion. + * The header names the form and what is being inspected; the rest is the + * selected form's field groups, each rendered as a panel of + * `inspection-field/input`. 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 and + * the app 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; - resultOptions = RESULT_OPTIONS; - severityOptions = SEVERITY_OPTIONS; - get itemResults() { - const results = this.args.resource?.item_results; - return Array.isArray(results) ? results : []; + constructor() { + super(...arguments); + next(() => this.load.perform(this.args.resource?.form)); } - get failedCount() { - return this.itemResults.filter((item) => item.passed === false).length; + get fields() { + return flattenFields(this.groups); + } + + get hasStructure() { + return this.fields.length > 0; } - get totalCount() { - return this.itemResults.length; + get failedCount() { + return this.fields.filter((field) => field.type === 'pass-fail' && this.values[field.uuid]?.passed === false && this.values[field.uuid]?.not_applicable !== true).length; } - setResults(results) { - const failed = results.filter((item) => item.passed === false).length; - this.args.resource.item_results = results; - this.args.resource.total_items = results.length; - this.args.resource.failed_items = failed; - this.args.resource.result = failed > 0 ? 'failed' : 'passed'; + get passFailCount() { + return this.fields.filter((field) => field.type === 'pass-fail').length; } - seedFromForm(form) { - const items = form?.items ?? []; - if (!items.length || this.itemResults.length) { + @task *load(form) { + this.groups = []; + + if (!form?.id) { return; } - this.setResults( - items.map((item, index) => ({ - item_key: item.key || `item_${index + 1}`, - label: item.label, - category: item.category, - severity: item.severity || 'medium', - status: 'passed', - passed: true, - comments: '', - photos: [], - })) - ); + 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 = this.seed(groups, stored); + this.announce(); + } catch (error) { + this.notifications.serverError(error); + } + } + + /** + * Every field starts with an answer, so a form saved untouched still files + * a complete set: a pass-fail field passes unless the inspector says + * otherwise, which is what the first cut did and what the app does. + */ + seed(groups, stored = {}) { + return flattenFields(groups).reduce((carry, field) => { + if (stored[field.uuid] !== undefined) { + carry[field.uuid] = stored[field.uuid]; + return carry; + } + + carry[field.uuid] = field.type === 'pass-fail' ? { passed: true, not_applicable: false, severity: null, comments: '', photos: [], unsafe: false } : null; + + return carry; + }, {}); + } + + /** The answers, as the server accepts them. */ + get rows() { + return this.fields.map((field) => ({ + custom_field: field.uuid, + value_type: valueTypeForFieldType(field.type), + value: this.serializeValue(field, this.values[field.uuid]), + })); + } + + /** + * A file value read back from the server arrives resolved to an object; on + * the way out it has to be a reference again, which is what the file's + * public id is — `InspectionFileStore::normalize()` resolves a `file_…` id + * back to `file:`. + */ + serializeValue(field, value) { + if (field.type === 'pass-fail') { + const answer = value && typeof value === 'object' ? value : { passed: true, not_applicable: false }; + + return { + ...answer, + photos: (Array.isArray(answer.photos) ? answer.photos : []).map((photo) => this.serializeFile(photo)).filter(Boolean), + }; + } + + if (field.type === 'file-upload' || field.type === 'signature') { + return this.serializeFile(value); + } + + return value; + } + + serializeFile(value) { + if (value && typeof value === 'object') { + return value.id ?? null; + } + + return typeof value === 'string' && value !== '' ? value : null; + } + + 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; + this.args.resource.inspection_form_uuid = form?.id ?? null; this.args.resource.type = form?.type || this.args.resource.type || 'dvir'; - this.seedFromForm(form); + + return this.load.perform(form); } @action assignVehicle(vehicle) { @@ -73,50 +166,17 @@ export default class InspectionSubmissionFormComponent extends Component { this.args.resource.driver = driver; } - @action addResult() { - const results = this.itemResults; - this.setResults([ - ...results, - { - item_key: `custom_${results.length + 1}`, - label: '', - category: '', - severity: 'medium', - status: 'passed', - passed: true, - comments: '', - photos: [], - }, - ]); - } - - @action removeResult(index) { - this.setResults(this.itemResults.filter((_, itemIndex) => itemIndex !== index)); - } - - /** For selects, which hand over the chosen value. */ - @action updateResult(index, key, value) { - this.setResults( - this.itemResults.map((item, itemIndex) => { - if (itemIndex !== index) { - return item; - } - - const next = { ...item, [key]: value }; - if (key === 'status') { - next.passed = value !== 'failed'; - } - if (key === 'passed') { - next.status = value ? 'passed' : 'failed'; - } - - return next; - }) - ); - } - - /** For text inputs, which hand over the DOM event. */ - @action updateResultField(index, key, event) { - this.updateResult(index, key, event.target.value); + @action setStatus(status) { + this.args.resource.status = status; + } + + @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..367b79133 --- /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/modals/inspection-field.hbs b/addon/components/modals/inspection-field.hbs new file mode 100644 index 000000000..4c12ca8af --- /dev/null +++ b/addon/components/modals/inspection-field.hbs @@ -0,0 +1,5 @@ + + + diff --git a/addon/components/modals/inspection-field.js b/addon/components/modals/inspection-field.js new file mode 100644 index 000000000..7ba5ac276 --- /dev/null +++ b/addon/components/modals/inspection-field.js @@ -0,0 +1,20 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; + +/** + * The builder's field editor, in a modal. + * + * The field is a plain object in the builder's draft; `options.state` is the + * handle both sides hold, so the builder's `confirm` callback reads back + * whatever the editor last produced without either side mutating the draft + * until the author accepts. + */ +export default class ModalsInspectionFieldComponent extends Component { + @tracked field = this.args.options.state.field; + + @action onChange(field) { + this.field = field; + this.args.options.state.field = field; + } +} diff --git a/addon/controllers/maintenance/inspection-forms/index/edit.js b/addon/controllers/maintenance/inspection-forms/index/edit.js index cf7c0d620..859e4a7a7 100644 --- a/addon/controllers/maintenance/inspection-forms/index/edit.js +++ b/addon/controllers/maintenance/inspection-forms/index/edit.js @@ -5,23 +5,34 @@ import { action } from '@ember/object'; import { task } from 'ember-concurrency'; export default class MaintenanceInspectionFormsIndexEditController extends Controller { + @service inspectionFormActions; @service hostRouter; @service notifications; + @service intl; @tracked overlay; + /** The builder's draft, set only once the author has changed something. */ + @tracked structure = null; + @task *save(inspectionForm) { try { yield inspectionForm.save(); + yield this.inspectionFormActions.saveStructure(inspectionForm, this.structure); + this.overlay?.close(); yield this.hostRouter.refresh(); yield this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index.details', inspectionForm); - this.notifications.success('Inspection form updated.'); + this.notifications.success(this.intl.t('inspection.form.updated')); } catch (err) { this.notifications.serverError(err); } } + @action setStructure(groups) { + this.structure = groups; + } + @action cancel() { return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index.details', this.model); } diff --git a/addon/controllers/maintenance/inspection-forms/index/new.js b/addon/controllers/maintenance/inspection-forms/index/new.js index 711f154a8..f5c1f29b9 100644 --- a/addon/controllers/maintenance/inspection-forms/index/new.js +++ b/addon/controllers/maintenance/inspection-forms/index/new.js @@ -8,26 +8,41 @@ export default class MaintenanceInspectionFormsIndexNewController extends Contro @service inspectionFormActions; @service hostRouter; @service notifications; + @service intl; @service events; @tracked overlay; @tracked inspectionForm = this.inspectionFormActions.createNewInstance(); + /** The builder's draft, laid out before the form record exists. */ + @tracked structure = null; + @task *save(inspectionForm) { try { yield inspectionForm.save(); + + // The structure is posted separately, under the key the server + // reads it from — Ember Data cannot carry it, because the + // `inspection-form` model declares no attribute for it. + yield this.inspectionFormActions.saveStructure(inspectionForm, this.structure); + this.events.trackResourceCreated(inspectionForm); this.overlay?.close(); yield this.hostRouter.refresh(); yield this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index.details', inspectionForm); - this.notifications.success('Inspection form created.'); + this.notifications.success(this.intl.t('inspection.form.created')); this.resetForm(); } catch (err) { this.notifications.serverError(err); } } + @action setStructure(groups) { + this.structure = groups; + } + @action resetForm() { + this.structure = null; this.inspectionForm = this.inspectionFormActions.createNewInstance(); } } diff --git a/addon/controllers/maintenance/inspection-submissions/index/details.js b/addon/controllers/maintenance/inspection-submissions/index/details.js index 8369eacd3..a2377c87c 100644 --- a/addon/controllers/maintenance/inspection-submissions/index/details.js +++ b/addon/controllers/maintenance/inspection-submissions/index/details.js @@ -6,8 +6,17 @@ import { action } from '@ember/object'; export default class MaintenanceInspectionSubmissionsIndexDetailsController extends Controller { @service inspectionSubmissionActions; @service hostRouter; + @service intl; @tracked overlay; + get tabs() { + return [ + { route: 'maintenance.inspection-submissions.index.details.index', label: this.intl.t('inspection.record.overview') }, + { route: 'maintenance.inspection-submissions.index.details.photos', label: this.intl.t('inspection.record.photos') }, + { route: 'maintenance.inspection-submissions.index.details.audit', label: this.intl.t('inspection.record.audit') }, + ]; + } + get actionButtons() { return [ { icon: 'triangle-exclamation', fn: this.createIssue, text: 'Create Issue', permission: 'fleet-ops create-issue inspection-submission' }, diff --git a/addon/controllers/maintenance/inspection-submissions/index/edit.js b/addon/controllers/maintenance/inspection-submissions/index/edit.js index 525c72496..40c1d1162 100644 --- a/addon/controllers/maintenance/inspection-submissions/index/edit.js +++ b/addon/controllers/maintenance/inspection-submissions/index/edit.js @@ -5,23 +5,34 @@ import { action } from '@ember/object'; import { task } from 'ember-concurrency'; export default class MaintenanceInspectionSubmissionsIndexEditController extends Controller { + @service inspectionSubmissionActions; @service hostRouter; @service notifications; + @service intl; @tracked overlay; + /** The answers, as the server accepts them. */ + @tracked answers = null; + @task *save(inspectionSubmission) { try { yield inspectionSubmission.save(); + yield this.inspectionSubmissionActions.saveAnswers(inspectionSubmission, this.answers); + this.overlay?.close(); yield this.hostRouter.refresh(); yield this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index.details', inspectionSubmission); - this.notifications.success('Inspection updated.'); + this.notifications.success(this.intl.t('inspection.record.updated')); } catch (err) { this.notifications.serverError(err); } } + @action setAnswers(rows) { + this.answers = rows; + } + @action cancel() { return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index.details', this.model); } diff --git a/addon/controllers/maintenance/inspection-submissions/index/new.js b/addon/controllers/maintenance/inspection-submissions/index/new.js index e81c8dd68..972179434 100644 --- a/addon/controllers/maintenance/inspection-submissions/index/new.js +++ b/addon/controllers/maintenance/inspection-submissions/index/new.js @@ -8,26 +8,41 @@ export default class MaintenanceInspectionSubmissionsIndexNewController extends @service inspectionSubmissionActions; @service hostRouter; @service notifications; + @service intl; @service events; @tracked overlay; @tracked inspectionSubmission = this.inspectionSubmissionActions.createNewInstance(); + /** The answers, as the server accepts them. */ + @tracked answers = null; + @task *save(inspectionSubmission) { try { yield inspectionSubmission.save(); + + // The answers are posted separately, under the key the server + // reads them from — the `inspection-submission` model declares no + // `custom_field_values` relationship, so Ember Data drops them. + yield this.inspectionSubmissionActions.saveAnswers(inspectionSubmission, this.answers); + this.events.trackResourceCreated(inspectionSubmission); this.overlay?.close(); yield this.hostRouter.refresh(); yield this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index.details', inspectionSubmission); - this.notifications.success('Inspection saved.'); + this.notifications.success(this.intl.t('inspection.record.saved')); this.resetForm(); } catch (err) { this.notifications.serverError(err); } } + @action setAnswers(rows) { + this.answers = rows; + } + @action resetForm() { + this.answers = null; this.inspectionSubmission = this.inspectionSubmissionActions.createNewInstance(); } } diff --git a/addon/routes.js b/addon/routes.js index ba0042017..6a03c807c 100644 --- a/addon/routes.js +++ b/addon/routes.js @@ -243,6 +243,8 @@ export default buildRoutes(function () { this.route('edit', { path: '/edit/:public_id' }); this.route('details', { path: '/:public_id' }, function () { this.route('index', { path: '/' }); + this.route('photos'); + this.route('audit'); }); }); }); diff --git a/addon/routes/maintenance/inspection-submissions/index/details/audit.js b/addon/routes/maintenance/inspection-submissions/index/details/audit.js new file mode 100644 index 000000000..63707bc25 --- /dev/null +++ b/addon/routes/maintenance/inspection-submissions/index/details/audit.js @@ -0,0 +1,8 @@ +import Route from '@ember/routing/route'; + +export default class MaintenanceInspectionSubmissionsIndexDetailsAuditRoute extends Route { + /** The tab renders the submission the record panel is showing. */ + model() { + return this.modelFor('maintenance.inspection-submissions.index.details'); + } +} diff --git a/addon/routes/maintenance/inspection-submissions/index/details/photos.js b/addon/routes/maintenance/inspection-submissions/index/details/photos.js new file mode 100644 index 000000000..a493628b0 --- /dev/null +++ b/addon/routes/maintenance/inspection-submissions/index/details/photos.js @@ -0,0 +1,8 @@ +import Route from '@ember/routing/route'; + +export default class MaintenanceInspectionSubmissionsIndexDetailsPhotosRoute extends Route { + /** The tab renders the submission the record panel is showing. */ + model() { + return this.modelFor('maintenance.inspection-submissions.index.details'); + } +} diff --git a/addon/services/inspection-form-actions.js b/addon/services/inspection-form-actions.js index 0841ae54f..140a0e2f1 100644 --- a/addon/services/inspection-form-actions.js +++ b/addon/services/inspection-form-actions.js @@ -2,6 +2,7 @@ import ResourceActionService from '@fleetbase/ember-core/services/resource-actio import { action, set } from '@ember/object'; import { inject as service } from '@ember/service'; import copyToClipboard from '@fleetbase/ember-core/utils/copy-to-clipboard'; +import { normalizeFieldGroups, serializeFieldGroups } from '../utils/inspection-form-structure'; export default class InspectionFormActionsService extends ResourceActionService { @service fetch; @@ -30,6 +31,42 @@ export default class InspectionFormActionsService extends ResourceActionService create: () => this.transitionTo('maintenance.inspection-forms.index.new'), }; + /** + * A form's structure — its field groups and their fields. + * + * The `inspection-form` model belongs to `@fleetbase/fleetops-data` and + * declares no attribute for the structure, so Ember Data drops it on the + * way in and on the way out. Both directions go through the internal + * endpoint directly instead: a read carries `field_groups` beside a flat + * `fields` list, and a write posts the whole thing back under + * `inspection_form.field_groups`, which is the key + * `InspectionFormController::syncStructureFromRequest()` reads. + */ + async loadStructure(form) { + if (!form?.id) { + return []; + } + + const response = await this.fetch.get(`inspection-forms/${form.id}`); + + return normalizeFieldGroups(response?.inspection_form ?? response?.inspectionForm ?? response); + } + + /** + * Writes the whole structure. The builder always posts every group and + * every field, so the server prunes what the post no longer lists — a + * field the post dropped is a field the author deleted. + */ + async saveStructure(form, groups) { + if (!form?.id || !Array.isArray(groups) || groups.length === 0) { + return null; + } + + return this.fetch.put(`inspection-forms/${form.id}`, { + inspection_form: { field_groups: serializeFieldGroups(groups) }, + }); + } + @action async publish(form) { try { await this.fetch.post(`inspection-forms/${form.id}/publish`); diff --git a/addon/services/inspection-submission-actions.js b/addon/services/inspection-submission-actions.js index 80af46b0b..4427569a0 100644 --- a/addon/services/inspection-submission-actions.js +++ b/addon/services/inspection-submission-actions.js @@ -24,6 +24,55 @@ export default class InspectionSubmissionActionsService extends ResourceActionSe create: () => this.transitionTo('maintenance.inspection-submissions.index.new'), }; + /** + * The answers filed against a submission, as the resource projects them: + * every value beside the field it answers, with `file:` references + * resolved to something fetchable. + * + * The `inspection-submission` model belongs to `@fleetbase/fleetops-data` + * and declares no `custom_field_values` relationship, so Ember Data drops + * the projection; this reads it from the internal payload directly. + * + * @return {Object} the answers keyed by the field uuid they answer + */ + async loadAnswers(submission) { + if (!submission?.id) { + return {}; + } + + const response = await this.fetch.get(`inspection-submissions/${submission.id}`); + const record = response?.inspection_submission ?? response?.inspectionSubmission ?? response; + const values = Array.isArray(record?.custom_field_values) ? record.custom_field_values : []; + + return values.reduce((carry, value) => { + const key = value?.custom_field; + if (key) { + carry[key] = value.value; + } + + return carry; + }, {}); + } + + /** + * Writes the answers. `inspection_submission.custom_field_values` is what + * `InspectionSubmissionController::syncAnswersFromRequest()` reads, and it + * is the same body the driver API accepts — the console and the app write + * the same rows, and the server derives the item results from the + * pass-fail answers among them. + * + * @param {Array} rows [{ custom_field, value, value_type }] + */ + async saveAnswers(submission, rows) { + if (!submission?.id || !Array.isArray(rows) || rows.length === 0) { + return null; + } + + return this.fetch.put(`inspection-submissions/${submission.id}`, { + inspection_submission: { custom_field_values: rows }, + }); + } + @action async submit(submission) { return this.postAction(submission, 'submit', 'Inspection submitted.'); } diff --git a/addon/templates/maintenance/inspection-forms/index/details.hbs b/addon/templates/maintenance/inspection-forms/index/details.hbs index 2433a52ea..143f18fd2 100644 --- a/addon/templates/maintenance/inspection-forms/index/details.hbs +++ b/addon/templates/maintenance/inspection-forms/index/details.hbs @@ -8,7 +8,7 @@ @headerClass="no-bottom-border" @bodyClass="no-scroll" > - + {{outlet}} diff --git a/addon/templates/maintenance/inspection-forms/index/edit.hbs b/addon/templates/maintenance/inspection-forms/index/edit.hbs index 092c0b5cb..66ed8b45c 100644 --- a/addon/templates/maintenance/inspection-forms/index/edit.hbs +++ b/addon/templates/maintenance/inspection-forms/index/edit.hbs @@ -1,11 +1,11 @@ - + diff --git a/addon/templates/maintenance/inspection-forms/index/new.hbs b/addon/templates/maintenance/inspection-forms/index/new.hbs index 1e89f4a7e..0c871b039 100644 --- a/addon/templates/maintenance/inspection-forms/index/new.hbs +++ b/addon/templates/maintenance/inspection-forms/index/new.hbs @@ -1,11 +1,11 @@ - + diff --git a/addon/templates/maintenance/inspection-submissions/index/details.hbs b/addon/templates/maintenance/inspection-submissions/index/details.hbs index 7b35fdb9a..a787113ee 100644 --- a/addon/templates/maintenance/inspection-submissions/index/details.hbs +++ b/addon/templates/maintenance/inspection-submissions/index/details.hbs @@ -1,14 +1,14 @@ - + {{outlet}} diff --git a/addon/templates/maintenance/inspection-submissions/index/details/audit.hbs b/addon/templates/maintenance/inspection-submissions/index/details/audit.hbs new file mode 100644 index 000000000..56b77f233 --- /dev/null +++ b/addon/templates/maintenance/inspection-submissions/index/details/audit.hbs @@ -0,0 +1 @@ + diff --git a/addon/templates/maintenance/inspection-submissions/index/details/photos.hbs b/addon/templates/maintenance/inspection-submissions/index/details/photos.hbs new file mode 100644 index 000000000..dbd948e04 --- /dev/null +++ b/addon/templates/maintenance/inspection-submissions/index/details/photos.hbs @@ -0,0 +1 @@ + diff --git a/addon/templates/maintenance/inspection-submissions/index/edit.hbs b/addon/templates/maintenance/inspection-submissions/index/edit.hbs index 29c055ab8..03d23e740 100644 --- a/addon/templates/maintenance/inspection-submissions/index/edit.hbs +++ b/addon/templates/maintenance/inspection-submissions/index/edit.hbs @@ -1,11 +1,11 @@ - + diff --git a/addon/templates/maintenance/inspection-submissions/index/new.hbs b/addon/templates/maintenance/inspection-submissions/index/new.hbs index e02ef7cb3..460ad5e82 100644 --- a/addon/templates/maintenance/inspection-submissions/index/new.hbs +++ b/addon/templates/maintenance/inspection-submissions/index/new.hbs @@ -1,11 +1,11 @@ - + diff --git a/addon/utils/inspection-field-types.js b/addon/utils/inspection-field-types.js new file mode 100644 index 000000000..9f646f5c6 --- /dev/null +++ b/addon/utils/inspection-field-types.js @@ -0,0 +1,72 @@ +/** + * The field types an inspection form may be built from. + * + * This list mirrors `Fleetbase\FleetOps\Models\InspectionForm::FIELD_TYPES` + * exactly — the server refuses anything else and falls back to `input`, so the + * builder must not offer a type the writer will silently rewrite. + */ +export const INSPECTION_FIELD_TYPES = ['pass-fail', 'input', 'textarea', 'number', 'select', 'radio-button', 'boolean', 'date-picker', 'date-time-input', 'file-upload', 'signature']; + +/** The severities a failed pass-fail answer can carry. */ +export const INSPECTION_SEVERITIES = ['low', 'medium', 'high', 'critical']; + +/** The types whose answer is chosen from a list the author writes. */ +export const OPTION_FIELD_TYPES = ['select', 'radio-button']; + +/** + * The types FleetOps renders itself. `pass-fail`, `signature` and the + * inspection flavour of `file-upload` are inspection-only; `textarea`, + * `number` and `boolean` are ordinary but absent from the platform's + * custom-field type map, so there is nothing to delegate them to. + */ +export const FLEETOPS_OWNED_FIELD_TYPES = ['pass-fail', 'signature', 'file-upload', 'textarea', 'number', 'boolean']; + +/** The types the platform's own `custom-field/input` already renders. */ +export const DELEGATED_FIELD_TYPES = ['input', 'select', 'radio-button', 'date-picker', 'date-time-input']; + +/** + * The console component that renders a field type. Mirrors + * `InspectionFormSync::componentFor()` so a field built here and a field + * converted from the first cut's checklist name the same component. + */ +export function componentForFieldType(type) { + return type === 'radio-button' ? 'radio-button-select' : type; +} + +/** + * How the server stores an answer of this type — the `value_type` a submitted + * `custom_field_values` row carries. Mirrors + * `InspectionSubmitter::normalizeValue()`. + */ +export function valueTypeForFieldType(type) { + switch (type) { + case 'pass-fail': + return 'object'; + case 'file-upload': + case 'signature': + return 'file'; + case 'number': + return 'number'; + case 'boolean': + return 'boolean'; + case 'date-picker': + case 'date-time-input': + return 'date'; + default: + return 'text'; + } +} + +/** Whether a field of this type needs the author to write its options. */ +export function isOptionFieldType(type) { + return OPTION_FIELD_TYPES.includes(type); +} + +/** Whether FleetOps renders this type itself rather than delegating it. */ +export function isFleetOpsOwnedFieldType(type) { + return FLEETOPS_OWNED_FIELD_TYPES.includes(type); +} + +export default function inspectionFieldTypes() { + return INSPECTION_FIELD_TYPES; +} diff --git a/addon/utils/inspection-form-structure.js b/addon/utils/inspection-form-structure.js new file mode 100644 index 000000000..d72fddf38 --- /dev/null +++ b/addon/utils/inspection-form-structure.js @@ -0,0 +1,144 @@ +import generateUuid from '@fleetbase/ember-core/utils/generate-uuid'; +import isObject from '@fleetbase/ember-core/utils/is-object'; +import { componentForFieldType, INSPECTION_FIELD_TYPES } from './inspection-field-types'; + +/** + * A form's structure, in the one shape the console holds it in. + * + * The `inspection-form` model belongs to `@fleetbase/fleetops-data` and + * declares no structure attribute, so the builder cannot hang the groups off + * the record and let Ember Data carry them. It reads the structure from the + * internal form payload and writes it back whole under + * `inspection_form.field_groups`, which is what + * `InspectionFormController::syncStructureFromRequest()` looks for and what + * `InspectionFormSync::sync()` matches on `uuid`. + * + * Everything here is plain objects. Nothing in this file mutates its argument. + */ + +/** Sorts groups or fields the way the builder laid them out. */ +function byOrder(a, b) { + const ao = a?.order ?? Number.MAX_SAFE_INTEGER; + const bo = b?.order ?? Number.MAX_SAFE_INTEGER; + return ao - bo; +} + +function metaOf(value) { + return isObject(value) ? { ...value } : {}; +} + +/** One field, as the builder and the answering screen both read it. */ +export function normalizeField(field, index = 0) { + const type = INSPECTION_FIELD_TYPES.includes(field?.type) ? field.type : 'input'; + + return { + uuid: field?.uuid ?? field?.id ?? generateUuid(), + name: field?.name ?? '', + label: field?.label ?? '', + description: field?.description ?? null, + help_text: field?.help_text ?? null, + type, + component: field?.component ?? componentForFieldType(type), + required: Boolean(field?.required), + editable: field?.editable === undefined ? true : Boolean(field.editable), + options: Array.isArray(field?.options) ? [...field.options] : [], + order: field?.order ?? index + 1, + meta: metaOf(field?.meta), + }; +} + +/** One group, with its fields inside it and sorted. */ +export function normalizeGroup(group, index = 0, fields = []) { + const own = Array.isArray(group?.fields) ? group.fields : Array.isArray(group?.customFields) ? group.customFields : fields; + + return { + uuid: group?.uuid ?? group?.id ?? generateUuid(), + name: group?.name ?? '', + description: group?.description ?? null, + order: group?.order ?? index + 1, + meta: { grid_size: 1, ...metaOf(group?.meta) }, + fields: [...own].sort(byOrder).map((field, fieldIndex) => normalizeField(field, fieldIndex)), + }; +} + +/** + * The structure held in an internal form payload. + * + * A read carries `field_groups` (the groups alone) beside a flat `fields` list + * that names its group by `category_uuid`; `grouped_fields` carries the same + * thing already nested, and is what the driver API answers with. Either is + * accepted, so this works against a record read through the console and one + * read through the public link. + */ +export function normalizeFieldGroups(payload) { + if (!payload) { + return []; + } + + const groups = Array.isArray(payload.field_groups) ? payload.field_groups : []; + const fields = Array.isArray(payload.fields) ? payload.fields : []; + + if (groups.length) { + return groups + .slice() + .sort(byOrder) + .map((group, index) => { + const groupUuid = group?.uuid ?? group?.id; + const own = fields.filter((field) => (field?.category_uuid ?? null) === groupUuid); + return normalizeGroup(group, index, own); + }); + } + + const grouped = Array.isArray(payload.grouped_fields) ? payload.grouped_fields : []; + + return grouped + .slice() + .sort(byOrder) + .map((group, index) => normalizeGroup(group, index)); +} + +/** Every field of every group, flattened, in the order they are answered. */ +export function flattenFields(groups = []) { + return groups.reduce((carry, group) => carry.concat(Array.isArray(group?.fields) ? group.fields : []), []); +} + +/** + * The structure as the server writes it. `order` is rewritten from the + * builder's own ordering so a drag or a delete renumbers the whole form, and + * the uuid is kept so a second save updates rather than duplicates. + */ +export function serializeFieldGroups(groups = []) { + return groups.map((group, groupIndex) => ({ + uuid: group.uuid, + name: group.name, + description: group.description ?? null, + order: groupIndex + 1, + meta: metaOf(group.meta), + fields: (Array.isArray(group.fields) ? group.fields : []).map((field, fieldIndex) => ({ + uuid: field.uuid, + name: field.name || null, + label: field.label, + description: field.description ?? null, + help_text: field.help_text ?? null, + type: field.type, + component: componentForFieldType(field.type), + required: Boolean(field.required), + editable: field.editable === undefined ? true : Boolean(field.editable), + options: Array.isArray(field.options) ? field.options.filter((option) => typeof option === 'string' && option.trim() !== '') : [], + order: fieldIndex + 1, + meta: metaOf(field.meta), + })), + })); +} + +/** A blank group, ready for the builder to name. */ +export function createFieldGroup(attributes = {}) { + return normalizeGroup({ uuid: generateUuid(), name: '', meta: { grid_size: 1 }, fields: [], ...attributes }); +} + +/** A blank field of the given type. */ +export function createField(type = 'pass-fail', attributes = {}) { + const meta = type === 'pass-fail' ? { severity: 'medium', require_photo_on_fail: false, require_comment_on_fail: false, unsafe_on_fail: false } : {}; + + return normalizeField({ uuid: generateUuid(), label: '', type, meta, ...attributes }); +} diff --git a/app/components/inspection-field/form.js b/app/components/inspection-field/form.js new file mode 100644 index 000000000..b7aa86835 --- /dev/null +++ b/app/components/inspection-field/form.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/inspection-field/form'; diff --git a/app/components/inspection-field/input.js b/app/components/inspection-field/input.js new file mode 100644 index 000000000..c99fd657a --- /dev/null +++ b/app/components/inspection-field/input.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/inspection-field/input'; diff --git a/app/components/inspection-field/value.js b/app/components/inspection-field/value.js new file mode 100644 index 000000000..ad0a25c1c --- /dev/null +++ b/app/components/inspection-field/value.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/inspection-field/value'; diff --git a/app/components/inspection-form/builder.js b/app/components/inspection-form/builder.js new file mode 100644 index 000000000..55ff7f1e9 --- /dev/null +++ b/app/components/inspection-form/builder.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/inspection-form/builder'; diff --git a/app/components/inspection-submission/photos.js b/app/components/inspection-submission/photos.js new file mode 100644 index 000000000..172bb17c1 --- /dev/null +++ b/app/components/inspection-submission/photos.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/inspection-submission/photos'; diff --git a/app/components/modals/inspection-field.js b/app/components/modals/inspection-field.js new file mode 100644 index 000000000..6ed255818 --- /dev/null +++ b/app/components/modals/inspection-field.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/modals/inspection-field'; diff --git a/app/routes/maintenance/inspection-submissions/index/details/audit.js b/app/routes/maintenance/inspection-submissions/index/details/audit.js new file mode 100644 index 000000000..c7ffdd249 --- /dev/null +++ b/app/routes/maintenance/inspection-submissions/index/details/audit.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-submissions/index/details/audit'; diff --git a/app/routes/maintenance/inspection-submissions/index/details/photos.js b/app/routes/maintenance/inspection-submissions/index/details/photos.js new file mode 100644 index 000000000..1fa654a4b --- /dev/null +++ b/app/routes/maintenance/inspection-submissions/index/details/photos.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/routes/maintenance/inspection-submissions/index/details/photos'; diff --git a/app/templates/maintenance/inspection-submissions/index/details/audit.js b/app/templates/maintenance/inspection-submissions/index/details/audit.js new file mode 100644 index 000000000..094ec5262 --- /dev/null +++ b/app/templates/maintenance/inspection-submissions/index/details/audit.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-submissions/index/details/audit'; diff --git a/app/templates/maintenance/inspection-submissions/index/details/photos.js b/app/templates/maintenance/inspection-submissions/index/details/photos.js new file mode 100644 index 000000000..d9b86fd4e --- /dev/null +++ b/app/templates/maintenance/inspection-submissions/index/details/photos.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/templates/maintenance/inspection-submissions/index/details/photos'; diff --git a/app/utils/inspection-field-types.js b/app/utils/inspection-field-types.js new file mode 100644 index 000000000..1f8e9cea2 --- /dev/null +++ b/app/utils/inspection-field-types.js @@ -0,0 +1,2 @@ +export { default } from '@fleetbase/fleetops-engine/utils/inspection-field-types'; +export * from '@fleetbase/fleetops-engine/utils/inspection-field-types'; diff --git a/app/utils/inspection-form-structure.js b/app/utils/inspection-form-structure.js new file mode 100644 index 000000000..a9db08bed --- /dev/null +++ b/app/utils/inspection-form-structure.js @@ -0,0 +1 @@ +export * from '@fleetbase/fleetops-engine/utils/inspection-form-structure'; diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 42f1c9ebf..e4c2a7c93 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -2748,3 +2748,151 @@ orchestrator: stop-type-dropoff: Dropoff pod-required: POD no-pod: No POD + +inspection: + form: + overview: Overview + details: Form Details + name: Name + name-placeholder: Daily vehicle inspection + type: Type + status: Status + frequency: Frequency + description: Description + description-placeholder: What this inspection covers + builder: Form Builder + fields: Fields + published: Published + structure: Form Structure + loading-structure: Loading form structure... + no-structure: This form has no field groups yet. + settings: Settings + legacy-checklist: Legacy Checklist + legacy-checklist-help: The first cut's checklist, kept read-only. It is migrated into a "Checklist" group of pass/fail fields. + create: Create inspection form + created: Inspection form created. + updated: Inspection form updated. + builder: + help: Group the things a driver checks, then add a field for each one. + loading: Loading form builder... + new-group: New field group + new-field: New field + edit-field: 'Edit field: {label}' + save-field: Save field + untitled-group: Untitled group + untitled-field: Untitled field + group-name: Group name + group-name-placeholder: Exterior + group-description: Group description + group-description-placeholder: What this group covers + grid-size: Columns + move-up: Move up + move-down: Move down + no-fields: No fields in this group yet. + empty-title: No field groups + empty-description: Add your first field group to start building this form. + delete: Delete + delete-group-title: Delete this field group? + delete-group-body: Deleting this group deletes every field inside it. This cannot be undone once the form is saved. + delete-field-title: Delete this field? + delete-field-body: Deleting this field also deletes every answer already filed against it. This cannot be undone once the form is saved. + field: + label: Field label + label-placeholder: Brake lights + name: Field name + name-help: The machine name a submission refers to this field by. It follows the label until you set it yourself. + name-placeholder: brake-lights + type: Field type + description: Field description + description-placeholder: What to check, and what a pass looks like + help-text: Field help text + help-text-placeholder: Shown beside the field while it is answered + required: Field is required + editable: Field is editable + options: Field options + no-options: No options yet. + option-placeholder: Add an option + add-option: Add + unit: Unit + unit-help: Shown beside the number, for example km or hours. + unit-placeholder: km + odometer-role: This is the odometer reading + odometer-role-help: The answer is copied to the inspection's own odometer column. + on-fail: On fail + on-fail-help: What a failed answer means, and what the inspector must supply before it is accepted. + default-severity: Default severity + require-photo-on-fail: Require a photo on fail + require-comment-on-fail: Require a comment on fail + unsafe-on-fail: Mark the vehicle unsafe on fail + unsafe-on-fail-help: A failed answer defaults to unsafe, which is what an out-of-service defect is. + instructions: Instructions + instructions-placeholder: How to check this, shown to the driver + column-span: Column span + field-type: + pass-fail: Pass / Fail + input: Text + textarea: Long text + number: Number + select: Select + radio-button: Radio buttons + boolean: Yes / No + date-picker: Date + date-time-input: Date and time + file-upload: Photo or file + signature: Signature + severity: + low: Low + medium: Medium + high: High + critical: Critical + answer: + pass: Pass + fail: Fail + not-applicable: N/A + unanswered: Unanswered + severity: Severity + unsafe: Unsafe to operate + comments: Comments + comments-placeholder: What is wrong, and what it needs + photos: Photos + add-photo: Add photo + upload-photo: Upload photo + no-photo: No photo attached. + upload-signature: Upload signature + no-signature: No signature captured. + record: + overview: Overview + inspection: Inspection + details: Inspection Details + form: Form + select-form: Select inspection form + status: Status + result: Result + vehicle: Vehicle + select-vehicle: Select vehicle + driver: Driver + select-driver: Select driver + odometer: Odometer + odometer-placeholder: Current odometer + engine-hours: Engine Hours + engine-hours-placeholder: Current engine hours + answers: Answers + loading-form: Loading inspection form... + loading-answers: Loading answers... + form-has-no-fields: This form has no fields yet. Build it on the inspection form screen before filling it in. + summary: Summary + failed-of: '{failed} failed of {total} pass/fail checks' + metadata: Metadata + submitted: Submitted + resolved: Resolved + item-results: Item Results + no-item-results: No item results recorded. + follow-up: Follow Up + linked-issue: Linked Issue + linked-work-order: Linked Work Order + photos: Photos + loading-photos: Loading photos... + audit: Audit + create: Create inspection + saved: Inspection saved. + updated: Inspection updated. From f8c569fc6f78e45ebb1808e1de0a00970f6c81a9 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 13:07:20 +0800 Subject: [PATCH 10/44] Take the console's file references and its cleared answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the console does that the driver app does not. It uploads a photo the moment it is picked and keeps the reference the upload answered with, which names the file by its public id — `file:file_…`, not `file:`. That reference was passed through untouched, so nothing downstream could resolve it: the file was never claimed by the submission and the resource handed the raw string back. A `file:` reference that does not name a uuid is now looked up and rewritten to the one it does. And it can clear a field. `custom_field_values.value` is a NOT NULL column, so an answer that is nothing is not an answer — it deletes the row instead of writing a null the column will not take. --- server/src/Support/InspectionFileStore.php | 24 +++++++++++++++---- server/src/Support/InspectionSubmitter.php | 4 +++- server/tests/InspectionFieldContractsTest.php | 16 ++++++++++++- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/server/src/Support/InspectionFileStore.php b/server/src/Support/InspectionFileStore.php index 3a9b58ac8..962501054 100644 --- a/server/src/Support/InspectionFileStore.php +++ b/server/src/Support/InspectionFileStore.php @@ -32,7 +32,17 @@ public static function normalize(mixed $value, InspectionSubmission $submission, $value = trim($value); - if (Str::startsWith($value, 'file:') || static::isUrl($value)) { + if (Str::startsWith($value, 'file:')) { + // The console uploads a photo as soon as it is picked and keeps + // the reference; the upload answers with the file's public id, not + // its uuid, so a reference that names one is rewritten to the uuid + // the rest of this class — and the platform's own cast — reads. + $reference = substr($value, 5); + + return Str::isUuid($reference) ? $value : static::referenceByPublicId($reference, $value); + } + + if (static::isUrl($value)) { return $value; } @@ -41,9 +51,7 @@ public static function normalize(mixed $value, InspectionSubmission $submission, } if (Str::startsWith($value, 'file_')) { - $file = File::query()->where('public_id', $value)->first(); - - return $file ? 'file:' . $file->uuid : $value; + return static::referenceByPublicId($value, $value); } if (static::isBase64($value)) { @@ -106,6 +114,14 @@ public static function attachReferenced(InspectionSubmission $submission, array ->update(['subject_uuid' => $submission->uuid, 'subject_type' => $submission->getMorphClass()]); } + /** A file named by its public id, as `file:`; the fallback when it is unknown. */ + protected static function referenceByPublicId(string $publicId, string $fallback): string + { + $file = File::query()->where('public_id', $publicId)->first(); + + return $file ? 'file:' . $file->uuid : $fallback; + } + /** The uuid a `file:` value points at, or null for anything else. */ public static function referencedUuid(mixed $value): ?string { diff --git a/server/src/Support/InspectionSubmitter.php b/server/src/Support/InspectionSubmitter.php index 98790d652..1988a0188 100644 --- a/server/src/Support/InspectionSubmitter.php +++ b/server/src/Support/InspectionSubmitter.php @@ -156,7 +156,9 @@ public static function applyCustomFieldValues(InspectionSubmission $submission, throw ValidationException::withMessages($errors); } - $summary = $submission->syncCustomFieldValues($payload); + // `custom_field_values.value` is a NOT NULL column, so an answer that + // is nothing is not an answer: it clears whatever was there instead. + $summary = $submission->syncCustomFieldValues($payload, ['treat_null_as_delete' => true]); $submission->syncItemResultsFromCustomFieldValues(); $submission->syncResultCounts(); diff --git a/server/tests/InspectionFieldContractsTest.php b/server/tests/InspectionFieldContractsTest.php index 7af94bea9..245899bc8 100644 --- a/server/tests/InspectionFieldContractsTest.php +++ b/server/tests/InspectionFieldContractsTest.php @@ -471,7 +471,12 @@ function fleetOpsInspectionFieldPhoto(): string ->and(InspectionFileStore::normalize('not base64!', $submission))->toBe('not base64!') ->and(InspectionFileStore::normalize($file->uuid, $submission))->toBe('file:' . $file->uuid) ->and(InspectionFileStore::normalize($file->public_id, $submission))->toBe('file:' . $file->uuid) - ->and(InspectionFileStore::normalize('file_missing', $submission))->toBe('file_missing'); + ->and(InspectionFileStore::normalize('file_missing', $submission))->toBe('file_missing') + // The console uploads a photo as soon as it is picked and keeps the + // reference the upload answered with, which names the file by its + // public id; that is rewritten to the uuid everything else reads. + ->and(InspectionFileStore::normalize('file:' . $file->public_id, $submission))->toBe('file:' . $file->uuid) + ->and(InspectionFileStore::normalize('file:file_missing', $submission))->toBe('file:file_missing'); // A data URI carries its own content type; bare base64 is sniffed. $jpeg = InspectionFileStore::resolve(InspectionFileStore::normalize('data:image/jpeg;base64,' . fleetOpsInspectionFieldPhoto(), $submission, InspectionFileStore::TYPE_SIGNATURE)); @@ -576,6 +581,15 @@ function fleetOpsInspectionFieldPhoto(): string ->and($submission->meta['unsafe'])->toBeFalse() ->and($submission->result)->toBe('passed'); + // Clearing an answer is not an answer: the value row goes rather than a + // null being written to a column that will not take one. + InspectionSubmitter::applyCustomFieldValues($submission, [ + ['custom_field' => $fields['notes']->uuid, 'value' => null], + ['custom_field' => $fields['odometer']->uuid, 'value' => ''], + ]); + expect($submission->fresh()->customFieldValues()->where('custom_field_uuid', $fields['notes']->uuid)->count())->toBe(0) + ->and($submission->fresh()->customFieldValues()->where('custom_field_uuid', $fields['odometer']->uuid)->count())->toBe(0); + // An answer that is taken away — the console clearing a field — takes its // result row with it; a row written any other way is left alone. $submission->customFieldValues()->where('custom_field_uuid', $fields['mirrors']->uuid)->delete(); From 1addf1f9559734d4bc0c7d31262af33e5c3bcb78 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 13:09:55 +0800 Subject: [PATCH 11/44] Do not reach for a destroyed component, or a form's console id Three components kick a load off in the next runloop; if the panel closes first the task is performed on something already torn down. And the form the answering screen assigns writes its own uuid into the column, never the id the console addresses it by. --- addon/components/inspection-submission/form.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/addon/components/inspection-submission/form.js b/addon/components/inspection-submission/form.js index 1aab63e42..f2f8e7280 100644 --- a/addon/components/inspection-submission/form.js +++ b/addon/components/inspection-submission/form.js @@ -35,7 +35,13 @@ export default class InspectionSubmissionFormComponent extends Component { constructor() { super(...arguments); - next(() => this.load.perform(this.args.resource?.form)); + next(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.load.perform(this.args.resource?.form); + }); } get fields() { @@ -152,7 +158,9 @@ export default class InspectionSubmissionFormComponent extends Component { @action assignForm(form) { this.args.resource.form = form; - this.args.resource.inspection_form_uuid = form?.id ?? null; + // 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); From 7fac84caaf5c816d5ba4c10d70e6b110c3814fbb Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 13:11:10 +0800 Subject: [PATCH 12/44] Do not ask for a translation of a severity nobody wrote A severity that is not one of the four the field editor offers has no key, and asking for one puts "Missing translation" on the record. Fall back to the value itself. --- addon/components/inspection-field/value.hbs | 4 +++- addon/components/inspection-field/value.js | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/addon/components/inspection-field/value.hbs b/addon/components/inspection-field/value.hbs index 04b047e5b..3ebfa4492 100644 --- a/addon/components/inspection-field/value.hbs +++ b/addon/components/inspection-field/value.hbs @@ -5,7 +5,9 @@
{{t this.resultLabel}} {{#if this.answer.severity}} - {{t (concat "inspection.severity." this.answer.severity)}} + + {{#if this.severityLabel}}{{t this.severityLabel}}{{else}}{{smart-humanize this.answer.severity}}{{/if}} + {{/if}} {{#if this.answer.unsafe}} {{t "inspection.answer.unsafe"}} diff --git a/addon/components/inspection-field/value.js b/addon/components/inspection-field/value.js index 8a67bd1af..0840d35cc 100644 --- a/addon/components/inspection-field/value.js +++ b/addon/components/inspection-field/value.js @@ -1,4 +1,5 @@ import Component from '@glimmer/component'; +import { INSPECTION_SEVERITIES } from '../../utils/inspection-field-types'; /** * One stored answer, read-only — what the record's Overview shows. @@ -75,6 +76,12 @@ export default class InspectionFieldValueComponent extends Component { return answer.passed === false ? 'danger' : 'success'; } + /** The severity's own label, or the raw value when it is not one of ours. */ + get severityLabel() { + const severity = this.answer.severity; + return INSPECTION_SEVERITIES.includes(severity) ? `inspection.severity.${severity}` : null; + } + get photos() { return this.answer.photos.map((photo) => this.#describeFile(photo)); } From df591b262cd866f1a3493b2ddb8845eb4630ffbe Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 13:12:18 +0800 Subject: [PATCH 13/44] Say datetime where the app says datetime `valueTypeFor` in the app's useInspections sends `datetime` for a date-time field and `date` for a date; the console said `date` for both. The server takes either, but the two clients should file the same row. --- addon/utils/inspection-field-types.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/addon/utils/inspection-field-types.js b/addon/utils/inspection-field-types.js index 9f646f5c6..97b56f425 100644 --- a/addon/utils/inspection-field-types.js +++ b/addon/utils/inspection-field-types.js @@ -35,8 +35,9 @@ export function componentForFieldType(type) { /** * How the server stores an answer of this type — the `value_type` a submitted - * `custom_field_values` row carries. Mirrors - * `InspectionSubmitter::normalizeValue()`. + * `custom_field_values` row carries. Mirrors `InspectionSubmitter::normalizeValue()` + * and the app's own `valueTypeFor` in `src/v3/data/useInspections.ts`, so the + * console and the driver app file the same rows. */ export function valueTypeForFieldType(type) { switch (type) { @@ -50,8 +51,9 @@ export function valueTypeForFieldType(type) { case 'boolean': return 'boolean'; case 'date-picker': - case 'date-time-input': return 'date'; + case 'date-time-input': + return 'datetime'; default: return 'text'; } From fe6e5f9f4e1a335965d09d06bf54cdd59c601ead Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 13:14:32 +0800 Subject: [PATCH 14/44] Pin the whole body the app sends buildSubmission in the app's useInspections.ts emits both bodies at once, every pass-fail answer carrying not_applicable and unsafe, photos and the signature as bare base64. That exact shape now goes through the submitter in a test, so a change here that the app would not survive fails here first. --- server/tests/InspectionFieldContractsTest.php | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/server/tests/InspectionFieldContractsTest.php b/server/tests/InspectionFieldContractsTest.php index 245899bc8..87c83dc63 100644 --- a/server/tests/InspectionFieldContractsTest.php +++ b/server/tests/InspectionFieldContractsTest.php @@ -828,6 +828,61 @@ function fleetOpsInspectionFieldPhoto(): string expect($controller->export(FleetOpsInspectionExportRequestFake::create('/int/v1/inspection-submissions/export', 'POST'))['download'])->toEndWith('.xlsx'); }); +test('the submitter takes the body the driver app builds, whole', function () { + fleetOpsInspectionFieldDatabase(); + Carbon::setTestNow('2026-09-10 07:30:00'); + $form = fleetOpsInspectionFieldForm(); + InspectionFormSync::sync($form, fleetOpsInspectionFieldDraft()); + $fields = $form->fields->keyBy('name'); + $photo = fleetOpsInspectionFieldPhoto(); + + // Exactly what `buildSubmission` in the app's useInspections.ts emits: + // both bodies, every pass-fail answer carrying `not_applicable` and + // `unsafe`, photos and the signature as bare base64. + $submission = InspectionSubmitter::submit($form, [ + 'odometer' => 112480, + 'engine_hours' => null, + 'custom_field_values' => [ + ['custom_field' => $fields['brakes']->uuid, 'value_type' => 'object', 'value' => ['passed' => false, 'not_applicable' => false, 'severity' => 'critical', 'comments' => 'Soft pedal', 'photos' => [$photo], 'unsafe' => true]], + ['custom_field' => $fields['mirrors']->uuid, 'value_type' => 'object', 'value' => ['passed' => true, 'not_applicable' => true, 'severity' => null, 'comments' => null, 'photos' => [], 'unsafe' => false]], + ['custom_field' => $fields['odometer']->uuid, 'value_type' => 'number', 'value' => 112480], + ['custom_field' => $fields['signature']->uuid, 'value_type' => 'file', 'value' => $photo], + ['custom_field' => $fields['notes']->uuid, 'value_type' => 'text', 'value' => 'Nearside mirror scuffed'], + ], + 'item_results' => [ + ['item_key' => 'brakes', 'label' => 'Brakes', 'category' => 'Exterior', 'status' => 'failed', 'severity' => 'critical', 'passed' => false, 'comments' => 'Soft pedal', 'photos' => [$photo]], + ['item_key' => 'mirrors', 'label' => 'Mirrors', 'category' => 'Exterior', 'status' => 'not_applicable', 'severity' => null, 'passed' => true, 'comments' => null, 'photos' => []], + ], + 'location' => ['latitude' => 1.3521, 'longitude' => 103.8198], + 'signature' => ['image' => $photo, 'signed_at' => '2026-09-10T07:30:00Z'], + 'meta' => ['source_app' => 'navigator', 'unsafe' => true], + ], [ + 'driver_uuid' => 'driver-1', + 'vehicle_uuid' => 'vehicle-1', + 'submitted_by_uuid' => 'user-driver', + 'source' => 'navigator', + ]); + + $submission = $submission->fresh(['itemResults', 'customFieldValues.customField', 'files']); + $results = $submission->itemResults->keyBy('item_key'); + + expect($submission->customFieldValues)->toHaveCount(5) + // The field values won; the duplicated `item_results` were ignored, + // so there is one row per pass-fail field and no more. + ->and($submission->itemResults)->toHaveCount(2) + ->and($results['brakes']->status)->toBe('failed') + ->and($results['brakes']->photos[0])->toStartWith('file:') + ->and($results['mirrors']->status)->toBe('not_applicable') + ->and($results['mirrors']->passed)->toBeTrue() + ->and($submission->total_items)->toBe(2) + ->and($submission->failed_items)->toBe(1) + ->and($submission->result)->toBe('failed') + ->and($submission->meta['unsafe'])->toBeTrue() + ->and($submission->odometer)->toBe(112480) + ->and($submission->files)->toHaveCount(2) + ->and($submission->source)->toBe('navigator'); +}); + test('the submitter takes field answers straight from a submit body', function () { fleetOpsInspectionFieldDatabase(); $form = fleetOpsInspectionFieldForm(['settings' => ['create_issue_on_failure' => true, 'create_work_order_on_failure' => true]]); From 8d76d6248c76862985cc5dc7e01e5cb3ccc06df0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 13:15:44 +0800 Subject: [PATCH 15/44] Guard the two remaining deferred loads Same as the others: the details panels kick their load off in the next runloop, and closing the panel first would perform a task on a component already torn down. --- addon/components/inspection-form/details.js | 8 +++++++- addon/components/inspection-submission/details.js | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/addon/components/inspection-form/details.js b/addon/components/inspection-form/details.js index bc55b0748..cfb2abb46 100644 --- a/addon/components/inspection-form/details.js +++ b/addon/components/inspection-form/details.js @@ -16,7 +16,13 @@ export default class InspectionFormDetailsComponent extends Component { constructor() { super(...arguments); - next(() => this.load.perform()); + next(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.load.perform(); + }); } get fieldCount() { diff --git a/addon/components/inspection-submission/details.js b/addon/components/inspection-submission/details.js index 3b85c40ca..add5bcc89 100644 --- a/addon/components/inspection-submission/details.js +++ b/addon/components/inspection-submission/details.js @@ -22,7 +22,13 @@ export default class InspectionSubmissionDetailsComponent extends Component { constructor() { super(...arguments); - next(() => this.load.perform()); + next(() => { + if (this.isDestroying || this.isDestroyed) { + return; + } + + this.load.perform(); + }); } get hasAnswers() { From f63667c842271b47773c68d5e130109238f776f1 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 13:16:31 +0800 Subject: [PATCH 16/44] Let the public link run a form built from fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tokenised link built its checklist from `items`, which a form built in the new builder does not have — so the link showed an empty form and would not submit. It now falls back to the pass/fail fields of `grouped_fields`, keyed the way the server keys a derived result, so a link submission and an app submission name the same item. The other field types are still not offered there: a public link is a checklist for a contractor, not the app. --- addon/components/public-inspection.js | 58 ++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/addon/components/public-inspection.js b/addon/components/public-inspection.js index a675d8c1b..812ceae6c 100644 --- a/addon/components/public-inspection.js +++ b/addon/components/public-inspection.js @@ -55,16 +55,7 @@ export default class PublicInspectionComponent extends Component { const response = yield this.fetch.get(`inspections/forms/${this.formId}`, { token: this.token }, { namespace: 'fleet-ops/public' }); this.form = response?.form; this.identity = response?.identity; - this.itemResults = (this.form?.items ?? []).map((item, index) => ({ - item_key: item.key || `item_${index + 1}`, - label: item.label, - category: item.category, - severity: item.severity || 'medium', - status: 'passed', - passed: true, - comments: '', - photos: [], - })); + this.itemResults = this.checklistOf(this.form); } catch (error) { this.error = error?.payload?.error ?? error?.message ?? 'Unable to load inspection.'; } @@ -92,6 +83,53 @@ export default class PublicInspectionComponent extends Component { } } + /** + * The checklist a tokenised link offers. + * + * A form built from fields answers `grouped_fields`, not `items`, so the + * pass/fail fields among them are what the link asks about — keyed the way + * the server keys a derived result (`field.name ?? field.id`), so a link + * submission and an app submission name the same item. The other field + * types are not offered here: a public link is a checklist handed to a + * contractor with a phone number, not the app. + */ + checklistOf(form) { + const items = Array.isArray(form?.items) ? form.items : []; + if (items.length) { + return items.map((item, index) => ({ + item_key: item.key || `item_${index + 1}`, + label: item.label, + category: item.category, + severity: item.severity || 'medium', + status: 'passed', + passed: true, + comments: '', + photos: [], + })); + } + + const groups = Array.isArray(form?.grouped_fields) ? form.grouped_fields : []; + + return groups.reduce((carry, group) => { + const fields = Array.isArray(group?.fields) ? group.fields : []; + + return carry.concat( + fields + .filter((field) => field?.type === 'pass-fail') + .map((field) => ({ + item_key: field.name || field.id, + label: field.label, + category: group.name ?? null, + severity: field.meta?.severity || 'medium', + status: 'passed', + passed: true, + comments: '', + photos: [], + })) + ); + }, []); + } + @action updateReading(key, event) { this[key] = event.target.value; } From 3e42dbd64ef7104bbfe64e743af76e50b96e1b24 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 13:17:47 +0800 Subject: [PATCH 17/44] Do not let the builder delete a field it never owned `InspectionFormSync` read every custom field subjected to the form, not just the ones filed under the inspection kind. The builder posts the whole form and prunes what the post no longer lists, so a custom field the console's generic panel had added to the form *record* was deleted by the next save of the structure. Both the read and the prune are now scoped. --- server/src/Support/InspectionFormSync.php | 10 ++++++++-- server/tests/InspectionFieldContractsTest.php | 8 +++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/server/src/Support/InspectionFormSync.php b/server/src/Support/InspectionFormSync.php index 83a5468ff..905bc4edd 100644 --- a/server/src/Support/InspectionFormSync.php +++ b/server/src/Support/InspectionFormSync.php @@ -45,8 +45,11 @@ public static function sync(InspectionForm $form, array $draft, bool $pruneMissi ->where(['owner_uuid' => $form->uuid, 'for' => InspectionForm::GROUP_FOR]) ->get() ->keyBy('uuid'); + // Only the fields a driver answers. A custom field the console's + // generic panel added to the form *record* has the same subject and + // would otherwise be pruned as a field the builder no longer lists. $existingFields = CustomField::query() - ->where('subject_uuid', $form->uuid) + ->where(['subject_uuid' => $form->uuid, 'for' => InspectionForm::FIELD_FOR]) ->get() ->keyBy('uuid'); @@ -213,7 +216,10 @@ protected static function prune(InspectionForm $form, Collection $existingGroups { $groupsToDelete = $existingGroups->keys()->diff($keptGroups)->values(); if ($groupsToDelete->isNotEmpty()) { - CustomField::query()->where('subject_uuid', $form->uuid)->whereIn('category_uuid', $groupsToDelete)->delete(); + CustomField::query() + ->where(['subject_uuid' => $form->uuid, 'for' => InspectionForm::FIELD_FOR]) + ->whereIn('category_uuid', $groupsToDelete) + ->delete(); Category::query()->whereIn('uuid', $groupsToDelete)->delete(); } diff --git a/server/tests/InspectionFieldContractsTest.php b/server/tests/InspectionFieldContractsTest.php index 87c83dc63..b7787d8d8 100644 --- a/server/tests/InspectionFieldContractsTest.php +++ b/server/tests/InspectionFieldContractsTest.php @@ -304,6 +304,10 @@ function fleetOpsInspectionFieldPhoto(): string ->and($fields['fuel']->component)->toBe('radio-button-select') ->and($fields['fuel']->options)->toBe(['full', 'half']); + // A custom field the console's generic panel added to the form record has + // the same subject; the builder must not take it for one of its own. + CustomField::create(['company_uuid' => 'company-insp', 'subject_uuid' => $form->uuid, 'subject_type' => $form->getMorphClass(), 'name' => 'depot', 'label' => 'Depot', 'type' => 'text']); + // A second post with the same uuids updates rather than duplicates, and // what it no longer lists is deleted. $draft = fleetOpsInspectionFieldDraft(); @@ -321,7 +325,9 @@ function fleetOpsInspectionFieldPhoto(): string ->and($form->fieldGroups->first()->name)->toBe('Exterior walk-around') ->and($form->fields)->toHaveCount(2) ->and($form->fields->firstWhere('uuid', $fields['mirrors']->uuid)->label)->toBe('Mirrors and glass') - ->and(CustomField::query()->count())->toBe(2) + // Two inspection fields, plus the unrelated one, left where it was. + ->and(CustomField::query()->count())->toBe(3) + ->and(CustomField::query()->where('name', 'depot')->exists())->toBeTrue() ->and(Category::query()->count())->toBe(1); }); From 0f76aa768d52594cd24d7dc3a847060e1e61132c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 13:20:11 +0800 Subject: [PATCH 18/44] Guard the other severity label the same way The form's own details view asked for a translation of whatever severity a field carries, which for a field converted from a hand-written first-cut item can be anything. Same fallback as the record. --- addon/components/inspection-form/details.hbs | 6 +++++- addon/components/inspection-form/details.js | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/addon/components/inspection-form/details.hbs b/addon/components/inspection-form/details.hbs index fb9ced19f..4240e7498 100644 --- a/addon/components/inspection-form/details.hbs +++ b/addon/components/inspection-form/details.hbs @@ -54,7 +54,11 @@
{{#if (and (eq field.type "pass-fail") field.meta.severity)}} - {{t (concat "inspection.severity." 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}}
diff --git a/addon/components/inspection-form/details.js b/addon/components/inspection-form/details.js index cfb2abb46..ac044629b 100644 --- a/addon/components/inspection-form/details.js +++ b/addon/components/inspection-form/details.js @@ -1,4 +1,5 @@ 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'; @@ -25,6 +26,14 @@ export default class InspectionFormDetailsComponent extends Component { }); } + /** + * 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); } From 4599c822191b080a0602c6978e6a61306589de1f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 13:46:13 +0800 Subject: [PATCH 19/44] console: fix the inspection form's settings, labels, selects and field editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six things from the first real run of the builder: - Settings rendered as a raw metadata editor, collapsed. They are three known switches — two the server reads when a submission has failures, one the driver app reads before it will submit — so they are checkboxes in an unlabelled input group, and the panel opens. - Type and status showed smart-humanize output ("Dvir"). They now come from labelled option lists in fleet-ops-options, so DVIR reads as DVIR. - Six inspection types were missing: pre-operational, post-operational, safety inspection, maintenance inspection, damage assessment and annual inspection. - Every PowerSelect is wrapped in the fleetbase-model-select / fleetbase-power-select / ember-model-select div so it renders as the rest of the console does. - The field editor opens as a right-side overlay over the form panel rather than a modal, sized xs so the form stays visible beside it. It holds the field in tracked state and writes through to the overlay's shared handle, which is what the builder reads back on save. - Frequency is dropped from the form. The column and the API field stay, but nothing schedules an inspection from it, so the dropdown only asked the author a question the product does not act on. --- addon/components/inspection-field/form.hbs | 60 ++++++++-------- addon/components/inspection-field/form.js | 27 ++++++- addon/components/inspection-field/input.hbs | 24 ++++--- addon/components/inspection-form/builder.js | 29 +++++--- addon/components/inspection-form/form.hbs | 71 ++++++++++++++----- addon/components/inspection-form/form.js | 59 ++++++++++----- .../components/inspection-submission/form.hbs | 18 ++++- .../components/inspection-submission/form.js | 4 +- addon/components/modals/inspection-field.hbs | 5 -- addon/components/modals/inspection-field.js | 20 ------ addon/utils/fleet-ops-options.js | 40 +++++++++++ app/components/modals/inspection-field.js | 1 - translations/en-us.yaml | 8 +++ 13 files changed, 251 insertions(+), 115 deletions(-) delete mode 100644 addon/components/modals/inspection-field.hbs delete mode 100644 addon/components/modals/inspection-field.js delete mode 100644 app/components/modals/inspection-field.js diff --git a/addon/components/inspection-field/form.hbs b/addon/components/inspection-field/form.hbs index cb9e8d12d..b95754ce8 100644 --- a/addon/components/inspection-field/form.hbs +++ b/addon/components/inspection-field/form.hbs @@ -1,14 +1,14 @@
- + - + - {{#each this.fieldTypes as |fieldType|}} - + - +
- - + +
{{#if this.hasOptions}} @@ -35,15 +35,15 @@
{{#each this.options as |option index|}}
- -
{{else}}
{{t "inspection.field.no-options"}}
{{/each}}
- -
@@ -51,9 +51,9 @@ {{#if this.isNumber}} - + - + {{/if}} {{#if this.isPassFail}} @@ -62,25 +62,27 @@
{{t "inspection.field.on-fail-help"}}
- - {{t (concat "inspection.severity." severity)}} - +
+ + {{t (concat "inspection.severity." severity)}} + +
- - - + + + - +
{{/if}} @@ -92,7 +94,7 @@ @type={{if (eq this.meta.colSpan size) "primary" "default"}} @size="xs" @text={{size}} - @disabled={{@disabled}} + @disabled={{this.isDisabled}} @onClick={{fn this.setColSpan size}} /> {{/each}} diff --git a/addon/components/inspection-field/form.js b/addon/components/inspection-field/form.js index 3c94a663f..683032b0e 100644 --- a/addon/components/inspection-field/form.js +++ b/addon/components/inspection-field/form.js @@ -25,8 +25,24 @@ export default class InspectionFieldFormComponent extends Component { 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.args.field ?? {}; + return this.localField; + } + + get isDisabled() { + return this.args.disabled ?? this.args.overlay?.disabled ?? false; } get meta() { @@ -56,8 +72,15 @@ export default class InspectionFieldFormComponent extends Component { /** 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({ ...this.field, ...attributes }); + this.args.onChange(next); } } diff --git a/addon/components/inspection-field/input.hbs b/addon/components/inspection-field/input.hbs index 2ed795e06..655391ede 100644 --- a/addon/components/inspection-field/input.hbs +++ b/addon/components/inspection-field/input.hbs @@ -24,17 +24,19 @@ {{#if this.isFailed}}
- - {{t (concat "inspection.severity." severity)}} - +
+ + {{t (concat "inspection.severity." severity)}} + +
diff --git a/addon/components/inspection-form/builder.js b/addon/components/inspection-form/builder.js index 3e793a510..b6f779c52 100644 --- a/addon/components/inspection-form/builder.js +++ b/addon/components/inspection-form/builder.js @@ -4,6 +4,7 @@ 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'; /** @@ -23,6 +24,7 @@ import { createField, createFieldGroup } from '../../utils/inspection-form-struc export default class InspectionFormBuilderComponent extends Component { @service inspectionFormActions; @service modalsManager; + @service resourceContextPanel; @service notifications; @service intl; @@ -116,22 +118,31 @@ export default class InspectionFormBuilderComponent extends Component { 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.modalsManager.show('modals/inspection-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 }), - acceptButtonText: this.intl.t('inspection.builder.save-field'), - acceptButtonIcon: 'check', - acceptButtonIconPrefix: 'fas', - declineButtonIcon: 'times', - declineButtonIconPrefix: 'fas', + size: 'xs', + panelContentClass: 'py-2 px-4', state, disabled: this.args.disabled, - confirm: (modal) => { + saveTask: inlineTask((resource, { overlay } = {}) => { this.applyField(group, state.field, isNew); - modal.done(); - }, + this.resourceContextPanel.close(overlay?.id); + }), }); } diff --git a/addon/components/inspection-form/form.hbs b/addon/components/inspection-form/form.hbs index b0c4ef2c5..f54ba8e94 100644 --- a/addon/components/inspection-form/form.hbs +++ b/addon/components/inspection-form/form.hbs @@ -5,19 +5,40 @@ - - {{smart-humanize type}} - +
+ +
+
{{option.label}}
+
{{option.description}}
+
+
+
- - {{smart-humanize status}} - - - - - {{smart-humanize frequency}} - +
+ +
+
{{option.label}}
+
{{option.description}}
+
+
+
@@ -25,6 +46,28 @@
+ +
+ {{#each this.settingOptions as |setting|}} + + + + {{/each}} +
+
+ @@ -39,16 +82,12 @@
{{n-a item.label}}
{{n-a item.category}}
- {{smart-humanize item.severity}} + {{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 index e3da137b9..c661ddaf6 100644 --- a/addon/components/inspection-form/form.js +++ b/addon/components/inspection-form/form.js @@ -1,9 +1,6 @@ import Component from '@glimmer/component'; import { action } from '@ember/object'; - -const TYPE_OPTIONS = ['dvir', 'safety', 'compliance', 'maintenance', 'pre_trip', 'post_trip']; -const STATUS_OPTIONS = ['draft', 'published', 'archived']; -const FREQUENCY_OPTIONS = ['daily', 'weekly', 'monthly', 'pre_trip', 'post_trip', 'ad_hoc']; +import { inject as service } from '@ember/service'; /** * The inspection form screen: what the form is, and what it is built from. @@ -14,11 +11,43 @@ const FREQUENCY_OPTIONS = ['daily', 'weekly', 'monthly', 'pre_trip', 'post_trip' * * Nothing writes to `@resource` during render — text inputs update from the * DOM event, and every other change arrives from an action. + * + * `frequency` is deliberately not offered. The column exists and the API still + * carries it, but nothing schedules an inspection from it, so a dropdown here + * would ask an author to answer a question the product does not yet act on. */ export default class InspectionFormFormComponent extends Component { - typeOptions = TYPE_OPTIONS; - statusOptions = STATUS_OPTIONS; - frequencyOptions = FREQUENCY_OPTIONS; + @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() { @@ -34,16 +63,16 @@ export default class InspectionFormFormComponent extends Component { this.args.resource.description = event.target.value; } - @action setType(type) { - this.args.resource.type = type; + @action setType(option) { + this.args.resource.type = option?.value ?? null; } - @action setStatus(status) { - this.args.resource.status = status; + @action setStatus(option) { + this.args.resource.status = option?.value ?? null; } - @action setFrequency(frequency) { - this.args.resource.frequency = frequency; + @action setSetting(key, event) { + this.args.resource.settings = { ...this.settings, [key]: event.target.checked }; } @action setStructure(groups) { @@ -51,8 +80,4 @@ export default class InspectionFormFormComponent extends Component { this.args.onStructureChange(groups); } } - - @action setSettings(settings) { - this.args.resource.settings = settings; - } } diff --git a/addon/components/inspection-submission/form.hbs b/addon/components/inspection-submission/form.hbs index f745753dc..e02c51ab4 100644 --- a/addon/components/inspection-submission/form.hbs +++ b/addon/components/inspection-submission/form.hbs @@ -18,9 +18,21 @@ - - {{smart-humanize status}} - +
+ +
+
{{option.label}}
+
{{option.description}}
+
+
+
- - diff --git a/addon/components/modals/inspection-field.js b/addon/components/modals/inspection-field.js deleted file mode 100644 index 7ba5ac276..000000000 --- a/addon/components/modals/inspection-field.js +++ /dev/null @@ -1,20 +0,0 @@ -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { action } from '@ember/object'; - -/** - * The builder's field editor, in a modal. - * - * The field is a plain object in the builder's draft; `options.state` is the - * handle both sides hold, so the builder's `confirm` callback reads back - * whatever the editor last produced without either side mutating the draft - * until the author accepts. - */ -export default class ModalsInspectionFieldComponent extends Component { - @tracked field = this.args.options.state.field; - - @action onChange(field) { - this.field = field; - this.args.options.state.field = field; - } -} diff --git a/addon/utils/fleet-ops-options.js b/addon/utils/fleet-ops-options.js index ab201b51e..db2be0428 100644 --- a/addon/utils/fleet-ops-options.js +++ b/addon/utils/fleet-ops-options.js @@ -216,6 +216,41 @@ export const fuelReportStatuses = [ { label: 'Reimbursed', value: 'reimbursed', description: 'Driver expense reimbursed' }, ]; +export const inspectionFormTypes = [ + { label: 'DVIR', value: 'dvir', description: "Driver vehicle inspection report — the daily walk-round a driver signs." }, + { label: 'Pre-Trip', value: 'pre_trip', description: 'Completed before the vehicle leaves.' }, + { label: 'Post-Trip', value: 'post_trip', description: 'Completed when the vehicle returns.' }, + { label: 'Pre-Operational', value: 'pre_operational', description: 'Checks performed before operation begins.' }, + { label: 'Post-Operational', value: 'post_operational', description: 'Checks performed after operation ends.' }, + { label: 'Safety Inspection', value: 'safety_inspection', description: 'Comprehensive safety and compliance inspection.' }, + { label: 'Maintenance Inspection', value: 'maintenance_inspection', description: 'Scheduled maintenance check and service inspection.' }, + { label: 'Damage Assessment', value: 'damage_assessment', description: 'Inspection to assess damage or condition issues.' }, + { label: 'Annual Inspection', value: 'annual_inspection', description: 'Yearly comprehensive vehicle inspection.' }, + { label: 'Safety', value: 'safety', description: 'General safety checklist.' }, + { label: 'Compliance', value: 'compliance', description: 'Regulatory or audit checklist.' }, + { label: 'Maintenance', value: 'maintenance', description: 'Workshop or technician checklist.' }, +]; + +export const inspectionFormStatuses = [ + { label: 'Draft', value: 'draft', description: 'Still being written. Drivers cannot see it.' }, + { label: 'Published', value: 'published', description: 'Available to drivers and to the public link.' }, + { label: 'Archived', value: 'archived', description: 'Retired. Kept for the records already filed against it.' }, +]; + +export const inspectionSeverities = [ + { label: 'Minor', value: 'low', description: 'Monitor it. Nothing stops.' }, + { label: 'Medium', value: 'medium', description: 'Book it in.' }, + { label: 'High', value: 'high', description: 'Unsafe. Needs attention before the next trip.' }, + { label: 'Critical', value: 'critical', description: 'Immobilise the vehicle.' }, +]; + +export const inspectionSubmissionStatuses = [ + { label: 'Draft', value: 'draft', description: 'Started but not filed.' }, + { label: 'Submitted', value: 'submitted', description: 'Filed by the driver or the console.' }, + { label: 'Needs Review', value: 'needs_review', description: 'Flagged for a supervisor to look at.' }, + { label: 'Resolved', value: 'resolved', description: 'Follow-up is complete.' }, +]; + export const workOrderStatuses = [ { label: 'Open', value: 'open', description: 'Work order has been created and is awaiting planning or assignment' }, { label: 'Scheduled', value: 'scheduled', description: 'Work has been planned for a specific service window' }, @@ -945,6 +980,11 @@ export default function fleetOpsOptions(key) { routingConstraintOptions, serviceTimePresets, importColumnMappings, + // Inspections + inspectionFormTypes, + inspectionFormStatuses, + inspectionSeverities, + inspectionSubmissionStatuses, }; return allOptions[key] ?? []; diff --git a/app/components/modals/inspection-field.js b/app/components/modals/inspection-field.js deleted file mode 100644 index 6ed255818..000000000 --- a/app/components/modals/inspection-field.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@fleetbase/fleetops-engine/components/modals/inspection-field'; diff --git a/translations/en-us.yaml b/translations/en-us.yaml index e4c2a7c93..97d276516 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -2767,6 +2767,14 @@ inspection: loading-structure: Loading form structure... no-structure: This form has no field groups yet. settings: Settings + type-placeholder: Select the kind of inspection this form is + status-placeholder: Select a status + setting-create-issue: Open a defect record when an item fails + setting-create-issue-help: A failed inspection files an issue against the vehicle, so the failure has somewhere to live. + setting-create-work-order: Open a work order when an item fails + setting-create-work-order-help: The failed items become a work order checklist for the workshop. Needs workshops enabled. + setting-require-signature: Require the driver to sign + setting-require-signature-help: The driver app will not submit without a signature. Add a signature field to the form instead if you want it in a particular place. legacy-checklist: Legacy Checklist legacy-checklist-help: The first cut's checklist, kept read-only. It is migrated into a "Checklist" group of pass/fail fields. create: Create inspection form From cc7fc5169b21748bed1eef5ccc0056631378a03a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 13:59:42 +0800 Subject: [PATCH 20/44] console: keep the builder's draft alive, and stop the group inputs losing focus Three from the second run: - Collapsing the Form Builder panel emptied the form. ContentPanel unrenders its body when closed, so the builder component was destroyed and the draft went with it; reopening rebuilt it from nothing. The draft now lives on the controller, which the collapse does not touch, and the builder renders @groups and reports changes through @onChange. The load is skipped when the controller already holds a structure, so reopening cannot overwrite unsaved edits with server state either. - Typing in a group's name or description lost focus after each keystroke. The iteration was unkeyed, so replacing the edited group object changed its identity and Glimmer rebuilt the input. Both loops are keyed on uuid, which every group and field is guaranteed. The two inputs also take their value once on insert rather than re-binding it, so a mid-word edit cannot move the caret to the end. - The settings checkbox description sat tight against its label; it now has mt-1. --- addon/components/inspection-form/builder.hbs | 8 +-- addon/components/inspection-form/builder.js | 52 ++++++++++++++----- addon/components/inspection-form/form.hbs | 4 +- .../inspection-forms/index/edit.hbs | 2 +- .../inspection-forms/index/new.hbs | 2 +- 5 files changed, 47 insertions(+), 21 deletions(-) diff --git a/addon/components/inspection-form/builder.hbs b/addon/components/inspection-form/builder.hbs index 35e4fda9d..64f46d3b3 100644 --- a/addon/components/inspection-form/builder.hbs +++ b/addon/components/inspection-form/builder.hbs @@ -10,14 +10,14 @@ {{else}}
- {{#each this.groups as |group groupIndex|}} + {{#each this.groups key="uuid" as |group groupIndex|}}
- + - +
@@ -43,7 +43,7 @@
- {{#each group.fields as |field fieldIndex|}} + {{#each group.fields key="uuid" as |field fieldIndex|}}
{{or field.label (t "inspection.builder.untitled-field")}}
diff --git a/addon/components/inspection-form/builder.js b/addon/components/inspection-form/builder.js index b6f779c52..fd5fb576c 100644 --- a/addon/components/inspection-form/builder.js +++ b/addon/components/inspection-form/builder.js @@ -1,5 +1,4 @@ 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'; @@ -11,12 +10,18 @@ import { createField, createFieldGroup } from '../../utils/inspection-form-struc * 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 builder holds the - * whole structure as a draft of plain objects and hands it up through - * `@onChange`; the controller posts it with the save that creates or updates - * the form, and `InspectionFormSync` writes it in one go. Plain objects rather - * than Ember Data records because the `inspection-form` model belongs to - * `@fleetbase/fleetops-data` and declares no structure attribute. + * 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. @@ -28,10 +33,13 @@ export default class InspectionFormBuilderComponent extends Component { @service notifications; @service intl; - @tracked groups = []; - 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(() => { @@ -49,21 +57,27 @@ export default class InspectionFormBuilderComponent extends Component { } @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.groups = yield this.inspectionFormActions.loadStructure(this.args.resource); + this.write(yield this.inspectionFormActions.loadStructure(this.args.resource)); } catch (error) { this.notifications.serverError(error); } } - /** The one place the draft is written, and the one place it is announced. */ + /** The one place the draft is announced. The controller stores it. */ write(groups) { - this.groups = groups; - if (typeof this.args.onChange === 'function') { this.args.onChange(groups); } @@ -73,6 +87,18 @@ export default class InspectionFormBuilderComponent extends Component { 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 })]); } diff --git a/addon/components/inspection-form/form.hbs b/addon/components/inspection-form/form.hbs index f54ba8e94..007739015 100644 --- a/addon/components/inspection-form/form.hbs +++ b/addon/components/inspection-form/form.hbs @@ -60,7 +60,7 @@ /> {{setting.label}} - {{setting.description}} + {{setting.description}} @@ -69,7 +69,7 @@ - + {{#if this.legacyItems.length}} diff --git a/addon/templates/maintenance/inspection-forms/index/edit.hbs b/addon/templates/maintenance/inspection-forms/index/edit.hbs index 66ed8b45c..4a6c5e5b4 100644 --- a/addon/templates/maintenance/inspection-forms/index/edit.hbs +++ b/addon/templates/maintenance/inspection-forms/index/edit.hbs @@ -6,6 +6,6 @@ @onPressCancel={{this.cancel}} @onOverlayReady={{fn (mut this.overlay)}} > - + diff --git a/addon/templates/maintenance/inspection-forms/index/new.hbs b/addon/templates/maintenance/inspection-forms/index/new.hbs index 0c871b039..bf5b379e9 100644 --- a/addon/templates/maintenance/inspection-forms/index/new.hbs +++ b/addon/templates/maintenance/inspection-forms/index/new.hbs @@ -6,6 +6,6 @@ @onPressCancel={{transition-to "maintenance.inspection-forms.index"}} @onOverlayReady={{fn (mut this.overlay)}} > - + From 85b146400c1b87f29cf443a912fe7266d80f10a0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 14:15:16 +0800 Subject: [PATCH 21/44] console: let the field editor save, and stack its two toggles The panel's Save was disabled for good. Without pojoResource the header falls through to `cannot-write @resource`, and the overlay carries a plain object rather than an Ember Data record, so the permission check had nothing to judge and denies by default. The field is a POJO, which is exactly what pojoResource is for. Required and editable now stack with spacing rather than sitting in a row. --- addon/components/inspection-field/form.hbs | 2 +- addon/components/inspection-form/builder.js | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/addon/components/inspection-field/form.hbs b/addon/components/inspection-field/form.hbs index b95754ce8..94342955d 100644 --- a/addon/components/inspection-field/form.hbs +++ b/addon/components/inspection-field/form.hbs @@ -25,7 +25,7 @@ -
+
diff --git a/addon/components/inspection-form/builder.js b/addon/components/inspection-form/builder.js index fd5fb576c..efc67c64b 100644 --- a/addon/components/inspection-form/builder.js +++ b/addon/components/inspection-form/builder.js @@ -163,6 +163,11 @@ export default class InspectionFormBuilderComponent extends Component { 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 } = {}) => { From 8a7638c557ed7dfaec4f6373f0294a12c1df4702 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 14:54:23 +0800 Subject: [PATCH 22/44] console: let a field's name read, with its actions below MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing one row with four buttons truncated every label to a few characters in a two- or three-column group — unreadable, and impossible to keep track of while building a form. The name takes its own line, with the required mark and the type pill beside it and the actions right-aligned underneath. The pill no longer truncates; the label is the only thing that gives way. --- addon/components/inspection-form/builder.hbs | 22 +++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/addon/components/inspection-form/builder.hbs b/addon/components/inspection-form/builder.hbs index 64f46d3b3..e558e2a15 100644 --- a/addon/components/inspection-form/builder.hbs +++ b/addon/components/inspection-form/builder.hbs @@ -44,19 +44,25 @@
{{#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")}}
-
- {{field.type}} -
{{#if field.required}} - * + * {{/if}} +
+ {{field.type}} +
-
-
From f8370d46826582d59e02337cc477e61495184eed Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 16:17:37 +0800 Subject: [PATCH 23/44] console: retire frequency, and show option labels rather than values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five things reported while testing inspection forms in the console. Publish stayed in the header after a form was published, so the only thing pressing it could do was report that the form was already published. It now appears only while the form can be published, and as `type="success"`. Generate Link moves the other way: it needs a published form, so it appears at the same moment Publish leaves. A field's machine name came from `dasherize`, which rewrites spaces and underscores and leaves everything else alone — "Sidewall condition, offside rear" became `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. It is now a real slug, with anything that is not a letter or a digit acting as a separator. `frequency` is gone. It was inherited metadata: a column, an attribute and a filter that nothing scheduled an inspection from. It is removed from the v1 resource, the model's fillable and filter params, the report schema, the index column and query param, the details panel and the create defaults. The database column is deliberately left in place — the create migration has already run on live instances, and a dead nullable column is cheaper than editing a migration mid-test. A later migration can drop it. Type rendered through `smart-humanize`, so a form of type `dvir` read "Dvir". Option lists already carry the labels, so this adds a table cell that reads them: `table/cell/fleet-ops-option` takes the list name from the column's `optionsKey` and falls back to humanizing anything with no matching option, so a retired value is still legible. The index Type column and the details panel's type and status now use it. The Created column's own bug is in fleetops-data, where the model hands out a raw `Date` — fleetbase/fleetops-data#77 fixes that. --- addon/components/inspection-field/form.js | 28 +++++++++++++++---- addon/components/inspection-form/details.hbs | 8 ++---- addon/components/inspection-form/form.js | 4 --- .../table/cell/fleet-ops-option.hbs | 20 +++++++++++++ .../maintenance/inspection-forms/index.js | 18 ++---------- .../inspection-forms/index/details.js | 11 ++++++-- .../maintenance/inspection-forms/index.js | 1 - addon/services/inspection-form-actions.js | 1 - app/components/table/cell/fleet-ops-option.js | 1 + .../src/Http/Resources/v1/InspectionForm.php | 1 - server/src/Models/InspectionForm.php | 3 +- .../Reporting/FleetOpsReportSchema.php | 1 - .../InspectionControllerContractsTest.php | 5 ++-- server/tests/InspectionFieldContractsTest.php | 2 +- server/tests/InspectionModelContractsTest.php | 2 +- translations/en-us.yaml | 1 - 16 files changed, 62 insertions(+), 45 deletions(-) create mode 100644 addon/components/table/cell/fleet-ops-option.hbs create mode 100644 app/components/table/cell/fleet-ops-option.js diff --git a/addon/components/inspection-field/form.js b/addon/components/inspection-field/form.js index 683032b0e..e1215755b 100644 --- a/addon/components/inspection-field/form.js +++ b/addon/components/inspection-field/form.js @@ -1,7 +1,6 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; -import { dasherize } from '@ember/string'; import { INSPECTION_FIELD_TYPES, INSPECTION_SEVERITIES, componentForFieldType, isOptionFieldType } from '../../utils/inspection-field-types'; /** @@ -89,23 +88,40 @@ export default class InspectionFieldFormComponent extends Component { } /** - * The label names the field; the machine name follows it until the author - * types one of their own, matching the platform's editor. + * 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 = dasherize((this.field.label ?? '').trim().toLowerCase()); + 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 ? dasherize(label.trim().toLowerCase()) : current, + name: follows ? this.slugify(label) : current, }); } @action setName(event) { - this.change({ name: dasherize(event.target.value.trim().toLowerCase()) }); + this.change({ name: this.slugify(event.target.value) }); } @action setDescription(event) { diff --git a/addon/components/inspection-form/details.hbs b/addon/components/inspection-form/details.hbs index 4240e7498..2d3d77284 100644 --- a/addon/components/inspection-form/details.hbs +++ b/addon/components/inspection-form/details.hbs @@ -7,15 +7,11 @@
{{t "inspection.form.status"}}
-
{{smart-humanize @resource.status}}
+
{{or (get-fleet-ops-option-label "inspectionFormStatuses" @resource.status) (smart-humanize @resource.status)}}
{{t "inspection.form.type"}}
-
{{smart-humanize @resource.type}}
-
-
-
{{t "inspection.form.frequency"}}
-
{{smart-humanize @resource.frequency}}
+
{{or (get-fleet-ops-option-label "inspectionFormTypes" @resource.type) (n-a (smart-humanize @resource.type))}}
{{t "inspection.form.fields"}}
diff --git a/addon/components/inspection-form/form.js b/addon/components/inspection-form/form.js index c661ddaf6..62164f766 100644 --- a/addon/components/inspection-form/form.js +++ b/addon/components/inspection-form/form.js @@ -11,10 +11,6 @@ import { inject as service } from '@ember/service'; * * Nothing writes to `@resource` during render — text inputs update from the * DOM event, and every other change arrives from an action. - * - * `frequency` is deliberately not offered. The column exists and the API still - * carries it, but nothing schedules an inspection from it, so a dropdown here - * would ask an author to answer a question the product does not yet act on. */ export default class InspectionFormFormComponent extends Component { @service intl; diff --git a/addon/components/table/cell/fleet-ops-option.hbs b/addon/components/table/cell/fleet-ops-option.hbs new file mode 100644 index 000000000..6d8c8ae42 --- /dev/null +++ b/addon/components/table/cell/fleet-ops-option.hbs @@ -0,0 +1,20 @@ +{{! + A table cell that shows an option's label rather than the value stored on + the record, so a Type column reads "DVIR" where the row holds "dvir". + + The column names the list it draws from: + + { valuePath: 'type', cellComponent: 'table/cell/fleet-ops-option', optionsKey: 'inspectionFormTypes' } + + A value with no matching option is humanized rather than blanked, so a row + written before an option was retired is still legible. +}} +
+ + {{#if @value}} + {{or (get-fleet-ops-option-label @column.optionsKey @value) (smart-humanize @value)}} + {{else}} + - + {{/if}} + +
diff --git a/addon/controllers/maintenance/inspection-forms/index.js b/addon/controllers/maintenance/inspection-forms/index.js index d60c227de..53d812903 100644 --- a/addon/controllers/maintenance/inspection-forms/index.js +++ b/addon/controllers/maintenance/inspection-forms/index.js @@ -6,14 +6,13 @@ export default class MaintenanceInspectionFormsIndexController extends Controlle @service inspectionFormActions; @service intl; - @tracked queryParams = ['status', 'type', 'frequency', 'page', 'limit', 'sort', 'query', 'public_id', 'created_at', 'updated_at']; + @tracked queryParams = ['status', 'type', 'page', 'limit', 'sort', 'query', 'public_id', 'created_at', 'updated_at']; @tracked page = 1; @tracked limit; @tracked sort = '-created_at'; @tracked public_id; @tracked status; @tracked type; - @tracked frequency; get actionButtons() { return [ @@ -43,8 +42,8 @@ export default class MaintenanceInspectionFormsIndexController extends Controlle { label: 'Type', valuePath: 'type', - cellComponent: 'table/cell/base', - humanize: true, + cellComponent: 'table/cell/fleet-ops-option', + optionsKey: 'inspectionFormTypes', resizable: true, sortable: true, filterable: true, @@ -61,17 +60,6 @@ export default class MaintenanceInspectionFormsIndexController extends Controlle filterParam: 'status', filterComponent: 'filter/string', }, - { - label: 'Frequency', - valuePath: 'frequency', - cellComponent: 'table/cell/base', - humanize: true, - resizable: true, - sortable: true, - filterable: true, - filterParam: 'frequency', - filterComponent: 'filter/string', - }, { label: 'Items', valuePath: 'item_count', resizable: true, sortable: false }, { label: this.intl.t('column.created-at'), valuePath: 'createdAt', sortParam: 'created_at', resizable: true, sortable: true, filterable: true, filterComponent: 'filter/date' }, { diff --git a/addon/controllers/maintenance/inspection-forms/index/details.js b/addon/controllers/maintenance/inspection-forms/index/details.js index 2dfd9b0d8..4cef722a9 100644 --- a/addon/controllers/maintenance/inspection-forms/index/details.js +++ b/addon/controllers/maintenance/inspection-forms/index/details.js @@ -8,10 +8,17 @@ export default class MaintenanceInspectionFormsIndexDetailsController extends Co @service hostRouter; @tracked overlay; + /** + * Publish disappears once the form is published — leaving it there invites + * an author to press a button that can only tell them it is already done. + * The public link needs a published form, so it appears at the same moment. + */ get actionButtons() { + const isPublished = this.model?.is_published === true || this.model?.status === 'published'; + return [ - { icon: 'check', fn: this.publish, text: 'Publish', permission: 'fleet-ops publish inspection-form' }, - { icon: 'link', fn: this.generateLink, text: 'Generate Link', permission: 'fleet-ops view inspection-form' }, + ...(isPublished ? [] : [{ icon: 'check', fn: this.publish, text: 'Publish', type: 'success', permission: 'fleet-ops publish inspection-form' }]), + ...(isPublished ? [{ icon: 'link', fn: this.generateLink, text: 'Generate Link', permission: 'fleet-ops view inspection-form' }] : []), { icon: 'edit', fn: this.edit, permission: 'fleet-ops update inspection-form' }, { icon: 'trash', fn: this.delete, type: 'danger', permission: 'fleet-ops delete inspection-form' }, ]; diff --git a/addon/routes/maintenance/inspection-forms/index.js b/addon/routes/maintenance/inspection-forms/index.js index 6d116d307..3ddda8c95 100644 --- a/addon/routes/maintenance/inspection-forms/index.js +++ b/addon/routes/maintenance/inspection-forms/index.js @@ -12,7 +12,6 @@ export default class MaintenanceInspectionFormsIndexRoute extends Route { public_id: { refreshModel: true }, status: { refreshModel: true }, type: { refreshModel: true }, - frequency: { refreshModel: true }, created_at: { refreshModel: true }, updated_at: { refreshModel: true }, }; diff --git a/addon/services/inspection-form-actions.js b/addon/services/inspection-form-actions.js index 140a0e2f1..6ab71d099 100644 --- a/addon/services/inspection-form-actions.js +++ b/addon/services/inspection-form-actions.js @@ -14,7 +14,6 @@ export default class InspectionFormActionsService extends ResourceActionService defaultAttributes: { type: 'dvir', status: 'draft', - frequency: 'daily', items: [], settings: { require_signature: true, diff --git a/app/components/table/cell/fleet-ops-option.js b/app/components/table/cell/fleet-ops-option.js new file mode 100644 index 000000000..2c6995c32 --- /dev/null +++ b/app/components/table/cell/fleet-ops-option.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/table/cell/fleet-ops-option'; diff --git a/server/src/Http/Resources/v1/InspectionForm.php b/server/src/Http/Resources/v1/InspectionForm.php index 8c6dd1097..db7ca58f1 100644 --- a/server/src/Http/Resources/v1/InspectionForm.php +++ b/server/src/Http/Resources/v1/InspectionForm.php @@ -37,7 +37,6 @@ public function toArray($request) 'description' => $this->description, 'type' => $this->type, 'status' => $this->status, - 'frequency' => $this->frequency, 'items' => data_get($this, 'items', []), 'grouped_fields' => array_map(fn (Category $group) => static::groupToArray($group, $internal), $this->grouped_fields), 'field_groups' => $this->whenLoaded('fieldGroups', fn () => $this->fieldGroups->map(fn (Category $group) => static::groupToArray($group, $internal, false))->values()->all()), diff --git a/server/src/Models/InspectionForm.php b/server/src/Models/InspectionForm.php index 1d3a9eadd..da5eed410 100644 --- a/server/src/Models/InspectionForm.php +++ b/server/src/Models/InspectionForm.php @@ -61,7 +61,7 @@ class InspectionForm extends Model protected $table = 'inspection_forms'; protected $publicIdType = 'inspection_form'; protected $searchableColumns = ['name', 'description', 'type', 'public_id']; - protected $filterParams = ['status', 'type', 'frequency', 'subject_type', 'subject_uuid']; + protected $filterParams = ['status', 'type', 'subject_type', 'subject_uuid']; protected $fillable = [ 'company_uuid', @@ -69,7 +69,6 @@ class InspectionForm extends Model 'description', 'type', 'status', - 'frequency', 'subject_type', 'subject_uuid', 'items', diff --git a/server/src/Support/Reporting/FleetOpsReportSchema.php b/server/src/Support/Reporting/FleetOpsReportSchema.php index 64cfb7820..dc83928ff 100644 --- a/server/src/Support/Reporting/FleetOpsReportSchema.php +++ b/server/src/Support/Reporting/FleetOpsReportSchema.php @@ -1048,7 +1048,6 @@ protected function createInspectionSubmissionsTable(): Table ->columns([ Column::make('name', 'string')->label('Form Name'), Column::make('type', 'string')->label('Form Type'), - Column::make('frequency', 'string')->label('Frequency'), ]), Relationship::hasAutoJoin('vehicle', 'vehicles') ->label('Vehicle') diff --git a/server/tests/InspectionControllerContractsTest.php b/server/tests/InspectionControllerContractsTest.php index 561132438..1125ded28 100644 --- a/server/tests/InspectionControllerContractsTest.php +++ b/server/tests/InspectionControllerContractsTest.php @@ -97,7 +97,7 @@ public function __call($method, $arguments) $schema = $connection->getSchemaBuilder(); $tables = [ - 'inspection_forms' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'description', 'type', 'status', 'frequency', 'subject_type', 'subject_uuid', 'items', 'settings', 'meta', 'published_at', 'created_by_uuid', 'updated_by_uuid'], + 'inspection_forms' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'description', 'type', 'status', 'subject_type', 'subject_uuid', 'items', 'settings', 'meta', 'published_at', 'created_by_uuid', 'updated_by_uuid'], 'inspection_links' => ['uuid', 'public_id', '_key', 'company_uuid', 'inspection_form_uuid', 'driver_uuid', 'vehicle_uuid', 'created_by_uuid', 'token_hash', 'status', 'single_use', 'expires_at', 'last_viewed_at', 'used_at', 'used_ip', 'used_user_agent', 'meta'], 'inspection_submissions' => ['uuid', 'public_id', '_key', 'company_uuid', 'inspection_form_uuid', 'vehicle_uuid', 'driver_uuid', 'submitted_by_uuid', 'issue_uuid', 'work_order_uuid', 'type', 'status', 'result', 'source', 'odometer', 'engine_hours', 'total_items', 'failed_items', 'started_at', 'submitted_at', 'resolved_at', 'location', 'signature', 'attachments', 'meta', 'created_by_uuid', 'updated_by_uuid'], 'inspection_item_results' => ['uuid', '_key', 'company_uuid', 'inspection_submission_uuid', 'issue_uuid', 'work_order_uuid', 'item_key', 'label', 'category', 'status', 'severity', 'passed', 'comments', 'photos', 'meta', 'created_by_uuid', 'updated_by_uuid'], @@ -463,7 +463,7 @@ function fleetOpsInspectionControllerRefusal(callable $call): ?JsonResponse fleetOpsInspectionControllerDatabase(); $controller = new InspectionController(); - $form = fleetOpsInspectionControllerForm(['subject_type' => Vehicle::class, 'subject_uuid' => 'vehicle-1', 'frequency' => 'daily', 'meta' => ['sla' => 'am']]); + $form = fleetOpsInspectionControllerForm(['subject_type' => Vehicle::class, 'subject_uuid' => 'vehicle-1', 'meta' => ['sla' => 'am']]); $draft = fleetOpsInspectionControllerForm(['status' => 'draft', 'published_at' => null]); $other = fleetOpsInspectionControllerForm(['company_uuid' => 'company-other']); @@ -471,7 +471,6 @@ function fleetOpsInspectionControllerRefusal(callable $call): ?JsonResponse expect($shown['id'])->toBe($form->public_id) ->and($shown['name'])->toBe('Pre-trip DVIR') ->and($shown['type'])->toBe('dvir') - ->and($shown['frequency'])->toBe('daily') ->and($shown['items'])->toHaveCount(2) ->and($shown['item_count'])->toBe(2) ->and($shown['settings'])->toBe(['create_issue_on_failure' => true, 'create_work_order_on_failure' => true]) diff --git a/server/tests/InspectionFieldContractsTest.php b/server/tests/InspectionFieldContractsTest.php index b7787d8d8..4f1120367 100644 --- a/server/tests/InspectionFieldContractsTest.php +++ b/server/tests/InspectionFieldContractsTest.php @@ -164,7 +164,7 @@ public function __call($method, $arguments) $schema = $connection->getSchemaBuilder(); $tables = [ - 'inspection_forms' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'description', 'type', 'status', 'frequency', 'subject_type', 'subject_uuid', 'items', 'settings', 'meta', 'published_at', 'created_by_uuid', 'updated_by_uuid'], + 'inspection_forms' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'description', 'type', 'status', 'subject_type', 'subject_uuid', 'items', 'settings', 'meta', 'published_at', 'created_by_uuid', 'updated_by_uuid'], 'inspection_submissions' => ['uuid', 'public_id', '_key', 'company_uuid', 'inspection_form_uuid', 'vehicle_uuid', 'driver_uuid', 'submitted_by_uuid', 'issue_uuid', 'work_order_uuid', 'type', 'status', 'result', 'source', 'odometer', 'engine_hours', 'total_items', 'failed_items', 'started_at', 'submitted_at', 'resolved_at', 'location', 'signature', 'attachments', 'meta', 'created_by_uuid', 'updated_by_uuid'], 'inspection_item_results' => ['uuid', '_key', 'company_uuid', 'inspection_submission_uuid', 'issue_uuid', 'work_order_uuid', 'item_key', 'label', 'category', 'status', 'severity', 'passed', 'comments', 'photos', 'meta', 'created_by_uuid', 'updated_by_uuid'], 'issues' => ['uuid', 'public_id', '_key', 'company_uuid', 'reported_by_uuid', 'assigned_to_uuid', 'vehicle_uuid', 'driver_uuid', 'order_uuid', 'issue_id', 'location', 'category', 'type', 'report', 'title', 'tags', 'priority', 'meta', 'resolved_at', 'status'], diff --git a/server/tests/InspectionModelContractsTest.php b/server/tests/InspectionModelContractsTest.php index 5fded0281..9de0f1607 100644 --- a/server/tests/InspectionModelContractsTest.php +++ b/server/tests/InspectionModelContractsTest.php @@ -87,7 +87,7 @@ public function __call($method, $arguments) $schema = $connection->getSchemaBuilder(); $tables = [ - 'inspection_forms' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'description', 'type', 'status', 'frequency', 'subject_type', 'subject_uuid', 'items', 'settings', 'meta', 'published_at', 'created_by_uuid', 'updated_by_uuid'], + 'inspection_forms' => ['uuid', 'public_id', '_key', 'company_uuid', 'name', 'description', 'type', 'status', 'subject_type', 'subject_uuid', 'items', 'settings', 'meta', 'published_at', 'created_by_uuid', 'updated_by_uuid'], 'inspection_links' => ['uuid', 'public_id', '_key', 'company_uuid', 'inspection_form_uuid', 'driver_uuid', 'vehicle_uuid', 'created_by_uuid', 'token_hash', 'status', 'single_use', 'expires_at', 'last_viewed_at', 'used_at', 'used_ip', 'used_user_agent', 'meta'], 'inspection_submissions' => ['uuid', 'public_id', '_key', 'company_uuid', 'inspection_form_uuid', 'vehicle_uuid', 'driver_uuid', 'submitted_by_uuid', 'issue_uuid', 'work_order_uuid', 'type', 'status', 'result', 'source', 'odometer', 'engine_hours', 'total_items', 'failed_items', 'started_at', 'submitted_at', 'resolved_at', 'location', 'signature', 'attachments', 'meta', 'created_by_uuid', 'updated_by_uuid'], 'inspection_item_results' => ['uuid', '_key', 'company_uuid', 'inspection_submission_uuid', 'issue_uuid', 'work_order_uuid', 'item_key', 'label', 'category', 'status', 'severity', 'passed', 'comments', 'photos', 'meta', 'created_by_uuid', 'updated_by_uuid'], diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 97d276516..a46717a3f 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -2757,7 +2757,6 @@ inspection: name-placeholder: Daily vehicle inspection type: Type status: Status - frequency: Frequency description: Description description-placeholder: What this inspection covers builder: Form Builder From 8f84da0f404cbb2db5f3953af98b72269b007ad1 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 17:15:53 +0800 Subject: [PATCH 24/44] Make an inspection read as a checklist, and keep its links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things reported while testing inspections in the console. **The sheet.** An inspection form rendered as a bag of inputs: groups were collapsible content panels, fields sat in a CSS grid, some had a border and some did not, and failing a check grew that grid cell — which stretched the row and left its neighbour floating in white space. It was not a form anyone would want to work down. It is now one sheet. Every field is a row of the same shape: what is being checked on the left, the control on the right, a full-width control under its label when it needs one. A failure opens its defect block below its own row, so answering one line can never change the shape of another. Groups are plain sections that are always open — a panel that shuts invites an inspector not to read a line they have to read — with the one number that matters in the header: what failed, or what is still owed. A running total sits at the foot, and an unsafe-to- operate answer is called out there. A failed row is marked down its own edge so a long sheet can be scanned. Pass / fail / not-applicable is one segmented control rather than three loose buttons. Toggles get their own row like everything else. Number fields have a placeholder and show their unit. All eleven field types are rendered here now rather than five of them being handed to the platform's `custom-field/input`, which brought its own label chrome and made the sheet read as two interleaved forms. One `inspection-sheet` component renders it everywhere — the console's submission form, the read-only record, and the public link — so the three cannot drift. The seeding and the `custom_field_values` payload moved to `utils/inspection-answers` for the same reason. Two modifiers replace `value={{...}}` on inputs that re-render on every keystroke: `sync-value` writes a value in only while the field is not being typed in, which is the focus bug from the form builder in another guise, and `when-changed` runs something on change but not on insert. **The public link opened a blank page.** The generated path was `/inspection?…`, which matches the *authenticated* `console/:slug` route. The public one is `/~/:slug`, a sibling of `console`, outside its chrome and its auth gate — the same route ledger's invoice links use. The path now carries the `~/`, and the page itself was rebuilt on the shared sheet instead of the pass-fail-only checklist it had. Uploads are off there: the file endpoint needs a session and a link does not have one, so the row says where a photo can be added rather than offering a button that would fail. **A generated link existed only as a toast.** It went to the clipboard and, once that was overwritten, there was no way to find out what had been handed out, to whom, or whether it still worked. Links are now listed — under the form's details and below the generate form — with the URL to copy again, the vehicle and driver it was for, when it was made, when it was last opened, whether it is active, expired, used or revoked, and a way to revoke one. That needs the link itself, which was stored only as a sha256. A new migration adds an encrypted `token` column beside the hash; `token_hash` is untouched and remains the unique index every public request resolves through. A link is a capability URL — one published form, once, for one vehicle, until it expires — not a credential, and being re-readable is how share links behave. Links minted before this are listed without a URL rather than pretending to have one. --- addon/components/inspection-field/input.hbs | 388 ++++++++----- addon/components/inspection-field/input.js | 114 +++- addon/components/inspection-field/value.hbs | 92 ++- addon/components/inspection-field/value.js | 18 +- addon/components/inspection-form/details.hbs | 4 + addon/components/inspection-link/list.hbs | 59 ++ addon/components/inspection-link/list.js | 94 ++++ addon/components/inspection-sheet.hbs | 34 ++ addon/components/inspection-sheet.js | 29 + addon/components/inspection-sheet/section.hbs | 27 + addon/components/inspection-sheet/section.js | 55 ++ .../inspection-submission/details.hbs | 14 +- .../components/inspection-submission/form.hbs | 238 ++++---- .../components/inspection-submission/form.js | 81 +-- addon/components/modals/inspection-link.hbs | 22 +- addon/components/public-inspection.hbs | 192 ++++--- addon/components/public-inspection.js | 139 ++--- addon/modifiers/sync-value.js | 26 + addon/modifiers/when-changed.js | 25 + addon/services/inspection-form-actions.js | 15 +- addon/styles/fleetops-engine.css | 531 ++++++++++++++++++ addon/utils/inspection-answers.js | 204 +++++++ app/components/inspection-link/list.js | 1 + app/components/inspection-sheet.js | 1 + app/components/inspection-sheet/section.js | 1 + app/modifiers/sync-value.js | 1 + app/modifiers/when-changed.js | 1 + ...0_000002_add_token_to_inspection_links.php | 39 ++ .../Internal/v1/InspectionFormController.php | 65 ++- .../src/Http/Resources/v1/InspectionLink.php | 55 ++ server/src/Models/InspectionLink.php | 50 ++ server/src/routes.php | 2 + .../InspectionControllerContractsTest.php | 4 +- server/tests/InspectionModelContractsTest.php | 2 +- translations/en-us.yaml | 49 ++ 35 files changed, 2104 insertions(+), 568 deletions(-) create mode 100644 addon/components/inspection-link/list.hbs create mode 100644 addon/components/inspection-link/list.js create mode 100644 addon/components/inspection-sheet.hbs create mode 100644 addon/components/inspection-sheet.js create mode 100644 addon/components/inspection-sheet/section.hbs create mode 100644 addon/components/inspection-sheet/section.js create mode 100644 addon/modifiers/sync-value.js create mode 100644 addon/modifiers/when-changed.js create mode 100644 addon/utils/inspection-answers.js create mode 100644 app/components/inspection-link/list.js create mode 100644 app/components/inspection-sheet.js create mode 100644 app/components/inspection-sheet/section.js create mode 100644 app/modifiers/sync-value.js create mode 100644 app/modifiers/when-changed.js create mode 100644 server/migrations/2026_09_10_000002_add_token_to_inspection_links.php create mode 100644 server/src/Http/Resources/v1/InspectionLink.php diff --git a/addon/components/inspection-field/input.hbs b/addon/components/inspection-field/input.hbs index 655391ede..22e9b64d9 100644 --- a/addon/components/inspection-field/input.hbs +++ b/addon/components/inspection-field/input.hbs @@ -1,151 +1,273 @@ -
- {{#if (eq this.field.type "pass-fail")}} -
-
-
-
- {{this.field.label}} - {{#if this.field.required}}*{{/if}} -
- {{#if this.field.description}} -
{{this.field.description}}
- {{/if}} - {{#if this.meta.instructions}} -
{{this.meta.instructions}}
- {{/if}} -
-
-
+{{! + One line of an inspection, being answered. + + Every field type renders the same row: what is being checked on the left, + the control on the right. A type that needs the full width (a note, a + photo, a signature) puts its control under the label instead. A failed + check opens its defect block *below* its own row, so answering one line + never changes the shape of another. +}} +
+
+
+
+ {{this.label}} + {{#if this.field.required}}{{/if}}
+ {{#if this.field.description}} +
{{this.field.description}}
+ {{/if}} + {{#if this.instructions}} +
{{this.instructions}}
+ {{/if}} + {{#if this.field.help_text}} +
{{this.field.help_text}}
+ {{/if}} +
- {{#if this.isFailed}} -
- -
- - {{t (concat "inspection.severity." severity)}} - -
-
- - - - - - + {{#if (eq this.field.type "pass-fail")}} +
+
+ + +
+
-
-
- {{t "inspection.answer.photos"}} - {{#if this.requiresPhoto}}*{{/if}} -
-
- {{#each this.answerPhotos as |photo index|}} -
- {{#if photo.url}} - {{or - {{else}} -
- {{/if}} - {{#unless @disabled}} -
+ {{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 "input")}} +
+ +
+ + {{else if (eq this.field.type "date-picker")}} +
+ +
+ + {{else if (eq this.field.type "date-time-input")}} +
+ +
+ + {{else if (eq this.field.type "radio-button")}} +
+ {{#if this.choiceOptions}} +
+ {{#each this.choiceOptions key="@index" as |option|}} + {{/each}} - {{#unless @disabled}} - - - {{t "inspection.answer.add-photo"}} - - - {{/unless}} - {{#if this.uploadProgress}} - {{round this.uploadProgress.progress}}% - {{/if}}
+ {{else}} + {{t "inspection.answer.no-options"}} + {{/if}} +
+ + {{else if (eq this.field.type "select")}} +
+
+ + {{option}} +
- {{/if}} -
- {{else if (eq this.field.type "signature")}} - - {{#if this.file.url}} - {{this.field.label}} - {{else if this.file.reference}} -
{{this.file.reference}}
- {{else}} -
{{t "inspection.answer.no-signature"}}
- {{/if}} - {{#unless @disabled}} -
- - - {{t "inspection.answer.upload-signature"}} - - - {{#if this.file.reference}} -
+ + {{else if (eq this.field.type "textarea")}} +
+ +
+ + {{else if (eq this.field.type "signature")}} +
+
+ {{#if this.file.url}} +
{{this.label}}
+ {{else if this.file.reference}} +
+ {{else}} + {{t "inspection.answer.no-signature"}} + {{/if}} + {{#if this.canUpload}} + + + {{t "inspection.answer.upload-signature"}} + + + {{#if this.file.reference}} +
- {{/unless}} - - {{else if (eq this.field.type "file-upload")}} - - {{#if this.file.url}} - {{or - {{else if this.file.reference}} -
{{this.file.reference}}
- {{else}} -
{{t "inspection.answer.no-photo"}}
- {{/if}} - {{#unless @disabled}} -
- - - {{t "inspection.answer.upload-photo"}} - - - {{#if this.file.reference}} -
+ + {{else if (eq this.field.type "file-upload")}} +
+
+ {{#if this.file.url}} +
{{or
+ {{else if this.file.reference}} +
+ {{else}} + {{t "inspection.answer.no-photo"}} + {{/if}} + {{#if this.canUpload}} + + + {{t "inspection.answer.upload-photo"}} + + + {{#if this.file.reference}} +
+
+ {{/if}} +
+ + {{#if this.isFailed}} +
+
+
+ {{t "inspection.answer.severity"}} +
+ + {{t (concat "inspection.severity." severity)}} + +
+
+
+ +
+
+ +
+ + {{t "inspection.answer.comments"}} + {{#if this.requiresComment}}{{/if}} + + +
+ +
+ + {{t "inspection.answer.photos"}} + {{#if this.requiresPhoto}}{{/if}} + +
+ {{#each this.answerPhotos key="reference" as |photo index|}} +
+ {{#if photo.url}} + {{or + {{else}} +
+ {{/if}} + {{#unless @disabled}} +
+ {{/each}} + {{#if this.canUpload}} + + + {{t "inspection.answer.add-photo"}} + + + {{else if this.uploadsBlocked}} + {{t "inspection.answer.uploads-unavailable"}} + {{/if}} + {{#if this.uploadProgress}} + {{round this.uploadProgress.progress}}% {{/if}}
- {{/unless}} - - {{else if (eq this.field.type "textarea")}} - - - - {{else if (eq this.field.type "number")}} - -
- - {{#if this.meta.unit}} - {{this.meta.unit}} - {{/if}}
-
- {{else if (eq this.field.type "boolean")}} - - - - {{else}} - +
{{/if}}
diff --git a/addon/components/inspection-field/input.js b/addon/components/inspection-field/input.js index 8f7671477..429c95ede 100644 --- a/addon/components/inspection-field/input.js +++ b/addon/components/inspection-field/input.js @@ -2,25 +2,27 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; -import { componentForFieldType, INSPECTION_SEVERITIES } from '../../utils/inspection-field-types'; +import { INSPECTION_SEVERITIES } from '../../utils/inspection-field-types'; +import { answerState, isUnsafeAnswer } from '../../utils/inspection-answers'; const PASS_FAIL_DEFAULT = { passed: true, not_applicable: false, severity: null, comments: '', photos: [], unsafe: false }; /** * One inspection field, being answered. * - * `pass-fail`, `signature` and the inspection flavour of `file-upload` are - * FleetOps' own and are rendered here; `textarea`, `number` and `boolean` are - * rendered here too, because the platform's custom-field type map has no - * component for them. Everything else is handed to the platform's - * `custom-field/input`, which is given a read-only view of this field's value - * so an edit opens with what was answered before. + * 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. */ @@ -28,21 +30,6 @@ export default class InspectionFieldInputComponent extends Component { severityOptions = INSPECTION_SEVERITIES; - constructor() { - super(...arguments); - - const field = this.args.field ?? {}; - const id = field.uuid ?? field.id; - - // The platform's input reads both the field and its current value off - // objects it expects to be models. A plain field with an `id`, and a - // subject that answers `get('custom_field_values')`, is all it touches. - this.delegatedField = { ...field, id, component: componentForFieldType(field.type) }; - this.delegatedSubject = { - get: (key) => (key === 'custom_field_values' ? [{ custom_field_uuid: id, value: this.args.value ?? null }] : undefined), - }; - } - get field() { return this.args.field ?? {}; } @@ -52,9 +39,74 @@ export default class InspectionFieldInputComponent extends Component { return meta && typeof meta === 'object' ? meta : {}; } - get colSpanClass() { - const colSpan = this.meta.colSpan; - return colSpan ? `col-span-${colSpan}` : ''; + /** 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; + } + + /** + * A control that needs the full width sits under its label instead of + * beside it: a note, a photo, a signature. + */ + get isStacked() { + return ['textarea', 'file-upload', 'signature'].includes(this.field.type); + } + + /** 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 ---------- @@ -90,6 +142,14 @@ export default class InspectionFieldInputComponent extends Component { 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)); @@ -144,8 +204,8 @@ export default class InspectionFieldInputComponent extends Component { this.emit(Boolean(value)); } - @action setDelegatedValue(value) { - this.emit(value); + @action setChoice(option) { + this.emit(option ?? null); } @action markPassed() { diff --git a/addon/components/inspection-field/value.hbs b/addon/components/inspection-field/value.hbs index 3ebfa4492..861c251ca 100644 --- a/addon/components/inspection-field/value.hbs +++ b/addon/components/inspection-field/value.hbs @@ -1,8 +1,21 @@ -
-
{{this.field.label}}
-
- {{#if this.isPassFail}} -
+{{! + One stored answer, read-only. + + The same row as `inspection-field/input`, so a submitted inspection reads + exactly like the sheet it was filled in on — the check on the left, what + was answered on the right, and a failure's detail beneath its own row. +}} +
+
+
+
{{this.label}}
+ {{#if this.field.description}} +
{{this.field.description}}
+ {{/if}} +
+ +
+ {{#if this.isPassFail}} {{t this.resultLabel}} {{#if this.answer.severity}} @@ -12,35 +25,52 @@ {{#if this.answer.unsafe}} {{t "inspection.answer.unsafe"}} {{/if}} -
+ + {{else if this.isFile}} + {{#if this.file.url}} + + {{or + + {{else if this.file.reference}} + {{this.file.reference}} + {{else}} + {{t "inspection.answer.unanswered"}} + {{/if}} + + {{else if this.isBoolean}} + {{if this.booleanValue (t "common.yes") (t "common.no")}} + + {{else}} + {{n-a @value}} + {{#if this.meta.unit}}{{this.meta.unit}}{{/if}} + {{/if}} +
+
+ + {{#if this.hasDefectDetail}} +
{{#if this.answer.comments}} -
{{this.answer.comments}}
+
+ {{t "inspection.answer.comments"}} +
{{this.answer.comments}}
+
{{/if}} {{#if this.photos.length}} -
- {{#each this.photos as |photo|}} - {{#if photo.url}} - - {{or - - {{else}} -
{{photo.reference}}
- {{/if}} - {{/each}} +
+ {{t "inspection.answer.photos"}} +
+ {{#each this.photos key="reference" as |photo|}} + {{#if photo.url}} + + {{or + + {{else}} +
+ {{/if}} + {{/each}} +
{{/if}} - {{else if this.isFile}} - {{#if this.file.url}} - - {{or - - {{else}} - {{n-a this.file.reference}} - {{/if}} - {{else if this.isBoolean}} - {{if this.booleanValue (t "common.yes") (t "common.no")}} - {{else}} - {{n-a @value}} - {{/if}} -
+
+ {{/if}}
diff --git a/addon/components/inspection-field/value.js b/addon/components/inspection-field/value.js index 0840d35cc..b700d6721 100644 --- a/addon/components/inspection-field/value.js +++ b/addon/components/inspection-field/value.js @@ -1,5 +1,7 @@ import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; import { INSPECTION_SEVERITIES } from '../../utils/inspection-field-types'; +import { answerState } from '../../utils/inspection-answers'; /** * One stored answer, read-only — what the record's Overview shows. @@ -11,6 +13,8 @@ import { INSPECTION_SEVERITIES } from '../../utils/inspection-field-types'; * writes. */ export default class InspectionFieldValueComponent extends Component { + @service intl; + get field() { return this.args.field ?? {}; } @@ -20,9 +24,17 @@ export default class InspectionFieldValueComponent extends Component { return meta && typeof meta === 'object' ? meta : {}; } - get colSpanClass() { - const colSpan = this.meta.colSpan; - return colSpan ? `col-span-${colSpan}` : ''; + 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); + } + + /** 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() { diff --git a/addon/components/inspection-form/details.hbs b/addon/components/inspection-form/details.hbs index 2d3d77284..ef3454ec9 100644 --- a/addon/components/inspection-form/details.hbs +++ b/addon/components/inspection-form/details.hbs @@ -28,6 +28,10 @@
+ + + + {{#if this.load.isRunning}}
diff --git a/addon/components/inspection-link/list.hbs b/addon/components/inspection-link/list.hbs new file mode 100644 index 000000000..c7f26297f --- /dev/null +++ b/addon/components/inspection-link/list.hbs @@ -0,0 +1,59 @@ + diff --git a/addon/components/inspection-link/list.js b/addon/components/inspection-link/list.js new file mode 100644 index 000000000..021595192 --- /dev/null +++ b/addon/components/inspection-link/list.js @@ -0,0 +1,94 @@ +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. + * + * `@reloadOn` is any value the caller changes when it has minted a link; the + * list reloads when it does. + */ +export default class InspectionLinkListComponent extends Component { + @service fetch; + @service notifications; + @service intl; + + @tracked links = []; + @tracked error = null; + + /** Which link is being revoked, so only its own button spins. */ + @tracked revokingId = 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; + } + + /** 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) })); + } 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')); + } + + @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..84f364363 --- /dev/null +++ b/addon/components/inspection-sheet.hbs @@ -0,0 +1,34 @@ +
+ {{#each this.groups key="uuid" as |group|}} + + {{/each}} + + {{#if this.summary.checks}} +
+
+ {{this.summary.passed}} + {{t "inspection.answer.pass"}} +
+
+ {{this.summary.failed}} + {{t "inspection.answer.fail"}} +
+
+ {{this.summary.notApplicable}} + {{t "inspection.answer.not-applicable"}} +
+ {{#if this.summary.outstanding}} +
+ {{this.summary.outstanding}} + {{t "inspection.record.outstanding"}} +
+ {{/if}} + {{#if this.summary.unsafe}} + + + {{t "inspection.answer.unsafe"}} + + {{/if}} +
+ {{/if}} +
diff --git a/addon/components/inspection-sheet.js b/addon/components/inspection-sheet.js new file mode 100644 index 000000000..10703ebde --- /dev/null +++ b/addon/components/inspection-sheet.js @@ -0,0 +1,29 @@ +import Component from '@glimmer/component'; +import { flattenFields } from '../utils/inspection-form-structure'; +import { summarize } from '../utils/inspection-answers'; + +/** + * An inspection form, being filled in. + * + * One component renders the sheet wherever it is answered — the console's + * submission screen and the public link a driver opens on a phone — so the + * two cannot drift apart. `@values` in, `@onChange` out; the screen that owns + * the answers holds them, and nothing is written here during render. + * + * Groups are plain sections, always open. They were collapsible panels, which + * hid the point of the exercise: an inspector has to read every line, and a + * panel that can be shut invites them not to. + */ +export default class InspectionSheetComponent extends Component { + 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 ?? {}); + } +} diff --git a/addon/components/inspection-sheet/section.hbs b/addon/components/inspection-sheet/section.hbs new file mode 100644 index 000000000..3e387eef1 --- /dev/null +++ b/addon/components/inspection-sheet/section.hbs @@ -0,0 +1,27 @@ +
+
+
+

{{this.title}}

+ {{#if @group.description}} +

{{@group.description}}

+ {{/if}} +
+ {{#if this.statusText}} +
{{this.statusText}}
+ {{/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/section.js b/addon/components/inspection-sheet/section.js new file mode 100644 index 000000000..8ca1303cd --- /dev/null +++ b/addon/components/inspection-sheet/section.js @@ -0,0 +1,55 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { summarize } from '../../utils/inspection-answers'; + +/** + * One group of an inspection, as a section of the sheet. + * + * The header carries the group's name and, on the right, the one number that + * matters while the sheet is being filled in: what has failed, or what is + * still owed. It is never a progress bar over answers that were pre-filled — + * a pass-fail row opens on Pass, so counting it as "answered" would report + * progress nobody made. + */ +export default class InspectionSheetSectionComponent 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 summary() { + return summarize(this.fields, this.args.values ?? {}); + } + + /** Nothing failed and nothing is owed. */ + get isComplete() { + return this.fields.length > 0 && this.summary.failed === 0 && this.summary.outstanding === 0; + } + + get statusText() { + const { failed, outstanding } = this.summary; + + if (failed) { + return this.intl.t('inspection.record.section-failed', { count: failed }); + } + + if (outstanding) { + return this.intl.t('inspection.record.section-outstanding', { count: outstanding }); + } + + if (!this.fields.length) { + return null; + } + + return this.intl.t('inspection.record.section-clear'); + } +} diff --git a/addon/components/inspection-submission/details.hbs b/addon/components/inspection-submission/details.hbs index 432fac426..99055bd9d 100644 --- a/addon/components/inspection-submission/details.hbs +++ b/addon/components/inspection-submission/details.hbs @@ -51,17 +51,9 @@
{{else if this.hasAnswers}} - {{#each this.groups as |group|}} - {{#if group.fields.length}} - -
- {{#each group.fields as |field|}} - - {{/each}} -
-
- {{/if}} - {{/each}} +
+ +
{{/if}} diff --git a/addon/components/inspection-submission/form.hbs b/addon/components/inspection-submission/form.hbs index e02c51ab4..c36c03198 100644 --- a/addon/components/inspection-submission/form.hbs +++ b/addon/components/inspection-submission/form.hbs @@ -1,109 +1,155 @@ -
- -
- - - {{form.name}} - - - -
- -
-
{{option.label}}
-
{{option.description}}
+{{! + An inspection being filled in, in the console. + + The details of the inspection and the form's own field groups are rendered + the same way — one row per line, label on the left, control on the right — + because to whoever is filling it in they are one sheet, not a settings + panel followed by a checklist. +}} +
+
+
+

{{t "inspection.record.details"}}

+
+ +
+
+
+
+
{{t "inspection.record.form"}}
+
{{t "inspection.record.form-help"}}
+
+
+ + {{form.name}} + +
+
+
+ +
+
+
+
{{t "inspection.record.status"}}
+
+
+
+ +
+
{{option.label}}
+
{{option.description}}
+
+
- +
+
+
+ +
+
+
+
{{t "inspection.record.vehicle"}}
+
+
+ + {{or vehicle.displayName vehicle.name vehicle.public_id}} + +
+
+
+ +
+
+
+
{{t "inspection.record.driver"}}
+
+
+ + {{or driver.name driver.public_id}} + +
- - - - {{or vehicle.displayName vehicle.name vehicle.public_id}} - - - - - {{or driver.name driver.public_id}} - - - - - - - - +
+ +
+
+
+
{{t "inspection.record.odometer"}}
+
+
+ +
+
+
+ +
+
+
+
{{t "inspection.record.engine-hours"}}
+
+
+ +
+
+
- +
{{#if this.load.isRunning}} - +
- +
{{else if this.hasStructure}} - {{#each this.groups as |group|}} - - {{#if group.description}} -
{{group.description}}
- {{/if}} -
- {{#each group.fields as |field|}} - - {{/each}} -
-
- {{/each}} - - {{#if this.passFailCount}} - -
- {{t "inspection.record.failed-of" failed=this.failedCount total=this.passFailCount}} -
-
- {{/if}} + {{else if @resource.form}} - -
{{t "inspection.record.form-has-no-fields"}}
-
+
+
{{t "inspection.record.form-has-no-fields"}}
+
+ {{else}} +
+
{{t "inspection.record.choose-a-form"}}
+
{{/if}} diff --git a/addon/components/inspection-submission/form.js b/addon/components/inspection-submission/form.js index 80f6b1fb0..7305dd394 100644 --- a/addon/components/inspection-submission/form.js +++ b/addon/components/inspection-submission/form.js @@ -5,20 +5,20 @@ import { action } from '@ember/object'; import { next } from '@ember/runloop'; import { task } from 'ember-concurrency'; import { flattenFields } from '../../utils/inspection-form-structure'; -import { valueTypeForFieldType } from '../../utils/inspection-field-types'; +import { answerRows, seedAnswers } from '../../utils/inspection-answers'; const STATUS_OPTIONS = ['draft', 'submitted', 'needs_review', 'resolved']; /** * An inspection being filled in. * - * The header names the form and what is being inspected; the rest is the - * selected form's field groups, each rendered as a panel of - * `inspection-field/input`. 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 and - * the app write the same thing and the item results are derived from the - * pass-fail answers among them. + * 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. @@ -52,14 +52,6 @@ export default class InspectionSubmissionFormComponent extends Component { return this.fields.length > 0; } - get failedCount() { - return this.fields.filter((field) => field.type === 'pass-fail' && this.values[field.uuid]?.passed === false && this.values[field.uuid]?.not_applicable !== true).length; - } - - get passFailCount() { - return this.fields.filter((field) => field.type === 'pass-fail').length; - } - @task *load(form) { this.groups = []; @@ -72,69 +64,16 @@ export default class InspectionSubmissionFormComponent extends Component { const stored = this.args.resource?.id && !this.args.resource?.isNew ? yield this.inspectionSubmissionActions.loadAnswers(this.args.resource) : {}; this.groups = groups; - this.values = this.seed(groups, stored); + this.values = seedAnswers(groups, stored); this.announce(); } catch (error) { this.notifications.serverError(error); } } - /** - * Every field starts with an answer, so a form saved untouched still files - * a complete set: a pass-fail field passes unless the inspector says - * otherwise, which is what the first cut did and what the app does. - */ - seed(groups, stored = {}) { - return flattenFields(groups).reduce((carry, field) => { - if (stored[field.uuid] !== undefined) { - carry[field.uuid] = stored[field.uuid]; - return carry; - } - - carry[field.uuid] = field.type === 'pass-fail' ? { passed: true, not_applicable: false, severity: null, comments: '', photos: [], unsafe: false } : null; - - return carry; - }, {}); - } - /** The answers, as the server accepts them. */ get rows() { - return this.fields.map((field) => ({ - custom_field: field.uuid, - value_type: valueTypeForFieldType(field.type), - value: this.serializeValue(field, this.values[field.uuid]), - })); - } - - /** - * A file value read back from the server arrives resolved to an object; on - * the way out it has to be a reference again, which is what the file's - * public id is — `InspectionFileStore::normalize()` resolves a `file_…` id - * back to `file:`. - */ - serializeValue(field, value) { - if (field.type === 'pass-fail') { - const answer = value && typeof value === 'object' ? value : { passed: true, not_applicable: false }; - - return { - ...answer, - photos: (Array.isArray(answer.photos) ? answer.photos : []).map((photo) => this.serializeFile(photo)).filter(Boolean), - }; - } - - if (field.type === 'file-upload' || field.type === 'signature') { - return this.serializeFile(value); - } - - return value; - } - - serializeFile(value) { - if (value && typeof value === 'object') { - return value.id ?? null; - } - - return typeof value === 'string' && value !== '' ? value : null; + return answerRows(this.fields, this.values); } announce() { diff --git a/addon/components/modals/inspection-link.hbs b/addon/components/modals/inspection-link.hbs index ab756bc69..c225624dd 100644 --- a/addon/components/modals/inspection-link.hbs +++ b/addon/components/modals/inspection-link.hbs @@ -1,9 +1,7 @@ diff --git a/addon/components/public-inspection.hbs b/addon/components/public-inspection.hbs index 46b021552..ad7500376 100644 --- a/addon/components/public-inspection.hbs +++ b/addon/components/public-inspection.hbs @@ -1,105 +1,133 @@ -
-
+{{! + An inspection filled in from a tokenised link. + + Rendered by the host console's top-level `virtual` route at + `/~/inspection?id=…&token=…`, which sits outside the console's shell and + outside its authentication gate — so this page brings its own full-height + background and its own header, and never assumes a signed-in user. +}} +
+
{{#if this.loadInspection.isRunning}} -
- -

Loading inspection...

-
- {{else if this.error}} -
- -

{{this.error}}

+
+
+ {{else if this.submission}} -
- -

Inspection Submitted

-

- Result: - - {{smart-humanize this.submission.result}} - -

-

Reference: {{this.submission.public_id}}

+
+ +

{{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}} -
-

FleetOps Inspection

-

{{this.form.name}}

+
+

{{this.form.name}}

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

{{this.form.description}}

+

{{this.form.description}}

{{/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}} +
+ -
-
-
-
Driver
-
{{or this.identity.driver.name this.identity.driver.id "Assigned by link"}}
+
+
+
+

{{t "inspection.record.details"}}

-
Vehicle
-
{{or this.identity.vehicle.name this.identity.vehicle.id "Assigned by link"}}
+
+
+
{{t "inspection.record.odometer"}}
+
+ +
+
+
+
+
+
{{t "inspection.record.engine-hours"}}
+
+ +
+
+
- - -
+
-
- {{#each this.itemResults as |item index|}} -
-
-
-

{{item.label}}

- {{#if item.category}} -

{{item.category}}

- {{/if}} + {{#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"}}
+
+
+ +
- {{smart-humanize item.status}} -
-
- -
- {{#unless item.passed}} - - {{else if (eq this.field.type "date-picker")}} -
- -
+
+ {{#each this.answerPhotos key="reference" as |photo index|}} +
+ {{#if photo.url}} + {{or + {{else}} + + {{/if}} + {{#unless @disabled}} +
+ {{/each}} - {{else if (eq this.field.type "date-time-input")}} -
- -
+ {{#if this.canUpload}} + + + + + {{/if}} - {{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 this.uploadProgress}} + {{round this.uploadProgress.progress}}% {{/if}} -
- {{else if (eq this.field.type "select")}} -
-
- - {{option}} - -
+ {{#if this.defectRequirement}} + {{this.defectRequirement}} + {{else if this.uploadsBlocked}} + {{t "inspection.answer.uploads-unavailable"}} + {{/if}}
+
+
- {{else if (eq this.field.type "textarea")}} -
- -
+{{else if this.isRoomy}} +
+
+ + {{this.label}} + {{#if this.field.required}}{{/if}} + - {{else if (eq this.field.type "signature")}} -
-
+ {{#unless this.isStackedBand}} +
{{#if this.file.url}} -
{{this.label}}
+ {{or {{else if this.file.reference}} -
+ {{else}} - {{t "inspection.answer.no-signature"}} - {{/if}} - {{#if this.canUpload}} - - - {{t "inspection.answer.upload-signature"}} - - - {{#if this.file.reference}} -
-
- {{else if (eq this.field.type "file-upload")}} -
-
- {{#if this.file.url}} -
{{or
- {{else if this.file.reference}} -
- {{else}} - {{t "inspection.answer.no-photo"}} - {{/if}} {{#if this.canUpload}} - {{t "inspection.answer.upload-photo"}} + {{this.uploadLabel}} {{#if this.file.reference}}
+ {{/unless}} +
+ + {{#if this.isStackedBand}} +
+ {{#if this.field.description}} + {{this.field.description}} + {{/if}} +
{{/if}}
- {{#if this.isFailed}} -
-
-
- {{t "inspection.answer.severity"}} -
- + + {{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|}} +
-
-
- + {{option.label}} + + {{/each}}
-
-
- - {{t "inspection.answer.comments"}} - {{#if this.requiresComment}}{{/if}} - - -
+ {{sync-value @value}} + {{on "input" this.setNumber}} + /> + {{#if this.unit}}{{this.unit}}{{/if}} -
- - {{t "inspection.answer.photos"}} - {{#if this.requiresPhoto}}{{/if}} - -
- {{#each this.answerPhotos key="reference" as |photo index|}} -
- {{#if photo.url}} - {{or - {{else}} -
- {{/if}} - {{#unless @disabled}} -
- {{/each}} - {{#if this.canUpload}} - - - {{t "inspection.answer.add-photo"}} - - - {{else if this.uploadsBlocked}} - {{t "inspection.answer.uploads-unavailable"}} - {{/if}} - {{#if this.uploadProgress}} - {{round this.uploadProgress.progress}}% - {{/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}} -
+
+{{/if}} diff --git a/addon/components/inspection-field/input.js b/addon/components/inspection-field/input.js index 429c95ede..8e4a98e99 100644 --- a/addon/components/inspection-field/input.js +++ b/addon/components/inspection-field/input.js @@ -3,7 +3,10 @@ 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 } from '../../utils/inspection-answers'; +import { answerState, isUnsafeAnswer, isPromoted, isBlank, 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 }; @@ -28,8 +31,6 @@ export default class InspectionFieldInputComponent extends Component { /** Freshly uploaded files, so a photo can be shown before it is saved. */ @tracked previews = {}; - severityOptions = INSPECTION_SEVERITIES; - get field() { return this.args.field ?? {}; } @@ -39,6 +40,79 @@ export default class InspectionFieldInputComponent extends Component { return meta && typeof meta === 'object' ? meta : {}; } + /** Whether this field has left the grid for a full-width band. */ + get isPromoted() { + return isPromoted(this.field, this.args.value); + } + + /** A failed check: the band that carries severity, comment and photos. */ + get isDefect() { + return this.field.type === 'pass-fail' && this.answerState === 'fail'; + } + + /** 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'); @@ -52,14 +126,6 @@ export default class InspectionFieldInputComponent extends Component { return this.meta.unit ?? null; } - /** - * A control that needs the full width sits under its label instead of - * beside it: a note, a photo, a signature. - */ - get isStacked() { - return ['textarea', 'file-upload', 'signature'].includes(this.field.type); - } - /** What this row currently says, for the row's own `data-answer`. */ get answerState() { return answerState(this.field, this.args.value); @@ -124,20 +190,6 @@ export default class InspectionFieldInputComponent extends Component { return { ...PASS_FAIL_DEFAULT }; } - get isPassed() { - const answer = this.answer; - return answer.passed === true && answer.not_applicable !== true; - } - - get isFailed() { - const answer = this.answer; - return answer.passed === false && answer.not_applicable !== true; - } - - get isNotApplicable() { - return this.answer.not_applicable === true; - } - get severity() { return this.answer.severity ?? this.meta.severity ?? 'medium'; } @@ -156,11 +208,11 @@ export default class InspectionFieldInputComponent extends Component { } get requiresComment() { - return this.isFailed && this.meta.require_comment_on_fail === true; + return this.isDefect && this.meta.require_comment_on_fail === true; } get requiresPhoto() { - return this.isFailed && this.meta.require_photo_on_fail === true; + return this.isDefect && this.meta.require_photo_on_fail === true; } // ---------- file / signature ---------- @@ -208,24 +260,34 @@ export default class InspectionFieldInputComponent extends Component { this.emit(option ?? null); } - @action markPassed() { - this.emit({ ...this.answer, passed: true, not_applicable: false, severity: null, unsafe: false }); - } + /** + * 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), + }); + + return; + } - @action markFailed() { 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), + passed: true, + not_applicable: choice === 'na', + severity: null, + unsafe: false, }); } - @action markNotApplicable() { - this.emit({ ...this.answer, passed: true, not_applicable: true, severity: null, unsafe: false }); - } - @action setSeverity(severity) { this.emit({ ...this.answer, severity }); } diff --git a/addon/components/inspection-field/value.hbs b/addon/components/inspection-field/value.hbs index 861c251ca..1430a827e 100644 --- a/addon/components/inspection-field/value.hbs +++ b/addon/components/inspection-field/value.hbs @@ -1,76 +1,80 @@ {{! One stored answer, read-only. - The same row as `inspection-field/input`, so a submitted inspection reads - exactly like the sheet it was filled in on — the check on the left, what - was answered on the right, and a failure's detail beneath its own row. + The same two shapes as `inspection-field/input` — a compact cell in the + group's grid, or a full-width band for a failure, a note, an upload or a + signature — so a submitted inspection reads exactly like the sheet it was + filled in on. }} -
-
-
-
{{this.label}}
- {{#if this.field.description}} -
{{this.field.description}}
- {{/if}} +{{#if this.isDefect}} +
+
+ {{t "inspection.answer.fail"}} + {{this.label}} + + {{#if this.severityLabel}}{{t this.severityLabel}}{{/if}} + {{#if this.answer.unsafe}}{{t "inspection.answer.unsafe"}}{{/if}} +
- -
- {{#if this.isPassFail}} - {{t this.resultLabel}} - {{#if this.answer.severity}} - - {{#if this.severityLabel}}{{t this.severityLabel}}{{else}}{{smart-humanize this.answer.severity}}{{/if}} - + {{#if this.hasDefectDetail}} +
+ {{#if this.answer.comments}} +

{{this.answer.comments}}

{{/if}} - {{#if this.answer.unsafe}} - {{t "inspection.answer.unsafe"}} - {{/if}} - - {{else if this.isFile}} - {{#if this.file.url}} - - {{or - - {{else if this.file.reference}} - {{this.file.reference}} - {{else}} - {{t "inspection.answer.unanswered"}} - {{/if}} - - {{else if this.isBoolean}} - {{if this.booleanValue (t "common.yes") (t "common.no")}} - - {{else}} - {{n-a @value}} - {{#if this.meta.unit}}{{this.meta.unit}}{{/if}} - {{/if}} -
-
- - {{#if this.hasDefectDetail}} -
- {{#if this.answer.comments}} -
- {{t "inspection.answer.comments"}} -
{{this.answer.comments}}
-
- {{/if}} - {{#if this.photos.length}} -
- {{t "inspection.answer.photos"}} -
+ {{#if this.photos.length}} +
{{#each this.photos key="reference" as |photo|}} {{#if photo.url}} - + {{or {{else}} -
+ {{/if}} {{/each}}
-
+ {{/if}} +
+ {{/if}} +
+ +{{else if this.isRoomy}} +
+
+ {{this.label}} + {{#unless this.isStackedBand}} + + {{#if this.file.url}} + + {{or + + {{else if this.file.reference}} + + {{else}} + {{t "inspection.answer.unanswered"}} + {{/if}} + + {{/unless}} +
+ {{#if this.isStackedBand}} +
+

{{n-a @value}}

+
+ {{/if}} +
+ +{{else}} +
+ {{this.label}} +
+ {{#if this.isPassFail}} + {{t this.resultLabel}} + {{else if this.isBoolean}} + {{if this.booleanValue (t "common.yes") (t "common.no")}} + {{else}} + {{n-a @value}} + {{#if this.meta.unit}}{{this.meta.unit}}{{/if}} {{/if}}
- {{/if}} -
+
+{{/if}} diff --git a/addon/components/inspection-field/value.js b/addon/components/inspection-field/value.js index b700d6721..16632aab3 100644 --- a/addon/components/inspection-field/value.js +++ b/addon/components/inspection-field/value.js @@ -1,7 +1,7 @@ import Component from '@glimmer/component'; import { inject as service } from '@ember/service'; import { INSPECTION_SEVERITIES } from '../../utils/inspection-field-types'; -import { answerState } from '../../utils/inspection-answers'; +import { answerState, isPromoted, ROOMY_FIELD_TYPES } from '../../utils/inspection-answers'; /** * One stored answer, read-only — what the record's Overview shows. @@ -32,6 +32,26 @@ export default class InspectionFieldValueComponent extends Component { return answerState(this.field, this.args.value); } + get isPromoted() { + return isPromoted(this.field, this.args.value); + } + + get isDefect() { + return this.isPassFail && this.answerState === 'fail'; + } + + get isRoomy() { + return ROOMY_FIELD_TYPES.includes(this.field.type); + } + + get isStackedBand() { + return this.field.type === 'textarea'; + } + + get isTargeted() { + return Boolean(this.field.uuid) && this.args.targetId === this.field.uuid; + } + /** A failure's comment and photos, shown only when there is something to show. */ get hasDefectDetail() { return this.isPassFail && (Boolean(this.answer.comments) || this.photos.length > 0); diff --git a/addon/components/inspection-link/list.hbs b/addon/components/inspection-link/list.hbs index c7f26297f..ec0015341 100644 --- a/addon/components/inspection-link/list.hbs +++ b/addon/components/inspection-link/list.hbs @@ -4,7 +4,7 @@
{{else if this.error}} -
{{this.error}}
+ {{else if this.hasLinks}}
{{#each this.links key="id" as |link|}} @@ -54,6 +54,6 @@ {{/each}}
{{else}} -
{{t "inspection.link.none"}}
+ {{/if}}
diff --git a/addon/components/inspection-sheet.hbs b/addon/components/inspection-sheet.hbs index 84f364363..e31fc8721 100644 --- a/addon/components/inspection-sheet.hbs +++ b/addon/components/inspection-sheet.hbs @@ -1,34 +1,60 @@
- {{#each this.groups key="uuid" as |group|}} - - {{/each}} +
+
+ {{#each this.groups key="uuid" as |group|}} + + {{/each}} +
- {{#if this.summary.checks}} -
-
- {{this.summary.passed}} - {{t "inspection.answer.pass"}} -
-
- {{this.summary.failed}} - {{t "inspection.answer.fail"}} -
-
- {{this.summary.notApplicable}} - {{t "inspection.answer.not-applicable"}} -
- {{#if this.summary.outstanding}} -
- {{this.summary.outstanding}} - {{t "inspection.record.outstanding"}} + {{#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}} - {{#if this.summary.unsafe}} - - - {{t "inspection.answer.unsafe"}} - - {{/if}} -
- {{/if}} + + {{#if this.summary.unsafeField}} +
+ {{t "inspection.answer.unsafe"}} + {{this.unsafeDescription}} + +
+ {{/if}} + + {{#if this.summary.firstOutstanding}} +
+ {{this.outstandingDescription}} + +
+ {{/if}} +
+ {{/if}} +
diff --git a/addon/components/inspection-sheet.js b/addon/components/inspection-sheet.js index 10703ebde..c84a9867e 100644 --- a/addon/components/inspection-sheet.js +++ b/addon/components/inspection-sheet.js @@ -1,20 +1,29 @@ 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 } from '../utils/inspection-answers'; +import { summarize, passFailAnswer } from '../utils/inspection-answers'; /** * An inspection form, being filled in. * * One component renders the sheet wherever it is answered — the console's - * submission screen and the public link a driver opens on a phone — so the - * two cannot drift apart. `@values` in, `@onChange` out; the screen that owns - * the answers holds them, and nothing is written here during render. + * 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. * - * Groups are plain sections, always open. They were collapsible panels, which - * hid the point of the exercise: an inspector has to read every line, and a - * panel that can be shut invites them not to. + * 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; + get groups() { return Array.isArray(this.args.groups) ? this.args.groups : []; } @@ -26,4 +35,60 @@ export default class InspectionSheetComponent extends Component { get summary() { return summarize(this.fields, this.args.values ?? {}); } + + get hasFields() { + return this.fields.length > 0; + } + + /** "Lights and indicators · High" — the failure, and how bad it is. */ + get unsafeDescription() { + const field = this.summary.unsafeField; + + if (!field) { + return null; + } + + const severity = passFailAnswer(this.args.values?.[field.uuid])?.severity; + const label = field.label || this.intl.t('inspection.builder.untitled-field'); + + if (!severity) { + return label; + } + + return `${label} · ${this.intl.t(`inspection.severity.${severity}`)}`; + } + + 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 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..cec797460 --- /dev/null +++ b/addon/components/inspection-sheet/group.hbs @@ -0,0 +1,57 @@ +
+
+ {{this.title}} + {{#if this.markers}} + + {{/if}} + {{this.meta}} +
+ + {{#if @group.description}} +

{{@group.description}}

+ {{/if}} + + {{#if this.fields}} + {{#if this.compactFields}} +
+ {{#each this.compactFields key="uuid" as |field|}} + {{#if @readonly}} + + {{else}} + + {{/if}} + {{/each}} +
+ {{/if}} + + {{#each this.promotedFields 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..6fdfb06e0 --- /dev/null +++ b/addon/components/inspection-sheet/group.js @@ -0,0 +1,93 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { summarize, isPromoted, fieldMarker } from '../../utils/inspection-answers'; + +const MAX_COLUMNS = 4; + +/** + * One group of an inspection form. + * + * The author's `grid_size` is honoured — but only for the fields that stay + * compact. A field that needs room is promoted out of the grid into a + * full-width band underneath it, which is what keeps one answer from changing + * the shape of another: after promotion there is no neighbouring cell left to + * stretch. + * + * 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 compactFields() { + return this.fields.filter((field) => !isPromoted(field, this.values[field.uuid])); + } + + /** Promoted fields keep the order they were authored in. */ + get promotedFields() { + return this.fields.filter((field) => isPromoted(field, this.values[field.uuid])); + } + + 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 line at the right of the header. What is outstanding takes + * precedence over how the group is laid out — an inspector needs the first + * far more often than the second. + */ + get meta() { + const outstanding = this.summary.outstanding; + + if (outstanding) { + return this.intl.t('inspection.record.section-outstanding', { count: outstanding }); + } + + const promoted = this.promotedFields.length; + + if (promoted) { + return this.intl.t('inspection.record.columns-and-promoted', { columns: this.columns, promoted }); + } + + return this.intl.t('inspection.record.columns', { columns: this.columns }); + } + + get hasOutstanding() { + return this.summary.outstanding > 0; + } +} diff --git a/addon/components/inspection-sheet/section.hbs b/addon/components/inspection-sheet/section.hbs deleted file mode 100644 index 3e387eef1..000000000 --- a/addon/components/inspection-sheet/section.hbs +++ /dev/null @@ -1,27 +0,0 @@ -
-
-
-

{{this.title}}

- {{#if @group.description}} -

{{@group.description}}

- {{/if}} -
- {{#if this.statusText}} -
{{this.statusText}}
- {{/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/section.js b/addon/components/inspection-sheet/section.js deleted file mode 100644 index 8ca1303cd..000000000 --- a/addon/components/inspection-sheet/section.js +++ /dev/null @@ -1,55 +0,0 @@ -import Component from '@glimmer/component'; -import { inject as service } from '@ember/service'; -import { summarize } from '../../utils/inspection-answers'; - -/** - * One group of an inspection, as a section of the sheet. - * - * The header carries the group's name and, on the right, the one number that - * matters while the sheet is being filled in: what has failed, or what is - * still owed. It is never a progress bar over answers that were pre-filled — - * a pass-fail row opens on Pass, so counting it as "answered" would report - * progress nobody made. - */ -export default class InspectionSheetSectionComponent 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 summary() { - return summarize(this.fields, this.args.values ?? {}); - } - - /** Nothing failed and nothing is owed. */ - get isComplete() { - return this.fields.length > 0 && this.summary.failed === 0 && this.summary.outstanding === 0; - } - - get statusText() { - const { failed, outstanding } = this.summary; - - if (failed) { - return this.intl.t('inspection.record.section-failed', { count: failed }); - } - - if (outstanding) { - return this.intl.t('inspection.record.section-outstanding', { count: outstanding }); - } - - if (!this.fields.length) { - return null; - } - - return this.intl.t('inspection.record.section-clear'); - } -} diff --git a/addon/components/inspection-submission/form.hbs b/addon/components/inspection-submission/form.hbs index ba5bd8859..3d4329ea7 100644 --- a/addon/components/inspection-submission/form.hbs +++ b/addon/components/inspection-submission/form.hbs @@ -84,21 +84,21 @@
{{#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/modals/inspection-link.hbs b/addon/components/modals/inspection-link.hbs index c225624dd..fce9df230 100644 --- a/addon/components/modals/inspection-link.hbs +++ b/addon/components/modals/inspection-link.hbs @@ -40,13 +40,9 @@
-
-
-
-

{{t "inspection.link.existing"}}

-
- -
+
diff --git a/addon/components/public-inspection.hbs b/addon/components/public-inspection.hbs index ad7500376..bbfc4fe8c 100644 --- a/addon/components/public-inspection.hbs +++ b/addon/components/public-inspection.hbs @@ -48,60 +48,60 @@
-
-
-

{{t "inspection.record.details"}}

-
-
-
-
-
{{t "inspection.record.odometer"}}
-
- -
+
+
+
+
+ {{t "inspection.record.details"}}
-
-
-
-
{{t "inspection.record.engine-hours"}}
-
- +
+
+ {{t "inspection.record.odometer"}} +
+ +
+
+
+ {{t "inspection.record.engine-hours"}} +
+ +
-
+
-
+
{{#if this.hasSheet}} {{else}}
-
-
{{t "inspection.record.form-has-no-fields"}}
-
+
+
{{t "inspection.record.form-has-no-fields"}}
+
{{/if}}
-
-
-

{{t "inspection.public.sign-off"}}

-
-
-
-
-
-
{{t "inspection.public.your-name"}}
-
{{t "inspection.public.your-name-help"}}
-
-
- +
+
+
+
+ {{t "inspection.public.sign-off"}} +
+
+
+ {{t "inspection.public.your-name"}} + {{t "inspection.public.your-name-help"}} +
+ +
-
+
- +
{{#if this.error}} diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css index 116cbaf07..fdfc18a4f 100644 --- a/addon/styles/fleetops-engine.css +++ b/addon/styles/fleetops-engine.css @@ -8885,256 +8885,356 @@ body[data-theme='dark'] .filter-multi-model > .clear-button { } /* ========================================================================== - Inspection sheet + Inspection sheet — "Promotion" -------------------------------------------------------------------------- - An inspection is a checklist, not a form: an inspector reads down it once - and answers every line. So every field is a row of the same shape — what is - being checked on the left, the control on the right — and the rows sit in - plain, always-open sections. Nothing collapses, nothing is in a grid, and - answering a row never changes the height of the row beside it, because - there is no row beside it. - - Used by the console's submission form and by the public link page, so a - driver filling this in from a phone and a manager filling it in from the - console are reading the same sheet. + The author's grid survives, but only for fields that stay compact. The + moment a field needs room — a pass-fail that failed and now owes a + severity, a comment and photos, or a note, upload or signature that never + fitted a column — it is promoted out of the grid into a full-width band at + the end of its group. Nothing stretches its neighbour, because after + promotion it has no neighbour. + + Each group header carries one dot per field, so an inspector can see at a + glance what is still open without reading a label. + + Rendered identically by the console's submission form, the read-only + record, and the public link a driver opens on a phone. ========================================================================== */ -/* - * The sheet sits inside an overlay panel that has its own edge. Without an - * inset its section borders land on that edge and read as a double rule. - */ -.inspection-sheet-inset { - padding: 0.75rem; -} - .inspection-sheet { - --inspection-border: #e5e7eb; - --inspection-border-strong: #d1d5db; - --inspection-surface: #fff; - --inspection-surface-sunken: #f9fafb; - --inspection-text: #111827; - --inspection-text-muted: #6b7280; - --inspection-accent: #2563eb; - --inspection-pass: #16a34a; - --inspection-fail: #dc2626; - --inspection-na: #6b7280; - --inspection-defect-border: #fecaca; - --inspection-defect-surface: #fef2f2; + --ins-bg: #fff; + --ins-bg-sunken: #f9fafb; + --ins-border: #e5e7eb; + --ins-border-strong: #d1d5db; + --ins-text: #111827; + --ins-text-soft: #374151; + --ins-text-muted: #6b7280; + --ins-text-faint: #9ca3af; + --ins-pass: #16a34a; + --ins-fail: #dc2626; + --ins-fail-text: #b91c1c; + --ins-fail-edge: #fecaca; + --ins-fail-fill: transparent; + --ins-na: #6b7280; + --ins-warn: #d97706; + --ins-warn-text: #92400e; + --ins-warn-strong: #b45309; + --ins-hatch-a: #e5e7eb; + --ins-hatch-b: #f3f4f6; + --ins-mono: ui-monospace, "IBM Plex Mono", sfmono-regular, menlo, monaco, consolas, monospace; display: flex; flex-direction: column; - gap: 1rem; -} - -body[data-theme='dark'] .inspection-sheet { - --inspection-border: #374151; - --inspection-border-strong: #4b5563; - --inspection-surface: #1f2937; /* - * Deliberately the same as the surface in dark. A gray-900 fill inside a - * gray-800 panel reads as a hole punched in the page; the border under a - * section header and the shadow on the card already say where one thing - * ends and the next begins. + * The sheet is measured, not the window. It renders in a ~600px overlay + * panel on a wide screen and full width on a phone, so a viewport media + * query would collapse the author's columns in exactly the wrong places. */ - --inspection-surface-sunken: #1f2937; - --inspection-text: #f9fafb; - --inspection-text-muted: #9ca3af; - --inspection-accent: #3b82f6; - --inspection-pass: #22c55e; - --inspection-fail: #ef4444; - --inspection-na: #9ca3af; - --inspection-defect-border: #7f1d1d; - --inspection-defect-surface: rgb(127 29 29 / 18%); + container-type: inline-size; } -/* --- sections ----------------------------------------------------------- */ - -.inspection-section { - border: 1px solid var(--inspection-border); +body[data-theme='dark'] .inspection-sheet { + --ins-bg: #1f2937; + --ins-bg-sunken: #1f2937; + --ins-border: #374151; + --ins-border-strong: #4b5563; + --ins-text: #f9fafb; + --ins-text-soft: #e5e7eb; + --ins-text-muted: #9ca3af; + --ins-text-faint: #6b7280; + --ins-pass: #16a34a; + --ins-fail: #dc2626; + --ins-fail-text: #fca5a5; + --ins-fail-edge: #4c2326; + --ins-fail-fill: #241b1e; + --ins-na: #9ca3af; + --ins-warn: #b45309; + --ins-warn-text: #fcd34d; + --ins-warn-strong: #fbbf24; + --ins-hatch-a: #374151; + --ins-hatch-b: #2b3644; +} + +/* The sheet is one card, not one card per group. */ +.inspection-sheet__body { + border: 1px solid var(--ins-border); border-radius: 0.5rem; - background-color: var(--inspection-surface); + background-color: var(--ins-bg); box-shadow: 0 1px 2px rgb(0 0 0 / 6%); overflow: hidden; } -body[data-theme='dark'] .inspection-section { +body[data-theme='dark'] .inspection-sheet__body { box-shadow: 0 1px 3px rgb(0 0 0 / 35%); } -.inspection-section__header { +.inspection-sheet__groups { + padding: 0.875rem 1rem 0; +} + +/* --- group header ------------------------------------------------------- */ + +.inspection-group__header { display: flex; - align-items: baseline; - justify-content: space-between; - gap: 1rem; - padding: 0.625rem 1rem; - border-bottom: 1px solid var(--inspection-border); - background-color: var(--inspection-surface-sunken); + align-items: center; + gap: 0.625rem; + padding-bottom: 0.5625rem; + border-bottom: 1px solid var(--ins-border); } -.inspection-section__title { +.inspection-group + .inspection-group .inspection-group__header { + padding-top: 1.125rem; +} + +.inspection-group__name { font-size: 0.75rem; - font-weight: 700; - letter-spacing: 0.04em; + font-weight: 600; + line-height: 1; + letter-spacing: 0.08em; text-transform: uppercase; - color: var(--inspection-text); - margin: 0; + color: var(--ins-text-soft); + min-width: 0; } -.inspection-section__description { - margin: 0.25rem 0 0; - font-size: 0.75rem; - line-height: 1.4; - color: var(--inspection-text-muted); +/* One dot per field: what is answered, what failed, what is still open. */ +.inspection-group__dots { + display: flex; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; } -.inspection-section__count { +.inspection-dot { + width: 7px; + height: 7px; + border-radius: 50%; + box-sizing: border-box; + background-color: var(--ins-text-faint); +} + +.inspection-dot[data-marker='pass'] { + background-color: var(--ins-pass); +} + +.inspection-dot[data-marker='fail'] { + background-color: var(--ins-fail); +} + +.inspection-dot[data-marker='na'] { + background-color: var(--ins-na); +} + +.inspection-dot[data-marker='outstanding'] { + background-color: var(--ins-warn-strong); +} + +.inspection-dot[data-marker='empty'] { + background-color: transparent; + border: 1px solid var(--ins-text-faint); +} + +.inspection-group__meta { + margin-left: auto; flex-shrink: 0; + font-family: var(--ins-mono); font-size: 0.6875rem; - font-variant-numeric: tabular-nums; - color: var(--inspection-text-muted); + line-height: 1; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--ins-text-faint); + white-space: nowrap; } -.inspection-section__count[data-complete='true'] { - color: var(--inspection-pass); - font-weight: 600; +.inspection-group__meta[data-outstanding='true'] { + color: var(--ins-warn-strong); } -.inspection-section__empty { - padding: 1rem; +.inspection-group__description { + margin: 0.5rem 0 0; font-size: 0.75rem; - color: var(--inspection-text-muted); + line-height: 1.45; + color: var(--ins-text-muted); } -/* --- rows --------------------------------------------------------------- */ +.inspection-group__empty { + padding: 0.75rem 0; + font-size: 0.75rem; + color: var(--ins-text-muted); +} -.inspection-row { - padding: 0.75rem 1rem; +/* --- the author's grid, for fields that stay compact -------------------- */ + +.inspection-group__grid { + display: grid; + gap: 0.625rem; + align-items: start; + padding-top: 0.75rem; } -.inspection-row + .inspection-row { - border-top: 1px solid var(--inspection-border); +.inspection-group__grid[data-columns='1'] { + grid-template-columns: minmax(0, 1fr); } -/* - * A failed check is marked down its own edge, so scanning a long sheet for - * what went wrong does not mean reading every label. - */ -.inspection-row[data-answer='fail'] { - box-shadow: inset 3px 0 0 var(--inspection-fail); +.inspection-group__grid[data-columns='2'] { + grid-template-columns: repeat(2, minmax(0, 1fr)); } -/* - * The label and the control share one line. The label takes the slack, so a - * long "Sidewall condition, offside rear" wraps in place instead of squeezing - * the control it belongs to. - */ -.inspection-row__main { - display: flex; - align-items: flex-start; +.inspection-group__grid[data-columns='3'] { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.inspection-group__grid[data-columns='4'] { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +/* A narrow sheet cannot hold the wider authored grids. */ +@container (width <= 660px) { + .inspection-group__grid[data-columns='3'], + .inspection-group__grid[data-columns='4'] { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@container (width <= 420px) { + .inspection-group__grid { + grid-template-columns: minmax(0, 1fr); + } + + .inspection-group__grid[data-columns='1'] .inspection-cell { + flex-direction: column; + align-items: stretch; + } + + .inspection-group__grid[data-columns='1'] .inspection-cell__control { + flex: 1 1 auto; + width: 100%; + min-width: 0; + } +} + +/* A one-column group reads as a list of rows, label beside control. */ +.inspection-group__grid[data-columns='1'] .inspection-cell { + flex-direction: row; + align-items: center; justify-content: space-between; - gap: 1rem; + gap: 0.75rem; } -.inspection-row__label { - flex: 1 1 auto; +.inspection-group__grid[data-columns='1'] .inspection-cell__control { + flex: 0 0 auto; + width: auto; + min-width: 12rem; +} + +/* --- a compact field --------------------------------------------------- */ + +.inspection-cell { + display: flex; + flex-direction: column; + gap: 0.5rem; min-width: 0; } -.inspection-row__title { +.inspection-cell__label { font-size: 0.8125rem; font-weight: 600; - line-height: 1.35; - color: var(--inspection-text); + line-height: 1.3; + color: var(--ins-text); } -.inspection-row__required { - color: var(--inspection-fail); +.inspection-required { + color: var(--ins-fail); margin-left: 0.125rem; } -.inspection-row__hint { - margin-top: 0.125rem; +.inspection-cell__hint { font-size: 0.75rem; line-height: 1.4; - color: var(--inspection-text-muted); + color: var(--ins-text-muted); white-space: pre-wrap; } -.inspection-row__control { - flex: 0 0 auto; +.inspection-cell__control { display: flex; align-items: center; gap: 0.5rem; - max-width: 100%; -} - -/* Typed controls get one width, so a column of them lines up. */ -.inspection-row__control--sized { - flex: 0 1 18rem; - width: 18rem; -} - -/* Anything that needs the full width sits under its label rather than beside it. */ -.inspection-row--stacked .inspection-row__main { - flex-direction: column; - align-items: stretch; - gap: 0.5rem; -} - -.inspection-row--stacked .inspection-row__control, -.inspection-row--stacked .inspection-row__control--sized { - flex: 1 1 auto; + min-width: 0; width: 100%; } -/* A select brings its own wrapper; let it take the control's width. */ -.inspection-row__control > .fleetbase-model-select, -.inspection-row__control > .ember-basic-dropdown { +.inspection-cell__control > .fleetbase-model-select, +.inspection-cell__control > .ember-basic-dropdown { flex: 1 1 auto; min-width: 0; } -.inspection-row__unit { +.inspection-unit { flex-shrink: 0; + font-family: var(--ins-mono); font-size: 0.75rem; - color: var(--inspection-text-muted); + color: var(--ins-text-muted); } -@media (width <= 640px) { - .inspection-row__main { - flex-direction: column; - align-items: stretch; - gap: 0.5rem; - } +.inspection-note { + font-size: 0.75rem; + color: var(--ins-text-faint); +} - .inspection-row__control, - .inspection-row__control--sized { - flex: 1 1 auto; - width: 100%; - } +/* Every control on the sheet is the same height, so a row of them lines up. */ +.inspection-sheet .form-input, +.inspection-sheet .form-select, +.inspection-sheet .ember-power-select-trigger { + height: 1.875rem; + min-height: 1.875rem; + font-size: 0.8125rem; } -/* --- the pass / fail / n-a control -------------------------------------- */ +.inspection-sheet textarea.form-input { + height: auto; + min-height: 3.5rem; + line-height: 1.5; +} + +/* A required answer that is still missing says so on its own edge. */ +.inspection-input--outstanding.form-input, +.inspection-input--outstanding .ember-power-select-trigger { + border-color: var(--ins-warn); +} + +/* --- the pass / fail / n-a and severity segmented controls -------------- */ .inspection-choice { - display: inline-flex; + display: flex; align-items: stretch; - border: 1px solid var(--inspection-border-strong); + height: 1.875rem; + border: 1px solid var(--ins-border); border-radius: 0.375rem; overflow: hidden; - background-color: var(--inspection-surface); + flex: 1 1 auto; + min-width: 0; +} + +/* On a promoted band the control is pushed to the right at its natural size. */ +.inspection-choice--auto { + flex: 0 0 auto; + margin-left: auto; } .inspection-choice__option { appearance: none; border: 0; background: transparent; - padding: 0.3125rem 0.75rem; + flex: 1 1 0; + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 0 0.5rem; font-size: 0.75rem; - font-weight: 600; - line-height: 1.25; - color: var(--inspection-text-muted); + font-weight: 500; + line-height: 1; + color: var(--ins-text-muted); cursor: pointer; white-space: nowrap; transition: @@ -9142,17 +9242,21 @@ body[data-theme='dark'] .inspection-section { color 0.12s ease; } +.inspection-choice--auto .inspection-choice__option { + flex: 0 0 auto; + padding: 0 0.75rem; +} + .inspection-choice__option + .inspection-choice__option { - border-left: 1px solid var(--inspection-border-strong); + border-left: 1px solid var(--ins-border); } .inspection-choice__option:hover:not(:disabled) { - color: var(--inspection-text); - background-color: var(--inspection-surface-sunken); + color: var(--ins-text); } .inspection-choice__option:focus-visible { - outline: 2px solid var(--inspection-accent); + outline: 2px solid #2563eb; outline-offset: -2px; } @@ -9162,138 +9266,350 @@ body[data-theme='dark'] .inspection-section { } .inspection-choice__option[aria-pressed='true'] { + font-weight: 600; color: #fff; + background-color: var(--ins-na); } .inspection-choice__option[aria-pressed='true'][data-answer='pass'] { - background-color: var(--inspection-pass); + background-color: var(--ins-pass); } -.inspection-choice__option[aria-pressed='true'][data-answer='fail'] { - background-color: var(--inspection-fail); +.inspection-choice__option[aria-pressed='true'][data-answer='fail'], +.inspection-choice__option[aria-pressed='true'][data-answer='severity'] { + background-color: var(--ins-fail); } -.inspection-choice__option[aria-pressed='true'][data-answer='na'] { - background-color: var(--inspection-na); +/* --- a promoted band ---------------------------------------------------- */ + +.inspection-band { + margin-top: 0.625rem; + border: 1px solid var(--ins-border); + border-radius: 0.375rem; + overflow: hidden; } -/* --- the defect block a failed row opens -------------------------------- */ +.inspection-band__head { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.6875rem 0.75rem; +} -.inspection-row__detail { - margin-top: 0.75rem; - border: 1px solid var(--inspection-defect-border); - border-radius: 0.5rem; - background-color: var(--inspection-defect-surface); +.inspection-band__label { + font-size: 0.8125rem; + font-weight: 600; + line-height: 1.3; + color: var(--ins-text); + min-width: 0; +} + +.inspection-band__aside { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.5625rem; + flex-shrink: 0; +} + +.inspection-band__body { padding: 0.75rem; display: flex; flex-direction: column; - gap: 0.75rem; + gap: 0.625rem; +} + +.inspection-band--stacked .inspection-band__body { + padding-top: 0; +} + +/* A failure keeps the same band, in red, with its own detail below. */ +.inspection-band--defect { + border-color: var(--ins-fail); + background-color: var(--ins-fail-fill); +} + +.inspection-band--defect .inspection-band__head { + border-bottom: 1px solid var(--ins-fail-edge); + padding-right: 0.75rem; +} + +.inspection-chip { + flex-shrink: 0; + font-family: var(--ins-mono); + font-size: 0.625rem; + font-weight: 700; + line-height: 1; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ins-fail-text); + border: 1px solid var(--ins-fail-edge); + border-radius: 0.1875rem; + padding: 0.25rem 0.3125rem; } -.inspection-detail__grid { +.inspection-defect__row { display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 0.75rem; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.625rem; + align-items: center; } -@media (width >= 768px) { - .inspection-detail__grid { - grid-template-columns: minmax(0, 14rem) minmax(0, 1fr); - align-items: end; +@container (width <= 560px) { + .inspection-defect__row { + grid-template-columns: minmax(0, 1fr); } } -.inspection-detail__label { - display: block; - margin-bottom: 0.25rem; - font-size: 0.6875rem; +/* The unsafe switch is a pill, not a bare toggle: it is the gravest thing here. */ +.inspection-unsafe { + display: flex; + align-items: center; + gap: 0.4375rem; + height: 1.875rem; + padding: 0 0.625rem; + border: 1px solid var(--ins-fail); + border-radius: 0.375rem; + font-size: 0.75rem; font-weight: 600; - letter-spacing: 0.03em; - text-transform: uppercase; - color: var(--inspection-text-muted); + color: var(--ins-fail-text); + white-space: nowrap; +} + +.inspection-unsafe[data-on='true'] { + background-color: var(--ins-fail-fill); } -.inspection-detail__photos { +/* --- photo slots -------------------------------------------------------- */ + +.inspection-slots { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; } -.inspection-photo { +.inspection-slot { + display: block; position: relative; - height: 4rem; - width: 4rem; - border-radius: 0.375rem; - border: 1px solid var(--inspection-border); + width: 60px; + height: 44px; + border-radius: 0.3125rem; + border: 1px solid var(--ins-border); overflow: hidden; - background-color: var(--inspection-surface-sunken); + flex-shrink: 0; + background: repeating-linear-gradient(45deg, var(--ins-hatch-a) 0 6px, var(--ins-hatch-b) 6px 12px); } -.inspection-photo img { - height: 100%; +.inspection-slot img { + display: block; width: 100%; + height: 100%; object-fit: cover; } -.inspection-photo__placeholder { +.inspection-slot__icon { display: flex; - height: 100%; width: 100%; + height: 100%; align-items: center; justify-content: center; - color: var(--inspection-text-muted); + color: var(--ins-text-faint); } -/* --- the running total at the foot of the sheet ------------------------- */ +.inspection-slot__remove { + position: absolute; + top: 0; + right: 0; +} -.inspection-summary { +/* The empty slot invites the next photo rather than sitting as a button. */ +.inspection-slot--add { display: flex; - flex-wrap: wrap; align-items: center; - gap: 0.75rem 1.5rem; - border: 1px solid var(--inspection-border); - border-radius: 0.5rem; - background-color: var(--inspection-surface-sunken); - padding: 0.75rem 1rem; + justify-content: center; + border: 1px dashed var(--ins-border-strong); + background: none; + cursor: pointer; + font-size: 1.125rem; + line-height: 1; + color: var(--ins-text-faint); + padding: 0; +} + +.inspection-slot--add:hover { + color: var(--ins-text); + border-color: var(--ins-text-faint); } -.inspection-summary__item { +.inspection-slots__note { + margin-left: auto; + text-align: right; + font-family: var(--ins-mono); + font-size: 0.6875rem; + line-height: 1.4; + text-transform: uppercase; + color: var(--ins-text-faint); +} + +/* --- the foot ----------------------------------------------------------- */ + +.inspection-foot { + border-top: 1px solid var(--ins-border); + background-color: var(--ins-bg-sunken); + padding: 1rem; display: flex; - align-items: baseline; - gap: 0.375rem; - font-size: 0.75rem; - color: var(--inspection-text-muted); + flex-direction: column; + gap: 0.625rem; } -.inspection-summary__value { - font-size: 0.875rem; +.inspection-tallies { + display: flex; + align-items: stretch; + gap: 0.5rem; +} + +.inspection-tally { + flex: 1 1 0; + min-width: 0; + background-color: var(--ins-bg); + border: 1px solid var(--ins-border); + border-left: 3px solid var(--ins-na); + border-radius: 0.375rem; + padding: 0.625rem 0.75rem; + display: flex; + flex-direction: column; + gap: 0.3125rem; +} + +.inspection-tally[data-kind='passed'] { + border-left-color: var(--ins-pass); +} + +.inspection-tally[data-kind='failed'] { + border-left-color: var(--ins-fail); +} + +.inspection-tally[data-kind='outstanding'] { + border-left-color: var(--ins-warn); +} + +.inspection-tally[data-kind='outstanding'][data-any='true'] { + border-color: var(--ins-warn); + border-left-color: var(--ins-warn); +} + +.inspection-tally__value { + font-size: 1.25rem; font-weight: 700; + line-height: 1; font-variant-numeric: tabular-nums; - color: var(--inspection-text); + color: var(--ins-text); } -.inspection-summary__value--fail { - color: var(--inspection-fail); +.inspection-tally[data-kind='outstanding'][data-any='true'] .inspection-tally__value { + color: var(--ins-warn-text); } -.inspection-summary__value--pass { - color: var(--inspection-pass); +.inspection-tally__label { + font-size: 0.6875rem; + font-weight: 500; + line-height: 1; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ins-text-muted); } -.inspection-summary__unsafe { - margin-left: auto; - display: inline-flex; +.inspection-tally[data-kind='outstanding'][data-any='true'] .inspection-tally__label { + color: var(--ins-warn-strong); +} + +.inspection-banner { + display: flex; align-items: center; - gap: 0.375rem; - border-radius: 9999px; - background-color: var(--inspection-fail); - color: #fff; - padding: 0.1875rem 0.625rem; + gap: 0.625rem; + padding: 0.6875rem 0.75rem; + border: 1px solid var(--ins-warn); + border-radius: 0.375rem; + background-color: var(--ins-bg); + font-size: 0.75rem; + line-height: 1.4; + color: var(--ins-warn-text); +} + +.inspection-banner--unsafe { + border-color: var(--ins-fail); + background-color: var(--ins-fail-fill); + color: var(--ins-fail-text); +} + +body[data-theme='dark'] .inspection-banner--unsafe { + background-color: #3f1d1d; +} + +.inspection-banner__chip { + flex-shrink: 0; + font-family: var(--ins-mono); font-size: 0.6875rem; font-weight: 700; + line-height: 1; + letter-spacing: 0.05em; text-transform: uppercase; - letter-spacing: 0.04em; + color: #fff; + background-color: var(--ins-fail); + border-radius: 0.25rem; + padding: 0.3125rem 0.4375rem; +} + +.inspection-banner__jump { + appearance: none; + border: 0; + background: none; + margin-left: auto; + flex-shrink: 0; + padding: 0; + font-size: 0.75rem; + font-weight: 600; + color: inherit; + cursor: pointer; + white-space: nowrap; +} + +.inspection-banner__jump:hover { + text-decoration: underline; +} + +/* A jumped-to field is held for a moment so the eye can find it. */ +.inspection-cell--targeted, +.inspection-band--targeted { + animation: inspection-target 1.6s ease-out; +} + +@keyframes inspection-target { + 0%, + 60% { + box-shadow: 0 0 0 2px var(--ins-warn); + } + + 100% { + box-shadow: 0 0 0 2px transparent; + } +} + +@media (prefers-reduced-motion: reduce) { + .inspection-cell--targeted, + .inspection-band--targeted { + animation: none; + box-shadow: 0 0 0 2px var(--ins-warn); + } +} + +/* + * The sheet sits inside an overlay panel that has its own edge. Without an + * inset its borders land on that edge and read as a double rule. + */ +.inspection-sheet-inset { + padding: 0.75rem; } /* ========================================================================== @@ -9320,6 +9636,38 @@ body[data-theme='dark'] .inspection-link-list { --inspection-text-muted: #9ca3af; } +.inspection-link-list__empty { + padding: 0.75rem; + font-size: 0.75rem; + color: var(--inspection-text-muted); +} + +/* The modal shows the list inside its own bordered block. */ +.inspection-link-panel { + border: 1px solid var(--inspection-border, #e5e7eb); + border-radius: 0.5rem; + overflow: hidden; +} + +body[data-theme='dark'] .inspection-link-panel { + border-color: #374151; +} + +.inspection-link-panel__header { + padding: 0.5rem 0.625rem; + border-bottom: 1px solid var(--inspection-border, #e5e7eb); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #374151; +} + +body[data-theme='dark'] .inspection-link-panel__header { + border-bottom-color: #374151; + color: #e5e7eb; +} + .inspection-link { display: flex; flex-direction: column; @@ -9436,6 +9784,16 @@ body[data-theme='dark'] .inspection-link__url { color: var(--inspection-text); } -a.inspection-photo { - display: block; +/* A stored answer, read back: the value sits where the control was. */ +.inspection-answer { + margin: 0; + font-size: 0.8125rem; + line-height: 1.5; + color: var(--ins-text-soft); + white-space: pre-wrap; + min-width: 0; +} + +.inspection-slot--add { + text-decoration: none; } diff --git a/addon/utils/inspection-answers.js b/addon/utils/inspection-answers.js index 0bc3a0f11..2e1b1d309 100644 --- a/addon/utils/inspection-answers.js +++ b/addon/utils/inspection-answers.js @@ -104,6 +104,8 @@ export function summarize(fields = [], values = {}) { missingRequired: 0, incompleteDefects: 0, unsafe: false, + unsafeField: null, + firstOutstanding: null, }; for (const field of fields) { @@ -130,8 +132,18 @@ export function summarize(fields = [], values = {}) { summary.incompleteDefects += 1; } + // The banners name the field rather than counting it, so an inspector + // is told what to go and fix, not how many things are wrong. + if (!summary.firstOutstanding && fieldMarker(field, value) === 'outstanding') { + summary.firstOutstanding = field; + } + if (isUnsafeAnswer(field, value)) { summary.unsafe = true; + + if (!summary.unsafeField) { + summary.unsafeField = field; + } } } diff --git a/app/components/inspection-sheet/section.js b/app/components/inspection-sheet/group.js similarity index 71% rename from app/components/inspection-sheet/section.js rename to app/components/inspection-sheet/group.js index 58dcd8e95..f8190b170 100644 --- a/app/components/inspection-sheet/section.js +++ b/app/components/inspection-sheet/group.js @@ -1 +1 @@ -export { default } from '@fleetbase/fleetops-engine/components/inspection-sheet/section'; +export { default } from '@fleetbase/fleetops-engine/components/inspection-sheet/group'; diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 69fc4eafc..68adb37cc 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -2872,6 +2872,11 @@ inspection: text-placeholder: Type an answer no-options: This field has no answers to choose from. uploads-unavailable: Photos can be added from the console or the driver app. + promoted: Promoted + comments-required: Say what is wrong, and what it needs + comment-required: Comment mandatory on fail + photo-required: Photo mandatory on fail + comment-and-photo-required: Comment + photo mandatory on fail record: overview: Overview inspection: Inspection @@ -2912,9 +2917,12 @@ inspection: choose-a-form: Choose an inspection form to begin. group-has-no-fields: This section has no checks. outstanding: still to answer - section-failed: "{count, plural, one {# failed} other {# failed}}" - section-outstanding: "{count, plural, one {# to answer} other {# to answer}}" - section-clear: All clear + section-outstanding: "{count, plural, one {# outstanding} other {# outstanding}}" + columns: "{columns, plural, =1 {1 col} other {# col}}" + columns-and-promoted: "{columns, plural, =1 {1 col} other {# col}} · {promoted, plural, one {# promoted} other {# promoted}}" + outstanding-field: "{label} is required before submitting" + review: "Review →" + jump-to: "Jump to →" link: public-links: Public Links existing: Generated Links From 9fa0d8b5244c3ff36e4458ffd1f00133951c1ff6 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 19:25:12 +0800 Subject: [PATCH 28/44] Add the three exports the sheet was already importing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fieldMarker`, `isPromoted` and `ROOMY_FIELD_TYPES` were never written to `utils/inspection-answers`. A scripted edit that was meant to insert them matched nothing and reported nothing, so the group component and `summarize` both called a function that did not exist. Every sheet threw on render: the public link, and the console submission form the moment a form was chosen. They exist now, and three checks were run against the real module rather than a restatement of its rules — which is what let this through, since the render harness reimplemented the logic instead of importing it: - every export the sheet imports resolves, exercised end to end on a live LGV Daily Pre-Trip payload; - every `this.x` in all fourteen inspection templates resolves to a member of its component; - every translation key the inspection code names exists. --- addon/utils/inspection-answers.js | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/addon/utils/inspection-answers.js b/addon/utils/inspection-answers.js index 2e1b1d309..72956b656 100644 --- a/addon/utils/inspection-answers.js +++ b/addon/utils/inspection-answers.js @@ -88,6 +88,50 @@ export function defectIncomplete(field, value) { return meta.require_photo_on_fail === true && !(Array.isArray(answer.photos) && answer.photos.length > 0); } +/** The types that always need the full width of a group, whatever the answer. */ +export const ROOMY_FIELD_TYPES = ['textarea', 'file-upload', 'signature']; + +/** + * Whether a field leaves its group's grid and becomes a full-width band. + * + * This is what stops one answer changing the shape of another. A compact + * field sits in the column the author gave it; the moment it needs room — a + * pass-fail that failed and now owes a severity, a comment and photos, or a + * note, upload or signature that never fitted a column in the first place — + * it is promoted out of the grid entirely, so there is no neighbouring cell + * left to stretch. + */ +export function isPromoted(field, value) { + if (ROOMY_FIELD_TYPES.includes(field?.type)) { + return true; + } + + return answerState(field, value) === 'fail'; +} + +/** + * The one-word state a group header's dot shows for a field: `pass`, `fail`, + * `na`, `outstanding` (required and unanswered, or a failure still owing its + * comment or photo), `done`, or `empty`. + */ +export function fieldMarker(field, value) { + const state = answerState(field, value); + + if (state === 'fail') { + return defectIncomplete(field, value) ? 'outstanding' : 'fail'; + } + + if (state) { + return state; + } + + if (field?.required && isBlank(field, value)) { + return 'outstanding'; + } + + return isBlank(field, value) ? 'empty' : 'done'; +} + /** * The totals a section header and the sheet's foot both read. * From a64a66ec447e402e2583e72aa9e9e82b27f25087 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 10 Sep 2026 21:23:40 +0800 Subject: [PATCH 29/44] Span a promoted field in place, and stop restyling the console's inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven things reported against the sheet. Promotion moved a field to the end of its group, so failing a check re-sorted the group and the fields after it jumped up past it. A field that needs room now stays exactly where it was authored and spans the full width of the grid instead. It is still alone on its row, so it still cannot stretch a neighbour, and nothing moves. A failure's own comment box, photo slots and segmented controls kept the neutral border while everything around them turned red. They take the failure's edge colour now, so the block reads as one thing. The sheet was forcing a height and a font size onto every `.form-input`, `.form-select` and power-select trigger, which made an input on this screen shorter and tighter than the same input anywhere else in Fleetbase. That is gone; controls keep the console's own sizing, and the segmented controls match a real control's height rather than setting their own. Groups sit further apart, and the group block has padding on all four sides — the last group used to touch the divider above the tallies, and the public link's details and sign-off panels had no bottom padding at all. Every field is stacked now, label above control, whatever the column count. A one-column group used to put the label beside the control, which read as two different forms interleaved. The "2 col · 1 promoted" line is gone. It described the layout to someone who can see the layout. The header keeps only what is outstanding, and only when something is. Checked by rendering the real LGV Daily Pre-Trip payload through the sheet's markup and stylesheet in both themes, and by re-running the three checks: every template reference, every local import and every translation key resolves. --- addon/components/inspection-sheet/group.hbs | 56 ++++++--------- addon/components/inspection-sheet/group.js | 46 ++++-------- addon/styles/fleetops-engine.css | 77 ++++++++------------- translations/en-us.yaml | 4 +- 4 files changed, 62 insertions(+), 121 deletions(-) diff --git a/addon/components/inspection-sheet/group.hbs b/addon/components/inspection-sheet/group.hbs index cec797460..1258923ff 100644 --- a/addon/components/inspection-sheet/group.hbs +++ b/addon/components/inspection-sheet/group.hbs @@ -8,7 +8,9 @@ {{/each}} {{/if}} - {{this.meta}} + {{#if this.hasOutstanding}} + {{this.outstanding}} + {{/if}}
{{#if @group.description}} @@ -16,41 +18,23 @@ {{/if}} {{#if this.fields}} - {{#if this.compactFields}} -
- {{#each this.compactFields key="uuid" as |field|}} - {{#if @readonly}} - - {{else}} - - {{/if}} - {{/each}} -
- {{/if}} - - {{#each this.promotedFields key="uuid" as |field|}} - {{#if @readonly}} - - {{else}} - - {{/if}} - {{/each}} +
+ {{#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 index 6fdfb06e0..c0c5dbcf3 100644 --- a/addon/components/inspection-sheet/group.js +++ b/addon/components/inspection-sheet/group.js @@ -1,17 +1,20 @@ import Component from '@glimmer/component'; import { inject as service } from '@ember/service'; -import { summarize, isPromoted, fieldMarker } from '../../utils/inspection-answers'; +import { summarize, fieldMarker } from '../../utils/inspection-answers'; const MAX_COLUMNS = 4; /** * One group of an inspection form. * - * The author's `grid_size` is honoured — but only for the fields that stay - * compact. A field that needs room is promoted out of the grid into a - * full-width band underneath it, which is what keeps one answer from changing - * the shape of another: after promotion there is no neighbouring cell left to - * stretch. + * 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. @@ -46,15 +49,6 @@ export default class InspectionSheetGroupComponent extends Component { return Math.min(Math.round(size), MAX_COLUMNS); } - get compactFields() { - return this.fields.filter((field) => !isPromoted(field, this.values[field.uuid])); - } - - /** Promoted fields keep the order they were authored in. */ - get promotedFields() { - return this.fields.filter((field) => isPromoted(field, this.values[field.uuid])); - } - get markers() { return this.fields.map((field) => ({ uuid: field.uuid, @@ -67,24 +61,12 @@ export default class InspectionSheetGroupComponent extends Component { } /** - * The line at the right of the header. What is outstanding takes - * precedence over how the group is laid out — an inspector needs the first - * far more often than the second. + * 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 meta() { - const outstanding = this.summary.outstanding; - - if (outstanding) { - return this.intl.t('inspection.record.section-outstanding', { count: outstanding }); - } - - const promoted = this.promotedFields.length; - - if (promoted) { - return this.intl.t('inspection.record.columns-and-promoted', { columns: this.columns, promoted }); - } - - return this.intl.t('inspection.record.columns', { columns: this.columns }); + get outstanding() { + return this.intl.t('inspection.record.section-outstanding', { count: this.summary.outstanding }); } get hasOutstanding() { diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css index fdfc18a4f..552a93db8 100644 --- a/addon/styles/fleetops-engine.css +++ b/addon/styles/fleetops-engine.css @@ -8970,7 +8970,7 @@ body[data-theme='dark'] .inspection-sheet__body { } .inspection-sheet__groups { - padding: 0.875rem 1rem 0; + padding: 1rem; } /* --- group header ------------------------------------------------------- */ @@ -8983,8 +8983,8 @@ body[data-theme='dark'] .inspection-sheet__body { border-bottom: 1px solid var(--ins-border); } -.inspection-group + .inspection-group .inspection-group__header { - padding-top: 1.125rem; +.inspection-group + .inspection-group { + margin-top: 1.75rem; } .inspection-group__name { @@ -9067,9 +9067,13 @@ body[data-theme='dark'] .inspection-sheet__body { .inspection-group__grid { display: grid; - gap: 0.625rem; + gap: 1rem 0.875rem; align-items: start; - padding-top: 0.75rem; + padding-top: 0.875rem; +} + +.inspection-group__grid > .inspection-band { + grid-column: 1 / -1; } .inspection-group__grid[data-columns='1'] { @@ -9100,31 +9104,6 @@ body[data-theme='dark'] .inspection-sheet__body { .inspection-group__grid { grid-template-columns: minmax(0, 1fr); } - - .inspection-group__grid[data-columns='1'] .inspection-cell { - flex-direction: column; - align-items: stretch; - } - - .inspection-group__grid[data-columns='1'] .inspection-cell__control { - flex: 1 1 auto; - width: 100%; - min-width: 0; - } -} - -/* A one-column group reads as a list of rows, label beside control. */ -.inspection-group__grid[data-columns='1'] .inspection-cell { - flex-direction: row; - align-items: center; - justify-content: space-between; - gap: 0.75rem; -} - -.inspection-group__grid[data-columns='1'] .inspection-cell__control { - flex: 0 0 auto; - width: auto; - min-width: 12rem; } /* --- a compact field --------------------------------------------------- */ @@ -9181,21 +9160,6 @@ body[data-theme='dark'] .inspection-sheet__body { color: var(--ins-text-faint); } -/* Every control on the sheet is the same height, so a row of them lines up. */ -.inspection-sheet .form-input, -.inspection-sheet .form-select, -.inspection-sheet .ember-power-select-trigger { - height: 1.875rem; - min-height: 1.875rem; - font-size: 0.8125rem; -} - -.inspection-sheet textarea.form-input { - height: auto; - min-height: 3.5rem; - line-height: 1.5; -} - /* A required answer that is still missing says so on its own edge. */ .inspection-input--outstanding.form-input, .inspection-input--outstanding .ember-power-select-trigger { @@ -9207,7 +9171,7 @@ body[data-theme='dark'] .inspection-sheet__body { .inspection-choice { display: flex; align-items: stretch; - height: 1.875rem; + min-height: 2.25rem; border: 1px solid var(--ins-border); border-radius: 0.375rem; overflow: hidden; @@ -9230,8 +9194,8 @@ body[data-theme='dark'] .inspection-sheet__body { display: flex; align-items: center; justify-content: center; - padding: 0 0.5rem; - font-size: 0.75rem; + padding: 0.375rem 0.5rem; + font-size: 0.8125rem; font-weight: 500; line-height: 1; color: var(--ins-text-muted); @@ -9283,7 +9247,6 @@ body[data-theme='dark'] .inspection-sheet__body { /* --- a promoted band ---------------------------------------------------- */ .inspection-band { - margin-top: 0.625rem; border: 1px solid var(--ins-border); border-radius: 0.375rem; overflow: hidden; @@ -9348,6 +9311,20 @@ body[data-theme='dark'] .inspection-sheet__body { padding: 0.25rem 0.3125rem; } +.inspection-band--defect .form-input { + border-color: var(--ins-fail-edge); + background-color: transparent; +} + +.inspection-band--defect .form-input:focus { + border-color: var(--ins-fail); +} + +.inspection-band--defect .inspection-choice, +.inspection-band--defect .inspection-slot { + border-color: var(--ins-fail-edge); +} + .inspection-defect__row { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -9366,7 +9343,7 @@ body[data-theme='dark'] .inspection-sheet__body { display: flex; align-items: center; gap: 0.4375rem; - height: 1.875rem; + min-height: 2.25rem; padding: 0 0.625rem; border: 1px solid var(--ins-fail); border-radius: 0.375rem; diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 68adb37cc..c70d4016d 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -2871,7 +2871,7 @@ inspection: note-placeholder: Add a note text-placeholder: Type an answer no-options: This field has no answers to choose from. - uploads-unavailable: Photos can be added from the console or the driver app. + uploads-unavailable: Can be added from the console or the driver app. promoted: Promoted comments-required: Say what is wrong, and what it needs comment-required: Comment mandatory on fail @@ -2918,8 +2918,6 @@ inspection: group-has-no-fields: This section has no checks. outstanding: still to answer section-outstanding: "{count, plural, one {# outstanding} other {# outstanding}}" - columns: "{columns, plural, =1 {1 col} other {# col}}" - columns-and-promoted: "{columns, plural, =1 {1 col} other {# col}} · {promoted, plural, one {# promoted} other {# promoted}}" outstanding-field: "{label} is required before submitting" review: "Review →" jump-to: "Jump to →" From f20d63fb15597eced74dd79cdeb0da961de536aa Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 11 Sep 2026 11:44:26 +0800 Subject: [PATCH 30/44] Open a failed check's detail in a flyout, so no answer moves the sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed pass-fail used to widen into a full-width band in place. That still re-flowed the group every time a check failed. The failure now keeps its cell, and its severity, unsafe flag, comment and photos open in a flyout anchored to it. The sheet's layout depends on the form alone; answering a field can never change it. Notes, uploads and signatures keep their full-width band, because the form decides their size. The read-only record keeps each defect inline, where it is the most important thing on the page. How the flyout behaves: - Choosing Fail opens it, with no second click, and focuses the comment. - A caret points at the Fail button, and the field is ringed with an outline, which takes no space, so opening it moves nothing either. - It closes on Done, its close button, Escape, or a press outside it and its field — never on blur, because focus leaves for the native photo picker. Escape and Done return focus to the Fail button. - Closing is always safe: every answer is saved as it is typed. So Done still closes when a required comment or photo is missing, and the chip it leaves says what is owed in amber instead of trapping the inspector. - Only one is open at a time. Switching an accidental Fail back to Pass keeps the comment and photos. - On a sheet narrower than 520px — the public link on a phone — the same content comes up as a bottom sheet with a backdrop, mounted in the root wormhole so it is pinned to the screen rather than to the sheet. Placement is done here rather than with the platform's Floating, which positions once and never follows, and FleetOps does not declare floating-ui. The flyout lives in a layer inside the sheet, so it scrolls with its field for free and needs no scroll listener. It goes below the field unless only the space above can hold it: whatever hangs below can always be scrolled to, but a panel pushed above the start of the sheet could not be reached at all. It is revealed once, by the least scroll that brings it fully into view with a margin, or its title if it is taller than the view. The reveal is worked out from layout after the opening animation finishes: measured mid-animation it was clamped against a briefly shorter scroll area and stopped exactly the animated 4px short. An invisible 8px spacer below the flyout gives that margin room at the very end of the page, since an absolutely placed box extends the scroll area but its margin does not. A defects tray at the foot of the sheet lists every failure with its severity, field and evidence, and each row reopens that flyout. It replaces the separate unsafe banner. Verified by loading the shipped modifier and stylesheet into a real browser and checking placement, focus, scrolling with the field, dismissal, teardown, both sides of the flip rule, the reveal and its margin, a flyout taller than the view, and the end of the page; and by re-running the template-reference, local-import and translation sweeps. --- addon/components/inspection-field/input.hbs | 204 ++++----- addon/components/inspection-field/input.js | 80 +++- addon/components/inspection-field/value.js | 6 +- addon/components/inspection-flyout.hbs | 34 ++ addon/components/inspection-flyout.js | 66 +++ addon/components/inspection-sheet.hbs | 30 +- addon/components/inspection-sheet.js | 80 +++- addon/components/inspection-sheet/group.hbs | 3 + addon/modifiers/inspection-flyout.js | 237 +++++++++++ addon/styles/fleetops-engine.css | 450 +++++++++++++++++++- addon/utils/inspection-answers.js | 45 +- app/components/inspection-flyout.js | 1 + app/modifiers/inspection-flyout.js | 1 + translations/en-us.yaml | 16 +- 14 files changed, 1108 insertions(+), 145 deletions(-) create mode 100644 addon/components/inspection-flyout.hbs create mode 100644 addon/components/inspection-flyout.js create mode 100644 addon/modifiers/inspection-flyout.js create mode 100644 app/components/inspection-flyout.js create mode 100644 app/modifiers/inspection-flyout.js diff --git a/addon/components/inspection-field/input.hbs b/addon/components/inspection-field/input.hbs index 062a668cc..79fc50ffd 100644 --- a/addon/components/inspection-field/input.hbs +++ b/addon/components/inspection-field/input.hbs @@ -1,108 +1,16 @@ {{! One field of an inspection, being answered. - It renders in one of two places. Compact, it is a cell in its group's - grid: label above, control below, every control the same height so a row - of them lines up. Promoted, it is a full-width band below the grid — - because it failed and now owes a severity, a comment and photos, or - because it is a note, an upload or a signature that never fitted a column. + 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. - Promotion is what stops one answer changing the shape of another: once a - field leaves the grid it has no neighbouring cell left to stretch. + 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.isDefect}} -
-
- {{t "inspection.answer.promoted"}} - {{this.label}} -
- {{#each this.passFailOptions key="value" as |option|}} - - {{/each}} -
-
- -
-
-
- {{#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.defectRequirement}} - {{this.defectRequirement}} - {{else if this.uploadsBlocked}} - {{t "inspection.answer.uploads-unavailable"}} - {{/if}} -
-
-
- -{{else if this.isRoomy}} +{{#if this.isRoomy}}
{{/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 index 8e4a98e99..c3ea89298 100644 --- a/addon/components/inspection-field/input.js +++ b/addon/components/inspection-field/input.js @@ -3,7 +3,7 @@ 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, isPromoted, isBlank, ROOMY_FIELD_TYPES } from '../../utils/inspection-answers'; +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' }; @@ -40,16 +40,50 @@ export default class InspectionFieldInputComponent extends Component { return meta && typeof meta === 'object' ? meta : {}; } - /** Whether this field has left the grid for a full-width band. */ - get isPromoted() { - return isPromoted(this.field, this.args.value); - } - - /** A failed check: the band that carries severity, comment and photos. */ + /** 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); @@ -276,9 +310,14 @@ export default class InspectionFieldInputComponent extends Component { 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, @@ -286,6 +325,33 @@ export default class InspectionFieldInputComponent extends Component { 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) { diff --git a/addon/components/inspection-field/value.js b/addon/components/inspection-field/value.js index 16632aab3..564efbdd1 100644 --- a/addon/components/inspection-field/value.js +++ b/addon/components/inspection-field/value.js @@ -1,7 +1,7 @@ import Component from '@glimmer/component'; import { inject as service } from '@ember/service'; import { INSPECTION_SEVERITIES } from '../../utils/inspection-field-types'; -import { answerState, isPromoted, ROOMY_FIELD_TYPES } from '../../utils/inspection-answers'; +import { answerState, ROOMY_FIELD_TYPES } from '../../utils/inspection-answers'; /** * One stored answer, read-only — what the record's Overview shows. @@ -32,10 +32,6 @@ export default class InspectionFieldValueComponent extends Component { return answerState(this.field, this.args.value); } - get isPromoted() { - return isPromoted(this.field, this.args.value); - } - get isDefect() { return this.isPassFail && this.answerState === 'fail'; } 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-sheet.hbs b/addon/components/inspection-sheet.hbs index e31fc8721..b279fc51c 100644 --- a/addon/components/inspection-sheet.hbs +++ b/addon/components/inspection-sheet.hbs @@ -1,4 +1,7 @@
+ {{! 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|}} @@ -11,6 +14,9 @@ @allowUploads={{@allowUploads}} @readonly={{@readonly}} @targetId={{this.targetId}} + @openFieldId={{this.openFieldId}} + @onOpenFlyout={{this.openFlyout}} + @onCloseFlyout={{this.closeFlyout}} /> {{/each}}
@@ -36,13 +42,23 @@
- {{#if this.summary.unsafeField}} -
- {{t "inspection.answer.unsafe"}} - {{this.unsafeDescription}} - + {{#if this.defects}} +
+
+ {{t "inspection.tray.title"}} + {{this.defects.length}} +
+ {{#each this.defects key="field.uuid" as |defect|}} + + {{/each}}
{{/if}} diff --git a/addon/components/inspection-sheet.js b/addon/components/inspection-sheet.js index c84a9867e..d0321e9de 100644 --- a/addon/components/inspection-sheet.js +++ b/addon/components/inspection-sheet.js @@ -3,7 +3,8 @@ 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, passFailAnswer } from '../utils/inspection-answers'; +import { summarize, listDefects } from '../utils/inspection-answers'; +import { INSPECTION_SEVERITIES } from '../utils/inspection-field-types'; /** * An inspection form, being filled in. @@ -24,6 +25,9 @@ export default class InspectionSheetComponent extends Component { /** 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 : []; } @@ -40,22 +44,53 @@ export default class InspectionSheetComponent extends Component { return this.fields.length > 0; } - /** "Lights and indicators · High" — the failure, and how bad it is. */ - get unsafeDescription() { - const field = this.summary.unsafeField; + /** + * 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), + })); + } - if (!field) { - return null; + severityLabel(severity) { + if (!severity) { + return this.intl.t('inspection.answer.fail'); } - const severity = passFailAnswer(this.args.values?.[field.uuid])?.severity; - const label = field.label || this.intl.t('inspection.builder.untitled-field'); + return INSPECTION_SEVERITIES.includes(severity) ? this.intl.t(`inspection.severity.${severity}`) : severity; + } - if (!severity) { - return label; + /** "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'); } - return `${label} · ${this.intl.t(`inspection.severity.${severity}`)}`; + 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() { @@ -77,6 +112,29 @@ export default class InspectionSheetComponent extends Component { * 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; diff --git a/addon/components/inspection-sheet/group.hbs b/addon/components/inspection-sheet/group.hbs index 1258923ff..4fa21b82b 100644 --- a/addon/components/inspection-sheet/group.hbs +++ b/addon/components/inspection-sheet/group.hbs @@ -31,6 +31,9 @@ @disabled={{@disabled}} @allowUploads={{@allowUploads}} @targetId={{@targetId}} + @openFieldId={{@openFieldId}} + @onOpenFlyout={{@onOpenFlyout}} + @onCloseFlyout={{@onCloseFlyout}} /> {{/if}} {{/each}} diff --git a/addon/modifiers/inspection-flyout.js b/addon/modifiers/inspection-flyout.js new file mode 100644 index 000000000..b234dd83f --- /dev/null +++ b/addon/modifiers/inspection-flyout.js @@ -0,0 +1,237 @@ +import { modifier } from 'ember-modifier'; + +/** Room left between the flyout and its field, and between it and the sheet's edge. */ +const GAP = 8; +const EDGE = 8; + +/** The nearest ancestor that actually scrolls, or null for the window. */ +function scrollParentOf(element) { + let node = element?.parentElement; + + while (node && node !== document.body) { + const { overflowY } = getComputedStyle(node); + + if (/(auto|scroll|overlay)/.test(overflowY) && node.scrollHeight > node.clientHeight) { + return node; + } + + node = node.parentElement; + } + + return null; +} + +/** + * Place a floating flyout against its field. + * + * Both live inside the same sheet, so they scroll together and nothing has to + * follow the scroll. The flyout goes below the field unless only the visible + * space above it can hold it, is kept inside the sheet horizontally, and + * points its caret at the field's Fail button. + */ +export function placeFlyout(element, anchor, container) { + const containerRect = container.getBoundingClientRect(); + const anchorRect = anchor.getBoundingClientRect(); + const width = element.offsetWidth; + const height = element.offsetHeight; + + const scroller = scrollParentOf(anchor); + const viewTop = scroller ? scroller.getBoundingClientRect().top : 0; + const viewBottom = scroller ? scroller.getBoundingClientRect().bottom : window.innerHeight; + + const fitsBelow = viewBottom - anchorRect.bottom >= height + GAP; + const fitsAbove = anchorRect.top - viewTop >= height + GAP; + + // Below, unless only the space above can hold it. Whatever hangs below a + // field can always be scrolled to — an absolutely placed panel extends + // the scroll area — but a panel pushed above the start of the sheet + // cannot be reached at all, so "more room above" is not reason enough. + const below = fitsBelow || !fitsAbove; + + const top = below ? anchorRect.bottom - containerRect.top + GAP : anchorRect.top - containerRect.top - height - GAP; + + const maxLeft = Math.max(EDGE, container.clientWidth - width - EDGE); + const left = Math.max(EDGE, Math.min(anchorRect.left - containerRect.left, maxLeft)); + + element.style.top = `${Math.round(top)}px`; + element.style.left = `${Math.round(left)}px`; + element.dataset.placement = below ? 'bottom' : 'top'; + + const pointAt = anchor.querySelector('[data-answer="fail"]') ?? anchor; + const pointRect = pointAt.getBoundingClientRect(); + const caret = pointRect.left + pointRect.width / 2 - containerRect.left - left; + + element.style.setProperty('--flyout-caret-x', `${Math.round(Math.max(16, Math.min(caret, width - 16)))}px`); + + // Where it now sits, worked out from layout rather than read off its + // rendered box: the open animation is still translating it, and a reveal + // measured from the moving box stops exactly that many pixels short. + const flyoutTop = containerRect.top + top; + + return { placement: below ? 'bottom' : 'top', flyoutTop, flyoutBottom: flyoutTop + height, viewTop, viewBottom, scroller }; +} + +/** + * Scroll just enough to bring a newly opened flyout fully into view, keeping + * a small margin from the edge. If it is taller than the view, its top — the + * title, and the first thing to answer — is what stays in view. Returns how + * far it scrolled. + */ +export function revealFlyout({ flyoutTop, flyoutBottom, viewTop, viewBottom, scroller }) { + let delta = 0; + + if (flyoutBottom + EDGE > viewBottom) { + delta = flyoutBottom + EDGE - viewBottom; + } + + if (flyoutTop - delta - EDGE < viewTop) { + delta = flyoutTop - EDGE - viewTop; + } + + if (Math.abs(delta) < 1) { + return 0; + } + + const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + (scroller ?? window).scrollBy({ top: delta, behavior: reduce ? 'auto' : 'smooth' }); + + return delta; +} + +/** + * Run something once the flyout's opening animation has finished. + * + * The animation translates the panel, and a translated box changes the + * scrollable area: a reveal worked out mid-animation is clamped against a + * scroll area that is briefly too short, and lands short by exactly the + * animated offset. Without an animation — reduced motion — it runs at once. + * Returns a timer to clear on teardown. + */ +function afterOpening(element, callback) { + const style = getComputedStyle(element); + const seconds = parseFloat(style.animationDuration) || 0; + + if (style.animationName === 'none' || seconds === 0) { + callback(); + return null; + } + + let done = false; + const finish = () => { + if (done) { + return; + } + + done = true; + element.removeEventListener('animationend', finish); + callback(); + }; + + element.addEventListener('animationend', finish); + + // In case the event never comes: a hidden tab, an interrupted animation. + return setTimeout(finish, seconds * 1000 + 50); +} + +/** + * Keep a defect flyout attached to its field, and close it the natural way. + * + *
+ * + * It closes on a press outside itself and outside its own field, and on + * Escape. It deliberately does not close on blur or on scroll: focus leaves + * the page for the native photo picker, and scrolling moves the flyout with + * its field anyway. Nothing is lost by closing, because every answer is saved + * as it is typed. + * + * A bottom sheet is placed by its stylesheet, so it only takes the + * dismissal half of this. + */ +export default modifier(function inspectionFlyout(element, [anchor], { presentation = 'floating', onDismiss, focus } = {}) { + if (!(anchor instanceof Element)) { + return; + } + + const container = anchor.closest('.inspection-sheet') ?? document.body; + const floating = presentation === 'floating'; + let frame = null; + let revealed = false; + let revealTimer = null; + + const place = () => { + if (!floating || !element.isConnected || !anchor.isConnected) { + return; + } + + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + placeFlyout(element, anchor, container); + + // A panel that opens half off the page is not natural. Bring it + // into view once, by the least scroll that will do it, as soon as + // it has finished opening; after that the inspector is in charge + // of the scrolling. + if (!revealed) { + revealed = true; + revealTimer = afterOpening(element, () => { + if (element.isConnected && anchor.isConnected) { + revealFlyout(placeFlyout(element, anchor, container)); + } + }); + } + }); + }; + + const dismiss = (reason) => { + if (typeof onDismiss === 'function') { + onDismiss(reason); + } + }; + + // A press anywhere but the flyout or its own field closes it. Pointerdown, + // not click, so pressing another field's Fail closes this one first. + const onPointerDown = (event) => { + const target = event.target; + + if (element.contains(target) || anchor.contains(target)) { + return; + } + + dismiss('outside'); + }; + + const onKeyDown = (event) => { + if (event.key === 'Escape') { + event.stopPropagation(); + dismiss('escape'); + } + }; + + document.addEventListener('pointerdown', onPointerDown, true); + document.addEventListener('keydown', onKeyDown, true); + window.addEventListener('resize', place); + + // Re-place when anything around it changes height: a field above it + // gaining a line, or the flyout itself growing as a photo is added. + const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(place) : null; + observer?.observe(container); + observer?.observe(element); + + place(); + + if (focus) { + requestAnimationFrame(() => { + const target = focus === true ? element : element.querySelector(focus); + target?.focus({ preventScroll: true }); + }); + } + + return () => { + cancelAnimationFrame(frame); + clearTimeout(revealTimer); + document.removeEventListener('pointerdown', onPointerDown, true); + document.removeEventListener('keydown', onKeyDown, true); + window.removeEventListener('resize', place); + observer?.disconnect(); + }; +}); diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css index 552a93db8..d6790153b 100644 --- a/addon/styles/fleetops-engine.css +++ b/addon/styles/fleetops-engine.css @@ -8901,7 +8901,8 @@ body[data-theme='dark'] .filter-multi-model > .clear-button { record, and the public link a driver opens on a phone. ========================================================================== */ -.inspection-sheet { +.inspection-sheet, +.inspection-flyout { --ins-bg: #fff; --ins-bg-sunken: #f9fafb; --ins-border: #e5e7eb; @@ -8922,7 +8923,11 @@ body[data-theme='dark'] .filter-multi-model > .clear-button { --ins-hatch-a: #e5e7eb; --ins-hatch-b: #f3f4f6; --ins-mono: ui-monospace, "IBM Plex Mono", sfmono-regular, menlo, monaco, consolas, monospace; +} +.inspection-sheet { + /* The floating flyouts are positioned against the sheet. */ + position: relative; display: flex; flex-direction: column; @@ -8934,7 +8939,8 @@ body[data-theme='dark'] .filter-multi-model > .clear-button { container-type: inline-size; } -body[data-theme='dark'] .inspection-sheet { +body[data-theme='dark'] .inspection-sheet, +body[data-theme='dark'] .inspection-flyout { --ins-bg: #1f2937; --ins-bg-sunken: #1f2937; --ins-border: #374151; @@ -9774,3 +9780,443 @@ body[data-theme='dark'] .inspection-link__url { .inspection-slot--add { text-decoration: none; } + +/* ========================================================================== + Defect flyout + -------------------------------------------------------------------------- + A failed check keeps its cell. Its severity, unsafe flag, comment and + photos open in a panel anchored to that cell, so failing a check can never + change the layout of the sheet. Closing it leaves a chip in the cell, and + the defects tray at the foot keeps the record. + ========================================================================== */ + +/* The open field is ringed with an outline, which takes no space: opening a + flyout must not move anything either. */ +.inspection-cell--flyout-open { + outline: 2px solid var(--ins-fail); + outline-offset: 6px; + border-radius: 0.375rem; +} + +/* --- the chip a closed failure leaves in its cell ----------------------- */ + +.inspection-defect-chip { + appearance: none; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem 0.5rem; + width: 100%; + padding: 0.4375rem 0.625rem; + border: 1px solid var(--ins-fail-edge); + border-left: 3px solid var(--ins-fail); + border-radius: 0.375rem; + background-color: var(--ins-fail-fill); + color: var(--ins-fail-text); + font-size: 0.75rem; + line-height: 1.3; + text-align: left; + cursor: pointer; +} + +.inspection-defect-chip:hover:not(:disabled) { + border-color: var(--ins-fail); +} + +.inspection-defect-chip:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +.inspection-defect-chip:disabled { + cursor: default; +} + +.inspection-defect-chip.is-incomplete { + border-left-color: var(--ins-warn); +} + +.inspection-defect-chip__severity { + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.inspection-defect-chip__unsafe { + font-family: var(--ins-mono); + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #fff; + background-color: var(--ins-fail); + border-radius: 0.1875rem; + padding: 0.1875rem 0.3125rem; +} + +.inspection-defect-chip__meta { + display: inline-flex; + align-items: center; + gap: 0.25rem; + color: var(--ins-text-muted); +} + +.inspection-defect-chip__status { + font-weight: 600; + color: var(--ins-warn-strong); +} + +.inspection-defect-chip__edit { + margin-left: auto; + color: var(--ins-text-muted); +} + +/* --- the flyout -------------------------------------------------------- */ + +.inspection-flyout { + display: flex; + flex-direction: column; + background-color: var(--ins-bg); + color: var(--ins-text); + border: 1px solid var(--ins-fail); + border-radius: 0.5rem; + box-shadow: + 0 12px 32px rgb(0 0 0 / 18%), + 0 2px 6px rgb(0 0 0 / 8%); + outline: none; +} + +body[data-theme='dark'] .inspection-flyout { + box-shadow: + 0 16px 40px rgb(0 0 0 / 55%), + 0 2px 6px rgb(0 0 0 / 30%); +} + +.inspection-flyout--floating { + position: absolute; + z-index: 40; + width: min(30rem, calc(100% - 1rem)); + animation: inspection-flyout-in 0.14s ease-out; +} + +.inspection-flyout--floating[data-placement='top'] { + animation-name: inspection-flyout-in-above; +} + +/* + * Room for the reveal's margin at the very end of the page. An absolutely + * placed box extends the scroll area, but its margin does not, so without this + * a flyout opened below the last field could only ever sit flush against the + * bottom edge of the panel. + */ +.inspection-flyout--floating::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: -0.5rem; + height: 0.5rem; + pointer-events: none; +} + +/* A caret points at the Fail button of the field it belongs to. */ +.inspection-flyout__caret { + position: absolute; + left: var(--flyout-caret-x, 1.5rem); + width: 12px; + height: 12px; + background-color: var(--ins-bg); + border: 1px solid var(--ins-fail); + transform: translateX(-50%) rotate(45deg); + pointer-events: none; +} + +.inspection-flyout[data-placement='bottom'] .inspection-flyout__caret { + top: -7px; + border-right: 0; + border-bottom: 0; +} + +.inspection-flyout[data-placement='top'] .inspection-flyout__caret { + bottom: -7px; + border-top: 0; + border-left: 0; +} + +.inspection-flyout__head { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 0.75rem; + border-bottom: 1px solid var(--ins-fail-edge); +} + +.inspection-flyout__title { + min-width: 0; + font-size: 0.8125rem; + font-weight: 600; + line-height: 1.3; +} + +.inspection-flyout__close { + appearance: none; + border: 0; + background: none; + margin-left: auto; + padding: 0.25rem 0.375rem; + border-radius: 0.25rem; + color: var(--ins-text-muted); + cursor: pointer; +} + +.inspection-flyout__close:hover { + color: var(--ins-text); +} + +.inspection-flyout__close:focus-visible { + outline: 2px solid #2563eb; +} + +.inspection-flyout__body { + display: flex; + flex-direction: column; + gap: 0.625rem; + padding: 0.75rem; + overflow-y: auto; +} + +.inspection-flyout__foot { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + border-top: 1px solid var(--ins-border); +} + +.inspection-flyout__note { + margin-right: auto; + font-family: var(--ins-mono); + font-size: 0.6875rem; + line-height: 1.4; + text-transform: uppercase; + color: var(--ins-warn-strong); +} + +/* Inside a failure, the controls take the failure's edge colour. */ +.inspection-flyout .form-input { + border-color: var(--ins-fail-edge); + background-color: transparent; +} + +.inspection-flyout .form-input:focus { + border-color: var(--ins-fail); +} + +.inspection-flyout .inspection-choice, +.inspection-flyout .inspection-slot { + border-color: var(--ins-fail-edge); +} + +/* --- on a phone, the same content as a bottom sheet --------------------- */ + +.inspection-flyout--sheet { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 1100; + max-height: 85vh; + border-bottom: 0; + border-radius: 0.875rem 0.875rem 0 0; + padding-bottom: env(safe-area-inset-bottom); + animation: inspection-sheet-in 0.2s ease-out; +} + +/* A grab handle, so it reads as something that came up and will go down. */ +.inspection-flyout--sheet::before { + content: ''; + display: block; + width: 2.5rem; + height: 0.25rem; + margin: 0.5rem auto 0; + border-radius: 9999px; + background-color: var(--ins-border-strong); +} + +.inspection-flyout-backdrop { + position: fixed; + inset: 0; + z-index: 1099; + background-color: rgb(0 0 0 / 40%); + animation: inspection-fade-in 0.2s ease-out; +} + +@keyframes inspection-flyout-in { + from { + opacity: 0; + transform: translateY(-4px); + } + + to { + opacity: 1; + transform: none; + } +} + +@keyframes inspection-flyout-in-above { + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: none; + } +} + +@keyframes inspection-sheet-in { + from { + transform: translateY(100%); + } + + to { + transform: none; + } +} + +@keyframes inspection-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .inspection-flyout, + .inspection-flyout-backdrop { + animation: none; + } +} + +/* --- the defects tray -------------------------------------------------- */ + +.inspection-tray { + border: 1px solid var(--ins-border); + border-radius: 0.375rem; + background-color: var(--ins-bg); + overflow: hidden; +} + +.inspection-tray__head { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--ins-border); + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ins-text-soft); +} + +.inspection-tray__count { + min-width: 1.25rem; + padding: 0.125rem 0.375rem; + border-radius: 9999px; + background-color: var(--ins-fail); + color: #fff; + font-size: 0.625rem; + text-align: center; + letter-spacing: 0; +} + +.inspection-tray__row { + appearance: none; + border: 0; + background: none; + width: 100%; + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.625rem 0.75rem; + text-align: left; + cursor: pointer; + color: var(--ins-text); + font-size: 0.8125rem; +} + +.inspection-tray__row + .inspection-tray__row { + border-top: 1px solid var(--ins-border); +} + +.inspection-tray__row:hover { + background-color: rgb(127 127 127 / 8%); +} + +.inspection-tray__row:focus-visible { + outline: 2px solid #2563eb; + outline-offset: -2px; +} + +.inspection-tray__severity { + flex-shrink: 0; + min-width: 4.25rem; + padding: 0.25rem 0.375rem; + border: 1px solid var(--ins-fail-edge); + border-radius: 0.25rem; + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.06em; + text-align: center; + text-transform: uppercase; + color: var(--ins-fail-text); +} + +.inspection-tray__severity[data-severity='high'], +.inspection-tray__severity[data-severity='critical'] { + border-color: var(--ins-fail); + background-color: var(--ins-fail); + color: #fff; +} + +.inspection-tray__label { + flex: 1 1 auto; + min-width: 0; + font-weight: 600; +} + +.inspection-tray__evidence { + flex-shrink: 0; + font-size: 0.75rem; + color: var(--ins-text-muted); +} + +.inspection-tray__evidence[data-incomplete='true'] { + font-weight: 600; + color: var(--ins-warn-strong); +} + +.inspection-tray__action { + flex-shrink: 0; + font-size: 0.75rem; + font-weight: 600; + color: var(--ins-fail-text); +} + +@container (width <= 480px) { + .inspection-tray__row { + flex-wrap: wrap; + } + + .inspection-tray__evidence { + flex-basis: 100%; + order: 5; + } +} diff --git a/addon/utils/inspection-answers.js b/addon/utils/inspection-answers.js index 72956b656..2255a5f3f 100644 --- a/addon/utils/inspection-answers.js +++ b/addon/utils/inspection-answers.js @@ -92,21 +92,44 @@ export function defectIncomplete(field, value) { export const ROOMY_FIELD_TYPES = ['textarea', 'file-upload', 'signature']; /** - * Whether a field leaves its group's grid and becomes a full-width band. + * Whether a field spans the full width of its group's grid. * - * This is what stops one answer changing the shape of another. A compact - * field sits in the column the author gave it; the moment it needs room — a - * pass-fail that failed and now owes a severity, a comment and photos, or a - * note, upload or signature that never fitted a column in the first place — - * it is promoted out of the grid entirely, so there is no neighbouring cell - * left to stretch. + * Only the types whose size the form itself decides: a note, an upload, a + * signature. It depends on the field and never on the answer, so answering a + * field can never change the layout. A failure's detail used to widen its + * field too, which re-flowed the group every time a check failed; it now + * opens in a flyout instead, and the field stays the size it was. */ +// eslint-disable-next-line no-unused-vars export function isPromoted(field, value) { - if (ROOMY_FIELD_TYPES.includes(field?.type)) { - return true; - } + return ROOMY_FIELD_TYPES.includes(field?.type); +} + +/** + * What a failed check has recorded, in one shape: for the chip a closed + * failure leaves behind in its field, and for the defects tray. + */ +export function defectSummary(field, value) { + const answer = passFailAnswer(value) ?? {}; + const photos = Array.isArray(answer.photos) ? answer.photos : []; + const meta = field?.meta && typeof field.meta === 'object' ? field.meta : {}; + const hasComment = Boolean(String(answer.comments ?? '').trim()); + + return { + field, + severity: answer.severity ?? meta.severity ?? null, + unsafe: answer.unsafe === true, + photoCount: photos.length, + hasComment, + needsComment: meta.require_comment_on_fail === true && !hasComment, + needsPhoto: meta.require_photo_on_fail === true && photos.length === 0, + incomplete: defectIncomplete(field, value), + }; +} - return answerState(field, value) === 'fail'; +/** Every failed check on the sheet, in the order they are answered. */ +export function listDefects(fields = [], values = {}) { + return fields.filter((field) => answerState(field, values?.[field.uuid]) === 'fail').map((field) => defectSummary(field, values?.[field.uuid])); } /** diff --git a/app/components/inspection-flyout.js b/app/components/inspection-flyout.js new file mode 100644 index 000000000..ac0b52ba2 --- /dev/null +++ b/app/components/inspection-flyout.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/inspection-flyout'; diff --git a/app/modifiers/inspection-flyout.js b/app/modifiers/inspection-flyout.js new file mode 100644 index 000000000..6d152b644 --- /dev/null +++ b/app/modifiers/inspection-flyout.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/modifiers/inspection-flyout'; diff --git a/translations/en-us.yaml b/translations/en-us.yaml index c70d4016d..34a9ab99c 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -2872,7 +2872,6 @@ inspection: text-placeholder: Type an answer no-options: This field has no answers to choose from. uploads-unavailable: Can be added from the console or the driver app. - promoted: Promoted comments-required: Say what is wrong, and what it needs comment-required: Comment mandatory on fail photo-required: Photo mandatory on fail @@ -2958,3 +2957,18 @@ inspection: blocked-required: "{count, plural, one {# required answer is missing} other {# required answers are missing}}" blocked-defects: "{count, plural, one {# failure still needs a comment or a photo} other {# failures still need a comment or a photo}}" powered-by: Powered by Fleetbase FleetOps + flyout: + title: "Defect: {label}" + done: Done + close: Close + defect: + edit: Edit defect + photos: "{count, plural, one {# photo} other {# photos}}" + comment: Comment + needs-comment: Needs a comment + needs-photo: Needs a photo + needs-both: Needs a comment and a photo + no-evidence: No comment or photo yet + tray: + title: Defects + review: Review From 5026ecfd645d1dae7836fdccd731fa52e47758d4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 11 Sep 2026 12:53:00 +0800 Subject: [PATCH 31/44] Finish the failure style, refresh link lists live, use the attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things reported against the inspection console. The failure flyout had lost most of its red. The panel was the sheet's grey with a red edge, and the comment box stayed grey whatever the flyout set. The cause was specificity: ember-ui styles every console input with `body[data-theme='dark'] .fleetbase-console .form-input`, which outranks `.inspection-flyout .form-input`, so the box always fell back to the platform grey. The earlier attempt to redden the old band's comment box lost the same way. The flyout is now the failure throughout: a red-tinted panel and caret, red dividers, a red-edged comment box with its own field colour, focus ring and placeholder, red-hatched photo slots and a red-dashed slot for the next photo, and the unsafe switch red whether it is on or off. The comment selector is deliberately heavier than ember-ui's, and every colour comes from the theme tokens. The light theme's failure fill is now a pale red rather than transparent, so a failure stands out in both. Verified in a browser against a reproduction of ember-ui's own rules, loaded after the sheet's stylesheet so that any tie would go to them: every computed colour matches, in both themes. A link generated from the header did not appear in the details panel's list until a reload. Only the list inside the generate modal was told a link had been minted. The signal now lives on the form actions service, so every open list — the modal's and the details panel's — reloads. The public page's "Powered by" line is now ember-ui's `FleetbaseAttribution`, which also honours the host's switch for turning attribution off. --- addon/components/inspection-field/input.hbs | 2 +- addon/components/inspection-link/list.hbs | 2 +- addon/components/inspection-link/list.js | 6 +- addon/components/modals/inspection-link.hbs | 2 +- addon/components/public-inspection.hbs | 2 +- addon/services/inspection-form-actions.js | 18 ++++-- addon/styles/fleetops-engine.css | 72 +++++++++++++++++---- translations/en-us.yaml | 1 - 8 files changed, 81 insertions(+), 24 deletions(-) diff --git a/addon/components/inspection-field/input.hbs b/addon/components/inspection-field/input.hbs index 79fc50ffd..e5fcc810c 100644 --- a/addon/components/inspection-field/input.hbs +++ b/addon/components/inspection-field/input.hbs @@ -226,7 +226,7 @@