Bỏ qua

Hướng Dẫn Tích Hợp Frontend — 3 Tính Năng Mới

Đối tượng: Đội Frontend
Base URL: http://localhost:3000 (API Gateway) — thay bằng biến môi trường của bạn
Xác thực: Tất cả endpoint Admin yêu cầu JWT hợp lệ với role ADMIN. Đính kèm Authorization: Bearer <token> trong mọi request.


Mục Lục

  1. Thống Kê & Báo Cáo Hệ Thống (Admin)
  2. Tổng Quan Dashboard Admin
  3. Quản Lý Người Dùng (Admin)
  4. Lịch Trống Realtime — SSE
  5. Polling Dự Phòng với ETag
  6. Tài Liệu Xử Lý Lỗi

1. Thống Kê & Báo Cáo Hệ Thống (Admin)

Endpoint

GET /admin/stats

Xác thực: Yêu cầu role ADMIN
Cache: Các service downstream cache nội bộ khoảng 10 phút. Nếu cần dữ liệu tươi hơn, hãy truyền khoảng thời gian cụ thể.

Query Parameters

Tham số Kiểu Bắt buộc Mặc định Ví dụ
startDate string Không 30 ngày trước 2026-04-01
endDate string Không Hôm nay 2026-05-25

Cấu Trúc Response

{
  "users": {
    "total": 245,
    "byRole": {
      "CUSTOMER": 204,
      "FIELD_OWNER": 40,
      "ADMIN": 1
    },
    "byStatus": {
      "ACTIVE": 245
    },
    "newInPeriod": 4,
    "growthTrend": [{ "date": "2026-05-09", "count": 4 }]
  },
  "fields": {
    "total": 207,
    "byStatus": {
      "ACTIVE": 207,
      "PENDING_APPROVAL": 0,
      "INACTIVE": 0,
      "MAINTENANCE": 0
    },
    "newInPeriod": 0,
    "growthTrend": []
  },
  "bookings": {
    "period": { "startDate": "2026-04-25", "endDate": "2026-05-25" },
    "totalBookings": 37,
    "totalRevenue": 494000,
    "byStatus": {
      "cancelled": 4,
      "completed": 2,
      "expired": 31
    },
    "cancellationRate": 0.1081,
    "bookingTrend": [{ "date": "2026-05-15", "count": 15, "revenue": 294000 }],
    "totalConfirmedAllTime": 1611
  },
  "errors": {
    "users": null,
    "fields": null,
    "bookings": null
  }
}

Lỗi một phần: Nếu một service downstream bị lỗi, key tương ứng sẽ là nullerrors.<tên_service> sẽ chứa thông báo lỗi. Hãy render những gì có và hiển thị banner cảnh báo cho phần bị lỗi.

Lưu Ý Tích Hợp

  • growthTrend có định dạng ngày YYYY-MM-DD — truyền thẳng vào thư viện chart (Chart.js, Recharts, v.v.).
  • cancellationRate là số thập phân (ví dụ: 0.1081 = 10,81%). Nhân với 100 khi hiển thị.
  • totalRevenue tính bằng VNĐ — không có số thập phân. Dùng .toLocaleString('vi-VN') để format.

Ví Dụ (TypeScript / Fetch)

const response = await fetch('/admin/stats?startDate=2026-04-01&endDate=2026-05-25', {
  headers: { Authorization: `Bearer ${token}` },
});
const data = await response.json();

// Dữ liệu biểu đồ doanh thu
const revenueChart = data.bookings?.bookingTrend.map((d) => ({
  x: d.date,
  y: d.revenue,
}));

// Dữ liệu biểu đồ tăng trưởng người dùng
const userGrowth = data.users?.growthTrend.map((d) => ({
  x: d.date,
  y: d.count,
}));

2. Tổng Quan Dashboard Admin

Endpoint

GET /admin/dashboard

Xác thực: Yêu cầu role ADMIN
Chiến lược làm mới: Poll mỗi 2 phút (dữ liệu ít thay đổi thường xuyên).

Cấu Trúc Response

{
  "pendingFieldApprovals": 3,
  "today": {
    "bookings": 5,
    "revenue": 1050000,
    "cancellationRate": 0.0
  },
  "users": {
    "total": 245,
    "byRole": { "CUSTOMER": 204, "FIELD_OWNER": 40, "ADMIN": 1 },
    "byStatus": { "ACTIVE": 245 }
  },
  "errors": {
    "pendingFields": null,
    "todayBookings": null,
    "userStats": null
  }
}

Mapping Dữ Liệu Lên Các Card Dashboard

Card Đường dẫn dữ liệu
Badge chờ duyệt sân pendingFieldApprovals
Lượt đặt sân hôm nay today.bookings
Doanh thu hôm nay today.revenue (VNĐ)
Tỷ lệ huỷ hôm nay today.cancellationRate * 100 %
Tổng người dùng users.total
Biểu đồ donut theo role users.byRole

Ví Dụ (React Hook)

function useAdminDashboard() {
  const [data, setData] = useState(null);

  useEffect(() => {
    const load = async () => {
      const res = await fetch('/admin/dashboard', {
        headers: { Authorization: `Bearer ${token}` },
      });
      setData(await res.json());
    };

    load();
    // Poll mỗi 2 phút
    const interval = setInterval(load, 2 * 60 * 1000);
    return () => clearInterval(interval);
  }, []);

  return data;
}

3. Quản Lý Người Dùng (Admin)

3.1 Danh Sách Người Dùng (có phân trang và bộ lọc)

GET /admin/users

Query Parameters:

Tham số Kiểu Bắt buộc Ví dụ
page number Không 1
limit number Không 20
role ADMIN \| FIELD_OWNER \| CUSTOMER Không CUSTOMER
status ACTIVE \| INACTIVE \| BANNED Không BANNED
search string Không nguyen

Response:

{
  "data": [
    {
      "id": "uuid",
      "email": "user@example.com",
      "name": "Nguyễn Văn A",
      "phone": "0901234567",
      "role": "CUSTOMER",
      "status": "ACTIVE",
      "createdAt": "2026-01-01T00:00:00.000Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 245,
    "totalPages": 13
  }
}

3.2 Xem Chi Tiết Một Người Dùng

GET /admin/users/:id

Trả về object người dùng tương tự như trên.

3.3 Cập Nhật Trạng Thái Người Dùng

PATCH /admin/users/:id/status

Request Body:

{
  "status": "BANNED",
  "reason": "Vi phạm chính sách nền tảng nhiều lần"
}
Trường Kiểu Bắt buộc Giá trị hợp lệ
status string ACTIVE, INACTIVE, BANNED
reason string Không Văn bản tự do, lưu vào metadata

Response: Object người dùng đã cập nhật (HTTP 200).

Các trường hợp lỗi:

  • 404 — Không tìm thấy người dùng
  • 403 — Người gọi không phải ADMIN
  • 400 — Giá trị status không hợp lệ

Ví Dụ (React)

async function banUser(userId: string, reason: string, token: string) {
  const res = await fetch(`/admin/users/${userId}/status`, {
    method: 'PATCH',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({ status: 'BANNED', reason }),
  });
  if (!res.ok) throw new Error(await res.text());
  return res.json();
}

4. Lịch Trống Realtime — SSE

Sử dụng endpoint SSE để nhận cập nhật tức thì khi có đặt sân mới, xác nhận hoặc huỷ đặt sân cho một sân cụ thể vào một ngày cụ thể. Không cần polling — server tự đẩy dữ liệu khi có thay đổi.

Endpoints

Stream theo ngày cụ thể (khuyến nghị cho giao diện lịch đặt sân)

GET /bookings/fields/:fieldId/availability/stream?date=YYYY-MM-DD&maxAge=300

Stream tất cả ngày (dành cho feed hoạt động của chủ sân)

GET /bookings/fields/:fieldId/availability/stream/all?maxAge=300

Xác thực: Public (không cần token) — giống endpoint schedule-grid.

Query Parameters

Tham số Kiểu Bắt buộc Mặc định Mô tả
date string Có* Ngày cần theo dõi (YYYY-MM-DD). *Không cần cho /all
maxAge number Không 300 Thời gian sống của kết nối tính bằng giây (mặc định 5 phút)

Các Loại Sự Kiện

Tên sự kiện Mô tả
availability_update Trạng thái đặt sân thay đổi cho sân/ngày này
heartbeat Giữ kết nối sống (mỗi 30 giây) — có thể bỏ qua

Payload của availability_update

{
  "fieldId": "uuid-của-sân",
  "date": "2026-05-25",
  "eventType": "BOOKING_CREATED",
  "bookingId": "uuid-của-booking",
  "ts": 1748140800000
}
Trường Mô tả
fieldId Sân nào bị ảnh hưởng
date Ngày (YYYY-MM-DD) của booking bị ảnh hưởng
eventType BOOKING_CREATED, BOOKING_CONFIRMED, BOOKING_CANCELLED, v.v.
bookingId Booking đã kích hoạt cập nhật
ts Unix timestamp (ms) của sự kiện

Tích Hợp (Vanilla JS / React)

function connectAvailabilityStream(
  fieldId: string,
  date: string, // "YYYY-MM-DD"
  onUpdate: () => void, // callback để gọi lại schedule-grid
) {
  const url = `/bookings/fields/${fieldId}/availability/stream?date=${date}&maxAge=300`;
  const es = new EventSource(url);

  es.addEventListener('availability_update', (e) => {
    const payload = JSON.parse(e.data);
    console.log('Lịch trống đã thay đổi:', payload);
    onUpdate(); // gọi lại để lấy dữ liệu mới nhất
  });

  // Bỏ qua sự kiện heartbeat
  es.addEventListener('heartbeat', () => {});

  es.onerror = (err) => {
    console.warn('SSE lỗi — sẽ tự kết nối lại', err);
    // EventSource tự động kết nối lại, không cần xử lý thêm
  };

  return es; // caller phải gọi es.close() khi unmount
}

Ví Dụ React Hook

function useAvailabilityStream(fieldId: string, date: string) {
  const [lastEvent, setLastEvent] = useState<null | object>(null);

  useEffect(() => {
    if (!fieldId || !date) return;

    const es = connectAvailabilityStream(fieldId, date, () => {
      setLastEvent({ ts: Date.now() }); // báo hiệu cho component cha fetch lại
    });

    return () => es.close(); // cleanup khi unmount hoặc đổi ngày
  }, [fieldId, date]);

  return lastEvent; // component cha theo dõi để quyết định khi nào fetch lại
}

Ví Dụ Sử Dụng Trong Component

function FieldCalendar({ fieldId }: { fieldId: string }) {
  const [date, setDate] = useState('2026-05-25');
  const [slots, setSlots] = useState([]);

  const fetchSlots = useCallback(async () => {
    const res = await fetch(`/bookings/fields/${fieldId}/schedule-grid?date=${date}`);
    const data = await res.json();
    setSlots(data.slots);
  }, [fieldId, date]);

  // Lần đầu tải dữ liệu
  useEffect(() => {
    fetchSlots();
  }, [fetchSlots]);

  // Lắng nghe cập nhật realtime — tự động fetch lại khi có thay đổi
  useAvailabilityStream(fieldId, date);
  // (truyền fetchSlots vào onUpdate nếu dùng phiên bản có callback)

  return (
    <div>
      {slots.map((slot) => (
        <SlotCard key={slot.startTime} slot={slot} />
      ))}
    </div>
  );
}

Chiến Lược Kết Nối Lại Khi Hết maxAge

EventSource tự kết nối lại khi có lỗi mạng. Stream tự đóng sau maxAge giây — browser sẽ thấy readyState === 2 (CLOSED). Đối với SPA mà người dùng ở lâu trên trang, hãy tự mở lại:

function openStreamWithAutoReconnect(
  fieldId: string,
  date: string,
  onUpdate: () => void,
): () => void {
  let es: EventSource;

  const open = () => {
    es = connectAvailabilityStream(fieldId, date, onUpdate);
    es.addEventListener('error', () => {
      // Nếu thực sự đóng (không phải lỗi mạng thoáng qua), mở lại sau 5 giây
      if (es.readyState === EventSource.CLOSED) {
        setTimeout(open, 5000);
      }
    });
  };

  open();
  return () => es?.close(); // trả về hàm cleanup
}

5. Polling Dự Phòng với ETag

Nếu SSE không khả thi (ví dụ: proxy không hỗ trợ long-lived connection), bạn có thể poll GET /bookings/fields/:fieldId/schedule-grid hiệu quả bằng HTTP ETag / If-None-Match — server trả về 304 Not Modified khi không có gì thay đổi, tiết kiệm băng thông.

Cách Hoạt Động

Lần đầu tiên — không có header If-None-Match:

GET /bookings/fields/uuid/schedule-grid?date=2026-05-25

Response: 200 OK
ETag: "a3f1b2c4..."
Body: { ... dữ liệu slot ... }

Các lần poll tiếp theo — gửi lại ETag đã nhận:

GET /bookings/fields/uuid/schedule-grid?date=2026-05-25
If-None-Match: "a3f1b2c4..."

Response: 304 Not Modified   ← không có body, không có gì thay đổi

Khi lịch thay đổi — server gửi dữ liệu mới:

Response: 200 OK
ETag: "d9e8f7a6..."   ← ETag mới
Body: { ... dữ liệu slot đã cập nhật ... }

Lưu ý: API Gateway hiện tại forward ETagIf-None-Match dạng pass-through. Browser fetch API không tự xử lý 304 — cần xử lý thủ công như ví dụ bên dưới.

Triển Khai Polling

let currentETag: string | null = null;
let currentSlots: SlotData[] = [];

async function pollScheduleGrid(fieldId: string, date: string): Promise<SlotData[] | null> {
  const headers: Record<string, string> = {};
  if (currentETag) headers['If-None-Match'] = currentETag;

  const res = await fetch(`/bookings/fields/${fieldId}/schedule-grid?date=${date}`, { headers });

  if (res.status === 304) {
    return null; // Không có gì thay đổi — giữ nguyên currentSlots
  }

  currentETag = res.headers.get('ETag');
  const data = await res.json();
  currentSlots = data.slots;
  return currentSlots;
}

// Poll mỗi 10 giây
setInterval(() => pollScheduleGrid(fieldId, date), 10_000);

Khi Nào Dùng Phương Pháp Nào

Tình huống Phương pháp
Browser hiện đại, proxy không hạn chế SSE (Mục 4) — khuyến nghị
Proxy nghiêm ngặt hoặc load balancer ETag polling (Mục 5) mỗi 10 giây
Kết hợp SSE là chính + ETag polling làm dự phòng

6. Tài Liệu Xử Lý Lỗi

Mã HTTP Status

Status Ý nghĩa Hiển thị gì
200 Thành công Render bình thường
304 Không thay đổi (ETag khớp) Giữ nguyên dữ liệu hiện tại, không báo gì
400 Yêu cầu không hợp lệ (params/body sai) Hiển thị lỗi từ trường message
401 Chưa xác thực — JWT thiếu hoặc không hợp lệ Redirect về trang đăng nhập
403 Không có quyền — JWT hợp lệ nhưng sai role Hiển thị màn hình "Không có quyền truy cập"
404 Không tìm thấy tài nguyên Hiển thị thông báo "Không tìm thấy"
409 Xung đột (ví dụ: sân đã được duyệt/từ chối rồi) Hiển thị thông báo xung đột từ message
503 Gateway — service downstream không khả dụng Hiển thị banner bảo trì

Cấu Trúc Response Lỗi

{
  "statusCode": 403,
  "message": "Forbidden resource",
  "error": "Forbidden"
}

Xử Lý Lỗi Một Phần (Stats & Dashboard)

Với GET /admin/statsGET /admin/dashboard, lỗi một phần vẫn trả HTTP 200 — service bị lỗi sẽ có data là null và message lỗi trong trường errors. Luôn kiểm tra errors trước khi render chart:

const stats = await fetchAdminStats();

if (stats.errors.bookings) {
  showWarning('Thống kê đặt sân tạm thời không khả dụng');
}

// Vẫn render các phần còn lại nếu có
if (stats.users) renderUserStats(stats.users);
if (stats.fields) renderFieldStats(stats.fields);

Ví Dụ Xử Lý Lỗi Toàn Diện (TypeScript)

async function fetchWithAuth<T>(url: string, token: string, options?: RequestInit): Promise<T> {
  const res = await fetch(url, {
    ...options,
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
      ...(options?.headers ?? {}),
    },
  });

  if (res.status === 401) {
    // Token hết hạn hoặc không hợp lệ
    redirectToLogin();
    throw new Error('Phiên đăng nhập đã hết hạn');
  }

  if (res.status === 403) {
    throw new Error('Bạn không có quyền thực hiện thao tác này');
  }

  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    throw new Error(body.message ?? `Lỗi HTTP ${res.status}`);
  }

  return res.json();
}