Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions addon/components/widget/proof-of-delivery.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<div class="fleet-ops-widget fleet-ops-proof-of-delivery flex flex-col h-full {{@class}}" data-test-proof-of-delivery-widget ...attributes>
<div class="flex items-center justify-between px-3 py-1.5 border dark:border-gray-700 border-gray-200 dark:bg-gray-800 bg-gray-50 rounded-t-md shadow-sm">
<span class="text-xs font-bold text-black dark:text-gray-100">{{t "widget.proof-of-delivery.title"}}</span>
<FaIcon @icon="camera" class="text-gray-400" />
</div>

<div class="flex-1 p-2 border border-t-0 dark:border-gray-700 border-gray-200 rounded-b-md bg-white dark:bg-gray-900 overflow-auto min-h-0">
{{#if this.load.isRunning}}
<div class="flex items-center justify-center h-full"><Spinner /></div>
{{else if this.error}}
<div class="flex items-center justify-center h-full text-xs text-red-500">{{this.error}}</div>
{{else if this.photos.length}}
<div class="grid grid-cols-3 gap-2">
{{#each this.photos as |proof|}}
<div class="min-w-0" data-test-proof-of-delivery-photo={{proof.id}}>
<button
type="button"
class="block w-full aspect-square rounded-md overflow-hidden border dark:border-gray-700 border-gray-200 bg-gray-100 dark:bg-gray-800 hover:opacity-90"
title={{or proof.remarks (t "widget.proof-of-delivery.open-photo")}}
aria-label={{t "widget.proof-of-delivery.open-photo"}}
{{on "click" (fn this.openPhoto proof)}}
>
<img src={{proof.url}} alt={{or proof.remarks (t "widget.proof-of-delivery.title")}} loading="lazy" class="w-full h-full object-cover" />
</button>
<div class="mt-0.5 leading-tight">
{{#if proof.order_id}}
<LinkTo
@route={{this.orderRoute}}
@model={{proof.order_id}}
class="block text-[10px] font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200 truncate"
title={{proof.customer_name}}
data-test-proof-of-delivery-order
>{{proof.order_id}}</LinkTo>
{{/if}}
{{#if proof.timeAgo}}
<div class="text-[9px] text-gray-500 dark:text-gray-400 truncate">{{proof.timeAgo}}</div>
{{/if}}
</div>
</div>
{{/each}}
</div>
{{else}}
<div class="flex flex-col items-center justify-center h-full text-center text-gray-500 dark:text-gray-400" data-test-proof-of-delivery-empty>
<FaIcon @icon="camera" class="text-lg mb-1 text-gray-300 dark:text-gray-600" />
<div class="text-[11px]">{{t "widget.proof-of-delivery.empty"}}</div>
</div>
{{/if}}
</div>
</div>
62 changes: 62 additions & 0 deletions addon/components/widget/proof-of-delivery.js
Original file line number Diff line number Diff line change
@@ -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';
}
}
}
10 changes: 10 additions & 0 deletions addon/extension.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions app/components/widget/proof-of-delivery.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from '@fleetbase/fleetops-engine/components/widget/proof-of-delivery';
47 changes: 47 additions & 0 deletions server/src/Http/Controllers/Internal/v1/ProofController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
7 changes: 6 additions & 1 deletion server/src/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
170 changes: 170 additions & 0 deletions server/tests/Feature/Http/Internal/ProofControllerRecentTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php

use Fleetbase\FleetOps\Http\Controllers\Internal\v1\ProofController;
use Fleetbase\FleetOps\Models\Contact;
use Fleetbase\FleetOps\Models\Vendor;
use Fleetbase\Models\File;
use Illuminate\Config\Repository;
use Illuminate\Database\ConnectionResolver;
use Illuminate\Database\Eloquent\Model as EloquentModel;
use Illuminate\Database\SQLiteConnection;
use Illuminate\Events\Dispatcher;
use Illuminate\Http\Request;

/**
* The real query over an in-memory database; only the signed file URL is stood in,
* since File::url resolves a storage disk the harness does not boot.
*/
class FleetOpsRecentProofController extends ProofController
{
protected function photoUrl(File $file): ?string
{
return 'https://cdn.test/' . $file->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'));");
});
Loading
Loading