diff --git a/src/api/admin.test.ts b/src/api/admin.test.ts index 9bd3da6..2257b09 100644 --- a/src/api/admin.test.ts +++ b/src/api/admin.test.ts @@ -82,6 +82,8 @@ describe('normalizeDriver', () => { PlateNumber: '', VehicleReviewStatus: '', VehicleReviewNote: '', + RatingAvg: 0, + RatingCount: 0, CreatedAt: '2026-07-11T13:09:08+08:00', UpdatedAt: '2026-07-11T16:46:44+08:00', }); @@ -106,11 +108,28 @@ describe('normalizeDriver', () => { PlateNumber: '', VehicleReviewStatus: '', VehicleReviewNote: '', + RatingAvg: 0, + RatingCount: 0, CreatedAt: '2026-07-10T00:00:00Z', UpdatedAt: '2026-07-10T01:00:00Z', }); }); + it('解析評分彙總(B5);舊後端缺鍵時歸零=尚無評分', () => { + const rated = normalizeDriver({ + ID: 9, LineUserID: 'l', Name: 'D', Phone: '', Status: 1, + rating_avg: 4.5, rating_count: 12, + }); + expect(rated.RatingAvg).toBe(4.5); + expect(rated.RatingCount).toBe(12); + + // 舊後端不帶 rating_* → 0/0,呈現端據 RatingCount===0 顯示「尚無評分」, + // 不需要再判一種 undefined。 + const legacy = normalizeDriver({ ID: 10, LineUserID: 'l', Name: 'D', Phone: '', Status: 1 }); + expect(legacy.RatingAvg).toBe(0); + expect(legacy.RatingCount).toBe(0); + }); + it('解析車輛與審核欄位(O5)', () => { const d = normalizeDriver({ ID: 5, diff --git a/src/api/admin.ts b/src/api/admin.ts index 20c71ec..502f2e6 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -26,6 +26,9 @@ export interface Driver { PlateNumber: string; // 車牌(O1);'' 為未填 VehicleReviewStatus: string; // 審核狀態(O5):''/pending/approved/rejected VehicleReviewNote: string; // 退回原因(O5) + /** 乘客評分平均(B5);RatingCount 為 0 時無意義,呈現端要顯示「尚無評分」而非 0.0 */ + RatingAvg: number; + RatingCount: number; CreatedAt: string; // 後端回傳的 ISO 時間字串;缺值為空字串 UpdatedAt: string; } @@ -152,12 +155,21 @@ export interface RideEvent { created_at: string; } +/** 乘客給司機的評分(B5);一趟至多一則 */ +export interface RideRating { + score: number; + comment: string; + created_at: string; +} + export interface RideDetail { ride: RideFull; track_geojson: string; events: RideEvent[]; /** 多停靠點行程(N)才有;單點訂單為空陣列 */ stops: RideStop[]; + /** 乘客評分(B5);**未評分時為 null**(後端不帶該鍵) */ + rating: RideRating | null; } // 軌跡 GeoJSON:admin 端點回傳裸 LineString;司機/乘客端點包成 Feature @@ -210,6 +222,9 @@ export function normalizeDriver(raw: Record): Driver { PlateNumber: str(raw, 'PlateNumber', 'plate_number'), VehicleReviewStatus: str(raw, 'VehicleReviewStatus', 'vehicle_review_status'), VehicleReviewNote: str(raw, 'VehicleReviewNote', 'vehicle_review_note'), + // 舊後端不帶 rating_* → num() 回 0,等同「尚無評分」,不需另做缺鍵判斷 + RatingAvg: num(raw, 'RatingAvg', 'rating_avg'), + RatingCount: num(raw, 'RatingCount', 'rating_count'), CreatedAt: str(raw, 'CreatedAt', 'created_at'), UpdatedAt: str(raw, 'UpdatedAt', 'updated_at'), }; @@ -260,6 +275,7 @@ export async function fetchRideDetail(id: number): Promise { track_geojson: string; events?: Record[]; stops?: Record[]; + rating?: Record; }>(`/admin/rides/${id}`); return { ride: normalizeRide(data.ride), @@ -267,6 +283,16 @@ export async function fetchRideDetail(id: number): Promise { events: (data.events ?? []).map(normalizeRideEvent), // 單點訂單後端不帶 stops 鍵(omitempty),缺席=沒有停靠點,不是錯誤 stops: (data.stops ?? []).map(normalizeRideStop), + // 未評分時後端不帶 rating 鍵——缺席=乘客沒評,不是錯誤 + rating: data.rating ? normalizeRideRating(data.rating) : null, + }; +} + +export function normalizeRideRating(raw: Record): RideRating { + return { + score: num(raw, 'score'), + comment: str(raw, 'comment'), + created_at: str(raw, 'created_at'), }; } diff --git a/src/constants.ts b/src/constants.ts index 57cb834..ac9997b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -67,3 +67,13 @@ export function rideEventLabel(eventType: string): string { export function actorRoleLabel(role: string): string { return ACTOR_ROLE_LABEL[role] ?? (role || '—'); } + +/** + * 評分星色(B5):低分要**一眼看得出來**,那是營運會採取行動的訊號。 + * 門檻 3.5/4.5 是呈現用的粗分級,不參與任何計算或派單邏輯。 + */ +export function RATING_COLOR(avg: number): string { + if (avg < 3.5) return '#cf1322'; // 紅:需要關注 + if (avg < 4.5) return '#d48806'; // 黃:普通 + return '#faad14'; // 金星:良好 +} diff --git a/src/pages/DriversPage.test.tsx b/src/pages/DriversPage.test.tsx index cffdf96..6edd799 100644 --- a/src/pages/DriversPage.test.tsx +++ b/src/pages/DriversPage.test.tsx @@ -78,3 +78,43 @@ describe('DriversPage', () => { expect(screen.queryByText('寵物車司機')).not.toBeInTheDocument(); }); }); + +describe('DriversPage 評價欄(B5)', () => { + beforeEach(() => { + setRole('superadmin'); + mockFetchDrivers.mockReset(); + mockFetchDrivers.mockResolvedValue([ + { ID: 1, Name: '高分司機', Phone: '', LineUserID: 'l1', Status: 1, RatingAvg: 4.8, RatingCount: 25 }, + { ID: 2, Name: '低分司機', Phone: '', LineUserID: 'l2', Status: 1, RatingAvg: 2.5, RatingCount: 4 }, + { ID: 3, Name: '新司機', Phone: '', LineUserID: 'l3', Status: 1, RatingAvg: 0, RatingCount: 0 }, + ]); + }); + + it('有評分顯示「平均(則數)」,沒評分顯示「尚無評分」而非 0.0', async () => { + renderWithProviders(); + await waitFor(() => expect(screen.getByText('高分司機')).toBeInTheDocument()); + + expect(screen.getByText('4.8')).toBeInTheDocument(); + expect(screen.getByText('(25)')).toBeInTheDocument(); + expect(screen.getByText('2.5')).toBeInTheDocument(); + expect(screen.getByText('尚無評分')).toBeInTheDocument(); + // 0 則不該被渲染成 0.0 顆星——那看起來像「被評成 0 分」 + expect(screen.queryByText('0.0')).not.toBeInTheDocument(); + }); + + it('依評價排序:低分在前,沒評分的排最後(0 則不代表差)', async () => { + const user = userEvent.setup(); + renderWithProviders(); + await waitFor(() => expect(screen.getByText('高分司機')).toBeInTheDocument()); + + await user.click(screen.getByText('評價')); + + await waitFor(() => { + const names = screen + .getAllByRole('row') + .slice(1) + .map((r) => r.querySelector('a')?.textContent ?? ''); + expect(names).toEqual(['低分司機', '高分司機', '新司機']); + }); + }); +}); diff --git a/src/pages/DriversPage.tsx b/src/pages/DriversPage.tsx index 690ff0e..fbec549 100644 --- a/src/pages/DriversPage.tsx +++ b/src/pages/DriversPage.tsx @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; 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 { StarFilled } from '@ant-design/icons'; import { fetchDrivers, patchDriverStatus, reviewDriverVehicle, type Driver } from '../api/admin'; import PageHeader from '../components/PageHeader'; @@ -10,6 +11,7 @@ import { canDispatch } from '../auth/auth'; import { DRIVER_STATUS, DRIVER_STATUS_DISABLED, + RATING_COLOR, VEHICLE_REVIEW_STATUS, VEHICLE_TYPE_LABEL, } from '../constants'; @@ -191,6 +193,30 @@ export default function DriversPage() { ); }, }, + { + title: '評價', + width: 120, + // 可排序:營運要能一眼找出評價最低的司機,那正是這一欄存在的理由。 + // **沒評分的排在最後**(不是最前)——0 則不代表差,不該混進低分名單。 + sorter: (a: Driver, b: Driver) => { + if (a.RatingCount === 0 && b.RatingCount === 0) return 0; + if (a.RatingCount === 0) return 1; + if (b.RatingCount === 0) return -1; + return a.RatingAvg - b.RatingAvg; + }, + render: (_: unknown, driver: Driver) => + driver.RatingCount > 0 ? ( + + + + {driver.RatingAvg.toFixed(1)} + ({driver.RatingCount}) + + + ) : ( + 尚無評分 + ), + }, { title: '車輛審核', width: 150, diff --git a/src/pages/OrderDetailPage.test.tsx b/src/pages/OrderDetailPage.test.tsx index 1ba8e14..750a969 100644 --- a/src/pages/OrderDetailPage.test.tsx +++ b/src/pages/OrderDetailPage.test.tsx @@ -50,6 +50,34 @@ vi.mock('../api/admin', async () => { }; }); +/** 評分測試用的最小訂單詳情:只需要訂單本體渲染得出來即可。 */ +function ratingFixture(rating: unknown) { + return { + ride: { + id: 1, + customer_id: 10, + driver_id: 2, + status: 4, + pickup_point: { lat: 25.034, lng: 121.566 }, + pickup_address: '台北101', + dropoff_point: null, + dropoff_address: '', + requested_at: '2026-07-06T14:53:13+08:00', + accepted_at: '2026-07-06T14:53:16+08:00', + picked_up_at: '2026-07-06T14:53:16+08:00', + completed_at: '2026-07-06T14:53:16+08:00', + distance_m: 0, + eta_pickup_sec: 100, + created_at: '2026-07-06T14:53:13+08:00', + updated_at: '2026-07-06T14:53:16+08:00', + }, + track_geojson: '', + events: [], + stops: [], + rating, + }; +} + describe('OrderDetailPage', () => { beforeEach(() => { mockFetchRideDetail.mockReset(); @@ -412,4 +440,32 @@ describe('OrderDetailPage', () => { }); expect(screen.queryByText(/^停靠點(/)).not.toBeInTheDocument(); }); + + it('乘客評分(B5):有評分顯示星等與評論', async () => { + mockFetchRideDetail.mockResolvedValue( + ratingFixture({ score: 4, comment: '司機很準時', created_at: '2026-07-27T20:07:00+08:00' }), + ); + renderWithProviders(, { route: '/orders/1', path: '/orders/:id' }); + + await waitFor(() => expect(screen.getByText('乘客評分')).toBeInTheDocument()); + expect(screen.getByText('4 / 5')).toBeInTheDocument(); + expect(screen.getByText('司機很準時')).toBeInTheDocument(); + }); + + it('乘客評分(B5):只給星等沒留言時說明,未評分整塊不顯示', async () => { + mockFetchRideDetail.mockResolvedValue(ratingFixture({ score: 5, comment: '', created_at: '' })); + const { unmount } = renderWithProviders(, { + route: '/orders/1', + path: '/orders/:id', + }); + await waitFor(() => expect(screen.getByText('乘客評分')).toBeInTheDocument()); + expect(screen.getByText('乘客只給了星等,沒有留言')).toBeInTheDocument(); + unmount(); + + // 未評分(後端不帶 rating 鍵)→ 整塊不顯示,不留一張空卡片 + mockFetchRideDetail.mockResolvedValue(ratingFixture(null)); + renderWithProviders(, { route: '/orders/1', path: '/orders/:id' }); + await waitFor(() => expect(screen.getByText('訂單 #1')).toBeInTheDocument()); + expect(screen.queryByText('乘客評分')).not.toBeInTheDocument(); + }); }); diff --git a/src/pages/OrderDetailPage.tsx b/src/pages/OrderDetailPage.tsx index dc44716..112beec 100644 --- a/src/pages/OrderDetailPage.tsx +++ b/src/pages/OrderDetailPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { App, Card, Descriptions, Tag, Spin, Empty, Button, Space, Slider, Alert, Breadcrumb, Timeline, Tooltip } from 'antd'; +import { App, Card, Descriptions, Tag, Spin, Empty, Button, Space, Slider, Alert, Breadcrumb, Timeline, Tooltip, Rate } from 'antd'; import { ArrowLeftOutlined, CaretRightOutlined, PauseOutlined, StopOutlined } from '@ant-design/icons'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; @@ -97,6 +97,7 @@ export default function OrderDetailPage() { [data], ); const stops = useMemo(() => data?.stops ?? [], [data]); + const rating = data?.rating ?? null; // 只有停靠點、還沒有軌跡的行程(尚未開始跑)也要看得到地圖 const showMap = coordinates.length > 0 || stops.length > 0; @@ -374,6 +375,27 @@ export default function OrderDetailPage() { /> + {/* 乘客評分(B5):客服要能回答「這位乘客給了幾分、寫了什麼」。 + **未評分時整塊不顯示**——沒評不是缺資料,留一張空卡片只會讓人以為壞了。 */} + {rating && ( + + + + + {rating.score} / 5 + {rating.created_at && ( + {fmtTime(rating.created_at)} + )} + + {rating.comment ? ( +
{rating.comment}
+ ) : ( + 乘客只給了星等,沒有留言 + )} +
+
+ )} + {/* 多停靠點(N):單點訂單沒有 stops,整塊不顯示(不留一張空卡片) */} {stops.length > 0 && (