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


Source: https://docs.fleetwork.vn/sdk/tracking-sdk-react

> 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](https://www.npmjs.com/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](https://maps.vietmap.vn/docs/) để 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

```bash
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

| 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.                                                                                 |

> `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`**

| Field     | Type         | Mô tả                                                                        |
| --------- | ------------ | ---------------------------------------------------------------------------- |
| `status`  | `401 \| 403` | HTTP status.                                                                 |
| `message` | `string`     | Message từ backend (`message`/`error`/`detail`), fallback text mặc định.     |
| `url`     | `string?`    | URL request gây lỗi.                                                         |
| `method`  | `string?`    | HTTP method (`GET`, `POST`, ...).                                            |
| `payload` | `unknown?`   | 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                      |

```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`**

| 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`; 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`**

| 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**

```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á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.

```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                                                                                                        |
| ------------ | -------------------------------------------------------------------------------------------------------------- |
| `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.

```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.

```ts
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:

```ts
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

```ts
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`                                        |

```ts
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:

```ts
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`**

| 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

```ts
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";
```

```ts
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                            |
| ------ | ---------------------------------- |
| `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](#32-xử-lý-lỗi-xác-thực-401--403).

---

## 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>
  );
}
```
