Преглед на файлове

feat: flesh out customer detail page

ChenYu преди 4 месеца
родител
ревизия
8e57034cb8

+ 6 - 2
frontend/public/css/aitori.css

@@ -386,9 +386,9 @@ header.sticky.sticky-active .disable-fixed[data-header-hover=light] {
     overflow-x: auto;
 }
 .customer-row {
-    min-width: 760px;
+    min-width: 900px;
     display: grid;
-    grid-template-columns: 260px 140px minmax(180px, 1fr) 120px;
+    grid-template-columns: 260px 140px minmax(180px, 1fr) 120px 140px;
     gap: 16px;
     align-items: center;
     padding: 16px 0;
@@ -408,6 +408,10 @@ header.sticky.sticky-active .disable-fixed[data-header-hover=light] {
     align-items: center;
     gap: 14px;
 }
+.customer-row-action {
+    display: flex;
+    justify-content: flex-end;
+}
 .customer-avatar {
     width: 42px;
     height: 42px;

+ 22 - 22
frontend/src/i18n/locales/ja/translation.json

@@ -554,16 +554,14 @@
   },
   "customerDetailPage": {
     "title": "顧客詳細",
-    "description": "Aitori Stylist の顧客詳細ページ。顧客カルテ、施術履歴、写真、AI シミュレーション履歴、次回来店提案を確認できます。",
+    "description": "Aitori Stylist の顧客詳細ページ。顧客の基本情報、カウンセリング内容、髪情報、次回来店予定を確認できます。",
     "back": "顧客一覧へ戻る",
-    "edit": "プロフィール編集",
-    "simulate": "AIヘアシミュレーション",
+    "edit": "顧客を編集",
     "badge": "顧客プロフィール",
-    "heroTags": ["初回来店 2025/11/12", "担当スタイリスト 小川泰明", "LINE 連携済み"],
     "nextVisit": {
       "badge": "次回来店",
-      "lead": "カラー退色の状況を確認し、前髪と顔周りレイヤーを調整する提案です。30日後フォロー対象です。",
-      "action": "来店リマインドを送信"
+      "lead": "リマインド日:{{date}}",
+      "action": "リマインド送信(未開発)"
     },
     "states": {
       "undeveloped": "現在のバージョンでは未開発です"
@@ -571,28 +569,30 @@
     "basic": {
       "badge": "基本情報",
       "title": "基本情報",
-      "labels": ["電話番号", "メールアドレス", "生年月日", "来店頻度", "髪質", "薬剤メモ"],
-      "fallbacks": ["1994/08/18", "4〜6週間", "細毛 / 軽いうねり / 乾燥しやすい", "8Lvベージュ、赤み抑制、低刺激優先"]
+      "labels": ["電話番号", "メール", "誕生日", "希望連絡手段", "来店頻度"]
+    },
+    "consultation": {
+      "badge": "カウンセリング",
+      "title": "カウンセリング情報",
+      "labels": ["来店予定", "来店目的", "悩み", "相談内容", "希望スタイル"]
+    },
+    "hair": {
+      "badge": "髪情報",
+      "title": "髪情報",
+      "labels": ["長さ", "髪質", "注意事項", "アレルギー", "内部メモ"]
     },
     "history": {
       "badge": "施術履歴",
-      "title": "施術履歴",
-      "action": "記録を追加"
+      "title": "施術履歴"
     },
     "photos": {
-      "badge": "写真とシミュレーション",
-      "title": "過去写真とAIヘアシミュレーション",
-      "items": [
-        { "label": "来店前", "title": "2026/06/01 来店時", "alt": "来店時の写真" },
-        { "label": "AIヘアシミュレーション", "title": "顔周りレイヤー案", "alt": "AIヘアシミュレーション結果" },
-        { "label": "仕上がり", "title": "仕上がり記録", "alt": "仕上がり写真" },
-        { "label": "フォロー", "title": "30〜60日後提案", "alt": "次回提案シミュレーション" }
-      ]
+      "badge": "過去写真",
+      "title": "過去写真"
     },
-    "cta": {
-      "badge": "カウンセリング",
-      "title": "次回の相談と施術を始める",
-      "lead": "前回の写真、AI シミュレーション結果、薬剤メモを引き継いで新しい提案を作成します。"
+    "simulation": {
+      "badge": "AI ヘアシミュレーション",
+      "title": "AI ヘアシミュレーション",
+      "action": "シミュレーション開始(未開発)"
     }
   },
   "simulationResultPage": {

+ 22 - 22
frontend/src/i18n/locales/zh/translation.json

@@ -554,16 +554,14 @@
   },
   "customerDetailPage": {
     "title": "顾客详情",
-    "description": "Aitori Stylist 顾客详情页面。可确认顾客档案、施术历史、照片、AI 发型模拟历史和下次到店建议。",
+    "description": "Aitori Stylist 顾客详情页面。可查看顾客基础信息、咨询内容、头发信息与下次到店安排。",
     "back": "返回顾客列表",
-    "edit": "编辑档案",
-    "simulate": "AI发型模拟",
+    "edit": "编辑顾客",
     "badge": "顾客档案",
-    "heroTags": ["首次到店 2025/11/12", "负责美发师 小川泰明", "LINE 已关联"],
     "nextVisit": {
       "badge": "下次到店",
-      "lead": "建议确认染后褪色情况,并调整刘海和脸周层次。该顾客为30天后跟进对象。",
-      "action": "发送到店提醒"
+      "lead": "提醒时间:{{date}}",
+      "action": "发送提醒(未开发)"
     },
     "states": {
       "undeveloped": "当前版本暂未开发"
@@ -571,28 +569,30 @@
     "basic": {
       "badge": "基本资料",
       "title": "基本资料",
-      "labels": ["电话号码", "邮箱", "生日", "到店频率", "发质", "药剂备注"],
-      "fallbacks": ["1994/08/18", "4〜6周", "细软 / 轻微发流 / 容易干燥", "8Lv米色、红调抑制、低刺激优先"]
+      "labels": ["电话号码", "邮箱", "生日", "期望联系", "到店频率"]
+    },
+    "consultation": {
+      "badge": "咨询信息",
+      "title": "咨询信息",
+      "labels": ["到店计划", "来店目的", "烦恼", "咨询内容", "期待造型"]
+    },
+    "hair": {
+      "badge": "头发信息",
+      "title": "头发信息",
+      "labels": ["发长", "发质", "注意事项", "过敏备注", "内部备注"]
     },
     "history": {
       "badge": "施术历史",
-      "title": "施术历史",
-      "action": "添加记录"
+      "title": "施术历史"
     },
     "photos": {
-      "badge": "照片与模拟",
-      "title": "过往照片和AI发型模拟",
-      "items": [
-        { "label": "到店前", "title": "2026/06/01 到店时", "alt": "到店时的照片" },
-        { "label": "AI发型模拟", "title": "脸周层次方案", "alt": "AI发型模拟结果" },
-        { "label": "完成后", "title": "完成效果记录", "alt": "完成效果照片" },
-        { "label": "跟进", "title": "30〜60天后建议", "alt": "下次建议用模拟" }
-      ]
+      "badge": "过往照片",
+      "title": "过往照片"
     },
-    "cta": {
-      "badge": "咨询",
-      "title": "开始下一次咨询与施术",
-      "lead": "沿用上次照片、AI发型模拟结果和药剂备注,创建新的发型建议。"
+    "simulation": {
+      "badge": "AI 发型模拟",
+      "title": "AI 发型模拟",
+      "action": "开始模拟(未开发)"
     }
   },
   "simulationResultPage": {

+ 95 - 0
frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx

@@ -9,6 +9,19 @@ function getDisplayValue(value?: string) {
   return value?.trim() ? value : '—'
 }
 
+function InfoList({ labels, values }: { labels: string[]; values: string[] }) {
+  return (
+    <div className="customer-info-list">
+      {labels.map((label, index) => (
+        <div key={label}>
+          <span>{label}</span>
+          <strong>{values[index]}</strong>
+        </div>
+      ))}
+    </div>
+  )
+}
+
 export function CustomerDetailPage() {
   const { locale, path, copy } = usePageContext()
   const { customerId } = useParams()
@@ -64,6 +77,88 @@ export function CustomerDetailPage() {
           </div>
         </div>
       </section>
+
+      <section className="pt-0">
+        <div className="container">
+          <div className="row g-4">
+            <div className="col-xl-4 col-lg-5">
+              <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100">
+                <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.basic.badge}</span>
+                <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.basic.title}</h2>
+                <InfoList
+                  labels={pageCopy.basic.labels}
+                  values={[
+                    getDisplayValue(customer.phone),
+                    getDisplayValue(customer.email),
+                    getDisplayValue(customer.birthday),
+                    getDisplayValue(customer.expectContact),
+                    getDisplayValue(customer.visitFrequencyLabel),
+                  ]}
+                />
+              </div>
+            </div>
+
+            <div className="col-xl-8 col-lg-7">
+              <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100">
+                <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.consultation.badge}</span>
+                <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.consultation.title}</h2>
+                <InfoList
+                  labels={pageCopy.consultation.labels}
+                  values={[
+                    getDisplayValue(customer.planDate),
+                    getDisplayValue(customer.visitPurpose),
+                    getDisplayValue(customer.trouble),
+                    getDisplayValue(customer.consultContent),
+                    getDisplayValue(customer.expectLook),
+                  ]}
+                />
+              </div>
+            </div>
+          </div>
+
+          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mt-4">
+            <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.hair.badge}</span>
+            <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.hair.title}</h2>
+            <InfoList
+              labels={pageCopy.hair.labels}
+              values={[
+                getDisplayValue(customer.hairLength),
+                getDisplayValue(customer.hairTexture),
+                getDisplayValue(customer.notice),
+                getDisplayValue(customer.allergy),
+                getDisplayValue(customer.innerRemark),
+              ]}
+            />
+          </div>
+
+          <div className="row g-4 mt-1">
+            <div className="col-lg-4">
+              <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100">
+                <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.history.badge}</span>
+                <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.history.title}</h2>
+                <p className="fs-15 text-medium-gray mb-0">{pageCopy.states.undeveloped}</p>
+              </div>
+            </div>
+            <div className="col-lg-4">
+              <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100">
+                <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.photos.badge}</span>
+                <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.photos.title}</h2>
+                <p className="fs-15 text-medium-gray mb-0">{pageCopy.states.undeveloped}</p>
+              </div>
+            </div>
+            <div className="col-lg-4">
+              <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px h-100">
+                <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.simulation.badge}</span>
+                <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.simulation.title}</h2>
+                <button type="button" disabled className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">
+                  {pageCopy.simulation.action}
+                </button>
+                <p className="fs-15 text-medium-gray mt-15px mb-0">{pageCopy.states.undeveloped}</p>
+              </div>
+            </div>
+          </div>
+        </div>
+      </section>
     </DashboardLayout>
   )
 }

+ 92 - 0
frontend/src/pages/dashboard/customers/__tests__/CustomerDetailPage.test.tsx

@@ -0,0 +1,92 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import { HelmetProvider } from 'react-helmet-async'
+import { vi, describe, it, expect } from 'vitest'
+import { CustomerDetailPage } from '../CustomerDetailPage'
+
+const getCustomerById = vi.fn()
+
+vi.mock('../../../../services/api/customers', () => ({
+  getCustomerById: (...args: unknown[]) => getCustomerById(...args),
+}))
+
+vi.mock('../../../../shared/pageContext', async () => {
+  const actual = await vi.importActual<typeof import('../../../../shared/pageContext')>('../../../../shared/pageContext')
+  return {
+    ...actual,
+    usePageContext: () => ({
+      locale: 'zh',
+      path: (value: string) => `/zh${value}`,
+      navigate: vi.fn(),
+      copy: {
+        common: {},
+        customerDetailPage: {
+          title: '顾客详情',
+          description: 'desc',
+          badge: 'DETAIL',
+          back: '返回列表',
+          edit: '编辑',
+          states: { undeveloped: '当前版本暂未开发' },
+          nextVisit: { badge: '下次到店', lead: '提醒时间:{{date}}', action: '发送提醒(未开发)' },
+          basic: { badge: '基础信息', title: '基础信息', labels: ['电话', '邮箱', '生日', '期望联系', '到店频率'] },
+          consultation: { badge: '咨询信息', title: '咨询信息', labels: ['到店计划', '来店目的', '烦恼', '咨询内容', '期待造型'] },
+          hair: { badge: '头发信息', title: '头发信息', labels: ['发长', '发质', '注意事项', '过敏备注', '内部备注'] },
+          history: { badge: '施术历史', title: '施术历史' },
+          photos: { badge: '过往照片', title: '过往照片' },
+          simulation: { badge: 'AI 发型模拟', title: 'AI 发型模拟', action: '开始模拟(未开发)' },
+        },
+      },
+    }),
+  }
+})
+
+vi.mock('../../../../layouts/DashboardLayout', () => ({
+  DashboardLayout: ({ children }: { children: React.ReactNode }) => <>{children}</>,
+}))
+
+describe('CustomerDetailPage', () => {
+  it('renders current-field sections and undeveloped placeholders', async () => {
+    getCustomerById.mockResolvedValue({
+      id: '8',
+      name: 'Sato Misaki',
+      namePronunciation: 'さとう みさき',
+      phone: '090-0000-0000',
+      email: 'misaki@example.com',
+      birthday: '1998-04-10',
+      expectContact: 'LINE',
+      hairLength: 'MEDIUM',
+      hairTexture: 'NORMAL',
+      notice: '注意保湿',
+      allergy: '无',
+      innerRemark: '常客',
+      nextFollowDate: '2026-07-15',
+      recommendVisitDate: '2026-07-22',
+      planDate: '2026-06-28',
+      visitPurpose: 'CONSULT_SIM',
+      trouble: '发量少',
+      consultContent: '希望轻盈',
+      expectLook: '自然层次',
+      avatar: 'https://example.com/avatar.png',
+      visitFrequencyLabel: '约 2 个月',
+    })
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/customers/8']}>
+          <Routes>
+            <Route path="/:locale/customers/:customerId" element={<CustomerDetailPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    await waitFor(() => {
+      expect(screen.getByText('Sato Misaki')).toBeInTheDocument()
+    })
+
+    expect(screen.getByText('约 2 个月')).toBeInTheDocument()
+    expect(screen.getAllByText('当前版本暂未开发').length).toBeGreaterThanOrEqual(3)
+    expect(screen.getByRole('button', { name: '发送提醒(未开发)' })).toBeDisabled()
+    expect(screen.getByRole('button', { name: '开始模拟(未开发)' })).toBeDisabled()
+  })
+})