diff --git a/package-lock.json b/package-lock.json index 8502b59..7c38891 100644 --- a/package-lock.json +++ b/package-lock.json @@ -492,6 +492,21 @@ "node": ">=20.19.0" } }, + "node_modules/@dagrejs/dagre": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.1.1.tgz", + "integrity": "sha512-zroZB1dFOFiGgv4Xcrn1DckB1o4aOikPqD2NDQPV0WM//CXGcS6xiD0rNkqHmw6FEg4tabt4nxPLwgCWT+Vb2A==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "4.0.5" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.5.tgz", + "integrity": "sha512-7xrBTqIts3o+PMUZX97wSc+7TUbW+/rULzGNCTP6yooNVDXbzw4Wutg/H/xOutTB/c/k0YqOAavgPh4/Zk9PFA==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -1150,6 +1165,55 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz", + "integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1192,7 +1256,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1202,7 +1266,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.3.0" @@ -1519,6 +1583,48 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.12.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.12.0.tgz", + "integrity": "sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.83", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.83", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.83.tgz", + "integrity": "sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", @@ -1734,6 +1840,12 @@ "node": ">=18" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1774,9 +1886,114 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", @@ -3396,6 +3613,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", @@ -3704,10 +3930,40 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "web": { "name": "cdp-web", "version": "0.1.0", "dependencies": { + "@dagrejs/dagre": "3.1.1", + "@xyflow/react": "12.12.0", "react": "^19.3.1", "react-dom": "^19.3.1" }, diff --git a/web/package.json b/web/package.json index 89f080e..9643251 100644 --- a/web/package.json +++ b/web/package.json @@ -10,6 +10,8 @@ "test": "vitest run --passWithNoTests" }, "dependencies": { + "@dagrejs/dagre": "3.1.1", + "@xyflow/react": "12.12.0", "react": "^19.3.1", "react-dom": "^19.3.1" }, diff --git a/web/src/App.tsx b/web/src/App.tsx index 040b307..0978789 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,12 +5,17 @@ import { AuthContext } from './auth' import { PRODUCT_NAME, Shell, type NavKey } from './components/Shell' import { ErrorBox } from './components/ui' import { AudiencePage } from './pages/AudiencePage' -import { DetailPage } from './pages/DetailPage' +import { CustomerPage } from './pages/CustomerPage' +import { CustomerSearchPage } from './pages/CustomerSearchPage' +import { EnrollmentBrowsePage } from './pages/EnrollmentBrowsePage' import { ForbiddenPage } from './pages/ForbiddenPage' +import { JourneyListPage } from './pages/JourneyListPage' +import { JourneyPage } from './pages/JourneyPage' import { LoginPage } from './pages/LoginPage' import { NotFoundPage } from './pages/NotFoundPage' import { PasswordPage } from './pages/PasswordPage' import { SearchPage } from './pages/SearchPage' +import { TracePage } from './pages/TracePage' import { navigate, safeNext, useLocation } from './router' import { consumeLoggedIn, markLoggedIn } from './sessionFlag' @@ -21,8 +26,10 @@ type AuthState = | { status: 'error'; error: ApiError } function currentNav(pathname: string): NavKey { - if (pathname === '/' || pathname.startsWith('/enrollments/')) return 'journeys' - if (pathname === '/customers' || pathname.startsWith('/customers/')) return 'customers' + if (pathname === '/' || pathname === '/enrollments' || pathname === '/journeys' || pathname.startsWith('/journeys/')) + return 'journeys' + if (pathname === '/customers' || pathname.startsWith('/customers/') || pathname.startsWith('/enrollments/')) + return 'customers' if (pathname === '/audience') return 'audience' return null } @@ -133,12 +140,19 @@ export function App() { } if (!authInfo || pathname === '/login') return null - const detail = /^\/enrollments\/([^/]+)\/?$/.exec(pathname) + const trace = /^\/enrollments\/([^/]+)\/?$/.exec(pathname) + const journey = /^\/journeys\/([^/]+)\/?$/.exec(pathname) + const customer = /^\/customers\/([^/]+)\/?$/.exec(pathname) const here = pathname + search let page if (forbiddenAt === here) page = else if (pathname === '/') page = - else if (detail) page = + else if (pathname === '/journeys') page = + else if (journey) page = + else if (pathname === '/enrollments') page = + else if (trace) page = + else if (pathname === '/customers') page = + else if (customer) page = else if (pathname === '/audience') page = else if (pathname === '/account/password') page = else page = diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ac1ebd3..de27893 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,6 +1,17 @@ // 所有呼叫走相對路徑 /api/v1(dev server 轉給後端,見契約 §1)。 // session 的寫入請求(非 GET,登入/登出除外)帶 X-XSRF-TOKEN(docs/contracts/auth.md §1.1)。 -import type { CurrentUser, Delivery, EnrollmentDetail, EnrollmentSummary, Page } from './types' +import type { + CurrentUser, + Delivery, + EnrollmentDetail, + EnrollmentSummary, + JourneyDetail, + JourneySummary, + JourneyVersion, + Page, + ProfileDetail, + ProfileSummary, +} from './types' export class ApiError extends Error { readonly status: number @@ -103,7 +114,8 @@ async function request(method: string, path: string, opts: Options = {}): Pro throw err } if (res.status === 204) return undefined as T - return (await res.json()) as T + // clone:同一個回應被重複讀取(例如開發模式下 effect 執行兩次、共用同一個進行中的請求)時不會因 body 已用過而失敗 + return (await res.clone().json()) as T } const enc = encodeURIComponent @@ -121,8 +133,25 @@ export const logout = () => request('POST', '/api/v1/auth/logout', { redir export const changePassword = (current_password: string, new_password: string) => request('POST', '/api/v1/auth/password', { body: { current_password, new_password } }) -export const listEnrollments = (p: { q: string; status?: string; cursor?: string }) => - request>('GET', `/api/v1/enrollments${qs({ q: p.q, status: p.status, cursor: p.cursor })}`) +/** q 省略 = 最近 Enrollment 瀏覽(journey-engine §4) */ +export const listEnrollments = (p: { q?: string; status?: string; journey_id?: string; cursor?: string }) => + request>( + 'GET', + `/api/v1/enrollments${qs({ q: p.q, status: p.status, journey_id: p.journey_id, cursor: p.cursor })}`, + ) export const getEnrollment = (id: string) => request('GET', `/api/v1/enrollments/${enc(id)}`) export const listDeliveries = (id: string, cursor: string) => request>('GET', `/api/v1/enrollments/${enc(id)}/deliveries${qs({ cursor })}`, { pageOn403: false }) + +export const listJourneys = (p: { q?: string; status?: string; cursor?: string; limit?: number }) => + request>('GET', `/api/v1/journeys${qs({ q: p.q, status: p.status, cursor: p.cursor, limit: p.limit })}`) +export const getJourney = (id: string) => request('GET', `/api/v1/journeys/${enc(id)}`) +/** 版本定義失敗只影響圖,不切整頁無權限 */ +export const getJourneyVersion = (id: string, version: number) => + request('GET', `/api/v1/journeys/${enc(id)}/versions/${version}`, { pageOn403: false }) + +export const searchProfiles = (q: string) => + request<{ items: ProfileSummary[] }>('GET', `/api/v1/profiles/search${qs({ q })}`, { pageOn403: false }) +export const getProfile = (id: string) => request('GET', `/api/v1/profiles/${enc(id)}`) +export const listProfileEnrollments = (id: string, cursor?: string) => + request>('GET', `/api/v1/profiles/${enc(id)}/enrollments${qs({ cursor })}`, { pageOn403: false }) diff --git a/web/src/api/types.ts b/web/src/api/types.ts index b086b6f..411c929 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -95,3 +95,68 @@ export interface CurrentUser { display_name: string role: Role } + +// 型別對應 docs/contracts/journeys.md §1 +export type JourneyStatus = 'DRAFT' | 'ACTIVE' | 'ABORTED' +export interface UserRef { + user_id: string + display_name: string +} +export interface JourneySummary { + journey_id: string + name: string + status: JourneyStatus + latest_version: number | null + has_draft: boolean + review_status: 'PENDING' | 'REJECTED' | null + updated_at: string + published_at: string | null +} +export interface JourneyDetail extends JourneySummary { + description: string | null + versions: { version: number; published_at: string }[] + stopped_at: string | null + stop_reason: string | null + stopped_by: UserRef | null +} +export interface JourneyNode { + id: string + type: string + name: string | null + config: Record + outputs: Record +} +export interface JourneyVersion { + journey_id: string + name: string + version: number + description: string | null + settings: Record + entry_node: string + nodes: JourneyNode[] + layout: { nodes: Record } | null + published_at: string +} + +// 型別對應 docs/contracts/profiles.md §1(IdentifierType 可能出現未知值,以 string 接) +export interface Identifier { + type: string + value: string + masked: boolean +} +export interface ProfileSummary { + profile_id: string + matched_by: string + identifiers: Identifier[] + created_at: string + updated_at: string +} +export interface ProfileDetail { + profile_id: string + identifiers: Identifier[] + created_at: string + updated_at: string + attributes: Record + masked_paths: string[] + as_of: string +} diff --git a/web/src/components/Shell.tsx b/web/src/components/Shell.tsx index bdece98..4c63760 100644 --- a/web/src/components/Shell.tsx +++ b/web/src/components/Shell.tsx @@ -9,7 +9,7 @@ export const MAIN_ID = 'main-content' export type NavKey = 'journeys' | 'customers' | 'audience' | null const NAV: { key: Exclude; to: string; label: string; soon?: boolean }[] = [ - { key: 'journeys', to: '/', label: '旅程' }, + { key: 'journeys', to: '/journeys', label: '旅程' }, { key: 'customers', to: '/customers', label: '客戶' }, { key: 'audience', to: '/audience', label: '受眾', soon: true }, ] diff --git a/web/src/components/ui.tsx b/web/src/components/ui.tsx index 302b205..2de2cd2 100644 --- a/web/src/components/ui.tsx +++ b/web/src/components/ui.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, type ReactNode } from 'react' +import { createContext, useContext, useState, type ReactNode } from 'react' import { formatLocal, type Tone } from '../format' import { ApiError } from '../api/client' @@ -75,3 +75,59 @@ export function Loading({ children }: { children: ReactNode }) { ) } + +/** 骨架列:role=status、讀屏讀「載入中」 */ +export function Skeleton({ lines = 3 }: { lines?: number }) { + return ( +
+ 載入中 + {Array.from({ length: lines }, (_, i) => ( + + ))} +
+ ) +} + +/** cursor 分頁的「載入更多」;失敗時改顯示錯誤與重試 */ +export function MoreControls({ + next, + busy, + error, + onMore, + label = '載入更多', +}: { + next: string | null + busy: boolean + error: ApiError | null + onMore: () => void + label?: string +}) { + if (error) { + return ( +
+ +
+ ) + } + if (!next) return null + return ( +

+ +

+ ) +} + +/** 複製完整值(只用於未遮罩的 ID) */ +export function CopyButton({ text, label }: { text: string; label: string }) { + const [done, setDone] = useState(false) + function copy() { + void navigator.clipboard?.writeText(text).then(() => setDone(true), () => undefined) + } + return ( + + ) +} diff --git a/web/src/format.ts b/web/src/format.ts index c3219e4..ccb4358 100644 --- a/web/src/format.ts +++ b/web/src/format.ts @@ -48,3 +48,25 @@ export const indexPath = (base: string, i: number) => `${base}[${i}]` export const isPlainObject = (v: unknown): v is Record => v !== null && typeof v === 'object' && !Array.isArray(v) + +export const JOURNEY_STATUS: Record = { + DRAFT: { label: '草稿', tone: 'neutral' }, + ACTIVE: { label: '進行中', tone: 'success' }, + ABORTED: { label: '已停止', tone: 'danger' }, +} + +const IDENTIFIER_LABEL: Record = { + EMAIL: 'email', + PHONE: '手機', + MEMBER_ID: '會員編號', + LINE_USER_ID: 'LINE 使用者 ID', + DEVICE_ID: '裝置 ID', + PROFILE_ID: '客戶 ID', +} +/** 未知的識別碼類型顯示原值(DESIGN-04 §5) */ +export const identifierLabel = (type: string) => IDENTIFIER_LABEL[type] ?? type + +/** 長 ID 只顯示頭尾,完整值放 title 或客戶頁 */ +export function shortId(id: string): string { + return id.length > 14 ? `${id.slice(0, 4)}…${id.slice(-6)}` : id +} diff --git a/web/src/graph/FlowGraph.tsx b/web/src/graph/FlowGraph.tsx new file mode 100644 index 0000000..06484f5 --- /dev/null +++ b/web/src/graph/FlowGraph.tsx @@ -0,0 +1,150 @@ +// 唯讀旅程圖:@xyflow/react 負責節點與連線繪製,座標來自 model.ts(layout 或 dagre 由左到右)。 +// 畫面固定 1:1、不縮放不拖曳;超出容器時容器自己左右捲動(窄螢幕提示改看文字版節點清單)。 +import { + BaseEdge, + getSmoothStepPath, + Handle, + Position, + ReactFlow, + type Edge, + type EdgeProps, + type Node, + type NodeProps, +} from '@xyflow/react' +import { memo, useMemo, useState } from 'react' +import { NODE_H, NODE_W, type FlowNodeData, type GraphModel } from './model' + +const STATE_TEXT = { visited: '已經過', current: '目前位置', unvisited: '未經過', plain: '' } as const + +type StepNode = Node + +const StepNodeView = memo(function StepNodeView({ data }: NodeProps) { + const [selected, setSelected] = useState(false) + const label = [data.name, `${data.categoryLabel}節點`, data.type, STATE_TEXT[data.state]].filter(Boolean).join(',') + return ( + + ) +}) + +interface FlowEdgeData extends Record { + taken: boolean + skipped: boolean + back: boolean +} + +function FlowEdgeView(props: EdgeProps>) { + const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data } = props + let path: string + if (data?.back) { + // 回邊:從節點下方繞回,點狀線 + const y = Math.max(sourceY, targetY) + NODE_H / 2 + 20 + path = `M${sourceX} ${sourceY} C ${sourceX + 40} ${y}, ${targetX - 40} ${y}, ${targetX} ${targetY}` + } else { + ;[path] = getSmoothStepPath({ sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, borderRadius: 8 }) + } + const cls = ['e', data?.taken && 'taken', data?.skipped && !data.taken && 'skipped', data?.back && 'back'] + .filter(Boolean) + .join(' ') + const markerId = `mk-${id}` + return ( + + ) +} + +const nodeTypes = { step: StepNodeView } +const edgeTypes = { flow: FlowEdgeView } + +export function FlowGraph({ model, label }: { model: GraphModel; label: string }) { + const nodes = useMemo( + () => + model.nodes.map((n) => ({ + id: n.id, + type: 'step', + position: { x: n.x, y: n.y }, + data: n.data, + width: NODE_W, + height: NODE_H, + draggable: false, + selectable: false, + focusable: false, + // 先給接點位置,連線不必等瀏覽器量測就能畫出來 + handles: [ + { type: 'target', position: Position.Left, x: 0, y: NODE_H / 2, width: 1, height: 1 }, + { type: 'source', position: Position.Right, x: NODE_W, y: NODE_H / 2, width: 1, height: 1 }, + ], + })), + [model], + ) + const edges = useMemo[]>( + () => + model.edges.map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + type: 'flow', + selectable: false, + focusable: false, + data: { taken: e.taken, skipped: e.skipped, back: e.back }, + })), + [model], + ) + return ( +
+
+ +
+
+ ) +} diff --git a/web/src/graph/GraphSection.tsx b/web/src/graph/GraphSection.tsx new file mode 100644 index 0000000..0d365a4 --- /dev/null +++ b/web/src/graph/GraphSection.tsx @@ -0,0 +1,63 @@ +import { useMemo } from 'react' +import type { JourneyVersion } from '../api/types' +import { FlowGraph } from './FlowGraph' +import { buildModel, type TraceOverlay } from './model' + +const CATEGORIES = [ + ['source', '來源'], + ['wait', '等待'], + ['branch', '分流'], + ['send', '發送'], + ['end', '出口'], +] as const + +/** 圖例+圖+文字版節點清單+窄螢幕提示;overlay 有值 = 疊上軌跡(三態) */ +export function GraphSection({ + def, + overlay, + label, +}: { + def: JourneyVersion + overlay: TraceOverlay | null + label: string +}) { + const model = useMemo(() => buildModel(def, overlay), [def, overlay]) + return ( + <> +
    + {CATEGORIES.map(([k, text]) => ( +
  • + + {text} +
  • + ))} + {overlay && ( + <> +
  • + + 已經過(✓) +
  • +
  • + + 目前位置(●) +
  • +
  • + + 未經過(虛線) +
  • +
  • 藍粗線:實際走的路徑
  • + + )} + {model.edges.some((e) => e.back) &&
  • 點狀線=回到前面節點的迴圈
  • } +
+ +
+ 節點清單(文字版,供讀屏與鍵盤瀏覽) +
    +
  1. {model.textOrder.join(' → ')}
  2. +
+
+

窄螢幕:圖可左右捲動,建議改看上方「節點清單」。

+ + ) +} diff --git a/web/src/graph/model.ts b/web/src/graph/model.ts new file mode 100644 index 0000000..0042db7 --- /dev/null +++ b/web/src/graph/model.ts @@ -0,0 +1,165 @@ +import { Graph, layout } from '@dagrejs/dagre' +import type { EnrollmentStatus, JourneyVersion, TraceEntry } from '../api/types' +import { TERMINAL } from '../format' + +/** 尺寸對應 tokens.css 的 --node-width/--node-height、--node-pitch(圖上相鄰欄中心距) */ +export const NODE_W = 148 +export const NODE_H = 72 +export const RANK_SEP = 32 +export const NODE_SEP = 32 +export const MARGIN = 16 + +export type Category = 'source' | 'wait' | 'branch' | 'send' | 'action' | 'end' +const CATEGORY: Record = { + StartNode: ['source', '來源'], + EventSourceNode: ['source', '來源'], + WaitDelayNode: ['wait', '等待'], + WaitForEventNode: ['wait', '等待'], + ConditionNode: ['branch', '分流'], + SplitTestNode: ['branch', '分流'], + MergeNode: ['branch', '分流'], + SendMessageNode: ['send', '發送'], + EndNode: ['end', '出口'], + ExitNode: ['end', '出口'], + GoalNode: ['end', '出口'], +} +export const categoryOf = (type: string): [Category, string] => CATEGORY[type] ?? ['action', '動作'] + +/** 文字版節點清單用的簡短類型說明 */ +const TYPE_NOTE: Record = { EventSourceNode: '事件來源', SendMessageNode: '發送' } + +export type NodeState = 'visited' | 'current' | 'unvisited' | 'plain' + +export interface FlowNodeData extends Record { + name: string + type: string + category: Category + categoryLabel: string + state: NodeState +} +export interface PlacedNode { + id: string + x: number + y: number + data: FlowNodeData +} +export interface PlacedEdge { + id: string + source: string + target: string + taken: boolean + /** 有軌跡且這條線沒走過 */ + skipped: boolean + back: boolean +} +export interface GraphModel { + nodes: PlacedNode[] + edges: PlacedEdge[] + width: number + height: number + /** 文字版節點清單(依連線順序) */ + textOrder: string[] +} + +export interface TraceOverlay { + visited: ReadonlySet + current: string | null + taken: ReadonlySet +} + +export const edgeId = (source: string, port: string) => `${source}:${port}` + +/** 軌跡與定義對不上(node_id 不在定義裡)→ null,呼叫端退回逐步紀錄(journey-engine §4.5,不推測) */ +export function deriveOverlay( + def: JourneyVersion, + trace: TraceEntry[], + status: EnrollmentStatus, +): TraceOverlay | null { + const byId = new Map(def.nodes.map((n) => [n.id, n])) + if (trace.some((t) => !byId.has(t.node_id))) return null + const visited = new Set(trace.map((t) => t.node_id)) + const taken = new Set() + if (trace.length > 0 && byId.has(def.entry_node)) { + visited.add(def.entry_node) + for (const [port, target] of Object.entries(byId.get(def.entry_node)!.outputs)) { + if (target === trace[0].node_id) taken.add(edgeId(def.entry_node, port)) + } + } + trace.forEach((t, i) => { + if (t.out_port === null) return + const target = byId.get(t.node_id)!.outputs[t.out_port] + if (target !== undefined && trace[i + 1]?.node_id === target) taken.add(edgeId(t.node_id, t.out_port)) + }) + let current: string | null = null + if (!TERMINAL.has(status)) { + const open = [...trace].reverse().find((t) => t.exited_at === null) + current = open?.node_id ?? null + } + return { visited, current, taken } +} + +export function buildModel(def: JourneyVersion, overlay: TraceOverlay | null): GraphModel { + const ids = new Set(def.nodes.map((n) => n.id)) + const g = new Graph() + g.setGraph({ rankdir: 'LR', nodesep: NODE_SEP, ranksep: RANK_SEP, marginx: MARGIN, marginy: MARGIN, acyclicer: 'greedy' }) + g.setDefaultEdgeLabel(() => ({})) + for (const n of def.nodes) g.setNode(n.id, { width: NODE_W, height: NODE_H }) + const rawEdges: { id: string; source: string; target: string }[] = [] + for (const n of def.nodes) { + for (const [port, target] of Object.entries(n.outputs)) { + if (!ids.has(target)) continue + rawEdges.push({ id: edgeId(n.id, port), source: n.id, target }) + g.setEdge(n.id, target) + } + } + layout(g) + + // 座標:layout 有列的用 layout(左上角),沒列的用 dagre(中心點換左上角) + const saved = def.layout?.nodes ?? {} + const pos = new Map() + for (const n of def.nodes) { + const s = saved[n.id] + if (s && Number.isFinite(s.x) && Number.isFinite(s.y)) pos.set(n.id, { x: s.x, y: s.y }) + else { + const p = g.node(n.id) + pos.set(n.id, { x: p.x - NODE_W / 2, y: p.y - NODE_H / 2 }) + } + } + + const nodes: PlacedNode[] = def.nodes.map((n) => { + const [category, categoryLabel] = categoryOf(n.type) + let state: NodeState = 'plain' + if (overlay) state = overlay.current === n.id ? 'current' : overlay.visited.has(n.id) ? 'visited' : 'unvisited' + return { + id: n.id, + ...pos.get(n.id)!, + data: { name: n.name ?? n.type, type: n.type, category, categoryLabel, state }, + } + }) + const edges: PlacedEdge[] = rawEdges.map((e) => ({ + ...e, + taken: overlay?.taken.has(e.id) ?? false, + skipped: overlay !== null && !overlay.taken.has(e.id), + back: pos.get(e.target)!.x <= pos.get(e.source)!.x, + })) + const width = Math.max(...nodes.map((n) => n.x + NODE_W), 0) + MARGIN + const height = Math.max(...nodes.map((n) => n.y + NODE_H), 0) + MARGIN * 2 + return { nodes, edges, width, height, textOrder: textOrder(def) } +} + +function textOrder(def: JourneyVersion): string[] { + const byId = new Map(def.nodes.map((n) => [n.id, n])) + const seen = new Set() + const out: string[] = [] + const queue = [def.entry_node, ...def.nodes.map((n) => n.id)] + while (queue.length) { + const id = queue.shift()! + const n = byId.get(id) + if (!n || seen.has(id)) continue + seen.add(id) + const note = TYPE_NOTE[n.type] + out.push(`${n.name ?? n.type}${note ? `(${note})` : ''}`) + queue.unshift(...Object.values(n.outputs)) + } + return out +} diff --git a/web/src/hooks.ts b/web/src/hooks.ts new file mode 100644 index 0000000..0a0665b --- /dev/null +++ b/web/src/hooks.ts @@ -0,0 +1,35 @@ +import { useCallback, useMemo, useState } from 'react' +import { ApiError } from './api/client' +import type { Page } from './api/types' +import { useFetch } from './useFetch' + +/** 以 cursor 分頁的清單:`load` 用 useCallback/useMemo 固定;null = 閒置 */ +export function usePagedList(load: ((cursor?: string) => Promise>) | null) { + const first = useMemo(() => (load ? () => load() : null), [load]) + const { data, error, loading, reload } = useFetch(first) + const [more, setMore] = useState<{ base: Page; items: T[]; next: string | null } | null>(null) + const [moreBusy, setMoreBusy] = useState(false) + const [moreErr, setMoreErr] = useState<{ base: Page; error: ApiError } | null>(null) + + const mine = data && more && more.base === data ? more : null + const items = data ? [...data.items, ...(mine?.items ?? [])] : [] + const next = data ? (mine ? mine.next : data.next_cursor) : null + const moreError = data && moreErr && moreErr.base === data ? moreErr.error : null + + const loadMore = useCallback(async () => { + if (!load || !data || !next) return + setMoreBusy(true) + setMoreErr(null) + try { + const page = await load(next) + setMore({ base: data, items: [...(mine?.items ?? []), ...page.items], next: page.next_cursor }) + } catch (e) { + if (e instanceof ApiError && e.status === 401) return + setMoreErr({ base: data, error: e instanceof ApiError ? e : new ApiError(0, null, null) }) + } finally { + setMoreBusy(false) + } + }, [load, data, next, mine]) + + return { data, items, next, error, loading, reload, loadMore, moreBusy, moreError } +} diff --git a/web/src/main.tsx b/web/src/main.tsx index f553bad..806c552 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { App } from './App' +import '@xyflow/react/dist/style.css' import './styles/tokens.css' import './styles/app.css' diff --git a/web/src/pages/CustomerPage.tsx b/web/src/pages/CustomerPage.tsx new file mode 100644 index 0000000..4f4577f --- /dev/null +++ b/web/src/pages/CustomerPage.tsx @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { getProfile, listProfileEnrollments } from '../api/client' +import type { ProfileDetail } from '../api/types' +import { AppLink } from '../components/AppLink' +import { KeyValues } from '../components/KeyValues' +import { Crumbs } from '../components/Shell' +import { Badge, CopyButton, ErrorBox, MoreControls, Skeleton, Time, UtcContext } from '../components/ui' +import { ENROLLMENT_STATUS, identifierLabel, shortId } from '../format' +import { usePagedList } from '../hooks' +import { useFetch } from '../useFetch' + +interface NavState { + profileId?: string + q?: string + matched_by?: string +} + +/** 客戶頁:左基本資料(識別碼、屬性、遮罩)、右參加過的旅程;兩個 API 分開處理 */ +export function CustomerPage({ id }: { id: string }) { + const load = useCallback(() => getProfile(id), [id]) + const { data, error, reload } = useFetch(load) + const [showUtc, setShowUtc] = useState(false) + + const h1 = useRef(null) + const focused = useRef(null) + useEffect(() => { + if (data && focused.current !== data.profile_id) { + focused.current = data.profile_id + h1.current?.focus() + } + }, [data]) + + const nav = window.history.state as NavState | null + const direct = nav?.profileId === id && nav.q && nav.matched_by ? nav : null + + if (error?.status === 404) { + return ( + <> + +
+

找不到這位客戶

+

客戶 ID 可能有誤。

+

+ + 回搜尋 + +

+
+ + ) + } + if (error) { + return ( + <> + + + + ) + } + + const nameAttr = data?.attributes.name + const useName = + data && typeof nameAttr === 'string' && nameAttr !== '' && !data.masked_paths.includes('attributes.name') + const crumbLast = !data ? '載入中…' : useName ? nameAttr : `客戶 ${shortId(id)}` + + return ( + + +
+
+

+ {!data ? ( + '載入中…' + ) : useName ? ( + nameAttr + ) : ( + <> + 客戶 {shortId(id)} + + )} +

+ {data && useName && ( +

+ 客戶 ID {shortId(id)} +

+ )} +
+ {data && ( +
+ +
+ )} +
+ {!data && } + {data && ( + <> + {direct && ( +

+ 搜尋「{direct.q}」比對到這位客戶(比對方式:{identifierLabel(direct.matched_by!)})。{' '} + 重新搜尋 +

+ )} +
+ + +
+ + )} +
+ ) +} + +function Basic({ p }: { p: ProfileDetail }) { + const masked = new Set(p.masked_paths) + const attrs = Object.keys(p.attributes).length + const anyMask = masked.size > 0 || p.identifiers.some((i) => i.masked) + return ( +
+

基本資料

+
+
客戶 ID
+
+ {p.profile_id} +
+
建立時間
+
+
+
最近更新
+
+
+
資料時間
+
+
+
+
+ + + + + + + + + + {p.identifiers.map((i, k) => { + const m = i.masked || masked.has(`identifiers[${k}].value`) + return ( + + + + + ) + })} + +
識別碼
類型值
{identifierLabel(i.type)} + {i.value} {m && 已遮罩} +
+
+

客戶屬性

+ {attrs > 0 ? ( + + ) : ( +

沒有客戶屬性。

+ )} + {anyMask && ( +

+ 只有白名單的欄位顯示明文;遮罩值無法解除,也不提供複製。屬性 name 未遮罩時當頁面標題,否則以客戶 ID 為標題。 +

+ )} +
+ ) +} + +function Enrollments({ id }: { id: string }) { + const load = useCallback((cursor?: string) => listProfileEnrollments(id, cursor), [id]) + const { items, next, error, loading, reload, loadMore, moreBusy, moreError, data } = usePagedList(load) + return ( +
+

參加過的旅程

+ {loading && } + {error && } + {data && items.length === 0 && ( +
+

還沒有參加任何旅程

+

客戶觸發旅程的入口事件後,會顯示在這裡。

+
+ )} + {data && items.length > 0 && ( + <> +
+ + + + + + + + + + + + + + {items.map((r) => { + const st = ENROLLMENT_STATUS[r.status] + return ( + + + + + + + + + ) + })} + +
{next ? `參加過的旅程,已載入 ${items.length} 筆` : `參加過 ${items.length} 個旅程`},新到舊
旅程(版本)狀態目前/最後節點進入時間結束時間Enrollment ID
+ + {r.journey_name}(v{r.journey_version}) + + + {st.label} + {r.current_node_name ?? r.current_node_type} + + {r.terminated_at ? + + {shortId(r.enrollment_id)} + +
+
+ +

時間為瀏覽器時區(D-016)。點旅程名稱看圖形軌跡。

+ + )} +
+ ) +} diff --git a/web/src/pages/CustomerSearchPage.tsx b/web/src/pages/CustomerSearchPage.tsx new file mode 100644 index 0000000..e478b8a --- /dev/null +++ b/web/src/pages/CustomerSearchPage.tsx @@ -0,0 +1,198 @@ +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type FormEvent } from 'react' +import { searchProfiles } from '../api/client' +import type { ProfileSummary } from '../api/types' +import { AppLink } from '../components/AppLink' +import { Crumbs } from '../components/Shell' +import { Badge, ErrorBox, Skeleton, Time } from '../components/ui' +import { identifierLabel, shortId } from '../format' +import { navigate, type Loc } from '../router' +import { useFetch } from '../useFetch' + +/** 客戶搜尋(D-048):完全比對;單筆直進客戶頁,多筆列表最多 20 */ +export function CustomerSearchPage({ loc }: { loc: Loc }) { + const q = (new URLSearchParams(loc.search).get('q') ?? '').trim() + const load = useCallback(() => searchProfiles(q), [q]) + const { data, error, loading, reload } = useFetch(q ? load : null) + + const focusResults = useRef(false) + const headingRef = useRef(null) + const [emptyInput, setEmptyInput] = useState(false) + + // 只有一位客戶:直接進客戶頁(取代這一筆歷史,上一頁不會再被彈回去) + const only = data && data.items.length === 1 ? data.items[0] : null + useEffect(() => { + if (only) { + navigate(`/customers/${encodeURIComponent(only.profile_id)}`, { + replace: true, + state: { profileId: only.profile_id, q, matched_by: only.matched_by }, + }) + } + }, [only, q]) + + // 結果出現後焦點到結果標題(layout effect:與 DOM 更新同一時間點) + const shown = data && data.items.length > 1 + useLayoutEffect(() => { + if (shown && focusResults.current) { + focusResults.current = false + headingRef.current?.focus() + } + }, [shown, data]) + + function submit(value: string) { + const v = value.trim() + if (!v) { + setEmptyInput(true) + return + } + setEmptyInput(false) + focusResults.current = true + if (v === q) reload() + else navigate(`/customers?q=${encodeURIComponent(v)}`) + } + + const invalid = emptyInput || error?.status === 400 + + return ( + <> + +

客戶

+ +
+ {!q && !emptyInput && } + {q && loading && } + {error && error.status !== 400 && } + {data && data.items.length === 0 && ( +
+

找不到符合的客戶

+

可能原因:輸入內容不完整或有錯字、客戶尚未送過任何事件、識別碼尚未綁定到客戶。

+
+ )} + {shown && } +
+ + ) +} + +function SearchForm({ + initial, + busy, + invalid, + onSubmit, +}: { + initial: string + busy: boolean + invalid: boolean + onSubmit: (v: string) => void +}) { + const [value, setValue] = useState(initial) + const id = useId() + function handle(e: FormEvent) { + e.preventDefault() + if (!busy) onSubmit(value) + } + return ( +
+
+ + setValue(e.target.value)} + /> + + 輸入完整內容;手機可含空格與破折號。只做完全比對,不支援輸入一部分(D-048)。 + + {invalid && ( + + 請輸入要搜尋的內容。 + + )} +
+ +
+ ) +} + +function Hints() { + return ( +
+

可以輸入什麼

+
+
email
+
+ name@example.com(不分大小寫) +
+
手機
+
+ 0912-345-678 或 +886912345678 +
+
外部 ID
+
會員編號、LINE 使用者 ID、裝置 ID
+
客戶 ID
+
平台產生的 26 碼 ID
+
+

只做完全比對,不支援輸入一部分。

+
+ ) +} + +function Results({ + items, + headingRef, +}: { + items: ProfileSummary[] + headingRef: React.RefObject +}) { + return ( +
+

+ 搜尋結果 +

+
+ + + + + + + + + + + + {items.map((p) => ( + + + + + + + ))} + +
找到 {items.length} 位客戶(最多顯示 20 筆)
比對方式識別碼客戶 ID最近更新
{identifierLabel(p.matched_by)} + {p.identifiers.map((i, k) => ( +
+ {identifierLabel(i.type)}:{i.value}{' '} + {i.masked && 已遮罩} +
+ ))} +
+ + {shortId(p.profile_id)} + + +
+
+

+ 同一位客戶只列一次;「比對方式」是命中的識別碼類型。長 ID 只顯示頭尾,完整值在客戶頁。個資依白名單遮罩。 +

+
+ ) +} diff --git a/web/src/pages/EnrollmentBrowsePage.tsx b/web/src/pages/EnrollmentBrowsePage.tsx new file mode 100644 index 0000000..9b14931 --- /dev/null +++ b/web/src/pages/EnrollmentBrowsePage.tsx @@ -0,0 +1,160 @@ +import { useCallback, useState, type FormEvent } from 'react' +import { listEnrollments, listJourneys } from '../api/client' +import { AppLink } from '../components/AppLink' +import { Crumbs } from '../components/Shell' +import { Badge, ErrorBox, MoreControls, Skeleton, Time } from '../components/ui' +import { ENROLLMENT_STATUS } from '../format' +import { usePagedList } from '../hooks' +import { navigate, type Loc } from '../router' +import { useFetch } from '../useFetch' + +const STATUSES = Object.keys(ENROLLMENT_STATUS) as (keyof typeof ENROLLMENT_STATUS)[] + +/** 最近 Enrollment 瀏覽(D-030):不收 q,可依狀態、旅程篩選;精確查詢走 `/` */ +export function EnrollmentBrowsePage({ loc }: { loc: Loc }) { + const params = new URLSearchParams(loc.search) + const status = params.get('status') ?? '' + const journeyId = params.get('journey_id') ?? '' + + const load = useCallback( + (cursor?: string) => listEnrollments({ status, journey_id: journeyId, cursor }), + [status, journeyId], + ) + const { items, next, error, loading, reload, loadMore, moreBusy, moreError, data } = usePagedList(load) + + const loadJourneys = useCallback(() => listJourneys({ limit: 100 }), []) + const journeys = useFetch(loadJourneys) + + function submit(nextStatus: string, nextJourney: string) { + const sp = new URLSearchParams() + if (nextStatus) sp.set('status', nextStatus) + if (nextJourney) sp.set('journey_id', nextJourney) + const search = sp.toString() ? `?${sp.toString()}` : '' + if (search === loc.search) reload() + else navigate(`/enrollments${search}`) + } + + return ( + <> + +
+
+

最近的 Enrollment

+

依進入時間新到舊。要查特定客戶或 Enrollment,請用精確查詢。

+
+ 依 ID 查詢 +
+ [j.journey_id, j.name] as const) ?? []} + busy={loading} + onSubmit={submit} + /> + {loading && } + {error && } + {data && items.length === 0 && ( +
+

找不到符合條件的 Enrollment

+

試著放寬狀態或旅程條件。

+
+ )} + {data && items.length > 0 && ( + <> +
+ + + + + + + + + + + + + {items.map((r) => { + const st = ENROLLMENT_STATUS[r.status] + return ( + + + + + + + + ) + })} + +
已載入 {items.length} 筆,依進入時間新到舊
旅程(版本)狀態目前節點進入時間最後更新
+ + {r.journey_name}(v{r.journey_version}) + + + {st.label} + {r.current_node_name ?? r.current_node_type} + +
+
+ + + )} + + ) +} + +function FilterForm({ + status, + journeyId, + journeys, + busy, + onSubmit, +}: { + status: string + journeyId: string + journeys: (readonly [string, string])[] + busy: boolean + onSubmit: (status: string, journeyId: string) => void +}) { + const [s, setS] = useState(status) + const [j, setJ] = useState(journeyId) + function handle(e: FormEvent) { + e.preventDefault() + if (!busy) onSubmit(s, j) + } + // 網址指定了旅程但清單還沒載到(或不在清單內)時,仍保留該選項 + const options = journeys.some(([id]) => id === j) || !j ? journeys : [...journeys, [j, j] as const] + return ( +
+
+ + +
+
+ + +
+ +
+ ) +} diff --git a/web/src/pages/ForbiddenPage.tsx b/web/src/pages/ForbiddenPage.tsx index e679f6b..9e2d062 100644 --- a/web/src/pages/ForbiddenPage.tsx +++ b/web/src/pages/ForbiddenPage.tsx @@ -11,7 +11,7 @@ export function ForbiddenPage() { const viewer = user.role === 'VIEWER' return ( <> - +

你沒有權限使用這個功能 @@ -22,7 +22,7 @@ export function ForbiddenPage() {

{viewer ? '如需編輯權限,請聯絡管理員。' : '如需調整權限,請聯絡管理員。'}

- + 回旅程列表

diff --git a/web/src/pages/JourneyListPage.tsx b/web/src/pages/JourneyListPage.tsx new file mode 100644 index 0000000..396ae6d --- /dev/null +++ b/web/src/pages/JourneyListPage.tsx @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useState } from 'react' +import { listJourneys } from '../api/client' +import { AppLink } from '../components/AppLink' +import { Crumbs } from '../components/Shell' +import { Badge, ErrorBox, MoreControls, Skeleton, Time } from '../components/ui' +import { JOURNEY_STATUS } from '../format' +import { usePagedList } from '../hooks' +import { navigate, type Loc } from '../router' + +const STATUS_CHIPS: [string, string][] = [ + ['', '全部'], + ['DRAFT', '草稿'], + ['ACTIVE', '進行中'], + ['ABORTED', '已停止'], +] + +/** 旅程列表(唯讀):欄位依 JourneySummary;不顯示人數(D-050) */ +export function JourneyListPage({ loc }: { loc: Loc }) { + const params = new URLSearchParams(loc.search) + const q = (params.get('q') ?? '').trim() + const status = params.get('status') ?? '' + const [qInput, setQInput] = useState(q) + + function go(nextQ: string, nextStatus: string) { + const sp = new URLSearchParams() + if (nextQ.trim()) sp.set('q', nextQ.trim()) + if (nextStatus) sp.set('status', nextStatus) + const search = sp.toString() ? `?${sp.toString()}` : '' + if (search !== loc.search) navigate(`/journeys${search}`, { replace: true }) + } + + // 輸入停頓後才查詢,並同步網址 + useEffect(() => { + if (qInput.trim() === q) return + const t = window.setTimeout(() => go(qInput, status), 300) + return () => window.clearTimeout(t) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [qInput]) + + const load = useCallback((cursor?: string) => listJourneys({ q, status, cursor }), [q, status]) + const { items, next, error, loading, reload, loadMore, moreBusy, moreError, data } = usePagedList(load) + const filtered = q !== '' || status !== '' + + function clear() { + setQInput('') + navigate('/journeys', { replace: true }) + } + + return ( + <> + +
+
+

旅程

+
+ 最近的 Enrollment +
+
+
+ + setQInput(e.target.value)} /> +
+
+ 狀態 + {STATUS_CHIPS.map(([v, label]) => ( + + ))} +
+
+ + {loading && } + {error && } + {data && items.length === 0 && !filtered && ( +
+

還沒有旅程

+

旅程由團隊協助建立並發佈後會出現在這裡。

+

在網頁上建立與編輯旅程預計 v0.3.0 提供。

+
+ )} + {data && items.length === 0 && filtered && ( +
+

沒有符合條件的旅程

+

+ 狀態「{STATUS_CHIPS.find(([v]) => v === status)?.[1] ?? status}」 + {q && <>、名稱含「{q}」}。 +

+

+ +

+
+ )} + {data && items.length > 0 && ( + <> +
+ + + + + + + + + + + + + {items.map((j) => { + const st = JOURNEY_STATUS[j.status] ?? { label: j.status, tone: 'neutral' as const } + return ( + + + + + + + + ) + })} + +
已顯示 {items.length} 筆,依最後更新新到舊
名稱狀態版本最後更新最近發佈
+ + {j.name} + + + {st.label} + + {j.latest_version === null ? '—' : `v${j.latest_version}`} + + + {j.published_at ?
+
+ + + )} + + ) +} diff --git a/web/src/pages/JourneyPage.tsx b/web/src/pages/JourneyPage.tsx new file mode 100644 index 0000000..77009c3 --- /dev/null +++ b/web/src/pages/JourneyPage.tsx @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { getJourney, getJourneyVersion } from '../api/client' +import { AppLink } from '../components/AppLink' +import { Crumbs } from '../components/Shell' +import { Badge, ErrorBox, Skeleton, Time } from '../components/ui' +import { formatLocal, JOURNEY_STATUS } from '../format' +import { GraphSection } from '../graph/GraphSection' +import { navigate, type Loc } from '../router' +import { useFetch } from '../useFetch' + +/** 旅程頁(唯讀,D-062):標頭、版本下拉、節點圖(不疊軌跡) */ +export function JourneyPage({ id, loc }: { id: string; loc: Loc }) { + const load = useCallback(() => getJourney(id), [id]) + const { data: detail, error, reload } = useFetch(load) + + const vp = Number(new URLSearchParams(loc.search).get('version')) + const requested = Number.isInteger(vp) && vp > 0 ? vp : null + const latest = detail?.latest_version ?? null + const version = requested ?? latest + + const loadDef = useMemo( + () => (detail && version !== null ? () => getJourneyVersion(id, version) : null), + [detail, id, version], + ) + const def = useFetch(loadDef) + + const h1 = useRef(null) + const focused = useRef(null) + useEffect(() => { + if (detail && focused.current !== detail.journey_id) { + focused.current = detail.journey_id + h1.current?.focus() + } + }, [detail]) + + const crumbs = (last: string) => + + if (error?.status === 404) { + return ( + <> + {crumbs('找不到')} +
+

找不到這個旅程

+

它可能不存在,或網址有誤。

+

+ + 回旅程列表 + +

+
+ + ) + } + if (error) { + return ( + <> + {crumbs('旅程')} + + + ) + } + if (!detail) { + return ( + <> + {crumbs('載入中…')} +

載入中…

+ + + ) + } + + const st = JOURNEY_STATUS[detail.status] ?? { label: detail.status, tone: 'neutral' as const } + const old = version !== null && latest !== null && version !== latest + const selectVersion = (v: number) => + navigate(`/journeys/${encodeURIComponent(id)}${v === latest ? '' : `?version=${v}`}`, { replace: true }) + + return ( + <> + {crumbs(detail.name)} +
+
+

+ {detail.name} {st.label} +

+

+ {latest !== null ? `最新版本 v${latest}` : '尚未發佈'} + {detail.published_at && ( + <> + ・最近發佈

+
+ 這個旅程的 Enrollment +
+ {detail.description && ( +
+

說明

+

{detail.description}

+
+ )} + {detail.versions.length === 0 ? ( +

這個旅程尚未發佈任何版本,沒有流程圖可看。

+ ) : ( + <> +
+ + + 切換後只更新下方的圖;已發佈的版本不可修改。 +
+ {old && ( +
+

+ 你正在看 v{version},不是最新版本。已發佈的版本不可修改;新進入的客戶使用最新版本 v{latest}。{' '} + 回到最新版本 +

+
+ )} + {def.loading && } + {def.error && ( + + )} + {def.data && Array.isArray(def.data.nodes) && ( + <> + +

+ v0.2.0 的旅程頁唯讀:沒有編輯、停止、審核按鈕;節點只顯示名稱與類型,設定值 v0.3.0 才提供。 +

+ + )} + + )} + + ) +} diff --git a/web/src/pages/SearchPage.tsx b/web/src/pages/SearchPage.tsx index 0aaa994..50fe098 100644 --- a/web/src/pages/SearchPage.tsx +++ b/web/src/pages/SearchPage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState, type FormEvent, type MouseEvent } from 'react' import { ApiError, listEnrollments } from '../api/client' import type { EnrollmentSummary, Page } from '../api/types' +import { AppLink } from '../components/AppLink' import { Badge, ErrorBox, Loading, Time } from '../components/ui' import { ENROLLMENT_STATUS } from '../format' import { lastNavigationWasPop, navigate, type Loc } from '../router' @@ -81,6 +82,9 @@ export function SearchPage({ loc }: { loc: Loc }) {

客戶旅程軌跡

唯讀。查某位客戶走到哪、為什麼沒收到訊息。

+

+ 最近的 Enrollment +

diff --git a/web/src/pages/DetailPage.tsx b/web/src/pages/TracePage.tsx similarity index 58% rename from web/src/pages/DetailPage.tsx rename to web/src/pages/TracePage.tsx index aaa2997..0df3326 100644 --- a/web/src/pages/DetailPage.tsx +++ b/web/src/pages/TracePage.tsx @@ -1,13 +1,17 @@ -import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode, type RefObject } from 'react' -import { ApiError, getEnrollment, listDeliveries } from '../api/client' -import type { Delivery, EnrollmentDetail, TraceEntry } from '../api/types' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react' +import { ApiError, getEnrollment, getJourneyVersion, listDeliveries } from '../api/client' +import type { Delivery, EnrollmentDetail, JourneyVersion, TraceEntry } from '../api/types' +import { AppLink } from '../components/AppLink' import { KeyValues } from '../components/KeyValues' -import { Badge, ErrorBox, Loading, RequestId, Time, UtcContext } from '../components/ui' -import { DELIVERY_STATUS, ENROLLMENT_STATUS, TERMINAL, utcOffsetLabel } from '../format' -import { navigate } from '../router' +import { Crumbs } from '../components/Shell' +import { Badge, ErrorBox, RequestId, Skeleton, Time, UtcContext } from '../components/ui' +import { DELIVERY_STATUS, ENROLLMENT_STATUS, shortId, TERMINAL, utcOffsetLabel } from '../format' +import { GraphSection } from '../graph/GraphSection' +import { deriveOverlay } from '../graph/model' import { useFetch } from '../useFetch' -export function DetailPage({ id }: { id: string }) { +/** 圖形軌跡(直線):頁首+目前狀況+圖+逐步紀錄+發送歷史+觸發快照;定義取不到時退回逐步紀錄 */ +export function TracePage({ id }: { id: string }) { const load = useCallback(() => getEnrollment(id), [id]) const { data, error, loading, last, reload } = useFetch(load) const shown = data ?? (last && last.enrollment_id === id ? last : undefined) @@ -22,21 +26,25 @@ export function DetailPage({ id }: { id: string }) { } }, [shown]) - function back(e: MouseEvent) { - e.preventDefault() - const state = window.history.state as { fromList?: boolean } | null - if (state?.fromList) window.history.back() - else navigate(shown ? `/?q=${encodeURIComponent(shown.profile_id)}` : '/') - } + // 以 Enrollment 綁定的版本取定義(不是最新版);Enrollment 失敗時不取 + const journeyId = shown?.journey_id + const journeyVersion = shown?.journey_version + const loadDef = useMemo( + () => + journeyId !== undefined && journeyVersion !== undefined ? () => getJourneyVersion(journeyId, journeyVersion) : null, + [journeyId, journeyVersion], + ) + const def = useFetch(loadDef) return ( <> -

- - ← 回到列表 - -

- {!shown && loading && 載入軌跡中…} + {!shown && !error && ( + <> + +

載入中…

+ + + )} {error && } {shown && ( @@ -47,6 +55,10 @@ export function DetailPage({ id }: { id: string }) { onRefresh={reload} showUtc={showUtc} onToggleUtc={setShowUtc} + def={def.data} + defError={def.error} + defLoading={def.loading} + onRetryDef={def.reload} /> )} @@ -57,23 +69,18 @@ export function DetailPage({ id }: { id: string }) { function DetailError({ error, onRetry }: { error: ApiError; onRetry: () => void }) { if (error.status === 404) { return ( -
-

- 找不到這筆 Enrollment -

-

- ID 可能有誤,或這筆紀錄不存在。 -

- { - e.preventDefault() - navigate('/') - }} - > - 回到查詢 - -
+ <> + +
+

找不到這筆 Enrollment

+

+ ID 可能有誤,或這筆紀錄不存在。 +

+ + 回到查詢 + +
+ ) } return @@ -86,6 +93,10 @@ function DetailBody({ onRefresh, showUtc, onToggleUtc, + def, + defError, + defLoading, + onRetryDef, }: { d: EnrollmentDetail h1Ref: RefObject @@ -93,53 +104,145 @@ function DetailBody({ onRefresh: () => void showUtc: boolean onToggleUtc: (v: boolean) => void + def: JourneyVersion | undefined + defError: ApiError | undefined + defLoading: boolean + onRetryDef: () => void }) { const masked = new Set(d.masked_paths) const st = ENROLLMENT_STATUS[d.status] const terminal = TERMINAL.has(d.status) + const usableDef = def !== undefined && Array.isArray(def.nodes) && def.nodes.length > 0 ? def : null + const overlay = useMemo( + () => (usableDef ? deriveOverlay(usableDef, d.trace, d.status) : null), + [usableDef, d.trace, d.status], + ) + const customerTo = `/customers/${encodeURIComponent(d.profile_id)}` + + // 第 N 次經過:前端依 seq 計算(journey-engine §4.5);只有同一節點出現多次才標示 + const visitOf = useMemo(() => { + const total = new Map() + for (const t of d.trace) total.set(t.node_id, (total.get(t.node_id) ?? 0) + 1) + const seen = new Map() + return d.trace.map((t) => { + const n = (seen.get(t.node_id) ?? 0) + 1 + seen.set(t.node_id, n) + return (total.get(t.node_id) ?? 0) > 1 ? n : 0 + }) + }, [d.trace]) + + let graph: ReactNode = null + if (usableDef && overlay) { + graph = ( + + ) + } else if (defLoading) { + graph =

載入流程圖中…

+ } else if (defError || def !== undefined) { + const reason = defError + ? `取得 v${d.journey_version} 流程定義失敗` + : `軌跡與 v${d.journey_version} 流程定義對不上` + graph = ( +
+

+ 無法顯示旅程圖({reason})。 + 下方改顯示逐步紀錄。 {defError && }{' '} + {defError && defError.status !== 404 && defError.status !== 403 && ( + + )} +

+
+ ) + } + return ( <> -
-

- {d.journey_name}(v{d.journey_version}) {st.label} -

-

- {d.enrollment_id} 客戶 {d.profile_id} -

- {terminal && d.terminated_at && ( + +
+
+

+ {d.journey_name} {st.label} +

- 進入

- )} -

-

+
+ + - 資料更新於
+

+ +
+
Enrollment ID
+
{d.enrollment_id}
+
資料更新於
+
+
+
+

(時間以本機時區 {utcOffsetLabel()} 顯示)

+ {graph} + {masked.size > 0 && (

- 標示「已遮罩」的值,系統基於個資保護不顯示完整內容,此頁無法解除。ID、時間與事件名稱不遮罩。 + 標示「已遮罩」的值,系統基於個資保護不顯示完整內容,遮罩值無法解除,也不提供複製。ID、時間與事件名稱不遮罩。

)} -

節點軌跡

-
    - {d.trace.map((t, i) => ( - - ))} -
+
+

逐步紀錄(舊到新)

+
    + {d.trace.map((t, i) => ( + + ))} +
+
+ + {terminal && d.terminated_at && ( +
+
結束時間
+
+
+ {d.termination_reason && ( + <> +
結束原因
+
{d.termination_reason}
+ + )} +
+ )} @@ -179,11 +282,14 @@ function Summary({ d }: { d: EnrollmentDetail }) { function TraceItem({ t, index, + visit, current, masked, }: { t: TraceEntry index: number + /** 第幾次經過;0 = 只經過一次,不標示 */ + visit: number current: boolean masked: ReadonlySet }) { @@ -195,6 +301,7 @@ function TraceItem({ {t.seq}. {t.node_name ?? t.node_type} {t.node_name === null && (未命名)} + {visit > 0 && 第 {visit} 次經過} {current && 目前在此} {t.node_type} · {t.node_id} diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 2237fbf..38be00d 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -136,3 +136,78 @@ input[aria-invalid="true"] { border-color: var(--color-danger-fg); border-width: } @media (prefers-reduced-motion: no-preference) { .skel { animation: pulse 1.2s ease-in-out infinite; } } @keyframes pulse { 50% { opacity: .5; } } + +/* ---------- v0.2.0 旅程列表、旅程頁、客戶、圖形軌跡(對應設計稿 docs/ui/journey-platform/index.html) ---------- */ +.sec { font-size: var(--text-lg); margin: 0 0 var(--space-3); } +h2.sec, h3 { font-size: var(--text-lg); } +td.nw, td.nw a { white-space: nowrap; } +.check { display: inline-flex; align-items: center; gap: var(--space-2); min-height: var(--touch-target); } +.check input { width: 20px; height: 20px; min-height: 0; } +.tap { display: inline-flex; align-items: center; min-height: 32px; } +.toolbar { display: flex; flex-wrap: wrap; gap: var(--space-4); align-items: flex-end; margin-bottom: var(--space-4); } +.toolbar .field { min-width: 220px; } +.chips { display: flex; flex-wrap: wrap; gap: var(--space-2); border: 0; padding: 0; margin: 0; } +.chips legend { font-size: var(--text-sm); font-weight: 600; padding: 0; margin-bottom: var(--space-2); } +.chip { display: inline-flex; align-items: center; min-height: 36px; padding: 0 var(--space-3); border: 1px solid var(--color-border-strong); border-radius: var(--radius-pill); background: var(--color-surface); color: var(--color-text); font: inherit; font-size: var(--text-sm); cursor: pointer; } +.chip[aria-pressed="true"] { background: var(--color-primary); border-color: var(--color-primary); color: var(--color-primary-text); font-weight: 600; } +.cols { display: grid; grid-template-columns: minmax(0, 1fr) var(--panel-width); gap: var(--space-4); align-items: start; } +.cols.two { grid-template-columns: 340px minmax(0, 1fr); } +.search-big { display: flex; flex-wrap: wrap; gap: var(--space-3); } +.search-big .field { flex: 1 1 360px; } +.search-big input { min-height: 48px; font-size: var(--text-md); } +.search-big .btn { min-height: 48px; align-self: flex-start; } +.search-big .btn[aria-disabled="true"] { cursor: progress; opacity: .7; } +table.plain th, table.plain td { padding: var(--space-1) var(--space-2); } + +.legend { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); font-size: var(--text-sm); color: var(--color-text-muted); margin: 0 0 var(--space-2); padding: 0; list-style: none; } +.legend li { display: inline-flex; align-items: center; gap: var(--space-2); } +.sw { display: inline-block; width: 28px; height: 14px; border-radius: 3px; background: var(--color-surface); border: 2px solid var(--color-border-strong); } +.sw.v { border-color: var(--color-trace-visited); } +.sw.c { border-color: var(--color-trace-current); border-width: 3px; } +.sw.u { border-style: dashed; border-color: var(--color-trace-unvisited); } +.graph { position: relative; background-color: var(--color-graph-bg); background-image: radial-gradient(var(--color-graph-dot) 1px, transparent 1px); background-size: 16px 16px; border: 1px solid var(--color-border); border-radius: var(--radius-md); overflow-x: auto; overflow-y: hidden; margin-bottom: var(--space-2); } +.graph .inner { position: relative; } +.graph .react-flow { background: transparent; } +.graph .react-flow__pane, .graph .react-flow__renderer { cursor: default; } +.graph .react-flow__edge-path.e { fill: none; stroke: var(--color-edge); stroke-width: 2; } +.graph .react-flow__edge-path.e.taken { stroke: var(--color-edge-taken); stroke-width: 3.5; } +.graph .react-flow__edge-path.e.skipped { stroke-dasharray: 6 5; } +.graph .react-flow__edge-path.e.back { stroke-dasharray: 2 5; stroke-linecap: round; } +.graph .mk path { fill: var(--color-edge); } +.graph .mk.taken path { fill: var(--color-edge-taken); } +.graph .react-flow__handle { opacity: 0; pointer-events: none; } +.graph .react-flow__node { pointer-events: none; } +.node { pointer-events: all; width: var(--node-width); height: var(--node-height); text-align: left; font: inherit; padding: var(--space-1) var(--space-2) var(--space-1) var(--space-3); background: var(--color-surface); color: var(--color-text); border: 1px solid var(--color-border-strong); border-left: 6px solid var(--catc, var(--color-edge)); border-radius: var(--radius-sm); cursor: pointer; display: flex; flex-direction: column; justify-content: center; gap: 2px; overflow: hidden; } +.node.cat-source { --catc: var(--color-node-source); } +.node.cat-wait { --catc: var(--color-node-wait); } +.node.cat-branch { --catc: var(--color-node-branch); } +.node.cat-send { --catc: var(--color-node-send); } +.node.cat-action { --catc: var(--color-node-action); } +.node.cat-end { --catc: var(--color-node-end); } +.node .n-cat { font-size: var(--text-xs); color: var(--color-text-muted); } +.node .n-name { font-size: var(--text-sm); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.node .n-type { font: var(--text-xs) var(--font-mono); color: var(--color-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.node .n-state { font-size: var(--text-xs); font-weight: 700; } +.node.visited { border-color: var(--color-trace-visited); border-width: 2px; border-left-width: 6px; } +.node.visited .n-state { color: var(--color-trace-visited); } +.node.current { border-color: var(--color-trace-current); border-width: 3px; border-left-width: 6px; background: var(--color-warning-bg); } +.node.current .n-state { color: var(--color-warning-fg); } +.node.unvisited { border-style: dashed; border-color: var(--color-trace-unvisited); } +.node.selected { box-shadow: 0 0 0 3px var(--color-focus); } +.node:focus-visible, .graph:focus-visible { box-shadow: var(--focus-ring); } +.graph-note { display: none; } +.react-flow__attribution { font-size: var(--text-xs); } + +/* 窄螢幕補充(<= 640px):連結與篩選鈕達 44px、圖可左右捲動並提示改看文字版 */ +@media (max-width: 640px) { + .chip { min-height: 44px; } + .tap, .check { min-height: 44px; } + .crumbs a { display: inline-flex; align-items: center; min-height: 44px; } + .search-big .btn, .search-big input { min-height: 48px; } + table.stack td.nw, table.stack td.nw a { white-space: normal; } + table.stack td a { min-height: 44px; display: inline-flex; align-items: center; } + .graph-note { display: block; } + .toolbar .field { min-width: 0; flex: 1 1 100%; } + .cols, .cols.two { grid-template-columns: minmax(0, 1fr); } +} +@media (max-width: 1100px) { .cols, .cols.two { grid-template-columns: minmax(0, 1fr); } } diff --git a/web/tests/acceptance/journey-customer-ui/customer-page.test.tsx b/web/tests/acceptance/journey-customer-ui/customer-page.test.tsx new file mode 100644 index 0000000..20f5bb2 --- /dev/null +++ b/web/tests/acceptance/journey-customer-ui/customer-page.test.tsx @@ -0,0 +1,233 @@ +// @vitest-environment jsdom +/** journey-customer-ui:客戶頁(基本資料、遮罩、參加過的旅程)。假設見 fixtures.tsx。 */ +import { cleanup, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + PID, backend, bodyText, deferred, enrollmentSummary, gotoNav, json, page, params, problem, problemWithId, + profileDetail, profileSummary, resetEnv, start, teardown, +} from './fixtures' + +beforeEach(resetEnv) +afterEach(() => { cleanup(); teardown() }) + +const ENR = `/api/v1/profiles/${PID}/enrollments` +const enrollments = () => [ + enrollmentSummary(), + enrollmentSummary({ + enrollment_id: 'e2', journey_id: 'j2', journey_name: '新會員歡迎', journey_version: 7, status: 'COMPLETED', + current_node_name: '結束', current_node_type: 'EndNode', enrolled_at: '2026-09-02T06:30:00Z', terminated_at: '2026-09-04T06:31:00Z', + }), +] + +async function openCustomer(routes: Parameters[0] = {}) { + const api = backend({ + 'GET /api/v1/profiles/search': () => json({ items: [profileSummary({ profile_id: PID, matched_by: 'PROFILE_ID' })] }), + [`GET /api/v1/profiles/${PID}`]: () => json(profileDetail()), + [`GET ${ENR}`]: () => json(page(enrollments())), + ...routes, + }) + start('/') + await gotoNav('客戶') + await userEvent.type(await screen.findByLabelText('搜尋客戶'), PID) + await userEvent.click(screen.getByRole('button', { name: '搜尋' })) + return api +} +const h1 = () => screen.findByRole('heading', { level: 1 }) +const section = async (name: string) => (await screen.findByRole('heading', { name })).closest('section, div.card, div') as HTMLElement + +describe('客戶頁:基本資料', () => { + it('左:客戶 ID(完整值+複製鈕)、建立/最近更新/資料時間(本地時區)、識別碼表、客戶屬性;標題為客戶 ID(沒有 name)', async () => { + await openCustomer() + const title = await h1() + expect(title.textContent).toMatch(/^客戶/) + expect(title.textContent).toContain('…') // 長 ID 只顯示頭尾 + await screen.findByRole('heading', { name: '基本資料' }) + expect(bodyText()).toContain(PID) // 完整值在客戶頁 + expect(screen.getByRole('button', { name: '複製客戶 ID' })).toBeTruthy() + const text = bodyText() + expect(text).toContain('2026-05-03 11:20') // created_at 2026-05-03T03:20:00Z + expect(text).toContain('2026-11-01 16:00') // updated_at + expect(text).toContain('2026-11-01 16:05') // as_of + const idTable = screen.getByRole('table', { name: '識別碼' }) + expect(within(idTable).getAllByRole('columnheader').map((h) => h.textContent?.trim())).toEqual(['類型', '值']) + expect(idTable.textContent).toContain('email') + expect(idTable.textContent).toContain('會員編號') + expect(idTable.textContent).toContain('M-20418') + expect(screen.getByRole('heading', { name: '客戶屬性' })).toBeTruthy() + expect(text).toContain('tier') + expect(text).toContain('regular') + await waitFor(() => expect(document.activeElement).toBe(title)) + }) + + it('標題規則:name 存在且未遮罩 → 用姓名(客戶 ID 移到副標)', async () => { + await openCustomer({ + [`GET /api/v1/profiles/${PID}`]: () => + json(profileDetail({ attributes: { name: '陳大華', tier: 'regular' }, masked_paths: ['identifiers[0].value'] })), + }) + const title = await h1() + await waitFor(() => expect(title.textContent).toContain('陳大華')) + expect(title.textContent).not.toContain('…') + expect(bodyText()).toContain(PID) + }) + + it('標題規則:name 在 masked_paths(值為 ***)→ 用客戶 ID,不以 *** 當標題', async () => { + await openCustomer({ + [`GET /api/v1/profiles/${PID}`]: () => + json(profileDetail({ attributes: { name: '***', tier: 'regular' }, masked_paths: ['attributes.name'] })), + }) + const title = await h1() + await screen.findByRole('heading', { name: '基本資料' }) + expect(title.textContent).toMatch(/^客戶/) + expect(title.textContent).not.toContain('*') + }) + + it('遮罩只依後端標記:masked_paths/masked 的值顯示「已遮罩」徽章;未標記的 *** 原樣顯示、無徽章;遮罩值無複製鈕', async () => { + await openCustomer({ + [`GET /api/v1/profiles/${PID}`]: () => + json(profileDetail({ + attributes: { tier: 'regular', birthday: '***', note: '***' }, + masked_paths: ['attributes.birthday', 'identifiers[0].value'], + })), + }) + await screen.findByRole('heading', { name: '客戶屬性' }) + const idTable = screen.getByRole('table', { name: '識別碼' }) + const emailRow = within(idTable).getByText('email').closest('tr')! + expect(emailRow.textContent).toContain('a***@example.com') + expect(within(emailRow).getByText('已遮罩')).toBeTruthy() + const memberRow = within(idTable).getByText('會員編號').closest('tr')! + expect(within(memberRow).queryByText('已遮罩')).toBeNull() + const dd = (key: string) => screen.getByText(key, { selector: 'dt' }).nextElementSibling as HTMLElement + expect(within(dd('birthday')).getByText('已遮罩')).toBeTruthy() + expect(dd('note').textContent).toContain('***') + expect(within(dd('note')).queryByText('已遮罩')).toBeNull() + expect(within(dd('tier')).queryByText('已遮罩')).toBeNull() + expect(bodyText()).toContain('遮罩值無法解除') + expect(within(emailRow).queryByRole('button')).toBeNull() + }) + + it('未知 IdentifierType 顯示原值、不報錯(DESIGN-04 §5)', async () => { + await openCustomer({ + [`GET /api/v1/profiles/${PID}`]: () => + json(profileDetail({ + identifiers: [ + { type: 'EMAIL', value: 'a***@example.com', masked: true }, + { type: 'TWITTER_HANDLE', value: '@amy_tw', masked: false }, + ], + })), + }) + const idTable = await screen.findByRole('table', { name: '識別碼' }) + const row = within(idTable).getByText('TWITTER_HANDLE').closest('tr')! + expect(row.textContent).toContain('@amy_tw') + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('「同時顯示 UTC 原值」勾選後時間旁加註 UTC 原值', async () => { + await openCustomer() + await screen.findByRole('heading', { name: '基本資料' }) + expect(bodyText()).not.toContain('2026-05-03T03:20:00Z') + await userEvent.click(screen.getByLabelText('同時顯示 UTC 原值')) + expect(bodyText()).toContain('2026-05-03T03:20:00Z') + }) + + it('404 PROFILE_NOT_FOUND:「找不到這位客戶」+回搜尋連結(與 5xx 分開)', async () => { + await openCustomer({ [`GET /api/v1/profiles/${PID}`]: () => problem(404, 'PROFILE_NOT_FOUND') }) + await screen.findByRole('heading', { level: 1, name: '找不到這位客戶' }) + expect(screen.getByRole('link', { name: '回搜尋' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '重試' })).toBeNull() + }) + + it('基本資料 5xx:alert 附 request_id 與重試,重試後顯示', async () => { + let fail = true + await openCustomer({ + [`GET /api/v1/profiles/${PID}`]: () => (fail ? problemWithId(500, 'INTERNAL_ERROR', 'req_pf1') : json(profileDetail())), + }) + const alert = await screen.findByRole('alert') + expect(alert.textContent).toContain('req_pf1') + expect(bodyText()).not.toContain('找不到這位客戶') + fail = false + await userEvent.click(within(alert).getByRole('button', { name: '重試' })) + await screen.findByRole('heading', { name: '基本資料' }) + }) + + it('載入中:h1「載入中…」骨架(role=status)', async () => { + const d = deferred() + await openCustomer({ [`GET /api/v1/profiles/${PID}`]: () => d.promise }) + await screen.findByRole('heading', { level: 1, name: '載入中…' }) + expect(screen.getAllByRole('status').length).toBeGreaterThan(0) + d.resolve(json(profileDetail())) + await screen.findByRole('heading', { name: '基本資料' }) + }) +}) + +describe('客戶頁:參加過的旅程', () => { + it('表格:旅程(版本)、狀態、目前/最後節點、進入時間、結束時間、Enrollment ID;新到舊;未結束的結束時間為「—」', async () => { + const api = await openCustomer() + const table = await screen.findByRole('table', { name: /參加過/ }) + expect(within(table).getAllByRole('columnheader').map((h) => h.textContent?.trim())) + .toEqual(['旅程(版本)', '狀態', '目前/最後節點', '進入時間', '結束時間', 'Enrollment ID']) + const rows = within(table).getAllByRole('row').slice(1) + expect(rows).toHaveLength(2) + expect(within(rows[0]).getByRole('link', { name: '棄單挽回(v3)' })).toBeTruthy() + expect(rows[0].textContent).toContain('等待中') + expect(rows[0].textContent).toContain('等 3 天') + expect(rows[0].textContent).toContain('2026-10-18 10:00') + expect(rows[0].textContent).toContain('—') + expect(rows[1].textContent).toContain('新會員歡迎(v7)') + expect(rows[1].textContent).toContain('已完成') + expect(rows[1].textContent).toContain('2026-09-04 14:31') + expect(api.calls.some((c) => c.path === ENR)).toBe(true) + }) + + it('cursor 載入更多', async () => { + const api = await openCustomer({ + [`GET ${ENR}`]: (c) => + params(c).get('cursor') === 'c9' + ? json(page([enrollmentSummary({ enrollment_id: 'e3', journey_name: '第二頁旅程' })])) + : json(page(enrollments(), 'c9')), + }) + await screen.findByRole('table', { name: /參加過/ }) + await userEvent.click(await screen.findByRole('button', { name: '載入更多' })) + await screen.findByRole('link', { name: /第二頁旅程/ }) + expect(api.calls.some((c) => c.path === ENR && params(c).get('cursor') === 'c9')).toBe(true) + expect(screen.queryByRole('button', { name: '載入更多' })).toBeNull() + }) + + it('尚無旅程:「還沒有參加任何旅程」並說明何時出現;基本資料照常', async () => { + await openCustomer({ [`GET ${ENR}`]: () => json(page([])) }) + await screen.findByRole('heading', { name: '還沒有參加任何旅程' }) + expect(bodyText()).toContain('客戶觸發旅程的入口事件後,會顯示在這裡') + expect(screen.getByRole('heading', { name: '基本資料' })).toBeTruthy() + }) + + it('旅程紀錄失敗只影響該區塊:基本資料仍顯示,區塊內 alert 附 request_id 與重試;重試成功', async () => { + let fail = true + await openCustomer({ + [`GET ${ENR}`]: () => (fail ? problemWithId(500, 'INTERNAL_ERROR', 'req_91ab') : json(page(enrollments()))), + }) + await screen.findByRole('heading', { name: '基本資料' }) + const alert = await screen.findByRole('alert') + expect(alert.textContent).toContain('無法載入旅程紀錄') + expect(alert.textContent).toContain('req_91ab') + expect(screen.getByRole('table', { name: '識別碼' })).toBeTruthy() + fail = false + await userEvent.click(within(alert).getByRole('button', { name: '重試' })) + await screen.findByRole('table', { name: /參加過/ }) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('基本資料已顯示時,旅程紀錄仍在載入也不阻擋(兩個 API 分開處理)', async () => { + const d = deferred() + await openCustomer({ [`GET ${ENR}`]: () => d.promise }) + await screen.findByRole('heading', { name: '基本資料' }) + expect(screen.queryByRole('table', { name: /參加過/ })).toBeNull() + d.resolve(json(page(enrollments()))) + await screen.findByRole('table', { name: /參加過/ }) + }) + + it('同一頁以 section 呈現「基本資料」與「參加過的旅程」兩個區塊標題', async () => { + await openCustomer() + await section('基本資料') + await section('參加過的旅程') + }) +}) diff --git a/web/tests/acceptance/journey-customer-ui/customer-search.test.tsx b/web/tests/acceptance/journey-customer-ui/customer-search.test.tsx new file mode 100644 index 0000000..ca5e967 --- /dev/null +++ b/web/tests/acceptance/journey-customer-ui/customer-search.test.tsx @@ -0,0 +1,161 @@ +// @vitest-environment jsdom +/** journey-customer-ui:客戶搜尋(D-048)。假設見 fixtures.tsx。 */ +import { cleanup, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + PID, backend, bodyText, deferred, gotoNav, json, params, problem, problemWithId, profileDetail, profileSummary, + resetEnv, start, teardown, page, +} from './fixtures' + +beforeEach(resetEnv) +afterEach(() => { cleanup(); teardown() }) + +const SEARCH = '/api/v1/profiles/search' +const searches = (api: ReturnType) => api.calls.filter((c) => c.path === SEARCH) +const items = (list: unknown[]) => () => json({ items: list }) + +async function openSearch(routes: Parameters[0]) { + const api = backend({ + [`GET /api/v1/profiles/${PID}`]: () => json(profileDetail()), + [`GET /api/v1/profiles/${PID}/enrollments`]: () => json(page([])), + ...routes, + }) + start('/') + await gotoNav('客戶') + await screen.findByRole('heading', { level: 1, name: '客戶' }) + return api +} +async function search(q: string) { + const box = screen.getByLabelText('搜尋客戶') + await userEvent.clear(box) + if (q) await userEvent.type(box, q) + await userEvent.click(screen.getByRole('button', { name: '搜尋' })) +} +const two = () => [ + profileSummary({ matched_by: 'MEMBER_ID' }), + profileSummary({ + profile_id: '01JCX7K2M4N8P3Q6R9S5T7H9KMN', matched_by: 'PROFILE_ID', updated_at: '2026-10-30T01:12:00Z', + identifiers: [{ type: 'PHONE', value: '***', masked: true }], + }), +] + +describe('客戶搜尋', () => { + it('初始:說明可輸入的內容(email、手機、外部 ID、客戶 ID)與完全比對提示,且不送出搜尋', async () => { + const api = await openSearch({ [`GET ${SEARCH}`]: items([]) }) + const text = bodyText() + expect(text).toContain('可以輸入什麼') + expect(text).toContain('完全比對') + for (const w of ['email', '手機', '外部 ID', '客戶 ID']) expect(text).toContain(w) + expect(searches(api)).toHaveLength(0) + }) + + it('單筆結果直接進客戶頁,並顯示「搜尋『…』比對到這位客戶(比對方式:email)」;請求 q 為輸入值', async () => { + const api = await openSearch({ [`GET ${SEARCH}`]: items([profileSummary()]) }) + await search('amy@example.com') + const note = await screen.findByText(/比對到這位客戶/) + expect(note.textContent).toContain('amy@example.com') + expect(note.textContent).toContain('比對方式:email') + expect(params(searches(api)[0]).get('q')).toBe('amy@example.com') + // 進到客戶頁:有基本資料區,沒有結果表 + await screen.findByRole('heading', { name: '基本資料' }) + expect(screen.queryByRole('table', { name: /找到/ })).toBeNull() + await waitFor(() => expect(document.activeElement?.tagName).toBe('H1')) + }) + + it('多筆結果:表格(比對方式、識別碼、客戶 ID、最近更新)、遮罩徽章、長 ID 只顯示頭尾,連結進客戶頁', async () => { + await openSearch({ [`GET ${SEARCH}`]: items(two()) }) + await search('M-20418') + const table = await screen.findByRole('table') + expect(table.querySelector('caption')!.textContent).toContain('找到 2 位客戶') + expect(table.querySelector('caption')!.textContent).toContain('最多顯示 20 筆') + expect(within(table).getAllByRole('columnheader').map((h) => h.textContent?.trim())) + .toEqual(['比對方式', '識別碼', '客戶 ID', '最近更新']) + const rows = within(table).getAllByRole('row').slice(1) + expect(rows).toHaveLength(2) + expect(rows[0].textContent).toContain('會員編號') // MEMBER_ID + expect(rows[0].textContent).toContain('M-20418') + expect(rows[0].textContent).toContain('a***@example.com') + expect(within(rows[0]).getByText('已遮罩')).toBeTruthy() + expect(rows[1].textContent).toContain('客戶 ID') // PROFILE_ID + expect(within(rows[1]).getByText('已遮罩')).toBeTruthy() + expect(rows[1].textContent).toContain('2026-10-30 09:12') + const link = within(rows[0]).getByRole('link') + expect(link.textContent).toContain('…') + expect(link.textContent).not.toBe(PID) + // 完成後焦點到結果標題 + await waitFor(() => expect(['H1', 'H2', 'H3']).toContain(document.activeElement?.tagName)) + await userEvent.click(link) + await screen.findByRole('heading', { name: '基本資料' }) + }) + + it('最多 20 筆:後端回 20 筆就全列出', async () => { + const many = Array.from({ length: 20 }, (_, i) => + profileSummary({ profile_id: `01JCX7K2M4N8P3Q6R9S5T${String(i).padStart(5, '0')}`, matched_by: 'MEMBER_ID' })) + await openSearch({ [`GET ${SEARCH}`]: items(many) }) + await search('M-1') + const table = await screen.findByRole('table') + expect(within(table).getAllByRole('row')).toHaveLength(21) + }) + + it('網址同步 ?q=:送出後網址帶 q;以該網址重新載入會自動搜尋並顯示同樣結果', async () => { + const api = await openSearch({ [`GET ${SEARCH}`]: items(two()) }) + await search('M-20418') + await screen.findByRole('table') + expect(new URLSearchParams(window.location.search).get('q')).toBe('M-20418') + const url = window.location.pathname + window.location.search + cleanup() + const before = searches(api).length + start(url) + await screen.findByRole('table') + expect(searches(api).length).toBeGreaterThan(before) + expect(params(searches(api).at(-1)!).get('q')).toBe('M-20418') + expect((screen.getByLabelText('搜尋客戶') as HTMLInputElement).value).toBe('M-20418') + }) + + it('查無(200、items 為空):「找不到符合的客戶」並列出可能原因', async () => { + await openSearch({ [`GET ${SEARCH}`]: items([]) }) + await search('nobody@example.com') + await screen.findByRole('heading', { name: '找不到符合的客戶' }) + const text = bodyText() + expect(text).toContain('可能原因') + expect(text).toContain('輸入內容不完整或有錯字') + expect(text).toContain('客戶尚未送過任何事件') + expect(text).toContain('識別碼尚未綁定到客戶') + }) + + it('空字串:「請輸入要搜尋的內容。」顯示在欄位旁,aria-invalid 並以 aria-describedby 關聯', async () => { + await openSearch({ [`GET ${SEARCH}`]: () => problem(400, 'VALIDATION_FAILED') }) + await search('') + const msg = await screen.findByText('請輸入要搜尋的內容。') + const box = screen.getByLabelText('搜尋客戶') + expect(box.getAttribute('aria-invalid')).toBe('true') + const ids = (box.getAttribute('aria-describedby') ?? '').split(/\s+/) + expect(ids).toContain(msg.id) + expect(bodyText()).not.toContain('搜尋失敗') + }) + + it('搜尋中:按鈕「搜尋中…」並有載入狀態(role=status)', async () => { + const d = deferred() + await openSearch({ [`GET ${SEARCH}`]: () => d.promise }) + await search('abc') + expect(await screen.findByRole('button', { name: '搜尋中…' })).toBeTruthy() + expect(screen.getAllByRole('status').some((s) => s.textContent?.includes('載入中'))).toBe(true) + d.resolve(json({ items: [] })) + await screen.findByRole('heading', { name: '找不到符合的客戶' }) + }) + + it('5xx:role=alert 附 request_id 與重試,重試成功顯示結果', async () => { + let fail = true + await openSearch({ + [`GET ${SEARCH}`]: () => (fail ? problemWithId(503, 'SERVICE_UNAVAILABLE', 'req_77d1') : json({ items: two() })), + }) + await search('abc') + const alert = await screen.findByRole('alert') + expect(alert.textContent).toContain('搜尋失敗') + expect(alert.textContent).toContain('req_77d1') + fail = false + await userEvent.click(within(alert).getByRole('button', { name: '重試' })) + await screen.findByRole('table') + }) +}) diff --git a/web/tests/acceptance/journey-customer-ui/enrollment-browse.test.tsx b/web/tests/acceptance/journey-customer-ui/enrollment-browse.test.tsx new file mode 100644 index 0000000..53efa8b --- /dev/null +++ b/web/tests/acceptance/journey-customer-ui/enrollment-browse.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment jsdom +/** + * journey-customer-ui:最近 Enrollment 瀏覽(D-030;journey-engine §4 `/enrollments`,`q` 省略 = 瀏覽)。 + * 網址 ROUTES.browse(= `/enrollments`,routes.md §1);「旅程」篩選控制項(label 含「旅程」的