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
19 changes: 19 additions & 0 deletions src/api/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Expand All @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions src/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -210,6 +222,9 @@ export function normalizeDriver(raw: Record<string, unknown>): 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'),
};
Expand Down Expand Up @@ -260,13 +275,24 @@ export async function fetchRideDetail(id: number): Promise<RideDetail> {
track_geojson: string;
events?: Record<string, unknown>[];
stops?: Record<string, unknown>[];
rating?: Record<string, unknown>;
}>(`/admin/rides/${id}`);
return {
ride: normalizeRide(data.ride),
track_geojson: data.track_geojson,
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<string, unknown>): RideRating {
return {
score: num(raw, 'score'),
comment: str(raw, 'comment'),
created_at: str(raw, 'created_at'),
};
}

Expand Down
10 changes: 10 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'; // 金星:良好
}
40 changes: 40 additions & 0 deletions src/pages/DriversPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<DriversPage />);
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(<DriversPage />);
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(['低分司機', '高分司機', '新司機']);
});
});
});
26 changes: 26 additions & 0 deletions src/pages/DriversPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ 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';
import { canDispatch } from '../auth/auth';
import {
DRIVER_STATUS,
DRIVER_STATUS_DISABLED,
RATING_COLOR,
VEHICLE_REVIEW_STATUS,
VEHICLE_TYPE_LABEL,
} from '../constants';
Expand Down Expand Up @@ -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 ? (
<Tooltip title={`${driver.RatingCount} 位乘客評分`}>
<Space size={4}>
<StarFilled style={{ color: RATING_COLOR(driver.RatingAvg) }} />
<span>{driver.RatingAvg.toFixed(1)}</span>
<span style={{ color: '#999' }}>({driver.RatingCount})</span>
</Space>
</Tooltip>
) : (
<span style={{ color: '#999' }}>尚無評分</span>
),
},
{
title: '車輛審核',
width: 150,
Expand Down
56 changes: 56 additions & 0 deletions src/pages/OrderDetailPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(<OrderDetailPage />, { 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(<OrderDetailPage />, {
route: '/orders/1',
path: '/orders/:id',
});
await waitFor(() => expect(screen.getByText('乘客評分')).toBeInTheDocument());
expect(screen.getByText('乘客只給了星等,沒有留言')).toBeInTheDocument();
unmount();

// 未評分(後端不帶 rating 鍵)→ 整塊不顯示,不留一張空卡片
mockFetchRideDetail.mockResolvedValue(ratingFixture(null));
renderWithProviders(<OrderDetailPage />, { route: '/orders/1', path: '/orders/:id' });
await waitFor(() => expect(screen.getByText('訂單 #1')).toBeInTheDocument());
expect(screen.queryByText('乘客評分')).not.toBeInTheDocument();
});
});
24 changes: 23 additions & 1 deletion src/pages/OrderDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -374,6 +375,27 @@ export default function OrderDetailPage() {
/>
</Card>

{/* 乘客評分(B5):客服要能回答「這位乘客給了幾分、寫了什麼」。
**未評分時整塊不顯示**——沒評不是缺資料,留一張空卡片只會讓人以為壞了。 */}
{rating && (
<Card title="乘客評分">
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space size={8}>
<Rate disabled value={rating.score} />
<span style={{ fontWeight: 600 }}>{rating.score} / 5</span>
{rating.created_at && (
<span style={{ color: '#999' }}>{fmtTime(rating.created_at)}</span>
)}
</Space>
{rating.comment ? (
<div style={{ whiteSpace: 'pre-wrap' }}>{rating.comment}</div>
) : (
<span style={{ color: '#999' }}>乘客只給了星等,沒有留言</span>
)}
</Space>
</Card>
)}

{/* 多停靠點(N):單點訂單沒有 stops,整塊不顯示(不留一張空卡片) */}
{stops.length > 0 && (
<Card title={`停靠點(${stops.length} 站)`}>
Expand Down
Loading