From fa8b4fbe9df5e8ced862185ff0c8a1cb627e2288 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 11 Oct 2026 16:13:38 +0800 Subject: [PATCH] feat(dashboard): proof of delivery photos widget Adds a Proof of Delivery dashboard tile showing the latest delivery photos in a grid, each linking to its order, with loading and empty states. Backed by GET /int/v1/fleet-ops/proofs/recent, which returns the session company's latest photo proofs (file type 'photo') on orders, newest first, with order public_id, customer name, capture time and a signed photo url. The limit defaults to 12 and is capped between 1 and 24. --- addon/components/widget/proof-of-delivery.hbs | 49 +++++ addon/components/widget/proof-of-delivery.js | 62 +++++++ addon/extension.js | 10 ++ app/components/widget/proof-of-delivery.js | 1 + .../Internal/v1/ProofController.php | 47 +++++ server/src/routes.php | 7 +- .../Internal/ProofControllerRecentTest.php | 170 ++++++++++++++++++ .../widget/proof-of-delivery-test.js | 131 ++++++++++++++ translations/en-us.yaml | 4 + 9 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 addon/components/widget/proof-of-delivery.hbs create mode 100644 addon/components/widget/proof-of-delivery.js create mode 100644 app/components/widget/proof-of-delivery.js create mode 100644 server/tests/Feature/Http/Internal/ProofControllerRecentTest.php create mode 100644 tests/unit/components/widget/proof-of-delivery-test.js diff --git a/addon/components/widget/proof-of-delivery.hbs b/addon/components/widget/proof-of-delivery.hbs new file mode 100644 index 000000000..696b8c3a2 --- /dev/null +++ b/addon/components/widget/proof-of-delivery.hbs @@ -0,0 +1,49 @@ +
+
+ {{t "widget.proof-of-delivery.title"}} + +
+ +
+ {{#if this.load.isRunning}} +
+ {{else if this.error}} +
{{this.error}}
+ {{else if this.photos.length}} +
+ {{#each this.photos as |proof|}} +
+ +
+ {{#if proof.order_id}} + {{proof.order_id}} + {{/if}} + {{#if proof.timeAgo}} +
{{proof.timeAgo}}
+ {{/if}} +
+
+ {{/each}} +
+ {{else}} +
+ +
{{t "widget.proof-of-delivery.empty"}}
+
+ {{/if}} +
+
\ No newline at end of file diff --git a/addon/components/widget/proof-of-delivery.js b/addon/components/widget/proof-of-delivery.js new file mode 100644 index 000000000..1d68d2f6d --- /dev/null +++ b/addon/components/widget/proof-of-delivery.js @@ -0,0 +1,62 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { getOwner } from '@ember/application'; +import { task } from 'ember-concurrency'; +import { formatDistanceToNowStrict, isValid } from 'date-fns'; +import config from '../../config/environment'; + +/** + * The latest proof of delivery photos across the fleet, each linking to its order. + */ +export default class WidgetProofOfDeliveryComponent extends Component { + static widgetId = 'fleet-ops-proof-of-delivery-widget'; + static limit = 12; + + @service fetch; + + @tracked proofs = []; + @tracked error = null; + + constructor() { + super(...arguments); + this.load.perform(); + } + + get photos() { + return (this.proofs ?? []) + .filter((proof) => proof?.url) + .map((proof) => ({ + ...proof, + timeAgo: this.timeAgo(proof.captured_at), + })); + } + + get orderRoute() { + // Dashboard widgets render under the host owner, outside the engine's routing context. + return getOwner(this).mountPoint ? 'operations.orders.index.details' : `${config.mountedEngineRoutePrefix}.operations.orders.index.details`; + } + + timeAgo(capturedAt) { + const date = capturedAt ? new Date(capturedAt) : null; + + return date && isValid(date) ? formatDistanceToNowStrict(date, { addSuffix: true }) : null; + } + + @action openPhoto(proof) { + if (proof?.url) { + window.open(proof.url, '_blank', 'noopener,noreferrer'); + } + } + + @task({ restartable: true }) *load() { + this.error = null; + try { + const response = yield this.fetch.get('fleet-ops/proofs/recent', { limit: WidgetProofOfDeliveryComponent.limit }); + this.proofs = response?.proofs ?? []; + } catch (err) { + this.error = err?.message ?? 'Failed to load'; + } + } +} diff --git a/addon/extension.js b/addon/extension.js index ee1f1e52e..a4bd25358 100644 --- a/addon/extension.js +++ b/addon/extension.js @@ -467,6 +467,16 @@ export default { order: 80, default: true, }), + new Widget({ + id: 'fleet-ops-proof-of-delivery-widget', + name: 'Proof of Delivery', + description: 'The latest proof of delivery photos, each linking to its order.', + icon: 'camera', + component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/proof-of-delivery'), + grid_options: { w: 4, h: 9, minW: 3, minH: 6 }, + category: 'Operations', + default: false, + }), new Widget({ id: 'fleet-ops-geofence-violations-widget', name: 'Geofence Violations', diff --git a/app/components/widget/proof-of-delivery.js b/app/components/widget/proof-of-delivery.js new file mode 100644 index 000000000..1f47e2ded --- /dev/null +++ b/app/components/widget/proof-of-delivery.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/widget/proof-of-delivery'; diff --git a/server/src/Http/Controllers/Internal/v1/ProofController.php b/server/src/Http/Controllers/Internal/v1/ProofController.php index 186d73495..9a59555e2 100644 --- a/server/src/Http/Controllers/Internal/v1/ProofController.php +++ b/server/src/Http/Controllers/Internal/v1/ProofController.php @@ -21,6 +21,53 @@ class ProofController extends FleetOpsController */ public $resource = 'proof'; + /** + * The most a dashboard can ask for in one request. + */ + public const RECENT_LIMIT_MAX = 24; + + /** + * The latest photo proofs captured for the company's orders, newest first, + * for the dashboard's proof-of-delivery tile. + */ + public function recent(Request $request) + { + $limit = max(1, min(static::RECENT_LIMIT_MAX, (int) $request->input('limit', 12))); + $proofs = Proof::where('company_uuid', session('company')) + ->whereNotNull('order_uuid') + ->whereHas('file', fn ($query) => $query->where('type', 'photo')) + ->with(['file', 'order.customer']) + ->orderByDesc('created_at') + ->limit($limit) + ->get(); + + return $this->jsonResponse([ + 'proofs' => $proofs->map(fn (Proof $proof) => $this->recentProofPayload($proof))->values()->all(), + ]); + } + + protected function recentProofPayload(Proof $proof): array + { + $order = $proof->order; + + return [ + 'id' => $proof->public_id, + 'order_id' => $order?->public_id, + 'customer_name' => data_get($order, 'customer.name'), + 'remarks' => $proof->remarks, + 'url' => $this->photoUrl($proof->file), + 'captured_at' => $proof->created_at?->toIso8601String(), + ]; + } + + /** + * File::url signs the object on s3/gcs, so a private bucket still serves the photo. + */ + protected function photoUrl(File $file): ?string + { + return $file->url; + } + /** * Verify a QR code. * diff --git a/server/src/routes.php b/server/src/routes.php index 9ee007feb..8060b5512 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -582,7 +582,12 @@ function ($router, $controller) { $router->post('import', $controller('import')); } ); - $router->fleetbaseRoutes('proofs'); + $router->fleetbaseRoutes( + 'proofs', + function ($router, $controller) { + $router->get('recent', $controller('recent')); + } + ); $router->fleetbaseRoutes('purchase-rates'); $router->fleetbaseRoutes('routes'); $router->fleetbaseRoutes('positions', function ($router, $controller) { diff --git a/server/tests/Feature/Http/Internal/ProofControllerRecentTest.php b/server/tests/Feature/Http/Internal/ProofControllerRecentTest.php new file mode 100644 index 000000000..d157347b5 --- /dev/null +++ b/server/tests/Feature/Http/Internal/ProofControllerRecentTest.php @@ -0,0 +1,170 @@ +path; + } +} + +function fleetOpsRecentProofDatabase(): SQLiteConnection +{ + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + EloquentModel::setEventDispatcher(new Dispatcher()); + EloquentModel::clearBootedModels(); + + $config = new Repository([ + 'activitylog' => ['enabled' => false, 'default_auth_driver' => null, 'default_log_name' => 'default'], + 'api' => ['cache' => ['enabled' => false]], + 'filesystems' => ['default' => 'local'], + ]); + app()->instance('config', $config); + app()->instance(Illuminate\Contracts\Config\Repository::class, $config); + + $common = ['uuid', 'public_id', '_key', 'company_uuid', 'meta', 'slug', 'internal_id']; + $tables = [ + 'proofs' => ['file_uuid', 'order_uuid', 'subject_uuid', 'subject_type', 'remarks', 'raw_data', 'data'], + 'files' => ['uploader_uuid', 'subject_uuid', 'subject_type', 'path', 'disk', 'bucket', 'type', 'content_type', 'original_filename'], + 'orders' => ['customer_uuid', 'customer_type', 'status', 'type'], + 'contacts' => ['name', 'type'], + 'vendors' => ['name', 'type'], + ]; + + $schema = $connection->getSchemaBuilder(); + foreach ($tables as $table => $columns) { + $schema->create($table, function ($blueprint) use ($common, $columns) { + $blueprint->increments('id'); + foreach (array_unique(array_merge($common, $columns)) as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + $connection->table('contacts')->insert(['uuid' => 'contact-ada', 'public_id' => 'contact_ada', 'company_uuid' => 'company-a', 'name' => 'Ada Lovelace', 'type' => 'customer']); + $connection->table('vendors')->insert(['uuid' => 'vendor-acme', 'public_id' => 'vendor_acme', 'company_uuid' => 'company-a', 'name' => 'Acme Retail']); + $connection->table('orders')->insert([ + ['uuid' => 'order-1', 'public_id' => 'order_1', 'company_uuid' => 'company-a', 'customer_uuid' => 'contact-ada', 'customer_type' => Contact::class], + ['uuid' => 'order-2', 'public_id' => 'order_2', 'company_uuid' => 'company-a', 'customer_uuid' => 'vendor-acme', 'customer_type' => Vendor::class], + ['uuid' => 'order-3', 'public_id' => 'order_3', 'company_uuid' => 'company-a', 'customer_uuid' => null, 'customer_type' => null], + ['uuid' => 'order-x', 'public_id' => 'order_x', 'company_uuid' => 'company-b', 'customer_uuid' => null, 'customer_type' => null], + ]); + + session(['company' => 'company-a']); + + return $connection; +} + +function fleetOpsRecentProof(SQLiteConnection $connection, string $key, string $createdAt, array $proof = [], ?string $fileType = 'photo'): void +{ + $proof += ['company_uuid' => 'company-a', 'order_uuid' => 'order-1', 'remarks' => 'Verified by Photo']; + + if ($fileType) { + $connection->table('files')->insert(['uuid' => "file-{$key}", 'public_id' => "file_{$key}", 'company_uuid' => $proof['company_uuid'], 'path' => "photos/{$key}.png", 'type' => $fileType]); + } + + $connection->table('proofs')->insert($proof + [ + 'uuid' => "proof-{$key}", + 'public_id' => "proof_{$key}", + 'file_uuid' => $fileType ? "file-{$key}" : null, + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); +} + +function fleetOpsRecentProofs(array $query = []): array +{ + return (new FleetOpsRecentProofController())->recent(Request::create('/int/v1/fleet-ops/proofs/recent', 'GET', $query))->getData(true)['proofs']; +} + +test('recent proofs are the company photo proofs on orders, newest first, with their order and customer', function () { + $db = fleetOpsRecentProofDatabase(); + + fleetOpsRecentProof($db, 'old', '2026-10-01 08:00:00'); + fleetOpsRecentProof($db, 'new', '2026-10-03 08:00:00', ['order_uuid' => 'order-2', 'remarks' => 'Left at the door']); + fleetOpsRecentProof($db, 'mid', '2026-10-02 08:00:00', ['order_uuid' => 'order-3']); + // Not photos: a signature, a QR scan with no file, a photo on no order. + fleetOpsRecentProof($db, 'signature', '2026-10-04 08:00:00', [], 'signature'); + fleetOpsRecentProof($db, 'scan', '2026-10-04 09:00:00', [], null); + fleetOpsRecentProof($db, 'orderless', '2026-10-04 10:00:00', ['order_uuid' => null]); + // Another company's photo. + fleetOpsRecentProof($db, 'foreign', '2026-10-05 08:00:00', ['company_uuid' => 'company-b', 'order_uuid' => 'order-x']); + + $proofs = fleetOpsRecentProofs(); + + expect(array_column($proofs, 'id'))->toBe(['proof_new', 'proof_mid', 'proof_old']) + ->and($proofs[0])->toBe([ + 'id' => 'proof_new', + 'order_id' => 'order_2', + 'customer_name' => 'Acme Retail', + 'remarks' => 'Left at the door', + 'url' => 'https://cdn.test/photos/new.png', + 'captured_at' => '2026-10-03T08:00:00+00:00', + ]) + ->and($proofs[1]['customer_name'])->toBeNull() + ->and($proofs[2]['customer_name'])->toBe('Ada Lovelace') + ->and($proofs[2]['order_id'])->toBe('order_1'); +}); + +test('recent proofs default to 12 and cap the limit between 1 and 24', function () { + $db = fleetOpsRecentProofDatabase(); + + foreach (range(1, 30) as $index) { + fleetOpsRecentProof($db, "p{$index}", sprintf('2026-10-01 08:%02d:00', $index)); + } + + expect(fleetOpsRecentProofs())->toHaveCount(12) + ->and(fleetOpsRecentProofs(['limit' => 5]))->toHaveCount(5) + ->and(fleetOpsRecentProofs(['limit' => 500]))->toHaveCount(ProofController::RECENT_LIMIT_MAX) + ->and(fleetOpsRecentProofs(['limit' => 0]))->toHaveCount(1) + ->and(fleetOpsRecentProofs(['limit' => 'abc']))->toHaveCount(1) + ->and(fleetOpsRecentProofs(['limit' => 5])[0]['id'])->toBe('proof_p30'); +}); + +test('recent proofs are empty when the session has no company', function () { + $db = fleetOpsRecentProofDatabase(); + fleetOpsRecentProof($db, 'one', '2026-10-01 08:00:00'); + session(['company' => null]); + + expect(fleetOpsRecentProofs())->toBe([]); +}); + +test('the photo url is the file url, signed for private buckets', function () { + $file = new class extends File { + public function getUrlAttribute() + { + return 'https://signed.test/photo.png'; + } + }; + + $method = new ReflectionMethod(ProofController::class, 'photoUrl'); + $method->setAccessible(true); + + expect($method->invoke(new ProofController(), $file))->toBe('https://signed.test/photo.png'); +}); + +test('the recent proofs route is registered with the proof routes', function () { + $routes = file_get_contents(dirname(__DIR__, 4) . '/src/routes.php'); + + expect($routes)->toContain("\$router->get('recent', \$controller('recent'));"); +}); diff --git a/tests/unit/components/widget/proof-of-delivery-test.js b/tests/unit/components/widget/proof-of-delivery-test.js new file mode 100644 index 000000000..6161a7f69 --- /dev/null +++ b/tests/unit/components/widget/proof-of-delivery-test.js @@ -0,0 +1,131 @@ +import Service from '@ember/service'; +import { setOwner } from '@ember/application'; +import { module, test } from 'qunit'; +import { settled } from '@ember/test-helpers'; +import { setupTest } from 'dummy/tests/helpers'; +import WidgetProofOfDeliveryComponent from 'dummy/components/widget/proof-of-delivery'; + +class FetchStub extends Service { + requests = []; + response = Promise.resolve({ proofs: [] }); + + get(path, query) { + this.requests.push([path, query]); + + return this.response; + } +} + +module('Unit | Component | widget/proof-of-delivery', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:fetch', FetchStub); + this.fetch = this.owner.lookup('service:fetch'); + // Glimmer only lets its component manager construct a component, so build the + // instance on the prototype and run what the constructor runs. + this.makeWidget = async () => { + const component = Object.create(WidgetProofOfDeliveryComponent.prototype); + setOwner(component, this.owner); + component.load.perform(); + await settled(); + + return component; + }; + }); + + test('it registers under the dashboard widget id', function (assert) { + assert.strictEqual(WidgetProofOfDeliveryComponent.widgetId, 'fleet-ops-proof-of-delivery-widget'); + }); + + test('loading asks for the latest photos and keeps them', async function (assert) { + const proofs = [{ id: 'proof_1', order_id: 'order_1', url: 'https://cdn.test/1.png' }]; + this.fetch.response = Promise.resolve({ proofs }); + + const component = await this.makeWidget(); + + assert.deepEqual(this.fetch.requests, [['fleet-ops/proofs/recent', { limit: 12 }]]); + assert.deepEqual(component.proofs, proofs); + assert.strictEqual(component.error, null); + }); + + test('an empty response leaves no proofs', async function (assert) { + this.fetch.response = Promise.resolve(null); + + const component = await this.makeWidget(); + + assert.deepEqual(component.proofs, []); + assert.deepEqual(component.photos, []); + }); + + test('a failed load reports the error', async function (assert) { + this.fetch.response = Promise.reject(new Error('Nope')); + const failing = await this.makeWidget(); + assert.strictEqual(failing.error, 'Nope'); + + this.fetch.response = Promise.reject({}); + const silent = await this.makeWidget(); + assert.strictEqual(silent.error, 'Failed to load'); + }); + + test('photos skip proofs without a url and carry a relative capture time', async function (assert) { + const component = await this.makeWidget(); + const capturedAt = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + component.proofs = [ + { id: 'proof_1', order_id: 'order_1', url: 'https://cdn.test/1.png', captured_at: capturedAt }, + { id: 'proof_2', order_id: 'order_2', url: null, captured_at: capturedAt }, + null, + { id: 'proof_3', order_id: null, url: 'https://cdn.test/3.png', captured_at: null }, + ]; + + const photos = component.photos; + + assert.deepEqual( + photos.map((photo) => photo.id), + ['proof_1', 'proof_3'] + ); + assert.strictEqual(photos[0].timeAgo, '5 minutes ago'); + assert.strictEqual(photos[0].order_id, 'order_1', 'keeps the proof fields'); + assert.strictEqual(photos[1].timeAgo, null, 'no capture time, no relative time'); + + component.proofs = null; + assert.deepEqual(component.photos, []); + }); + + test('time ago ignores invalid dates', async function (assert) { + const component = await this.makeWidget(); + + assert.strictEqual(component.timeAgo('not a date'), null); + assert.strictEqual(component.timeAgo(undefined), null); + }); + + test('order links resolve from the host dashboard and inside the engine', async function (assert) { + const component = await this.makeWidget(); + + assert.strictEqual(component.orderRoute, 'console.fleet-ops.operations.orders.index.details'); + + this.owner.mountPoint = 'console.fleet-ops'; + try { + assert.strictEqual(component.orderRoute, 'operations.orders.index.details'); + } finally { + delete this.owner.mountPoint; + } + }); + + test('opening a photo opens its url in a new tab', async function (assert) { + const component = await this.makeWidget(); + const originalOpen = window.open; + const opened = []; + window.open = (...args) => opened.push(args); + + try { + component.openPhoto({ url: 'https://cdn.test/1.png' }); + component.openPhoto({ url: null }); + component.openPhoto(undefined); + } finally { + window.open = originalOpen; + } + + assert.deepEqual(opened, [['https://cdn.test/1.png', '_blank', 'noopener,noreferrer']]); + }); +}); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index cf1043c0b..6ecf22a9f 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -2578,6 +2578,10 @@ widget: title: Geofence Violations live-fleet: title: Live Fleet Map + proof-of-delivery: + title: Proof of Delivery + open-photo: Open photo + empty: No proof of delivery photos yet. get-started: title: Get Started with Fleet-Ops subtitle: Set up the essentials and complete your first dispatch flow.