Bỏ qua, tới nội dung chính
Fleetwork
Fleetwork

Liên hệ

Chúng tôi rất mong nhận được liên hệ từ bạn. Vui lòng điền vào biểu mẫu bên dưới.

SDK

Hướng dẫn tích hợp React SDK

Hướng dẫn tích hợp Dashboard, LiveMap và Report GPS Tracking vào ứng dụng React web.

Package: @vietmap/tracking-sdk-react

Drop-in components Dashboard, LiveMap, Report dựng trên VietmapGL và shadcn/ui.

SDK này sử dụng VietMap Maps APIs để hỗ trợ các tính năng liên quan đến bản đồ: hiển thị tilemap, vẽ lộ trình, tìm kiếm địa chỉ, dẫn đường,…


Yêu cầu

Version
React≥ 16.8
TypeScript≥ 5.0 (khuyến nghị)
Node.js≥ 18

1. Cài đặt

Shell
npm install @vietmap/tracking-sdk-react
# hoặc
pnpm add @vietmap/tracking-sdk-react
yarn add @vietmap/tracking-sdk-react

Bắt buộc import CSS:

TSX
import "@vietmap/tracking-sdk-react/styles.css";

Peer dependencies: react >= 16.8.0, react-dom >= 16.8.0


2. Quick Start

TSX
import {
  FleetworkProvider,
  Dashboard,
  LiveMap,
  Report,
} from "@vietmap/tracking-sdk-react";
import "@vietmap/tracking-sdk-react/styles.css";

export default function App() {
  return (
    <FleetworkProvider apiKey="YOUR_API_TOKEN">
      <Dashboard />
      <LiveMap
        apiKeyTilemap="YOUR_VIETMAP_TILE_KEY"
        height="600px"
        memberNameKey="userName"
      />
      <Report />
    </FleetworkProvider>
  );
}

3. Provider

Bọc ứng dụng bằng FleetworkProvider:

TSX
import { FleetworkProvider } from "@vietmap/tracking-sdk-react";

function App() {
  return (
    <FleetworkProvider apiKey="YOUR_API_KEY" locale="vi">
      <YourApp />
    </FleetworkProvider>
  );
}

Provider Props

PropTypeMặc địnhMô tả
apiKeystring—API token (gửi qua header X-API-Key).
baseUrlstringhttps://app.fleetwork.vn/api/v1Base URL của API server.
locale'vi' | 'en''vi'Ngôn ngữ giao diện.
themeThemeConfig—Tuỳ chỉnh CSS variables (xem mục Theming).
memberNameKeystring—Key trong metaData của row dùng làm tên hiển thị (LiveMap + Dashboard MemberReport + bảng Report).
onAuthError(event) => void—Callback khi backend trả 401/403.
disableAuthErrorOverlaybooleanfalseTắt overlay mặc định.
renderAuthError(event, dismiss) => ReactNode—Render overlay riêng.

baseUrl và locale là tuỳ chọn — chỉ truyền khi cần override mặc định. apiKeyTilemap truyền trực tiếp vào prop của LiveMap, không qua Provider.

3.1 Tên hiển thị từ metaData — memberNameKey

Backend trả mỗi row user kèm field metaData chứa thuộc tính tuỳ ý (object hoặc JSON string). Đặt memberNameKey ở Provider để LiveMap markers, Dashboard MemberReport và bảng Report đồng nhất hiển thị tên từ field đó.

TSX
<FleetworkProvider apiKey="..." memberNameKey="userName">
  <Dashboard />                    {/* MemberReport hiện metaData.userName */}
  <LiveMap apiKeyTilemap="..." />  {/* Marker name từ metaData.userName */}
  <Report />                       {/* Bảng Hành trình / Nhiên liệu hiện metaData.userName */}
</FleetworkProvider>

Override ở từng component vẫn được ưu tiên hơn Provider:

TSX
<LiveMap apiKeyTilemap="..." memberNameKey="driverCode" />

Chuỗi fallback cho mỗi row: resolveMemberName(metaData, key) → row.name (nếu có) → row.userId. SDK export sẵn helper resolveMemberName để dùng ở ngoài (custom render…).

3.2 Xử lý lỗi xác thực (401 / 403)

SDK tự bắt response 401/403 từ backend và hiện overlay chặn UI với message đúng từ backend (không tự biên text). Người dùng đóng overlay bằng nút ×, phím Esc, click nền, hoặc bấm “Tải lại trang” (chỉ hiện với 401).

Response được parse theo thứ tự ưu tiên: message → status → detail → error → errors[0]. Ví dụ payload:

JSON
{
  "error": "unauthorized",
  "message": "Invalid API key",
  "status": "Invalid - API Key Not Found",
  "errors": ["API key not found in database"]
}

sẽ hiển thị "Invalid API key" (vì message ưu tiên cao nhất — đây là field dành cho người đọc, còn error thường là code slug). Muốn ưu tiên field khác hoặc hiện tổ hợp thì dùng renderAuthError để tự render từ event.payload.

TSX
// Mặc định — overlay tự bật, vẫn nhận callback song song
<FleetworkProvider
  apiKey="..."
  onAuthError={(event) => {
    // event: { status: 401|403, message, url, method, payload }
    if (event.status === 401) {
      // xử lý riêng
    }
  }}
>

// Tắt overlay nếu muốn tự xử lý hoàn toàn
<FleetworkProvider apiKey="..." disableAuthErrorOverlay onAuthError={handler}>

// Render overlay tuỳ chỉnh
<FleetworkProvider
  apiKey="..."
  renderAuthError={(event, dismiss) => (
    <MyCustomDialog message={event.message} onClose={dismiss} />
  )}
>

AuthErrorEvent

  • status401 | 403
    HTTP status.
  • messagestring
    Message từ backend (message/error/detail), fallback text mặc định.
  • urlstring?
    URL request gây lỗi.
  • methodstring?
    HTTP method (GET, POST, …).
  • payloadunknown?
    Raw response body từ server.

Nhiều lỗi trong khoảng 800ms được debounce — overlay chỉ refresh chậm hơn, tránh nháy khi nhiều request fail liên tiếp.


4. Cấp độ sử dụng

Cấp độMô tảKhi nào dùng
ComponentsUI có sẵn, import và renderNhanh, giao diện mặc định
HooksTrả về data + loading/errorCustom UI riêng
ControllersAsync functions thuầnNgoài React (Redux, Zustand…)

5. Components

5.1 Dashboard

Dashboard tổng hợp gồm 5 widget. Mỗi widget dùng độc lập được.

WidgetMô tả
SummaryCardsTổng quãng đường / thời gian / chi phí nhiên liệu hôm nay
MemberReportBảng nhân viên với phân trang và badge trạng thái
ActivityHeatmapHeatmap giờ hoạt động (T2–CN × 0–23h)
FuelTrackingBiểu đồ quãng đường vs tiêu thụ nhiên liệu
MonthlyExpensesBiểu đồ chi phí phân loại theo tháng
TSX
import {
  Dashboard,
  SummaryCards,
  MemberReport,
} from "@vietmap/tracking-sdk-react";

<Dashboard pollInterval={30_000} />
<Dashboard userIds={["u1", "u2"]} />   {/* lọc toàn bộ dashboard theo tập user */}

// Widget dùng độc lập cũng nhận userIds:
<SummaryCards userIds={["u1", "u2"]} />
<MemberReport pageSize={20} userIds={["u1", "u2"]} />

DashboardProps

PropTypeMặc địnhMô tả
datenumberhôm nayTimestamp ms.
pollIntervalnumber30000Tự động refresh (ms).
userIdsstring[]—Lọc toàn bộ dashboard (mọi widget) theo tập user, API lọc server-side; bỏ trống = tất cả.
showSummaryCardsbooleantrueHiện/ẩn widget.
showMemberReportbooleantrueHiện/ẩn widget.
showActivityHeatmapbooleantrueHiện/ẩn widget.
showFuelTrackingbooleantrueHiện/ẩn widget.
showMonthlyExpensesbooleantrueHiện/ẩn widget.

5.2 LiveMap

Bản đồ fleet real-time dùng VietmapGL (CDN loader) với clustering tăng tốc GPU.

Tính năng:

  • Tự động poll vị trí nhân viên (pollInterval, mặc định 10 giây).
  • GeoJSON clustering — render 3000+ marker, zoom để mở cluster.
  • Marker kiểu avatar — 2 chữ cái đầu tên trong circle + label tên (pill trắng) bên dưới, tự giãn theo độ dài tên; marker đè nhau thì chữ/label của marker bị đè tự ẩn.
  • Marker được chọn: glow + ring mảnh cùng màu trạng thái, label luôn hiển thị.
  • Spiderfy — click nhiều marker trùng toạ độ sẽ fan out để chọn riêng từng người.
  • Sidebar nhân viên — collapsible pill, infinite scroll, sắp xếp moving → stopped → mất tín hiệu, có tìm kiếm.
  • Tile switcher: terrain / light / dark / satellite.
  • Click marker → popup → Xem lộ trình → playback có animation kèm route overlay.
  • History panel: thống kê quãng đường + thời gian, timeline bar, danh sách điểm theo nhóm, date picker.
  • Đang xem lịch sử mà chọn nhân viên khác thì tự tải lại lịch sử của người đó.
  • Route map matching — tuyến lịch sử vẽ theo từng segment (không có đường “chim bay”), chọn nguồn qua prop dataSource; mode both/raw có thêm nút RAW trên thanh playback để chồng GPS gốc (cam nét đứt) lên tuyến đã làm mượt.
  • Lọc theo userIds và theo status (moving / stopped / signal_lost).
TSX
import { useRef } from "react";
import { LiveMap, type LiveMapRef } from "@vietmap/tracking-sdk-react";

const mapRef = useRef<LiveMapRef>(null);

<LiveMap
  ref={mapRef}
  apiKeyTilemap="YOUR_VIETMAP_KEY"
  height="600px"
  center={[106.63, 10.82]}
  zoom={11}
  defaultTile="terrain"
  pollInterval={10_000}
  maxUsers={3000}
  clusterRadius={50}
  clusterMaxZoom={14}
  memberNameKey="userName"
  showList
  onMarkerClick={(m) => console.log(m.name)}
  onMapReady={(map) => console.log("zoom:", map.getZoom())}
/>;

LiveMapProps

PropTypeMặc địnhMô tả
apiKeyTilemapstring—VietMap tile key (bắt buộc).
heightstring"100dvh"Chiều cao container.
center[lng, lat][106.6, 10.8]Toạ độ trung tâm bản đồ.
zoomnumber11Mức zoom ban đầu.
defaultTileTileType"terrain"light / dark / terrain / satellite.
pollIntervalnumber10000Chu kỳ refresh vị trí (ms).
maxUsersnumber3000Số nhân viên tối đa mỗi lần poll (chỉ áp dụng khi không lọc userIds).
userIdsstring[]—Chỉ tải/hiển thị các user này. API lọc server-side; bỏ trống = tất cả.
statusFilterMemberStatusKind[]—Chỉ hiện member có status này. Bỏ trống = tất cả. Controlled — khi set sẽ thắng setStatusFilter của ref.
dataSourceHistoryDataSource | null—Loại tuyến panel lịch sử yêu cầu (raw/both/merged); bỏ trống = để backend tự chọn.
showTransitionMarkersbooleanfalseHiện marker 🔄 ở ranh giới các segment lịch sử (hover xem time-gap).
autoFitbooleantrueTự fit viewport vào tất cả member ở lần tải đầu. Chỉ fit 1 lần và dừng ngay khi user pan/zoom → poll không đè view.
clusterRadiusnumber50Bán kính cluster (px).
clusterMaxZoomnumber14Mức zoom tắt cluster.
memberNameKeystring—Key trong metadata dùng làm tên hiển thị.
membersMemberStatus[]—Override data (tắt API polling).
showListbooleantrueHiện sidebar nhân viên.
onMarkerClick(m) => void | boolean—Return false để chặn popup mặc định.
onMemberClick(m) => void | boolean—Click item trên sidebar.
onMapClick([lng, lat]) => void—Click nền bản đồ.
onMapReady(map) => void—Fires sau khi bản đồ load xong.
renderMarkerPopup(m) => ReactNode—Tuỳ chỉnh nội dung popup.
renderMemberItem(m, default) => ReactNode—Tuỳ chỉnh hàng sidebar.

LiveMapRef — imperative API

TSX
mapRef.current?.flyTo([106.63, 10.82], 14);
mapRef.current?.fitBounds([
  [102, 8],
  [110, 23],
]);
mapRef.current?.focusMember("user-123");      // Bay đến + mở popup
mapRef.current?.getMembers();                 // MemberStatus[] (đã áp status filter)
mapRef.current?.getMap();                     // MapInstance
mapRef.current?.setStatusFilter(["moving"]);  // Lọc; [] hoặc undefined = xoá lọc
mapRef.current?.getStatusFilter();            // MemberStatusKind[] | undefined

Lọc theo nhóm user (userIds)

Mặc định LiveMap tải toàn bộ fleet. Truyền userIds để chỉ tải/hiển thị một tập user — API lọc server-side nên không kéo thừa data:

TSX
<LiveMap apiKeyTilemap="..." userIds={["driver-01", "driver-02", "driver-03"]} />

Lọc theo status

Filter áp lên cả marker lẫn sidebar và getMembers(). Hai cách dùng:

TSX
// 1) Controlled — qua prop (component cha giữ state)
<LiveMap apiKeyTilemap="..." statusFilter={["moving", "stopped"]} />

// 2) Imperative — gọi qua ref (bỏ prop để ở chế độ uncontrolled)
const mapRef = useRef<LiveMapRef>(null);
<LiveMap ref={mapRef} apiKeyTilemap="..." />;
mapRef.current?.setStatusFilter(["signal_lost"]); // chỉ hiện mất tín hiệu
mapRef.current?.setStatusFilter([]);              // xoá lọc

Khi truyền prop statusFilter, nó thắng setStatusFilter của ref (controlled). Bỏ prop thì ref điều khiển (uncontrolled). Status hợp lệ: "moving", "stopped", "signal_lost".


5.3 Report

Hub báo cáo — màn hình chính với 3 thẻ, điều hướng vào từng loại.

Báo cáoMô tả
Hành trìnhBảng tổng hợp per-user → click 1 user mở chi tiết hành trình của user đó
Nhiên liệuBảng tổng hợp per-user → click 1 user mở chi tiết nhiên liệu của user đó
Giờ hoạt độngSố nhân viên hoạt động theo giờ

Drill-down theo user: Hành trình và Nhiên liệu hiển thị bảng Tổng hợp; cột cuối “Thao tác” có nút Xem chi tiết (👁, click cả dòng cũng được) → mở trang chi tiết riêng của user đó (phân trang/sort/đổi khoảng ngày + nút quay lại, ẩn cột nhân viên).

Tất cả bảng: header sort được, sticky header + sticky hàng tổng, scroll ngang/dọc, DateRangePicker 2 tháng có nút xác nhận.

TSX
<Report from={Date.now() - 30 * 86_400_000} to={Date.now()} />

// userIds: chỉ lấy một tập user cho mọi báo cáo (API lọc server-side)
<Report from={from} to={to} userIds={["u1", "u2"]} />

ReportProps: from?, to?, userIds?, className?, style?, onError?.

Dùng từng sub-report độc lập:

TSX
import {
  TripSummaryReport,
  TripDetailReport,
  FuelSummaryReport,
  FuelDetailReport,
  ActivityTimeReport,
} from "@vietmap/tracking-sdk-react";

// Summary: range, onRangeChange, onBack, onError, pageSize, userIds, onUserClick
<TripSummaryReport
  range={{ from, to }}
  onRangeChange={setRange}
  onUserClick={(userId, name) => openDetail(userId, name)}
/>;

// Detail: thêm userId + userName để drill-down 1 user
// (ẩn cột nhân viên, lọc theo userId, tên hiện ở tiêu đề)
<TripDetailReport
  range={{ from, to }}
  onRangeChange={setRange}
  userId="u1"
  userName="Nguyễn Văn A"
/>;

6. Hooks

Tất cả hook cần FleetworkProvider trong tree. Trả về { data, isLoading, isFetching, error, refetch }.

SDK không dùng TanStack Query — hooks thuần useState/useEffect.

FieldÝ nghĩa
isLoadingChỉ true ở lần tải đầu (chưa có data) → hiện skeleton. Có delay 150ms để tránh nháy khi response nhanh.
isFetchingtrue mỗi khi có request đang chạy, kể cả refetch nền (đổi trang/sort/filter/khoảng ngày). Dùng cho spinner.

Các report giữ data cũ khi refetch nhưng vẫn hiện lại skeleton mỗi khi đổi trang/sort/filter (để rõ là đang tải lại). Các hook polling (map/dashboard) thì không, tránh nháy skeleton mỗi nhịp poll.

TSX
// Dashboard
const { data } = useSummaryCards({ date?, userIds?, pollInterval? })
const { data } = useMemberReport({ date?, page?, pageSize?, status?, userIds? })
const { data } = useActivityHeatmap({ from?, to?, metric?, userId?, userIds? })
const { data } = useFuelTracking({ from?, to?, groupBy?, userId?, userIds? })
const { data } = useMonthlyExpenses({ from?, to?, currency?, userIds? })

// LiveMap
const { data } = useMembers({ pollInterval?, nameKey?, maxUsers?, userIds? })
const { data } = useMember(userId)
const { data } = useHistoryRoute({ userId, startTime, endTime })

// Report
// userId? (1 user, drill-down) hoặc userIds? (nhiều user, summary lọc server-side)
const { data } = useTripSummaryReport({ from, to, page?, pageSize?, sortBy?, sortDesc?, userId?, userIds? })
const { data } = useTripDetailReport({ from, to, page?, pageSize?, sortBy?, sortDesc?, userId? })
const { data } = useFuelSummaryReport({ from, to, page?, pageSize?, sortBy?, sortDesc?, userId?, userIds? })
const { data } = useFuelDetailReport({ from, to, page?, pageSize?, sortBy?, sortDesc?, userId? })
const { data } = useActivityTimeReport({ from, to, page?, pageSize? })

7. Controllers (framework-agnostic)

Dùng ngoài React — Zustand, Redux, Node.js script, hoặc bất kỳ context nào.

TypeScript
import {
  initFleetwork,
  DashboardController,
  LiveMapController,
  ReportController,
} from "@vietmap/tracking-sdk-react";

initFleetwork({ apiKey: "...", baseUrl: "..." });

const summary = await DashboardController.getSummaryCards();
const members = await LiveMapController.getMembers({ pageSize: 3000 });
const some = await LiveMapController.getMembers({ userIds: ["u1", "u2"] }); // lọc server-side
const report = await DashboardController.getMemberReport(Date.now(), {
  userIds: ["u1", "u2"],
});
const history = await LiveMapController.getHistoryRoute(userId, from, to);
const trips = await ReportController.getTripSummary({
  from,
  to,
  userIds: ["u1", "u2"],
});
const fuel = await ReportController.getFuelSummary({ from, to });
const userTrips = await ReportController.getTripDetail({
  from,
  to,
  userId: "u1",
}); // chi tiết 1 user

POST + userIds: Toàn bộ DashboardController.* và LiveMapController.getMembers, ReportController.getTripSummary / getFuelSummary gọi bản POST (body) của API. Truyền userIds (mảng) để backend lọc nhiều user server-side; vượt 1000 id sẽ tự tách nhiều call rồi gộp. Báo cáo chi tiết (getTripDetail / getFuelDetail), getActivityTime, history/latest vẫn dùng GET; chi tiết lọc 1 user qua userId.

7.1 HttpService — gọi endpoint tuỳ chỉnh

Dùng client đã có sẵn API key + error handling (401/403 tự phát auth event) để gọi endpoint riêng:

TypeScript
import { HttpService } from "@vietmap/tracking-sdk-react";

const data = await HttpService.get<MyType>("my/endpoint", { from, to });
const created = await HttpService.post<MyType>("my/endpoint", { name: "..." });

Cả 4 method get / post / put / delete trả thẳng data; lỗi đã được interceptor chuẩn hoá thành SdkError (message dễ đọc + status). Tham số cuối (tuỳ chọn) nhận AxiosInstance riêng nếu không muốn dùng global client.

7.2 Ví dụ với Zustand

TypeScript
import { create } from "zustand";
import { DashboardController } from "@vietmap/tracking-sdk-react";
import type { SummaryCardsData } from "@vietmap/tracking-sdk-react";

interface Store {
  summary: SummaryCardsData | null;
  loading: boolean;
  fetchSummary: (date: number) => Promise<void>;
}

export const useDashboardStore = create<Store>((set) => ({
  summary: null,
  loading: false,
  fetchSummary: async (date) => {
    set({ loading: true });
    try {
      const summary = await DashboardController.getSummaryCards(date);
      set({ summary, loading: false });
    } catch {
      set({ loading: false });
    }
  },
}));

8. Lịch sử hành trình — dataSource

getHistoryComparison() và getHistoryRoute() nhận option dataSource để chọn tuyến mà backend dựng.

dataSourceBackend làm gìpoints lấy từField đi kèm
bỏ trống / nullMặc định. Không gửi DataSource. Ưu tiên enriched; khoảng thời gian nào chưa map-matching thì fallback rawenrichedSegments (flatten), fallback trackingDataenrichedSegments (khi có)
"raw"Bỏ qua map-matching hoàn toàn (không query enriched)trackingData (GPS gốc)rawPoints
"both"Query cả hai nguồn, trả song song để client tự đối chiếuenrichedSegments (flatten)rawPoints + enrichedPoints + enrichedSegments
"merged"Lấy enriched làm xương sống, chèn raw vào các đoạn matcher không match đượctrackingData (tuyến đã ghép)routeSummary
TypeScript
import { LiveMapController } from "@vietmap/tracking-sdk-react";

// Mặc định — backend tự quyết enriched hay raw
const auto = await LiveMapController.getHistoryComparison(userId, from, to);
auto.dataSource; // null

// GPS gốc, không map-matching — nhẹ nhất
const raw = await LiveMapController.getHistoryComparison(userId, from, to, {
  dataSource: "raw",
});

// Đối chiếu: vẽ enriched liền nét, raw nét đứt chồng lên
const cmp = await LiveMapController.getHistoryComparison(userId, from, to, {
  dataSource: "both",
});
cmp.enrichedSegments?.forEach((seg) => drawPolyline(seg)); // vẽ từng segment riêng
if (cmp.rawPoints) drawDashed(cmp.rawPoints);

// Một tuyến liền mạch, không hở ở chỗ matcher fail
const merged = await LiveMapController.getHistoryComparison(userId, from, to, {
  dataSource: "merged",
});
drawPolyline(merged.points);

// getHistoryRoute() = getHistoryComparison().points
const points = await LiveMapController.getHistoryRoute(userId, from, to, {
  dataSource: "merged",
});

Kết quả trả về luôn có field dataSource echo lại mode đã dùng (null khi để backend tự chọn), nên khi truyền mode động bạn không cần tự theo dõi.

Vì sao points lấy từ nguồn khác nhau: với "both" và mode mặc định, enrichedSegments là tuyến chuẩn và mỗi segment phải vẽ thành một polyline riêng — nối chúng lại sẽ sinh đường chim bay ở chỗ matcher cắt. Với "merged" thì ngược lại: trackingData đã là tuyến ghép hoàn chỉnh, nếu lại flatten enrichedSegments sẽ mất đúng những điểm raw vừa chèn vào và hở lại như cũ.

rawPoints được chuẩn hoá: ở mode "raw" backend trả GPS gốc trong trackingData và bỏ trống rawData, nhưng SDK vẫn set rawPoints để bạn đọc tuyến raw ở cùng một field bất kể mode.

Chọn mode từ component LiveMap

Prop dataSource được truyền thẳng xuống panel lịch sử; đổi prop sẽ tự fetch lại tuyến, giữ nguyên ngày đang chọn.

TSX
<LiveMap apiKeyTilemap={key} dataSource="merged" />

Chỉ "both" và "raw" trả về tuyến raw, nên nút bật/tắt overlay raw nét đứt trong LiveMap sẽ tự ẩn ở mode "merged" và ở mode mặc định khi backend dựng được enriched.

Segment lịch sử

enrichedSegments trả về có thể chứa nhiều entry cùng một segmentIndex (backend cắt một segment logic thành nhiều mảnh). SDK gộp chúng theo index trước khi vẽ, nối điểm theo thứ tự mảng (không sort theo thời gian, tránh zigzag). Các segment sau khi gộp được vẽ thành các polyline riêng (cắt tại ranh giới nên không có đường “chim bay”) nhưng cùng một màu.

Marker chuyển tiếp 🔄 giữa hai segment (hover hiện time-gap — khoảng trống thời gian đó là lý do matcher cắt đoạn) mặc định ẩn; bật bằng prop showTransitionMarkers:

TSX
<LiveMap apiKeyTilemap="..." showTransitionMarkers />

9. Theming

Design tokens của SDK là bộ shadcn slate (oklch), hỗ trợ sẵn light + dark.

Dark mode

Class-based: app chỉ cần toggle class .dark trên thẻ html là toàn bộ SDK components (bảng, chart, popup, overlay) tự đổi theo — không cần cấu hình gì thêm:

TypeScript
document.documentElement.classList.toggle("dark");

Tuỳ chỉnh qua prop theme

TSX
<FleetworkProvider
  theme={{
    colors: {
      primary: "#2563eb",
      background: "#0f172a",
      text: "#f1f5f9",
      border: "#1e293b",
      destructive: "#ef4444",
      statusMoving: "#10b981",
      statusStopped: "#f59e0b",
      statusSignalLost: "#94a3b8",
    },
    borderRadius: 8,
    fontFamily: "Inter, sans-serif",
  }}
>

ThemeConfig

FieldCSS variableMô tả
colors.primary--primaryMàu chính (button, focus ring)
colors.background--backgroundMàu nền trang
colors.text--foregroundMàu chữ chính
colors.border--borderMàu viền
colors.destructive--destructiveMàu lỗi / xoá
colors.statusMoving--status-movingMàu chấm “đang di chuyển”
colors.statusStopped--status-stoppedMàu chấm “dừng”
colors.statusSignalLost--status-signal-lostMàu chấm “mất tín hiệu”
borderRadius--radiusBorder radius (px)
fontFamily--dc-fontFont chữ

10. TypeScript Types

TypeScript
import type {
  // Config
  ThemeConfig,
  FleetworkConfig,
  Locale,
  TileType,
  MemberStatusKind,
  // Dashboard
  SummaryCardsData,
  MemberReportData,
  MemberRow,
  ActivityHeatmapData,
  ActivityHeatmapCell,
  FuelTrackingData,
  MonthlyExpensesData,
  // LiveMap
  MemberStatus,
  GpsPoint,
  HistoryDataSource,
  HistoryRoute,
  EnrichedSegment,
  RouteSummary,
  // Lỗi
  SdkError,
  AuthErrorEvent,
} from "@vietmap/tracking-sdk-react";
TypeScript
type Locale = "vi" | "en";
type TileType = "light" | "dark" | "terrain" | "satellite";
type MemberStatusKind = "moving" | "stopped" | "signal_lost";

interface SummaryCardsData {
  date: number; // Unix ms
  activeUsers: { active: number; total: number };
  totalDistance: { value: number; unit: string };
  totalTravelTime: { totalSeconds: number; formatted: string };
  totalFuelCost: { value: number; currency: string; formatted: string };
  generatedAt: number; // Unix ms
}

interface MemberReportData {
  date: number; // Unix ms
  summary: {
    total: number;
    moving: number;
    stopped: number;
    signalLost: number;
  };
  users: MemberRow[]; // key: users
  pagination: {
    page: number;
    pageSize: number;
    totalItems: number;
    totalPages: number;
  };
}

interface MemberRow {
  userId: string;
  distance: { value: number; unit: string };
  travelTime: { totalSeconds: number; formatted: string };
  fuel: {
    consumedLiters: number;
    costVnd: number;
    costFormatted: string;
    efficiencyKmPerL: number;
  };
  status: MemberStatusKind;
  statusLabel: string;
  lastLocation: {
    lat: number;
    lng: number;
    address: string | null;
    speed: number; // km/h
    time: number; // Unix ms
    // JSON string các thuộc tính tuỳ ý — dùng resolveMemberName để lấy tên
    metadata?: string | null;
  } | null;
  lastSeenAt: number | null; // Unix ms
}

interface ActivityHeatmapCell {
  dayOfWeek?: string | number | null; // 'Mon' | 'monday' | 'T2' | thiếu → dùng date
  date: number; // Mốc bucket (ms) — nguồn chuẩn để map ngày/giờ
  hour?: number; // 0–23 giờ địa phương; thiếu thì suy từ date
  value: number;
  label?: string;
}

interface ActivityHeatmapData {
  from: number;
  to: number;
  metric: "distance" | "points";
  resolution: "hour";
  cells: ActivityHeatmapCell[];
  maxValue: number;
  minValue: number;
  totalCells: number;
}

interface FuelTrackingData {
  from: number;
  to: number;
  groupBy: "day" | "week" | "month";
  fuelEfficiency: { value: number; unit: string; trend: string };
  series: {
    period: string; // "2026-W01" | "2026-01" | "2026-04-01"
    label: string;
    distanceKm: number;
    fuelLiters: number;
    efficiencyKmPerL: number;
  }[];
  totals: {
    totalDistanceKm: number;
    totalFuelLiters: number;
    avgEfficiencyKmPerL: number;
  };
}

interface MonthlyExpensesData {
  from: number;
  to: number;
  currency: string; // "VND"
  months: {
    month: number; // 1–12
    label: string;
    costs: {
      fuel: number;
      maintenance: number;
      insurance: number;
      other: number;
      total: number;
    };
  }[];
  totals: {
    fuel: number;
    maintenance: number;
    insurance: number;
    other: number;
    grandTotal: number;
  };
  categories: { key: string; label: string; color: string }[];
}

interface MemberStatus {
  userId: string;
  name: string;
  avatarUrl?: string | null;
  groupName?: string | null;
  status: MemberStatusKind;
  statusLabel: string;
  lat: number;
  lng: number;
  speed?: number; // km/h
  lastSeenAt?: number; // Unix ms
  lastAddress?: string;
}

interface GpsPoint {
  time: number; // Unix ms
  status?: number;
  lat: number;
  lng: number;
  speed: number; // km/h
  heading: number; // 0–360
  userId?: string | null;
  vehicleId?: string | null;
  deviceId?: string | null;
  data?: string | null;
  // Endpoint /gps-tracking/users trả JSON string; latest/history trả object
  metadata?: string | Record<string, unknown> | null;
}

// Một segment polyline liền mạch sau map-matching — vẽ mỗi segment thành 1 polyline riêng
interface EnrichedSegment {
  segmentIndex: number;
  points: GpsPoint[];
}

interface RouteSummary {
  distanceMeters: number; // Quãng đường thực đi, KHÔNG tính khoảng mất GPS
  movingDurationMs: number; // Thời gian di chuyển
  stoppedDurationMs: number; // Dừng nhưng GPS vẫn hoạt động
  lostGpsDurationMs: number; // Mất tín hiệu — KHÔNG nằm trong 3 field trên
}

type HistoryDataSource = "raw" | "both" | "merged";

interface HistoryRoute {
  points: GpsPoint[]; // Dùng cho playback / timeline / marker
  rawPoints: GpsPoint[] | null; // Tuyến GPS gốc, sort theo thời gian
  enrichedPoints: GpsPoint[] | null; // Tuyến map-matched
  enrichedSegments: GpsPoint[][] | null; // Vẽ mỗi phần tử thành 1 polyline riêng
  enriched: boolean;
  routeSummary: RouteSummary | null;
  dataSource: HistoryDataSource | null;
}

interface SdkError extends Error {
  status?: number;
}

11. Error Handling

TSX
// Hooks — error object
const { data, error } = useSummaryCards();
if (error) return <div>Lỗi: {error.message}</div>;

// Components — onError callback
<Dashboard onError={(err) => console.error(err)} />;

// Controllers — throw SdkError
try {
  const data = await DashboardController.getSummaryCards();
} catch (error) {
  // error.status: 401 | 403 | 429 | 500
  // error.message: string
}
StatusÝ nghĩa
401API key không hợp lệ hoặc hết hạn.
403Không có quyền truy cập.
429Vượt giới hạn rate limit.
500Lỗi server.

401/403 ngoài việc throw còn phát AuthErrorEvent để Provider hiện overlay — xem mục 3.2.


12. Ví dụ tích hợp hoàn chỉnh

Dashboard mặc định

TSX
import { FleetworkProvider, Dashboard } from "@vietmap/tracking-sdk-react";
import "@vietmap/tracking-sdk-react/styles.css";

export default function DashboardPage() {
  const today = new Date();
  today.setHours(0, 0, 0, 0);

  return (
    <FleetworkProvider apiKey="YOUR_API_KEY" locale="vi" memberNameKey="userName">
      <div className="p-6">
        <h1 className="text-2xl font-bold mb-6">GPS Dashboard</h1>
        <Dashboard date={today.getTime()} />
      </div>
    </FleetworkProvider>
  );
}

Custom layout với Hooks

TSX
import {
  useSummaryCards,
  useMemberReport,
  resolveMemberName,
} from "@vietmap/tracking-sdk-react";

function CustomDashboard({ date }: { date: number }) {
  const { data: summary, isLoading: s1 } = useSummaryCards({ date });
  const { data: report, isLoading: s2 } = useMemberReport({ date, pageSize: 5 });

  if (s1 || s2) return <p>Đang tải...</p>;

  return (
    <div>
      <div style={{ display: "flex", gap: 16 }}>
        <div>
          {summary?.activeUsers.active} / {summary?.activeUsers.total} nhân viên
        </div>
        <div>{summary?.totalDistance.value} km</div>
        <div>{summary?.totalFuelCost.formatted}</div>
      </div>
      <table>
        <tbody>
          {report?.users.map((m) => (
            <tr key={m.userId}>
              <td>
                {resolveMemberName(m.lastLocation?.metadata, "userName") ??
                  m.userId}
              </td>
              <td>{m.distance.value} km</td>
              <td>{m.statusLabel}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

LiveMap với Ref

TSX
import { useRef } from "react";
import {
  FleetworkProvider,
  LiveMap,
  type LiveMapRef,
} from "@vietmap/tracking-sdk-react";
import "@vietmap/tracking-sdk-react/styles.css";

export default function MapPage() {
  const mapRef = useRef<LiveMapRef>(null);

  return (
    <FleetworkProvider apiKey="YOUR_API_KEY">
      <div style={{ padding: 8, display: "flex", gap: 8 }}>
        <button
          onClick={() =>
            mapRef.current?.fitBounds([
              [105.8, 21.0],
              [106.7, 10.7],
            ])
          }
        >
          Fit All
        </button>
        <button onClick={() => mapRef.current?.setStatusFilter(["moving"])}>
          Chỉ hiện xe đang chạy
        </button>
      </div>
      <LiveMap
        ref={mapRef}
        apiKeyTilemap="YOUR_VIETMAP_TILE_KEY"
        height="calc(100vh - 48px)"
        center={[106.6, 10.8]}
        zoom={12}
        pollInterval={10_000}
        onMarkerClick={(member) => {
          alert(`${member.name} — ${member.statusLabel}`);
          return false;
        }}
        renderMarkerPopup={(member) => (
          <div style={{ minWidth: 180 }}>
            <strong>{member.name}</strong>
            <div>{member.lastAddress ?? "—"}</div>
            <div>Tốc độ: {member.speed ?? 0} km/h</div>
          </div>
        )}
      />
    </FleetworkProvider>
  );
}