Menu

Change language

GitHub repo

Sales Territory Map

blockanalytics

Sales territory map. Region pins with quota attainment; select a territory to fly the camera.

Installation

$npx uipkge-ng@latest add sales-territory-map
Manifest:https://uipkge.dev/r/angular/sales-territory-map.jsonInstalls to:src/app/components/blocks/sales-territory-map/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

SalesTerritory
interface SalesTerritory {
  id: string
  name: string
  code: string
  arr: string
  attainment: number
  status: 'attained' | 'ontrack' | 'attention'
  centerLngLat: [number, number]
}

npm dependencies

Includes

Files installed (2)

  • src/app/components/blocks/sales-territory-map/sales-territory-map.component.ts7.7 kB
    // Sales territory map. Region pins with quota attainment on a Mapbox map, and a
    // quota list beside it; selecting a territory (pin or row) flies the camera there.
    import {
      type AfterViewInit,
      Component,
      type ElementRef,
      Input,
      type OnDestroy,
      type OnInit,
      type QueryList,
      ViewChildren,
      signal,
      ChangeDetectionStrategy,
    } from '@angular/core'
    import type * as mapboxgl from 'mapbox-gl'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent, type BadgeVariant } from '@/ui/badge/badge.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiMapComponent } from '@/ui/map/map.component'
    
    export interface SalesTerritory {
      id: string
      name: string
      code: string
      arr: string
      attainment: number
      status: 'attained' | 'ontrack' | 'attention'
      centerLngLat: [number, number]
    }
    
    export const DEFAULT_TERRITORIES: SalesTerritory[] = [
      {
        id: 'na-enterprise',
        name: 'North America',
        code: 'NA-ENT',
        arr: '$6.4M',
        attainment: 111,
        status: 'attained',
        centerLngLat: [-98.5, 39.8],
      },
      {
        id: 'emea-central',
        name: 'EMEA Central',
        code: 'EMEA-C',
        arr: '$5.8M',
        attainment: 124,
        status: 'attained',
        centerLngLat: [10.4, 51.1],
      },
      {
        id: 'apac-growth',
        name: 'APAC Growth',
        code: 'APAC-G',
        arr: '$4.1M',
        attainment: 94,
        status: 'ontrack',
        centerLngLat: [120.0, 30.0],
      },
      {
        id: 'latam-emerging',
        name: 'Latin America',
        code: 'LATAM-E',
        arr: '$2.1M',
        attainment: 83,
        status: 'attention',
        centerLngLat: [-55.0, -14.0],
      },
    ]
    
    function statusVariant(status: SalesTerritory['status']): BadgeVariant {
      if (status === 'attained') return 'info'
      if (status === 'ontrack') return 'secondary'
      return 'warning'
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-sales-territory-map, [ui-sales-territory-map]',
      standalone: true,
      imports: [
        UiBadgeComponent,
        UiCardComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiCardContentComponent,
        UiMapComponent,
      ],
      host: {
        'data-slot': 'sales-territory-map',
        '[class]': 'hostClass',
      },
      template: `
        <div class="flex flex-wrap items-center justify-between gap-3">
          <h2 class="text-foreground text-lg font-semibold tracking-tight">Territories</h2>
          <p class="text-muted-foreground text-xs">{{ territories.length }} regions</p>
        </div>
    
        <div class="grid min-h-0 flex-1 grid-cols-1 gap-4 lg:grid-cols-12">
          <div ui-card class="border-border flex min-h-0 flex-col overflow-hidden shadow-xs lg:col-span-8">
            <div class="relative min-h-[360px] flex-1 overflow-hidden">
              <div
                ui-map
                [accessToken]="accessToken ?? ''"
                variant="dark"
                [center]="[15, 20]"
                [zoom]="1.2"
                class="absolute inset-0 size-full"
                (created)="onMapCreated($event)"
              >
                <!-- Marker elements: handed to mapbox-gl Markers once the map exists, which moves the
                     inner #marker node onto the map; the outer node stays put for Angular's @for. -->
                <div class="hidden">
                  @for (t of territories; track t.id) {
                    <div>
                      <div #marker>
                        <button type="button" [class]="markerClass(t.id)" (click)="selectTerritory(t)">
                          {{ t.code }} {{ t.attainment }}%
                        </button>
                      </div>
                    </div>
                  }
                </div>
              </div>
            </div>
          </div>
    
          <div ui-card class="border-border flex min-h-0 flex-col overflow-hidden shadow-xs lg:col-span-4">
            <div ui-card-header class="border-border border-b p-4">
              <h3 ui-card-title class="text-sm font-semibold">Quota</h3>
            </div>
            <div ui-card-content class="divide-border min-h-0 flex-1 divide-y overflow-auto p-0">
              @for (t of territories; track t.id) {
                <button type="button" [class]="rowClass(t.id)" (click)="selectTerritory(t)">
                  <div class="min-w-0">
                    <p class="text-foreground truncate text-sm font-medium">{{ t.code }} · {{ t.name }}</p>
                    <p class="text-muted-foreground font-mono text-xs tabular-nums">{{ t.arr }}</p>
                  </div>
                  <span ui-badge [variant]="statusVariant(t.status)" class="shrink-0 text-xs tabular-nums">
                    {{ t.attainment }}%
                  </span>
                </button>
              }
            </div>
          </div>
        </div>
      `,
    })
    export class UiSalesTerritoryMapComponent implements OnInit, AfterViewInit, OnDestroy {
      @Input() territories: SalesTerritory[] = DEFAULT_TERRITORIES
      @Input() accessToken?: string
      @Input('class') className?: string
    
      @ViewChildren('marker') private markerEls?: QueryList<ElementRef<HTMLDivElement>>
    
      readonly selectedId = signal('')
      protected readonly statusVariant = statusVariant
    
      private map: mapboxgl.Map | null = null
      private markers: mapboxgl.Marker[] = []
      private destroyed = false
    
      get hostClass(): string {
        return cn('flex h-full min-h-0 flex-col gap-4', this.className)
      }
    
      ngOnInit(): void {
        this.selectedId.set(this.territories[0]?.id ?? '')
      }
    
      ngAfterViewInit(): void {
        // A new territories list re-renders the marker elements; put the new set on the map.
        this.markerEls?.changes.subscribe(() => void this.syncMarkers())
      }
    
      ngOnDestroy(): void {
        this.destroyed = true
        this.clearMarkers()
        this.map = null
      }
    
      markerClass(id: string): string {
        return this.selectedId() === id
          ? 'border-primary bg-primary text-primary-foreground rounded-full border px-2 py-0.5 font-mono text-xs font-semibold shadow-xs'
          : 'border-border bg-card text-foreground rounded-full border px-2 py-0.5 font-mono text-xs font-semibold shadow-xs'
      }
    
      rowClass(id: string): string {
        return cn(
          'hover:bg-accent/50 flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors',
          this.selectedId() === id && 'bg-accent/60',
        )
      }
    
      onMapCreated(map: mapboxgl.Map): void {
        this.map = map
        if (typeof map.loaded === 'function' && map.loaded()) this.fitTerritories(map)
        else map.once('load', () => this.fitTerritories(map))
        void this.syncMarkers()
      }
    
      selectTerritory(territory: SalesTerritory): void {
        this.selectedId.set(territory.id)
        this.map?.flyTo({
          center: territory.centerLngLat,
          zoom: 2.8,
          essential: true,
          duration: 800,
        })
      }
    
      private fitTerritories(map: mapboxgl.Map): void {
        const coords = this.territories.map((t) => t.centerLngLat)
        if (coords.length < 2) return
        const lons = coords.map((c) => c[0])
        const lats = coords.map((c) => c[1])
        try {
          map.fitBounds(
            [
              [Math.min(...lons), Math.min(...lats)],
              [Math.max(...lons), Math.max(...lats)],
            ],
            { padding: { top: 48, bottom: 48, left: 80, right: 80 }, duration: 0, maxZoom: 1.8 },
          )
        } catch {
          /* map not ready */
        }
      }
    
      /** One mapbox-gl Marker per territory, anchored at its centre (the React MapMarker, anchor="center"). */
      private async syncMarkers(): Promise<void> {
        const map = this.map
        const els = this.markerEls?.toArray() ?? []
        if (!map) return
        const mod = await import('mapbox-gl')
        const mapbox = mod.default ?? mod
        if (this.destroyed || map !== this.map) return
        this.clearMarkers()
        this.markers = els.flatMap((el, i) => {
          const t = this.territories[i]
          if (!t) return []
          return [new mapbox.Marker({ element: el.nativeElement, anchor: 'center' }).setLngLat(t.centerLngLat).addTo(map)]
        })
      }
    
      private clearMarkers(): void {
        for (const m of this.markers) m.remove()
        this.markers = []
      }
    }
    
  • src/app/components/blocks/sales-territory-map/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/sales-territory-map.json