diff --git a/docs/screenshots/ux-2026-07-10/01-login.png b/docs/screenshots/ux-2026-07-10/01-login.png new file mode 100644 index 0000000..ff11e0c Binary files /dev/null and b/docs/screenshots/ux-2026-07-10/01-login.png differ diff --git a/docs/screenshots/ux-2026-07-10/02-dashboard.png b/docs/screenshots/ux-2026-07-10/02-dashboard.png new file mode 100644 index 0000000..f7488e3 Binary files /dev/null and b/docs/screenshots/ux-2026-07-10/02-dashboard.png differ diff --git a/docs/screenshots/ux-2026-07-10/03-fleet.png b/docs/screenshots/ux-2026-07-10/03-fleet.png new file mode 100644 index 0000000..fbd3888 Binary files /dev/null and b/docs/screenshots/ux-2026-07-10/03-fleet.png differ diff --git a/docs/screenshots/ux-2026-07-10/04-orders.png b/docs/screenshots/ux-2026-07-10/04-orders.png new file mode 100644 index 0000000..04e7ea1 Binary files /dev/null and b/docs/screenshots/ux-2026-07-10/04-orders.png differ diff --git a/docs/screenshots/ux-2026-07-10/05-order-detail.png b/docs/screenshots/ux-2026-07-10/05-order-detail.png new file mode 100644 index 0000000..951446c Binary files /dev/null and b/docs/screenshots/ux-2026-07-10/05-order-detail.png differ diff --git a/docs/screenshots/ux-2026-07-10/06-drivers.png b/docs/screenshots/ux-2026-07-10/06-drivers.png new file mode 100644 index 0000000..7e5142f Binary files /dev/null and b/docs/screenshots/ux-2026-07-10/06-drivers.png differ diff --git a/docs/screenshots/ux-2026-07-10/07-reports.png b/docs/screenshots/ux-2026-07-10/07-reports.png new file mode 100644 index 0000000..d10a1d9 Binary files /dev/null and b/docs/screenshots/ux-2026-07-10/07-reports.png differ diff --git a/docs/screenshots/ux-2026-07-10/report.json b/docs/screenshots/ux-2026-07-10/report.json new file mode 100644 index 0000000..0c5f602 --- /dev/null +++ b/docs/screenshots/ux-2026-07-10/report.json @@ -0,0 +1,62 @@ +{ + "date": "2026-07-10", + "base": "http://localhost:5173", + "rideId": "4", + "passed": 6, + "failed": 1, + "results": [ + { + "page": "01-login", + "url": "http://localhost:5173/login", + "screenshot": "/Users/mac/Documents/line-fleet-admin/docs/screenshots/ux-2026-07-10/01-login.png", + "status": "pass", + "notes": "" + }, + { + "page": "02-dashboard", + "url": "http://localhost:5173/", + "screenshot": "/Users/mac/Documents/line-fleet-admin/docs/screenshots/ux-2026-07-10/02-dashboard.png", + "status": "pass", + "notes": "" + }, + { + "page": "03-fleet", + "url": "http://localhost:5173/fleet", + "screenshot": "/Users/mac/Documents/line-fleet-admin/docs/screenshots/ux-2026-07-10/03-fleet.png", + "status": "pass", + "notes": "", + "mapCanvas": true + }, + { + "page": "04-orders", + "url": "http://localhost:5173/orders", + "screenshot": "/Users/mac/Documents/line-fleet-admin/docs/screenshots/ux-2026-07-10/04-orders.png", + "status": "pass", + "notes": "", + "tableRows": 0 + }, + { + "page": "05-order-detail", + "url": "http://localhost:5173/orders/4", + "screenshot": "/Users/mac/Documents/line-fleet-admin/docs/screenshots/ux-2026-07-10/05-order-detail.png", + "status": "fail", + "notes": "page.waitForSelector: Timeout 20000ms exceeded.\nCall log:\n - waiting for locator('text=訂單 #') to be visible\n" + }, + { + "page": "06-drivers", + "url": "http://localhost:5173/drivers", + "screenshot": "/Users/mac/Documents/line-fleet-admin/docs/screenshots/ux-2026-07-10/06-drivers.png", + "status": "pass", + "notes": "", + "tableRows": 0 + }, + { + "page": "07-reports", + "url": "http://localhost:5173/reports", + "screenshot": "/Users/mac/Documents/line-fleet-admin/docs/screenshots/ux-2026-07-10/07-reports.png", + "status": "pass", + "notes": "", + "tableRows": 0 + } + ] +} \ No newline at end of file diff --git a/src/api/admin.test.ts b/src/api/admin.test.ts index b930997..9bd3da6 100644 --- a/src/api/admin.test.ts +++ b/src/api/admin.test.ts @@ -13,6 +13,7 @@ import { normalizeDriver, parseTrackCoordinates, patchDriverStatus, + reviewDriverVehicle, updateAdmin, updateDispatchSettings, updateFeeSettings, @@ -77,6 +78,10 @@ describe('normalizeDriver', () => { Name: '司機A', Phone: '0912', Status: 1, + VehicleType: '', + PlateNumber: '', + VehicleReviewStatus: '', + VehicleReviewNote: '', CreatedAt: '2026-07-11T13:09:08+08:00', UpdatedAt: '2026-07-11T16:46:44+08:00', }); @@ -97,11 +102,33 @@ describe('normalizeDriver', () => { Name: '司機B', Phone: '', Status: 3, + VehicleType: '', + PlateNumber: '', + VehicleReviewStatus: '', + VehicleReviewNote: '', CreatedAt: '2026-07-10T00:00:00Z', UpdatedAt: '2026-07-10T01:00:00Z', }); }); + it('解析車輛與審核欄位(O5)', () => { + const d = normalizeDriver({ + ID: 5, + LineUserID: 'l', + Name: 'D', + Phone: '', + Status: 1, + vehicle_type: 'pet', + plate_number: 'PET-0001', + vehicle_review_status: 'rejected', + vehicle_review_note: '車牌照片模糊', + }); + expect(d.VehicleType).toBe('pet'); + expect(d.PlateNumber).toBe('PET-0001'); + expect(d.VehicleReviewStatus).toBe('rejected'); + expect(d.VehicleReviewNote).toBe('車牌照片模糊'); + }); + it('缺時間欄位時回空字串', () => { const d = normalizeDriver({ ID: 3, LineUserID: 'l', Name: 'C', Phone: '', Status: 0 }); expect(d.CreatedAt).toBe(''); @@ -256,6 +283,33 @@ describe('P2 admin write APIs', () => { expect(driver.Status).toBe(3); }); + it('reviewDriverVehicle 核准', async () => { + vi.mocked(api.post).mockResolvedValue({ + data: { driver: { ID: 4, Name: 'A', LineUserID: 'U4', Status: 0, vehicle_review_status: 'approved' } }, + }); + const driver = await reviewDriverVehicle(4, true); + expect(api.post).toHaveBeenCalledWith('/admin/drivers/4/vehicle-review', { approve: true, note: '' }); + expect(driver.VehicleReviewStatus).toBe('approved'); + }); + + it('reviewDriverVehicle 退回帶原因', async () => { + vi.mocked(api.post).mockResolvedValue({ + data: { + driver: { + ID: 4, Name: 'A', LineUserID: 'U4', Status: 0, + vehicle_review_status: 'rejected', vehicle_review_note: '車牌模糊', + }, + }, + }); + const driver = await reviewDriverVehicle(4, false, '車牌模糊'); + expect(api.post).toHaveBeenCalledWith('/admin/drivers/4/vehicle-review', { + approve: false, + note: '車牌模糊', + }); + expect(driver.VehicleReviewStatus).toBe('rejected'); + expect(driver.VehicleReviewNote).toBe('車牌模糊'); + }); + it('fetchDispatchSettings', async () => { vi.mocked(api.get).mockResolvedValue({ data: { radius_m: 3000, max_drivers: 5, offer_timeout_sec: 20, max_attempts: 3, rate_limit_per_min: 5 }, diff --git a/src/api/admin.ts b/src/api/admin.ts index 5cb7600..4c287fd 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -22,6 +22,10 @@ export interface Driver { Name: string; Phone: string; Status: number; // 0=離線 1=待命 2=載客中 + VehicleType: string; // 車種 code(O1);'' 為未填 + PlateNumber: string; // 車牌(O1);'' 為未填 + VehicleReviewStatus: string; // 審核狀態(O5):''/pending/approved/rejected + VehicleReviewNote: string; // 退回原因(O5) CreatedAt: string; // 後端回傳的 ISO 時間字串;缺值為空字串 UpdatedAt: string; } @@ -164,6 +168,10 @@ export function normalizeDriver(raw: Record): Driver { Name: str(raw, 'Name', 'name'), Phone: str(raw, 'Phone', 'phone'), Status: num(raw, 'Status', 'status'), + VehicleType: str(raw, 'VehicleType', 'vehicle_type'), + PlateNumber: str(raw, 'PlateNumber', 'plate_number'), + VehicleReviewStatus: str(raw, 'VehicleReviewStatus', 'vehicle_review_status'), + VehicleReviewNote: str(raw, 'VehicleReviewNote', 'vehicle_review_note'), CreatedAt: str(raw, 'CreatedAt', 'created_at'), UpdatedAt: str(raw, 'UpdatedAt', 'updated_at'), }; @@ -411,6 +419,19 @@ export async function patchDriverStatus(id: number, enabled: boolean): Promise { + const { data } = await api.post<{ driver: Record }>( + `/admin/drivers/${id}/vehicle-review`, + { approve, note }, + ); + return normalizeDriver(data.driver); +} + export async function fetchDispatchSettings(): Promise { const { data } = await api.get('/admin/settings/dispatch'); return data; diff --git a/src/constants.ts b/src/constants.ts index 4c4d385..57cb834 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -19,6 +19,23 @@ export const DRIVER_STATUS: Record = { /** 後端 DriverStatusDisabled */ export const DRIVER_STATUS_DISABLED = 3; +/** 車種顯示名(對齊後端 constants/vehicle.go 的 code) */ +export const VEHICLE_TYPE_LABEL: Record = { + sedan: '轎車', + suv: '休旅車', + van7: '七人座', + accessible: '無障礙車', + pet: '寵物用車', +}; + +/** 車輛審核狀態(O5,對齊後端 constants.VehicleReview*) */ +export const VEHICLE_REVIEW_STATUS: Record = { + '': { label: '未填車輛', color: 'default' }, + pending: { label: '待審核', color: 'warning' }, + approved: { label: '已核准', color: 'success' }, + rejected: { label: '已退回', color: 'error' }, +}; + /** 後台可強制取消的訂單狀態(已上車/已完成/已取消不可) */ export function isRideCancellable(status: number): boolean { return status >= 0 && status <= 2; diff --git a/src/pages/DriversPage.tsx b/src/pages/DriversPage.tsx index c727d78..64b997c 100644 --- a/src/pages/DriversPage.tsx +++ b/src/pages/DriversPage.tsx @@ -1,17 +1,23 @@ import { useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { App, Card, Empty, Input, Select, Space, Switch, Table, Tag, Tooltip } from 'antd'; +import { App, Button, Card, Empty, Input, Select, Space, Switch, Table, Tag, Tooltip } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { Link } from 'react-router-dom'; -import { fetchDrivers, patchDriverStatus, type Driver } from '../api/admin'; +import { fetchDrivers, patchDriverStatus, reviewDriverVehicle, type Driver } from '../api/admin'; import PageHeader from '../components/PageHeader'; import { canDispatch } from '../auth/auth'; -import { DRIVER_STATUS, DRIVER_STATUS_DISABLED } from '../constants'; +import { + DRIVER_STATUS, + DRIVER_STATUS_DISABLED, + VEHICLE_REVIEW_STATUS, + VEHICLE_TYPE_LABEL, +} from '../constants'; import { apiError } from '../utils/apiError'; const STATUS_OPTIONS = [ { value: 'all', label: '全部狀態' }, + { value: 'review', label: '待審核車輛' }, ...Object.entries(DRIVER_STATUS).map(([value, meta]) => ({ value, label: meta.label })), ]; @@ -29,12 +35,25 @@ export default function DriversPage() { const filtered = useMemo(() => { const kw = keyword.trim().toLowerCase(); return drivers.filter((d) => { - if (statusFilter !== 'all' && String(d.Status) !== statusFilter) return false; + if (statusFilter === 'review') { + if (d.VehicleReviewStatus !== 'pending') return false; + } else if (statusFilter !== 'all' && String(d.Status) !== statusFilter) { + return false; + } if (!kw) return true; - return d.Name.toLowerCase().includes(kw) || d.Phone.toLowerCase().includes(kw); + return ( + d.Name.toLowerCase().includes(kw) || + d.Phone.toLowerCase().includes(kw) || + d.PlateNumber.toLowerCase().includes(kw) + ); }); }, [drivers, keyword, statusFilter]); + const pendingCount = useMemo( + () => drivers.filter((d) => d.VehicleReviewStatus === 'pending').length, + [drivers], + ); + const mutation = useMutation({ mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => patchDriverStatus(id, enabled), @@ -45,6 +64,53 @@ export default function DriversPage() { onError: (err) => message.error(apiError(err, '操作失敗')), }); + // 車輛審核(O5):核准/退回。退回必附原因(後端也擋)。 + const reviewMutation = useMutation({ + mutationFn: ({ id, approve, note }: { id: number; approve: boolean; note?: string }) => + reviewDriverVehicle(id, approve, note), + onSuccess: (_data, vars) => { + message.success(vars.approve ? '已核准車輛' : '已退回車輛'); + queryClient.invalidateQueries({ queryKey: ['drivers'] }); + }, + onError: (err) => message.error(apiError(err, '審核失敗')), + }); + + const approveVehicle = (driver: Driver) => { + modal.confirm({ + title: `核准「${driver.Name || `司機 #${driver.ID}`}」的車輛?`, + content: `車種 ${VEHICLE_TYPE_LABEL[driver.VehicleType] ?? driver.VehicleType}/車牌 ${driver.PlateNumber}。核准後即可開始接單。`, + okText: '核准', + cancelText: '取消', + onOk: () => reviewMutation.mutateAsync({ id: driver.ID, approve: true }), + }); + }; + + const rejectVehicle = (driver: Driver) => { + let note = ''; + modal.confirm({ + title: `退回「${driver.Name || `司機 #${driver.ID}`}」的車輛`, + content: ( + { + note = e.target.value; + }} + /> + ), + okText: '退回', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + if (!note.trim()) { + message.error('退回必須填寫原因'); + return Promise.reject(new Error('note required')); + } + return reviewMutation.mutateAsync({ id: driver.ID, approve: false, note: note.trim() }); + }, + }); + }; + const toggleEnabled = (driver: Driver, enabled: boolean) => { const action = () => mutation.mutate({ id: driver.ID, enabled }); @@ -83,6 +149,53 @@ export default function DriversPage() { return {meta.label}; }, }, + { + title: '車輛', + width: 160, + render: (_: unknown, driver: Driver) => + driver.VehicleType ? ( + + {VEHICLE_TYPE_LABEL[driver.VehicleType] ?? driver.VehicleType} + {driver.PlateNumber} + + ) : ( + 未填 + ), + }, + { + title: '審核', + dataIndex: 'VehicleReviewStatus', + width: 110, + render: (s: string, driver: Driver) => { + const meta = VEHICLE_REVIEW_STATUS[s] ?? { label: s, color: 'default' }; + const tag = {meta.label}; + // 已退回時把原因掛在 tooltip,方便 admin 回想退了什麼。 + return s === 'rejected' && driver.VehicleReviewNote ? ( + {tag} + ) : ( + tag + ); + }, + }, + { + title: '車輛審核', + width: 150, + render: (_: unknown, driver: Driver) => { + if (driver.VehicleReviewStatus !== 'pending') return ; + if (!canDispatch()) return ; + const busy = reviewMutation.isPending && reviewMutation.variables?.id === driver.ID; + return ( + + + + + ); + }, + }, { title: '帳號', width: 100, @@ -110,10 +223,19 @@ export default function DriversPage() { setKeyword(e.target.value)} /> + {pendingCount > 0 && ( + setStatusFilter('review')} + > + {pendingCount} 台車輛待審核 + + )}