Kaynağa Gözat

feat: unify customer create and edit form

ChenYu 4 ay önce
ebeveyn
işleme
8528a2099f

+ 28 - 12
frontend/src/pages/dashboard/customers/CustomerFormPage.tsx

@@ -1,9 +1,10 @@
-import { useState, type FormEvent } from 'react'
-import { Link } from 'react-router-dom'
+import { useEffect, useState, type FormEvent } from 'react'
+import { Link, useParams } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
-import { createCustomer } from '../../../services/api/customers'
+import { createCustomer, getCustomerById, updateCustomer } from '../../../services/api/customers'
 import { uploadMedia } from '../../../services/api/media'
 import {
+  customerToFormValues,
   emptyCustomerForm,
   formValuesToPayload,
   type CustomerFormValues,
@@ -29,6 +30,7 @@ function getSubmitErrorMessage(error: unknown, locale: 'zh' | 'ja') {
 
 export function CustomerFormPage() {
   const { locale, navigate, path, copy } = usePageContext()
+  const { customerId } = useParams()
   const [form, setForm] = useState<CustomerFormValues>(emptyCustomerForm)
   const [submitting, setSubmitting] = useState(false)
   const [error, setError] = useState('')
@@ -37,7 +39,16 @@ export function CustomerFormPage() {
 
   const patch = (part: Partial<CustomerFormValues>) => setForm((current) => ({ ...current, ...part }))
   const pageCopy = copy.customerFormPage
-  const modeCopy = pageCopy.new
+  const isEditMode = Boolean(customerId)
+  const modeCopy = isEditMode ? pageCopy.edit : pageCopy.new
+
+  useEffect(() => {
+    if (!customerId) return
+
+    getCustomerById(customerId)
+      .then((customer) => setForm(customerToFormValues(customer)))
+      .catch(() => setError(locale === 'ja' ? '顧客情報の取得に失敗しました' : '获取顾客信息失败'))
+  }, [customerId, locale])
 
   const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
     event.preventDefault()
@@ -48,8 +59,13 @@ export function CustomerFormPage() {
       let avatar: string | undefined
       if (photoFile) avatar = (await uploadMedia(photoFile, 'avatar')).url
       const payload = formValuesToPayload(form, avatar)
-      await createCustomer(payload)
-      navigate(path('/customers'))
+      if (isEditMode && customerId) {
+        await updateCustomer(customerId, payload)
+        navigate(path(`/customers/${customerId}`))
+      } else {
+        await createCustomer(payload)
+        navigate(path('/customers'))
+      }
     } catch (err) {
       setError(getSubmitErrorMessage(err, locale))
     } finally {
@@ -67,9 +83,9 @@ export function CustomerFormPage() {
       <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>{modeCopy.back}</Link>
+            <Link to={isEditMode && customerId ? path(`/customers/${customerId}`) : 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>{modeCopy.back}</Link>
             <div className="d-flex flex-wrap gap-2">
-              <Link to={path('/customers')} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{copy.common.cancel}</Link>
+              <Link to={isEditMode && customerId ? path(`/customers/${customerId}`) : path('/customers')} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{copy.common.cancel}</Link>
               <button type="submit" form="customer-new-form" className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-user-plus me-10px"></i>{modeCopy.submit}</button>
             </div>
           </div>
@@ -77,7 +93,7 @@ export function CustomerFormPage() {
           <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
             <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">{modeCopy.badge}</span>
             <h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">{modeCopy.heading}</h1>
-            <p className="fs-18 lh-30 mb-0">{modeCopy.lead}</p>
+            <p className="fs-18 lh-30 mb-0">{modeCopy.lead.replace('{{name}}', form.name || '')}</p>
           </div>
         </div>
       </section>
@@ -228,8 +244,8 @@ export function CustomerFormPage() {
 
             <div className="customer-edit-actions workbench-panel border-radius-8px p-25px mt-4">
               <div>
-                <strong>{pageCopy.submit.titleNew}</strong>
-                <span>{pageCopy.submit.leadNew}</span>
+                <strong>{isEditMode ? pageCopy.submit.titleEdit : pageCopy.submit.titleNew}</strong>
+                <span>{isEditMode ? pageCopy.submit.leadEdit : pageCopy.submit.leadNew}</span>
               </div>
               <div className="customer-edit-action-panel">
                 {error ? (
@@ -238,7 +254,7 @@ export function CustomerFormPage() {
                   </div>
                 ) : null}
                 <div className="customer-edit-action-buttons">
-                  <Link to={path('/customers')} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{copy.common.cancel}</Link>
+                  <Link to={isEditMode && customerId ? path(`/customers/${customerId}`) : path('/customers')} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{copy.common.cancel}</Link>
                   <button type="submit" disabled={submitting} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className="feather icon-feather-user-plus me-10px"></i>{modeCopy.submit}</button>
                 </div>
               </div>

+ 63 - 2
frontend/src/pages/dashboard/customers/__tests__/CustomerFormPage.test.tsx

@@ -5,9 +5,11 @@ import type { ReactNode } from 'react'
 import { describe, expect, it, vi } from 'vitest'
 import { CustomerFormPage } from '../CustomerFormPage'
 
-const { uploadMedia, createCustomer, navigate } = vi.hoisted(() => ({
+const { uploadMedia, createCustomer, getCustomerById, updateCustomer, navigate } = vi.hoisted(() => ({
   uploadMedia: vi.fn(),
   createCustomer: vi.fn(),
+  getCustomerById: vi.fn(),
+  updateCustomer: vi.fn(),
   navigate: vi.fn(),
 }))
 
@@ -21,6 +23,8 @@ vi.mock('../../../../services/api/media', () => ({
 
 vi.mock('../../../../services/api/customers', () => ({
   createCustomer,
+  getCustomerById,
+  updateCustomer,
 }))
 
 vi.mock('../../../../shared/pageContext', () => ({
@@ -123,7 +127,7 @@ vi.mock('../../../../shared/pageContext', () => ({
           badge: 'badge',
           heading: '编辑顾客',
           lead: '编辑 {{name}}',
-          submit: '保存顾客',
+          submit: '保存编辑',
           back: '返回顾客详情',
         },
       },
@@ -212,4 +216,61 @@ describe('CustomerFormPage', () => {
       screen.getByRole('option', { name: '请选择到店目的' }),
     ).toBeInTheDocument()
   })
+
+  it('loads existing customer data in edit mode and submits update', 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',
+      firstSuggestHair: 'AUTO',
+      planDate: '2026-06-28',
+      visitPurpose: 'CONSULT_SIM',
+      trouble: '发量少',
+      consultContent: '希望轻盈',
+      expectLook: '自然层次',
+      avatar: 'https://example.com/avatar.png',
+      visitFrequencyLabel: '约 2 个月',
+    })
+    updateCustomer.mockResolvedValue({ id: '8' })
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/customers/8/edit']}>
+          <Routes>
+            <Route path="/:locale/customers/:customerId/edit" element={<CustomerFormPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    await waitFor(() => {
+      expect(screen.getByDisplayValue('Sato Misaki')).toBeInTheDocument()
+    })
+
+    fireEvent.change(screen.getByLabelText(/电话号码/i), { target: { value: '090-0000-0001' } })
+    fireEvent.click(screen.getAllByRole('button', { name: /保存编辑/i })[0])
+
+    await waitFor(() => {
+      expect(updateCustomer).toHaveBeenCalledWith(
+        '8',
+        expect.objectContaining({
+          name: 'Sato Misaki',
+          phone: '090-0000-0001',
+          nextFollowDate: '2026-07-15',
+          recommendVisitDate: '2026-07-22',
+        }),
+      )
+    })
+  })
 })

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

@@ -64,6 +64,13 @@ export async function getCustomerById(customerId: string): Promise<CustomerDetai
   return http.get<CustomerDetailEntity>(`/customers/${customerId}`)
 }
 
+export async function updateCustomer(
+  customerId: string,
+  input: CustomerUpsertInput,
+): Promise<CustomerDetailEntity> {
+  return http.patch<CustomerDetailEntity>(`/customers/${customerId}`, input)
+}
+
 export async function getCustomerDashboardSummary(): Promise<CustomerDashboardSummary> {
   return http.get<CustomerDashboardSummary>('/customers/summary')
 }