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ả endpointAdminyêu cầu JWT hợp lệ với roleADMIN. Đính kèmAuthorization: Bearer <token>trong mọi request.
Mục Lục
- Thống Kê & Báo Cáo Hệ Thống (Admin)
- Tổng Quan Dashboard Admin
- Quản Lý Người Dùng (Admin)
- Lịch Trống Realtime — SSE
- Polling Dự Phòng với ETag
- 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à
nullvàerrors.<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
growthTrendcó định dạng ngàyYYYY-MM-DD— truyền thẳng vào thư viện chart (Chart.js, Recharts, v.v.).cancellationRatelà số thập phân (ví dụ:0.1081= 10,81%). Nhân với 100 khi hiển thị.totalRevenuetí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 | Có | 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ùng403— Người gọi không phải ADMIN400— Giá trịstatuskhô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
ETagvàIf-None-Matchdạng pass-through. BrowserfetchAPI 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/stats và GET /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();
}