Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions docs/TODO.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# line-fleet-admin — 缺口清單

> 建立:2026-07-08。最後盤點:2026-07-10(以程式碼實測為準)。
> 建立:2026-07-08。最後盤點:**2026-07-28**(以程式碼實測為準)。
> 編號沿用後端 repo 的 [gap-analysis-plan](../../line-fleet-dispatch/docs/2026-07-07-gap-analysis-plan.md)(C=後台前端、D=後端)。
> 每完成一項:實跑驗收 → 勾選回填 → commit + push(main)。

Expand All @@ -13,12 +13,22 @@
| 核心瀏覽 | ✅ 登入、營運總覽 Dashboard、即時車隊地圖、訂單列表/詳情+軌跡回放(含日期/關鍵字篩選)、司機列表、日報表(可匯出 CSV) |
| 寫入操作 | ✅ 司機啟停、派單參數、強制取消(2026-07-08) |
| 韌性 | ✅ 全域 Error Boundary、JWT `exp` 主動登出(2026-07-10) |
| 測試 | ✅ 19 測試檔/76 tests含 Dashboard/tokens/csv/ErrorBoundary) |
| 測試 | ✅ **26 測試檔/134 tests**(2026-07-28;含 Dashboard/tokens/csv/ErrorBoundary/`DriverDetailPage`) |
| CI | ✅ lint + test + build(`.github/workflows/ci.yml`);2026-07-10 修好 `npm ci` ERESOLVE |
| 視覺驗收 | ✅ C5(2026-07-08)+ UI/UX 翻新腳本已對齊新路由(`docs/screenshots/ux-2026-07-10/`) |

**一句話**:「看」已齊全;「管」核心寫入(C2/C3/強制取消)已串接 P2 API。

### 2026-07-28 勾選稽核(過期未勾)

本檔的四個 `[ ]`(3.3 登出確認、3.5 點擊司機連動、3.6 搜尋/篩選、3.6 司機詳情頁)
**其實全部早就實作了**,只是沒回填。逐條對照程式碼確認後補勾(各項證據見下方條目)。

**但其中三項先前完全沒有測試**——「做了沒勾」的代價不只是文件難讀,而是
**沒人知道它該有測試**:司機詳情頁連測試檔都不存在、popup 連結與登出確認也都沒被覆蓋。
同日補上 10 個測試(124 → **134 passed**),FleetPage 兩案做過反向驗證
(拿掉 popup 連結/拿掉委派 handler,各自只讓對應那案 FAIL)。

---

## 一、已完成 ✅
Expand Down Expand Up @@ -78,7 +88,9 @@
`RequireAuth` 依 `exp` 排到期鬧鐘(delay clamp 到 2^31-1,否則 setTimeout 溢位變立即觸發),
處理「停在頁面上不發請求、沒有 401 可觸發登出」的情境。
- [x] **已登入導向** — LoginPage 已登入自動導回首頁
- [ ] **登出確認** — 可選,避免誤觸
- [x] **登出確認** ✅ 已做(2026-07-28 查證補勾)— `AppLayout` 的登出走 `App.useApp()` 的
`modal.confirm`(「確定要登出?」),確認才 `clearSession()` + 導向 `/login`。
**先前沒有任何測試**,同日補 2 案(取消不清 session/確認才清)。

### 3.4 訂單瀏覽增強

Expand All @@ -97,15 +109,24 @@
### 3.5 車隊地圖增強

- [x] **Marker popup 資訊** — 姓名、狀態、更新時間
- [ ] **點擊司機連動** — 導向司機列表或詳情
- [x] **點擊司機連動** ✅ 已做(2026-07-28 查證補勾)— popup 內「查看司機 →」,
以**委派點擊**(`[data-driver-link]`)走 SPA 導向 `/drivers/:id`,`href` 保留當 JS 失效的退路。
**先前沒有任何測試**,同日補 3 案(popup 帶連結與正確 id/點連結走 SPA 導向/
點地圖其他位置不導向);兩案都做過反向驗證(拿掉連結或拿掉 handler 各自 FAIL)。
- [x] **共用 Map 元件** — `src/components/mapStyle.ts`
- [x] **地圖視野** — fitBounds 依司機點位

### 3.6 司機管理增強

- [x] **normalizeDriver** — `fetchDrivers` 兼容 PascalCase/snake_case
- [ ] **搜尋/篩選** — 姓名、電話、狀態
- [ ] **司機詳情頁** — `/drivers/:id`(後端若有單筆端點再串;否則用列表資料)
- [x] **搜尋/篩選** ✅ 已做(2026-07-28 查證補勾)— `Input.Search`「搜尋姓名/電話/車牌」
(比原規劃多了車牌)+狀態下拉(含「待審核」)+車種下拉(含「未填車輛」)。
先前只有車種篩選有測試,同日補關鍵字搜尋 1 案(姓名/電話/**車牌大小寫不敏感**三路都驗)。
- [x] **司機詳情頁** ✅ 已做(2026-07-28 查證補勾)— `DriverDetailPage` + 路由 `/drivers/:id`。
**後端確實沒有單筆司機 GET**(`api/admin.ts` 只有列表/狀態/車輛審核),
所以照原規劃的退路走列表資料:列表快取命中免再打,直接開網址則自己抓一次。
**先前完全沒有測試**,同日補 `DriverDetailPage.test.tsx` 4 案
(明細顯示/直接進入會自行取資料/查不到該 id 顯示「找不到司機 #999」並留兩處返回/麵包屑回列表)。

### 3.7 報表增強

Expand Down
51 changes: 49 additions & 2 deletions src/components/AppLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { afterEach, describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import AppLayout from './AppLayout';
import { renderWithProviders } from '../test/render';
import { clearSession, saveSession } from '../auth/auth';
import { clearSession, isLoggedIn, saveSession } from '../auth/auth';

describe('AppLayout 選單分級', () => {
afterEach(() => {
Expand All @@ -30,3 +31,49 @@ describe('AppLayout 選單分級', () => {
expect(screen.queryByText('使用者管理')).not.toBeInTheDocument();
});
});

// 登出鈕就在 Header 右上角、緊鄰使用者名稱,誤觸成本是整個 session 重登。
// 這兩案的重點是「取消真的什麼都沒發生」——只驗確認路徑會讓誤觸防護形同虛設。
describe('AppLayout 登出確認', () => {
afterEach(() => {
clearSession();
});

// ⚠️ antd 會在「剛好兩個中文字」的按鈕中間插入空格(DOM 實際是「登 出」「取 消」),
// 所以 name 一律用寬鬆的 /登\s*出/,精確字串比對會查不到元素。
// 標題同時出現在 ant-modal-title 與 ant-modal-confirm-title 兩處,
// 因此斷言走 dialog 的 textContent 而非 getByText(後者會 Found multiple)。
it('按登出先跳確認框,按取消不清 session', async () => {
const user = userEvent.setup();
saveSession('tok', '王小明', 'dispatcher');

renderWithProviders(<AppLayout />);

await user.click(await screen.findByRole('button', { name: /登\s*出/ }));
const dialog = await screen.findByRole('dialog');
expect(dialog).toHaveTextContent('確定要登出?');

await user.click(await within(dialog).findByRole('button', { name: /取\s*消/ }));

await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
expect(isLoggedIn()).toBe(true);
});

it('確認後才清 session', async () => {
const user = userEvent.setup();
saveSession('tok', '王小明', 'dispatcher');

renderWithProviders(<AppLayout />);

await user.click(await screen.findByRole('button', { name: /登\s*出/ }));
// 確認框的主鈕文案也是「登出」,取 dialog 內那顆以免點回 Header 上的原鈕
const dialog = await screen.findByRole('dialog');
await user.click(await within(dialog).findByRole('button', { name: /登\s*出/ }));

await waitFor(() => {
expect(isLoggedIn()).toBe(false);
});
});
});
75 changes: 75 additions & 0 deletions src/pages/DriverDetailPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import DriverDetailPage from './DriverDetailPage';
import { renderWithProviders } from '../test/render';

const mockFetchDrivers = vi.fn();

vi.mock('../api/admin', () => ({
fetchDrivers: (...args: unknown[]) => mockFetchDrivers(...args),
}));

const driver = {
ID: 7,
Name: '王大明',
Phone: '0912345678',
LineUserID: 'line-7',
Status: 1,
VehicleType: 'sedan',
PlateNumber: 'ABC-1234',
VehicleReviewStatus: 'approved',
VehicleReviewNote: '',
RatingAvg: 4.5,
RatingCount: 2,
CreatedAt: '2026-07-20T01:02:03Z',
UpdatedAt: '2026-07-21T04:05:06Z',
};

describe('DriverDetailPage', () => {
beforeEach(() => {
mockFetchDrivers.mockReset();
mockFetchDrivers.mockResolvedValue([driver]);
});

it('從列表資料取出對應司機並顯示明細', async () => {
renderWithProviders(<DriverDetailPage />, { route: '/drivers/7', path: '/drivers/:id' });

// 標題用姓名,代表確實比對到了 id 而不是只印路由參數
expect(await screen.findByText('司機:王大明')).toBeInTheDocument();
expect(screen.getByText('0912345678')).toBeInTheDocument();
expect(screen.getByText('line-7')).toBeInTheDocument();
expect(screen.getByText('待命')).toBeInTheDocument();
});

// 直接開網址(沒經過列表)時本頁會自己抓一次;這是它能單獨被連結的前提,
// 例如即時車隊地圖 popup 的「查看司機 →」。
it('直接進入時自行呼叫 fetchDrivers', async () => {
renderWithProviders(<DriverDetailPage />, { route: '/drivers/7', path: '/drivers/:id' });

expect(await screen.findByText('司機:王大明')).toBeInTheDocument();
expect(mockFetchDrivers).toHaveBeenCalled();
});

// 後端無單筆端點,找不到只可能是「列表裡沒有這個 id」——要說清楚是哪個 id,
// 並留一條回列表的路,不能只給一個空白 Card。
it('列表中沒有該 id 時顯示找不到並提供返回', async () => {
renderWithProviders(<DriverDetailPage />, { route: '/drivers/999', path: '/drivers/:id' });

expect(await screen.findByText('找不到司機 #999')).toBeInTheDocument();
// 兩顆:PageHeader 右側固定有一顆,Empty 裡再給一顆——空狀態的視線焦點在中間,
// 只留右上角那顆會讓人以為沒有出路。
expect(screen.getAllByRole('button', { name: /返回司機列表/ })).toHaveLength(2);
});

it('麵包屑可回到司機管理', async () => {
const user = userEvent.setup();
renderWithProviders(<DriverDetailPage />, { route: '/drivers/7', path: '/drivers/:id' });

expect(await screen.findByText('司機:王大明')).toBeInTheDocument();
const crumb = screen.getByRole('link', { name: '司機管理' });
expect(crumb).toHaveAttribute('href', '/drivers');
await user.click(crumb); // 不該丟例外(路由存在於 MemoryRouter)
});
});
38 changes: 38 additions & 0 deletions src/pages/DriversPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,44 @@ describe('DriversPage', () => {
});
expect(screen.queryByText('寵物車司機')).not.toBeInTheDocument();
});

// 搜尋框寫著「搜尋姓名/電話/車牌」——**三個欄位都要真的能搜**。
// placeholder 承諾了卻搜不到比沒有搜尋更糟:使用者會以為查無此人。
it('關鍵字搜尋:姓名/電話/車牌三個欄位都命中', async () => {
const user = userEvent.setup();
mockFetchDrivers.mockResolvedValue([
{ ID: 1, Name: '王大明', Phone: '0911111111', LineUserID: 'l1', Status: 1, VehicleType: 'sedan', PlateNumber: 'AAA-1111', VehicleReviewStatus: 'approved', VehicleReviewNote: '' },
{ ID: 2, Name: '李小美', Phone: '0922222222', LineUserID: 'l2', Status: 1, VehicleType: 'pet', PlateNumber: 'PET-0002', VehicleReviewStatus: 'approved', VehicleReviewNote: '' },
]);

renderWithProviders(<DriversPage />);
await waitFor(() => {
expect(screen.getByText('王大明')).toBeInTheDocument();
});

const search = screen.getByPlaceholderText('搜尋姓名/電話/車牌');

await user.type(search, '李小');
await waitFor(() => {
expect(screen.queryByText('王大明')).not.toBeInTheDocument();
});
expect(screen.getByText('李小美')).toBeInTheDocument();

await user.clear(search);
await user.type(search, '0911');
await waitFor(() => {
expect(screen.queryByText('李小美')).not.toBeInTheDocument();
});
expect(screen.getByText('王大明')).toBeInTheDocument();

// 車牌大小寫不該影響命中——路邊比對車牌時沒人在意大小寫
await user.clear(search);
await user.type(search, 'pet-0002');
await waitFor(() => {
expect(screen.queryByText('王大明')).not.toBeInTheDocument();
});
expect(screen.getByText('李小美')).toBeInTheDocument();
});
});

describe('DriversPage 評價欄(B5)', () => {
Expand Down
87 changes: 84 additions & 3 deletions src/pages/FleetPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import FleetPage from './FleetPage';
import { renderWithProviders } from '../test/render';

const mockFetchFleet = vi.fn();
const mockFetchDrivers = vi.fn();

// maplibre 被 mock 掉,popup 的 HTML 不會真的進 DOM——所以把它記下來直接驗字串,
// 並記住地圖容器(委派點擊掛在它身上),才能測到「點 popup 連結會導向詳情頁」。
const captured = vi.hoisted(() => ({
container: null as HTMLElement | null,
popupHtml: [] as string[],
}));
const navigateSpy = vi.hoisted(() => vi.fn());

vi.mock('react-router-dom', async (importOriginal) => ({
...(await importOriginal<typeof import('react-router-dom')>()),
useNavigate: () => navigateSpy,
}));

vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
vi.mock('maplibre-gl', () => {
const map = {
Expand All @@ -18,16 +32,29 @@ vi.mock('maplibre-gl', () => {
const marker = {
setLngLat: vi.fn().mockReturnThis(),
setPopup: vi.fn().mockReturnThis(),
getPopup: vi.fn(() => ({ setHTML: vi.fn() })),
getPopup: vi.fn(() => ({
setHTML: vi.fn((html: string) => {
captured.popupHtml.push(html);
}),
})),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
};
return {
default: {
Map: vi.fn(() => map),
Map: vi.fn((opts: { container: HTMLElement }) => {
captured.container = opts.container;
return map;
}),
Marker: vi.fn(() => marker),
Popup: vi.fn(function Popup() {
return { setHTML: vi.fn().mockReturnThis(), setText: vi.fn().mockReturnThis() };
return {
setHTML: vi.fn(function (this: unknown, html: string) {
captured.popupHtml.push(html);
return this;
}),
setText: vi.fn().mockReturnThis(),
};
}),
LngLatBounds: vi.fn(function LngLatBounds() {
return { extend: vi.fn().mockReturnThis() };
Expand All @@ -52,6 +79,9 @@ vi.mock('../api/admin', () => ({

describe('FleetPage', () => {
beforeEach(() => {
captured.container = null;
captured.popupHtml = [];
navigateSpy.mockReset();
mockFetchFleet.mockReset();
mockFetchDrivers.mockReset();
mockFetchFleet.mockResolvedValue([{ driver_id: 1, lat: 25.03, lng: 121.56, updated_at: 100 }]);
Expand All @@ -72,4 +102,55 @@ describe('FleetPage', () => {
expect(mockFetchFleet).toHaveBeenCalled();
expect(mockFetchDrivers).toHaveBeenCalled();
});

// popup 是「地圖 → 司機詳情」唯一的入口。它是手組的 HTML 字串,
// 沒有型別保護,改動 popup 內容時最容易把連結弄掉,所以直接驗字串。
it('marker popup 帶「查看司機」連結與正確的 driver id', async () => {
renderWithProviders(<FleetPage />);

await waitFor(() => {
expect(captured.popupHtml.length).toBeGreaterThan(0);
});

const html = captured.popupHtml.join('\n');
expect(html).toContain('查看司機');
expect(html).toContain('data-driver-link="1"');
// href 要留著:委派點擊靠 JS,JS 掛掉時整頁跳轉是唯一退路
expect(html).toContain('href="/drivers/1"');
expect(html).toContain('測試司機');
});

it('點 popup 的連結走 SPA 導向而不是整頁跳轉', async () => {
renderWithProviders(<FleetPage />);

await waitFor(() => {
expect(captured.container).not.toBeNull();
});

// maplibre 被 mock,popup 不會真的掛進 DOM;此處還原它的效果——
// 把同一段 HTML 放進地圖容器,驗證委派 handler 真的接得到。
const container = captured.container as HTMLElement;
container.insertAdjacentHTML(
'beforeend',
'<a href="/drivers/1" data-driver-link="1">查看司機 →</a>',
);

const link = container.querySelector('[data-driver-link]') as HTMLElement;
await userEvent.click(link);

expect(navigateSpy).toHaveBeenCalledWith('/drivers/1');
});

// 反向確認:容器內其他地方的點擊不該觸發導向,否則整張地圖都變成連結
it('點地圖其他位置不會導向', async () => {
renderWithProviders(<FleetPage />);

await waitFor(() => {
expect(captured.container).not.toBeNull();
});

await userEvent.click(captured.container as HTMLElement);

expect(navigateSpy).not.toHaveBeenCalled();
});
});
Loading