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
67 changes: 40 additions & 27 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,47 +226,60 @@ CI 慢於本機,才會踩中這個時間差。

---

## 五之二、新需求對應(2026-07-16 加入,未實作
## 五之二、新需求對應(2026-07-16 加入;**2026-07-22 補完**

> 以下兩項由使用者 2026-07-16 提出,**主規格在後端**
> [line-fleet-dispatch/docs/TODO.md](../../line-fleet-dispatch/docs/TODO.md)「N. 多乘客/多停靠點行程」與「O. 司機車輛資訊」。
> admin 端多為「呈現」與(可能的)「審核」,**依賴後端 API 先行**。
> **2026-07-22**:後端 N/O/P 早已全數上線,admin 這側落後很久;本批一次補完,
> 只剩兩項待產品定方向(訂單列表多乘客標記、車種供給觀察)與兩項可選(車輛編輯)。

### 多乘客/多停靠點(依賴後端 N1–N3)

- [ ] **訂單詳情顯示停靠點**:`OrderDetailPage` 目前只有上車/下車兩點+軌跡回放。
要依序列出各停靠點(乘客標籤 A/B/C/D、上車/下車、地址、到達時間),
並在 MapLibre 地圖上標出多個點(現行只畫軌跡線)。
- [ ] **訂單列表**:是否顯示「多乘客」標記/乘客數欄位(待產品定)。
- [ ] **報表**:多停靠點行程的里程/車資已由後端定格,admin 只呈現,**勿在前端算錢**(沿用既有原則)。
- [x] **訂單詳情顯示停靠點** ✅ 2026-07-22:`OrderDetailPage` 加「停靠點(N 站)」Timeline——
依序列出乘客標籤 A/B/C/D、上車/下車、地址、座標、到達/跳過時間。
**已跳過的站畫刪除線並標「不計費」**(後端 N5 的計費路線會排除它,
後台看不出來的話客服對不上金額)。地圖上每站一個 marker,
**待處理藍/已到達綠/已跳過灰**,popup 帶「是誰、上車還下車、處理了沒」。
**尚無軌跡但有停靠點時地圖照樣顯示**(原本只在有軌跡時才給地圖,
這種行程剛派出去、最需要看的就是站點分布)並以 Alert 說明它只畫停靠點。
單點訂單完全不顯示這塊(不留空卡片)。
**依賴**:後端同批新增 `GET /api/admin/rides/:id` 回 `stops`(dispatch Q2)。
- [ ] **訂單列表**:是否顯示「多乘客」標記/乘客數欄位(待產品定;
後端列表 API 目前不回停靠點數,要做需先加欄位)。
- [x] **報表**:多停靠點行程的里程/車資由後端定格,admin 只呈現,**未在前端算錢**(沿用既有原則)。

### 司機車輛資訊/寵物車清潔費(依賴後端 O1–O7)

- [ ] **司機列表/詳情顯示車種車牌**:`DriversPage`/`DriverDetailPage` 加欄位
車種為**選單值**(後端存 code:`sedan`/`suv`/`van7`/`accessible`/`pet`),
admin 端要對應顯示名稱(轎車/休旅/七人座/無障礙/寵物用車)
- [ ] **搜尋**:車牌納入司機搜尋條件(現行搜尋為姓名/電話/狀態)。
- [ ] **車種篩選**:依車種過濾司機(有了選單才做得到;自由文字時期做不到)
- [ ] **寵物車清潔費設定** ✅ 定案(2026-07-16):`FeeSettingsPage` 加「寵物車清潔費(%)」欄位,
串後端 `pet_cleaning_fee_bps`(bps↔% 換算,比照既有「遺失物協尋處理費(%)」)。
**前端要擋 0–30%**(後端有 DB CHECK ≤ 3000 bps 為最後防線)
並用 tooltip 註明「完成行程當下快照,不影響歷史行程」。
- [ ] **報表清潔費呈現** ✅ 已拍板(2026-07-16):**清潔費不計入抽成、不計入營業額**,
報表**分項顯示**——日/月報表加「清潔費」欄(後端會在 F5/F6/F7 加分項欄位),
營業額/手續費/應付總公司欄位的語意不變。CSV 匯出同步加欄
沿用既有原則:**金額由後端定格,admin 只呈現,勿在前端算錢**
- [ ] **訂單顯示「乘客指定車種」** ✅ 定案(2026-07-16,主規格 P):
`rides.required_vehicle_type` 是清潔費的觸發依據,訂單詳情要看得到
(否則客服無法解釋「為什麼這筆多收清潔費」)。
與「司機當時的車輛快照」是**兩個不同欄位**,不要混淆
- [x] **司機列表/詳情顯示車種車牌** ✅ 2026-07-19(PR #20):車種顯示名 + 等寬車牌
- [x] **搜尋**:車牌已納入司機搜尋條件 ✅ 2026-07-19(PR #20)。
- [x] **車種篩選** ✅ 2026-07-22:`DriversPage` 加車種下拉
選項含**「未填車輛」**——這群人接不了單(O3/O5 gate),是實際會被找的一群,
只給五個車種會漏掉他們
- [x] **寵物車清潔費設定** ✅ 2026-07-22(定案 2026-07-16):`FeeSettingsPage` 加
「寵物車清潔費(%,上限 30%)」,串 `pet_cleaning_fee_bps`(bps↔% 換算,比照遺失物處理費)。
**前端擋 0–30%**(InputNumber max 夾住,後端 DB CHECK ≤ 3000 bps 為最後防線)
tooltip 註明「依**乘客指定**的車種加收、完成當下快照、不影響歷史行程、不計入抽成與營業額」。
驗收:vitest 斷言超額值送出去的一定是 3000 bps,不是 3500。
- [x] **報表清潔費呈現** ✅ 2026-07-22(拍板 2026-07-16):日/月報表加「清潔費」欄與合計、
CSV 匯出同步加欄;營業額/手續費/應付總公司語意不變(清潔費**不影響應付總公司**)
測試以等式把關:「營業額 − 手續費 + 清潔費 = 實得」
**依賴**:日報表的分項欄由後端同批補上(dispatch Q1;月報表與司機收入本來就有)。
- [x] **訂單顯示「乘客指定車種」** ✅ 2026-07-22(定案 2026-07-16,主規格 P):
訂單詳情加「乘客指定車種」(未指定顯示「不指定」,寵物車以橘色 Tag 突顯)。
與「當時車輛(快照)」**並列但分開兩欄**,就是為了不混淆
前者=乘客要求什麼車,後者=實際派來哪台車。
- [ ] **(可選)車種供給觀察**:若要支援「車隊目前有哪些車種可用」(主規格 P 風險 2),
admin 的即時車隊頁是天然的呈現點(現行 marker popup 已有姓名/狀態/更新時間)。
- [ ] **訂單詳情顯示車輛快照**:後端 O7 會在 `rides` 落當時的車種車牌 →
訂單詳情要顯示「當時搭的車」(而非司機現在的車),避免換車後對不上。
- [ ] **(可選)車輛審核**:若後端採「審核制」(主規格 O5 待拍板),
admin 需要審核 UI(pending → approved),且要考慮 RBAC(誰能審)。
- [x] **訂單詳情顯示車輛快照** ✅ 2026-07-22:顯示「當時搭的車」(車種+等寬車牌),
而非司機現在的車——司機換車後歷史行程仍對得上。
同時補上費用分項:**車資/寵物車清潔費/手續費/司機實得**(僅已計費的行程顯示)。
清潔費 **null =未加收**(後端未加收時不帶該鍵),顯示「未加收」而不是 NT$ 0——
「加收 0 元」與「沒加收」是兩件事。
- [x] **(可選)車輛審核** ✅ 2026-07-19(PR #20,後端 O5 拍板實作):
待審核列的核准/退回(退回開 modal 填原因)+「N 台車輛待審核」快捷 tag。
- [ ] **(可選)車輛編輯**:admin 是否能代司機修正車牌(打錯字的情境)。
註:司機端已可自行修改(改動會重新進入審核),這項只在「司機打錯又聯絡不上」時才有價值。

## 六、驗收紀錄

Expand Down
65 changes: 64 additions & 1 deletion src/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export interface DailyReportRow {
// 金額欄位(分),後端 F5 起提供;顯示時除 100。
total_revenue_cents: number;
total_commission_cents: number;
/** 寵物車清潔費合計(O6):不計入營業額與抽成,但含在司機實得裡 */
total_cleaning_fee_cents: number;
driver_net_cents: number;
}

Expand All @@ -60,9 +62,11 @@ export interface MonthlyReportRow {
trip_count: number;
total_revenue_cents: number;
total_commission_cents: number;
/** 寵物車清潔費合計(O6):不計入營業額與抽成,但含在司機實得裡 */
total_cleaning_fee_cents: number;
driver_net_cents: number;
membership_fee_cents: number;
owed_to_hq_cents: number; // 應付總公司 = 手續費 + 月會費
owed_to_hq_cents: number; // 應付總公司 = 手續費 + 月會費(不受清潔費影響)
}

// 費率設定(後端 F1/F4)。金額為「分」,手續費為基點 bps(1500 = 15%)。
Expand All @@ -74,6 +78,8 @@ export interface FeeSettings {
monthly_membership_fee_cents: number;
/** 遺失物協尋處理費(bps,1000 = 10%),對該趟車資抽成,建單時快照 */
lost_item_fee_bps: number;
/** 寵物車清潔費(bps,上限 3000 = 30%,DB CHECK 兜底);依**乘客指定車種**加收,非司機車種 */
pet_cleaning_fee_bps: number;
}

// 對齊後端 model.GeoPoint(2026-07-08 後端補了 json tag,欄位改 snake_case)
Expand All @@ -99,10 +105,40 @@ export interface RideFull {
completed_at: string | null;
distance_m: number | null;
eta_pickup_sec: number | null;
/** 乘客**指定**的車種 code(P1);'' =不指定。這是清潔費加收與否的依據 */
required_vehicle_type: string;
/** 接單當下的司機車輛快照(O7);'' =尚未接單。與 required_vehicle_type 是兩回事 */
driver_vehicle_type: string;
driver_plate_number: string;
/** 完成時定格的金額(分);未完成/取消為 null */
fare_amount_cents: number | null;
commission_amount_cents: number | null;
driver_net_amount_cents: number | null;
/** 寵物車清潔費(O6);只有乘客指定 pet 的行程才有值 */
cleaning_fee_cents: number | null;
created_at: string;
updated_at: string;
}

/** 停靠點種類(後端 constants.StopKind*) */
export type StopKind = 'pickup' | 'dropoff';

/**
* 單一停靠點(N)。形狀與司機/乘客端完全相同(後端共用 service.StopViews)——
* arrived_at/skipped_at 皆缺席=尚未處理,兩者互斥。
*/
export interface RideStop {
id: number;
seq: number;
kind: StopKind;
lat: number;
lng: number;
passenger_label: string;
address: string;
arrived_at: string | null;
skipped_at: string | null;
}

/** 訂單狀態審計(對齊後端 ride_events / D4) */
export interface RideEvent {
id: number;
Expand All @@ -120,6 +156,8 @@ export interface RideDetail {
ride: RideFull;
track_geojson: string;
events: RideEvent[];
/** 多停靠點行程(N)才有;單點訂單為空陣列 */
stops: RideStop[];
}

// 軌跡 GeoJSON:admin 端點回傳裸 LineString;司機/乘客端點包成 Feature
Expand Down Expand Up @@ -221,11 +259,29 @@ export async function fetchRideDetail(id: number): Promise<RideDetail> {
ride: Record<string, unknown>;
track_geojson: string;
events?: Record<string, unknown>[];
stops?: 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),
};
}

/** 停靠點只走 snake_case(後端以 map 手工序列化),故不需 PascalCase 兼容 */
export function normalizeRideStop(raw: Record<string, unknown>): RideStop {
return {
id: num(raw, 'id'),
seq: num(raw, 'seq'),
kind: (str(raw, 'kind') || 'pickup') as StopKind,
lat: num(raw, 'lat'),
lng: num(raw, 'lng'),
passenger_label: str(raw, 'passenger_label'),
address: str(raw, 'address'),
arrived_at: nullableStr(raw, 'arrived_at'),
skipped_at: nullableStr(raw, 'skipped_at'),
};
}

Expand Down Expand Up @@ -265,6 +321,13 @@ function normalizeRide(raw: Record<string, unknown>): RideFull {
completed_at: nullableStr(raw, 'completed_at', 'CompletedAt'),
distance_m: nullableNum(raw, 'distance_m', 'DistanceM'),
eta_pickup_sec: nullableNum(raw, 'eta_pickup_sec', 'EtaPickupSec'),
required_vehicle_type: str(raw, 'required_vehicle_type', 'RequiredVehicleType'),
driver_vehicle_type: str(raw, 'driver_vehicle_type', 'DriverVehicleType'),
driver_plate_number: str(raw, 'driver_plate_number', 'DriverPlateNumber'),
fare_amount_cents: nullableNum(raw, 'fare_amount_cents', 'FareAmountCents'),
commission_amount_cents: nullableNum(raw, 'commission_amount_cents', 'CommissionAmountCents'),
driver_net_amount_cents: nullableNum(raw, 'driver_net_amount_cents', 'DriverNetAmountCents'),
cleaning_fee_cents: nullableNum(raw, 'cleaning_fee_cents', 'CleaningFeeCents'),
created_at: str(raw, 'created_at', 'CreatedAt'),
updated_at: str(raw, 'updated_at', 'UpdatedAt'),
};
Expand Down
33 changes: 33 additions & 0 deletions src/pages/DriversPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import DriversPage from './DriversPage';
import { setRole } from '../auth/auth';
Expand Down Expand Up @@ -44,4 +45,36 @@ describe('DriversPage', () => {

expect(screen.getByRole('switch')).toBeDisabled();
});

it('車種篩選:選寵物用車只留寵物車司機,選「未填車輛」只留沒填的', async () => {
const user = userEvent.setup();
mockFetchDrivers.mockResolvedValue([
{ ID: 1, Name: '轎車司機', Phone: '0911', LineUserID: 'l1', Status: 1, VehicleType: 'sedan', PlateNumber: 'AAA-1111', VehicleReviewStatus: 'approved', VehicleReviewNote: '' },
{ ID: 2, Name: '寵物車司機', Phone: '0922', LineUserID: 'l2', Status: 1, VehicleType: 'pet', PlateNumber: 'PET-0001', VehicleReviewStatus: 'approved', VehicleReviewNote: '' },
// 沒填車輛的司機接不了單(O3/O5 gate),是實際會被找的一群,不能只給五個車種而漏掉
{ ID: 3, Name: '未填車輛司機', Phone: '0933', LineUserID: 'l3', Status: 0, VehicleType: '', PlateNumber: '', VehicleReviewStatus: '', VehicleReviewNote: '' },
]);

renderWithProviders(<DriversPage />);
await waitFor(() => {
expect(screen.getByText('轎車司機')).toBeInTheDocument();
});

// 兩個 Select:第一個是狀態、第二個是車種
const vehicleSelect = () => screen.getAllByRole('combobox')[1];

await user.click(vehicleSelect());
await user.click(await screen.findByTitle('寵物用車'));
await waitFor(() => {
expect(screen.queryByText('轎車司機')).not.toBeInTheDocument();
});
expect(screen.getByText('寵物車司機')).toBeInTheDocument();

await user.click(vehicleSelect());
await user.click(await screen.findByTitle('未填車輛'));
await waitFor(() => {
expect(screen.getByText('未填車輛司機')).toBeInTheDocument();
});
expect(screen.queryByText('寵物車司機')).not.toBeInTheDocument();
});
});
22 changes: 21 additions & 1 deletion src/pages/DriversPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,20 @@ const STATUS_OPTIONS = [
...Object.entries(DRIVER_STATUS).map(([value, meta]) => ({ value, label: meta.label })),
];

// 車種篩選(O1)。'none' 是「還沒填車輛」——這群人接不了單(O3/O5 gate),
// 是實際會被找的一群,不能只給五個車種而漏掉他們。
const VEHICLE_TYPE_OPTIONS = [
{ value: 'all', label: '全部車種' },
...Object.entries(VEHICLE_TYPE_LABEL).map(([value, label]) => ({ value, label })),
{ value: 'none', label: '未填車輛' },
];

export default function DriversPage() {
const queryClient = useQueryClient();
const { message, modal } = App.useApp();
const [keyword, setKeyword] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [vehicleFilter, setVehicleFilter] = useState('all');

const { data: drivers = [], isLoading } = useQuery({
queryKey: ['drivers'],
Expand All @@ -40,14 +49,19 @@ export default function DriversPage() {
} else if (statusFilter !== 'all' && String(d.Status) !== statusFilter) {
return false;
}
if (vehicleFilter === 'none') {
if (d.VehicleType) return false;
} else if (vehicleFilter !== 'all' && d.VehicleType !== vehicleFilter) {
return false;
}
if (!kw) return true;
return (
d.Name.toLowerCase().includes(kw) ||
d.Phone.toLowerCase().includes(kw) ||
d.PlateNumber.toLowerCase().includes(kw)
);
});
}, [drivers, keyword, statusFilter]);
}, [drivers, keyword, statusFilter, vehicleFilter]);

const pendingCount = useMemo(
() => drivers.filter((d) => d.VehicleReviewStatus === 'pending').length,
Expand Down Expand Up @@ -242,6 +256,12 @@ export default function DriversPage() {
options={STATUS_OPTIONS}
onChange={setStatusFilter}
/>
<Select
value={vehicleFilter}
style={{ width: 140 }}
options={VEHICLE_TYPE_OPTIONS}
onChange={setVehicleFilter}
/>
</Space>
<Table
rowKey="ID"
Expand Down
19 changes: 19 additions & 0 deletions src/pages/FeeSettingsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const defaultFees = {
commission_bps: 1500,
monthly_membership_fee_cents: 300000,
lost_item_fee_bps: 1000,
pet_cleaning_fee_bps: 2000, // 20%(O6,上限 30%)
};

describe('FeeSettingsPage', () => {
Expand Down Expand Up @@ -75,6 +76,24 @@ describe('FeeSettingsPage', () => {
expect(body.base_fare_cents).toBe(8500); // 85 元 → 8500 分
expect(body.monthly_membership_fee_cents).toBe(300000);
expect(body.lost_item_fee_bps).toBe(1000); // 10% → 1000 bps
expect(body.pet_cleaning_fee_bps).toBe(2000); // 20% → 2000 bps
});

it('寵物車清潔費:載入 bps→%,且擋掉超過 30% 的值', async () => {
saveSession('tok', '管理員', 'superadmin');
const user = userEvent.setup();
renderWithProviders(<FeeSettingsPage />);

// 2000 bps → 20.00 %
const cleaning = await screen.findByDisplayValue('20.00');
await user.clear(cleaning);
await user.type(cleaning, '35');
await user.click(await screen.findByRole('button', { name: /儲\s*存/ }));

// 上限是乘客實際被加收的錢:InputNumber 的 max 會把超額值夾回 30%,
// 送出去的一定不超過 3000 bps(後端 DB CHECK 只是最後防線,不該靠它擋)
await vi.waitFor(() => expect(mockUpdateFeeSettings).toHaveBeenCalled());
expect(mockUpdateFeeSettings.mock.calls[0][0].pet_cleaning_fee_bps).toBe(3000);
});

it('非 superadmin 時儲存鈕停用', async () => {
Expand Down
Loading
Loading