Browse Source

feat: restore customer detail route and page

ChenYu 4 months ago
parent
commit
5211f31d2d

+ 3 - 0
frontend/src/App.tsx

@@ -6,6 +6,7 @@ import { LoginPage } from './pages/public/LoginPage'
 import { DashboardPage } from './pages/dashboard/DashboardPage'
 import { CustomerListPage } from './pages/dashboard/customers/CustomerListPage'
 import { CustomerFormPage } from './pages/dashboard/customers/CustomerFormPage'
+import { CustomerDetailPage } from './pages/dashboard/customers/CustomerDetailPage'
 import { TemplateListPage } from './pages/dashboard/templates/TemplateListPage'
 import { TemplateFormPage } from './pages/dashboard/templates/TemplateFormPage'
 import { TemplateDetailPage } from './pages/dashboard/templates/TemplateDetailPage'
@@ -27,6 +28,8 @@ function App() {
         <Route path={routes.dashboard} element={<RequireAuth><DashboardPage /></RequireAuth>} />
         <Route path={routes.customers} element={<RequireAuth><CustomerListPage /></RequireAuth>} />
         <Route path={routes.customerNew} element={<RequireAuth><CustomerFormPage /></RequireAuth>} />
+        <Route path={routes.customerDetail} element={<RequireAuth><CustomerDetailPage /></RequireAuth>} />
+        <Route path={routes.customerEdit} element={<RequireAuth><CustomerFormPage /></RequireAuth>} />
         <Route path={routes.templates} element={<RequireAuth><TemplateListPage /></RequireAuth>} />
         <Route path={routes.templateNew} element={<RequireAuth><TemplateFormPage /></RequireAuth>} />
         <Route path={routes.templateDetail} element={<RequireAuth><TemplateDetailPage /></RequireAuth>} />

+ 8 - 6
frontend/src/__tests__/App.test.tsx

@@ -22,6 +22,10 @@ vi.mock('../pages/dashboard/customers/CustomerFormPage', () => ({
   CustomerFormPage: () => <div>customer-form-new</div>,
 }))
 
+vi.mock('../pages/dashboard/customers/CustomerDetailPage', () => ({
+  CustomerDetailPage: () => <div>customer-detail-page</div>,
+}))
+
 vi.mock('../pages/dashboard/templates/TemplateListPage', () => ({
   TemplateListPage: () => <div>template-list-page</div>,
 }))
@@ -67,25 +71,23 @@ describe('App customer routes', () => {
     window.history.replaceState({}, '', '/zh')
   })
 
-  it('redirects removed customer detail routes back to locale root', async () => {
+  it('renders customer detail route', async () => {
     window.history.replaceState({}, '', '/zh/customers/123')
 
     render(<App />)
 
     await waitFor(() => {
-      expect(window.location.pathname).toBe('/zh')
+      expect(screen.getByText('customer-detail-page')).toBeInTheDocument()
     })
-    expect(screen.getByText('landing-page')).toBeInTheDocument()
   })
 
-  it('redirects removed customer edit routes back to locale root', async () => {
+  it('renders customer edit route with the shared form page', async () => {
     window.history.replaceState({}, '', '/zh/customers/123/edit')
 
     render(<App />)
 
     await waitFor(() => {
-      expect(window.location.pathname).toBe('/zh')
+      expect(screen.getByText('customer-form-new')).toBeInTheDocument()
     })
-    expect(screen.getByText('landing-page')).toBeInTheDocument()
   })
 })

+ 2 - 0
frontend/src/config/routes.ts

@@ -5,6 +5,8 @@ export const routes = {
   dashboard: '/:locale/dashboard',
   customers: '/:locale/customers',
   customerNew: '/:locale/customers/new',
+  customerDetail: '/:locale/customers/:customerId',
+  customerEdit: '/:locale/customers/:customerId/edit',
   templates: '/:locale/templates',
   templateNew: '/:locale/templates/new',
   templateDetail: '/:locale/templates/:templateId',

+ 3 - 0
frontend/src/i18n/locales/ja/translation.json

@@ -565,6 +565,9 @@
       "lead": "カラー退色の状況を確認し、前髪と顔周りレイヤーを調整する提案です。30日後フォロー対象です。",
       "action": "来店リマインドを送信"
     },
+    "states": {
+      "undeveloped": "現在のバージョンでは未開発です"
+    },
     "basic": {
       "badge": "基本情報",
       "title": "基本情報",

+ 3 - 0
frontend/src/i18n/locales/zh/translation.json

@@ -565,6 +565,9 @@
       "lead": "建议确认染后褪色情况,并调整刘海和脸周层次。该顾客为30天后跟进对象。",
       "action": "发送到店提醒"
     },
+    "states": {
+      "undeveloped": "当前版本暂未开发"
+    },
     "basic": {
       "badge": "基本资料",
       "title": "基本资料",

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

@@ -0,0 +1,69 @@
+import { useEffect, useState } from 'react'
+import { Link, useParams } from 'react-router-dom'
+import { Helmet } from 'react-helmet-async'
+import { getCustomerById, type CustomerDetailEntity } from '../../../services/api/customers'
+import { usePageContext } from '../../../shared/pageContext'
+import { DashboardLayout } from '../../../layouts/DashboardLayout'
+
+function getDisplayValue(value?: string) {
+  return value?.trim() ? value : '—'
+}
+
+export function CustomerDetailPage() {
+  const { locale, path, copy } = usePageContext()
+  const { customerId } = useParams()
+  const [customer, setCustomer] = useState<CustomerDetailEntity | null>(null)
+  const pageCopy = copy.customerDetailPage
+
+  useEffect(() => {
+    if (!customerId) return
+    getCustomerById(customerId).then(setCustomer).catch(() => setCustomer(null))
+  }, [customerId])
+
+  if (!customer) {
+    return null
+  }
+
+  return (
+    <DashboardLayout>
+      <Helmet>
+        <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
+        <title>{`Aitori Stylist | ${pageCopy.title}`}</title>
+        <meta name="description" content={pageCopy.description} />
+      </Helmet>
+
+      <section className="stylist-workbench-top ipad-top-space-margin mb-2">
+        <div className="container">
+          <div className="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4">
+            <Link to={path('/customers')} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">
+              <i className="feather icon-feather-arrow-left me-6px"></i>{pageCopy.back}
+            </Link>
+            <Link to={path(`/customers/${customer.id}/edit`)} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700">
+              <i className="feather icon-feather-edit-3 me-8px"></i>{pageCopy.edit}
+            </Link>
+          </div>
+
+          <div className="customer-detail-hero workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
+            <div className="customer-detail-profile">
+              <img src={customer.avatar || '/images/aitori-before-photo.png'} alt={customer.name} className="customer-detail-avatar" />
+              <div>
+                <span className="bg-base-color d-inline-block mb-15px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">{pageCopy.badge}</span>
+                <h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">{customer.name}</h1>
+                <p className="fs-18 lh-30 mb-0">{getDisplayValue(customer.namePronunciation)}</p>
+              </div>
+            </div>
+            <div className="customer-next-visit">
+              <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.nextVisit.badge}</span>
+              <strong>{getDisplayValue(customer.recommendVisitDate)}</strong>
+              <p>{pageCopy.nextVisit.lead.replace('{{date}}', getDisplayValue(customer.nextFollowDate))}</p>
+              <button type="button" disabled className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">
+                {pageCopy.nextVisit.action}
+              </button>
+              <p className="fs-14 text-medium-gray mt-10px mb-0">{pageCopy.states.undeveloped}</p>
+            </div>
+          </div>
+        </div>
+      </section>
+    </DashboardLayout>
+  )
+}

+ 9 - 1
frontend/src/pages/dashboard/customers/CustomerListPage.tsx

@@ -201,7 +201,7 @@ export function CustomerListPage() {
 
             <div className="customer-table">
               <div className="customer-row customer-head">
-                {pageCopy.tableHeaders.slice(0, 4).map((label: string) => <span key={label}>{label}</span>)}
+                {pageCopy.tableHeaders.map((label: string) => <span key={label}>{label}</span>)}
               </div>
               {items.length === 0 ? (
                 <p className="fs-15 text-medium-gray mb-0 mt-3">{copy.common.empty}</p>
@@ -221,6 +221,14 @@ export function CustomerListPage() {
                   <span>{customer.planDate || '—'}</span>
                   <span>{customer.trouble || '—'}</span>
                   <span><span className="workbench-badge">{getFollowStatusLabel(customer.followState, pageCopy)}</span></span>
+                  <span className="customer-row-action">
+                    <Link
+                      to={path(`/customers/${customer.id}`)}
+                      className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
+                    >
+                      {pageCopy.detail}
+                    </Link>
+                  </span>
                 </div>
               ))}
             </div>

+ 4 - 0
frontend/src/pages/dashboard/customers/__tests__/CustomerListPage.test.tsx

@@ -138,5 +138,9 @@ describe('CustomerListPage', () => {
     expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument()
     expect(screen.getByRole('button', { name: '2' })).toBeInTheDocument()
     expect(screen.getByRole('button', { name: '3' })).toBeInTheDocument()
+    expect(screen.getByRole('link', { name: '查看详情' })).toHaveAttribute(
+      'href',
+      '/zh/customers/customer-1',
+    )
   })
 })

+ 4 - 0
frontend/src/services/api/contracts.ts

@@ -31,6 +31,10 @@ export type CustomerEntity = {
   expectLook?: string
   followState?: 'NEED_FOLLOW_UP' | 'NO_NEED_FOLLOW_UP'
 }
+
+export type CustomerDetailEntity = CustomerEntity & {
+  visitFrequencyLabel: string
+}
 export type TemplateEntity = {
   id: string
   name: string

+ 6 - 0
frontend/src/services/api/customers.ts

@@ -1,11 +1,13 @@
 import { http } from '../http/client'
 import type {
   CustomerDashboardSummary,
+  CustomerDetailEntity,
   CustomerEntity,
   PaginatedCustomerList,
 } from './contracts'
 export type {
   CustomerDashboardSummary,
+  CustomerDetailEntity,
   CustomerEntity,
   PaginatedCustomerList,
 } from './contracts'
@@ -58,6 +60,10 @@ export async function createCustomer(input: CustomerUpsertInput): Promise<Custom
   return http.post<CustomerEntity>('/customers', input)
 }
 
+export async function getCustomerById(customerId: string): Promise<CustomerDetailEntity> {
+  return http.get<CustomerDetailEntity>(`/customers/${customerId}`)
+}
+
 export async function getCustomerDashboardSummary(): Promise<CustomerDashboardSummary> {
   return http.get<CustomerDashboardSummary>('/customers/summary')
 }