diff --git a/docs/TODO.md b/docs/TODO.md index d458e5e..ef77162 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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 是否能代司機修正車牌(打錯字的情境)。 + 註:司機端已可自行修改(改動會重新進入審核),這項只在「司機打錯又聯絡不上」時才有價值。 ## 六、驗收紀錄 diff --git a/src/api/admin.ts b/src/api/admin.ts index 4c287fd..20c71ec 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -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; } @@ -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%)。 @@ -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) @@ -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; @@ -120,6 +156,8 @@ export interface RideDetail { ride: RideFull; track_geojson: string; events: RideEvent[]; + /** 多停靠點行程(N)才有;單點訂單為空陣列 */ + stops: RideStop[]; } // 軌跡 GeoJSON:admin 端點回傳裸 LineString;司機/乘客端點包成 Feature @@ -221,11 +259,29 @@ export async function fetchRideDetail(id: number): Promise { ride: Record; track_geojson: string; events?: Record[]; + stops?: Record[]; }>(`/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): 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'), }; } @@ -265,6 +321,13 @@ function normalizeRide(raw: Record): 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'), }; diff --git a/src/pages/DriversPage.test.tsx b/src/pages/DriversPage.test.tsx index 08d8590..cffdf96 100644 --- a/src/pages/DriversPage.test.tsx +++ b/src/pages/DriversPage.test.tsx @@ -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'; @@ -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(); + 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(); + }); }); diff --git a/src/pages/DriversPage.tsx b/src/pages/DriversPage.tsx index 64b997c..690ff0e 100644 --- a/src/pages/DriversPage.tsx +++ b/src/pages/DriversPage.tsx @@ -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'], @@ -40,6 +49,11 @@ 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) || @@ -47,7 +61,7 @@ export default function DriversPage() { d.PlateNumber.toLowerCase().includes(kw) ); }); - }, [drivers, keyword, statusFilter]); + }, [drivers, keyword, statusFilter, vehicleFilter]); const pendingCount = useMemo( () => drivers.filter((d) => d.VehicleReviewStatus === 'pending').length, @@ -242,6 +256,12 @@ export default function DriversPage() { options={STATUS_OPTIONS} onChange={setStatusFilter} /> +