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
npm install @vietmap/tracking-sdk-react
# hoặc
pnpm add @vietmap/tracking-sdk-react
yarn add @vietmap/tracking-sdk-reactBắt buộc import CSS:
import "@vietmap/tracking-sdk-react/styles.css";Peer dependencies: react >= 16.8.0, react-dom >= 16.8.0
2. Quick Start
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:
import { FleetworkProvider } from "@vietmap/tracking-sdk-react";
function App() {
return (
<FleetworkProvider apiKey="YOUR_API_KEY" locale="vi">
<YourApp />
</FleetworkProvider>
);
}Provider Props
| Prop | Type | Mặc định | Mô tả |
|---|---|---|---|
apiKey | string | — | API token (gửi qua header X-API-Key). |
baseUrl | string | https://app.fleetwork.vn/api/v1 | Base URL của API server. |
locale | 'vi' | 'en' | 'vi' | Ngôn ngữ giao diện. |
theme | ThemeConfig | — | Tuỳ chỉnh CSS variables (xem mục Theming). |
memberNameKey | string | — | 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. |
disableAuthErrorOverlay | boolean | false | Tắt overlay mặc định. |
renderAuthError | (event, dismiss) => ReactNode | — | Render overlay riêng. |
baseUrlvàlocalelà tuỳ chọn — chỉ truyền khi cần override mặc định.apiKeyTilemaptruyền trực tiếp vào prop củaLiveMap, 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 đó.
<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:
<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:
{
"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.
// 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 | 403HTTP status.messagestringMessage 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 |
|---|---|---|
| Components | UI có sẵn, import và render | Nhanh, giao diện mặc định |
| Hooks | Trả về data + loading/error | Custom UI riêng |
| Controllers | Async functions thuần | Ngoà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.
| Widget | Mô tả |
|---|---|
SummaryCards | Tổng quãng đường / thời gian / chi phí nhiên liệu hôm nay |
MemberReport | Bảng nhân viên với phân trang và badge trạng thái |
ActivityHeatmap | Heatmap giờ hoạt động (T2–CN × 0–23h) |
FuelTracking | Biểu đồ quãng đường vs tiêu thụ nhiên liệu |
MonthlyExpenses | Biểu đồ chi phí phân loại theo tháng |
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
| Prop | Type | Mặc định | Mô tả |
|---|---|---|---|
date | number | hôm nay | Timestamp ms. |
pollInterval | number | 30000 | Tự động refresh (ms). |
userIds | string[] | — | Lọc toàn bộ dashboard (mọi widget) theo tập user, API lọc server-side; bỏ trống = tất cả. |
showSummaryCards | boolean | true | Hiện/ẩn widget. |
showMemberReport | boolean | true | Hiện/ẩn widget. |
showActivityHeatmap | boolean | true | Hiện/ẩn widget. |
showFuelTracking | boolean | true | Hiện/ẩn widget. |
showMonthlyExpenses | boolean | true | Hiệ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; modeboth/rawcó thêm nútRAWtrên thanh playback để chồng GPS gốc (cam nét đứt) lên tuyến đã làm mượt. - Lọc theo
userIdsvà theo status (moving/stopped/signal_lost).
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
| Prop | Type | Mặc định | Mô tả |
|---|---|---|---|
apiKeyTilemap | string | — | VietMap tile key (bắt buộc). |
height | string | "100dvh" | Chiều cao container. |
center | [lng, lat] | [106.6, 10.8] | Toạ độ trung tâm bản đồ. |
zoom | number | 11 | Mức zoom ban đầu. |
defaultTile | TileType | "terrain" | light / dark / terrain / satellite. |
pollInterval | number | 10000 | Chu kỳ refresh vị trí (ms). |
maxUsers | number | 3000 | Số nhân viên tối đa mỗi lần poll (chỉ áp dụng khi không lọc userIds). |
userIds | string[] | — | Chỉ tải/hiển thị các user này. API lọc server-side; bỏ trống = tất cả. |
statusFilter | MemberStatusKind[] | — | Chỉ hiện member có status này. Bỏ trống = tất cả. Controlled — khi set sẽ thắng setStatusFilter của ref. |
dataSource | HistoryDataSource | null | — | Loại tuyến panel lịch sử yêu cầu (raw/both/merged); bỏ trống = để backend tự chọn. |
showTransitionMarkers | boolean | false | Hiện marker 🔄 ở ranh giới các segment lịch sử (hover xem time-gap). |
autoFit | boolean | true | Tự 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. |
clusterRadius | number | 50 | Bán kính cluster (px). |
clusterMaxZoom | number | 14 | Mức zoom tắt cluster. |
memberNameKey | string | — | Key trong metadata dùng làm tên hiển thị. |
members | MemberStatus[] | — | Override data (tắt API polling). |
showList | boolean | true | Hiệ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
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[] | undefinedLọ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:
<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:
// 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ọcKhi truyền prop
statusFilter, nó thắngsetStatusFiltercủ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áo | Mô tả |
|---|---|
| Hành trình | Bảng tổng hợp per-user → click 1 user mở chi tiết hành trình của user đó |
| Nhiên liệu | Bảng tổng hợp per-user → click 1 user mở chi tiết nhiên liệu của user đó |
| Giờ hoạt động | Số 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.
<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:
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 |
|---|---|
isLoading | Chỉ true ở lần tải đầu (chưa có data) → hiện skeleton. Có delay 150ms để tránh nháy khi response nhanh. |
isFetching | true 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.
// 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.
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 userPOST +
userIds: Toàn bộDashboardController.*vàLiveMapController.getMembers,ReportController.getTripSummary/getFuelSummarygọi bản POST (body) của API. TruyềnuserIds(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 quauserId.
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:
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
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.
dataSource | Backend làm gì | points lấy từ | Field đi kèm |
|---|---|---|---|
bỏ trống / null | Mặc định. Không gửi DataSource. Ưu tiên enriched; khoảng thời gian nào chưa map-matching thì fallback raw | enrichedSegments (flatten), fallback trackingData | enrichedSegments (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ếu | enrichedSegments (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 được | trackingData (tuyến đã ghép) | routeSummary |
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 trongtrackingDatavà bỏ trốngrawData, nhưng SDK vẫn setrawPointsđể 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.
<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:
<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:
document.documentElement.classList.toggle("dark");Tuỳ chỉnh qua prop theme
<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
| Field | CSS variable | Mô tả |
|---|---|---|
colors.primary | --primary | Màu chính (button, focus ring) |
colors.background | --background | Màu nền trang |
colors.text | --foreground | Màu chữ chính |
colors.border | --border | Màu viền |
colors.destructive | --destructive | Màu lỗi / xoá |
colors.statusMoving | --status-moving | Màu chấm “đang di chuyển” |
colors.statusStopped | --status-stopped | Màu chấm “dừng” |
colors.statusSignalLost | --status-signal-lost | Màu chấm “mất tín hiệu” |
borderRadius | --radius | Border radius (px) |
fontFamily | --dc-font | Font chữ |
10. TypeScript Types
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";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
// 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 |
|---|---|
401 | API key không hợp lệ hoặc hết hạn. |
403 | Không có quyền truy cập. |
429 | Vượt giới hạn rate limit. |
500 | Lỗ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
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
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
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>
);
}