Bladeren bron

refactor customer module to align with database design

ChenYu 4 maanden geleden
bovenliggende
commit
c8e9a4af3a
26 gewijzigde bestanden met toevoegingen van 864 en 945 verwijderingen
  1. 39 2
      backend/docs/数据库设计.md
  2. 1 13
      backend/src/modules/customers/customers.controller.ts
  3. 15 51
      backend/src/modules/customers/customers.mapper.ts
  4. 68 34
      backend/src/modules/customers/customers.service.spec.ts
  5. 96 108
      backend/src/modules/customers/customers.service.ts
  6. 24 32
      backend/src/modules/customers/dto/customer-upsert.dto.ts
  7. 6 6
      backend/src/modules/customers/dto/list-customers-query.dto.ts
  8. 43 28
      backend/test/app.e2e-spec.ts
  9. 30 2
      frontend/public/css/aitori.css
  10. 1 5
      frontend/src/App.tsx
  11. 91 0
      frontend/src/__tests__/App.test.tsx
  12. 0 2
      frontend/src/config/routes.ts
  13. 13 10
      frontend/src/i18n/locales/ja/translation.json
  14. 13 10
      frontend/src/i18n/locales/zh/translation.json
  15. 0 266
      frontend/src/pages/dashboard/customers/CustomerDetailPage.tsx
  16. 116 157
      frontend/src/pages/dashboard/customers/CustomerFormPage.tsx
  17. 75 22
      frontend/src/pages/dashboard/customers/CustomerListPage.tsx
  18. 56 12
      frontend/src/pages/dashboard/customers/__tests__/CustomerFormPage.test.tsx
  19. 71 17
      frontend/src/pages/dashboard/customers/__tests__/CustomerListPage.test.tsx
  20. 2 4
      frontend/src/pages/dashboard/simulation/AftercareResultPage.tsx
  21. 1 1
      frontend/src/pages/dashboard/simulation/SimulationEditPage.tsx
  22. 2 3
      frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx
  23. 3 3
      frontend/src/services/api/__tests__/integration-adapters.test.ts
  24. 18 24
      frontend/src/services/api/contracts.ts
  25. 17 62
      frontend/src/services/api/customers.ts
  26. 63 71
      frontend/src/shared/customerForm.ts

+ 39 - 2
backend/docs/数据库设计.md

@@ -11,8 +11,42 @@ CREATE TABLE `hairstylist_user` (
   UNIQUE KEY `uk_email` (`email`,`is_deleted`) COMMENT '邮箱唯一(排除已删除)',
   KEY `idx_is_deleted` (`is_deleted`) COMMENT '删除状态索引'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='发型师用户信息表';
-nicode_ci COMMENT='发型模板制作异步任务表';
 
+CREATE TABLE `customer` (
+  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '顾客主键ID',
+  `hairstylist_id` BIGINT UNSIGNED NOT NULL COMMENT '所属发型师ID',
+  `name` VARCHAR(30) NOT NULL COMMENT '顾客姓名(必填)',
+  `name_pronunciation` VARCHAR(50) DEFAULT '' COMMENT '姓名读音',
+  `phone` VARCHAR(20) NOT NULL COMMENT '联系电话(必填)',
+  `email` VARCHAR(100) DEFAULT '' COMMENT '邮箱',
+  `birthday` DATE NULL COMMENT '生日',
+  `avatar` VARCHAR(500) DEFAULT '' COMMENT '顾客头像图片URL地址',
+  `expect_contact` ENUM('LINE','PHONE','EMAIL','NONE') NOT NULL DEFAULT 'NONE' COMMENT '期望联系方式:LINE/电话/邮箱/未设置',
+  `hair_length` ENUM('SHORT','MEDIUM','LONG','UNCONFIRM') NOT NULL DEFAULT 'UNCONFIRM' COMMENT '当前头发长度:短发/中长发/长发/未确认',
+  `hair_texture` ENUM('UNCONFIRM','THIN','NORMAL','HARD_CRUDE','CURLY') NOT NULL DEFAULT 'UNCONFIRM' COMMENT '发质:未确认/细/普通/粗硬/有自然卷',
+  `notice` TEXT NULL COMMENT '发型护理注意事项',
+  `allergy` VARCHAR(500) DEFAULT '' COMMENT '过敏史/过敏产品',
+  `inner_remark` TEXT NULL COMMENT '门店内部备注(仅内部查看)',
+  `next_follow_date` DATE NULL COMMENT '下次跟进日期',
+  `recommend_visit_date` DATE NULL COMMENT '推荐到店日',
+  `first_suggest_hair` ENUM('AUTO','LONG','MEDIUM','SHORT') NOT NULL DEFAULT 'AUTO' COMMENT '首次建议发型模板:自动推荐/长发/中长发/短发',
+  `plan_date` DATE NULL COMMENT '到店计划日',
+  `is_first_register` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否首次登记:0否,1是',
+  `visit_purpose` ENUM('CUT_DYE','CUT','DYE','CONSULT_SIM') NULL COMMENT '到店目的:剪发+染发/剪发/染发/模拟咨询',
+  `trouble` TEXT NULL COMMENT '发质/发型烦恼',
+  `consult_content` TEXT NULL COMMENT '详细咨询内容',
+  `expect_look` TEXT NULL COMMENT '期望发型形象描述',
+  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `is_deleted` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除 0正常 1删除',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_phone_del` (`phone`,`is_deleted`) COMMENT '手机号唯一,删除后可重录',
+  KEY `idx_hairstylist_is_deleted` (`hairstylist_id`,`is_deleted`) COMMENT '按发型师隔离顾客数据',
+  KEY `idx_is_deleted` (`is_deleted`),
+  KEY `idx_next_follow` (`next_follow_date`,`is_deleted`) COMMENT '跟进日期筛选索引',
+  KEY `idx_recommend_visit` (`recommend_visit_date`,`is_deleted`) COMMENT '推荐到店日期索引',
+  KEY `idx_plan_date` (`plan_date`,`is_deleted`) COMMENT '到店计划日筛选索引'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='顾客信息+咨询登记表(合并单表)';
 
 CREATE TABLE `customer_treatment_record` (
   `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '施术记录主键ID',
@@ -29,6 +63,7 @@ CREATE TABLE `customer_treatment_record` (
   KEY `idx_hairstylist_id` (`hairstylist_id`,`is_deleted`) COMMENT '查询发型师负责的所有施术记录',
   KEY `idx_treatment_time` (`treatment_time`,`is_deleted`) COMMENT '按施术时间筛选统计'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='顾客施术历史记录表';
+
 CREATE TABLE `ai_hair_simulation` (
   `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '模拟记录主键ID',
   `customer_id` BIGINT UNSIGNED NULL COMMENT '关联顾客ID,可为NULL(匿名无顾客)',
@@ -42,6 +77,7 @@ CREATE TABLE `ai_hair_simulation` (
   KEY `idx_customer_id` (`customer_id`,`is_deleted`) COMMENT '查询指定顾客所有模拟记录',
   KEY `idx_hairstylist_id` (`hairstylist_id`,`is_deleted`) COMMENT '查询发型师制作的模拟方案'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI发型模拟记录表';
+
 CREATE TABLE `ai_hair_simulation_task` (
   `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '任务主键ID',
   `simulation_id` BIGINT UNSIGNED NOT NULL COMMENT '关联AI发型模拟表id',
@@ -82,6 +118,7 @@ CREATE TABLE `hair_template` (
   KEY `idx_status` (`status`,`is_deleted`) COMMENT '按模板状态筛选',
   KEY `idx_category` (`category`,`is_deleted`) COMMENT '按发型分类筛选模板'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI发型模板表';
+
 CREATE TABLE `hair_template_make_task` (
   `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '本地任务自增主键',
   `total_task_id` VARCHAR(200) NOT NULL COMMENT '总任务唯一ID',
@@ -104,4 +141,4 @@ CREATE TABLE `hair_template_make_task` (
   UNIQUE KEY `uk_total_task_id` (`total_task_id`) COMMENT '总任务ID唯一防重复提交',
   KEY `idx_task_status` (`task_status`,`is_deleted`) COMMENT '筛选待执行、失败任务',
   KEY `idx_template_id` (`template_id`,`is_deleted`) COMMENT '根据模板查询制作任务记录'
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='发型模板制作异步任务表';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='发型模板制作异步任务表';

+ 1 - 13
backend/src/modules/customers/customers.controller.ts

@@ -2,8 +2,6 @@ import {
   Body,
   Controller,
   Get,
-  Param,
-  Patch,
   Post,
   Query,
   Req,
@@ -17,7 +15,7 @@ import { CustomersService } from './customers.service';
 
 type AuthenticatedRequest = Request & {
   user: {
-    stylistId: number;
+    stylistId: string | number;
   };
 };
 
@@ -36,18 +34,8 @@ export class CustomersController {
     return this.customersService.getSummary(BigInt(request.user.stylistId));
   }
 
-  @Get(':id')
-  getById(@Param('id') id: string) {
-    return this.customersService.getById(id);
-  }
-
   @Post()
   create(@Req() request: AuthenticatedRequest, @Body() body: CustomerUpsertDto) {
     return this.customersService.create(BigInt(request.user.stylistId), body);
   }
-
-  @Patch(':id')
-  update(@Param('id') id: string, @Body() body: CustomerUpsertDto) {
-    return this.customersService.update(id, body);
-  }
 }

+ 15 - 51
backend/src/modules/customers/customers.mapper.ts

@@ -1,50 +1,3 @@
-const HAIR_LENGTH_MAP: Record<string, 'SHORT' | 'MEDIUM' | 'LONG'> = {
-  Short: 'SHORT',
-  Medium: 'MEDIUM',
-  Long: 'LONG',
-};
-
-const CONTACT_TYPE_MAP: Record<string, 'LINE' | 'PHONE' | 'EMAIL'> = {
-  LINE: 'LINE',
-  Phone: 'PHONE',
-  Email: 'EMAIL',
-};
-
-const LINE_STATUS_MAP: Record<string, 'LINKED' | 'UNLINKED' | 'INVITED' | 'KEEP'> = {
-  已关联: 'LINKED',
-  已連携: 'LINKED',
-  未关联: 'UNLINKED',
-  未連携: 'UNLINKED',
-  邀请已发送: 'INVITED',
-  保留: 'KEEP',
-};
-
-const FOLLOW_STATUS_MAP: Record<string, 'NONE' | 'NEED_FOLLOW_UP' | 'SENT'> = {
-  '': 'NONE',
-  NONE: 'NONE',
-  NEED_FOLLOW_UP: 'NEED_FOLLOW_UP',
-  SENT: 'SENT',
-  需跟进: 'NEED_FOLLOW_UP',
-  已发送: 'SENT',
-  发送计划: 'NEED_FOLLOW_UP',
-};
-
-export function normalizeHairLength(value?: string) {
-  return value ? HAIR_LENGTH_MAP[value] ?? null : null;
-}
-
-export function normalizeContactType(value?: string) {
-  return value ? CONTACT_TYPE_MAP[value] ?? 'LINE' : 'LINE';
-}
-
-export function normalizeLineStatus(value?: string) {
-  return value ? LINE_STATUS_MAP[value] ?? null : null;
-}
-
-export function normalizeFollowStatus(value?: string) {
-  return value == null ? 'NONE' : FOLLOW_STATUS_MAP[value] ?? 'NONE';
-}
-
 export function parseDateOnly(value?: string) {
   return value ? new Date(`${value}T00:00:00.000Z`) : null;
 }
@@ -53,10 +6,21 @@ export function formatDateOnly(value: Date | null) {
   return value ? value.toISOString().slice(0, 10) : undefined;
 }
 
-export function toFrontendHairLength(value: string | null) {
-  if (!value) {
-    return undefined;
+export function toBooleanFlag(value?: boolean) {
+  return value ? 1 : 0;
+}
+
+export function toFollowState(
+  nextFollowDate: Date | null,
+  now = new Date(),
+): 'NEED_FOLLOW_UP' | 'NO_NEED_FOLLOW_UP' {
+  if (!nextFollowDate) {
+    return 'NO_NEED_FOLLOW_UP';
   }
 
-  return value[0] + value.slice(1).toLowerCase();
+  const startOfToday = new Date(
+    Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
+  );
+
+  return nextFollowDate <= startOfToday ? 'NEED_FOLLOW_UP' : 'NO_NEED_FOLLOW_UP';
 }

+ 68 - 34
backend/src/modules/customers/customers.service.spec.ts

@@ -41,84 +41,103 @@ describe('CustomersService', () => {
     service = moduleRef.get(CustomersService);
   });
 
-  it('uses the default avatar url when image is omitted', async () => {
+  it('creates a customer using the source-of-truth fields', async () => {
     prisma.customer.create.mockResolvedValue({
       id: BigInt(11),
       hairstylistId: BigInt(1),
       name: 'Sato Misaki',
-      kana: '',
+      namePronunciation: 'さとう みさき',
       phone: '090-0000-0000',
       email: '',
       birthday: null,
-      avatarUrl:
+      avatar:
         'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
-      contactType: 'LINE',
-      lineStatus: null,
-      lastVisitDate: null,
-      nextVisitDate: null,
-      visitCycle: '',
+      expectContact: 'LINE',
       hairLength: 'MEDIUM',
-      hairType: '',
-      hairCondition: '',
-      concern: null,
-      desiredImage: null,
-      formulaNote: null,
-      allergyNote: null,
-      privateNote: null,
-      followStatus: 'NONE',
-      followTiming: '',
-      followMessage: null,
+      hairTexture: 'NORMAL',
+      notice: null,
+      allergy: '',
+      innerRemark: null,
+      nextFollowDate: new Date('2026-07-12T00:00:00.000Z'),
+      recommendVisitDate: new Date('2026-07-20T00:00:00.000Z'),
+      firstSuggestHair: 'AUTO',
+      planDate: new Date('2026-06-28T00:00:00.000Z'),
+      isFirstRegister: 1,
+      visitPurpose: 'CONSULT_SIM',
+      trouble: '发量少',
+      consultContent: '希望有轻盈感',
+      expectLook: '自然层次感',
       createTime: new Date('2026-06-28T00:00:00.000Z'),
       updateTime: new Date('2026-06-28T00:00:00.000Z'),
       isDeleted: 0,
     });
 
-    await service.create(BigInt(1), {
-      name: 'Sato Misaki',
-      phone: '090-0000-0000',
-      hairLength: 'Medium',
-      contact: 'LINE',
-    });
+    await service.create(
+      BigInt(1),
+      {
+        name: 'Sato Misaki',
+        namePronunciation: 'さとう みさき',
+        phone: '090-0000-0000',
+        expectContact: 'LINE',
+        hairLength: 'MEDIUM',
+        hairTexture: 'NORMAL',
+        nextFollowDate: '2026-07-12',
+        recommendVisitDate: '2026-07-20',
+        planDate: '2026-06-28',
+        visitPurpose: 'CONSULT_SIM',
+        trouble: '发量少',
+        consultContent: '希望有轻盈感',
+        expectLook: '自然层次感',
+      } as never,
+    );
 
     expect(prisma.customer.create).toHaveBeenCalledWith(
       expect.objectContaining({
         data: expect.objectContaining({
           hairstylistId: BigInt(1),
-          avatarUrl:
+          namePronunciation: 'さとう みさき',
+          avatar:
             'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
+          expectContact: 'LINE',
+          hairLength: 'MEDIUM',
+          hairTexture: 'NORMAL',
+          nextFollowDate: new Date('2026-07-12T00:00:00.000Z'),
+          recommendVisitDate: new Date('2026-07-20T00:00:00.000Z'),
+          planDate: new Date('2026-06-28T00:00:00.000Z'),
+          isFirstRegister: 1,
+          visitPurpose: 'CONSULT_SIM',
         }),
       }),
     );
   });
 
-  it('maps list filters into prisma query conditions', async () => {
+  it('maps visit filters to plan_date and follow filters to next_follow_date', async () => {
     prisma.customer.findMany.mockResolvedValue([]);
     prisma.customer.count.mockResolvedValue(0);
 
     await service.list(BigInt(1), {
-      page: 2,
+      page: 1,
       pageSize: 20,
-      q: '090',
+      q: 'Misaki',
       visitStatus: 'TODAY',
       followStatus: 'NEED_FOLLOW_UP',
-      hairLength: 'LONG',
+      hairLength: 'MEDIUM',
     });
 
     expect(prisma.customer.findMany).toHaveBeenCalledWith(
       expect.objectContaining({
-        skip: 20,
-        take: 20,
         where: expect.objectContaining({
           hairstylistId: BigInt(1),
           isDeleted: 0,
-          followStatus: 'NEED_FOLLOW_UP',
-          hairLength: 'LONG',
+          hairLength: 'MEDIUM',
+          planDate: expect.any(Object),
+          nextFollowDate: expect.any(Object),
         }),
       }),
     );
   });
 
-  it('counts summary values from customer and ai simulation data', async () => {
+  it('counts summary values using plan_date and next_follow_date', async () => {
     prisma.customer.count
       .mockResolvedValueOnce(12)
       .mockResolvedValueOnce(3)
@@ -137,5 +156,20 @@ describe('CustomersService', () => {
       followUp: 4,
       simulated: 2,
     });
+
+    expect(prisma.customer.count).toHaveBeenNthCalledWith(2, {
+      where: expect.objectContaining({
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+        planDate: expect.any(Object),
+      }),
+    });
+    expect(prisma.customer.count).toHaveBeenNthCalledWith(3, {
+      where: expect.objectContaining({
+        hairstylistId: BigInt(1),
+        isDeleted: 0,
+        nextFollowDate: expect.any(Object),
+      }),
+    });
   });
 });

+ 96 - 108
backend/src/modules/customers/customers.service.ts

@@ -1,4 +1,4 @@
-import { Injectable, NotImplementedException } from '@nestjs/common';
+import { Injectable } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import { Prisma } from '@prisma/client';
 import { PrismaService } from '../../prisma/prisma.service';
@@ -12,14 +12,35 @@ import { CustomerSummaryDto } from './dto/customer-summary.dto';
 import { ListCustomersQueryDto } from './dto/list-customers-query.dto';
 import {
   formatDateOnly,
-  normalizeContactType,
-  normalizeFollowStatus,
-  normalizeHairLength,
-  normalizeLineStatus,
   parseDateOnly,
-  toFrontendHairLength,
+  toFollowState,
 } from './customers.mapper';
 
+type CustomerRecord = {
+  id: bigint;
+  name: string;
+  namePronunciation: string;
+  phone: string;
+  email: string;
+  birthday: Date | null;
+  avatar: string;
+  expectContact: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE';
+  hairLength: 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM';
+  hairTexture: 'UNCONFIRM' | 'THIN' | 'NORMAL' | 'HARD_CRUDE' | 'CURLY';
+  notice: string | null;
+  allergy: string;
+  innerRemark: string | null;
+  nextFollowDate: Date | null;
+  recommendVisitDate: Date | null;
+  firstSuggestHair: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT';
+  planDate: Date | null;
+  isFirstRegister: number;
+  visitPurpose: 'CUT_DYE' | 'CUT' | 'DYE' | 'CONSULT_SIM' | null;
+  trouble: string | null;
+  consultContent: string | null;
+  expectLook: string | null;
+};
+
 @Injectable()
 export class CustomersService {
   constructor(
@@ -49,75 +70,56 @@ export class CustomersService {
     const total = await this.prisma.customer.count({ where });
 
     return {
-      items: items.map((item) => this.toCustomerEntity(item)),
+      items: items.map((item) => this.toCustomerEntity(item as unknown as CustomerRecord)),
       total,
       page,
       pageSize,
     };
   }
 
-  async getById(_id: string): Promise<never> {
-    throw new NotImplementedException('Customer detail is not implemented yet');
-  }
-
   async create(
     stylistId: bigint,
     body: CustomerUpsertDto,
   ): Promise<Record<string, unknown>> {
-    const avatarUrl =
-      body.image?.trim() ||
-      this.configService.getOrThrow<string>('customer.defaultAvatarUrl');
-
     const created = await this.prisma.customer.create({
       data: {
         hairstylistId: stylistId,
         name: body.name.trim(),
-        kana: body.kana?.trim() ?? '',
+        namePronunciation: body.namePronunciation?.trim() ?? '',
         phone: body.phone.trim(),
         email: body.email?.trim() ?? '',
         birthday: parseDateOnly(body.birthday),
-        avatarUrl,
-        contactType: normalizeContactType(body.contact),
-        lineStatus: normalizeLineStatus(body.lineStatus),
-        lastVisitDate: parseDateOnly(body.lastVisit),
-        nextVisitDate: parseDateOnly(body.nextVisit),
-        visitCycle: body.visitCycle?.trim() ?? '',
-        hairLength: normalizeHairLength(body.hairLength),
-        hairType: body.hairType?.trim() ?? '',
-        hairCondition: body.hairCondition?.trim() ?? '',
-        concern: body.concern?.trim() || null,
-        desiredImage: body.desiredImage?.trim() || null,
-        formulaNote: body.formulaNote?.trim() || null,
-        allergyNote: body.allergyNote?.trim() || null,
-        privateNote: body.privateNote?.trim() || null,
-        followStatus: normalizeFollowStatus(body.followStatus),
-        followTiming: body.followTiming?.trim() ?? '',
-        followMessage: body.followMessage?.trim() || null,
+        avatar:
+          body.avatar?.trim() ||
+          this.configService.getOrThrow<string>('customer.defaultAvatarUrl'),
+        expectContact: body.expectContact ?? 'NONE',
+        hairLength: body.hairLength ?? 'UNCONFIRM',
+        hairTexture: body.hairTexture ?? 'UNCONFIRM',
+        notice: body.notice?.trim() || null,
+        allergy: body.allergy?.trim() ?? '',
+        innerRemark: body.innerRemark?.trim() || null,
+        nextFollowDate: parseDateOnly(body.nextFollowDate),
+        recommendVisitDate: parseDateOnly(body.recommendVisitDate),
+        firstSuggestHair: body.firstSuggestHair ?? 'AUTO',
+        planDate: parseDateOnly(body.planDate),
+        isFirstRegister: 1,
+        visitPurpose: body.visitPurpose ?? null,
+        trouble: body.trouble?.trim() || null,
+        consultContent: body.consultContent?.trim() || null,
+        expectLook: body.expectLook?.trim() || null,
       },
     });
 
-    return this.toCustomerEntity(created);
-  }
-
-  async update(_id: string, _body: CustomerUpsertDto): Promise<never> {
-    throw new NotImplementedException('Customer update is not implemented yet');
+    return this.toCustomerEntity(created as unknown as CustomerRecord);
   }
 
   async getSummary(stylistId: bigint): Promise<CustomerSummaryDto> {
     const today = new Date();
     const startOfDay = new Date(
-      Date.UTC(
-        today.getUTCFullYear(),
-        today.getUTCMonth(),
-        today.getUTCDate(),
-      ),
+      Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()),
     );
     const endOfDay = new Date(
-      Date.UTC(
-        today.getUTCFullYear(),
-        today.getUTCMonth(),
-        today.getUTCDate() + 1,
-      ),
+      Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() + 1),
     );
 
     const [total, todayCount, followUp, simulations] = await Promise.all([
@@ -128,14 +130,14 @@ export class CustomersService {
         where: {
           hairstylistId: stylistId,
           isDeleted: 0,
-          nextVisitDate: { gte: startOfDay, lt: endOfDay },
+          planDate: { gte: startOfDay, lt: endOfDay },
         },
       }),
       this.prisma.customer.count({
         where: {
           hairstylistId: stylistId,
           isDeleted: 0,
-          followStatus: 'NEED_FOLLOW_UP',
+          nextFollowDate: { not: null, lte: startOfDay },
         },
       }),
       this.prisma.aiHairSimulation.findMany({
@@ -168,20 +170,18 @@ export class CustomersService {
       hairstylistId: stylistId,
       isDeleted: 0,
     };
+    const searchTerm = query.q?.trim();
 
-    if (query.q?.trim()) {
+    if (searchTerm) {
       where.OR = [
-        { name: { contains: query.q.trim() } },
-        { phone: { contains: query.q.trim() } },
-        { concern: { contains: query.q.trim() } },
-        { privateNote: { contains: query.q.trim() } },
+        { name: { contains: searchTerm } },
+        { phone: { contains: searchTerm } },
+        { innerRemark: { contains: searchTerm } },
+        { trouble: { contains: searchTerm } },
+        { consultContent: { contains: searchTerm } },
       ];
     }
 
-    if (query.followStatus && query.followStatus !== 'ALL') {
-      where.followStatus = query.followStatus;
-    }
-
     if (query.hairLength && query.hairLength !== 'ALL') {
       where.hairLength = query.hairLength;
     }
@@ -199,69 +199,57 @@ export class CustomersService {
     startOfWeek.setUTCDate(startOfWeek.getUTCDate() - diffToMonday);
     const endOfWeek = new Date(startOfWeek);
     endOfWeek.setUTCDate(endOfWeek.getUTCDate() + 7);
-    const thirtyDaysAgo = new Date(startOfToday);
-    thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 30);
 
     if (query.visitStatus === 'TODAY') {
-      where.nextVisitDate = { gte: startOfToday, lt: endOfToday };
+      where.planDate = { gte: startOfToday, lt: endOfToday };
     } else if (query.visitStatus === 'THIS_WEEK') {
-      where.nextVisitDate = { gte: startOfWeek, lt: endOfWeek };
-    } else if (query.visitStatus === 'OVER_30_DAYS_NO_VISIT') {
-      where.lastVisitDate = { lt: thirtyDaysAgo };
+      where.planDate = { gte: startOfWeek, lt: endOfWeek };
+    }
+
+    if (query.followStatus === 'NEED_FOLLOW_UP') {
+      where.nextFollowDate = { not: null, lte: startOfToday };
+    } else if (query.followStatus === 'NO_NEED_FOLLOW_UP') {
+      const noNeedFollowUp: Prisma.CustomerWhereInput = {
+        OR: [{ nextFollowDate: null }, { nextFollowDate: { gt: startOfToday } }],
+      };
+
+      if (where.OR) {
+        const searchConditions = where.OR;
+        delete where.OR;
+        where.AND = [{ OR: searchConditions }, noNeedFollowUp];
+      } else {
+        where.OR = noNeedFollowUp.OR;
+      }
     }
 
     return where;
   }
 
-  private toCustomerEntity(customer: {
-    id: bigint;
-    name: string;
-    kana: string;
-    phone: string;
-    email: string;
-    birthday: Date | null;
-    avatarUrl: string;
-    contactType: string;
-    lineStatus: string | null;
-    lastVisitDate: Date | null;
-    nextVisitDate: Date | null;
-    visitCycle: string;
-    hairLength: string | null;
-    hairType: string;
-    hairCondition: string;
-    concern: string | null;
-    desiredImage: string | null;
-    formulaNote: string | null;
-    allergyNote: string | null;
-    privateNote: string | null;
-    followStatus: string;
-    followTiming: string;
-    followMessage: string | null;
-  }) {
+  private toCustomerEntity(customer: CustomerRecord) {
     return {
       id: customer.id.toString(),
       name: customer.name,
-      kana: customer.kana || undefined,
+      namePronunciation: customer.namePronunciation || undefined,
       phone: customer.phone,
       email: customer.email || undefined,
       birthday: formatDateOnly(customer.birthday),
-      image: customer.avatarUrl,
-      contact: customer.contactType,
-      lineStatus: customer.lineStatus ?? undefined,
-      lastVisit: formatDateOnly(customer.lastVisitDate),
-      nextVisit: formatDateOnly(customer.nextVisitDate),
-      visitCycle: customer.visitCycle || undefined,
-      hairLength: toFrontendHairLength(customer.hairLength),
-      hairType: customer.hairType || undefined,
-      hairCondition: customer.hairCondition || undefined,
-      concern: customer.concern ?? undefined,
-      desiredImage: customer.desiredImage ?? undefined,
-      formulaNote: customer.formulaNote ?? undefined,
-      allergyNote: customer.allergyNote ?? undefined,
-      privateNote: customer.privateNote ?? undefined,
-      followStatus: customer.followStatus,
-      followTiming: customer.followTiming || undefined,
-      followMessage: customer.followMessage ?? undefined,
+      avatar: customer.avatar,
+      expectContact: customer.expectContact,
+      hairLength: customer.hairLength,
+      hairTexture: customer.hairTexture,
+      notice: customer.notice ?? undefined,
+      allergy: customer.allergy || undefined,
+      innerRemark: customer.innerRemark ?? undefined,
+      nextFollowDate: formatDateOnly(customer.nextFollowDate),
+      recommendVisitDate: formatDateOnly(customer.recommendVisitDate),
+      firstSuggestHair: customer.firstSuggestHair,
+      planDate: formatDateOnly(customer.planDate),
+      isFirstRegister: customer.isFirstRegister === 1,
+      visitPurpose: customer.visitPurpose ?? undefined,
+      trouble: customer.trouble ?? undefined,
+      consultContent: customer.consultContent ?? undefined,
+      expectLook: customer.expectLook ?? undefined,
+      followState: toFollowState(customer.nextFollowDate),
     };
   }
 }

+ 24 - 32
backend/src/modules/customers/dto/customer-upsert.dto.ts

@@ -1,15 +1,15 @@
-import { IsEmail, IsOptional, IsString } from 'class-validator';
+import { IsEmail, IsIn, IsOptional, IsString } from 'class-validator';
 
 export class CustomerUpsertDto {
   @IsString()
   name!: string;
 
+  @IsOptional()
   @IsString()
-  phone!: string;
+  namePronunciation?: string;
 
-  @IsOptional()
   @IsString()
-  kana?: string;
+  phone!: string;
 
   @IsOptional()
   @IsEmail()
@@ -21,69 +21,61 @@ export class CustomerUpsertDto {
 
   @IsOptional()
   @IsString()
-  image?: string;
+  avatar?: string;
 
   @IsOptional()
-  @IsString()
-  contact?: string;
+  @IsIn(['LINE', 'PHONE', 'EMAIL', 'NONE'])
+  expectContact?: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE';
 
   @IsOptional()
-  @IsString()
-  lineStatus?: string;
+  @IsIn(['SHORT', 'MEDIUM', 'LONG', 'UNCONFIRM'])
+  hairLength?: 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM';
 
   @IsOptional()
-  @IsString()
-  lastVisit?: string;
+  @IsIn(['UNCONFIRM', 'THIN', 'NORMAL', 'HARD_CRUDE', 'CURLY'])
+  hairTexture?: 'UNCONFIRM' | 'THIN' | 'NORMAL' | 'HARD_CRUDE' | 'CURLY';
 
   @IsOptional()
   @IsString()
-  nextVisit?: string;
+  notice?: string;
 
   @IsOptional()
   @IsString()
-  visitCycle?: string;
+  allergy?: string;
 
   @IsOptional()
   @IsString()
-  hairLength?: string;
+  innerRemark?: string;
 
   @IsOptional()
   @IsString()
-  hairType?: string;
+  nextFollowDate?: string;
 
   @IsOptional()
   @IsString()
-  hairCondition?: string;
+  recommendVisitDate?: string;
 
   @IsOptional()
-  @IsString()
-  concern?: string;
+  @IsIn(['AUTO', 'LONG', 'MEDIUM', 'SHORT'])
+  firstSuggestHair?: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT';
 
   @IsOptional()
   @IsString()
-  desiredImage?: string;
+  planDate?: string;
 
   @IsOptional()
-  @IsString()
-  formulaNote?: string;
-
-  @IsOptional()
-  @IsString()
-  allergyNote?: string;
-
-  @IsOptional()
-  @IsString()
-  privateNote?: string;
+  @IsIn(['CUT_DYE', 'CUT', 'DYE', 'CONSULT_SIM'])
+  visitPurpose?: 'CUT_DYE' | 'CUT' | 'DYE' | 'CONSULT_SIM';
 
   @IsOptional()
   @IsString()
-  followStatus?: string;
+  trouble?: string;
 
   @IsOptional()
   @IsString()
-  followTiming?: string;
+  consultContent?: string;
 
   @IsOptional()
   @IsString()
-  followMessage?: string;
+  expectLook?: string;
 }

+ 6 - 6
backend/src/modules/customers/dto/list-customers-query.dto.ts

@@ -20,14 +20,14 @@ export class ListCustomersQueryDto {
   q?: string;
 
   @IsOptional()
-  @IsIn(['ALL', 'TODAY', 'THIS_WEEK', 'OVER_30_DAYS_NO_VISIT'])
-  visitStatus?: 'ALL' | 'TODAY' | 'THIS_WEEK' | 'OVER_30_DAYS_NO_VISIT';
+  @IsIn(['ALL', 'TODAY', 'THIS_WEEK'])
+  visitStatus?: 'ALL' | 'TODAY' | 'THIS_WEEK';
 
   @IsOptional()
-  @IsIn(['ALL', 'NEED_FOLLOW_UP', 'SENT'])
-  followStatus?: 'ALL' | 'NEED_FOLLOW_UP' | 'SENT';
+  @IsIn(['ALL', 'NEED_FOLLOW_UP', 'NO_NEED_FOLLOW_UP'])
+  followStatus?: 'ALL' | 'NEED_FOLLOW_UP' | 'NO_NEED_FOLLOW_UP';
 
   @IsOptional()
-  @IsIn(['ALL', 'SHORT', 'MEDIUM', 'LONG'])
-  hairLength?: 'ALL' | 'SHORT' | 'MEDIUM' | 'LONG';
+  @IsIn(['ALL', 'SHORT', 'MEDIUM', 'LONG', 'UNCONFIRM'])
+  hairLength?: 'ALL' | 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM';
 }

+ 43 - 28
backend/test/app.e2e-spec.ts

@@ -2,12 +2,13 @@ import { INestApplication } from '@nestjs/common';
 import { JwtService } from '@nestjs/jwt';
 import { Test } from '@nestjs/testing';
 import * as request from 'supertest';
-import { AppModule } from '../src/app.module';
 import { configureApp } from '../src/app.setup';
 import { PrismaService } from '../src/prisma/prisma.service';
 import { GcsStorageDriver } from '../src/modules/storage/gcs-storage.driver';
 import { STORAGE_DRIVER } from '../src/modules/storage/storage.constants';
 
+const { AppModule } = require('../src/app.module');
+
 describe('App (e2e)', () => {
   let app: INestApplication;
   let jwtService: JwtService;
@@ -17,6 +18,7 @@ describe('App (e2e)', () => {
   async function signToken() {
     return jwtService.signAsync({
       sub: 1,
+      stylistId: '1',
       email: 'stylist@example.com',
       username: 'Ogawa',
     });
@@ -53,7 +55,7 @@ describe('App (e2e)', () => {
     await prisma.customer.deleteMany({
       where: {
         name: {
-          startsWith: 'app-e2e-customer-',
+          startsWith: 'e2e-',
         },
       },
     });
@@ -132,51 +134,63 @@ describe('App (e2e)', () => {
     });
   });
 
-  it('/api/v1/customers (POST) creates a customer with default avatar', async () => {
+  it('/api/v1/customers (POST) creates a customer using the redesigned fields', async () => {
     const token = await signToken();
     const uniqueSuffix = Date.now();
+    const customerName = `e2e-c-${uniqueSuffix}`;
 
     const response = await request(app.getHttpServer())
       .post('/api/v1/customers')
-      .set('Authorization', token)
+      .set('Authorization', `Bearer ${token}`)
       .send({
-        name: `app-e2e-customer-${uniqueSuffix}`,
+        name: customerName,
         phone: `090-${String(uniqueSuffix).slice(-4)}-0000`,
-        hairLength: 'Medium',
-        followStatus: 'NEED_FOLLOW_UP',
-        nextVisit: '2026-06-28',
+        namePronunciation: 'さとう みさき',
+        expectContact: 'LINE',
+        hairLength: 'MEDIUM',
+        hairTexture: 'NORMAL',
+        planDate: '2026-06-28',
+        nextFollowDate: '2026-07-12',
+        visitPurpose: 'CONSULT_SIM',
+        trouble: '发量少',
+        consultContent: '希望有轻盈感',
+        expectLook: '自然层次感',
       })
       .expect(201);
 
     expect(response.body.ok).toBe(true);
-    expect(response.body.data.name).toBe(`app-e2e-customer-${uniqueSuffix}`);
-    expect(response.body.data.image).toBe(
+    expect(response.body.data.name).toBe(customerName);
+    expect(response.body.data.namePronunciation).toBe('さとう みさき');
+    expect(response.body.data.avatar).toBe(
       'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
     );
   });
 
-  it('/api/v1/customers (GET) returns paginated customers', async () => {
+  it('/api/v1/customers (GET) returns paginated customers using plan_date semantics', async () => {
     const token = await signToken();
     const uniqueSuffix = `${Date.now()}-list`;
+    const customerName = `e2e-l-${uniqueSuffix}`;
 
     await prisma.customer.create({
       data: {
         hairstylistId: BigInt(1),
-        name: `app-e2e-customer-${uniqueSuffix}`,
-        kana: '',
+        name: customerName,
+        namePronunciation: '',
         phone: `090-${String(Date.now()).slice(-4)}-1111`,
         email: '',
-        avatarUrl:
+        avatar:
           'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
-        contactType: 'LINE',
+        expectContact: 'LINE',
         hairLength: 'MEDIUM',
-        followStatus: 'NEED_FOLLOW_UP',
-      },
+        hairTexture: 'NORMAL',
+        planDate: new Date(),
+        nextFollowDate: new Date(),
+      } as never,
     });
 
     const response = await request(app.getHttpServer())
-      .get('/api/v1/customers?page=1&pageSize=20&q=app-e2e-customer-&hairLength=MEDIUM&followStatus=NEED_FOLLOW_UP')
-      .set('Authorization', token)
+      .get(`/api/v1/customers?page=1&pageSize=20&q=e2e-l-&visitStatus=TODAY&hairLength=MEDIUM`)
+      .set('Authorization', `Bearer ${token}`)
       .expect(200);
 
     expect(response.body.ok).toBe(true);
@@ -191,33 +205,34 @@ describe('App (e2e)', () => {
   it('/api/v1/customers/summary (GET) returns customer summary', async () => {
     const token = await signToken();
     const uniqueSuffix = `${Date.now()}-summary`;
+    const customerName = `e2e-s-${uniqueSuffix}`;
     const customer = await prisma.customer.create({
       data: {
         hairstylistId: BigInt(1),
-        name: `app-e2e-customer-${uniqueSuffix}`,
-        kana: '',
+        name: customerName,
+        namePronunciation: '',
         phone: `090-${String(Date.now()).slice(-4)}-2222`,
         email: '',
-        avatarUrl:
+        avatar:
           'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
-        contactType: 'LINE',
-        nextVisitDate: new Date(),
-        followStatus: 'NEED_FOLLOW_UP',
-      },
+        expectContact: 'LINE',
+        planDate: new Date(),
+        nextFollowDate: new Date(),
+      } as never,
     });
 
     await prisma.aiHairSimulation.create({
       data: {
         customerId: customer.id,
         hairstylistId: BigInt(1),
-        originImage: customer.avatarUrl,
+        originImage: (customer as unknown as { avatar: string }).avatar,
         simulateRemark: `app-e2e-customer-summary-${uniqueSuffix}`,
       },
     });
 
     const response = await request(app.getHttpServer())
       .get('/api/v1/customers/summary')
-      .set('Authorization', token)
+      .set('Authorization', `Bearer ${token}`)
       .expect(200);
 
     expect(response.body.ok).toBe(true);

+ 30 - 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: 920px;
+    min-width: 760px;
     display: grid;
-    grid-template-columns: 260px 130px 220px 120px 86px;
+    grid-template-columns: 260px 140px minmax(180px, 1fr) 120px;
     gap: 16px;
     align-items: center;
     padding: 16px 0;
@@ -672,6 +672,26 @@ textarea.customer-edit-control {
     gap: 24px;
     background-color: #fff;
 }
+.customer-edit-action-panel {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-end;
+    gap: 12px;
+}
+.customer-edit-action-buttons {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: flex-end;
+    gap: 8px;
+}
+.customer-edit-action-feedback {
+    max-width: 420px;
+    color: #d92d20;
+    font-size: 14px;
+    line-height: 1.6;
+    text-align: right;
+}
 .customer-edit-actions strong,
 .customer-edit-actions span {
     display: block;
@@ -1670,6 +1690,14 @@ footer ul li {
         align-items: flex-start;
         flex-direction: column;
     }
+    .customer-edit-action-panel,
+    .customer-edit-action-buttons,
+    .customer-edit-action-feedback {
+        align-items: flex-start;
+        justify-content: flex-start;
+        text-align: left;
+        width: 100%;
+    }
     .template-grid {
         grid-template-columns: 1fr;
     }

+ 1 - 5
frontend/src/App.tsx

@@ -1,4 +1,3 @@
-import React from 'react'
 import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
 import { routes } from './config/routes'
 import { DEFAULT_LOCALE } from './shared/pageContext'
@@ -7,7 +6,6 @@ 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'
@@ -28,9 +26,7 @@ function App() {
         <Route path={routes.login} element={<LoginPage />} />
         <Route path={routes.dashboard} element={<RequireAuth><DashboardPage /></RequireAuth>} />
         <Route path={routes.customers} element={<RequireAuth><CustomerListPage /></RequireAuth>} />
-        <Route path={routes.customerNew} element={<RequireAuth><CustomerFormPage mode="new" /></RequireAuth>} />
-        <Route path={routes.customerDetail} element={<RequireAuth><CustomerDetailPage /></RequireAuth>} />
-        <Route path={routes.customerEdit} element={<RequireAuth><CustomerFormPage mode="edit" /></RequireAuth>} />
+        <Route path={routes.customerNew} 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>} />

+ 91 - 0
frontend/src/__tests__/App.test.tsx

@@ -0,0 +1,91 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi, beforeEach } from 'vitest'
+import App from '../App'
+
+vi.mock('../pages/public/LandingPage', () => ({
+  LandingPage: () => <div>landing-page</div>,
+}))
+
+vi.mock('../pages/public/LoginPage', () => ({
+  LoginPage: () => <div>login-page</div>,
+}))
+
+vi.mock('../pages/dashboard/DashboardPage', () => ({
+  DashboardPage: () => <div>dashboard-page</div>,
+}))
+
+vi.mock('../pages/dashboard/customers/CustomerListPage', () => ({
+  CustomerListPage: () => <div>customer-list-page</div>,
+}))
+
+vi.mock('../pages/dashboard/customers/CustomerFormPage', () => ({
+  CustomerFormPage: () => <div>customer-form-new</div>,
+}))
+
+vi.mock('../pages/dashboard/templates/TemplateListPage', () => ({
+  TemplateListPage: () => <div>template-list-page</div>,
+}))
+
+vi.mock('../pages/dashboard/templates/TemplateFormPage', () => ({
+  TemplateFormPage: () => <div>template-form-page</div>,
+}))
+
+vi.mock('../pages/dashboard/templates/TemplateDetailPage', () => ({
+  TemplateDetailPage: () => <div>template-detail-page</div>,
+}))
+
+vi.mock('../pages/dashboard/simulation/StylistSimulationPage', () => ({
+  StylistSimulationPage: () => <div>simulation-page</div>,
+}))
+
+vi.mock('../pages/dashboard/simulation/SimulationResultPage', () => ({
+  SimulationResultPage: () => <div>simulation-result-page</div>,
+}))
+
+vi.mock('../pages/dashboard/simulation/SimulationEditPage', () => ({
+  SimulationEditPage: () => <div>simulation-edit-page</div>,
+}))
+
+vi.mock('../pages/dashboard/simulation/AftercareResultPage', () => ({
+  AftercareResultPage: () => <div>aftercare-result-page</div>,
+}))
+
+vi.mock('../pages/customer/StylistProfilePage', () => ({
+  StylistProfilePage: () => <div>stylist-profile-page</div>,
+}))
+
+vi.mock('../pages/customer/CustomerSimulationPage', () => ({
+  CustomerSimulationPage: () => <div>customer-simulation-page</div>,
+}))
+
+vi.mock('../shared/RequireAuth', () => ({
+  RequireAuth: ({ children }: { children: React.ReactNode }) => <>{children}</>,
+}))
+
+describe('App customer routes', () => {
+  beforeEach(() => {
+    window.history.replaceState({}, '', '/zh')
+  })
+
+  it('redirects removed customer detail routes back to locale root', async () => {
+    window.history.replaceState({}, '', '/zh/customers/123')
+
+    render(<App />)
+
+    await waitFor(() => {
+      expect(window.location.pathname).toBe('/zh')
+    })
+    expect(screen.getByText('landing-page')).toBeInTheDocument()
+  })
+
+  it('redirects removed customer edit routes back to locale root', async () => {
+    window.history.replaceState({}, '', '/zh/customers/123/edit')
+
+    render(<App />)
+
+    await waitFor(() => {
+      expect(window.location.pathname).toBe('/zh')
+    })
+    expect(screen.getByText('landing-page')).toBeInTheDocument()
+  })
+})

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

@@ -5,8 +5,6 @@ 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',

+ 13 - 10
frontend/src/i18n/locales/ja/translation.json

@@ -215,19 +215,19 @@
   },
   "customerListPage": {
     "title": "顧客一覧",
-    "description": "Aitori Stylist の顧客一覧ページ。検索や絞り込みから顧客カルテ、施術記録、AI シミュレーション履歴、次回来店提案を確認できます。",
+    "description": "Aitori Stylist の顧客一覧ページ。検索や絞り込みから顧客カルテ、来店予定、相談内容、フォロー状況を確認できます。",
     "badge": "顧客管理",
-    "lead": "顧客を素早く検索・絞り込みし、カルテや施術記録、AIシミュレーション履歴を確認できます。",
+    "lead": "顧客を素早く検索・絞り込みし、カルテ、来店予定、相談内容、フォロー状況を確認できます。",
     "newCustomer": "新規顧客",
     "filters": {
       "searchLabel": "顧客検索",
       "searchPlaceholder": "氏名・電話番号・メモで検索",
       "visitLabel": "来店状況",
-      "visitOptions": ["すべて", "本日来店", "今週来店", "30日以上未来店"],
+      "visitOptions": ["すべて", "本日来店", "今週来店"],
       "followLabel": "フォロー",
-      "followOptions": ["すべて", "要フォロー", "送信済み"],
-      "styleLabel": "ヘアスタイル",
-      "styleOptions": ["すべて", "ショート", "ミディアム", "ロング"]
+      "followOptions": ["すべて", "要フォロー", "フォロー不要"],
+      "styleLabel": "髪の長さ",
+      "styleOptions": ["すべて", "ショート", "ミディアム", "ロング", "未確認"]
     },
     "stats": {
       "total": "顧客総数",
@@ -235,11 +235,11 @@
       "followUp": "要フォロー",
       "simulated": "シミュレーション済み"
     },
-    "resultBadge": "顧客一覧",
+    "resultBadge": "検索結果",
     "resultTitle": "検索結果 {{count}}件",
-    "tableHeaders": ["顧客", "最終来店", "次回提案", "状態", "操作"],
+    "tableHeaders": ["顧客", "来店予定", "相談内容", "状態", "操作"],
     "hairLengthMap": { "short": "ショート", "medium": "ミディアム", "long": "ロング" },
-    "rowStatuses": ["本日来店", "送信予定", "要フォロー", "60日後", "要連絡", "記録済み", "記録済み", "送信済み"],
+    "statusMap": { "none": "未設定" },
     "detail": "詳細",
     "pagination": { "prev": "前へ", "next": "次へ" }
   },
@@ -490,12 +490,14 @@
       "visitDateNew": "初回来店予定日",
       "visitDateEdit": "最近来店",
       "visitPurposeNew": "来店目的",
+      "visitPurposePlaceholder": "来店目的を選択してください",
       "visitPurposeEdit": "来店頻度",
       "concernNew": "悩み・相談内容",
       "concernEdit": "髪質メモ",
       "styleNew": "希望イメージ",
       "styleEdit": "希望スタイル",
       "concernPlaceholder": "例: 顔周りを変えたい、退色後の色味が気になる、前髪を相談したい",
+      "consultContent": "相談詳細",
       "stylePlaceholder": "例: やわらかい雰囲気、明るめベージュ、扱いやすいレイヤー",
       "styleEditDefault": "やわらかいベージュカラー。長さは残しつつ、顔周りに動きを出したい。",
       "lengthOptions": ["ショート", "ミディアム", "ロング"],
@@ -540,7 +542,8 @@
       "statusOptions": ["送信予定", "送信済み", "保留"],
       "contactOptions": ["LINE", "電話", "メール"],
       "templateOptions": ["自動提案", "ロング", "ミディアム", "ショート"],
-      "messageEditDefault": "前回のベージュカラーの退色状況と、顔周りレイヤーの扱いを確認する。"
+      "messageEditDefault": "前回のベージュカラーの退色状況と、顔周りレイヤーの扱いを確認する。",
+      "firstRegister": "初回登録"
     },
     "submit": {
       "titleNew": "新規顧客登録",

+ 13 - 10
frontend/src/i18n/locales/zh/translation.json

@@ -215,19 +215,19 @@
   },
   "customerListPage": {
     "title": "顾客列表",
-    "description": "Aitori Stylist 顾客列表页面。可搜索和筛选顾客,查看顾客档案、施术记录、AI 发型模拟历史和下次建议,并新建顾客。",
+    "description": "Aitori Stylist 顾客列表页面。可搜索和筛选顾客,查看顾客档案、到店计划、咨询内容和跟进状态,并新建顾客。",
     "badge": "顾客管理",
-    "lead": "可快速搜索和筛选顾客,并查看顾客档案、施术记录和 AI 发型模拟历史。",
+    "lead": "可快速搜索和筛选顾客,并查看顾客档案、到店计划、咨询记录和跟进状态。",
     "newCustomer": "新建顾客",
     "filters": {
       "searchLabel": "顾客搜索",
       "searchPlaceholder": "按姓名、电话号码、备注搜索",
       "visitLabel": "到店状态",
-      "visitOptions": ["全部", "今天到店", "本周到店", "30日以上未到店"],
+      "visitOptions": ["全部", "今天到店", "本周到店"],
       "followLabel": "跟进",
-      "followOptions": ["全部", "需跟进", "已发送"],
-      "styleLabel": "发型",
-      "styleOptions": ["全部", "短发", "中长发", "长发"]
+      "followOptions": ["全部", "需跟进", "无需跟进"],
+      "styleLabel": "发长",
+      "styleOptions": ["全部", "短发", "中长发", "长发", "未确认"]
     },
     "stats": {
       "total": "顾客总数",
@@ -235,11 +235,11 @@
       "followUp": "需跟进",
       "simulated": "已模拟"
     },
-    "resultBadge": "顾客列表",
+    "resultBadge": "搜索结果",
     "resultTitle": "搜索结果 {{count}}件",
-    "tableHeaders": ["顾客", "最后到店", "下次建议", "状态", "操作"],
+    "tableHeaders": ["顾客", "到店计划", "咨询内容", "状态", "操作"],
     "hairLengthMap": { "short": "短发", "medium": "中长发", "long": "长发" },
-    "rowStatuses": ["今天到店", "发送计划", "需跟进", "60天后", "需联系", "已记录", "已记录", "已发送"],
+    "statusMap": { "none": "待设置" },
     "detail": "详情",
     "pagination": { "prev": "上一页", "next": "下一页" }
   },
@@ -490,12 +490,14 @@
       "visitDateNew": "首次到店计划日",
       "visitDateEdit": "最近到店",
       "visitPurposeNew": "到店目的",
+      "visitPurposePlaceholder": "请选择到店目的",
       "visitPurposeEdit": "到店频率",
       "concernNew": "烦恼、咨询内容",
       "concernEdit": "发质备注",
       "styleNew": "期望形象",
       "styleEdit": "期望发型",
       "concernPlaceholder": "例: 想改变脸周、担心褪色后的色调、咨询刘海",
+      "consultContent": "详细咨询内容",
       "stylePlaceholder": "例: 柔和氛围、明亮米色、易打理层次",
       "styleEditDefault": "柔和米色染发。希望保留长度,并在脸周增加动感。",
       "lengthOptions": ["短发", "中长发", "长发"],
@@ -540,7 +542,8 @@
       "statusOptions": ["计划发送", "已发送", "保留"],
       "contactOptions": ["LINE", "电话", "邮箱"],
       "templateOptions": ["自动推荐", "长发", "中长发", "短发"],
-      "messageEditDefault": "确认上次米色染发的褪色情况,以及脸周层次的打理情况。"
+      "messageEditDefault": "确认上次米色染发的褪色情况,以及脸周层次的打理情况。",
+      "firstRegister": "首次登记"
     },
     "submit": {
       "titleNew": "新建顾客登记",

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

@@ -1,266 +0,0 @@
-import { useEffect, useMemo, useState } from 'react'
-import { Link, useParams } from 'react-router-dom'
-import { Helmet } from 'react-helmet-async'
-import { getCustomerById, getCustomerHistory, sendReminder, type CustomerHistoryItem } from '../../../services/api/customers'
-import type { CustomerEntity } from '../../../services/api/customers'
-import { usePageContext } from '../../../shared/pageContext'
-import { simulationSession } from '../../../shared/simulationSession'
-import { DashboardLayout } from '../../../layouts/DashboardLayout'
-
-type PhotoItem = { src: string; label: string; title: string; alt: string; href?: string | null }
-
-function isExternalUrl(value: string) {
-  return /^https?:\/\//.test(value)
-}
-
-export function CustomerDetailPage() {
-  const { locale, path, copy } = usePageContext()
-  const params = useParams()
-  const [customer, setCustomer] = useState<CustomerEntity | null>(null)
-  const [historyItems, setHistoryItems] = useState<CustomerHistoryItem[]>([])
-  const [reminderSending, setReminderSending] = useState(false)
-  const [reminderMessage, setReminderMessage] = useState('')
-  const pageCopy = copy.customerDetailPage
-
-  const startSimulation = () => {
-    if (customer) simulationSession.setCustomerId(customer.id)
-  }
-
-  useEffect(() => {
-    getCustomerById({ customerId: params.customerId }).then(setCustomer).catch(() => setCustomer(null))
-    if (params.customerId) {
-      getCustomerHistory(params.customerId).then(setHistoryItems).catch(() => setHistoryItems([]))
-    }
-  }, [params.customerId])
-
-  const getRecordHref = (record: CustomerHistoryItem) => {
-    if (record.detailUrl) return record.detailUrl
-    if (record.detailPath) {
-      if (isExternalUrl(record.detailPath)) return record.detailPath
-      if (record.detailPath.startsWith(`/${locale}/`)) return record.detailPath
-      return path(record.detailPath.startsWith('/') ? record.detailPath : `/${record.detailPath}`)
-    }
-    if (!record.jobId) return null
-
-    const recordType = (record.recordType || record.simulationType || '').toLowerCase()
-    if (recordType === 'aftercare') {
-      return path(`/simulation/aftercare?jobId=${encodeURIComponent(String(record.jobId))}&customerId=${encodeURIComponent(customer.id)}`)
-    }
-
-    return path(`/simulation/result?jobId=${encodeURIComponent(String(record.jobId))}&customerId=${encodeURIComponent(customer.id)}`)
-  }
-
-  const photos = useMemo<PhotoItem[]>(() => {
-    if (!customer) return []
-    const items: PhotoItem[] = []
-    if (customer.image) {
-      items.push({
-        src: customer.image,
-        label: pageCopy.photos.items[0].label,
-        title: pageCopy.photos.items[0].title,
-        alt: customer.name,
-      })
-    }
-    historyItems.forEach((record, index) => {
-      if (record.beforeImage) {
-        items.push({
-          src: record.beforeImage,
-          label: pageCopy.photos.items[0].label,
-          title: record.title,
-          alt: `${customer.name} before ${index + 1}`,
-          href: getRecordHref(record),
-        })
-      }
-      if (record.afterImage) {
-        items.push({
-          src: record.afterImage,
-          label: pageCopy.photos.items[1].label,
-          title: record.title,
-          alt: `${customer.name} after ${index + 1}`,
-          href: getRecordHref(record),
-        })
-      }
-    })
-    return items
-  }, [customer, historyItems, pageCopy.photos.items, locale, path])
-
-  const handleSendReminder = async () => {
-    if (!customer || reminderSending) return
-    setReminderSending(true)
-    setReminderMessage('')
-    try {
-      await sendReminder(customer.id, {
-        recommendedDate: customer.nextVisit || customer.followTiming,
-        message: customer.followMessage || customer.concern,
-        preferredContact: customer.contact,
-      })
-      setReminderMessage(copy.common.generated)
-    } catch (err) {
-      setReminderMessage(err instanceof Error ? err.message : '发送失败')
-    } finally {
-      setReminderSending(false)
-    }
-  }
-
-  if (!customer) {
-    return null
-  }
-
-  const summaryText = customer.summary || customer.concern || pageCopy.nextVisit.lead
-  const nextVisitLead = customer.followMessage || customer.concern || pageCopy.nextVisit.lead
-
-  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>
-            <div className="d-flex flex-wrap gap-2">
-              <Link to={path(`/customers/${customer.id}/edit`)} className="btn btn-large btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-edit-3 me-8px"></i>{pageCopy.edit}</Link>
-              <Link to={path('/simulation')} onClick={startSimulation} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700 customer-detail-start-btn"><i className="feather icon-feather-camera"></i>{pageCopy.simulate}</Link>
-            </div>
-          </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.image || '/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">{summaryText}</p>
-                <div className="customer-detail-tags">
-                  {(customer.tags?.length ? customer.tags : []).map((item: string) => <span key={item}>{item}</span>)}
-                  {customer.contact ? <span>{customer.contact}</span> : null}
-                  {customer.lineStatus ? <span>{customer.lineStatus}</span> : null}
-                </div>
-              </div>
-            </div>
-            <div className="customer-next-visit">
-              <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.nextVisit.badge}</span>
-              <strong>{customer.nextVisit || customer.followTiming || '—'}</strong>
-              <p>{nextVisitLead}</p>
-              <button type="button" onClick={handleSendReminder} disabled={reminderSending} className="btn btn-small btn-rounded btn-base-color fw-700">
-                {reminderSending ? copy.common.sending : pageCopy.nextVisit.action}
-              </button>
-              {reminderMessage ? <p className="fs-14 text-medium-gray mt-10px mb-0">{reminderMessage}</p> : null}
-            </div>
-          </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>
-                <div className="customer-info-list">
-                  <div><span>{pageCopy.basic.labels[0]}</span><strong>{customer.phone}</strong></div>
-                  <div><span>{pageCopy.basic.labels[1]}</span><strong>{customer.email || '—'}</strong></div>
-                  <div><span>{pageCopy.basic.labels[2]}</span><strong>{customer.birthday || pageCopy.basic.fallbacks[0]}</strong></div>
-                  <div><span>{pageCopy.basic.labels[3]}</span><strong>{customer.visitCycle || pageCopy.basic.fallbacks[1]}</strong></div>
-                  <div><span>{pageCopy.basic.labels[4]}</span><strong>{customer.hairCondition || customer.hairType || pageCopy.basic.fallbacks[2]}</strong></div>
-                  <div><span>{pageCopy.basic.labels[5]}</span><strong>{customer.formulaNote || pageCopy.basic.fallbacks[3]}</strong></div>
-                  {customer.allergyNote ? <div><span>{copy.customerFormPage.hair.fieldEditRight}</span><strong>{customer.allergyNote}</strong></div> : null}
-                </div>
-              </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">
-                <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px">
-                  <div>
-                    <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-0">{pageCopy.history.title}</h2>
-                  </div>
-                </div>
-                <div className="customer-history">
-                  {historyItems.length === 0 ? (
-                    <p className="fs-15 text-medium-gray mb-0">{copy.common.empty}</p>
-                  ) : historyItems.map((item) => (
-                    (() => {
-                      const href = getRecordHref(item)
-                      const content = (
-                        <>
-                          <span className="customer-history-date">{item.date}</span>
-                          <div><strong>{item.title}</strong><p>{item.body}</p></div>
-                        </>
-                      )
-
-                      if (!href) {
-                        return <div key={`${item.date}-${item.title}`} className="customer-history-item">{content}</div>
-                      }
-
-                      if (isExternalUrl(href)) {
-                        return <a key={`${item.date}-${item.title}`} href={href} className="customer-history-item">{content}</a>
-                      }
-
-                      return <Link key={`${item.date}-${item.title}`} to={href} className="customer-history-item">{content}</Link>
-                    })()
-                  ))}
-                </div>
-              </div>
-            </div>
-          </div>
-        </div>
-      </section>
-
-      <section className="pt-0">
-        <div className="container">
-          <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
-            <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px">
-              <div>
-                    <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-0">{pageCopy.photos.title}</h2>
-              </div>
-                  <Link to={path('/simulation')} onClick={startSimulation} className="btn btn-small btn-rounded btn-base-color fw-700"><i className="feather icon-feather-camera me-6px"></i>{pageCopy.simulate}</Link>
-            </div>
-            <div className="customer-photo-grid">
-              {photos.length === 0 ? (
-                <p className="fs-15 text-medium-gray mb-0">{copy.common.empty}</p>
-              ) : photos.map((item, index) => (
-                item.href ? (
-                  isExternalUrl(item.href) ? (
-                    <a key={`${item.src}-${index}`} href={item.href} className="customer-photo-card" title={item.title}>
-                      <img src={item.src} alt={item.alt} />
-                      <div><span>{item.label}</span><strong>{item.title}</strong></div>
-                    </a>
-                  ) : (
-                    <Link key={`${item.src}-${index}`} to={item.href} className="customer-photo-card" title={item.title}>
-                      <img src={item.src} alt={item.alt} />
-                      <div><span>{item.label}</span><strong>{item.title}</strong></div>
-                    </Link>
-                  )
-                ) : (
-                  <a key={`${item.src}-${index}`} href={item.src} className="customer-photo-card" title={item.title}>
-                    <img src={item.src} alt={item.alt} />
-                    <div><span>{item.label}</span><strong>{item.title}</strong></div>
-                  </a>
-                )
-              ))}
-            </div>
-          </div>
-        </div>
-      </section>
-
-      <section className="pt-0">
-        <div className="container">
-          <div className="customer-detail-cta workbench-panel border-radius-8px p-30px">
-            <div>
-              <span className="fs-14 fw-700 text-white text-uppercase">{pageCopy.cta.badge}</span>
-              <h2 className="fw-700 text-white fs-30 mb-10px">{pageCopy.cta.title}</h2>
-              <p className="text-white opacity-8 mb-0">{pageCopy.cta.lead}</p>
-            </div>
-            <Link to={path('/simulation')} onClick={startSimulation} className="btn btn-large btn-rounded bg-white text-dark-gray fw-700"><i className="feather icon-feather-play-circle me-8px"></i>{copy.common.start}</Link>
-          </div>
-        </div>
-      </section>
-    </DashboardLayout>
-  )
-}

+ 116 - 157
frontend/src/pages/dashboard/customers/CustomerFormPage.tsx

@@ -1,11 +1,9 @@
-import { useEffect, useState, type FormEvent } from 'react'
-import { Link, useParams } from 'react-router-dom'
+import { useState, type FormEvent } from 'react'
+import { Link } from 'react-router-dom'
 import { Helmet } from 'react-helmet-async'
-import { createCustomer, getCustomerById, updateCustomer } from '../../../services/api/customers'
+import { createCustomer } from '../../../services/api/customers'
 import { uploadMedia } from '../../../services/api/media'
-import type { CustomerEntity } from '../../../services/api/customers'
 import {
-  customerToFormValues,
   emptyCustomerForm,
   formValuesToPayload,
   type CustomerFormValues,
@@ -13,36 +11,33 @@ import {
 import { usePageContext, usePreviewUrl } from '../../../shared/pageContext'
 import { DashboardLayout } from '../../../layouts/DashboardLayout'
 
-export function CustomerFormPage({ mode }: { mode: 'new' | 'edit' }) {
+function getSubmitErrorMessage(error: unknown, locale: 'zh' | 'ja') {
+  const fallback = locale === 'ja' ? '保存に失敗しました' : '保存失败'
+
+  if (!(error instanceof Error)) {
+    return fallback
+  }
+
+  if (error.message === 'Internal server error') {
+    return locale === 'ja'
+      ? '保存に失敗しました。しばらくしてから再試行してください'
+      : '保存失败,请稍后重试'
+  }
+
+  return error.message || fallback
+}
+
+export function CustomerFormPage() {
   const { locale, navigate, path, copy } = usePageContext()
-  const params = useParams()
-  const [existing, setExisting] = useState<CustomerEntity | null>(null)
   const [form, setForm] = useState<CustomerFormValues>(emptyCustomerForm)
   const [submitting, setSubmitting] = useState(false)
   const [error, setError] = useState('')
   const [photoFile, setPhotoFile] = useState<File | null>(null)
-  const photoPreview = usePreviewUrl(photoFile, existing?.image || '/images/aitori-before-photo.png')
+  const photoPreview = usePreviewUrl(photoFile, '/images/aitori-before-photo.png')
 
   const patch = (part: Partial<CustomerFormValues>) => setForm((current) => ({ ...current, ...part }))
-
-  useEffect(() => {
-    if (mode !== 'edit') return
-
-    getCustomerById({ customerId: params.customerId })
-      .then((item) => {
-        setExisting(item)
-        if (item) setForm(customerToFormValues(item))
-      })
-      .catch(() => setExisting(null))
-  }, [mode, params.customerId])
-
-  if (mode === 'edit' && !existing) {
-    return null
-  }
-
-  const detailPath = existing ? path(`/customers/${existing.id}`) : path('/customers')
   const pageCopy = copy.customerFormPage
-  const modeCopy = mode === 'edit' ? pageCopy.edit : pageCopy.new
+  const modeCopy = pageCopy.new
 
   const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
     event.preventDefault()
@@ -50,18 +45,13 @@ export function CustomerFormPage({ mode }: { mode: 'new' | 'edit' }) {
     setError('')
     setSubmitting(true)
     try {
-      let image: string | undefined = existing?.image
-      if (photoFile) image = (await uploadMedia(photoFile, 'avatar')).url
-      const payload = formValuesToPayload(form, image)
-      if (mode === 'edit' && existing) {
-        await updateCustomer(existing.id, payload)
-        navigate(path(`/customers/${existing.id}`))
-      } else {
-        await createCustomer(payload)
-        navigate(path('/customers'))
-      }
+      let avatar: string | undefined
+      if (photoFile) avatar = (await uploadMedia(photoFile, 'avatar')).url
+      const payload = formValuesToPayload(form, avatar)
+      await createCustomer(payload)
+      navigate(path('/customers'))
     } catch (err) {
-      setError(err instanceof Error ? err.message : '保存失败')
+      setError(getSubmitErrorMessage(err, locale))
     } finally {
       setSubmitting(false)
     }
@@ -77,149 +67,130 @@ export function CustomerFormPage({ mode }: { mode: 'new' | 'edit' }) {
       <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={mode === 'edit' ? detailPath : 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={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={mode === 'edit' ? detailPath : 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={mode === 'edit' ? 'customer-edit-form' : 'customer-new-form'} className="btn btn-large btn-rounded btn-base-color btn-box-shadow fw-700"><i className={`feather ${mode === 'edit' ? 'icon-feather-save' : 'icon-feather-user-plus'} me-10px`}></i>{modeCopy.submit}</button>
+              <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>
+              <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>
 
           <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">{mode === 'edit' ? modeCopy.lead.replace('{{name}}', existing?.name || '') : modeCopy.lead}</p>
+            <p className="fs-18 lh-30 mb-0">{modeCopy.lead}</p>
           </div>
         </div>
       </section>
 
       <section className="pt-0">
         <div className="container">
-          <form id={mode === 'edit' ? 'customer-edit-form' : 'customer-new-form'} className="customer-edit-form" onSubmit={handleSubmit}>
+          <form id="customer-new-form" className="customer-edit-form" onSubmit={handleSubmit}>
             <div className="row g-4">
-              {mode === 'edit' ? (
-                <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.photo.badge}</span>
-                    <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.photo.title}</h2>
-                    <div className="customer-edit-photo">
-                      <img src={photoPreview} alt={existing?.name} />
-                      <label className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700 mb-0">
-                        <input type="file" accept="image/*" className="d-none" onChange={(e) => setPhotoFile(e.target.files?.[0] || null)} />
-                        <i className="feather icon-feather-upload me-6px"></i>{pageCopy.photo.change}
-                      </label>
-                    </div>
-                  </div>
-                </div>
-              ) : null}
-
-              <div className={mode === 'edit' ? 'col-xl-8 col-lg-7' : 'col-xl-8 col-lg-7'}>
+              <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.basic.badge}</span>
                   <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.basic.title}</h2>
                   <div className="row g-3">
-                    <div className="col-md-6"><label htmlFor="customer-name" className="customer-edit-label">{pageCopy.basic.name} {mode === 'new' ? <span className="text-base-color">*</span> : null}</label><input id="customer-name" type="text" className="form-control customer-edit-control" value={form.name} onChange={(event) => patch({ name: event.target.value })} placeholder={pageCopy.basic.namePlaceholder} required /></div>
-                    <div className="col-md-6"><label htmlFor="customer-kana" className="customer-edit-label">{pageCopy.basic.kana}</label><input id="customer-kana" type="text" className="form-control customer-edit-control" value={form.kana} onChange={(event) => patch({ kana: event.target.value })} placeholder={pageCopy.basic.kanaPlaceholder} /></div>
-                    <div className="col-md-6"><label htmlFor="customer-phone" className="customer-edit-label">{pageCopy.basic.phone} {mode === 'new' ? <span className="text-base-color">*</span> : null}</label><input id="customer-phone" type="tel" className="form-control customer-edit-control" value={form.phone} onChange={(event) => patch({ phone: event.target.value })} placeholder="090-0000-0000" required /></div>
+                    <div className="col-md-6"><label htmlFor="customer-name" className="customer-edit-label">{pageCopy.basic.name} <span className="text-base-color">*</span></label><input id="customer-name" type="text" className="form-control customer-edit-control" value={form.name} onChange={(event) => patch({ name: event.target.value })} placeholder={pageCopy.basic.namePlaceholder} required /></div>
+                    <div className="col-md-6"><label htmlFor="customer-kana" className="customer-edit-label">{pageCopy.basic.kana}</label><input id="customer-kana" type="text" className="form-control customer-edit-control" value={form.namePronunciation} onChange={(event) => patch({ namePronunciation: event.target.value })} placeholder={pageCopy.basic.kanaPlaceholder} /></div>
+                    <div className="col-md-6"><label htmlFor="customer-phone" className="customer-edit-label">{pageCopy.basic.phone} <span className="text-base-color">*</span></label><input id="customer-phone" type="tel" className="form-control customer-edit-control" value={form.phone} onChange={(event) => patch({ phone: event.target.value })} placeholder="090-0000-0000" required /></div>
                     <div className="col-md-6"><label htmlFor="customer-email" className="customer-edit-label">{pageCopy.basic.email}</label><input id="customer-email" type="email" className="form-control customer-edit-control" value={form.email} onChange={(event) => patch({ email: event.target.value })} placeholder="name@example.com" /></div>
                     <div className="col-md-6"><label htmlFor="customer-birthday" className="customer-edit-label">{pageCopy.basic.birthday}</label><input id="customer-birthday" type="date" className="form-control customer-edit-control" value={form.birthday} onChange={(event) => patch({ birthday: event.target.value })} /></div>
                     <div className="col-md-6">
-                      <label htmlFor="customer-contact" className="customer-edit-label">{mode === 'edit' ? pageCopy.basic.contactEdit : pageCopy.basic.contactNew}</label>
-                      {mode === 'edit' ? (
-                        <select id="customer-contact" className="form-select customer-edit-control" value={form.lineStatus} onChange={(event) => patch({ lineStatus: event.target.value })}>
-                          {pageCopy.basic.contactEditOptions.map((item: string) => <option key={item} value={item}>{item}</option>)}
-                        </select>
-                      ) : (
-                        <select id="customer-contact" className="form-select customer-edit-control" value={form.contact} onChange={(event) => patch({ contact: event.target.value })}>
-                          <option value="LINE">LINE</option>
-                          <option value="Phone">{pageCopy.basic.contactNewOptions[1]}</option>
-                          <option value="Email">{pageCopy.basic.contactNewOptions[2]}</option>
-                        </select>
-                      )}
+                      <label htmlFor="customer-contact" className="customer-edit-label">{pageCopy.basic.contactNew}</label>
+                      <select id="customer-contact" className="form-select customer-edit-control" value={form.expectContact} onChange={(event) => patch({ expectContact: event.target.value as CustomerFormValues['expectContact'] })}>
+                        <option value="LINE">LINE</option>
+                        <option value="PHONE">{pageCopy.basic.contactNewOptions[1]}</option>
+                        <option value="EMAIL">{pageCopy.basic.contactNewOptions[2]}</option>
+                        <option value="NONE">{pageCopy.basic.contactNewOptions[3]}</option>
+                      </select>
                     </div>
                   </div>
                 </div>
               </div>
 
-              {mode === 'new' ? (
-                <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.photo.badge}</span>
-                    <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.photo.title}</h2>
-                    <div className="customer-edit-photo mb-25px">
-                      <img src={photoPreview} alt={form.name || pageCopy.basic.name} />
-                      <label className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700 mb-0">
-                        <input type="file" accept="image/*" className="d-none" onChange={(e) => setPhotoFile(e.target.files?.[0] || null)} />
-                        <i className="feather icon-feather-upload me-6px"></i>{pageCopy.photo.change}
-                      </label>
-                    </div>
-                    <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.nextStep.badge}</span>
-                    <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.nextStep.title}</h2>
-                    <div className="customer-new-steps">
-                      {pageCopy.nextStep.steps.map((item: string, index: number) => <div key={item}><span>{index + 1}</span><strong>{item}</strong></div>)}
-                    </div>
+              <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.photo.badge}</span>
+                  <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.photo.title}</h2>
+                  <div className="customer-edit-photo mb-25px">
+                    <img src={photoPreview} alt={form.name || pageCopy.basic.name} />
+                    <label className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700 mb-0">
+                      <input type="file" accept="image/*" className="d-none" onChange={(e) => setPhotoFile(e.target.files?.[0] || null)} />
+                      <i className="feather icon-feather-upload me-6px"></i>{pageCopy.photo.change}
+                    </label>
+                  </div>
+                  <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.nextStep.badge}</span>
+                  <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.nextStep.title}</h2>
+                  <div className="customer-new-steps">
+                    {pageCopy.nextStep.steps.map((item: string, index: number) => <div key={item}><span>{index + 1}</span><strong>{item}</strong></div>)}
                   </div>
                 </div>
-              ) : null}
+              </div>
             </div>
 
             <div className="row g-4 mt-1">
               <div className="col-lg-6">
                 <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">{mode === 'edit' ? pageCopy.consultation.badgeEdit : pageCopy.consultation.badgeNew}</span>
-                  <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{mode === 'edit' ? pageCopy.consultation.titleEdit : pageCopy.consultation.titleNew}</h2>
+                  <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.consultation.badgeNew}</span>
+                  <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.consultation.titleNew}</h2>
                   <div className="row g-3">
                     <div className="col-md-6">
-                      <label htmlFor="visit-date" className="customer-edit-label">{mode === 'edit' ? pageCopy.consultation.visitDateEdit : pageCopy.consultation.visitDateNew}</label>
-                      <input id="visit-date" type="date" className="form-control customer-edit-control" value={form.lastVisit} onChange={(event) => patch({ lastVisit: event.target.value })} />
+                      <label htmlFor="visit-date" className="customer-edit-label">{pageCopy.consultation.visitDateNew}</label>
+                      <input id="visit-date" type="date" className="form-control customer-edit-control" value={form.planDate} onChange={(event) => patch({ planDate: event.target.value })} />
                     </div>
                     <div className="col-md-6">
-                      <label htmlFor="visit-purpose" className="customer-edit-label">{mode === 'edit' ? pageCopy.consultation.visitPurposeEdit : pageCopy.consultation.visitPurposeNew}</label>
-                      {mode === 'edit' ? (
-                        <select id="visit-purpose" className="form-select customer-edit-control" value={form.visitCycle} onChange={(event) => patch({ visitCycle: event.target.value })}>
-                          {pageCopy.consultation.visitCycleOptions.map((item: string) => <option key={item} value={item}>{item}</option>)}
-                        </select>
-                      ) : (
-                        <select id="visit-purpose" className="form-select customer-edit-control" value={form.visitCycle} onChange={(event) => patch({ visitCycle: event.target.value })}>
-                          {pageCopy.consultation.visitPurposeOptions.map((item: string) => <option key={item} value={item}>{item}</option>)}
-                        </select>
-                      )}
+                      <label htmlFor="visit-purpose" className="customer-edit-label">{pageCopy.consultation.visitPurposeNew}</label>
+                      <select id="visit-purpose" className="form-select customer-edit-control" value={form.visitPurpose} onChange={(event) => patch({ visitPurpose: event.target.value as CustomerFormValues['visitPurpose'] })}>
+                        <option value="">{pageCopy.consultation.visitPurposePlaceholder}</option>
+                        <option value="CUT_DYE">{pageCopy.consultation.visitPurposeOptions[0]}</option>
+                        <option value="CUT">{pageCopy.consultation.visitPurposeOptions[1]}</option>
+                        <option value="DYE">{pageCopy.consultation.visitPurposeOptions[2]}</option>
+                        <option value="CONSULT_SIM">{pageCopy.consultation.visitPurposeOptions[3]}</option>
+                      </select>
                     </div>
-                    <div className="col-12"><label htmlFor="customer-concern" className="customer-edit-label">{mode === 'edit' ? pageCopy.consultation.concernEdit : pageCopy.consultation.concernNew}</label><textarea id="customer-concern" className="form-control customer-edit-control" rows={4} value={form.concern} onChange={(event) => patch({ concern: event.target.value })} placeholder={pageCopy.consultation.concernPlaceholder}></textarea></div>
-                    <div className="col-12"><label htmlFor="style-image" className="customer-edit-label">{mode === 'edit' ? pageCopy.consultation.styleEdit : pageCopy.consultation.styleNew}</label><textarea id="style-image" className="form-control customer-edit-control" rows={4} value={form.desiredImage} onChange={(event) => patch({ desiredImage: event.target.value })} placeholder={pageCopy.consultation.stylePlaceholder}></textarea></div>
+                    <div className="col-md-6"><label htmlFor="customer-trouble" className="customer-edit-label">{pageCopy.consultation.concernNew}</label><textarea id="customer-trouble" className="form-control customer-edit-control" rows={4} value={form.trouble} onChange={(event) => patch({ trouble: event.target.value })} placeholder={pageCopy.consultation.concernPlaceholder}></textarea></div>
+                    <div className="col-md-6"><label htmlFor="customer-consult-content" className="customer-edit-label">{pageCopy.consultation.consultContent}</label><textarea id="customer-consult-content" className="form-control customer-edit-control" rows={4} value={form.consultContent} onChange={(event) => patch({ consultContent: event.target.value })} placeholder={pageCopy.consultation.concernPlaceholder}></textarea></div>
+                    <div className="col-12"><label htmlFor="style-image" className="customer-edit-label">{pageCopy.consultation.styleNew}</label><textarea id="style-image" className="form-control customer-edit-control" rows={4} value={form.expectLook} onChange={(event) => patch({ expectLook: event.target.value })} placeholder={pageCopy.consultation.stylePlaceholder}></textarea></div>
                   </div>
                 </div>
               </div>
 
               <div className="col-lg-6">
                 <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">{mode === 'edit' ? pageCopy.hair.badgeEdit : pageCopy.hair.badgeNew}</span>
-                  <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{mode === 'edit' ? pageCopy.hair.titleEdit : pageCopy.hair.titleNew}</h2>
+                  <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.hair.badgeNew}</span>
+                  <h2 className="fw-700 text-dark-gray fs-26 mb-25px">{pageCopy.hair.titleNew}</h2>
                   <div className="row g-3">
                     <div className="col-md-6">
                       <label htmlFor="hair-length" className="customer-edit-label">{pageCopy.hair.fieldNewLeft}</label>
-                      <select id="hair-length" className="form-select customer-edit-control" value={form.hairLength} onChange={(event) => patch({ hairLength: event.target.value })}>
-                        <option value="Short">{pageCopy.hair.lengthOptions[0]}</option>
-                        <option value="Medium">{pageCopy.hair.lengthOptions[1]}</option>
-                        <option value="Long">{pageCopy.hair.lengthOptions[2]}</option>
+                      <select id="hair-length" className="form-select customer-edit-control" value={form.hairLength} onChange={(event) => patch({ hairLength: event.target.value as CustomerFormValues['hairLength'] })}>
+                        <option value="SHORT">{pageCopy.hair.lengthOptions[0]}</option>
+                        <option value="MEDIUM">{pageCopy.hair.lengthOptions[1]}</option>
+                        <option value="LONG">{pageCopy.hair.lengthOptions[2]}</option>
+                        <option value="UNCONFIRM">{pageCopy.hair.lengthOptions[3]}</option>
                       </select>
                     </div>
                     <div className="col-md-6">
                       <label htmlFor="hair-type" className="customer-edit-label">{pageCopy.hair.fieldNewRight}</label>
-                      <select id="hair-type" className="form-select customer-edit-control" value={form.hairType} onChange={(event) => patch({ hairType: event.target.value })}>
-                        {pageCopy.hair.hairTypeOptions.map((item: string) => <option key={item} value={item}>{item}</option>)}
+                      <select id="hair-type" className="form-select customer-edit-control" value={form.hairTexture} onChange={(event) => patch({ hairTexture: event.target.value as CustomerFormValues['hairTexture'] })}>
+                        <option value="UNCONFIRM">{pageCopy.hair.hairTypeOptions[0]}</option>
+                        <option value="THIN">{pageCopy.hair.hairTypeOptions[1]}</option>
+                        <option value="NORMAL">{pageCopy.hair.hairTypeOptions[2]}</option>
+                        <option value="HARD_CRUDE">{pageCopy.hair.hairTypeOptions[3]}</option>
+                        <option value="CURLY">{pageCopy.hair.hairTypeOptions[4]}</option>
                       </select>
                     </div>
                     <div className="col-12">
                       <label htmlFor="formula-note" className="customer-edit-label">{pageCopy.hair.fieldEditLeft}</label>
-                      <textarea id="formula-note" className="form-control customer-edit-control" rows={4} value={form.formulaNote} onChange={(event) => patch({ formulaNote: event.target.value })} placeholder={pageCopy.hair.formulaDefault}></textarea>
+                      <textarea id="formula-note" className="form-control customer-edit-control" rows={4} value={form.notice} onChange={(event) => patch({ notice: event.target.value })} placeholder={pageCopy.hair.formulaDefault}></textarea>
                     </div>
                     <div className="col-12">
                       <label htmlFor="allergy-note" className="customer-edit-label">{pageCopy.hair.fieldEditRight}</label>
-                      <textarea id="allergy-note" className="form-control customer-edit-control" rows={4} value={form.allergyNote} onChange={(event) => patch({ allergyNote: event.target.value })} placeholder={pageCopy.hair.staffPlaceholder}></textarea>
+                      <textarea id="allergy-note" className="form-control customer-edit-control" rows={4} value={form.allergy} onChange={(event) => patch({ allergy: event.target.value })} placeholder={pageCopy.hair.staffPlaceholder}></textarea>
                     </div>
                     <div className="col-12">
                       <label htmlFor="staff-note-second" className="customer-edit-label">{pageCopy.hair.staffSecond}</label>
-                      <textarea id="staff-note-second" className="form-control customer-edit-control" rows={4} value={form.privateNote} onChange={(event) => patch({ privateNote: event.target.value })} placeholder={pageCopy.hair.staffSecondPlaceholder}></textarea>
+                      <textarea id="staff-note-second" className="form-control customer-edit-control" rows={4} value={form.innerRemark} onChange={(event) => patch({ innerRemark: event.target.value })} placeholder={pageCopy.hair.staffSecondPlaceholder}></textarea>
                     </div>
                   </div>
                 </div>
@@ -230,58 +201,46 @@ export function CustomerFormPage({ mode }: { mode: 'new' | 'edit' }) {
               <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px">
                 <div>
                   <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.follow.badge}</span>
-                  <h2 className="fw-700 text-dark-gray fs-26 mb-0">{mode === 'edit' ? pageCopy.follow.titleEdit : pageCopy.follow.titleNew}</h2>
+                  <h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.follow.titleNew}</h2>
                 </div>
-                <span className="workbench-badge">{mode === 'edit' ? pageCopy.follow.statusEdit : pageCopy.follow.statusNew}</span>
+                <span className="workbench-badge">{pageCopy.follow.statusNew}</span>
               </div>
               <div className="row g-3">
                 <div className="col-lg-4 col-md-6">
-                  <label htmlFor="follow-timing" className="customer-edit-label">{mode === 'edit' ? pageCopy.follow.timingEdit : pageCopy.follow.timingNew}</label>
-                  {mode === 'edit' ? (
-                    <input id="follow-timing" type="date" className="form-control customer-edit-control" value={form.followTiming} onChange={(event) => patch({ followTiming: event.target.value })} />
-                  ) : (
-                    <select id="follow-timing" className="form-select customer-edit-control" value={form.followTiming} onChange={(event) => patch({ followTiming: event.target.value })}>
-                      {pageCopy.follow.timingOptions.map((item: string) => <option key={item} value={item}>{item}</option>)}
-                    </select>
-                  )}
+                  <label htmlFor="follow-timing" className="customer-edit-label">{pageCopy.follow.timingNew}</label>
+                  <input id="follow-timing" type="date" className="form-control customer-edit-control" value={form.nextFollowDate} onChange={(event) => patch({ nextFollowDate: event.target.value })} />
                 </div>
                 <div className="col-lg-4 col-md-6">
-                  <label htmlFor="next-visit" className="customer-edit-label">{mode === 'edit' ? pageCopy.follow.nextVisitEdit : pageCopy.follow.nextVisitNew}</label>
-                  {mode === 'edit' ? (
-                    <select id="next-visit" className="form-select customer-edit-control" value={form.followStatus} onChange={(event) => patch({ followStatus: event.target.value })}>
-                      {pageCopy.follow.statusOptions.map((item: string) => <option key={item} value={item}>{item}</option>)}
-                    </select>
-                  ) : (
-                    <input id="next-visit" type="date" className="form-control customer-edit-control" value={form.nextVisit} onChange={(event) => patch({ nextVisit: event.target.value })} />
-                  )}
+                  <label htmlFor="next-visit" className="customer-edit-label">{pageCopy.follow.nextVisitNew}</label>
+                  <input id="next-visit" type="date" className="form-control customer-edit-control" value={form.recommendVisitDate} onChange={(event) => patch({ recommendVisitDate: event.target.value })} />
                 </div>
                 <div className="col-lg-4 col-md-6">
-                  <label htmlFor="template-source" className="customer-edit-label">{mode === 'edit' ? pageCopy.follow.templateEdit : pageCopy.follow.templateNew}</label>
-                  {mode === 'edit' ? (
-                    <select id="template-source" className="form-select customer-edit-control" value={form.contact} onChange={(event) => patch({ contact: event.target.value })}>
-                      <option value="LINE">LINE</option>
-                      <option value="Phone">{pageCopy.follow.contactOptions[1]}</option>
-                      <option value="Email">{pageCopy.follow.contactOptions[2]}</option>
-                    </select>
-                  ) : (
-                    <select id="template-source" className="form-select customer-edit-control" value={form.followStatus} onChange={(event) => patch({ followStatus: event.target.value })}>
-                      {pageCopy.follow.templateOptions.map((item: string) => <option key={item} value={item}>{item}</option>)}
-                    </select>
-                  )}
+                  <label htmlFor="template-source" className="customer-edit-label">{pageCopy.follow.templateNew}</label>
+                  <select id="template-source" className="form-select customer-edit-control" value={form.firstSuggestHair} onChange={(event) => patch({ firstSuggestHair: event.target.value as CustomerFormValues['firstSuggestHair'] })}>
+                    <option value="AUTO">{pageCopy.follow.templateOptions[0]}</option>
+                    <option value="LONG">{pageCopy.follow.templateOptions[1]}</option>
+                    <option value="MEDIUM">{pageCopy.follow.templateOptions[2]}</option>
+                    <option value="SHORT">{pageCopy.follow.templateOptions[3]}</option>
+                  </select>
                 </div>
-                <div className="col-12"><label htmlFor="follow-message" className="customer-edit-label">{mode === 'edit' ? pageCopy.follow.messageEdit : pageCopy.follow.messageNew}</label><textarea id="follow-message" className="form-control customer-edit-control" rows={4} value={form.followMessage} onChange={(event) => patch({ followMessage: event.target.value })}></textarea></div>
               </div>
             </div>
 
             <div className="customer-edit-actions workbench-panel border-radius-8px p-25px mt-4">
               <div>
-                <strong>{mode === 'edit' ? pageCopy.submit.titleEdit : pageCopy.submit.titleNew}</strong>
-                <span>{mode === 'edit' ? pageCopy.submit.leadEdit : pageCopy.submit.leadNew}</span>
+                <strong>{pageCopy.submit.titleNew}</strong>
+                <span>{pageCopy.submit.leadNew}</span>
               </div>
-              <div className="d-flex flex-wrap gap-2 align-items-center">
-                {error ? <span className="fs-14 text-red me-2" role="alert">{error}</span> : null}
-                <Link to={mode === 'edit' ? detailPath : 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 ${mode === 'edit' ? 'icon-feather-save' : 'icon-feather-user-plus'} me-10px`}></i>{modeCopy.submit}</button>
+              <div className="customer-edit-action-panel">
+                {error ? (
+                  <div className="customer-edit-action-feedback" role="alert">
+                    {error}
+                  </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>
+                  <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>
             </div>
           </form>

+ 75 - 22
frontend/src/pages/dashboard/customers/CustomerListPage.tsx

@@ -22,20 +22,18 @@ const VISIT_STATUS_API_MAP: Record<string, string | undefined> = {
   '全部': undefined,
   '今天到店': 'TODAY',
   '本周到店': 'THIS_WEEK',
-  '30日以上未到店': 'OVER_30_DAYS_NO_VISIT',
   '全て': undefined,
   '本日来店': 'TODAY',
   '今週来店': 'THIS_WEEK',
-  '30日以上未来店': 'OVER_30_DAYS_NO_VISIT',
 }
 
 const FOLLOW_STATUS_API_MAP: Record<string, string | undefined> = {
   '全部': undefined,
   '需跟进': 'NEED_FOLLOW_UP',
-  '已发送': 'SENT',
+  '无需跟进': 'NO_NEED_FOLLOW_UP',
   '全て': undefined,
   '要フォロー': 'NEED_FOLLOW_UP',
-  '送信済み': 'SENT',
+  'フォロー不要': 'NO_NEED_FOLLOW_UP',
 }
 
 const HAIR_LENGTH_API_MAP: Record<string, string | undefined> = {
@@ -43,18 +41,37 @@ const HAIR_LENGTH_API_MAP: Record<string, string | undefined> = {
   '短发': 'SHORT',
   '中长发': 'MEDIUM',
   '长发': 'LONG',
+  '未确认': 'UNCONFIRM',
   '全て': undefined,
   'ショート': 'SHORT',
   'ミディアム': 'MEDIUM',
   'ロング': 'LONG',
+  '未確認': 'UNCONFIRM',
 }
 
 function getFollowStatusLabel(status: string | undefined, pageCopy: CustomerListPageCopy) {
   if (status === 'NEED_FOLLOW_UP') return pageCopy.filters.followOptions[1]
-  if (status === 'SENT') return pageCopy.filters.followOptions[2]
+  if (status === 'NO_NEED_FOLLOW_UP') return pageCopy.statusMap?.none || '—'
   return '—'
 }
 
+function getHairLengthLabel(length: string | undefined, pageCopy: CustomerListPageCopy) {
+  if (length === 'SHORT') return pageCopy.hairLengthMap.short
+  if (length === 'MEDIUM') return pageCopy.hairLengthMap.medium
+  if (length === 'LONG') return pageCopy.hairLengthMap.long
+  if (length === 'UNCONFIRM') return pageCopy.statusMap?.none || '—'
+  return '—'
+}
+
+function getVisiblePages(currentPage: number, totalPages: number) {
+  if (totalPages <= 5) {
+    return Array.from({ length: totalPages }, (_, index) => index + 1)
+  }
+
+  const start = Math.max(1, Math.min(currentPage - 2, totalPages - 4))
+  return Array.from({ length: 5 }, (_, index) => start + index)
+}
+
 type CustomerListPageCopy = ReturnType<typeof usePageContext>['copy']['customerListPage']
 
 export function CustomerListPage() {
@@ -62,7 +79,7 @@ export function CustomerListPage() {
   const [items, setItems] = useState<CustomerEntity[]>([])
   const [summary, setSummary] = useState<CustomerDashboardSummary>(emptySummary)
   const [query, setQuery] = useState('')
-  const [page] = useState(1)
+  const [page, setPage] = useState(1)
   const [pageSize] = useState(20)
   const [total, setTotal] = useState(0)
   const [visitFilter, setVisitFilter] = useState(copy.customerListPage.filters.visitOptions[0])
@@ -94,10 +111,23 @@ export function CustomerListPage() {
     return () => clearTimeout(timer)
   }, [loadCustomers])
 
+  useEffect(() => {
+    setPage(1)
+  }, [query, visitFilter, followFilter, styleFilter])
+
   useEffect(() => {
     getCustomerDashboardSummary().then(setSummary).catch(() => setSummary(emptySummary))
   }, [])
 
+  const totalPages = Math.max(1, Math.ceil(total / pageSize))
+  const visiblePages = getVisiblePages(page, totalPages)
+
+  useEffect(() => {
+    if (page > totalPages) {
+      setPage(totalPages)
+    }
+  }, [page, totalPages])
+
   return (
     <DashboardLayout>
       <Helmet>
@@ -171,38 +201,61 @@ export function CustomerListPage() {
 
             <div className="customer-table">
               <div className="customer-row customer-head">
-                {pageCopy.tableHeaders.map((label: string) => <span key={label}>{label}</span>)}
+                {pageCopy.tableHeaders.slice(0, 4).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>
-              ) : items.map((customer, index) => (
+              ) : items.map((customer) => (
                 <div key={customer.id} className="customer-row">
                   <div className="customer-profile">
-                    <img src={customer.image || '/images/aitori-before-photo.png'} alt={customer.name} className="customer-avatar" />
+                    <img src={customer.avatar || '/images/aitori-before-photo.png'} alt={customer.name} className="customer-avatar" />
                     <div>
                       <span className="fw-700 text-dark-gray d-block">{customer.name}</span>
                       <span className="fs-14 text-medium-gray">
                         {customer.phone}
                         {' / '}
-                        {customer.hairLength === 'Medium'
-                          ? pageCopy.hairLengthMap.medium
-                          : customer.hairLength === 'Long'
-                            ? pageCopy.hairLengthMap.long
-                            : customer.hairLength === 'Short'
-                              ? pageCopy.hairLengthMap.short
-                              : '—'}
+                        {getHairLengthLabel(customer.hairLength, pageCopy)}
                       </span>
                     </div>
                   </div>
-                  <span>{customer.lastVisit || '—'}</span>
-                  <span>{customer.concern || '—'}</span>
-                  <span><span className="workbench-badge">{getFollowStatusLabel(customer.followStatus, pageCopy)}</span></span>
-                  <Link to={path(`/customers/${customer.id}`)} className={`btn btn-very-small btn-rounded ${index === 0 ? 'btn-base-color' : 'btn-transparent-light-gray border border-1 border-color-dark-gray'}`}>
-                    {pageCopy.detail}
-                  </Link>
+                  <span>{customer.planDate || '—'}</span>
+                  <span>{customer.trouble || '—'}</span>
+                  <span><span className="workbench-badge">{getFollowStatusLabel(customer.followState, pageCopy)}</span></span>
                 </div>
               ))}
             </div>
+
+            <div className="customer-pager">
+              <button
+                type="button"
+                className={`customer-pager-link${page <= 1 ? ' is-disabled' : ''}`}
+                aria-label={pageCopy.pagination?.prev || (locale === 'ja' ? '前へ' : '上一页')}
+                disabled={page <= 1}
+                onClick={() => setPage((current) => Math.max(1, current - 1))}
+              >
+                {pageCopy.pagination?.prev || (locale === 'ja' ? '前へ' : '上一页')}
+              </button>
+              {visiblePages.map((pageNumber) => (
+                <button
+                  key={pageNumber}
+                  type="button"
+                  className={`customer-pager-link${pageNumber === page ? ' is-active' : ''}`}
+                  aria-current={pageNumber === page ? 'page' : undefined}
+                  onClick={() => setPage(pageNumber)}
+                >
+                  {pageNumber}
+                </button>
+              ))}
+              <button
+                type="button"
+                className={`customer-pager-link${page >= totalPages ? ' is-disabled' : ''}`}
+                aria-label={pageCopy.pagination?.next || (locale === 'ja' ? '次へ' : '下一页')}
+                disabled={page >= totalPages}
+                onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
+              >
+                {pageCopy.pagination?.next || (locale === 'ja' ? '次へ' : '下一页')}
+              </button>
+            </div>
           </div>
         </div>
       </section>

+ 56 - 12
frontend/src/pages/dashboard/customers/__tests__/CustomerFormPage.test.tsx

@@ -21,8 +21,6 @@ vi.mock('../../../../services/api/media', () => ({
 
 vi.mock('../../../../services/api/customers', () => ({
   createCustomer,
-  getCustomerById: vi.fn(),
-  updateCustomer: vi.fn(),
 }))
 
 vi.mock('../../../../shared/pageContext', () => ({
@@ -57,9 +55,10 @@ vi.mock('../../../../shared/pageContext', () => ({
           titleEdit: '咨询信息',
           visitDateNew: '最近到店',
           visitDateEdit: '最近到店',
-          visitPurposeNew: '到店周期',
+          visitPurposeNew: '到店目的',
           visitPurposeEdit: '到店周期',
-          visitPurposeOptions: ['首次'],
+          visitPurposePlaceholder: '请选择到店目的',
+          visitPurposeOptions: ['剪发 + 染发', '剪发', '染发', '模拟咨询'],
           visitCycleOptions: ['30天'],
           concernNew: '关注点',
           concernEdit: '关注点',
@@ -134,7 +133,7 @@ vi.mock('../../../../shared/pageContext', () => ({
 }))
 
 describe('CustomerFormPage', () => {
-  it('uploads avatar images with avatar type before create', async () => {
+  it('submits the redesigned customer payload', async () => {
     uploadMedia.mockResolvedValue({ url: 'https://example.com/avatar.png' })
     createCustomer.mockResolvedValue({ id: '1' })
 
@@ -142,7 +141,7 @@ describe('CustomerFormPage', () => {
       <HelmetProvider>
         <MemoryRouter initialEntries={['/zh/customers/new']}>
           <Routes>
-            <Route path="/:locale/customers/new" element={<CustomerFormPage mode="new" />} />
+            <Route path="/:locale/customers/new" element={<CustomerFormPage />} />
           </Routes>
         </MemoryRouter>
       </HelmetProvider>,
@@ -160,12 +159,57 @@ describe('CustomerFormPage', () => {
     await waitFor(() => {
       expect(uploadMedia).toHaveBeenCalledWith(file, 'avatar')
     })
-    expect(createCustomer).toHaveBeenCalledWith(
-      expect.objectContaining({
-        name: 'Sato Misaki',
-        phone: '090-0000-0000',
-        image: 'https://example.com/avatar.png',
-      }),
+    await waitFor(() => {
+      expect(createCustomer).toHaveBeenCalledWith(
+        expect.objectContaining({
+          name: 'Sato Misaki',
+          phone: '090-0000-0000',
+          avatar: 'https://example.com/avatar.png',
+        }),
+      )
+    })
+    expect(createCustomer.mock.calls[0][0]).not.toHaveProperty('image')
+    expect(createCustomer.mock.calls[0][0]).not.toHaveProperty('isFirstRegister')
+  })
+
+  it('shows a friendly error message when create fails', async () => {
+    uploadMedia.mockReset()
+    createCustomer.mockReset()
+    createCustomer.mockRejectedValue(new Error('Internal server error'))
+
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/customers/new']}>
+          <Routes>
+            <Route path="/:locale/customers/new" element={<CustomerFormPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
     )
+
+    fireEvent.change(screen.getByLabelText(/姓名/i), { target: { value: 'Sato Misaki' } })
+    fireEvent.change(screen.getByLabelText(/电话号码/i), { target: { value: '090-0000-0000' } })
+
+    fireEvent.click(screen.getAllByRole('button', { name: /登记顾客/i })[0])
+
+    await waitFor(() => {
+      expect(screen.getByRole('alert')).toHaveTextContent('保存失败,请稍后重试')
+    })
+  })
+
+  it('shows a visible placeholder option for visit purpose', () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter initialEntries={['/zh/customers/new']}>
+          <Routes>
+            <Route path="/:locale/customers/new" element={<CustomerFormPage />} />
+          </Routes>
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    expect(
+      screen.getByRole('option', { name: '请选择到店目的' }),
+    ).toBeInTheDocument()
   })
 })

+ 71 - 17
frontend/src/pages/dashboard/customers/__tests__/CustomerListPage.test.tsx

@@ -2,26 +2,21 @@ import { HelmetProvider } from 'react-helmet-async'
 import { render, screen, waitFor } from '@testing-library/react'
 import { MemoryRouter } from 'react-router-dom'
 import type { ReactNode } from 'react'
-import { describe, expect, it, vi } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
 import { CustomerListPage } from '../CustomerListPage'
 
+const { listCustomers, getCustomerDashboardSummary } = vi.hoisted(() => ({
+  listCustomers: vi.fn(),
+  getCustomerDashboardSummary: vi.fn(),
+}))
+
 vi.mock('../../../../layouts/DashboardLayout', () => ({
   DashboardLayout: ({ children }: { children: ReactNode }) => <>{children}</>,
 }))
 
 vi.mock('../../../../services/api/customers', () => ({
-  listCustomers: vi.fn().mockResolvedValue({
-    items: [],
-    total: 42,
-    page: 1,
-    pageSize: 20,
-  }),
-  getCustomerDashboardSummary: vi.fn().mockResolvedValue({
-    total: 42,
-    today: 3,
-    followUp: 4,
-    simulated: 2,
-  }),
+  listCustomers,
+  getCustomerDashboardSummary,
 }))
 
 vi.mock('../../../../shared/pageContext', () => ({
@@ -60,6 +55,13 @@ vi.mock('../../../../shared/pageContext', () => ({
           medium: '中长发',
           long: '长发',
         },
+        statusMap: {
+          none: '待设置',
+        },
+        pagination: {
+          prev: '上一页',
+          next: '下一页',
+        },
         detail: '查看详情',
       },
     },
@@ -67,6 +69,36 @@ vi.mock('../../../../shared/pageContext', () => ({
 }))
 
 describe('CustomerListPage', () => {
+  beforeEach(() => {
+    listCustomers.mockReset()
+    getCustomerDashboardSummary.mockReset()
+
+    listCustomers.mockResolvedValue({
+      items: [
+        {
+          id: 'customer-1',
+          name: 'Sato Misaki',
+          phone: '090-0000-0000',
+          avatar: 'https://example.com/avatar.png',
+          hairLength: 'MEDIUM',
+          trouble: '想换发型',
+          planDate: '2026-06-20',
+          followState: 'NO_NEED_FOLLOW_UP',
+        },
+      ],
+      total: 42,
+      page: 1,
+      pageSize: 20,
+    })
+
+    getCustomerDashboardSummary.mockResolvedValue({
+      total: 42,
+      today: 3,
+      followUp: 4,
+      simulated: 2,
+    })
+  })
+
   it('renders backend summary and total search count', async () => {
     render(
       <HelmetProvider>
@@ -80,9 +112,31 @@ describe('CustomerListPage', () => {
       expect(screen.getByText('搜索结果 42件')).toBeInTheDocument()
     })
 
-    expect(screen.getByText('42')).toBeInTheDocument()
-    expect(screen.getByText('3')).toBeInTheDocument()
-    expect(screen.getByText('4')).toBeInTheDocument()
-    expect(screen.getByText('2')).toBeInTheDocument()
+    expect(screen.getAllByText('42').length).toBeGreaterThan(0)
+    expect(screen.getAllByText('3').length).toBeGreaterThan(0)
+    expect(screen.getAllByText('4').length).toBeGreaterThan(0)
+    expect(screen.getAllByText('2').length).toBeGreaterThan(0)
+  })
+
+  it('renders status label and pagination controls', async () => {
+    render(
+      <HelmetProvider>
+        <MemoryRouter>
+          <CustomerListPage />
+        </MemoryRouter>
+      </HelmetProvider>,
+    )
+
+    await waitFor(() => {
+      expect(screen.getByText('待设置')).toBeInTheDocument()
+    })
+
+    expect(screen.getByText('2026-06-20')).toBeInTheDocument()
+    expect(screen.getByText('想换发型')).toBeInTheDocument()
+    expect(screen.getByRole('button', { name: '上一页' })).toBeInTheDocument()
+    expect(screen.getByRole('button', { name: '下一页' })).toBeInTheDocument()
+    expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument()
+    expect(screen.getByRole('button', { name: '2' })).toBeInTheDocument()
+    expect(screen.getByRole('button', { name: '3' })).toBeInTheDocument()
   })
 })

+ 2 - 4
frontend/src/pages/dashboard/simulation/AftercareResultPage.tsx

@@ -23,8 +23,7 @@ export function AftercareResultPage() {
     ? jobIdFromQuery
     : simulationSession.getJobId()
   const isHistoryMode = !!searchParams.get('jobId')
-  const customerId = customerIdFromQuery || simulationSession.getCustomerId()
-  const backTarget = customerId ? path(`/customers/${customerId}`) : path('/simulation')
+  const backTarget = path('/customers')
   const activeJobId = Number.isFinite(jobIdFromQuery) && jobIdFromQuery > 0
     ? jobIdFromQuery
     : simulationSession.getJobId()
@@ -110,8 +109,7 @@ export function AftercareResultPage() {
         recommendedServices: selectedServices,
         bookingStatus,
       })
-      const customerId = simulationSession.getCustomerId()
-      navigate(customerId ? path(`/customers/${customerId}`) : path('/customers'))
+      navigate(path('/customers'))
     } catch (err) {
       setError(err instanceof Error ? err.message : '保存失败')
     } finally {

+ 1 - 1
frontend/src/pages/dashboard/simulation/SimulationEditPage.tsx

@@ -136,7 +136,7 @@ export function SimulationEditPage() {
             <div className="mb-3">
               <p className="fs-14 text-red mb-2" role="alert">{error}</p>
               {error === copy.common.sessionExpired ? (
-                <Link to={historyCustomerId ? path(`/customers/${historyCustomerId}`) : path('/simulation')} className="btn btn-small btn-rounded btn-base-color fw-700">{pageCopy.back}</Link>
+                <Link to={path('/customers')} className="btn btn-small btn-rounded btn-base-color fw-700">{pageCopy.back}</Link>
               ) : null}
             </div>
           ) : null}

+ 2 - 3
frontend/src/pages/dashboard/simulation/SimulationResultPage.tsx

@@ -34,7 +34,7 @@ export function SimulationResultPage() {
     : simulationSession.getJobId()
   const isHistoryMode = !!searchParams.get('jobId')
   const customerId = customerIdFromQuery || simulationSession.getCustomerId()
-  const backTarget = customerId ? path(`/customers/${customerId}`) : path('/simulation')
+  const backTarget = path('/customers')
 
   useEffect(() => {
     let cancelled = false
@@ -125,8 +125,7 @@ export function SimulationResultPage() {
     if (isHistoryMode) return
     try {
       await saveSimulationResult(resultId)
-      const customerId = simulationSession.getCustomerId()
-      navigate(customerId ? path(`/customers/${customerId}`) : path('/customers'))
+      navigate(path('/customers'))
     } catch (err) {
       setError(err instanceof Error ? err.message : '保存失败')
     }

+ 3 - 3
frontend/src/services/api/__tests__/integration-adapters.test.ts

@@ -36,13 +36,13 @@ describe('frontend integration adapters', () => {
     }
 
     const aftercare: AftercareOptionEntity = {
-      id: 'maintenance-cut',
+      id: 'home_styling',
       title: 'Maintenance Cut',
       image: '/images/aitori-simulation-result.png',
     }
 
     expect(galleryItem.id).toBe('tpl_1')
-    expect(aftercare.id).toBe('maintenance-cut')
+    expect(aftercare.id).toBe('home_styling')
     expect(template.templateUrl).toContain('bundle')
     expect(template.generationStatus).toBe('succeeded')
   })
@@ -89,7 +89,7 @@ describe('frontend integration adapters', () => {
   it('returns local aftercare options without requiring a backend endpoint', async () => {
     const result = await listAftercareOptions()
 
-    expect(result.map((item) => item.id)).toEqual(['maintenance-cut', 'bangs-cut', 'treatment'])
+    expect(result.map((item) => item.id)).toEqual(['home_styling', 'after_30_days', 'after_60_days'])
   })
 
   it('does not use mockDb for simulation result fallback shape', async () => {

+ 18 - 24
frontend/src/services/api/contracts.ts

@@ -9,29 +9,27 @@ import type {
 export type CustomerEntity = {
   id: string
   name: string
-  kana?: string
-  summary?: string
-  lastVisit?: string
-  nextVisit?: string
-  hairLength?: string
-  contact?: string
+  namePronunciation?: string
   phone: string
   email?: string
-  concern?: string
-  tags?: string[]
-  image?: string
   birthday?: string
-  visitCycle?: string
-  hairCondition?: string
-  hairType?: string
-  formulaNote?: string
-  privateNote?: string
-  desiredImage?: string
-  allergyNote?: string
-  lineStatus?: string
-  followStatus?: string
-  followTiming?: string
-  followMessage?: string
+  avatar?: string
+  expectContact?: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE'
+  hairLength?: 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM'
+  hairTexture?: 'UNCONFIRM' | 'THIN' | 'NORMAL' | 'HARD_CRUDE' | 'CURLY'
+  notice?: string
+  allergy?: string
+  innerRemark?: string
+  nextFollowDate?: string
+  recommendVisitDate?: string
+  firstSuggestHair?: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT'
+  planDate?: string
+  isFirstRegister?: boolean
+  visitPurpose?: 'CUT_DYE' | 'CUT' | 'DYE' | 'CONSULT_SIM'
+  trouble?: string
+  consultContent?: string
+  expectLook?: string
+  followState?: 'NEED_FOLLOW_UP' | 'NO_NEED_FOLLOW_UP'
 }
 export type TemplateEntity = {
   id: string
@@ -100,10 +98,6 @@ export type TemplateDetailStats = {
   monthlyGeneratedCount: number
 }
 
-export type GetCustomerByIdParams = {
-  customerId?: string
-}
-
 export type GetTemplateByIdParams = {
   templateId?: string
 }

+ 17 - 62
frontend/src/services/api/customers.ts

@@ -1,53 +1,36 @@
-import { http, ApiError, type ListEnvelope } from '../http/client'
+import { http } from '../http/client'
 import type {
   CustomerDashboardSummary,
   CustomerEntity,
-  GetCustomerByIdParams,
   PaginatedCustomerList,
 } from './contracts'
 export type {
   CustomerDashboardSummary,
   CustomerEntity,
-  GetCustomerByIdParams,
   PaginatedCustomerList,
 } from './contracts'
 
 export type CustomerUpsertInput = {
   name: string
+  namePronunciation?: string
   phone: string
-  kana?: string
   email?: string
-  concern?: string
-  hairLength?: string
-  contact?: string
-  image?: string
-  lastVisit?: string
-  nextVisit?: string
   birthday?: string
-  visitCycle?: string
-  hairCondition?: string
-  hairType?: string
-  formulaNote?: string
-  privateNote?: string
-  desiredImage?: string
-  allergyNote?: string
-  lineStatus?: string
-  followStatus?: string
-  followTiming?: string
-  followMessage?: string
-}
-
-export type CustomerHistoryItem = {
-  date: string
-  title: string
-  body: string
-  beforeImage?: string | null
-  afterImage?: string | null
-  jobId?: number | string | null
-  detailPath?: string | null
-  detailUrl?: string | null
-  recordType?: string | null
-  simulationType?: string | null
+  avatar?: string
+  expectContact?: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE'
+  hairLength?: 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM'
+  hairTexture?: 'UNCONFIRM' | 'THIN' | 'NORMAL' | 'HARD_CRUDE' | 'CURLY'
+  notice?: string
+  allergy?: string
+  innerRemark?: string
+  nextFollowDate?: string
+  recommendVisitDate?: string
+  firstSuggestHair?: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT'
+  planDate?: string
+  visitPurpose?: 'CUT_DYE' | 'CUT' | 'DYE' | 'CONSULT_SIM'
+  trouble?: string
+  consultContent?: string
+  expectLook?: string
 }
 
 export async function listCustomers(
@@ -71,38 +54,10 @@ export async function listCustomers(
   return http.get<PaginatedCustomerList>(`/customers${qs ? `?${qs}` : ''}`)
 }
 
-export async function getCustomerById({ customerId }: GetCustomerByIdParams = {}): Promise<CustomerEntity | null> {
-  if (!customerId) return null
-  try {
-    return await http.get<CustomerEntity>(`/customers/${encodeURIComponent(customerId)}`)
-  } catch (error) {
-    if (error instanceof ApiError && error.status === 404) return null
-    throw error
-  }
-}
-
 export async function createCustomer(input: CustomerUpsertInput): Promise<CustomerEntity> {
   return http.post<CustomerEntity>('/customers', input)
 }
 
-export async function updateCustomer(customerId: string, input: CustomerUpsertInput): Promise<CustomerEntity> {
-  return http.patch<CustomerEntity>(`/customers/${encodeURIComponent(customerId)}`, input)
-}
-
-export async function getCustomerHistory(customerId: string): Promise<CustomerHistoryItem[]> {
-  const data = await http.get<ListEnvelope<CustomerHistoryItem>>(
-    `/customers/${encodeURIComponent(customerId)}/records`,
-  )
-  return data.items
-}
-
-export async function sendReminder(
-  customerId: string,
-  body: { recommendedDate?: string; message?: string; preferredContact?: string } = {},
-): Promise<void> {
-  await http.post(`/customers/${encodeURIComponent(customerId)}/reminders`, body)
-}
-
 export async function getCustomerDashboardSummary(): Promise<CustomerDashboardSummary> {
   return http.get<CustomerDashboardSummary>('/customers/summary')
 }

+ 63 - 71
frontend/src/shared/customerForm.ts

@@ -1,106 +1,98 @@
 import type { CustomerEntity, CustomerUpsertInput } from '../services/api/customers'
 
-/** 顾客表单完整状态,字段与后端 CustomerUpsertRequest 对齐 */
 export type CustomerFormValues = {
   name: string
-  kana: string
+  namePronunciation: string
   phone: string
   email: string
   birthday: string
-  contact: string
-  lineStatus: string
-  lastVisit: string
-  nextVisit: string
-  visitCycle: string
-  hairLength: string
-  hairType: string
-  hairCondition: string
-  formulaNote: string
-  privateNote: string
-  desiredImage: string
-  allergyNote: string
-  concern: string
-  followStatus: string
-  followTiming: string
-  followMessage: string
+  expectContact: 'LINE' | 'PHONE' | 'EMAIL' | 'NONE'
+  hairLength: 'SHORT' | 'MEDIUM' | 'LONG' | 'UNCONFIRM'
+  hairTexture: 'UNCONFIRM' | 'THIN' | 'NORMAL' | 'HARD_CRUDE' | 'CURLY'
+  notice: string
+  allergy: string
+  innerRemark: string
+  nextFollowDate: string
+  recommendVisitDate: string
+  firstSuggestHair: 'AUTO' | 'LONG' | 'MEDIUM' | 'SHORT'
+  planDate: string
+  visitPurpose: 'CUT_DYE' | 'CUT' | 'DYE' | 'CONSULT_SIM' | ''
+  trouble: string
+  consultContent: string
+  expectLook: string
 }
 
 export function emptyCustomerForm(): CustomerFormValues {
   return {
     name: '',
-    kana: '',
+    namePronunciation: '',
     phone: '',
     email: '',
     birthday: '',
-    contact: 'LINE',
-    lineStatus: '',
-    lastVisit: '',
-    nextVisit: '',
-    visitCycle: '',
-    hairLength: 'Medium',
-    hairType: '',
-    hairCondition: '',
-    formulaNote: '',
-    privateNote: '',
-    desiredImage: '',
-    allergyNote: '',
-    concern: '',
-    followStatus: '',
-    followTiming: '',
-    followMessage: '',
+    expectContact: 'NONE',
+    hairLength: 'UNCONFIRM',
+    hairTexture: 'UNCONFIRM',
+    notice: '',
+    allergy: '',
+    innerRemark: '',
+    nextFollowDate: '',
+    recommendVisitDate: '',
+    firstSuggestHair: 'AUTO',
+    planDate: '',
+    visitPurpose: '',
+    trouble: '',
+    consultContent: '',
+    expectLook: '',
   }
 }
 
 export function customerToFormValues(customer: CustomerEntity): CustomerFormValues {
   return {
     name: customer.name || '',
-    kana: customer.kana || '',
+    namePronunciation: customer.namePronunciation || '',
     phone: customer.phone || '',
     email: customer.email || '',
     birthday: customer.birthday || '',
-    contact: customer.contact || 'LINE',
-    lineStatus: customer.lineStatus || '',
-    lastVisit: customer.lastVisit || '',
-    nextVisit: customer.nextVisit || '',
-    visitCycle: customer.visitCycle || '',
-    hairLength: customer.hairLength || 'Medium',
-    hairType: customer.hairType || '',
-    hairCondition: customer.hairCondition || '',
-    formulaNote: customer.formulaNote || '',
-    privateNote: customer.privateNote || '',
-    desiredImage: customer.desiredImage || '',
-    allergyNote: customer.allergyNote || '',
-    concern: customer.concern || '',
-    followStatus: customer.followStatus || '',
-    followTiming: customer.followTiming || '',
-    followMessage: customer.followMessage || '',
+    expectContact: customer.expectContact || 'NONE',
+    hairLength: customer.hairLength || 'UNCONFIRM',
+    hairTexture: customer.hairTexture || 'UNCONFIRM',
+    notice: customer.notice || '',
+    allergy: customer.allergy || '',
+    innerRemark: customer.innerRemark || '',
+    nextFollowDate: customer.nextFollowDate || '',
+    recommendVisitDate: customer.recommendVisitDate || '',
+    firstSuggestHair: customer.firstSuggestHair || 'AUTO',
+    planDate: customer.planDate || '',
+    visitPurpose: customer.visitPurpose || '',
+    trouble: customer.trouble || '',
+    consultContent: customer.consultContent || '',
+    expectLook: customer.expectLook || '',
   }
 }
 
-export function formValuesToPayload(values: CustomerFormValues, image?: string): CustomerUpsertInput {
+export function formValuesToPayload(values: CustomerFormValues, avatar?: string): CustomerUpsertInput {
   const pick = (v: string) => (v.trim() ? v.trim() : undefined)
+
   return {
     name: values.name.trim(),
     phone: values.phone.trim(),
-    kana: pick(values.kana),
+    namePronunciation: pick(values.namePronunciation),
     email: pick(values.email),
     birthday: pick(values.birthday),
-    contact: pick(values.contact),
-    lineStatus: pick(values.lineStatus),
-    lastVisit: pick(values.lastVisit),
-    nextVisit: pick(values.nextVisit),
-    visitCycle: pick(values.visitCycle),
-    hairLength: pick(values.hairLength),
-    hairType: pick(values.hairType),
-    hairCondition: pick(values.hairCondition),
-    formulaNote: pick(values.formulaNote),
-    privateNote: pick(values.privateNote),
-    desiredImage: pick(values.desiredImage),
-    allergyNote: pick(values.allergyNote),
-    concern: pick(values.concern),
-    followStatus: pick(values.followStatus),
-    followTiming: pick(values.followTiming),
-    followMessage: pick(values.followMessage),
-    image,
+    avatar,
+    expectContact: values.expectContact,
+    hairLength: values.hairLength,
+    hairTexture: values.hairTexture,
+    notice: pick(values.notice),
+    allergy: pick(values.allergy),
+    innerRemark: pick(values.innerRemark),
+    nextFollowDate: pick(values.nextFollowDate),
+    recommendVisitDate: pick(values.recommendVisitDate),
+    firstSuggestHair: values.firstSuggestHair,
+    planDate: pick(values.planDate),
+    visitPurpose: values.visitPurpose || undefined,
+    trouble: pick(values.trouble),
+    consultContent: pick(values.consultContent),
+    expectLook: pick(values.expectLook),
   }
 }