diff --git a/.DS_Store b/.DS_Store index afd3fbcb..57e47aa2 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/backend/README.md b/backend/README.md index b034844f..ca363fde 100644 --- a/backend/README.md +++ b/backend/README.md @@ -37,13 +37,17 @@ backend/ ## 启动前准备 -1. 执行数据库脚本: +1. 初始化数据库: ```bash -psql -h 127.0.0.1 -U postgres -d nex_auth -f sql/init_pg17.sql +psql -h 127.0.0.1 -U postgres -d nex_auth -f sql/init_full_pg17.sql ``` -2. 确保 `sys_user`、`work_todo`、`sys_activity_log`、`crm_customer`、`crm_opportunity`、`work_checkin` 中有业务数据。 +2. 如果是全新环境,先准备基础框架(`unisbase`)相关表与基础数据,再执行上面的 CRM 脚本。 + +3. 详细部署顺序见: + +- [docs/deployment-guide.md](/Users/kangwenjing/Downloads/crm/unis_crm/docs/deployment-guide.md) ## 启动项目 @@ -52,14 +56,14 @@ cd backend mvn spring-boot:run ``` -默认启动在 `8081` 端口,供前端开发环境通过 Vite 代理访问;`8080` 可继续保留给现有认证/系统服务。 +默认启动在 `8080` 端口,供前端开发环境通过 Vite 代理访问。 ## 首页接口 请求示例: ```bash -curl -H "X-User-Id: 1" "http://127.0.0.1:8081/api/dashboard/home" +curl -H "X-User-Id: 1" "http://127.0.0.1:8080/api/dashboard/home" ``` 首页接口只允许查询当前登录用户自己的数据,必须通过 `X-User-Id` 传入当前用户ID,不支持指定其他用户查询。 diff --git a/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java b/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java index 3144ea4e..493298ae 100644 --- a/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java @@ -22,7 +22,9 @@ public class ChannelExpansionItemDTO { private String intentLevel; private String intent; private Boolean hasDesktopExp; + private String channelAttributeCode; private String channelAttribute; + private String internalAttributeCode; private String internalAttribute; private String stageCode; private String stage; @@ -173,6 +175,14 @@ public class ChannelExpansionItemDTO { return channelAttribute; } + public String getChannelAttributeCode() { + return channelAttributeCode; + } + + public void setChannelAttributeCode(String channelAttributeCode) { + this.channelAttributeCode = channelAttributeCode; + } + public void setChannelAttribute(String channelAttribute) { this.channelAttribute = channelAttribute; } @@ -181,6 +191,14 @@ public class ChannelExpansionItemDTO { return internalAttribute; } + public String getInternalAttributeCode() { + return internalAttributeCode; + } + + public void setInternalAttributeCode(String internalAttributeCode) { + this.internalAttributeCode = internalAttributeCode; + } + public void setInternalAttribute(String internalAttribute) { this.internalAttribute = internalAttribute; } diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java index 542793cf..fc63c938 100644 --- a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java @@ -20,6 +20,7 @@ public class OpportunityItemDTO { private String stageCode; private String stage; private String type; + private Boolean archived; private Boolean pushedToOms; private String product; private String source; @@ -145,6 +146,14 @@ public class OpportunityItemDTO { this.type = type; } + public Boolean getArchived() { + return archived; + } + + public void setArchived(Boolean archived) { + this.archived = archived; + } + public Boolean getPushedToOms() { return pushedToOms; } diff --git a/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java b/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java index 370997f4..b38d7173 100644 --- a/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java +++ b/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java @@ -57,6 +57,7 @@ public interface ExpansionMapper { int deleteChannelContacts(@Param("channelExpansionId") Long channelExpansionId); + @DataScope(tableAlias = "s", ownerColumn = "owner_user_id") int updateSalesExpansion(@Param("userId") Long userId, @Param("id") Long id, @Param("request") UpdateSalesExpansionRequest request); int countSalesExpansionByEmployeeNo(@Param("userId") Long userId, @Param("employeeNo") String employeeNo); @@ -66,10 +67,13 @@ public interface ExpansionMapper { @Param("employeeNo") String employeeNo, @Param("excludeId") Long excludeId); + @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") int updateChannelExpansion(@Param("userId") Long userId, @Param("id") Long id, @Param("request") UpdateChannelExpansionRequest request); + @DataScope(tableAlias = "s", ownerColumn = "owner_user_id") int countOwnedSalesExpansion(@Param("userId") Long userId, @Param("id") Long id); + @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") int countOwnedChannelExpansion(@Param("userId") Long userId, @Param("id") Long id); int insertExpansionFollowUp( diff --git a/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java b/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java index e4b2e4f4..86f94df1 100644 --- a/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java +++ b/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java @@ -30,6 +30,7 @@ public interface OpportunityMapper { @Param("userId") Long userId, @Param("opportunityIds") List opportunityIds); + @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") Long selectOwnedCustomerIdByName(@Param("userId") Long userId, @Param("customerName") String customerName); int insertCustomer( @@ -43,16 +44,20 @@ public interface OpportunityMapper { @Param("customerId") Long customerId, @Param("request") CreateOpportunityRequest request); + @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") int countOwnedOpportunity(@Param("userId") Long userId, @Param("id") Long id); + @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") Boolean selectPushedToOms(@Param("userId") Long userId, @Param("id") Long id); + @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") int updateOpportunity( @Param("userId") Long userId, @Param("opportunityId") Long opportunityId, @Param("customerId") Long customerId, @Param("request") CreateOpportunityRequest request); + @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") int pushOpportunityToOms( @Param("userId") Long userId, @Param("opportunityId") Long opportunityId); diff --git a/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java index 672aed76..145f7371 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java @@ -17,7 +17,12 @@ import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest; import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest; import com.unis.crm.mapper.ExpansionMapper; import com.unis.crm.service.ExpansionService; +import java.math.BigDecimal; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.Collections; import java.util.List; import java.util.Map; @@ -35,6 +40,7 @@ public class ExpansionServiceImpl implements ExpansionService { private static final String INDUSTRY_TYPE_CODE = "tz_sshy"; private static final String CHANNEL_ATTRIBUTE_TYPE_CODE = "tz_qdsx"; private static final String INTERNAL_ATTRIBUTE_TYPE_CODE = "tz_xhsnbsx"; + private static final String MULTI_VALUE_CUSTOM_PREFIX = "__custom__:"; private static final DateTimeFormatter FOLLOW_UP_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); private static final Logger log = LoggerFactory.getLogger(ExpansionServiceImpl.class); @@ -75,6 +81,7 @@ public class ExpansionServiceImpl implements ExpansionService { attachChannelFollowUps(userId, channelItems); attachChannelContacts(userId, channelItems); attachChannelRelatedProjects(userId, channelItems); + fillChannelAttributeDisplay(channelItems); return new ExpansionOverviewDTO(salesItems, channelItems); } @@ -251,6 +258,79 @@ public class ExpansionServiceImpl implements ExpansionService { return expansionMapper.selectDictItems(typeCode); } + private void fillChannelAttributeDisplay(List channelItems) { + if (channelItems == null || channelItems.isEmpty()) { + return; + } + + Map channelAttributeLabels = toDictLabelMap(loadDictOptions(CHANNEL_ATTRIBUTE_TYPE_CODE)); + Map internalAttributeLabels = toDictLabelMap(loadDictOptions(INTERNAL_ATTRIBUTE_TYPE_CODE)); + + for (ChannelExpansionItemDTO item : channelItems) { + item.setChannelAttribute(formatMultiValueDisplay(item.getChannelAttributeCode(), channelAttributeLabels)); + item.setInternalAttribute(formatMultiValueDisplay(item.getInternalAttributeCode(), internalAttributeLabels)); + } + } + + private Map toDictLabelMap(List options) { + Map labelMap = new LinkedHashMap<>(); + for (DictOptionDTO option : options) { + if (option == null || isBlank(option.getValue()) || isBlank(option.getLabel())) { + continue; + } + labelMap.put(option.getValue().trim(), option.getLabel().trim()); + } + return labelMap; + } + + private String formatMultiValueDisplay(String rawValue, Map labelMap) { + if (isBlank(rawValue)) { + return "无"; + } + + List displayValues = new ArrayList<>(); + String customText = null; + + for (String token : rawValue.split(",")) { + String normalizedToken = token == null ? "" : token.trim(); + if (normalizedToken.isEmpty()) { + continue; + } + + if (normalizedToken.startsWith(MULTI_VALUE_CUSTOM_PREFIX)) { + customText = decodeCustomText(normalizedToken.substring(MULTI_VALUE_CUSTOM_PREFIX.length())); + continue; + } + + String label = labelMap.getOrDefault(normalizedToken, normalizedToken); + if (!isBlank(customText) && isOtherOption(normalizedToken, label)) { + displayValues.add(label + "(" + customText + ")"); + customText = null; + } else { + displayValues.add(label); + } + } + + if (!isBlank(customText)) { + displayValues.add(customText); + } + + LinkedHashSet uniqueValues = new LinkedHashSet<>(displayValues); + return uniqueValues.isEmpty() ? "无" : String.join("、", uniqueValues); + } + + private String decodeCustomText(String rawValue) { + if (isBlank(rawValue)) { + return ""; + } + return URLDecoder.decode(rawValue, StandardCharsets.UTF_8); + } + + private boolean isOtherOption(String value, String label) { + String candidate = (Objects.toString(value, "") + Objects.toString(label, "")).toLowerCase(); + return candidate.contains("其他") || candidate.contains("其它") || candidate.contains("other"); + } + private String normalizeKeyword(String keyword) { if (keyword == null) { return null; @@ -325,6 +405,16 @@ public class ExpansionServiceImpl implements ExpansionService { private void fillChannelDefaults(CreateChannelExpansionRequest request) { request.setChannelName(normalizeRequiredText(request.getChannelName(), "渠道名称不能为空")); + request.setProvince(normalizeRequiredText(request.getProvince(), "请填写省份")); + request.setOfficeAddress(normalizeRequiredText(request.getOfficeAddress(), "请填写办公地址")); + request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请填写聚焦行业")); + request.setAnnualRevenue(requirePositiveAmount(request.getAnnualRevenue(), "请填写年营收")); + request.setStaffSize(requirePositiveInteger(request.getStaffSize(), "请填写人员规模")); + if (request.getContactEstablishedDate() == null) { + throw new BusinessException("请选择建立联系时间"); + } + request.setChannelAttribute(normalizeRequiredText(request.getChannelAttribute(), "请选择渠道属性")); + request.setInternalAttribute(normalizeRequiredText(request.getInternalAttribute(), "请选择新华三内部属性")); if (isBlank(request.getStage())) { request.setStage("initial_contact"); } @@ -337,11 +427,21 @@ public class ExpansionServiceImpl implements ExpansionService { if (request.getHasDesktopExp() == null) { request.setHasDesktopExp(Boolean.FALSE); } - request.setContacts(normalizeContacts(request.getContacts())); + request.setContacts(normalizeRequiredContacts(request.getContacts())); } private void fillChannelDefaults(UpdateChannelExpansionRequest request) { request.setChannelName(normalizeRequiredText(request.getChannelName(), "渠道名称不能为空")); + request.setProvince(normalizeRequiredText(request.getProvince(), "请填写省份")); + request.setOfficeAddress(normalizeRequiredText(request.getOfficeAddress(), "请填写办公地址")); + request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请填写聚焦行业")); + request.setAnnualRevenue(requirePositiveAmount(request.getAnnualRevenue(), "请填写年营收")); + request.setStaffSize(requirePositiveInteger(request.getStaffSize(), "请填写人员规模")); + if (request.getContactEstablishedDate() == null) { + throw new BusinessException("请选择建立联系时间"); + } + request.setChannelAttribute(normalizeRequiredText(request.getChannelAttribute(), "请选择渠道属性")); + request.setInternalAttribute(normalizeRequiredText(request.getInternalAttribute(), "请选择新华三内部属性")); if (isBlank(request.getStage())) { request.setStage("initial_contact"); } @@ -354,7 +454,7 @@ public class ExpansionServiceImpl implements ExpansionService { if (request.getHasDesktopExp() == null) { request.setHasDesktopExp(Boolean.FALSE); } - request.setContacts(normalizeContacts(request.getContacts())); + request.setContacts(normalizeRequiredContacts(request.getContacts())); } private List normalizeContacts(List contacts) { @@ -368,6 +468,37 @@ public class ExpansionServiceImpl implements ExpansionService { .toList(); } + private List normalizeRequiredContacts(List contacts) { + if (contacts == null || contacts.isEmpty()) { + throw new BusinessException("请至少填写一位渠道联系人"); + } + + List normalized = new ArrayList<>(); + for (ChannelExpansionContactRequest contact : contacts) { + if (contact == null) { + continue; + } + String name = trimToNull(contact.getName()); + String mobile = trimToNull(contact.getMobile()); + String title = trimToNull(contact.getTitle()); + if (name == null && mobile == null && title == null) { + continue; + } + if (name == null || mobile == null || title == null) { + throw new BusinessException("请完整填写渠道联系人的姓名、联系电话和职位"); + } + contact.setName(name); + contact.setMobile(mobile); + contact.setTitle(title); + normalized.add(contact); + } + + if (normalized.isEmpty()) { + throw new BusinessException("请至少填写一位渠道联系人"); + } + return normalized; + } + private ChannelExpansionContactRequest normalizeContact(ChannelExpansionContactRequest contact) { if (contact == null) { return null; @@ -403,6 +534,20 @@ public class ExpansionServiceImpl implements ExpansionService { return trimmed.isEmpty() ? null : trimmed; } + private BigDecimal requirePositiveAmount(BigDecimal value, String message) { + if (value == null || value.compareTo(BigDecimal.ZERO) <= 0) { + throw new BusinessException(message); + } + return value; + } + + private Integer requirePositiveInteger(Integer value, String message) { + if (value == null || value <= 0) { + throw new BusinessException(message); + } + return value; + } + private String normalizeBizType(String bizType) { if ("sales".equalsIgnoreCase(bizType)) { return "sales"; diff --git a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java index 10da4fb0..4c5210ee 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java @@ -10,6 +10,7 @@ import com.unis.crm.dto.opportunity.OpportunityItemDTO; import com.unis.crm.dto.opportunity.OpportunityOverviewDTO; import com.unis.crm.mapper.OpportunityMapper; import com.unis.crm.service.OpportunityService; +import java.math.BigDecimal; import java.util.Collections; import java.util.List; import java.util.Map; @@ -226,23 +227,26 @@ public class OpportunityServiceImpl implements OpportunityService { } private void fillDefaults(CreateOpportunityRequest request) { - request.setCustomerName(request.getCustomerName().trim()); - request.setOpportunityName(request.getOpportunityName().trim()); - request.setProjectLocation(normalizeOptionalText(request.getProjectLocation())); - request.setOperatorName(normalizeOptionalText(request.getOperatorName())); - request.setCompetitorName(normalizeOptionalText(request.getCompetitorName())); + request.setCustomerName(normalizeRequiredText(request.getCustomerName(), "最终客户不能为空")); + request.setOpportunityName(normalizeRequiredText(request.getOpportunityName(), "项目名称不能为空")); + request.setProjectLocation(normalizeRequiredText(request.getProjectLocation(), "请填写项目地")); + request.setOperatorName(normalizeRequiredText(request.getOperatorName(), "请选择运作方")); + request.setAmount(requirePositiveAmount(request.getAmount(), "请填写预计金额")); request.setDescription(normalizeOptionalText(request.getDescription())); if (request.getExpectedCloseDate() == null) { - throw new BusinessException("预计结单日期不能为空"); + throw new BusinessException("请选择预计下单时间"); + } + if (request.getConfidencePct() == null || request.getConfidencePct() <= 0) { + throw new BusinessException("请选择项目把握度"); } if (isBlank(request.getStage())) { - request.setStage("initial_contact"); - } else { - request.setStage(normalizeStageValue(request.getStage())); + throw new BusinessException("请选择项目阶段"); } + request.setStage(normalizeStageValue(request.getStage())); if (isBlank(request.getOpportunityType())) { - request.setOpportunityType("新建"); + throw new BusinessException("请选择建设类型"); } + request.setOpportunityType(request.getOpportunityType().trim()); if (isBlank(request.getProductType())) { request.setProductType("VDI云桌面"); } @@ -255,6 +259,8 @@ public class OpportunityServiceImpl implements OpportunityService { if (request.getConfidencePct() == null) { request.setConfidencePct(50); } + request.setCompetitorName(normalizeRequiredText(request.getCompetitorName(), "请选择竞争对手")); + validateOperatorRelations(request.getOperatorName(), request.getSalesExpansionId(), request.getChannelExpansionId()); } private String normalizeKeyword(String keyword) { @@ -280,6 +286,17 @@ public class OpportunityServiceImpl implements OpportunityService { return value == null || value.trim().isEmpty(); } + private String normalizeRequiredText(String value, String message) { + if (value == null) { + throw new BusinessException(message); + } + String trimmed = value.trim(); + if (trimmed.isEmpty()) { + throw new BusinessException(message); + } + return trimmed; + } + private String normalizeOptionalText(String value) { if (value == null) { return null; @@ -288,6 +305,13 @@ public class OpportunityServiceImpl implements OpportunityService { return trimmed.isEmpty() ? null : trimmed; } + private BigDecimal requirePositiveAmount(BigDecimal value, String message) { + if (value == null || value.compareTo(BigDecimal.ZERO) <= 0) { + throw new BusinessException(message); + } + return value; + } + private String firstNonBlank(String... values) { if (values == null) { return null; @@ -331,6 +355,40 @@ public class OpportunityServiceImpl implements OpportunityService { throw new BusinessException("项目阶段无效: " + trimmed); } + private void validateOperatorRelations(String operatorName, Long salesExpansionId, Long channelExpansionId) { + String normalizedOperator = normalizeOperatorToken(operatorName); + boolean hasH3c = normalizedOperator.contains("新华三") || normalizedOperator.contains("h3c"); + boolean hasChannel = normalizedOperator.contains("渠道") || normalizedOperator.contains("channel"); + + if (hasH3c && hasChannel) { + if (salesExpansionId == null && channelExpansionId == null) { + throw new BusinessException("运作方选择“新华三+渠道”时,新华三负责人和渠道名称都必须填写"); + } + if (salesExpansionId == null) { + throw new BusinessException("运作方选择“新华三+渠道”时,新华三负责人必须填写"); + } + if (channelExpansionId == null) { + throw new BusinessException("运作方选择“新华三+渠道”时,渠道名称必须填写"); + } + return; + } + if (hasH3c && salesExpansionId == null) { + throw new BusinessException("运作方选择“新华三”时,新华三负责人必须填写"); + } + if (hasChannel && channelExpansionId == null) { + throw new BusinessException("运作方选择“渠道”时,渠道名称必须填写"); + } + } + + private String normalizeOperatorToken(String value) { + return value == null + ? "" + : value.trim() + .toLowerCase() + .replaceAll("\\s+", "") + .replace('+', '+'); + } + private record CommunicationRecord(String time, String content) { } } diff --git a/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml b/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml index b1e20315..fa249ee6 100644 --- a/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml +++ b/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml @@ -115,7 +115,9 @@ else '无' end as intent, coalesce(c.has_desktop_exp, false) as hasDesktopExp, + coalesce(c.channel_attribute, '') as channelAttributeCode, coalesce(channel_attribute_dict.item_label, c.channel_attribute, '无') as channelAttribute, + coalesce(c.internal_attribute, '') as internalAttributeCode, coalesce(internal_attribute_dict.item_label, c.internal_attribute, '无') as internalAttribute, c.stage as stageCode, case c.stage @@ -381,7 +383,7 @@ - update crm_sales_expansion + update crm_sales_expansion s set employee_no = #{request.employeeNo}, candidate_name = #{request.candidateName}, office_name = #{request.officeName}, @@ -397,8 +399,7 @@ employment_status = #{request.employmentStatus}, expected_join_date = #{request.expectedJoinDate}, remark = #{request.remark} - where id = #{id} - and owner_user_id = #{userId} + where s.id = #{id} - update crm_channel_expansion + update crm_channel_expansion c set channel_name = #{request.channelName}, province = #{request.province}, office_address = #{request.officeAddress}, @@ -436,22 +437,19 @@ landed_flag = #{request.landedFlag}, expected_sign_date = #{request.expectedSignDate}, remark = #{request.remark} - where id = #{id} - and owner_user_id = #{userId} + where c.id = #{id} diff --git a/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml b/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml index a9a0fcc7..d70fc2b8 100644 --- a/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml +++ b/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml @@ -53,6 +53,7 @@ end ) as stage, coalesce(o.opportunity_type, '新建') as type, + coalesce(o.archived, false) as archived, coalesce(o.pushed_to_oms, false) as pushedToOms, coalesce(o.product_type, 'VDI云桌面') as product, coalesce(o.source, '主动开发') as source, @@ -157,9 +158,8 @@ @@ -241,21 +241,19 @@ - update crm_opportunity + update crm_opportunity o set opportunity_name = #{request.opportunityName}, customer_id = #{customerId}, project_location = #{request.projectLocation}, @@ -282,17 +280,15 @@ else 'active' end, updated_at = now() - where id = #{opportunityId} - and owner_user_id = #{userId} + where o.id = #{opportunityId} - update crm_opportunity + update crm_opportunity o set pushed_to_oms = true, oms_push_time = coalesce(oms_push_time, now()), updated_at = now() - where id = #{opportunityId} - and owner_user_id = #{userId} + where o.id = #{opportunityId} and coalesce(pushed_to_oms, false) = false diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md new file mode 100644 index 00000000..ad62fe66 --- /dev/null +++ b/docs/deployment-guide.md @@ -0,0 +1,103 @@ +# 全新环境部署说明 + +## 目标 + +本仓库已经把 CRM 自有业务表整理为单一入口脚本: + +- `sql/init_full_pg17.sql` + +后续部署 CRM 表时,优先只执行这一份脚本即可。历史迁移脚本已归档到: + +- `sql/archive/` + +## 执行顺序 + +### 1. 准备基础环境 + +需要先准备: + +- PostgreSQL 17 +- Redis +- Java 17+ +- Maven 3.9+ + +### 2. 先初始化基础框架(UnisBase)表和基础数据 + +这一步不在本仓库维护,需要先由基础框架提供方执行。 + +当前 CRM 代码运行时会依赖下列基础表或基础数据: + +- `sys_user` +- `sys_org` +- `sys_dict_item` +- `sys_tenant_user` +- `sys_role` +- `sys_user_role` +- `sys_param` +- `sys_log` +- `device` + +如果这些表或基础数据未准备完成,CRM 即使业务表建好了,也会在登录、字典加载、组织信息、角色信息等链路上报错。 + +### 3. 执行 CRM 单文件初始化脚本 + +```bash +psql -h 127.0.0.1 -U postgres -d nex_auth -f sql/init_full_pg17.sql +``` + +说明: + +- 这份脚本既适合空库初始化,也兼容大部分旧环境补齐结构 +- 它已经吸收了仓库里原来的 DDL 迁移脚本 +- `sql/archive/` 中的旧脚本仅保留作历史追溯,正常部署不再单独执行 + +### 4. 初始化完成后建议检查的 CRM 表 + +建议至少确认以下表已存在: + +- `crm_customer` +- `crm_opportunity` +- `crm_opportunity_followup` +- `crm_sales_expansion` +- `crm_channel_expansion` +- `crm_channel_expansion_contact` +- `crm_expansion_followup` +- `work_checkin` +- `work_daily_report` +- `work_daily_report_comment` +- `work_todo` +- `sys_activity_log` + +### 5. 启动后端服务 + +```bash +cd backend +mvn spring-boot:run +``` + +默认端口: + +- `8080` + +### 6. 启动前端并验证 + +建议至少验证以下链路: + +- 登录 +- 首页统计、待办、动态 +- 拓展列表与详情 +- 商机列表与详情 +- 工作台打卡、日报、历史记录 + +## 升级已有环境时的建议 + +如果不是全新环境,而是已有库升级: + +1. 先备份数据库 +2. 直接执行 `sql/init_full_pg17.sql` +3. 验证登录、字典、组织、日报、商机、拓展几条主链路 + +## 当前目录约定 + +- `sql/init_full_pg17.sql`:唯一部署入口脚本 +- `sql/archive/`:历史迁移与修数脚本归档目录 diff --git a/frontend/src/components/AdaptiveSelect.tsx b/frontend/src/components/AdaptiveSelect.tsx index 748eff97..62dfcd01 100644 --- a/frontend/src/components/AdaptiveSelect.tsx +++ b/frontend/src/components/AdaptiveSelect.tsx @@ -9,16 +9,28 @@ export type AdaptiveSelectOption = { disabled?: boolean; }; -type AdaptiveSelectProps = { - value?: string; +type AdaptiveSelectBaseProps = { options: AdaptiveSelectOption[]; placeholder?: string; sheetTitle?: string; disabled?: boolean; className?: string; +}; + +type AdaptiveSelectSingleProps = AdaptiveSelectBaseProps & { + multiple?: false; + value?: string; onChange: (value: string) => void; }; +type AdaptiveSelectMultipleProps = AdaptiveSelectBaseProps & { + multiple: true; + value?: string[]; + onChange: (value: string[]) => void; +}; + +type AdaptiveSelectProps = AdaptiveSelectSingleProps | AdaptiveSelectMultipleProps; + function useIsMobileViewport() { const [isMobile, setIsMobile] = useState(() => { if (typeof window === "undefined") { @@ -49,19 +61,27 @@ function useIsMobileViewport() { } export function AdaptiveSelect({ - value = "", options, placeholder = "请选择", sheetTitle, disabled = false, className, + value, + multiple = false, onChange, }: AdaptiveSelectProps) { const [open, setOpen] = useState(false); const containerRef = useRef(null); const isMobile = useIsMobileViewport(); - const selectedOption = options.find((option) => option.value === value); - const selectedLabel = value ? selectedOption?.label || placeholder : placeholder; + const selectedValues = multiple + ? Array.isArray(value) ? value : [] + : typeof value === "string" && value ? [value] : []; + const selectedLabel = selectedValues.length > 0 + ? selectedValues + .map((selectedValue) => options.find((option) => option.value === selectedValue)?.label) + .filter((label): label is string => Boolean(label)) + .join("、") + : placeholder; useEffect(() => { if (!open || isMobile) { @@ -101,12 +121,23 @@ export function AdaptiveSelect({ }, [isMobile, open]); const handleSelect = (nextValue: string) => { - onChange(nextValue); + if (multiple) { + const currentValues = Array.isArray(value) ? value : []; + const nextValues = currentValues.includes(nextValue) + ? currentValues.filter((item) => item !== nextValue) + : [...currentValues, nextValue]; + (onChange as (value: string[]) => void)(nextValues); + return; + } + + (onChange as (value: string) => void)(nextValue); setOpen(false); }; const renderOption = (option: AdaptiveSelectOption) => { - const isSelected = option.value === value; + const isSelected = multiple + ? selectedValues.includes(option.value) + : option.value === value; return ( + ) : null} diff --git a/frontend/src/index.css b/frontend/src/index.css index c05e4f55..01bb84bc 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -113,6 +113,12 @@ select { font-size: 0.875rem; line-height: 1.4; font-weight: 600; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; } .crm-btn-sm { @@ -122,6 +128,125 @@ select { font-size: 0.875rem; line-height: 1.4; font-weight: 500; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; + } + + .crm-btn-primary { + background: #7c3aed; + color: #fff; + box-shadow: 0 10px 22px rgba(124, 58, 237, 0.2); + } + + .crm-btn-primary:hover { + background: #6d28d9; + } + + .crm-btn-secondary { + border: 1px solid #cbd5e1; + background: rgba(255, 255, 255, 0.96); + color: #334155; + } + + .crm-btn-secondary:hover { + border-color: #a5b4fc; + background: #f8fafc; + color: #4c1d95; + } + + .crm-btn-success { + background: #059669; + color: #fff; + box-shadow: 0 10px 22px rgba(5, 150, 105, 0.18); + } + + .crm-btn-success:hover { + background: #047857; + } + + .crm-btn-danger { + border: 1px solid #fecdd3; + background: #fff1f2; + color: #e11d48; + } + + .crm-btn-danger:hover { + background: #ffe4e6; + color: #be123c; + } + + .crm-btn-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border-radius: 9999px; + border: 1px solid #ddd6fe; + background: #f5f3ff; + color: #7c3aed; + } + + .crm-btn-chip:hover { + border-color: #c4b5fd; + background: #ede9fe; + } + + .crm-icon-sm, + .crm-icon-md, + .crm-icon-lg { + flex-shrink: 0; + stroke-width: 1.85; + } + + .crm-icon-sm { + width: 0.875rem; + height: 0.875rem; + } + + .crm-icon-md { + width: 1rem; + height: 1rem; + } + + .crm-icon-lg { + width: 1.25rem; + height: 1.25rem; + } + + .crm-tone-neutral, + .crm-tone-brand, + .crm-tone-success, + .crm-tone-warning, + .crm-tone-danger { + border-radius: 1rem; + } + + .crm-tone-neutral { + background: #f1f5f9; + color: #64748b; + } + + .crm-tone-brand { + background: #f5f3ff; + color: #7c3aed; + } + + .crm-tone-success { + background: #ecfdf5; + color: #059669; + } + + .crm-tone-warning { + background: #fffbeb; + color: #d97706; + } + + .crm-tone-danger { + background: #fff1f2; + color: #e11d48; } .crm-card { @@ -149,6 +274,73 @@ select { background: rgba(30, 41, 59, 0.38); } + .dark .crm-btn-primary { + box-shadow: 0 12px 24px rgba(109, 40, 217, 0.24); + } + + .dark .crm-btn-secondary { + border-color: rgba(51, 65, 85, 0.9); + background: rgba(15, 23, 42, 0.72); + color: #cbd5e1; + } + + .dark .crm-btn-secondary:hover { + border-color: rgba(139, 92, 246, 0.5); + background: rgba(30, 41, 59, 0.9); + color: #ddd6fe; + } + + .dark .crm-btn-success { + box-shadow: 0 12px 24px rgba(5, 150, 105, 0.2); + } + + .dark .crm-btn-danger { + border-color: rgba(190, 24, 93, 0.35); + background: rgba(244, 63, 94, 0.12); + color: #fda4af; + } + + .dark .crm-btn-danger:hover { + background: rgba(244, 63, 94, 0.18); + color: #fecdd3; + } + + .dark .crm-btn-chip { + border-color: rgba(139, 92, 246, 0.22); + background: rgba(139, 92, 246, 0.14); + color: #c4b5fd; + } + + .dark .crm-btn-chip:hover { + border-color: rgba(139, 92, 246, 0.35); + background: rgba(139, 92, 246, 0.2); + } + + .dark .crm-tone-neutral { + background: rgba(51, 65, 85, 0.85); + color: #cbd5e1; + } + + .dark .crm-tone-brand { + background: rgba(139, 92, 246, 0.14); + color: #c4b5fd; + } + + .dark .crm-tone-success { + background: rgba(16, 185, 129, 0.14); + color: #6ee7b7; + } + + .dark .crm-tone-warning { + background: rgba(245, 158, 11, 0.14); + color: #fcd34d; + } + + .dark .crm-tone-danger { + background: rgba(244, 63, 94, 0.14); + color: #fda4af; + } + .crm-pill { display: inline-flex; align-items: center; @@ -215,6 +407,35 @@ select { gap: 1.25rem; } + .crm-page-header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + } + + .crm-page-heading { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.25rem; + } + + .crm-page-title { + font-size: 1.25rem; + line-height: 1.2; + font-weight: 700; + letter-spacing: -0.02em; + color: #0f172a; + } + + .crm-page-subtitle { + font-size: 0.8125rem; + line-height: 1.6; + color: #64748b; + } + .crm-section-stack { display: flex; flex-direction: column; @@ -235,6 +456,50 @@ select { padding: 1.25rem; } + .crm-filter-bar { + border-radius: 1rem; + border: 1px solid #e2e8f0; + background: rgba(248, 250, 252, 0.82); + padding: 0.25rem; + } + + .crm-empty-panel { + border-radius: 1rem; + border: 1px dashed #cbd5e1; + background: rgba(248, 250, 252, 0.72); + padding: 1rem; + text-align: center; + font-size: 0.875rem; + line-height: 1.6; + color: #94a3b8; + } + + .crm-alert { + border-radius: 1rem; + border: 1px solid #e2e8f0; + padding: 0.875rem 1rem; + font-size: 0.875rem; + line-height: 1.6; + } + + .crm-alert-error { + border-color: #fecdd3; + background: #fff1f2; + color: #e11d48; + } + + .crm-alert-success { + border-color: #bbf7d0; + background: #ecfdf5; + color: #059669; + } + + .crm-alert-info { + border-color: #cbd5e1; + background: rgba(248, 250, 252, 0.78); + color: #64748b; + } + .crm-modal-stack { display: flex; flex-direction: column; @@ -247,6 +512,10 @@ select { gap: 1rem; } + .crm-form-grid > * { + min-width: 0; + } + .crm-form-section { display: flex; flex-direction: column; @@ -305,6 +574,47 @@ select { background: rgba(30, 41, 59, 0.38); } + .dark .crm-page-title { + color: #f8fafc; + } + + .dark .crm-page-subtitle { + color: #94a3b8; + } + + .dark .crm-filter-bar { + border-color: rgba(51, 65, 85, 0.8); + background: rgba(15, 23, 42, 0.46); + } + + .dark .crm-empty-panel { + border-color: rgba(51, 65, 85, 0.9); + background: rgba(30, 41, 59, 0.34); + color: #64748b; + } + + .dark .crm-alert { + border-color: rgba(51, 65, 85, 0.8); + } + + .dark .crm-alert-error { + border-color: rgba(190, 24, 93, 0.35); + background: rgba(244, 63, 94, 0.12); + color: #fda4af; + } + + .dark .crm-alert-success { + border-color: rgba(5, 150, 105, 0.35); + background: rgba(16, 185, 129, 0.12); + color: #6ee7b7; + } + + .dark .crm-alert-info { + border-color: rgba(51, 65, 85, 0.8); + background: rgba(30, 41, 59, 0.38); + color: #94a3b8; + } + .dark .crm-detail-label { color: #94a3b8; } @@ -318,6 +628,18 @@ select { gap: 1.5rem; } + .crm-page-header { + gap: 1rem; + } + + .crm-page-title { + font-size: 1.5rem; + } + + .crm-page-subtitle { + font-size: 0.875rem; + } + .crm-section-stack { gap: 1.25rem; } @@ -356,6 +678,14 @@ select { opacity: 1; } + input[type="date"].crm-input-text { + min-width: 0; + width: 100%; + max-width: 100%; + -webkit-appearance: none; + appearance: none; + } + .dark .crm-input-text::placeholder, .dark input::placeholder, .dark textarea::placeholder { diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 64c7dddc..6dba77a3 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -243,6 +243,7 @@ export interface OpportunityItem { stageCode?: string; stage?: string; type?: string; + archived?: boolean; pushedToOms?: boolean; product?: string; source?: string; @@ -363,7 +364,9 @@ export interface ChannelExpansionItem { intentLevel?: string; intent?: string; hasDesktopExp?: boolean; + channelAttributeCode?: string; channelAttribute?: string; + internalAttributeCode?: string; internalAttribute?: string; stageCode?: string; stage?: string; @@ -436,8 +439,9 @@ export interface CreateChannelExpansionPayload { contactEstablishedDate?: string; intentLevel?: string; hasDesktopExp?: boolean; - channelAttribute?: string; - internalAttribute?: string; + channelAttribute?: string[]; + channelAttributeCustom?: string; + internalAttribute?: string[]; stage?: string; remark?: string; contacts?: ChannelExpansionContact[]; @@ -447,6 +451,56 @@ export interface UpdateSalesExpansionPayload extends CreateSalesExpansionPayload export interface UpdateChannelExpansionPayload extends CreateChannelExpansionPayload {} +const EXPANSION_MULTI_VALUE_CUSTOM_PREFIX = "__custom__:"; + +function normalizeExpansionMultiValues(values?: string[]) { + return Array.from(new Set((values ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))); +} + +export function encodeExpansionMultiValue(values?: string[], customText?: string) { + const normalizedValues = normalizeExpansionMultiValues(values); + const normalizedCustomText = customText?.trim(); + if (normalizedCustomText) { + normalizedValues.push(`${EXPANSION_MULTI_VALUE_CUSTOM_PREFIX}${encodeURIComponent(normalizedCustomText)}`); + } + return normalizedValues.join(","); +} + +export function decodeExpansionMultiValue(rawValue?: string) { + const result = { + values: [] as string[], + customText: "", + }; + + if (!rawValue?.trim()) { + return result; + } + + rawValue + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + .forEach((item) => { + if (item.startsWith(EXPANSION_MULTI_VALUE_CUSTOM_PREFIX)) { + result.customText = decodeURIComponent(item.slice(EXPANSION_MULTI_VALUE_CUSTOM_PREFIX.length)); + return; + } + result.values.push(item); + }); + + result.values = Array.from(new Set(result.values)); + return result; +} + +function serializeChannelExpansionPayload(payload: CreateChannelExpansionPayload) { + const { channelAttributeCustom, ...rest } = payload; + return { + ...rest, + channelAttribute: encodeExpansionMultiValue(payload.channelAttribute, channelAttributeCustom), + internalAttribute: encodeExpansionMultiValue(payload.internalAttribute), + }; +} + export interface CreateExpansionFollowUpPayload { followUpType: string; content: string; @@ -792,7 +846,7 @@ export async function createSalesExpansion(payload: CreateSalesExpansionPayload) export async function createChannelExpansion(payload: CreateChannelExpansionPayload) { return request("/api/expansion/channel", { method: "POST", - body: JSON.stringify(payload), + body: JSON.stringify(serializeChannelExpansionPayload(payload)), }, true); } @@ -806,7 +860,7 @@ export async function updateSalesExpansion(id: number, payload: UpdateSalesExpan export async function updateChannelExpansion(id: number, payload: UpdateChannelExpansionPayload) { return request(`/api/expansion/channel/${id}`, { method: "PUT", - body: JSON.stringify(payload), + body: JSON.stringify(serializeChannelExpansionPayload(payload)), }, true); } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f4cb74be..a70691d7 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -116,11 +116,13 @@ export default function Dashboard() { return (
-
-

工作台

-

+

+
+

工作台

+

欢迎回来,{home.realName || "无"}。今天是你入职的第 {home.onboardingDays ?? 0} 天。 -

+

+
{loading ? ( @@ -206,7 +208,7 @@ export default function Dashboard() { ))} ) : ( -
+
暂无未完成待办
)} @@ -254,7 +256,7 @@ export default function Dashboard() { ) : null} ) : ( -
+
暂无历史待办
) diff --git a/frontend/src/pages/Expansion.tsx b/frontend/src/pages/Expansion.tsx index 0996d8c7..21cd5c23 100644 --- a/frontend/src/pages/Expansion.tsx +++ b/frontend/src/pages/Expansion.tsx @@ -5,6 +5,7 @@ import { useLocation } from "react-router-dom"; import { createChannelExpansion, createSalesExpansion, + decodeExpansionMultiValue, getExpansionMeta, getExpansionOverview, updateChannelExpansion, @@ -18,10 +19,34 @@ import { type SalesExpansionItem, } from "@/lib/auth"; import { AdaptiveSelect } from "@/components/AdaptiveSelect"; +import { cn } from "@/lib/utils"; type ExpansionItem = SalesExpansionItem | ChannelExpansionItem; type ExpansionTab = "sales" | "channel"; -const detailBadgeClass = "crm-pill crm-pill-violet inline-flex items-center gap-0.5 border border-violet-100 transition-all hover:border-violet-200 hover:bg-violet-100 dark:border-violet-500/20 dark:hover:border-violet-500/30 dark:hover:bg-violet-500/15"; +type SalesCreateField = + | "employeeNo" + | "officeName" + | "candidateName" + | "mobile" + | "targetDept" + | "industry" + | "title" + | "intentLevel" + | "employmentStatus"; +type ChannelField = + | "channelName" + | "province" + | "officeAddress" + | "channelIndustry" + | "annualRevenue" + | "staffSize" + | "contactEstablishedDate" + | "intentLevel" + | "channelAttribute" + | "channelAttributeCustom" + | "internalAttribute" + | "contacts"; +const detailBadgeClass = "crm-btn-chip text-[11px] font-semibold"; function createEmptyChannelContact(): ChannelExpansionContact { return { @@ -52,13 +77,173 @@ const defaultChannelForm: CreateChannelExpansionPayload = { contactEstablishedDate: "", intentLevel: "medium", hasDesktopExp: false, - channelAttribute: "", - internalAttribute: "", + channelAttribute: [], + channelAttributeCustom: "", + internalAttribute: [], stage: "initial_contact", remark: "", contacts: [createEmptyChannelContact()], }; +function isOtherOption(option?: ExpansionDictOption) { + const candidate = `${option?.label ?? ""}${option?.value ?? ""}`.toLowerCase(); + return candidate.includes("其他") || candidate.includes("其它") || candidate.includes("other"); +} + +function normalizeOptionalText(value?: string) { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function getFieldInputClass(hasError: boolean) { + return cn( + "crm-input-box crm-input-text w-full border bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", + hasError + ? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10" + : "border-slate-200 dark:border-slate-800", + ); +} + +function validateSalesCreateForm(form: CreateSalesExpansionPayload) { + const errors: Partial> = {}; + + if (!form.employeeNo?.trim()) { + errors.employeeNo = "请填写工号"; + } + if (!form.officeName?.trim()) { + errors.officeName = "请选择代表处 / 办事处"; + } + if (!form.candidateName?.trim()) { + errors.candidateName = "请填写姓名"; + } + if (!form.mobile?.trim()) { + errors.mobile = "请填写联系方式"; + } + if (!form.targetDept?.trim()) { + errors.targetDept = "请填写所属部门"; + } + if (!form.industry?.trim()) { + errors.industry = "请选择所属行业"; + } + if (!form.title?.trim()) { + errors.title = "请填写职务"; + } + if (!form.intentLevel?.trim()) { + errors.intentLevel = "请选择合作意向"; + } + if (!form.employmentStatus?.trim()) { + errors.employmentStatus = "请选择销售是否在职"; + } + + return errors; +} + +function validateChannelForm(form: CreateChannelExpansionPayload, channelOtherOptionValue?: string) { + const errors: Partial> = {}; + const invalidContactRows: number[] = []; + + if (!form.channelName?.trim()) { + errors.channelName = "请填写渠道名称"; + } + if (!form.province?.trim()) { + errors.province = "请填写省份"; + } + if (!form.officeAddress?.trim()) { + errors.officeAddress = "请填写办公地址"; + } + if (!form.channelIndustry?.trim()) { + errors.channelIndustry = "请填写聚焦行业"; + } + if (!form.annualRevenue || form.annualRevenue <= 0) { + errors.annualRevenue = "请填写年营收"; + } + if (!form.staffSize || form.staffSize <= 0) { + errors.staffSize = "请填写人员规模"; + } + if (!form.contactEstablishedDate?.trim()) { + errors.contactEstablishedDate = "请选择建立联系时间"; + } + if (!form.intentLevel?.trim()) { + errors.intentLevel = "请选择合作意向"; + } + if ((form.channelAttribute?.length ?? 0) <= 0) { + errors.channelAttribute = "请选择渠道属性"; + } + if (channelOtherOptionValue && form.channelAttribute?.includes(channelOtherOptionValue) && !form.channelAttributeCustom?.trim()) { + errors.channelAttributeCustom = "请选择“其它”后请补充具体渠道属性"; + } + if ((form.internalAttribute?.length ?? 0) <= 0) { + errors.internalAttribute = "请选择新华三内部属性"; + } + + const contacts = form.contacts ?? []; + if (contacts.length <= 0) { + errors.contacts = "请至少填写一位渠道联系人"; + invalidContactRows.push(0); + } else { + contacts.forEach((contact, index) => { + const hasName = Boolean(contact.name?.trim()); + const hasMobile = Boolean(contact.mobile?.trim()); + const hasTitle = Boolean(contact.title?.trim()); + if (!hasName || !hasMobile || !hasTitle) { + invalidContactRows.push(index); + } + }); + if (invalidContactRows.length > 0) { + errors.contacts = "请完整填写每位渠道联系人的姓名、联系电话和职位"; + } + } + + return { errors, invalidContactRows }; +} + +function normalizeSalesPayload(payload: CreateSalesExpansionPayload): CreateSalesExpansionPayload { + return { + employeeNo: payload.employeeNo.trim(), + candidateName: payload.candidateName.trim(), + officeName: normalizeOptionalText(payload.officeName), + mobile: normalizeOptionalText(payload.mobile), + email: normalizeOptionalText(payload.email), + targetDept: normalizeOptionalText(payload.targetDept), + industry: normalizeOptionalText(payload.industry), + title: normalizeOptionalText(payload.title), + intentLevel: normalizeOptionalText(payload.intentLevel) ?? "medium", + stage: normalizeOptionalText(payload.stage) ?? "initial_contact", + hasDesktopExp: Boolean(payload.hasDesktopExp), + inProgress: payload.inProgress ?? true, + employmentStatus: normalizeOptionalText(payload.employmentStatus) ?? "active", + expectedJoinDate: normalizeOptionalText(payload.expectedJoinDate), + remark: normalizeOptionalText(payload.remark), + }; +} + +function normalizeChannelPayload(payload: CreateChannelExpansionPayload): CreateChannelExpansionPayload { + return { + channelCode: normalizeOptionalText(payload.channelCode), + officeAddress: normalizeOptionalText(payload.officeAddress), + channelIndustry: normalizeOptionalText(payload.channelIndustry), + channelName: payload.channelName.trim(), + province: normalizeOptionalText(payload.province), + annualRevenue: payload.annualRevenue || undefined, + staffSize: payload.staffSize || undefined, + contactEstablishedDate: normalizeOptionalText(payload.contactEstablishedDate), + intentLevel: normalizeOptionalText(payload.intentLevel) ?? "medium", + hasDesktopExp: Boolean(payload.hasDesktopExp), + channelAttribute: Array.from(new Set((payload.channelAttribute ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))), + channelAttributeCustom: normalizeOptionalText(payload.channelAttributeCustom), + internalAttribute: Array.from(new Set((payload.internalAttribute ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))), + stage: normalizeOptionalText(payload.stage) ?? "initial_contact", + remark: normalizeOptionalText(payload.remark), + contacts: (payload.contacts ?? []) + .map((contact) => ({ + name: normalizeOptionalText(contact.name), + mobile: normalizeOptionalText(contact.mobile), + title: normalizeOptionalText(contact.title), + })) + .filter((contact) => contact.name || contact.mobile || contact.title), + }; +} + function ModalShell({ title, subtitle, @@ -129,6 +314,10 @@ function DetailItem({ ); } +function RequiredMark() { + return *; +} + export default function Expansion() { const location = useLocation(); const [activeTab, setActiveTab] = useState("sales"); @@ -141,6 +330,7 @@ export default function Expansion() { const [channelAttributeOptions, setChannelAttributeOptions] = useState([]); const [internalAttributeOptions, setInternalAttributeOptions] = useState([]); const [nextChannelCode, setNextChannelCode] = useState(""); + const channelOtherOptionValue = channelAttributeOptions.find(isOtherOption)?.value ?? ""; const [refreshTick, setRefreshTick] = useState(0); const [createOpen, setCreateOpen] = useState(false); @@ -148,6 +338,12 @@ export default function Expansion() { const [submitting, setSubmitting] = useState(false); const [createError, setCreateError] = useState(""); const [editError, setEditError] = useState(""); + const [salesCreateFieldErrors, setSalesCreateFieldErrors] = useState>>({}); + const [salesEditFieldErrors, setSalesEditFieldErrors] = useState>>({}); + const [channelCreateFieldErrors, setChannelCreateFieldErrors] = useState>>({}); + const [channelEditFieldErrors, setChannelEditFieldErrors] = useState>>({}); + const [invalidCreateChannelContactRows, setInvalidCreateChannelContactRows] = useState([]); + const [invalidEditChannelContactRows, setInvalidEditChannelContactRows] = useState([]); const [salesDetailTab, setSalesDetailTab] = useState<"projects" | "followups">("projects"); const [channelDetailTab, setChannelDetailTab] = useState<"projects" | "contacts" | "followups">("projects"); @@ -236,18 +432,46 @@ export default function Expansion() { const handleSalesChange = (key: K, value: CreateSalesExpansionPayload[K]) => { setSalesForm((current) => ({ ...current, [key]: value })); + if (key in salesCreateFieldErrors) { + setSalesCreateFieldErrors((current) => { + const next = { ...current }; + delete next[key as SalesCreateField]; + return next; + }); + } }; const handleChannelChange = (key: K, value: CreateChannelExpansionPayload[K]) => { setChannelForm((current) => ({ ...current, [key]: value })); + if (key in channelCreateFieldErrors) { + setChannelCreateFieldErrors((current) => { + const next = { ...current }; + delete next[key as ChannelField]; + return next; + }); + } }; const handleEditSalesChange = (key: K, value: CreateSalesExpansionPayload[K]) => { setEditSalesForm((current) => ({ ...current, [key]: value })); + if (key in salesEditFieldErrors) { + setSalesEditFieldErrors((current) => { + const next = { ...current }; + delete next[key as SalesCreateField]; + return next; + }); + } }; const handleEditChannelChange = (key: K, value: CreateChannelExpansionPayload[K]) => { setEditChannelForm((current) => ({ ...current, [key]: value })); + if (key in channelEditFieldErrors) { + setChannelEditFieldErrors((current) => { + const next = { ...current }; + delete next[key as ChannelField]; + return next; + }); + } }; const handleChannelContactChange = (index: number, key: keyof ChannelExpansionContact, value: string, isEdit = false) => { @@ -258,6 +482,27 @@ export default function Expansion() { nextContacts[index] = target; return { ...current, contacts: nextContacts }; }); + if (isEdit) { + setChannelEditFieldErrors((current) => { + if (!current.contacts) { + return current; + } + const next = { ...current }; + delete next.contacts; + return next; + }); + setInvalidEditChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index)); + return; + } + setChannelCreateFieldErrors((current) => { + if (!current.contacts) { + return current; + } + const next = { ...current }; + delete next.contacts; + return next; + }); + setInvalidCreateChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index)); }; const addChannelContact = (isEdit = false) => { @@ -283,6 +528,9 @@ export default function Expansion() { const resetCreateState = () => { setCreateOpen(false); setCreateError(""); + setSalesCreateFieldErrors({}); + setChannelCreateFieldErrors({}); + setInvalidCreateChannelContactRows([]); setSalesForm(defaultSalesForm); setChannelForm(defaultChannelForm); }; @@ -290,12 +538,18 @@ export default function Expansion() { const resetEditState = () => { setEditOpen(false); setEditError(""); + setSalesEditFieldErrors({}); + setChannelEditFieldErrors({}); + setInvalidEditChannelContactRows([]); setEditSalesForm(defaultSalesForm); setEditChannelForm(defaultChannelForm); }; const handleOpenCreate = () => { setCreateError(""); + setSalesCreateFieldErrors({}); + setChannelCreateFieldErrors({}); + setInvalidCreateChannelContactRows([]); setCreateOpen(true); }; @@ -306,6 +560,7 @@ export default function Expansion() { setEditError(""); if (selectedItem.type === "sales") { + setSalesEditFieldErrors({}); setEditSalesForm({ employeeNo: selectedItem.employeeNo === "无" ? "" : selectedItem.employeeNo ?? "", candidateName: selectedItem.name ?? "", @@ -319,6 +574,10 @@ export default function Expansion() { employmentStatus: selectedItem.active ? "active" : "left", }); } else { + const parsedChannelAttributes = decodeExpansionMultiValue(selectedItem.channelAttributeCode); + const parsedInternalAttributes = decodeExpansionMultiValue(selectedItem.internalAttributeCode); + setChannelEditFieldErrors({}); + setInvalidEditChannelContactRows([]); setEditChannelForm({ channelCode: selectedItem.channelCode ?? "", channelName: selectedItem.name ?? "", @@ -330,8 +589,9 @@ export default function Expansion() { contactEstablishedDate: selectedItem.establishedDate === "无" ? "" : selectedItem.establishedDate ?? "", intentLevel: selectedItem.intentLevel ?? "medium", hasDesktopExp: Boolean(selectedItem.hasDesktopExp), - channelAttribute: selectedItem.channelAttribute === "无" ? "" : selectedItem.channelAttribute ?? "", - internalAttribute: selectedItem.internalAttribute === "无" ? "" : selectedItem.internalAttribute ?? "", + channelAttribute: parsedChannelAttributes.values, + channelAttributeCustom: parsedChannelAttributes.customText, + internalAttribute: parsedInternalAttributes.values, stage: selectedItem.stageCode ?? "initial_contact", remark: selectedItem.notes === "无" ? "" : selectedItem.notes ?? "", contacts: (selectedItem.contacts?.length ?? 0) > 0 @@ -351,21 +611,31 @@ export default function Expansion() { return; } - setSubmitting(true); setCreateError(""); + if (activeTab === "sales") { + const validationErrors = validateSalesCreateForm(salesForm); + if (Object.keys(validationErrors).length > 0) { + setSalesCreateFieldErrors(validationErrors); + setCreateError("请先完整填写销售人员拓展必填字段"); + return; + } + } else { + const { errors: validationErrors, invalidContactRows } = validateChannelForm(channelForm, channelOtherOptionValue); + if (Object.keys(validationErrors).length > 0) { + setChannelCreateFieldErrors(validationErrors); + setInvalidCreateChannelContactRows(invalidContactRows); + setCreateError("请先完整填写渠道拓展必填字段"); + return; + } + } + + setSubmitting(true); try { if (activeTab === "sales") { - await createSalesExpansion({ - ...salesForm, - targetDept: salesForm.targetDept?.trim() || undefined, - }); + await createSalesExpansion(normalizeSalesPayload(salesForm)); } else { - await createChannelExpansion({ - ...channelForm, - annualRevenue: channelForm.annualRevenue || undefined, - staffSize: channelForm.staffSize || undefined, - }); + await createChannelExpansion(normalizeChannelPayload(channelForm)); } resetCreateState(); @@ -382,21 +652,31 @@ export default function Expansion() { return; } - setSubmitting(true); setEditError(""); + if (selectedItem.type === "sales") { + const validationErrors = validateSalesCreateForm(editSalesForm); + if (Object.keys(validationErrors).length > 0) { + setSalesEditFieldErrors(validationErrors); + setEditError("请先完整填写销售人员拓展必填字段"); + return; + } + } else { + const { errors: validationErrors, invalidContactRows } = validateChannelForm(editChannelForm, channelOtherOptionValue); + if (Object.keys(validationErrors).length > 0) { + setChannelEditFieldErrors(validationErrors); + setInvalidEditChannelContactRows(invalidContactRows); + setEditError("请先完整填写渠道拓展必填字段"); + return; + } + } + + setSubmitting(true); try { if (selectedItem.type === "sales") { - await updateSalesExpansion(selectedItem.id, { - ...editSalesForm, - targetDept: editSalesForm.targetDept?.trim() || undefined, - }); + await updateSalesExpansion(selectedItem.id, normalizeSalesPayload(editSalesForm)); } else { - await updateChannelExpansion(selectedItem.id, { - ...editChannelForm, - annualRevenue: editChannelForm.annualRevenue || undefined, - staffSize: editChannelForm.staffSize || undefined, - }); + await updateChannelExpansion(selectedItem.id, normalizeChannelPayload(editChannelForm)); } resetEditState(); @@ -410,15 +690,15 @@ export default function Expansion() { }; const renderEmpty = () => ( -
- 暂无 +
+ 暂无拓展数据,先新增一条试试。
); const renderFollowUpTimeline = () => { if (followUpRecords.length <= 0) { return ( -
+
暂无跟进记录
); @@ -453,14 +733,16 @@ export default function Expansion() { const renderSalesForm = ( form: CreateSalesExpansionPayload, onChange: (key: K, value: CreateSalesExpansionPayload[K]) => void, + fieldErrors?: Partial>, ) => (