Przeglądaj źródła

feat: 残疾人管理表单优化 - 监护人电话管理功能

实现功能:
- 创建 DisabledPersonGuardianPhone 实体(监护人电话表)
- 更新 DisabledPerson 实体添加监护人电话关系
- 添加监护人电话 Schema(DisabledPersonGuardianPhoneSchema)
- 创建 GuardianPhoneManagement UI 组件
- 集成监护人电话组件到残疾人管理表单
- 支持添加多个监护人电话,设置主要联系人
- 支持选择与残疾人的关系(父母、配偶、子女等)

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname 1 dzień temu
rodzic
commit
a4f9079338

+ 52 - 0
allin-packages/disability-module/src/entities/disabled-person-guardian-phone.entity.ts

@@ -0,0 +1,52 @@
+import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn } from 'typeorm';
+import { DisabledPerson } from './disabled-person.entity';
+
+@Entity('disabled_person_guardian_phone')
+export class DisabledPersonGuardianPhone {
+  @PrimaryGeneratedColumn({
+    name: 'id',
+    type: 'int',
+    comment: '监护人电话ID'
+  })
+  id!: number;
+
+  @Column({
+    name: 'person_id',
+    type: 'int',
+    nullable: false,
+    comment: '残疾人ID'
+  })
+  personId!: number;
+
+  @Column({
+    name: 'phone_number',
+    type: 'varchar',
+    length: 20,
+    nullable: false,
+    comment: '监护人电话号码'
+  })
+  phoneNumber!: string;
+
+  @Column({
+    name: 'relationship',
+    type: 'varchar',
+    length: 50,
+    nullable: false,
+    comment: '与残疾人的关系(如:父母、配偶、子女等)'
+  })
+  relationship!: string;
+
+  @Column({
+    name: 'is_primary',
+    type: 'smallint',
+    nullable: false,
+    default: 0,
+    comment: '是否为主要联系人:1-是,0-否'
+  })
+  isPrimary!: number;
+
+  // 关系定义 - 残疾人
+  @ManyToOne(() => DisabledPerson, person => person.guardianPhones, { onDelete: 'CASCADE' })
+  @JoinColumn({ name: 'person_id' })
+  person!: DisabledPerson;
+}

+ 4 - 0
allin-packages/disability-module/src/entities/disabled-person.entity.ts

@@ -3,6 +3,7 @@ import { DisabledBankCard } from './disabled-bank-card.entity';
 import { DisabledPhoto } from './disabled-photo.entity';
 import { DisabledRemark } from './disabled-remark.entity';
 import { DisabledVisit } from './disabled-visit.entity';
+import { DisabledPersonGuardianPhone } from './disabled-person-guardian-phone.entity';
 import { OrderPerson } from '@d8d/allin-order-module/entities';
 
 @Entity('disabled_person')
@@ -235,4 +236,7 @@ export class DisabledPerson {
 
   @OneToMany(() => OrderPerson, (orderPerson: OrderPerson) => orderPerson.person)
   orderPersons!: OrderPerson[];
+
+  @OneToMany(() => DisabledPersonGuardianPhone, (guardianPhone: DisabledPersonGuardianPhone) => guardianPhone.person)
+  guardianPhones!: DisabledPersonGuardianPhone[];
 }

+ 2 - 1
allin-packages/disability-module/src/entities/index.ts

@@ -2,4 +2,5 @@ export { DisabledPerson } from './disabled-person.entity';
 export { DisabledBankCard } from './disabled-bank-card.entity';
 export { DisabledPhoto } from './disabled-photo.entity';
 export { DisabledRemark } from './disabled-remark.entity';
-export { DisabledVisit } from './disabled-visit.entity';
+export { DisabledVisit } from './disabled-visit.entity';
+export { DisabledPersonGuardianPhone } from './disabled-person-guardian-phone.entity';

+ 1 - 1
allin-packages/disability-module/src/index.ts

@@ -1,5 +1,5 @@
 // 导出实体
-export { DisabledPerson, DisabledBankCard, DisabledPhoto, DisabledRemark, DisabledVisit } from './entities';
+export { DisabledPerson, DisabledBankCard, DisabledPhoto, DisabledRemark, DisabledVisit, DisabledPersonGuardianPhone } from './entities';
 
 // 导出服务
 export { DisabledPersonService, AggregatedService } from './services';

+ 69 - 0
allin-packages/disability-module/src/schemas/disabled-person.schema.ts

@@ -550,6 +550,66 @@ export const DisabledVisitSchema = z.object({
   })
 });
 
+// 监护人电话实体Schema
+export const DisabledPersonGuardianPhoneSchema = z.object({
+  id: z.number().int().positive().openapi({
+    description: '监护人电话ID',
+    example: 1
+  }),
+  personId: z.number().int().positive().openapi({
+    description: '残疾人ID',
+    example: 1
+  }),
+  phoneNumber: z.string().min(1).max(20).openapi({
+    description: '监护人电话号码',
+    example: '13900139000'
+  }),
+  relationship: z.string().min(1).max(50).openapi({
+    description: '与残疾人的关系(如:父母、配偶、子女等)',
+    example: '父母'
+  }),
+  isPrimary: z.number().int().min(0).max(1).default(0).openapi({
+    description: '是否为主要联系人:1-是,0-否',
+    example: 1
+  })
+});
+
+// 创建监护人电话DTO
+export const CreateDisabledPersonGuardianPhoneSchema = z.object({
+  personId: z.number().int().positive().openapi({
+    description: '残疾人ID',
+    example: 1
+  }),
+  phoneNumber: z.string().min(1).max(20).openapi({
+    description: '监护人电话号码',
+    example: '13900139000'
+  }),
+  relationship: z.string().min(1).max(50).openapi({
+    description: '与残疾人的关系(如:父母、配偶、子女等)',
+    example: '父母'
+  }),
+  isPrimary: z.number().int().min(0).max(1).default(0).optional().openapi({
+    description: '是否为主要联系人:1-是,0-否',
+    example: 1
+  })
+});
+
+// 更新监护人电话DTO
+export const UpdateDisabledPersonGuardianPhoneSchema = z.object({
+  phoneNumber: z.string().min(1).max(20).optional().openapi({
+    description: '监护人电话号码',
+    example: '13900139000'
+  }),
+  relationship: z.string().min(1).max(50).optional().openapi({
+    description: '与残疾人的关系(如:父母、配偶、子女等)',
+    example: '父母'
+  }),
+  isPrimary: z.number().int().min(0).max(1).optional().openapi({
+    description: '是否为主要联系人:1-是,0-否',
+    example: 1
+  })
+});
+
 // 创建聚合残疾人信息Schema
 export const CreateAggregatedDisabledPersonSchema = z.object({
   personInfo: CreateDisabledPersonSchema.openapi({
@@ -566,6 +626,9 @@ export const CreateAggregatedDisabledPersonSchema = z.object({
   }),
   visits: z.array(DisabledVisitSchema.omit({ id: true, personId: true, visitTime: true })).optional().openapi({
     description: '回访记录列表'
+  }),
+  guardianPhones: z.array(DisabledPersonGuardianPhoneSchema.omit({ id: true, personId: true })).optional().openapi({
+    description: '监护人电话列表'
   })
 });
 
@@ -585,6 +648,9 @@ export const AggregatedDisabledPersonSchema = z.object({
   }),
   visits: z.array(DisabledVisitSchema).openapi({
     description: '回访记录列表'
+  }),
+  guardianPhones: z.array(DisabledPersonGuardianPhoneSchema).openapi({
+    description: '监护人电话列表'
   })
 });
 
@@ -599,5 +665,8 @@ export type DisabledBankCard = z.infer<typeof DisabledBankCardSchema>;
 export type DisabledPhoto = z.infer<typeof DisabledPhotoSchema>;
 export type DisabledRemark = z.infer<typeof DisabledRemarkSchema>;
 export type DisabledVisit = z.infer<typeof DisabledVisitSchema>;
+export type DisabledPersonGuardianPhone = z.infer<typeof DisabledPersonGuardianPhoneSchema>;
+export type CreateDisabledPersonGuardianPhoneDto = z.infer<typeof CreateDisabledPersonGuardianPhoneSchema>;
+export type UpdateDisabledPersonGuardianPhoneDto = z.infer<typeof UpdateDisabledPersonGuardianPhoneSchema>;
 export type CreateAggregatedDisabledPersonDto = z.infer<typeof CreateAggregatedDisabledPersonSchema>;
 export type AggregatedDisabledPerson = z.infer<typeof AggregatedDisabledPersonSchema>;

+ 49 - 0
allin-packages/disability-person-management-ui/src/components/DisabilityPersonManagement.tsx

@@ -28,6 +28,7 @@ import PhotoPreview from './PhotoPreview';
 import BankCardManagement, { type BankCardItem } from './BankCardManagement';
 import RemarkManagement, { type RemarkItem } from './RemarkManagement';
 import VisitManagement, { type VisitItem } from './VisitManagement';
+import GuardianPhoneManagement, { type GuardianPhoneItem } from './GuardianPhoneManagement';
 import { parseIdCard } from '../utils/idCardParser';
 import { parseDisabilityId } from '../utils/disabilityIdParser';
 
@@ -59,6 +60,8 @@ const DisabilityPersonManagement: React.FC = () => {
   const [updateRemarks, setUpdateRemarks] = useState<RemarkItem[]>([]);
   const [createVisits, setCreateVisits] = useState<VisitItem[]>([]);
   const [updateVisits, setUpdateVisits] = useState<VisitItem[]>([]);
+  const [createGuardianPhones, setCreateGuardianPhones] = useState<GuardianPhoneItem[]>([]);
+  const [updateGuardianPhones, setUpdateGuardianPhones] = useState<GuardianPhoneItem[]>([]);
   const [currentUserId] = useState<number>(1); // 假设当前用户ID为1,实际应从认证状态获取
 
   // 表单实例 - 创建表单
@@ -274,6 +277,13 @@ const DisabilityPersonManagement: React.FC = () => {
             visitResult: visit.visitResult,
             nextVisitDate: visit.nextVisitDate,
             visitorId: visit.visitorId
+          })),
+        guardianPhones: createGuardianPhones
+          .filter(phone => phone.phoneNumber && phone.relationship)
+          .map(phone => ({
+            phoneNumber: phone.phoneNumber,
+            relationship: phone.relationship,
+            isPrimary: phone.isPrimary
           }))
       };
 
@@ -295,6 +305,7 @@ const DisabilityPersonManagement: React.FC = () => {
       setCreateBankCards([]); // 重置银行卡状态
       setCreateRemarks([]); // 重置备注状态
       setCreateVisits([]); // 重置回访状态
+      setCreateGuardianPhones([]); // 重置监护人电话状态
       refetch();
     },
     onError: (error) => {
@@ -372,6 +383,13 @@ const DisabilityPersonManagement: React.FC = () => {
             visitResult: visit.visitResult,
             nextVisitDate: visit.nextVisitDate,
             visitorId: visit.visitorId
+          })),
+        guardianPhones: updateGuardianPhones
+          .filter(phone => phone.phoneNumber && phone.relationship)
+          .map(phone => ({
+            phoneNumber: phone.phoneNumber,
+            relationship: phone.relationship,
+            isPrimary: phone.isPrimary
           }))
       };
 
@@ -393,6 +411,7 @@ const DisabilityPersonManagement: React.FC = () => {
       setUpdateBankCards([]); // 重置银行卡状态
       setUpdateRemarks([]); // 重置备注状态
       setUpdateVisits([]); // 重置回访状态
+      setUpdateGuardianPhones([]); // 重置监护人电话状态
       refetch();
     },
     onError: (error) => {
@@ -436,6 +455,7 @@ const DisabilityPersonManagement: React.FC = () => {
     setCreateBankCards([]); // 重置创建银行卡状态
     setCreateRemarks([]); // 重置创建备注状态
     setCreateVisits([]); // 重置创建回访状态
+    setCreateGuardianPhones([]); // 重置创建监护人电话状态
     setIsModalOpen(true);
   };
 
@@ -447,6 +467,7 @@ const DisabilityPersonManagement: React.FC = () => {
     setUpdateBankCards([]);
     setUpdateRemarks([]);
     setUpdateVisits([]);
+    setUpdateGuardianPhones([]);
 
     // 加载聚合数据获取照片、银行卡、备注、回访信息
     if (person.id) {
@@ -507,6 +528,17 @@ const DisabilityPersonManagement: React.FC = () => {
             }));
             setUpdateVisits(visits);
           }
+
+          // 加载监护人电话信息
+          if (aggregatedData && aggregatedData.guardianPhones) {
+            const guardianPhones: GuardianPhoneItem[] = aggregatedData.guardianPhones.map((phone: any) => ({
+              phoneNumber: phone.phoneNumber,
+              relationship: phone.relationship,
+              isPrimary: phone.isPrimary,
+              tempId: `existing-phone-${phone.id || Date.now()}`
+            }));
+            setUpdateGuardianPhones(guardianPhones);
+          }
         }
       }).catch(error => {
         console.error('加载聚合数据失败:', error);
@@ -1182,6 +1214,14 @@ const DisabilityPersonManagement: React.FC = () => {
                         visitTypes={['电话回访', '上门回访', '视频回访', '微信回访', '其他']}
                       />
                     </div>
+
+                    <div className="col-span-full">
+                      <GuardianPhoneManagement
+                        value={createGuardianPhones}
+                        onChange={setCreateGuardianPhones}
+                        maxPhones={5}
+                      />
+                    </div>
                   </div>
                 </form>
             </Form>
@@ -1587,6 +1627,15 @@ const DisabilityPersonManagement: React.FC = () => {
                         visitTypes={['电话回访', '上门回访', '视频回访', '微信回访', '其他']}
                       />
                     </div>
+
+                    {/* 监护人电话管理 */}
+                    <div className="col-span-full">
+                      <GuardianPhoneManagement
+                        value={updateGuardianPhones}
+                        onChange={setUpdateGuardianPhones}
+                        maxPhones={5}
+                      />
+                    </div>
                   </div>
                 </div>
               </form>

+ 208 - 0
allin-packages/disability-person-management-ui/src/components/GuardianPhoneManagement.tsx

@@ -0,0 +1,208 @@
+import React, { useState, useEffect } from 'react';
+import { Button } from '@d8d/shared-ui-components/components/ui/button';
+import { Card, CardContent } from '@d8d/shared-ui-components/components/ui/card';
+import { Label } from '@d8d/shared-ui-components/components/ui/label';
+import { Input } from '@d8d/shared-ui-components/components/ui/input';
+import { Switch } from '@d8d/shared-ui-components/components/ui/switch';
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@d8d/shared-ui-components/components/ui/select';
+import { Plus, Trash2, Phone } from 'lucide-react';
+import { toast } from 'sonner';
+
+// 与残疾人的关系枚举
+export const GUARDIAN_RELATIONSHIPS = [
+  '父母',
+  '配偶',
+  '子女',
+  '兄弟姐妹',
+  '祖父母/外祖父母',
+  '其他亲属',
+  '监护人',
+  '其他'
+] as const;
+
+export interface GuardianPhoneItem {
+  phoneNumber: string;
+  relationship: string;
+  isPrimary: number;
+  tempId?: string; // 临时ID用于React key
+}
+
+export interface GuardianPhoneManagementProps {
+  value?: GuardianPhoneItem[];
+  onChange?: (guardianPhones: GuardianPhoneItem[]) => void;
+  maxPhones?: number;
+}
+
+export const GuardianPhoneManagement: React.FC<GuardianPhoneManagementProps> = ({
+  value = [],
+  onChange,
+  maxPhones = 5,
+}) => {
+  const [guardianPhones, setGuardianPhones] = useState<GuardianPhoneItem[]>(value);
+
+  // 同步外部value变化
+  useEffect(() => {
+    setGuardianPhones(value);
+  }, [value]);
+
+  const handleAddGuardianPhone = () => {
+    if (guardianPhones.length >= maxPhones) {
+      toast.warning(`最多只能添加 ${maxPhones} 个监护人电话`);
+      return;
+    }
+
+    const newGuardianPhone: GuardianPhoneItem = {
+      phoneNumber: '',
+      relationship: '',
+      isPrimary: 0,
+      tempId: `temp-${Date.now()}-${Math.random()}`,
+    };
+
+    const newGuardianPhones = [...guardianPhones, newGuardianPhone];
+    setGuardianPhones(newGuardianPhones);
+    onChange?.(newGuardianPhones);
+  };
+
+  const handleRemoveGuardianPhone = (index: number) => {
+    const newGuardianPhones = guardianPhones.filter((_, i) => i !== index);
+    setGuardianPhones(newGuardianPhones);
+    onChange?.(newGuardianPhones);
+  };
+
+  const handleFieldChange = (index: number, field: keyof GuardianPhoneItem, value: string | number) => {
+    const newGuardianPhones = [...guardianPhones];
+    newGuardianPhones[index] = { ...newGuardianPhones[index], [field]: value };
+
+    // 如果设置为主要联系人,其他联系人取消主要状态
+    if (field === 'isPrimary' && value === 1) {
+      newGuardianPhones.forEach((phone, i) => {
+        if (i !== index) {
+          phone.isPrimary = 0;
+        }
+      });
+    }
+
+    setGuardianPhones(newGuardianPhones);
+    onChange?.(newGuardianPhones);
+  };
+
+  const validatePhoneNumber = (phoneNumber: string) => {
+    // 中国手机号验证:11位数字,1开头
+    const phoneRegex = /^1[3-9]\d{9}$/;
+    return phoneRegex.test(phoneNumber);
+  };
+
+  return (
+    <div className="space-y-4">
+      <div className="flex items-center justify-between">
+        <Label>监护人电话管理</Label>
+        <Button
+          type="button"
+          variant="outline"
+          size="sm"
+          onClick={handleAddGuardianPhone}
+          disabled={guardianPhones.length >= maxPhones}
+          data-testid="add-guardian-phone-button"
+        >
+          <Plus className="h-4 w-4 mr-2" />
+          添加监护人电话
+        </Button>
+      </div>
+
+      {guardianPhones.length === 0 ? (
+        <Card>
+          <CardContent className="pt-6">
+            <div className="flex flex-col items-center justify-center py-8 text-center">
+              <Phone className="h-12 w-12 text-muted-foreground mb-4" />
+              <p className="text-sm text-muted-foreground">暂无监护人电话信息</p>
+              <p className="text-xs text-muted-foreground mt-1">点击"添加监护人电话"按钮添加监护人电话</p>
+            </div>
+          </CardContent>
+        </Card>
+      ) : (
+        <div className="space-y-4">
+          {guardianPhones.map((phone, index) => (
+            <Card key={phone.tempId || index} className="relative">
+              <CardContent className="pt-6">
+                <div className="absolute top-4 right-4">
+                  <Button
+                    type="button"
+                    variant="ghost"
+                    size="sm"
+                    onClick={() => handleRemoveGuardianPhone(index)}
+                    data-testid={`remove-guardian-phone-${index}`}
+                  >
+                    <Trash2 className="h-4 w-4 text-destructive" />
+                  </Button>
+                </div>
+
+                <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+                  <div className="space-y-2">
+                    <Label htmlFor={`phoneNumber-${index}`}>电话号码 *</Label>
+                    <Input
+                      id={`phoneNumber-${index}`}
+                      value={phone.phoneNumber}
+                      onChange={(e) => handleFieldChange(index, 'phoneNumber', e.target.value)}
+                      placeholder="请输入11位手机号"
+                      data-testid={`phone-number-input-${index}`}
+                    />
+                    {phone.phoneNumber && !validatePhoneNumber(phone.phoneNumber) && (
+                      <p className="text-xs text-destructive">请输入有效的11位手机号</p>
+                    )}
+                  </div>
+
+                  <div className="space-y-2">
+                    <Label htmlFor={`relationship-${index}`}>与残疾人的关系 *</Label>
+                    <Select
+                      value={phone.relationship || ''}
+                      onValueChange={(value) => handleFieldChange(index, 'relationship', value)}
+                    >
+                      <SelectTrigger id={`relationship-${index}`} data-testid={`relationship-select-${index}`}>
+                        <SelectValue placeholder="请选择关系" />
+                      </SelectTrigger>
+                      <SelectContent>
+                        {GUARDIAN_RELATIONSHIPS.map((rel) => (
+                          <SelectItem key={rel} value={rel}>
+                            {rel}
+                          </SelectItem>
+                        ))}
+                      </SelectContent>
+                    </Select>
+                  </div>
+
+                  <div className="space-y-2 flex items-center">
+                    <div className="flex items-center space-x-2">
+                      <Switch
+                        checked={phone.isPrimary === 1}
+                        onCheckedChange={(checked) => handleFieldChange(index, 'isPrimary', checked ? 1 : 0)}
+                        data-testid={`primary-phone-switch-${index}`}
+                      />
+                      <Label htmlFor={`isPrimary-${index}`}>设为主要联系人</Label>
+                    </div>
+                    {phone.isPrimary === 1 && (
+                      <p className="text-xs text-muted-foreground ml-2">主要联系人</p>
+                    )}
+                  </div>
+                </div>
+              </CardContent>
+            </Card>
+          ))}
+        </div>
+      )}
+
+      <div className="text-xs text-muted-foreground">
+        <p>• 最多可添加 {maxPhones} 个监护人电话</p>
+        <p>• 只能设置一个主要联系人</p>
+        <p>• 主要联系人将优先用于联系</p>
+      </div>
+    </div>
+  );
+};
+
+export default GuardianPhoneManagement;