Skip to content

Lesson 7 — The field officer dashboard

The manager works the whole country. The dispatcher works their depot’s region. The field officer works one SA3 — the patch they’re posted to, where their jobs live and where their day plays out.

This is the simplest dashboard of the three. One SA, one list of today’s jobs, no FO layer (the FO is the user — no sense putting a single truck on a map of one SA). The point of the lesson is to show the role-scoping idea applies cleanly across roles, with each scope smaller than the last.

Three scopes, one pattern

By the end of this module the tutorial has built three dashboards with the same shape:

  • Manager — national scope, 340 SAs, 9 depots
  • Dispatcher — regional scope, ~38 SAs, 1 depot
  • Field officer — local scope, 1 SA, today only

Each filters the same models (Job, ServiceArea) by a different boundary derived from the user. The plumbing is identical:

  1. A service object that takes the user, scopes the data, and returns either a hash (for dashboards) or a GeoJSON FeatureCollection (for map sources)
  2. A controller action that calls the service and renders
  3. A page or map component that reads the result

What changes between roles is the scoping clause. The manager’s job count uses no scope. The dispatcher’s uses service_area_id IN (depot.service_areas). The FO’s uses service_area_id = current_user.service_area_id and filters by today.

Part 1 — The local map

The FO’s map is small: their assigned SA3, with the day’s jobs as circles. No SA outline (only one polygon — its boundary doesn’t help orient them; it would just box the view), no FO pin (they’re the user). Just the SA’s shape and their jobs.

The service object. Create app/services/local_jobs.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
module LocalJobs
  extend self

  def call(user)
    jobs = Job.where(service_area_id: user.service_area_id,
                     status: %w[pending scheduled in_progress])
              .where("scheduled_for IS NULL OR scheduled_for::date = ?", Date.current)
              .includes(:customer)

    {
      type: "FeatureCollection",
      features: jobs.map { |job|
        {
          type:       "Feature",
          id:         job.id,
          geometry:   RGeo::GeoJSON.encode(job.location),
          properties: {
            status:       job.status,
            status_label: job.status.tr("_", " ").capitalize,
            customer:     job.customer.name,
            address:      job.customer.address,
            suburb:       job.customer.suburb,
            scheduled:    job.scheduled_for&.strftime("%-l:%M%P")
          }
        }
      }
    }
  end
end

Two narrowings compared to RegionalJobs:

  • service_area_id = user.service_area_id — single SA rather than the depot’s set. The User model has service_area_id directly (per the schema we built in Module 5 Lesson 1); the FO is “posted” to one SA at a time.
  • Today filterscheduled_for IS NULL OR scheduled_for::date = ?. Pending jobs (no scheduled_for) and jobs scheduled for today both belong on the FO’s queue. Anything scheduled for tomorrow or later isn’t their problem yet.

The SA boundary. Create app/services/local_service_area.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
module LocalServiceArea
  extend self

  def call(user)
    sa = user.service_area

    {
      type: "FeatureCollection",
      features: [{
        type:       "Feature",
        id:         sa.id,
        geometry:   RGeo::GeoJSON.encode(sa.boundary),
        properties: { name: sa.name, code: sa.code }
      }]
    }
  end
end

A FeatureCollection with a single feature. Same encoding pattern as the regional service; just one polygon.

The controllers. Add local actions to the existing JobsController and ServiceAreasController:

1
2
3
4
5
6
7
8
9
class JobsController < ApplicationController
  def regional
    render json: RegionalJobs.call(Current.user)
  end

  def local
    render json: LocalJobs.call(Current.user)
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class ServiceAreasController < ApplicationController
  def index
    # ...
  end

  def regional
    render json: RegionalServiceAreas.call(Current.user)
  end

  def local
    render json: LocalServiceArea.call(Current.user)
  end
end

Routes:

1
2
get "/jobs/local",          to: "jobs#local"
get "/service_areas/local", to: "service_areas#local"

The URL hierarchy continues: /something/regional for dispatcher scope, /something/local for FO scope. National routes drop the prefix entirely (e.g. /service_areas for the manager’s whole-country set).

The map component. Create app/components/local_map.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class Components::LocalMap < Components::Base
  prop :id,     String, default: -> { "local-map" }
  prop :height, String, default: -> { "100%" }

  JOB_STATUS_COLOURS = {
    "pending"     => "#f59e0b",
    "scheduled"   => "#3b82f6",
    "in_progress" => "#10b981"
  }.freeze

  JOB_POPUP_TEMPLATE = <<~HTML.freeze
    <div class="vera-popup">
      <div class="vera-popup__head">
        <span class="vera-popup__pill vera-popup__pill--{{status}}">{{status_label}}</span>
        {{#if scheduled}}<span class="vera-popup__waiting">{{scheduled}}</span>{{/if}}
      </div>

      <div class="vera-popup__customer">{{customer}}</div>
      <div class="vera-popup__address">{{address}}, {{suburb}}</div>
    </div>
  HTML

  def view_template
    render Vera::Map.new(id: @id, height: @height, style: :voyager) do |m|
      add_service_area(m)
      add_jobs(m)
    end
  end

  private

  def add_service_area(m)
    m.source :service_area, url: "/service_areas/local.json",
                            fit_bounds: { padding: 40 }

    m.layer :service_area_outline, source: :service_area, type: :line,
            paint: { line_color: "#94a3b8", line_width: 1, line_opacity: 0.5 }
  end

  def add_jobs(m)
    m.source :jobs, url: "/jobs/local.json"

    m.layer :jobs_circle, source: :jobs, type: :circle,
            paint: {
              circle_color: ["match", ["get", "status"],
                             "pending",     JOB_STATUS_COLOURS["pending"],
                             "scheduled",   JOB_STATUS_COLOURS["scheduled"],
                             "in_progress", JOB_STATUS_COLOURS["in_progress"],
                             "#64748b"],
              circle_radius:       6,
              circle_stroke_color: "#ffffff",
              circle_stroke_width: 1
            },
            on_hover: { circle_radius: 8 },
            popup:    { template: JOB_POPUP_TEMPLATE }
  end
end

Same add_* pattern from Lesson 5. Two features (SA boundary, jobs), two methods, three lines in view_template.

The SA outline is #94a3b8 (slate-400) instead of #dc2626 (red). At regional scope the dispatcher needs the red outline to distinguish 38 polygons from each other. At local scope the FO has one polygon — it doesn’t need to fight for attention. A subtle slate boundary keeps the viewport oriented without dominating.

fit_bounds: { padding: 40 } gives more breathing room than the dispatcher’s regional map; for a single SA the padding controls how much “negative space” surrounds the polygon at the default zoom.

Part 2 — The dashboard data service

Same pattern as the dispatcher’s: DispatcherDashboardData.call(user). Build app/services/field_officer_dashboard_data.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
module FieldOfficerDashboardData
  extend self

  def call(user)
    {
      counts:        counts(user),
      todays_queue:  todays_queue_section(user)
    }
  end

  private

  def counts(user)
    today = Date.current
    {
      pending:        Job.where(service_area_id: user.service_area_id,
                                status: "pending").count,
      scheduled:      Job.where(service_area_id: user.service_area_id,
                                status: "scheduled",
                                scheduled_for: today.all_day).count,
      in_progress:    Job.where(service_area_id: user.service_area_id,
                                status: "in_progress").count,
      complete_today: Job.where(service_area_id: user.service_area_id,
                                status: "complete",
                                completed_at: today.all_day).count
    }
  end

  def todays_queue_section(user)
    today = Date.current
    relation = Job.where(service_area_id: user.service_area_id)
                  .where(<<~SQL.squish, today.beginning_of_day, today.end_of_day)
                    status = 'pending'
                    OR (status = 'scheduled' AND scheduled_for BETWEEN ? AND ?)
                    OR status = 'in_progress'
                  SQL
                  .order(Arel.sql("CASE status WHEN 'in_progress' THEN 1 WHEN 'scheduled' THEN 2 ELSE 3 END"),
                         :scheduled_for)
                  .includes(:customer)

    {
      total: relation.count,
      items: relation.limit(20)
    }
  end
end

The “today’s queue” section is the FO’s whole working day, ordered by status — in-progress first (what they’re doing right now), scheduled second (what’s coming up), pending last (the catch-all). Within each group, scheduled jobs order by their booking time.

The page component. Create app/components/dashboards/field_officer_dashboard.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Components::Dashboards::FieldOfficerDashboard < Components::Base
  prop :data, Hash
  prop :user, User

  def view_template
    div(class: "h-full flex flex-col bg-slate-50") do
      PageHeader(
        title:      @user.service_area.name,
        subtitle:   "Today's queue",
        breadcrumb: ["Dashboard"]
      )

      render_stat_strip

      div(class: "flex-1 min-h-0 p-6") do
        div(class: "h-full rounded-lg border border-slate-200 bg-white overflow-hidden flex") do
          div(class: "flex-1 min-w-0") { LocalMap(height: "100%") }
          aside(class: "w-96 border-l border-slate-200 overflow-y-auto") do
            div(class: "p-6") do
              TodaysQueueList(total: @data[:todays_queue][:total],
                              items: @data[:todays_queue][:items])
            end
          end
        end
      end
    end
  end

  private

  def render_stat_strip
    div(class: "grid grid-cols-2 md:grid-cols-4 gap-4 px-6 py-5 bg-slate-50 border-b border-slate-200") do
      StatTile(label: "Pending",        value: @data[:counts][:pending],        sublabel: "in your area")
      StatTile(label: "Scheduled",      value: @data[:counts][:scheduled],      sublabel: "today")
      StatTile(label: "In progress",    value: @data[:counts][:in_progress],    sublabel: "active")
      StatTile(label: "Complete today", value: @data[:counts][:complete_today], sublabel: "finished")
    end
  end
end

The page header carries the SA name — “Brisbane Inner”, “Toowong - West End”, whatever the FO’s posting is. That single piece of context is what the FO needs to ground themselves.

The today’s queue list. Create app/components/todays_queue_list.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class Components::TodaysQueueList < Components::Base
  prop :total, Integer
  prop :items, _Any

  STATUS_COLOURS = {
    "in_progress" => "text-emerald-700 bg-emerald-50",
    "scheduled"   => "text-blue-700 bg-blue-50",
    "pending"     => "text-amber-700 bg-amber-50"
  }.freeze

  def view_template
    section do
      div(class: "flex items-baseline justify-between mb-3") do
        h2(class: "text-sm font-semibold text-slate-700 uppercase tracking-wider") { "Today's queue" }
        span(class: "text-xs text-slate-500") { "#{@total} job#{'s' unless @total == 1}" }
      end

      if @items.any?
        ul(class: "space-y-3") do
          @items.each { |job| render_item(job) }
        end
      else
        p(class: "text-sm text-slate-500 italic") { "No jobs in your queue today." }
      end
    end
  end

  private

  def render_item(job)
    li(class: "flex items-start justify-between gap-3 pb-3 border-b border-slate-200 last:border-0 last:pb-0") do
      div(class: "min-w-0 flex-1") do
        div(class: "text-sm text-slate-900 font-medium truncate") do
          plain "#{job.customer.name} · #{job.customer.suburb}"
        end
        if job.scheduled_for
          div(class: "text-xs text-slate-500 mt-0.5") { job.scheduled_for.strftime("%-l:%M%P") }
        end
      end
      div(class: "shrink-0") do
        span(class: "inline-block px-2 py-0.5 rounded text-xs font-medium #{STATUS_COLOURS[job.status]}") do
          job.status.tr("_", " ").capitalize
        end
      end
    end
  end
end

Same shape as the dispatcher’s lists — header with count, list of items, empty state. The status pill carries the same colour vocabulary as the map circles, so the FO can glance at the list and the map and read both in one language.

Wire up the controller. Update app/controllers/dashboard_controller.rb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class DashboardController < ApplicationController
  def index
    @data = data_for(Current.user)
    @page = page_component_for(Current.user.role)
  end

  private

  def data_for(user)
    case user.role
    when "manager"        then ManagerDashboardData.call
    when "dispatcher"     then DispatcherDashboardData.call(user)
    when "field_officer"  then FieldOfficerDashboardData.call(user)
    end
  end

  def page_component_for(role)
    case role
    when "manager"        then Components::Dashboards::ManagerDashboard
    when "dispatcher"     then Components::Dashboards::DispatcherDashboard
    when "field_officer"  then Components::Dashboards::FieldOfficerDashboard
    end
  end
end

The controller now handles all three roles. Each case arm is one line; both the case statements stay legible even as the application grows.

Look what we just did. Sign out, sign in as a field officer (any of the seeded FO accounts will work). Visit /dashboard. You’ll see:

  • A page header naming your SA
  • A stats strip showing pending, scheduled, in progress, complete-today counts (all scoped to your single SA)
  • A map zoomed to your SA, with job circles inside its boundary
  • A sidebar with today’s queue, ordered by status

Each FO sees a different SA. The Brisbane Inner FO sees Brisbane Inner. The Toowong FO sees Toowong. Same code, local data.

Activity 1 — The full role tour

Sign in as one user from each role and visit /dashboard. Three completely different operational views from the same controller and the same page-routing logic:

  • Manager — nine depots, 340 SAs, full national operation
  • Dispatcher — one depot’s region, ~38 SAs, lots of jobs and FOs
  • Field officer — one SA, today’s queue

The role-scoping idea has reached its smallest scale. Module 6 will make these scopes interactive — clicking an SA on the manager’s map drills into the dispatcher’s view; clicking a job pin on the dispatcher’s map opens the FO’s local view. For now, the routes are role-static.

Activity 2 — Try a quiet SA

Find an SA where the FO has nothing on their queue today. You can either:

  • Look in console for an FO whose SA has no current jobs, or
  • Move an FO to a quiet SA temporarily:
1
2
3
4
5
6
fo = User.find_by(role: "field_officer", email_address: "field_officer.1@vera.test")
quiet_sa = ServiceArea
  .where.not(id: Job.where(status: %w[pending scheduled in_progress])
                    .select(:service_area_id))
  .first
fo.update!(service_area_id: quiet_sa.id)

Sign in as that FO. The map shows their SA with no job circles. The today’s queue says “No jobs in your queue today.” The page is silent but useful — they’re not broken; there’s just nothing for them right now.

Where this leaves us

Module 5 closes with three role-scoped dashboards on the same foundation. The plumbing — service object, controller action, page or map component — is identical across roles; what varies is the scope clause.

Two architectural ideas worth carrying forward:

  • One scope per role, applied uniformly. Manager sees national, dispatcher sees their depot, FO sees their SA. The data layer never decides scope; the user’s role decides, and every service object respects the same rule. This makes role-based tests possible and predictable.
  • The same visual language across scales. Status colours, pill shapes, list layouts, popup styling — the three dashboards share design even though they show different data. A user who’s seen one dashboard can read the others without a manual.

Module 6 introduces real spatial predicates — ST_Contains, ST_Within, ST_DistanceSphere — and the maps start answering questions that can only be answered with geometry. For now the scoping is relational; the foundation is solid; the dashboards work.