<- All posts

How to Build a Fleet Management Dashboard

Ronan McQuillan
15 min read Oct 13, 2023

An effective fleet management dashboard is one of the most fundamental tools in any logistics team鈥檚 arsenal. The idea is to give us a clear, up-to-date snapshot of our vehicles across our entire distribution network.

Today, we鈥檙e exploring how we can use 黑料正能量 to build professional dashboards that leverage existing data.

As we鈥檒l see, 黑料正能量 is the ideal tool for extracting insights from all kinds of data sources and turning them into clear, actionable insights - so we can make better-informed decisions.

But before that, let鈥檚 think about what this means for logistics teams.

What is a fleet management dashboard?

Fleet management dashboards are reporting UIs that display key metrics and KPIs relating to various aspects of how we use our vehicles.

This can include things like where our assets are, how much we鈥檙e spending on fuel, our performance in terms of deliveries, statistics around our overall vehicle utilization, driver safety, or any other relevant data that decision-makers might need.

Of course, this will vary greatly depending on the size and complexity of your fleet - as well as how you use it.

As with any kind of dashboard, the idea is that we can configure our reports once - and then they鈥檒l populate with the most up-to-date information in real-time.

What are we building?

Our fleet management dashboard is built around two thematic reports - one for insights into our vehicles themselves - and one for their performance on deliveries.

Here鈥檚 what the fleet dashboard UI will look like when we鈥檙e done:

Fleet Management Dashboard

Let鈥檚 dive in.

How to build a fleet management dashboard in 5 steps

If you haven鈥檛 already, sign up for a free 黑料正能量 account so you can follow along with this tutorial.

Join 200,000 teams building workflow apps with 黑料正能量

1. Create a 黑料正能量 app and connect your data

The first thing we鈥檙e going to do is create a new 黑料正能量 application and give it a name. We have the option of starting with a template or importing an existing app, but we鈥檙e starting from scratch:

New app

Straight away, we鈥檒l be prompted to choose our data source. 黑料正能量 offers dedicated connectors for a huge array of relational and non-relational databases, as well as REST, Google Sheets, and our own internal DB.

Data sources

We鈥檙e going to build our dashboard around data that lives in a Postgres database. When we select this, we鈥檙e prompted to enter our database credentials. Users on paid tiers can use environment variables to store these and leverage them across their 黑料正能量 apps.

Credentials

We鈥檙e then asked which tables we want to pull into our 黑料正能量 app:

Fetch Tables

Our fleet management database is made up of two tables, called vehicles and deliveries. We鈥檒l fetch both.

2. Configuring our data model

Once we鈥檝e fetched our tables, we can immediately start to manipulate them in 黑料正能量鈥檚 back-end:

Tables

We can use this editable table to add, edit, or delete rows - as well as altering our table鈥檚 schema.

Our fleet management dashboard will be mainly built around custom Postgres queries, but we鈥檙e still going to make a few changes to our data tables for the sake of house keeping.

On the vehicles table, we want to select the registration attribute as our display column:

Fleet Management Dashboard

We鈥檒l also edit the status attribute and choose the options type, rather than keeping it as a text field. We鈥檒l input the three possible values - On Delivery, Idle, and Maintenance:

Enum

We can set a custom color code for our options, but if we don鈥檛 then 黑料正能量 will do this for us. Here鈥檚 what our table looks like now:

Status

We鈥檒l make some similar changes to our deliveries table - setting the id attribute as our display column and changing the origin and destination fields to the options type.

Deliveries table

3. Building our fleet report

Now, we can start building some interfaces. Each of our reports is effectively made up of four distinct parts.

So, we can take each in turn.

Summary cards

At the top of our fleet management dashboard, we鈥檝e got a series of cards that read out key statistics about the current state of our fleet.

We鈥檒l start by adding a new blank screen:

Blank screen

This will be our home screen, so we鈥檒l leave the URL extension blank:

URL

The first component we want to add is a container. We鈥檒l also give this a name and set its direction to horizontal:

Horizontal container

Inside this, we鈥檒l place four cards blocks. Each of these will iterate over a specific data set and display whichever values from each entry that we want them to.

Cards

Eventually, we鈥檒l populate these with a data set that only has one entry - so we鈥檒l just have one card per cards block.

If you cast your mind back to our finished dashboard from earlier, the four metrics we鈥檙e going to display in these cards are:

  • The average age of vehicles in our fleet.
  • Their average mileage.
  • The percentage of our vehicles that are currently in use.
  • How much we鈥檝e spent on fuel this month.

We鈥檒l get these first three metrics from a single custom query.

So, head back to the data tab and add a new query under our Postgres connection:

Create a query

We鈥檒l call this AgeMileageUtilization.

We need to write a SELECT statement that will return three things:

  • The AVG of the current date minus each vehicle鈥檚 registration_date.
  • The AVG of the mileage attribute.
  • The COUNT of vehicles with the status attribute 鈥淥n Delivery鈥 divided by the COUNT of all vehicles.

Therefore, our query will be:

 1SELECT CAST(AVG(
 2
 3  EXTRACT(YEAR FROM AGE(NOW(), registration_date)) +
 4
 5  EXTRACT(MONTH FROM AGE(NOW(), registration_date)) / 12.0
 6
 7) AS DECIMAL) AS average_age,
 8
 9AVG(mileage) AS average_mileage,
10
11(COUNT(CASE WHEN status = 'On Delivery' THEN 1 END)::decimal / COUNT(*)) * 100 AS percentage_on_delivery
12
13FROM vehicles;

Query

This returns a data objec that looks like this:

1{
2
3 "average_age": "2.50000000000000000000",
4
5 "average_mileage": 99518.5,
6
7 "percentage_on_delivery": "50.00000000000000000000"
8
9}

Hit save and head back to the design section.

We鈥檒l start by giving our first three cards more descriptive names and setting their data to our new query. Now each one only displays one card - because it only iterates over a single object:

Cards

Each one has a title, subtitle, and description field. We鈥檒l set the title to be each of the respective figures - and the subtitle as some text to explain what this is.

We鈥檒l just delete the description outright - since we don鈥檛 need it.

黑料正能量 gives us two different ways to bind values to a field - handlebar expressions or JavaScript. If we were happy with the format of our data as it鈥檚 returned by our query, we could just select plain handlebars:

Decimals

But, we want to do a little bit of formatting. We鈥檒l use some custom JavaScript for the first two because we want to round our figures to two decimal places.

So, for the average age, we鈥檒l use the following JavaScript as our title binding:

1return Number($("AvgAge Card.AgeMileageUtilization.average_age")).toFixed(2);

And we鈥檒l set the subtitle to average vehicle age. We鈥檒l also do basically the same thing with our next card, using our average_mileage output from the query.

So far, we have:

Fleet management dashboard

For our utilization rate, we can just display an integer with a percentage sign appended to it. To do this, we鈥檒l use the following handlebars expression as our title binding:

1{{ round Utilization block.AgeMileageUtilization.percentage_on_delivery }}%

Now we have:

Percentage

For our last card, things are a bit more complicated. We want to display how much we鈥檝e spent on fuel for the current month so far.

To get this, we鈥檒l first need a new query to SELECT the numerical month, year, and SUM of the fuel costs - grouped and ordered by month and year.

We鈥檒l follow the same process as above to add a new query called FuelCostByMonth. The specific query we鈥檒l use is:

 1SELECT
 2
 3  CAST(EXTRACT(MONTH FROM departure_date_time) AS INTEGER) AS departure_month,
 4
 5CAST(EXTRACT(YEAR FROM departure_date_time) AS INTEGER) AS departure_year,
 6
 7  SUM(fuel_cost) AS total_fuel_cost
 8
 9FROM deliveries
10
11GROUP BY departure_year, departure_month
12
13ORDER BY departure_year, departure_month;

Query

Here鈥檚 the schema that this returns:

1{
2
3 "departure_month": 9,
4
5 "departure_year": 2023,
6
7 "total_fuel_cost": 396
8
9}

We鈥檒l go ahead and bind this total_fuel_cost value to our final card:

Fuel costs

But - there鈥檚 a problem!

Our query returns multiple data objects. So, we need one final step to add some front-end filtering so that we only see the current month鈥檚 figures.

Use the button on the right to open up the filter drawer:

Filtering

We need two expressions - one to match the departure_month to the current month and another to do the same for the departure_year.

For the month, we鈥檒l use this piece of JavaScript (we need to add one because JavaScript uses zero-based counting - so January has an index of 0:

1var currentDate = new Date();
2
3return currentDate.getMonth() + 1;

For the year, we can use:

1var currentDate = new Date();
2
3return currentDate.getFullYear();

And that鈥檚 our cards done!

Cards

Current location chart

Next, we want a pie chart that displays a breakdown of how our fleet is spread out on the ground.

We want this chart to occupy half of the screen - and later we鈥檒l put another graph beside it - so we鈥檒l start by adding another horizontal container:

Chart

We鈥檒l need another query, which we鈥檒l call VehicleCountByLocation.

Again, this is a slightly more complex query - because our vehicles table doesn鈥檛 have a column for their current location. However, we can figure out the last location where each one made a delivery.

So, we basically need a query that finds the row for each vehicle with the most recent arrival_date - and then counts the vehicles at each location based on this.

Our query is:

 1SELECT
 2
 3  d.destination AS current_location,
 4
 5  COUNT(*) AS vehicle_count
 6
 7FROM deliveries d
 8
 9INNER JOIN (
10
11  SELECT
12
13鈥    vehicle,
14
15MAX(arrival_date_time) AS max_arrival_date
16
17  FROM deliveries
18
19  GROUP BY vehicle
20
21) recent_arrival ON d.vehicle = recent_arrival.vehicle AND d.arrival_date_time = recent_arrival.max_arrival_date
22
23GROUP BY d.destination;

Response

Here鈥檚 an example of the kind of data object we鈥檒l get back:

1{
2
3 "current_location": "Pittsburgh",
4
5 "vehicle_count": "1"
6
7}

Back to the design tab!

Inside our container, we鈥檒l add a chart block. We鈥檒l give it a name, set its type to pie, and point it to our new query:

Fleet management dashboard

However, this won鈥檛 display any data until we tell it which attributes we want to use for what.

We鈥檒l set the label column to current_location and the data column to vehicle_count.

Pie Chart

Lastly, we want to make some space for the other chart, so we鈥檝e added some custom CSS to set the chart鈥檚 width to 50% and fix its height at 400px. We鈥檝e also given it a descriptive title:

Custom CSS

Fuel efficiency chart

Next, we want a bar chart that displays each of our vehicles鈥 average fuel cost per mile.

We鈥檒l start with a new query called FuelCostByVehicle. This one is relatively simple. We just need the average of (fuel_cost over miles), grouped by vehicle.

Here鈥檚 the query:

1SELECT
2
3  vehicle,
4
5  AVG(fuel_cost / miles) AS average_cost_per_mile
6
7FROM deliveries
8
9GROUP BY vehicle;

Return

And the output:

1{
2
3 "vehicle": "SEV-2710",
4
5 "average_cost_per_mile": 0.2158428505063057
6
7}

We鈥檒l add another chart block inside our container - this time setting its type to bar and its data to our new query:

Charts

Our label column is vehicle and the data column is average_cost_per_mile. We鈥檒l also select the horizontal option and apply some similar custom CSS to what we did for our pie chart.

Charts

Daily fuel costs

The last chart we鈥檒l put on this screen will display our daily total spend on fuel for the current month.

But, we don鈥檛 necessarily have values for every date. We鈥檒l need a query that takes account of this fact.

We鈥檒l first create a series of all of the days so far this month. We鈥檒l then use a COALESCE statement to SUM our fuel_cost attributes - but return 0 on the rows where this is NULL - and LEFT JOIN this to our series.

So, the final query is:

 1WITH all_dates AS (
 2
 3  SELECT generate_series(
 4
 5鈥        date_trunc('month', current_date)::date,
 6
 7current_date::date,
 8
 9'1 day'::interval
10
11鈥      )::date AS date
12
13)
14
15SELECT
16
17  ad.date AS departure_date,
18
19  COALESCE(SUM(d.fuel_cost), 0) AS total_fuel_cost
20
21FROM all_dates ad
22
23LEFT JOIN deliveries d ON DATE(d.departure_date_time) = ad.date
24
25GROUP BY ad.date
26
27ORDER BY ad.date;

Fuel cost query

The data we get back looks like this:

1{
2
3 "departure_date": "2023-10-01",
4
5 "total_fuel_cost": 95
6
7}

We鈥檒l use this data as a line chart below our existing charts:

Line chart

Since this is for the current month only, we鈥檒l use a bit of JavaScript to reflect this fact in the title, using the expression:

1const today = new Date();
2
3const month = String(today.getMonth() + 1).padStart(2, '0'); // Adding 1 because January is 0
4
5const year = today.getFullYear();
6
7return `Daily Fuel Spend This Month: (${month}/${year})`;

Our dashboard so far looks like this:

Fleet management dashboard

4. Building our deliveries report

Next, we want to build a very similar dashboard screen for data relating to our deliveries. We鈥檒l start by duplicating what we have so far.

Duplicate screen

We鈥檒l call the new screen /deliveries.

Now - what we want to do is work through each of our charts and swap out their data to display metrics that are relevant to our fleet鈥檚 performance.

We鈥檒l start at the top and work our way down.

Summary cards

We want our summary cards on this page of our fleet management dashboard to display the following metrics for the current month:

  1. The number of deliveries we鈥檝e made.
  2. The number of deliveries that have been late.
  3. The percentage of our deliveries that have been late.
  4. The average hours ahead or behind schedule we鈥檝e been.

So, we need to select the following:

  • The numerical month.
  • The numerical year.
  • The count of rows.
  • The count of rows where the real elapsed time exceeds the time limit.
  • The same thing expressed as a percentage.
  • The average difference between the time limit and the elapsed time.

Our query will be:

 1SELECT
 2
 3  CAST(EXTRACT(MONTH FROM departure_date_time) AS INTEGER) AS departure_month,
 4
 5CAST(EXTRACT(YEAR FROM departure_date_time) AS INTEGER) AS departure_year,
 6
 7  COUNT(*) AS total_deliveries,
 8
 9  COUNT(CASE WHEN EXTRACT(HOUR FROM arrival_date_time - departure_date_time) > time_limit_hours THEN 1 END) AS count_of_late_deliveries,
10
11  (COUNT(CASE WHEN EXTRACT(HOUR FROM arrival_date_time - departure_date_time) > time_limit_hours THEN 1 END)::decimal / COUNT(*)) * 100 AS percentage_late,
12
13  AVG(time_limit_hours - EXTRACT(HOUR FROM arrival_date_time - departure_date_time)) AS average_time_difference
14
15FROM deliveries
16
17GROUP BY departure_year, departure_month;

Postgres

And the returned data objects look like this:

 1{
 2
 3 "departure_month": 10,
 4
 5 "departure_year": 2023,
 6
 7 "total_deliveries": "12",
 8
 9 "count_of_late_deliveries": "4",
10
11 "percentage_late": "33.33333333333333333300",
12
13 "average_time_difference": 0.6666666666666666
14
15}

Now, we can go back and swap out our values for these without much fuss - we just need to filter for the month and year again - using the same JavaScript binding as we did earlier:

Cards

The fourth card is a bit trickier. Our query will return a positive or negative number depending on whether we鈥檙e ahead or behind schedule. We want to display a positive number no matter what and then alter the subtitle to indicate if we鈥檙e ahead or behind on average.

We鈥檒l use the ternary operator in JavaScript to achieve both of these.

So, for the title, we check if the time difference is positive or negative. If it鈥檚 positive, we return it as is. If it鈥檚 negative, we multiply it by -1 before we return it:

1var hours = $("TimeDifference card.LateDeliveriesByMonth.average_time_difference");
2
3var hoursDifference = hours >= 0 ? hours: (-1 * hours)
4
5return hoursDifference.toFixed(2);

And we use the same principle to decide what string to display below:

1var hours = $("AllTimeLate Cards block.LateDeliveriesByMonth.average_time_difference")
2
3var displayString = hours >= 0 ? "Avg Hours Ahead of Schedule": "Avg Hours Behind Schedule"
4
5return displayString;

Here are our finished cards:

Cards

Deliveries by origin charts

For our pie chart and bar chart, we鈥檙e going to display the number of deliveries that have departed from each of our sites.

We鈥檒l create a query called LateDeliveriesByOrigin. We鈥檙e basically going to get the same information as our previous query, but this time we鈥檒l group it by source rather than my date:

 1SELECT
 2
 3  origin,
 4
 5  COUNT(*) AS total_deliveries,
 6
 7  COUNT(CASE WHEN EXTRACT(HOUR FROM arrival_date_time - departure_date_time) > time_limit_hours THEN 1 END) AS count_of_late_deliveries,
 8
 9  (COUNT(CASE WHEN EXTRACT(HOUR FROM arrival_date_time - departure_date_time) > time_limit_hours THEN 1 END)::decimal / COUNT(*)) * 100 AS percentage_late
10
11FROM deliveries
12
13GROUP BY origin;

Queries

Here鈥檚 the response data:

 1{
 2
 3 "origin": "Pittsburgh",
 4
 5 "total_deliveries": "2",
 6
 7 "count_of_late_deliveries": "1",
 8
 9 "percentage_late": "50.00000000000000000000"
10
11}

And we can just straightforwardly swap out the data and display titles for our charts:

Fleet management dashboard

Daily departures

Our final chart is going to show the number of deliveries and late deliveries for each day this month.

So, we need to use the same WITH statement we did earlier to generate a series for each day this month and use a JOIN statement to match this up with data from our table.

Ultimately we鈥檒l select the same count of deliveries and count of late deliveries as we have in the last couple of queries. We also have one extra statement that prevents us from getting a zero division error.

Here鈥檚 the query:

 1WITH all_dates AS (
 2
 3  SELECT generate_series(
 4
 5鈥    date_trunc('month', current_date)::date,
 6
 7current_date::date,
 8
 9'1 day'::interval
10
11  )::date AS date
12
13)
14
15SELECT
16
17  ad.date AS departure_date,
18
19  COUNT(d.departure_date_time) AS total_deliveries,
20
21  COUNT(CASE WHEN EXTRACT(HOUR FROM d.arrival_date_time - d.departure_date_time) > d.time_limit_hours THEN 1 END) AS count_of_late_deliveries,
22
23  CASE
24
25WHEN COUNT(d.departure_date_time) = 0 THEN 0 -- Handle division by zero
26
27ELSE (COUNT(CASE WHEN EXTRACT(HOUR FROM d.arrival_date_time - d.departure_date_time) > d.time_limit_hours THEN 1 END)::decimal / COUNT(d.departure_date_time)) * 100
28
29  END AS percentage_late
30
31FROM all_dates ad
32
33LEFT JOIN deliveries d ON DATE(d.departure_date_time) = ad.date
34
35GROUP BY ad.date
36
37ORDER BY ad.date;

Queries

And the data we get back:

 1{
 2
 3 "departure_date": "2023-10-01",
 4
 5 "total_deliveries": "1",
 6
 7 "count_of_late_deliveries": "0",
 8
 9 "percentage_late": "0.00000000000000000000"
10
11}

We鈥檒l plug this data into our chart to get:

Line graph

5. Design tweaks and optimization

Lastly, we鈥檙e going to make a couple of little design tweaks. First of all, we鈥檒l head to the theme tab and select Nord to give our dashboard a slightly different feel:

Theme

Then, we鈥檒l select navigation and configure our menu links, so users can move between our two screens easily:

Nav

And that鈥檚 it! Here鈥檚 one last look at our finished fleet management dashboard:

Fleet management dashboard

We hope you found this tutorial helpful. To find out more about how 黑料正能量 empowers teams to turn data into action, check out our features overview .