diff --git a/addon/components/map/leaflet-live-map.hbs b/addon/components/map/leaflet-live-map.hbs index a82b064b5..920918b44 100644 --- a/addon/components/map/leaflet-live-map.hbs +++ b/addon/components/map/leaflet-live-map.hbs @@ -83,108 +83,10 @@ as |marker| > -
-
- - {{driver.name}} -
- {{n-a driver.meta.status_label (smart-humanize driver.status)}} -
-
-
-
-
ID
-
{{n-a driver.public_id driver.id}}
-
-
-
Phone
-
{{n-a driver.phone}}
-
-
-
Vehicle
-
{{n-a driver.vehicle_name}}
-
-
-
Email
-
{{n-a driver.email}}
-
-
-
Order
-
{{n-a driver.meta.current_order_reference}}
-
-
-
Speed
-
{{n-a driver.meta.speed_label}}
-
-
-
Heading
-
{{n-a driver.meta.heading_label}}
-
-
-
Location
-
{{n-a - driver.meta.location_coordinates - (point-coordinates driver.location) - }}
-
-
-
+
-
-
- - {{driver.name}} -
- {{n-a driver.meta.status_label (smart-humanize driver.status)}} -
-
-
-
-
ID
-
{{n-a driver.public_id driver.id}}
-
-
-
Phone
-
{{n-a driver.phone}}
-
-
-
Vehicle
-
{{n-a driver.vehicle_name}}
-
-
-
Email
-
{{n-a driver.email}}
-
-
-
Order
-
{{n-a driver.meta.current_order_reference}}
-
-
-
Speed
-
{{n-a driver.meta.speed_label}}
-
-
-
Heading
-
{{n-a driver.meta.heading_label}}
-
-
-
Location
-
{{n-a - driver.meta.location_coordinates - (point-coordinates driver.location) - }}
-
-
-
+
{{/each}} @@ -202,150 +104,10 @@ as |marker| > -
-
- - {{vehicle.displayName}} -
- {{n-a vehicle.meta.status_label}} -
-
-
-
-
Vehicle #
-
{{n-a - vehicle.internal_id - vehicle.public_id - vehicle.plate_number - vehicle.serial_number - vehicle.vin - }}
-
-
-
Driver
-
{{n-a vehicle.driver_name}}
-
-
-
{{t "resource.trailers"}}
-
- {{#each vehicle.trailers as |trailer|}} -
- - {{or trailer.displayName trailer.display_name trailer.name trailer.public_id}} -
- {{else}} - - - {{/each}} -
-
-
-
{{t "resource.devices"}}
-
- {{#each vehicle.devices as |device|}} -
- - {{or device.displayName device.display_name device.name device.device_id device.public_id}} -
- {{else}} - - - {{/each}} -
-
-
-
Order
-
{{n-a vehicle.meta.current_order_reference}}
-
-
-
Speed
-
{{n-a vehicle.meta.speed_label}}
-
-
-
Heading
-
{{n-a vehicle.meta.heading_label}}
-
-
-
Location
-
{{n-a vehicle.meta.location_coordinates}}
-
-
-
+
-
-
- - {{vehicle.displayName}} -
- {{n-a vehicle.meta.status_label}} -
-
-
-
-
Vehicle #
-
{{n-a - vehicle.internal_id - vehicle.public_id - vehicle.plate_number - vehicle.serial_number - vehicle.vin - }}
-
-
-
Driver
-
{{n-a vehicle.driver_name}}
-
-
-
{{t "resource.trailers"}}
-
- {{#each vehicle.trailers as |trailer|}} -
- - {{or trailer.displayName trailer.display_name trailer.name trailer.public_id}} -
- {{else}} - - - {{/each}} -
-
-
-
{{t "resource.devices"}}
-
- {{#each vehicle.devices as |device|}} -
- - {{or device.displayName device.display_name device.name device.device_id device.public_id}} -
- {{else}} - - - {{/each}} -
-
-
-
Order
-
{{n-a vehicle.meta.current_order_reference}}
-
-
-
Speed
-
{{n-a vehicle.meta.speed_label}}
-
-
-
Heading
-
{{n-a vehicle.meta.heading_label}}
-
-
-
Location
-
{{n-a vehicle.meta.location_coordinates}}
-
-
-
+
{{/each}} diff --git a/addon/components/map/marker-card/driver.hbs b/addon/components/map/marker-card/driver.hbs new file mode 100644 index 000000000..ff15f7e6d --- /dev/null +++ b/addon/components/map/marker-card/driver.hbs @@ -0,0 +1,57 @@ +{{! + The driver card the fleet-ops live map shows in a marker's popup and hover tooltip. + Shared with the dashboard's Live Fleet widget so both look the same. @driver is a driver + model or its index resource payload (the same attributes, plain JSON). + @variant: "popup" (default, a dark panel) or "tooltip" (inherits the tooltip's panel). +}} +
+
+ + {{@driver.name}} +
+ {{n-a @driver.meta.status_label (smart-humanize @driver.status)}} +
+
+
+
+
ID
+
{{n-a @driver.public_id @driver.id}}
+
+
+
Phone
+
{{n-a @driver.phone}}
+
+
+
Vehicle
+
{{n-a @driver.vehicle_name}}
+
+
+
Email
+
{{n-a @driver.email}}
+
+
+
Order
+
{{n-a @driver.meta.current_order_reference}}
+
+
+
Speed
+
{{n-a @driver.meta.speed_label}}
+
+
+
Heading
+
{{n-a @driver.meta.heading_label}}
+
+
+
Location
+
{{n-a + @driver.meta.location_coordinates + (point-coordinates @driver.location) + }}
+
+
+
+ diff --git a/addon/components/map/marker-card/driver.js b/addon/components/map/marker-card/driver.js new file mode 100644 index 000000000..fa8505356 --- /dev/null +++ b/addon/components/map/marker-card/driver.js @@ -0,0 +1,3 @@ +import templateOnly from '@ember/component/template-only'; + +export default templateOnly(); diff --git a/addon/components/map/marker-card/vehicle.hbs b/addon/components/map/marker-card/vehicle.hbs new file mode 100644 index 000000000..a6f5544cc --- /dev/null +++ b/addon/components/map/marker-card/vehicle.hbs @@ -0,0 +1,78 @@ +{{! + The vehicle card the fleet-ops live map shows in a marker's popup and hover tooltip. + Shared with the dashboard's Live Fleet widget so both look the same. @vehicle is a vehicle + model or its index resource payload (the same attributes, plain JSON). + @variant: "popup" (default, a dark panel) or "tooltip" (inherits the tooltip's panel). +}} +
+
+ + {{or @vehicle.displayName @vehicle.display_name @vehicle.name}} +
+ {{n-a @vehicle.meta.status_label}} +
+
+
+
+
Vehicle #
+
{{n-a + @vehicle.internal_id + @vehicle.public_id + @vehicle.plate_number + @vehicle.serial_number + @vehicle.vin + }}
+
+
+
Driver
+
{{n-a @vehicle.driver_name}}
+
+
+
{{t "resource.trailers"}}
+
+ {{#each @vehicle.trailers as |trailer|}} +
+ + {{or trailer.displayName trailer.display_name trailer.name trailer.public_id}} +
+ {{else}} + - + {{/each}} +
+
+
+
{{t "resource.devices"}}
+
+ {{#each @vehicle.devices as |device|}} +
+ + {{or device.displayName device.display_name device.name device.device_id device.public_id}} +
+ {{else}} + - + {{/each}} +
+
+
+
Order
+
{{n-a @vehicle.meta.current_order_reference}}
+
+
+
Speed
+
{{n-a @vehicle.meta.speed_label}}
+
+
+
Heading
+
{{n-a @vehicle.meta.heading_label}}
+
+
+
Location
+
{{n-a @vehicle.meta.location_coordinates}}
+
+
+
+ diff --git a/addon/components/map/marker-card/vehicle.js b/addon/components/map/marker-card/vehicle.js new file mode 100644 index 000000000..fa8505356 --- /dev/null +++ b/addon/components/map/marker-card/vehicle.js @@ -0,0 +1,3 @@ +import templateOnly from '@ember/component/template-only'; + +export default templateOnly(); diff --git a/addon/components/widget/live-fleet.hbs b/addon/components/widget/live-fleet.hbs index 82965680b..01ef5974d 100644 --- a/addon/components/widget/live-fleet.hbs +++ b/addon/components/widget/live-fleet.hbs @@ -28,7 +28,8 @@ {{#if this.load.isIdle}} {{! Drivers — fleetops' rotating tracking-marker matches the operational - live-map's look and animates on heading changes. }} + live-map's look and animates on heading changes. The popup and tooltip + are the live map's own cards, fed from each marker's index-resource card. }} {{#each this.drivers as |driver|}} - -
-
- {{driver.name}} -
-
-
{{or driver.name "Driver"}}
-
- {{#if driver.current_order_uuid}} - On order - {{else if driver.online}} - Online - {{else}} - Offline - {{/if}} -
-
{{driver.lat}}, {{driver.lng}}
-
-
+ + -
{{or driver.name "Driver"}}
+
{{/each}} @@ -77,27 +61,11 @@ @draggable={{false}} as |marker| > - -
-
- {{vehicle.name}} -
-
-
{{or vehicle.name "Vehicle"}}
- {{#if vehicle.plate_number}} -
{{vehicle.plate_number}}
- {{/if}} - {{#if vehicle.driver_name}} -
- {{vehicle.driver_name}} -
- {{/if}} -
{{vehicle.lat}}, {{vehicle.lng}}
-
-
+ + -
{{or vehicle.name "Vehicle"}}
+
{{/each}} diff --git a/addon/extension.js b/addon/extension.js index bcf5b4602..925f632f9 100644 --- a/addon/extension.js +++ b/addon/extension.js @@ -161,6 +161,14 @@ export default { }, registerWidgets(widgetService) { + // `order` places a default widget on the default dashboard, which mixes widgets from + // every extension; the grid fills rows first-fit, lowest order first (widgets without + // one follow). The default layout is: + // 10-40 KPI row: Radar, Revenue (ledger, 20), Active Orders, Drivers Online + // 41-44 Ledger KPI row: Expenses, Net Income, Outstanding AR, Overdue AR + // 50 Live Fleet Map, full width + // 60-80 Revenue Trend, Top Drivers, Maintenance Overview, a third of the width each + // 150-170 Ledger Recent Financial Activity + Cash Flow Summary | console Blog + GitHub const widgets = [ // Legacy monolithic 13-tile widget — kept registered for one release as // users have it pinned to existing dashboards. The new KPI tile widgets @@ -186,7 +194,7 @@ export default { component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/kpi-earnings'), grid_options: { w: 3, h: 4, minW: 3, minH: 4 }, category: 'KPI Tiles', - default: true, + default: false, }), new Widget({ id: 'fleet-ops-kpi-aov-widget', @@ -196,7 +204,7 @@ export default { component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/kpi-aov'), grid_options: { w: 3, h: 4, minW: 3, minH: 4 }, category: 'KPI Tiles', - default: true, + default: false, }), new Widget({ id: 'fleet-ops-kpi-distance-widget', @@ -216,6 +224,7 @@ export default { component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/kpi-active-orders'), grid_options: { w: 3, h: 4, minW: 3, minH: 4 }, category: 'KPI Tiles', + order: 30, default: true, }), new Widget({ @@ -226,6 +235,7 @@ export default { component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/kpi-drivers-online'), grid_options: { w: 3, h: 4, minW: 3, minH: 4 }, category: 'KPI Tiles', + order: 40, default: true, }), new Widget({ @@ -236,6 +246,7 @@ export default { component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/radar'), grid_options: { w: 3, h: 4, minW: 3, minH: 4 }, category: 'KPI Tiles', + order: 10, default: true, }), new Widget({ @@ -266,8 +277,9 @@ export default { description: 'Real-time driver positions and active routes.', icon: 'map-location-dot', component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/live-fleet'), - grid_options: { w: 8, h: 11, minW: 8, minH: 8 }, + grid_options: { w: 12, h: 12, minW: 8, minH: 8 }, category: 'Maps', + order: 50, default: true, }), new Widget({ @@ -276,8 +288,9 @@ export default { description: 'Revenue over time with period comparison.', icon: 'chart-line', component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/revenue-trend'), - grid_options: { w: 4, h: 11, minW: 4, minH: 8 }, + grid_options: { w: 4, h: 9, minW: 4, minH: 7 }, category: 'Analytics', + order: 60, default: true, }), new Widget({ @@ -306,8 +319,9 @@ export default { description: 'Driver leaderboard sortable by orders, on-time %, or distance.', icon: 'medal', component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/top-drivers'), - grid_options: { w: 6, h: 6, minW: 5, minH: 5 }, + grid_options: { w: 4, h: 9, minW: 4, minH: 6 }, category: 'Analytics', + order: 70, default: true, }), new Widget({ @@ -346,8 +360,9 @@ export default { description: 'Overdue, scheduled, and YTD maintenance spend.', icon: 'wrench', component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/maintenance-overview'), - grid_options: { w: 6, h: 6, minW: 5, minH: 5 }, + grid_options: { w: 4, h: 9, minW: 4, minH: 6 }, category: 'Analytics', + order: 80, default: true, }), new Widget({ diff --git a/app/components/map/marker-card/driver.js b/app/components/map/marker-card/driver.js new file mode 100644 index 000000000..233ee41cb --- /dev/null +++ b/app/components/map/marker-card/driver.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/map/marker-card/driver'; diff --git a/app/components/map/marker-card/vehicle.js b/app/components/map/marker-card/vehicle.js new file mode 100644 index 000000000..524d8ad84 --- /dev/null +++ b/app/components/map/marker-card/vehicle.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/map/marker-card/vehicle'; diff --git a/server/src/Http/Controllers/Internal/v1/FuelReportController.php b/server/src/Http/Controllers/Internal/v1/FuelReportController.php index 9ff460a31..c2e8db2bf 100644 --- a/server/src/Http/Controllers/Internal/v1/FuelReportController.php +++ b/server/src/Http/Controllers/Internal/v1/FuelReportController.php @@ -9,6 +9,7 @@ use Fleetbase\Http\Requests\ExportRequest; use Fleetbase\Http\Requests\ImportRequest; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Illuminate\Support\Str; use Maatwebsite\Excel\Facades\Excel; @@ -23,11 +24,16 @@ class FuelReportController extends FleetOpsController /** * Handle post save transactions. + * + * Reads the custom field values from the payload the base controller already extracted + * rather than from the raw request: Ember Data sends the resource under a camelCase root + * (`fuelReport`), so `$request->array('fuel_report.custom_field_values')` was always empty and no + * custom field value was ever persisted for this resource. */ - public function afterSave(Request $request, FuelReport $fuelReport) + public function afterSave(Request $request, FuelReport $fuelReport, array $input = []) { - $customFieldValues = $request->array('fuel_report.custom_field_values'); - if ($customFieldValues) { + $customFieldValues = Arr::get($input, 'custom_field_values'); + if (is_array($customFieldValues) && $customFieldValues) { $fuelReport->syncCustomFieldValues($customFieldValues); } } diff --git a/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php b/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php index 320a83265..36926e45a 100644 --- a/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php +++ b/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php @@ -7,6 +7,7 @@ use Fleetbase\FleetOps\Models\ServiceArea; use Fleetbase\Http\Requests\ExportRequest; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Illuminate\Support\Str; use Maatwebsite\Excel\Facades\Excel; @@ -21,11 +22,16 @@ class ServiceAreaController extends FleetOpsController /** * Handle post save transactions. + * + * Reads the custom field values from the payload the base controller already extracted + * rather than from the raw request: Ember Data sends the resource under a camelCase root + * (`serviceArea`), so `$request->array('service_area.custom_field_values')` was always empty and no + * custom field value was ever persisted for this resource. */ - public function afterSave(Request $request, ServiceArea $serviceArea) + public function afterSave(Request $request, ServiceArea $serviceArea, array $input = []) { - $customFieldValues = $request->array('service_area.custom_field_values'); - if ($customFieldValues) { + $customFieldValues = Arr::get($input, 'custom_field_values'); + if (is_array($customFieldValues) && $customFieldValues) { $serviceArea->syncCustomFieldValues($customFieldValues); } } diff --git a/server/src/Support/Analytics/LiveFleet.php b/server/src/Support/Analytics/LiveFleet.php index 9cfbccca6..1eff6bd5d 100644 --- a/server/src/Support/Analytics/LiveFleet.php +++ b/server/src/Support/Analytics/LiveFleet.php @@ -2,10 +2,13 @@ namespace Fleetbase\FleetOps\Support\Analytics; +use Fleetbase\FleetOps\Http\Resources\v1\Index\Driver as DriverIndexResource; +use Fleetbase\FleetOps\Http\Resources\v1\Index\Vehicle as VehicleIndexResource; use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Vehicle; use Fleetbase\FleetOps\Support\Metrics\OrdersInProgressMetric; +use Illuminate\Http\Resources\Json\JsonResource; /** * Real-time fleet snapshot used as the initial-state payload for Live Fleet Map. @@ -23,7 +26,8 @@ public function get(): array // Driver.name is a virtual attribute backed by users.name — eager-load the // user so the accessor resolves cleanly without a per-record query. - $drivers = Driver::with(['user:uuid,name,avatar_uuid']) + // The user and vehicle also feed each marker's card (see card()). + $drivers = Driver::with(['user', 'vehicle']) ->where('drivers.company_uuid', $companyUuid) ->whereNotNull('drivers.location') ->where(function ($q) { @@ -33,7 +37,8 @@ public function get(): array // Vehicles tracked independently of drivers (telematics-connected rigs may // report position without an active driver session). - $vehicles = Vehicle::where('company_uuid', $companyUuid) + $vehicles = Vehicle::with(['devices', 'driver', 'currentTrailers']) + ->where('company_uuid', $companyUuid) ->whereNotNull('location') ->get(); @@ -79,6 +84,7 @@ private function driverPayload(Driver $d): array 'lat' => $lat, 'lng' => $lng, 'updated_at' => $d->last_location_update_at, + 'card' => $this->card(new DriverIndexResource($d)), ]; } @@ -98,9 +104,19 @@ private function vehiclePayload(Vehicle $v): array 'heading' => (float) ($v->heading ?? 0), 'lat' => $lat, 'lng' => $lng, + 'card' => $this->card(new VehicleIndexResource($v)), ]; } + /** + * What the operational live map shows in a marker's popup and tooltip: the same index + * resource its live endpoints return, so the dashboard widget renders identical cards. + */ + private function card(JsonResource $resource): array + { + return $resource->resolve(request()); + } + /** * Pull [lat, lng] out of whatever shape the spatial accessor handed us. * Supports Point objects, [lat, lng] arrays, and {lat, lng} hashes. diff --git a/server/src/Support/Reporting/FleetOpsReportSchema.php b/server/src/Support/Reporting/FleetOpsReportSchema.php index dc83928ff..2b4ff8acd 100644 --- a/server/src/Support/Reporting/FleetOpsReportSchema.php +++ b/server/src/Support/Reporting/FleetOpsReportSchema.php @@ -47,12 +47,20 @@ public function registerReportSchema(ReportSchemaRegistry $registry): void /** * Create the Orders table definition. + * + * Money is stored in the currency's smallest unit (e.g. cents). `meta` has no fixed shape + * (it holds whatever users, integrations and extensions put there), so no column assumes a + * key in it; read one with a computed column, e.g. + * `CAST(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.total')) AS DECIMAL(15,2))`. + * + * Selecting line item columns (Payload Items) returns one row per item, so order-level + * sums over item rows repeat each order; count orders with Total Orders (a distinct count). */ protected function createOrdersTable(): Table { - return Table::make('orders') + return $this->softDeletes(Table::make('orders')) ->label('Orders') - ->description('Delivery and service orders') + ->description('Delivery and service orders, with their payload items, tracking, assignment and payment') ->category('Operations') ->extension('fleet-ops') ->excludeColumns(['uuid', 'deleted_at']) // Hide foreign keys and system columns @@ -60,7 +68,7 @@ protected function createOrdersTable(): Table ->cacheTtl(3600) ->columns([ Column::make('public_id', 'string') - ->label('Order ID') + ->label('ID') ->description('Public order identifier') ->searchable() ->filterable() @@ -94,8 +102,21 @@ protected function createOrdersTable(): Table }), Column::make('type', 'string') - ->label('Order Type') - ->description('Type of order service') + ->label('Type') + ->description('Type of order service, e.g. transport or storefront') + ->filterable() + ->sortable() + ->aggregatable(), + + Column::make('customer_type', 'string') + ->label('Customer Kind') + ->description('Whether the customer is a contact or a vendor') + ->filterable() + ->aggregatable(), + + Column::make('facilitator_type', 'string') + ->label('Facilitator Kind') + ->description('Whether the facilitator is a vendor or an integrated vendor') ->filterable() ->aggregatable(), @@ -106,6 +127,12 @@ protected function createOrdersTable(): Table ->sortable() ->aggregatable(), + Column::make('dispatched', 'boolean') + ->label('Dispatched') + ->description('Whether the order has been dispatched') + ->filterable() + ->aggregatable(), + Column::make('dispatched_at', 'datetime') ->label('Dispatched At') ->description('When the order was dispatched') @@ -113,6 +140,12 @@ protected function createOrdersTable(): Table ->sortable() ->aggregatable(), + Column::make('started', 'boolean') + ->label('Started') + ->description('Whether the order has been started') + ->filterable() + ->aggregatable(), + Column::make('started_at', 'datetime') ->label('Started At') ->description('When the order was started') @@ -120,18 +153,29 @@ protected function createOrdersTable(): Table ->sortable() ->aggregatable(), + Column::make('time_window_start', 'datetime') + ->label('Time Window Start') + ->description('Earliest time the order may be serviced') + ->filterable() + ->sortable(), + + Column::make('time_window_end', 'datetime') + ->label('Time Window End') + ->description('Latest time the order may be serviced') + ->filterable() + ->sortable(), + Column::make('distance', 'integer') - ->label('Distance (km)') - ->description('Total distance for the order') + ->label('Distance (m)') + ->description('Route distance for the order in meters') + ->filterable() ->aggregatable() - ->sortable() - ->transformer(function ($value) { - return round($value * 0.621371, 2); // Convert km to miles - }), + ->sortable(), Column::make('time', 'integer') - ->label('Duration (minutes)') - ->description('Estimated duration in minutes') + ->label('Duration (s)') + ->description('Estimated route duration in seconds') + ->filterable() ->aggregatable() ->sortable(), @@ -144,6 +188,12 @@ protected function createOrdersTable(): Table return $value ? 'Yes' : 'No'; }), + Column::make('adhoc_distance', 'integer') + ->label('Ad Hoc Distance (m)') + ->description('Radius in meters used to offer an ad hoc order to drivers') + ->filterable() + ->sortable(), + Column::make('pod_required', 'boolean') ->label('POD Required') ->description('Whether proof of delivery is required') @@ -153,6 +203,31 @@ protected function createOrdersTable(): Table return $value ? 'Yes' : 'No'; }), + Column::make('pod_method', 'string') + ->label('POD Method') + ->description('Proof of delivery method, e.g. scan, signature or photo') + ->filterable() + ->aggregatable(), + + Column::make('is_route_optimized', 'boolean') + ->label('Route Optimized') + ->description('Whether the route was optimized') + ->filterable() + ->aggregatable(), + + Column::make('orchestrator_priority', 'integer') + ->label('Priority') + ->description('Dispatch priority used by the orchestrator') + ->filterable() + ->sortable() + ->aggregatable(), + + Column::make('notes', 'string') + ->label('Notes') + ->description('Order notes') + ->searchable() + ->filterable(), + Column::make('created_at', 'datetime') ->label('Created At') ->description('When the order was created') @@ -168,76 +243,156 @@ protected function createOrdersTable(): Table Column::make('meta', 'json') ->label('Metadata') - ->description('Order metadata and custom fields') + ->description('Order metadata and custom fields; its keys vary by order, so read one with a computed column, e.g. JSON_UNQUOTE(JSON_EXTRACT(meta, \'$.key\'))') ->searchable() ->filterable(), ]) ->computedColumns([ - Column::count('total_orders', 'id') + // Distinct, so the count stays right when payload items are selected too. + Column::count('total_orders', 'DISTINCT id') ->label('Total Orders') - ->description('Count of orders'), + ->description('Number of orders'), + + Column::computed('completed_orders', "COUNT(DISTINCT CASE WHEN status = 'completed' THEN id END)", 'integer') + ->label('Completed Orders') + ->description('Number of completed orders'), + + Column::computed('canceled_orders', "COUNT(DISTINCT CASE WHEN status = 'canceled' THEN id END)", 'integer') + ->label('Canceled Orders') + ->description('Number of canceled orders'), Column::sum('total_distance', 'distance') - ->label('Total Distance') + ->label('Total Distance (m)') ->description('Sum of all order distances'), Column::avg('average_distance', 'distance') - ->label('Average Distance') + ->label('Average Distance (m)') ->description('Average distance per order'), Column::sum('total_time', 'time') - ->label('Total Time') + ->label('Total Duration (s)') ->description('Sum of all order durations'), Column::avg('average_time', 'time') - ->label('Average Time') + ->label('Average Duration (s)') ->description('Average duration per order'), - Column::sum('total_transaction_amount', 'amount') - ->label('Total Transaction Amount') - ->description('Sum of all transaction amounts'), + Column::sum('total_transaction_amount', 'transaction.amount') + ->label('Transaction Amount Sum (minor units)') + ->description('Sum of the orders\' transaction amounts'), - Column::avg('average_transaction_amount', 'amount') - ->label('Average Transaction Amount') + Column::avg('average_transaction_amount', 'transaction.amount') + ->label('Average Transaction Amount (minor units)') ->description('Average transaction amount per order'), - Column::count('orders_with_transactions', 'transaction_uuid') + Column::count('orders_with_transactions', 'DISTINCT transaction_uuid') ->label('Orders with Transactions') - ->description('Count of orders that have transactions'), + ->description('Number of orders that have a transaction'), ]) ->relationships([ - // Auto-join relationships for seamless access + Relationship::hasAutoJoin('tracking_number', 'tracking_numbers') + ->label('Tracking') + ->description('The order\'s tracking number and its latest tracking status') + ->localKey('tracking_number_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('tracking_number', 'string')->label('Number')->description('Tracking number'), + Column::make('public_id', 'string')->label('ID')->description('Tracking number record identifier'), + Column::make('region', 'string')->label('Region'), + ]) + ->with([ + Relationship::hasAutoJoin('status', 'tracking_statuses') + ->label('Tracking Status') + ->localKey('status_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('status', 'string')->label('Tracking Status'), + Column::make('code', 'string')->label('Code'), + Column::make('details', 'string')->label('Details'), + Column::make('complete', 'boolean')->label('Complete'), + Column::make('city', 'string')->label('City'), + Column::make('province', 'string')->label('Province'), + Column::make('country', 'string')->label('Country'), + Column::make('created_at', 'datetime')->label('Updated At'), + ]), + ]), + + Relationship::hasAutoJoin('order_config', 'order_configs') + ->label('Order Config') + ->description('The order configuration (order type) the order follows') + ->localKey('order_config_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('name', 'string')->label('Name'), + Column::make('key', 'string')->label('Key'), + Column::make('namespace', 'string')->label('Namespace'), + ]), + Relationship::hasAutoJoin('payload', 'payloads') ->label('Payload') ->localKey('payload_uuid') ->foreignKey('uuid') + ->columns([ + Column::make('public_id', 'string')->label('ID')->description('Payload identifier'), + Column::make('type', 'string')->label('Type'), + Column::make('payment_method', 'string')->label('Payment Method'), + Column::make('cod_amount', 'integer')->label('COD Amount (minor units)')->description('Cash on delivery amount in the currency\'s smallest unit'), + Column::make('cod_currency', 'string')->label('COD Currency'), + Column::make('cod_payment_method', 'string')->label('COD Payment Method'), + Column::make('provider', 'string')->label('Provider'), + ]) ->with([ Relationship::hasAutoJoin('pickup', 'places') ->label('Pickup') ->localKey('pickup_uuid') ->foreignKey('uuid') - ->columns([ - Column::make('name', 'string')->label('Name'), - Column::make('street1', 'string')->label('Street'), - Column::make('street2', 'string')->label('Street 2'), - Column::make('city', 'string')->label('City'), - Column::make('province', 'string')->label('Province'), - Column::make('postal_code', 'string')->label('Postal Code'), - Column::make('country', 'string')->label('Country'), - ]), + ->columns($this->placeColumns()), Relationship::hasAutoJoin('dropoff', 'places') ->label('Dropoff') ->localKey('dropoff_uuid') ->foreignKey('uuid') + ->columns($this->placeColumns()), + + Relationship::hasAutoJoin('return', 'places') + ->label('Return') + ->localKey('return_uuid') + ->foreignKey('uuid') + ->columns($this->placeColumns()), + + // One row per item: the goods, parcels or storefront products in the order. + $this->softDeletes(Relationship::hasAutoJoin('entities', 'entities')) + ->label('Item') + ->description('Items carried by the order (storefront products, parcels, goods); one row per item') + ->localKey('uuid') + ->foreignKey('payload_uuid') ->columns([ - Column::make('name', 'string')->label('Name'), - Column::make('street1', 'string')->label('Street'), - Column::make('street2', 'string')->label('Street 2'), - Column::make('city', 'string')->label('City'), - Column::make('province', 'string')->label('Province'), - Column::make('postal_code', 'string')->label('Postal Code'), - Column::make('country', 'string')->label('Country'), + Column::make('public_id', 'string')->label('ID')->description('Item identifier'), + Column::make('internal_id', 'string')->label('Internal ID')->description('Internal reference; the product ID for storefront items'), + Column::make('name', 'string')->label('Name')->aggregatable(), + Column::make('type', 'string')->label('Type')->aggregatable(), + Column::make('description', 'string')->label('Description'), + Column::make('sku', 'string')->label('SKU')->aggregatable(), + Column::make('currency', 'string')->label('Currency'), + Column::make('price', 'decimal')->label('Price (minor units)')->description('Unit price in the currency\'s smallest unit'), + Column::make('sale_price', 'decimal')->label('Sale Price (minor units)')->description('Unit sale price in the currency\'s smallest unit'), + Column::make('declared_value', 'integer')->label('Declared Value (minor units)'), + Column::make('weight', 'decimal')->label('Weight'), + Column::make('weight_unit', 'string')->label('Weight Unit'), + Column::make('length', 'decimal')->label('Length'), + Column::make('width', 'decimal')->label('Width'), + Column::make('height', 'decimal')->label('Height'), + Column::make('dimensions_unit', 'string')->label('Dimensions Unit'), + Column::make('barcode', 'string')->label('Barcode'), + Column::make('meta', 'json')->label('Metadata')->description('Item metadata; read a key with a computed column, e.g. JSON_EXTRACT(payload.entities.meta, \'$.quantity\')'), + Column::make('created_at', 'datetime')->label('Created At'), + ]) + ->with([ + Relationship::hasAutoJoin('destination', 'places') + ->label('Destination') + ->localKey('destination_uuid') + ->foreignKey('uuid') + ->columns($this->placeColumns()), ]), ]), @@ -246,6 +401,8 @@ protected function createOrdersTable(): Table ->localKey('driver_assigned_uuid') ->foreignKey('uuid') ->columns([ + Column::make('public_id', 'string')->label('ID')->description('Driver identifier'), + Column::make('internal_id', 'string')->label('Internal ID'), Column::make('drivers_license_number', 'string')->label('License Number'), Column::make('country', 'string')->label('Country'), Column::make('city', 'string')->label('City'), @@ -268,6 +425,9 @@ protected function createOrdersTable(): Table ->localKey('vehicle_assigned_uuid') ->foreignKey('uuid') ->columns([ + Column::make('public_id', 'string')->label('ID')->description('Vehicle identifier'), + Column::make('internal_id', 'string')->label('Internal ID'), + Column::make('name', 'string')->label('Name'), Column::make('make', 'string')->label('Make'), Column::make('model', 'string')->label('Model'), Column::make('year', 'integer')->label('Year'), @@ -279,9 +439,27 @@ protected function createOrdersTable(): Table Relationship::hasAutoJoin('customer', 'contacts') ->label('Customer') + ->description('The customer when it is a contact') + ->localKey('customer_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('public_id', 'string')->label('ID')->description('Customer identifier'), + Column::make('internal_id', 'string')->label('Internal ID'), + Column::make('name', 'string')->label('Name'), + Column::make('title', 'string')->label('Title'), + Column::make('email', 'string')->label('Email'), + Column::make('phone', 'string')->label('Phone'), + Column::make('type', 'string')->label('Type'), + ]), + + Relationship::hasAutoJoin('customer_vendor', 'vendors') + ->label('Customer Vendor') + ->description('The customer when it is a vendor') ->localKey('customer_uuid') ->foreignKey('uuid') ->columns([ + Column::make('public_id', 'string')->label('ID')->description('Vendor identifier'), + Column::make('internal_id', 'string')->label('Internal ID'), Column::make('name', 'string')->label('Name'), Column::make('email', 'string')->label('Email'), Column::make('phone', 'string')->label('Phone'), @@ -289,16 +467,48 @@ protected function createOrdersTable(): Table ]), Relationship::hasAutoJoin('facilitator', 'vendors') - ->label('Faciliator') + ->label('Facilitator') ->localKey('facilitator_uuid') ->foreignKey('uuid') ->columns([ + Column::make('public_id', 'string')->label('ID')->description('Facilitator identifier'), + Column::make('internal_id', 'string')->label('Internal ID'), Column::make('name', 'string')->label('Name'), Column::make('email', 'string')->label('Email'), Column::make('phone', 'string')->label('Phone'), Column::make('type', 'string')->label('Type'), ]), + Relationship::hasAutoJoin('created_by', 'users') + ->label('Created By') + ->localKey('created_by_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('name', 'string')->label('Name'), + Column::make('email', 'string')->label('Email'), + ]), + + Relationship::hasAutoJoin('purchase_rate', 'purchase_rates') + ->label('Purchase Rate') + ->description('The service quote purchased for the order') + ->localKey('purchase_rate_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('public_id', 'string')->label('ID')->description('Purchase rate identifier'), + Column::make('status', 'string')->label('Status'), + ]) + ->with([ + Relationship::hasAutoJoin('service_quote', 'service_quotes') + ->label('Service Quote') + ->localKey('service_quote_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('public_id', 'string')->label('ID')->description('Service quote identifier'), + Column::make('amount', 'integer')->label('Amount (minor units)')->description('Quoted delivery price in the currency\'s smallest unit'), + Column::make('currency', 'string')->label('Currency'), + ]), + ]), + Relationship::hasAutoJoin('transaction', 'transactions') ->label('Transaction') ->localKey('transaction_uuid') @@ -312,7 +522,7 @@ protected function createOrdersTable(): Table ->sortable(), Column::make('gateway_transaction_id', 'string') - ->label('Gateway Transaction ID') + ->label('Gateway ID') ->description('Transaction ID from payment gateway') ->searchable() ->filterable() @@ -324,15 +534,35 @@ protected function createOrdersTable(): Table ->filterable() ->aggregatable(), + Column::make('payment_method', 'string') + ->label('Payment Method') + ->description('Payment method used, e.g. card or cash') + ->filterable() + ->aggregatable(), + Column::make('amount', 'integer') - ->label('Amount') - ->description('Transaction amount (in cents)') + ->label('Amount (minor units)') + ->description('Transaction amount in the currency\'s smallest unit (e.g. cents)') ->aggregatable() - ->sortable() - ->transformer(function ($value) { - // Convert cents to dollars with 2 decimal places - return number_format($value / 100, 2); - }), + ->sortable(), + + Column::make('fee_amount', 'integer') + ->label('Fee Amount (minor units)') + ->description('Gateway fee in the currency\'s smallest unit') + ->aggregatable() + ->sortable(), + + Column::make('tax_amount', 'integer') + ->label('Tax Amount (minor units)') + ->description('Tax in the currency\'s smallest unit') + ->aggregatable() + ->sortable(), + + Column::make('net_amount', 'integer') + ->label('Net Amount (minor units)') + ->description('Amount after fees in the currency\'s smallest unit') + ->aggregatable() + ->sortable(), Column::make('currency', 'string') ->label('Currency') @@ -369,6 +599,12 @@ protected function createOrdersTable(): Table return $labels[$value] ?? ucfirst($value); }), + Column::make('settlement_status', 'string') + ->label('Settlement Status') + ->description('Whether the transaction has settled') + ->filterable() + ->aggregatable(), + Column::make('created_at', 'datetime') ->label('Transaction Date') ->description('When the transaction was created') @@ -378,30 +614,48 @@ protected function createOrdersTable(): Table ]) ->with([ // Nested relationship for transaction items - Relationship::hasAutoJoin('items', 'transaction_items') - ->label('Transaction Items') + $this->softDeletes(Relationship::hasAutoJoin('items', 'transaction_items')) + ->label('Transaction Item') ->localKey('uuid') ->foreignKey('transaction_uuid') ->columns([ - Column::make('amount', 'string') - ->label('Item Amount') + Column::make('description', 'string') + ->label('Description') + ->description('Line item description') + ->searchable() + ->filterable(), + + Column::make('quantity', 'integer') + ->label('Quantity') + ->description('Line item quantity') + ->aggregatable() + ->sortable(), + + Column::make('unit_price', 'integer') + ->label('Unit Price (minor units)') + ->description('Line item unit price') + ->aggregatable() + ->sortable(), + + Column::make('amount', 'integer') + ->label('Amount (minor units)') ->description('Line item amount') ->aggregatable() ->sortable(), Column::make('currency', 'string') - ->label('Item Currency') + ->label('Currency') ->description('Line item currency code') ->filterable(), Column::make('details', 'string') - ->label('Item Details') + ->label('Details') ->description('Detailed description of the line item') ->searchable() ->filterable(), Column::make('code', 'string') - ->label('Item Code') + ->label('Code') ->description('Item or SKU code') ->searchable() ->filterable() @@ -416,7 +670,7 @@ protected function createOrdersTable(): Table */ protected function createDriversTable(): Table { - return Table::make('drivers') + return $this->softDeletes(Table::make('drivers')) ->label('Drivers') ->description('Fleet drivers and personnel') ->category('Personnel') @@ -425,30 +679,28 @@ protected function createDriversTable(): Table ->maxRows(10000) ->columns([ Column::make('public_id', 'string') - ->label('Driver ID') + ->label('ID') ->description('Public driver identifier') ->searchable() ->filterable() ->sortable(), - Column::make('name', 'string') - ->label('Name') - ->description('Driver full name') + Column::make('internal_id', 'string') + ->label('Internal ID') + ->description('Internal driver reference') ->searchable() ->filterable() ->sortable(), - Column::make('email', 'string') - ->label('Email') - ->description('Driver email address') + Column::make('drivers_license_number', 'string') + ->label('License Number') + ->description('Driver license number') ->searchable() - ->filterable() - ->sortable(), + ->filterable(), - Column::make('phone', 'string') - ->label('Phone') - ->description('Driver phone number') - ->searchable() + Column::make('license_expiry', 'date') + ->label('License Expiry') + ->description('When the driver license expires') ->filterable() ->sortable(), @@ -477,24 +729,45 @@ protected function createDriversTable(): Table return $value ? 'Yes' : 'No'; }), + Column::make('city', 'string') + ->label('City') + ->filterable() + ->aggregatable(), + + Column::make('country', 'string') + ->label('Country') + ->filterable() + ->aggregatable(), + Column::make('created_at', 'datetime') ->label('Hired Date') - ->description('When the driver was hired') + ->description('When the driver was added') ->filterable() ->sortable() ->aggregatable(), ]) ->computedColumns([ - Column::count('total_drivers', 'id') + Column::count('total_drivers', 'DISTINCT id') ->label('Total Drivers') ->description('Count of drivers'), ]) ->relationships([ - Relationship::hasAutoJoin('current_vehicle', 'vehicles') + Relationship::hasAutoJoin('user', 'users') + ->label('Driver') + ->localKey('user_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('name', 'string')->label('Name'), + Column::make('email', 'string')->label('Email'), + Column::make('phone', 'string')->label('Phone'), + ]), + + Relationship::hasAutoJoin('vehicle', 'vehicles') ->label('Vehicle') - ->localKey('current_vehicle_uuid') + ->localKey('vehicle_uuid') ->foreignKey('uuid') ->columns([ + Column::make('public_id', 'string')->label('Vehicle ID'), Column::make('make', 'string')->label('Vehicle Make'), Column::make('model', 'string')->label('Vehicle Model'), Column::make('plate_number', 'string')->label('Plate Number'), @@ -507,7 +780,7 @@ protected function createDriversTable(): Table */ protected function createVehiclesTable(): Table { - return Table::make('vehicles') + return $this->softDeletes(Table::make('vehicles')) ->label('Vehicles') ->description('Fleet vehicles and assets') ->category('Fleet') @@ -516,12 +789,26 @@ protected function createVehiclesTable(): Table ->maxRows(10000) ->columns([ Column::make('public_id', 'string') - ->label('Vehicle ID') + ->label('ID') ->description('Public vehicle identifier') ->searchable() ->filterable() ->sortable(), + Column::make('internal_id', 'string') + ->label('Internal ID') + ->description('Internal vehicle reference') + ->searchable() + ->filterable() + ->sortable(), + + Column::make('name', 'string') + ->label('Name') + ->description('Vehicle name') + ->searchable() + ->filterable() + ->sortable(), + Column::make('make', 'string') ->label('Make') ->description('Vehicle manufacturer') @@ -583,19 +870,30 @@ protected function createVehiclesTable(): Table ->aggregatable(), ]) ->computedColumns([ - Column::count('total_vehicles', 'id') + Column::count('total_vehicles', 'DISTINCT id') ->label('Total Vehicles') ->description('Count of vehicles'), ]) ->relationships([ - Relationship::hasAutoJoin('current_driver', 'drivers') + // A driver points at the vehicle they drive (drivers.vehicle_uuid). + $this->softDeletes(Relationship::hasAutoJoin('driver', 'drivers')) ->label('Driver') - ->localKey('current_driver_uuid') - ->foreignKey('uuid') + ->localKey('uuid') + ->foreignKey('vehicle_uuid') ->columns([ - Column::make('name', 'string')->label('Driver Name'), - Column::make('email', 'string')->label('Driver Email'), - Column::make('phone', 'string')->label('Driver Phone'), + Column::make('public_id', 'string')->label('Driver ID'), + Column::make('status', 'string')->label('Driver Status'), + ]) + ->with([ + Relationship::hasAutoJoin('user', 'users') + ->label('Driver') + ->localKey('user_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('name', 'string')->label('Name'), + Column::make('email', 'string')->label('Email'), + Column::make('phone', 'string')->label('Phone'), + ]), ]), ]); } @@ -605,7 +903,7 @@ protected function createVehiclesTable(): Table */ protected function createAssetsTable(): Table { - return Table::make('assets') + return $this->softDeletes(Table::make('assets')) ->label('Trailers and Assets') ->description('Independently managed fleet assets; Trailer rows use asset_class trailer') ->category('Fleet') @@ -613,7 +911,7 @@ protected function createAssetsTable(): Table ->excludeColumns(['uuid', 'company_uuid', 'deleted_at', 'meta', 'attributes']) ->maxRows(10000) ->columns([ - Column::make('public_id', 'string')->label('Asset ID')->searchable()->filterable()->sortable(), + Column::make('public_id', 'string')->label('ID')->searchable()->filterable()->sortable(), Column::make('asset_class', 'string')->label('Asset Class')->filterable()->aggregatable(), Column::make('name', 'string')->label('Name')->searchable()->filterable()->sortable(), Column::make('code', 'string')->label('Code')->searchable()->filterable()->sortable(), @@ -640,7 +938,7 @@ protected function createAssetsTable(): Table */ protected function createPlacesTable(): Table { - return Table::make('places') + return $this->softDeletes(Table::make('places')) ->label('Places') ->description('Locations and addresses') ->category('Geography') @@ -649,7 +947,7 @@ protected function createPlacesTable(): Table ->maxRows(100000) ->columns([ Column::make('public_id', 'string') - ->label('Place ID') + ->label('ID') ->description('Public place identifier') ->searchable() ->filterable() @@ -706,7 +1004,7 @@ protected function createPlacesTable(): Table */ protected function createContactsTable(): Table { - return Table::make('contacts') + return $this->softDeletes(Table::make('contacts')) ->label('Contacts') ->description('Customer and vendor contacts') ->category('CRM') @@ -715,7 +1013,7 @@ protected function createContactsTable(): Table ->maxRows(50000) ->columns([ Column::make('public_id', 'string') - ->label('Contact ID') + ->label('ID') ->description('Public contact identifier') ->searchable() ->filterable() @@ -762,7 +1060,7 @@ protected function createContactsTable(): Table */ protected function createVendorsTable(): Table { - return Table::make('vendors') + return $this->softDeletes(Table::make('vendors')) ->label('Vendors') ->description('Service providers and vendors') ->category('CRM') @@ -771,7 +1069,7 @@ protected function createVendorsTable(): Table ->maxRows(10000) ->columns([ Column::make('public_id', 'string') - ->label('Vendor ID') + ->label('ID') ->description('Public vendor identifier') ->searchable() ->filterable() @@ -818,7 +1116,7 @@ protected function createVendorsTable(): Table */ protected function createFuelReportsTable(): Table { - return Table::make('fuel_reports') + return $this->softDeletes(Table::make('fuel_reports')) ->label('Fuel Reports') ->description('Vehicle fuel consumption reports') ->category('Operations') @@ -827,24 +1125,30 @@ protected function createFuelReportsTable(): Table ->maxRows(100000) ->columns([ Column::make('public_id', 'string') - ->label('Report ID') + ->label('ID') ->description('Public fuel report identifier') ->searchable() ->filterable() ->sortable(), Column::make('volume', 'decimal') - ->label('Volume (L)') - ->description('Fuel volume in liters') + ->label('Volume') + ->description('Fuel volume, in the report\'s metric unit') ->aggregatable() ->sortable(), - Column::make('odometer_reading', 'integer') + Column::make('metric_unit', 'string') + ->label('Volume Unit') + ->description('Unit of the fuel volume') + ->filterable() + ->aggregatable(), + + Column::make('odometer', 'integer') ->label('Odometer Reading') ->description('Vehicle odometer reading') ->sortable(), - Column::make('cost', 'decimal') + Column::make('amount', 'decimal') ->label('Cost') ->description('Fuel cost amount') ->aggregatable() @@ -856,15 +1160,26 @@ protected function createFuelReportsTable(): Table ->filterable() ->aggregatable(), - Column::make('report_date', 'date') + Column::make('status', 'string') + ->label('Status') + ->filterable() + ->aggregatable(), + + Column::make('report', 'string') + ->label('Report') + ->description('Report notes') + ->searchable() + ->filterable(), + + Column::make('created_at', 'datetime') ->label('Report Date') - ->description('Date of fuel report') + ->description('When the fuel report was recorded') ->filterable() ->sortable() ->aggregatable(), ]) ->computedColumns([ - Column::sum('total_fuel_cost', 'cost') + Column::sum('total_fuel_cost', 'amount') ->label('Total Fuel Cost') ->description('Sum of all fuel costs'), @@ -872,7 +1187,7 @@ protected function createFuelReportsTable(): Table ->label('Total Fuel Volume') ->description('Sum of all fuel volumes'), - Column::avg('average_fuel_cost', 'cost') + Column::avg('average_fuel_cost', 'amount') ->label('Average Fuel Cost') ->description('Average fuel cost per report'), ]) @@ -882,6 +1197,7 @@ protected function createFuelReportsTable(): Table ->localKey('vehicle_uuid') ->foreignKey('uuid') ->columns([ + Column::make('public_id', 'string')->label('Vehicle ID'), Column::make('make', 'string')->label('Vehicle Make'), Column::make('model', 'string')->label('Vehicle Model'), Column::make('plate_number', 'string')->label('Plate Number'), @@ -892,8 +1208,17 @@ protected function createFuelReportsTable(): Table ->localKey('driver_uuid') ->foreignKey('uuid') ->columns([ - Column::make('name', 'string')->label('Driver Name'), - Column::make('email', 'string')->label('Driver Email'), + Column::make('public_id', 'string')->label('Driver ID'), + ]) + ->with([ + Relationship::hasAutoJoin('user', 'users') + ->label('Driver') + ->localKey('user_uuid') + ->foreignKey('uuid') + ->columns([ + Column::make('name', 'string')->label('Name'), + Column::make('email', 'string')->label('Email'), + ]), ]), ]); } @@ -903,7 +1228,7 @@ protected function createFuelReportsTable(): Table */ protected function createWorkOrdersTable(): Table { - return Table::make('work_orders') + return $this->softDeletes(Table::make('work_orders')) ->label('Work Orders') ->description('Maintenance work orders, assignments, budgets, and lifecycle status') ->category('Maintenance') @@ -911,7 +1236,7 @@ protected function createWorkOrdersTable(): Table ->excludeColumns(['uuid', 'deleted_at', 'meta', 'checklist', 'cost_breakdown']) ->maxRows(100000) ->columns([ - Column::make('public_id', 'string')->label('Work Order ID')->searchable()->filterable()->sortable(), + Column::make('public_id', 'string')->label('ID')->searchable()->filterable()->sortable(), Column::make('code', 'string')->label('Code')->searchable()->filterable()->sortable(), Column::make('subject', 'string')->label('Subject')->searchable()->filterable()->sortable(), Column::make('status', 'string')->label('Status')->filterable()->sortable()->aggregatable(), @@ -938,7 +1263,7 @@ protected function createWorkOrdersTable(): Table ->localKey('target_uuid') ->foreignKey('uuid') ->columns([ - Column::make('public_id', 'string')->label('Vehicle ID'), + Column::make('public_id', 'string')->label('ID'), Column::make('plate_number', 'string')->label('Plate Number'), Column::make('make', 'string')->label('Make'), Column::make('model', 'string')->label('Model'), @@ -952,7 +1277,7 @@ protected function createWorkOrdersTable(): Table */ protected function createMaintenancesTable(): Table { - return Table::make('maintenances') + return $this->softDeletes(Table::make('maintenances')) ->label('Maintenance History') ->description('Completed and scheduled maintenance records with labor, parts, tax, and total cost') ->category('Maintenance') @@ -960,7 +1285,7 @@ protected function createMaintenancesTable(): Table ->excludeColumns(['uuid', 'deleted_at', 'meta', 'line_items', 'attachments']) ->maxRows(100000) ->columns([ - Column::make('public_id', 'string')->label('Maintenance ID')->searchable()->filterable()->sortable(), + Column::make('public_id', 'string')->label('ID')->searchable()->filterable()->sortable(), Column::make('type', 'string')->label('Type')->filterable()->sortable()->aggregatable(), Column::make('status', 'string')->label('Status')->filterable()->sortable()->aggregatable(), Column::make('priority', 'string')->label('Priority')->filterable()->sortable()->aggregatable(), @@ -1013,7 +1338,7 @@ protected function createMaintenancesTable(): Table */ protected function createInspectionSubmissionsTable(): Table { - return Table::make('inspection_submissions') + return $this->softDeletes(Table::make('inspection_submissions')) ->label('Inspections') ->description('DVIR and inspection submissions, pass/fail status, and linked maintenance follow-up') ->category('Maintenance') @@ -1021,7 +1346,7 @@ protected function createInspectionSubmissionsTable(): Table ->excludeColumns(['uuid', 'deleted_at', 'meta', 'location', 'signature', 'attachments']) ->maxRows(100000) ->columns([ - Column::make('public_id', 'string')->label('Inspection ID')->searchable()->filterable()->sortable(), + Column::make('public_id', 'string')->label('ID')->searchable()->filterable()->sortable(), Column::make('type', 'string')->label('Type')->filterable()->sortable()->aggregatable(), Column::make('status', 'string')->label('Status')->filterable()->sortable()->aggregatable(), Column::make('result', 'string')->label('Result')->filterable()->sortable()->aggregatable(), @@ -1046,8 +1371,8 @@ protected function createInspectionSubmissionsTable(): Table ->localKey('inspection_form_uuid') ->foreignKey('uuid') ->columns([ - Column::make('name', 'string')->label('Form Name'), - Column::make('type', 'string')->label('Form Type'), + Column::make('name', 'string')->label('Name'), + Column::make('type', 'string')->label('Type'), ]), Relationship::hasAutoJoin('vehicle', 'vehicles') ->label('Vehicle') @@ -1070,4 +1395,39 @@ protected function createInspectionSubmissionsTable(): Table ]), ]); } + + /** + * Columns reported for a place (pickup, dropoff, return or item destination). + */ + protected function placeColumns(): array + { + return [ + Column::make('public_id', 'string')->label('ID'), + Column::make('name', 'string')->label('Name'), + Column::make('street1', 'string')->label('Street'), + Column::make('street2', 'string')->label('Street 2'), + Column::make('neighborhood', 'string')->label('Neighborhood'), + Column::make('district', 'string')->label('District'), + Column::make('city', 'string')->label('City'), + Column::make('province', 'string')->label('Province'), + Column::make('postal_code', 'string')->label('Postal Code'), + Column::make('country', 'string')->label('Country'), + Column::make('latitude', 'decimal')->label('Latitude'), + Column::make('longitude', 'decimal')->label('Longitude'), + ]; + } + + /** + * Leave soft-deleted rows out of a table or joined relationship, on Core API releases that support it. + * + * @template T of Table|Relationship + * + * @param T $schema + * + * @return T + */ + protected function softDeletes(Table|Relationship $schema): Table|Relationship + { + return method_exists($schema, 'softDeletes') ? $schema->softDeletes() : $schema; + } } diff --git a/server/tests/AnalyticsRoutesTest.php b/server/tests/AnalyticsRoutesTest.php index 41048f9bf..07f243c14 100644 --- a/server/tests/AnalyticsRoutesTest.php +++ b/server/tests/AnalyticsRoutesTest.php @@ -17,6 +17,9 @@ use Fleetbase\FleetOps\Support\Analytics\TopDrivers; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Fleetbase\Models\Company; +use Illuminate\Database\ConnectionResolver; +use Illuminate\Database\Eloquent\Model as EloquentModel; +use Illuminate\Database\SQLiteConnection; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -273,6 +276,16 @@ function fleetOpsAnalyticsControllerRequest(array $input = []): Request }); test('live fleet analytics serializes driver and vehicle map payloads', function () { + // The markers' cards look up the current order and last known position. + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->statement('create table orders (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, driver_assigned_uuid varchar(64) null, vehicle_assigned_uuid varchar(64) null, tracking_number_uuid varchar(64) null, status varchar(64) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); + $connection->statement('create table drivers (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, user_uuid varchar(64) null, vehicle_uuid varchar(64) null, current_job_uuid varchar(64) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); + $connection->statement('create table users (uuid varchar(64) primary key, company_uuid varchar(64) null, name varchar(255) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); + $connection->statement('create table positions (uuid varchar(64) primary key, company_uuid varchar(64) null, subject_uuid varchar(64) null, subject_type varchar(255) null, order_uuid varchar(64) null, speed numeric null, heading numeric null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + $analytics = new LiveFleet(); $driver = new TestFleetOpsLiveFleetDriver(); @@ -308,7 +321,21 @@ function fleetOpsAnalyticsControllerRequest(array $input = []): Request $driverPayload->setAccessible(true); $vehiclePayload->setAccessible(true); - expect($driverPayload->invoke($analytics, $driver))->toBe([ + $driverResult = $driverPayload->invoke($analytics, $driver); + $vehicleResult = $vehiclePayload->invoke($analytics, $vehicle); + + // Each marker carries the live map's card: the same index resource the live endpoints return. + expect($driverResult['card'])->toBeArray() + ->and($driverResult['card'])->toHaveKeys(['name', 'status', 'phone', 'email', 'online', 'meta']) + ->and($driverResult['card']['meta'])->toHaveKeys(['status_label', 'speed_label', 'heading_label', 'location_coordinates', 'current_order_reference']) + ->and($driverResult['card']['name'])->toBe('Ada Driver') + ->and($vehicleResult['card'])->toHaveKeys(['display_name', 'driver_name', 'plate_number', 'online', 'meta']) + ->and($vehicleResult['card']['display_name'])->toBe('Van 7') + ->and($vehicleResult['card']['meta'])->toHaveKeys(['status_label', 'speed_label', 'heading_label', 'location_coordinates', 'current_order_reference']); + + unset($driverResult['card'], $vehicleResult['card']); + + expect($driverResult)->toBe([ 'uuid' => 'driver-uuid', 'public_id' => 'driver-public', 'name' => 'Ada Driver', @@ -320,7 +347,7 @@ function fleetOpsAnalyticsControllerRequest(array $input = []): Request 'lat' => 1.30, 'lng' => 103.80, 'updated_at' => '2026-01-01 10:00:00', - ])->and($vehiclePayload->invoke($analytics, $vehicle))->toBe([ + ])->and($vehicleResult)->toBe([ 'uuid' => 'vehicle-uuid', 'public_id' => 'vehicle-public', 'name' => 'Van 7', diff --git a/server/tests/ReportSchemaContractsTest.php b/server/tests/ReportSchemaContractsTest.php index b1465b71c..afdae61aa 100644 --- a/server/tests/ReportSchemaContractsTest.php +++ b/server/tests/ReportSchemaContractsTest.php @@ -119,9 +119,10 @@ function fleetOpsReportColumnFlag(Column $column, string $flag): bool function fleetOpsReportColumnAggregate(Column $column): ?string { + // The stand-in Column stores the aggregate name; the Core API one only flags it (a bool). $aggregate = fleetOpsReportProperty($column, 'aggregate'); - if ($aggregate) { + if (is_string($aggregate) && $aggregate !== '') { return $aggregate; } @@ -242,9 +243,10 @@ function fleetOpsReportRelationship(Table|Relationship $container, string $name) ->and(fleetOpsReportTableName(fleetOpsReportRelationship($orders, 'transaction')))->toBe('transactions'); expect(fleetOpsReportTableMeta($tables['drivers'], 'category'))->toBe('Personnel') - ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['drivers'], 'current_vehicle')))->toBe('vehicles') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['drivers'], 'vehicle')))->toBe('vehicles') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['drivers'], 'user')))->toBe('users') ->and(fleetOpsReportTableMeta($tables['vehicles'], 'category'))->toBe('Fleet') - ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['vehicles'], 'current_driver')))->toBe('drivers') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['vehicles'], 'driver')))->toBe('drivers') ->and(fleetOpsReportTableMeta($tables['assets'], 'label'))->toBe('Trailers and Assets') ->and(fleetOpsReportTableMeta($tables['assets'], 'category'))->toBe('Fleet') ->and(fleetOpsReportColumnFlag(fleetOpsReportColumn($tables['assets'], 'asset_class'), 'filterable'))->toBeTrue() @@ -265,7 +267,7 @@ function fleetOpsReportRelationship(Table|Relationship $container, string $name) ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['inspection_submissions'], 'work_order')))->toBe('work_orders'); }); -test('fleetops report schema transformers normalize labels booleans distances and money', function () { +test('fleetops report schema transformers normalize labels and booleans', function () { $registry = new ReportSchemaRegistry(); (new FleetOpsReportSchema())->registerReportSchema($registry); @@ -278,12 +280,124 @@ function fleetOpsReportRelationship(Table|Relationship $container, string $name) expect(fleetOpsReportTransform(fleetOpsReportColumn($orders, 'status'), 'driver_assigned'))->toBe('Driver Assigned') ->and(fleetOpsReportTransform(fleetOpsReportColumn($orders, 'status'), 'custom_status'))->toBe('Custom_status') - ->and(fleetOpsReportTransform(fleetOpsReportColumn($orders, 'distance'), 10))->toBe(6.21) ->and(fleetOpsReportTransform(fleetOpsReportColumn($orders, 'adhoc'), true))->toBe('Yes') ->and(fleetOpsReportTransform(fleetOpsReportColumn($orders, 'pod_required'), false))->toBe('No') ->and(fleetOpsReportTransform(fleetOpsReportColumn($drivers, 'status'), 'suspended'))->toBe('Suspended') ->and(fleetOpsReportTransform(fleetOpsReportColumn($drivers, 'online'), false))->toBe('No') ->and(fleetOpsReportTransform(fleetOpsReportColumn($vehicles, 'status'), 'out_of_service'))->toBe('Out of Service') - ->and(fleetOpsReportTransform(fleetOpsReportColumn($transaction, 'amount'), 12345))->toBe('123.45') ->and(fleetOpsReportTransform(fleetOpsReportColumn($transaction, 'status'), 'refunded'))->toBe('Refunded'); }); + +function fleetOpsReportColumnNames(Table|Relationship $container): array +{ + return array_map(fn (Column $column) => fleetOpsReportSchemaName($column), fleetOpsReportColumns($container)); +} + +function fleetOpsReportComputation(Column $column): ?string +{ + return fleetOpsReportCallOrProperty($column, 'getComputation', 'computation'); +} + +test('fleetops order report schema exposes identifiers tracking and items without assuming a meta shape', function () { + $registry = new ReportSchemaRegistry(); + + (new FleetOpsReportSchema())->registerReportSchema($registry); + + $orders = fleetOpsReportTables($registry)['orders']; + + // Order identifiers and the operational columns reports filter and group on. + expect(fleetOpsReportColumnNames($orders))->toContain( + 'public_id', + 'internal_id', + 'status', + 'type', + 'scheduled_at', + 'dispatched_at', + 'pod_method', + 'notes', + 'created_at', + 'meta', + ); + + // Columns of the table itself are not prefixed with the table's name ("Order ID" → "ID"). + $label = fn (Column $column) => fleetOpsReportCallOrProperty($column, 'getLabel', 'label'); + expect($label(fleetOpsReportColumn($orders, 'public_id')))->toBe('ID') + ->and($label(fleetOpsReportColumn($orders, 'type')))->toBe('Type'); + + expect(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'total_orders')))->toBe('COUNT(DISTINCT id)') + ->and(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'completed_orders')))->toBe("COUNT(DISTINCT CASE WHEN status = 'completed' THEN id END)") + ->and(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'total_transaction_amount')))->toBe('SUM(transaction.amount)'); + + // meta has no fixed shape, so no declared column may read a key out of it; users read + // keys with computed columns instead. + $computations = array_map(fn (Column $column) => (string) fleetOpsReportComputation($column), [ + ...fleetOpsReportColumns($orders), + ...fleetOpsReportComputedColumns($orders), + ...fleetOpsReportColumnsFromRelationships(fleetOpsReportRelationships($orders)), + ]); + expect(array_filter($computations, fn (string $computation) => str_contains($computation, 'JSON_') || str_contains($computation, 'meta')))->toBe([]); + + $tracking = fleetOpsReportRelationship($orders, 'tracking_number'); + expect(fleetOpsReportTableName($tracking))->toBe('tracking_numbers') + ->and(fleetOpsReportColumnNames($tracking))->toContain('tracking_number', 'public_id') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tracking, 'status')))->toBe('tracking_statuses') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($orders, 'order_config')))->toBe('order_configs') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($orders, 'customer_vendor')))->toBe('vendors') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($orders, 'created_by')))->toBe('users') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship(fleetOpsReportRelationship($orders, 'purchase_rate'), 'service_quote')))->toBe('service_quotes'); + + // Payload items: one row per entity, with the storefront quantity and line total from meta. + $payload = fleetOpsReportRelationship($orders, 'payload'); + expect(fleetOpsReportColumnNames($payload))->toContain('public_id', 'type', 'cod_amount') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($payload, 'return')))->toBe('places'); + + $items = fleetOpsReportRelationship($payload, 'entities'); + expect(fleetOpsReportTableName($items))->toBe('entities') + ->and(fleetOpsReportCallOrProperty($items, 'getLocalKey', 'localKey'))->toBe('uuid') + ->and(fleetOpsReportCallOrProperty($items, 'getForeignKey', 'foreignKey'))->toBe('payload_uuid') + ->and(fleetOpsReportColumnNames($items))->toContain('public_id', 'internal_id', 'name', 'sku', 'price', 'sale_price', 'meta') + ->and(fleetOpsReportColumnNames($items))->not->toContain('quantity', 'line_total', 'product_id') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship($items, 'destination')))->toBe('places'); + + // Relationship identifiers + expect(fleetOpsReportColumnNames(fleetOpsReportRelationship($orders, 'driver_assigned')))->toContain('public_id') + ->and(fleetOpsReportColumnNames(fleetOpsReportRelationship($orders, 'vehicle_assigned')))->toContain('public_id') + ->and(fleetOpsReportColumnNames(fleetOpsReportRelationship($orders, 'customer')))->toContain('public_id') + ->and(fleetOpsReportColumnNames(fleetOpsReportRelationship($orders, 'facilitator')))->toContain('public_id'); +}); + +test('fleetops report schema uses the columns the fuel report table actually has', function () { + $registry = new ReportSchemaRegistry(); + + (new FleetOpsReportSchema())->registerReportSchema($registry); + + $tables = fleetOpsReportTables($registry); + $fuel = $tables['fuel_reports']; + + expect(fleetOpsReportColumnNames($fuel))->toContain('amount', 'odometer', 'metric_unit', 'created_at') + ->not->toContain('cost', 'odometer_reading', 'report_date') + ->and(fleetOpsReportComputation(fleetOpsReportColumn($fuel, 'total_fuel_cost')))->toBe('SUM(amount)') + ->and(fleetOpsReportTableName(fleetOpsReportRelationship(fleetOpsReportRelationship($fuel, 'driver'), 'user')))->toBe('users') + ->and(fleetOpsReportColumnNames($tables['drivers']))->not->toContain('name', 'email', 'phone'); +}); + +test('fleetops report schema leaves soft deleted rows out where the core api supports it', function () { + $registry = new ReportSchemaRegistry(); + + (new FleetOpsReportSchema())->registerReportSchema($registry); + + $orders = fleetOpsReportTables($registry)['orders']; + $items = fleetOpsReportRelationship(fleetOpsReportRelationship($orders, 'payload'), 'entities'); + + if (!method_exists($orders, 'usesSoftDeletes')) { + // Older Core API: the schema still registers, without soft-delete filtering. + expect($orders)->toBeInstanceOf(Table::class); + + return; + } + + expect($orders->usesSoftDeletes())->toBeTrue() + ->and($items->usesSoftDeletes())->toBeTrue() + ->and(fleetOpsReportRelationship($orders, 'customer')->usesSoftDeletes())->toBeFalse() + ->and(fleetOpsReportColumn($orders, 'total_orders')->isAggregate())->toBeTrue(); +}); diff --git a/server/tests/Unit/Support/Analytics/LiveFleetTest.php b/server/tests/Unit/Support/Analytics/LiveFleetTest.php index e40deac5d..b535f6778 100644 --- a/server/tests/Unit/Support/Analytics/LiveFleetTest.php +++ b/server/tests/Unit/Support/Analytics/LiveFleetTest.php @@ -38,6 +38,12 @@ function fleetopsLiveFleetUseInMemoryConnection(): SQLiteConnection $connection->statement('create table drivers (uuid varchar(64) primary key, public_id varchar(64) null, user_uuid varchar(64) null, company_uuid varchar(64) null, vehicle_uuid varchar(64) null, current_job_uuid varchar(64) null, avatar_url varchar(255) null, location blob null, heading numeric null, online integer null, last_location_update_at datetime null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); $connection->statement('create table vehicles (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, driver_uuid varchar(64) null, photo_uuid varchar(64) null, avatar_url varchar(255) null, name varchar(255) null, year integer null, make varchar(255) null, model varchar(255) null, trim varchar(255) null, plate_number varchar(64) null, location blob null, heading numeric null, online integer null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); $connection->statement('create table orders (uuid varchar(64) primary key, public_id varchar(64) null, internal_id varchar(64) null, company_uuid varchar(64) null, driver_assigned_uuid varchar(64) null, status varchar(64) null, tracking_number_uuid varchar(64) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); + // Tables the markers' index-resource cards read: devices, coupled trailers and positions. + $connection->statement('create table devices (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, attachable_uuid varchar(64) null, attachable_type varchar(255) null, name varchar(255) null, device_id varchar(64) null, serial_number varchar(64) null, imei varchar(64) null, provider varchar(64) null, status varchar(64) null, online integer null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); + $connection->statement('create table asset_connections (uuid varchar(64) primary key, company_uuid varchar(64) null, connector_uuid varchar(64) null, connector_type varchar(255) null, connected_uuid varchar(64) null, connected_type varchar(255) null, connected_at datetime null, disconnected_at datetime null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); + $connection->statement('create table assets (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, asset_class varchar(64) null, name varchar(255) null, code varchar(64) null, type varchar(64) null, status varchar(64) null, plate_number varchar(64) null, online integer null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); + $connection->statement('create table positions (uuid varchar(64) primary key, company_uuid varchar(64) null, subject_uuid varchar(64) null, subject_type varchar(255) null, order_uuid varchar(64) null, speed numeric null, heading numeric null, coordinates blob null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); + $connection->statement('create table tracking_numbers (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, owner_uuid varchar(64) null, owner_type varchar(255) null, status_uuid varchar(64) null, tracking_number varchar(64) null, region varchar(64) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); $connection->statement('create table files (uuid varchar(64) primary key, type varchar(64) null, url varchar(255) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)'); $resolver = new ConnectionResolver([ diff --git a/tests/integration/components/map/marker-card/driver-test.js b/tests/integration/components/map/marker-card/driver-test.js new file mode 100644 index 000000000..17d6a624c --- /dev/null +++ b/tests/integration/components/map/marker-card/driver-test.js @@ -0,0 +1,35 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | map/marker-card/driver', function (hooks) { + setupRenderingTest(hooks); + + test('it shows the driver card from an index-resource payload', async function (assert) { + this.set('driver', { + name: 'Ada Driver', + online: true, + status: 'active', + public_id: 'driver_public', + phone: '+6590000000', + vehicle_name: 'Van 7', + email: 'ada@example.test', + meta: { status_label: 'Active', current_order_reference: 'ORD-1', speed_label: '42 km/h', heading_label: '90 deg', location_coordinates: '1.3 103.8' }, + }); + + await render(hbs``); + + for (const text of ['Ada Driver', 'Active', 'driver_public', '+6590000000', 'Van 7', 'ada@example.test', 'ORD-1', '42 km/h', '90 deg', '1.3 103.8']) { + assert.dom(this.element).containsText(text); + } + }); + + test('the tooltip variant drops the dark panel', async function (assert) { + this.set('driver', { name: 'Ada Driver', meta: {} }); + + await render(hbs``); + + assert.dom('div').doesNotHaveClass('bg-gray-900'); + }); +}); diff --git a/tests/integration/components/map/marker-card/vehicle-test.js b/tests/integration/components/map/marker-card/vehicle-test.js new file mode 100644 index 000000000..6bc4fd06d --- /dev/null +++ b/tests/integration/components/map/marker-card/vehicle-test.js @@ -0,0 +1,47 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupIntl } from 'ember-intl/test-support'; + +// The live map passes a vehicle model; the dashboard's Live Fleet widget passes the same +// index-resource payload as plain JSON. Both must render the same card. +const VEHICLE = { + display_name: 'Van 7', + online: true, + status: 'in_service', + internal_id: 'V-7', + driver_name: 'Ada Driver', + trailers: [{ display_name: 'Reefer 2', online: true }], + devices: [], + meta: { status_label: 'In Service', current_order_reference: 'ORD-1', speed_label: '42 km/h', heading_label: '90 deg', location_coordinates: '1.3 103.8' }, +}; + +module('Integration | Component | map/marker-card/vehicle', function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks, 'en-us'); + + test('it shows the vehicle card from an index-resource payload', async function (assert) { + this.set('vehicle', VEHICLE); + + await render(hbs``); + + assert.dom(this.element).containsText('Van 7', 'the display name falls back to display_name'); + assert.dom(this.element).containsText('In Service'); + assert.dom(this.element).containsText('V-7'); + assert.dom(this.element).containsText('Ada Driver'); + assert.dom(this.element).containsText('Reefer 2'); + assert.dom(this.element).containsText('ORD-1'); + assert.dom(this.element).containsText('42 km/h'); + assert.dom('div').hasClass('bg-gray-900', 'a popup card is its own dark panel'); + }); + + test('a model display name wins and the tooltip variant drops the panel', async function (assert) { + this.set('vehicle', { ...VEHICLE, displayName: 'Van 7 (SBA1234Z)' }); + + await render(hbs``); + + assert.dom(this.element).containsText('Van 7 (SBA1234Z)'); + assert.dom('div').doesNotHaveClass('bg-gray-900', 'the tooltip supplies its own panel'); + }); +}); diff --git a/tests/unit/extension-test.js b/tests/unit/extension-test.js index 99106b422..bb568a351 100644 --- a/tests/unit/extension-test.js +++ b/tests/unit/extension-test.js @@ -89,4 +89,40 @@ module('Unit | FleetOps extension', function () { 'default analytics widgets are available' ); }); + + test('it lays out the default dashboard: KPI row, full-width map, then three panels', function (assert) { + let registered = []; + extension.registerWidgets({ + registerDashboard() {}, + registerWidgets(id, widgets) { + if (id === 'dashboard') registered = widgets.map((widget) => widget.toObject()); + }, + registerDefaultWidgets() {}, + }); + + const byId = Object.fromEntries(registered.map((widget) => [widget.id, widget])); + const defaults = registered.filter((widget) => widget.default === true).sort((a, b) => a.order - b.order); + + assert.deepEqual( + defaults.map((widget) => [widget.id, widget.order]), + [ + ['fleet-ops-radar-widget', 10], + ['fleet-ops-kpi-active-orders-widget', 30], + ['fleet-ops-kpi-drivers-online-widget', 40], + ['fleet-ops-live-fleet-widget', 50], + ['fleet-ops-revenue-trend-widget', 60], + ['fleet-ops-top-drivers-widget', 70], + ['fleet-ops-maintenance-overview-widget', 80], + ], + 'every default widget has a place, leaving 20 for the ledger Revenue tile' + ); + assert.strictEqual(byId['fleet-ops-live-fleet-widget'].grid_options.w, 12, 'the map spans the full width'); + assert.deepEqual( + ['fleet-ops-revenue-trend-widget', 'fleet-ops-top-drivers-widget', 'fleet-ops-maintenance-overview-widget'].map((id) => byId[id].grid_options.w), + [4, 4, 4], + 'the panels share a row' + ); + assert.false(byId['fleet-ops-kpi-earnings-widget'].default, 'Earnings is no longer a default'); + assert.false(byId['fleet-ops-kpi-aov-widget'].default, 'Avg Order Value is no longer a default'); + }); });