main
kangwenjing 2026-03-27 17:05:41 +08:00
parent 2b0e477e39
commit c5c0652088
35 changed files with 2467 additions and 323 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -37,13 +37,17 @@ backend/
## 启动前准备 ## 启动前准备
1. 执行数据库脚本 1. 初始化数据库
```bash ```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 mvn spring-boot:run
``` ```
默认启动在 `8081` 端口,供前端开发环境通过 Vite 代理访问`8080` 可继续保留给现有认证/系统服务 默认启动在 `8080` 端口,供前端开发环境通过 Vite 代理访问。
## 首页接口 ## 首页接口
请求示例: 请求示例:
```bash ```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不支持指定其他用户查询。 首页接口只允许查询当前登录用户自己的数据,必须通过 `X-User-Id` 传入当前用户ID不支持指定其他用户查询。

View File

@ -22,7 +22,9 @@ public class ChannelExpansionItemDTO {
private String intentLevel; private String intentLevel;
private String intent; private String intent;
private Boolean hasDesktopExp; private Boolean hasDesktopExp;
private String channelAttributeCode;
private String channelAttribute; private String channelAttribute;
private String internalAttributeCode;
private String internalAttribute; private String internalAttribute;
private String stageCode; private String stageCode;
private String stage; private String stage;
@ -173,6 +175,14 @@ public class ChannelExpansionItemDTO {
return channelAttribute; return channelAttribute;
} }
public String getChannelAttributeCode() {
return channelAttributeCode;
}
public void setChannelAttributeCode(String channelAttributeCode) {
this.channelAttributeCode = channelAttributeCode;
}
public void setChannelAttribute(String channelAttribute) { public void setChannelAttribute(String channelAttribute) {
this.channelAttribute = channelAttribute; this.channelAttribute = channelAttribute;
} }
@ -181,6 +191,14 @@ public class ChannelExpansionItemDTO {
return internalAttribute; return internalAttribute;
} }
public String getInternalAttributeCode() {
return internalAttributeCode;
}
public void setInternalAttributeCode(String internalAttributeCode) {
this.internalAttributeCode = internalAttributeCode;
}
public void setInternalAttribute(String internalAttribute) { public void setInternalAttribute(String internalAttribute) {
this.internalAttribute = internalAttribute; this.internalAttribute = internalAttribute;
} }

View File

@ -20,6 +20,7 @@ public class OpportunityItemDTO {
private String stageCode; private String stageCode;
private String stage; private String stage;
private String type; private String type;
private Boolean archived;
private Boolean pushedToOms; private Boolean pushedToOms;
private String product; private String product;
private String source; private String source;
@ -145,6 +146,14 @@ public class OpportunityItemDTO {
this.type = type; this.type = type;
} }
public Boolean getArchived() {
return archived;
}
public void setArchived(Boolean archived) {
this.archived = archived;
}
public Boolean getPushedToOms() { public Boolean getPushedToOms() {
return pushedToOms; return pushedToOms;
} }

View File

@ -57,6 +57,7 @@ public interface ExpansionMapper {
int deleteChannelContacts(@Param("channelExpansionId") Long channelExpansionId); 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 updateSalesExpansion(@Param("userId") Long userId, @Param("id") Long id, @Param("request") UpdateSalesExpansionRequest request);
int countSalesExpansionByEmployeeNo(@Param("userId") Long userId, @Param("employeeNo") String employeeNo); int countSalesExpansionByEmployeeNo(@Param("userId") Long userId, @Param("employeeNo") String employeeNo);
@ -66,10 +67,13 @@ public interface ExpansionMapper {
@Param("employeeNo") String employeeNo, @Param("employeeNo") String employeeNo,
@Param("excludeId") Long excludeId); @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); 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); 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 countOwnedChannelExpansion(@Param("userId") Long userId, @Param("id") Long id);
int insertExpansionFollowUp( int insertExpansionFollowUp(

View File

@ -30,6 +30,7 @@ public interface OpportunityMapper {
@Param("userId") Long userId, @Param("userId") Long userId,
@Param("opportunityIds") List<Long> opportunityIds); @Param("opportunityIds") List<Long> opportunityIds);
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
Long selectOwnedCustomerIdByName(@Param("userId") Long userId, @Param("customerName") String customerName); Long selectOwnedCustomerIdByName(@Param("userId") Long userId, @Param("customerName") String customerName);
int insertCustomer( int insertCustomer(
@ -43,16 +44,20 @@ public interface OpportunityMapper {
@Param("customerId") Long customerId, @Param("customerId") Long customerId,
@Param("request") CreateOpportunityRequest request); @Param("request") CreateOpportunityRequest request);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
int countOwnedOpportunity(@Param("userId") Long userId, @Param("id") Long 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); Boolean selectPushedToOms(@Param("userId") Long userId, @Param("id") Long id);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
int updateOpportunity( int updateOpportunity(
@Param("userId") Long userId, @Param("userId") Long userId,
@Param("opportunityId") Long opportunityId, @Param("opportunityId") Long opportunityId,
@Param("customerId") Long customerId, @Param("customerId") Long customerId,
@Param("request") CreateOpportunityRequest request); @Param("request") CreateOpportunityRequest request);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
int pushOpportunityToOms( int pushOpportunityToOms(
@Param("userId") Long userId, @Param("userId") Long userId,
@Param("opportunityId") Long opportunityId); @Param("opportunityId") Long opportunityId);

View File

@ -17,7 +17,12 @@ import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest; import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
import com.unis.crm.mapper.ExpansionMapper; import com.unis.crm.mapper.ExpansionMapper;
import com.unis.crm.service.ExpansionService; 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.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; 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 INDUSTRY_TYPE_CODE = "tz_sshy";
private static final String CHANNEL_ATTRIBUTE_TYPE_CODE = "tz_qdsx"; private static final String CHANNEL_ATTRIBUTE_TYPE_CODE = "tz_qdsx";
private static final String INTERNAL_ATTRIBUTE_TYPE_CODE = "tz_xhsnbsx"; 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 DateTimeFormatter FOLLOW_UP_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private static final Logger log = LoggerFactory.getLogger(ExpansionServiceImpl.class); private static final Logger log = LoggerFactory.getLogger(ExpansionServiceImpl.class);
@ -75,6 +81,7 @@ public class ExpansionServiceImpl implements ExpansionService {
attachChannelFollowUps(userId, channelItems); attachChannelFollowUps(userId, channelItems);
attachChannelContacts(userId, channelItems); attachChannelContacts(userId, channelItems);
attachChannelRelatedProjects(userId, channelItems); attachChannelRelatedProjects(userId, channelItems);
fillChannelAttributeDisplay(channelItems);
return new ExpansionOverviewDTO(salesItems, channelItems); return new ExpansionOverviewDTO(salesItems, channelItems);
} }
@ -251,6 +258,79 @@ public class ExpansionServiceImpl implements ExpansionService {
return expansionMapper.selectDictItems(typeCode); return expansionMapper.selectDictItems(typeCode);
} }
private void fillChannelAttributeDisplay(List<ChannelExpansionItemDTO> channelItems) {
if (channelItems == null || channelItems.isEmpty()) {
return;
}
Map<String, String> channelAttributeLabels = toDictLabelMap(loadDictOptions(CHANNEL_ATTRIBUTE_TYPE_CODE));
Map<String, String> 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<String, String> toDictLabelMap(List<DictOptionDTO> options) {
Map<String, String> 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<String, String> labelMap) {
if (isBlank(rawValue)) {
return "无";
}
List<String> 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<String> 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) { private String normalizeKeyword(String keyword) {
if (keyword == null) { if (keyword == null) {
return null; return null;
@ -325,6 +405,16 @@ public class ExpansionServiceImpl implements ExpansionService {
private void fillChannelDefaults(CreateChannelExpansionRequest request) { private void fillChannelDefaults(CreateChannelExpansionRequest request) {
request.setChannelName(normalizeRequiredText(request.getChannelName(), "渠道名称不能为空")); 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())) { if (isBlank(request.getStage())) {
request.setStage("initial_contact"); request.setStage("initial_contact");
} }
@ -337,11 +427,21 @@ public class ExpansionServiceImpl implements ExpansionService {
if (request.getHasDesktopExp() == null) { if (request.getHasDesktopExp() == null) {
request.setHasDesktopExp(Boolean.FALSE); request.setHasDesktopExp(Boolean.FALSE);
} }
request.setContacts(normalizeContacts(request.getContacts())); request.setContacts(normalizeRequiredContacts(request.getContacts()));
} }
private void fillChannelDefaults(UpdateChannelExpansionRequest request) { private void fillChannelDefaults(UpdateChannelExpansionRequest request) {
request.setChannelName(normalizeRequiredText(request.getChannelName(), "渠道名称不能为空")); 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())) { if (isBlank(request.getStage())) {
request.setStage("initial_contact"); request.setStage("initial_contact");
} }
@ -354,7 +454,7 @@ public class ExpansionServiceImpl implements ExpansionService {
if (request.getHasDesktopExp() == null) { if (request.getHasDesktopExp() == null) {
request.setHasDesktopExp(Boolean.FALSE); request.setHasDesktopExp(Boolean.FALSE);
} }
request.setContacts(normalizeContacts(request.getContacts())); request.setContacts(normalizeRequiredContacts(request.getContacts()));
} }
private List<ChannelExpansionContactRequest> normalizeContacts(List<ChannelExpansionContactRequest> contacts) { private List<ChannelExpansionContactRequest> normalizeContacts(List<ChannelExpansionContactRequest> contacts) {
@ -368,6 +468,37 @@ public class ExpansionServiceImpl implements ExpansionService {
.toList(); .toList();
} }
private List<ChannelExpansionContactRequest> normalizeRequiredContacts(List<ChannelExpansionContactRequest> contacts) {
if (contacts == null || contacts.isEmpty()) {
throw new BusinessException("请至少填写一位渠道联系人");
}
List<ChannelExpansionContactRequest> 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) { private ChannelExpansionContactRequest normalizeContact(ChannelExpansionContactRequest contact) {
if (contact == null) { if (contact == null) {
return null; return null;
@ -403,6 +534,20 @@ public class ExpansionServiceImpl implements ExpansionService {
return trimmed.isEmpty() ? null : trimmed; 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) { private String normalizeBizType(String bizType) {
if ("sales".equalsIgnoreCase(bizType)) { if ("sales".equalsIgnoreCase(bizType)) {
return "sales"; return "sales";

View File

@ -10,6 +10,7 @@ import com.unis.crm.dto.opportunity.OpportunityItemDTO;
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO; import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
import com.unis.crm.mapper.OpportunityMapper; import com.unis.crm.mapper.OpportunityMapper;
import com.unis.crm.service.OpportunityService; import com.unis.crm.service.OpportunityService;
import java.math.BigDecimal;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -226,23 +227,26 @@ public class OpportunityServiceImpl implements OpportunityService {
} }
private void fillDefaults(CreateOpportunityRequest request) { private void fillDefaults(CreateOpportunityRequest request) {
request.setCustomerName(request.getCustomerName().trim()); request.setCustomerName(normalizeRequiredText(request.getCustomerName(), "最终客户不能为空"));
request.setOpportunityName(request.getOpportunityName().trim()); request.setOpportunityName(normalizeRequiredText(request.getOpportunityName(), "项目名称不能为空"));
request.setProjectLocation(normalizeOptionalText(request.getProjectLocation())); request.setProjectLocation(normalizeRequiredText(request.getProjectLocation(), "请填写项目地"));
request.setOperatorName(normalizeOptionalText(request.getOperatorName())); request.setOperatorName(normalizeRequiredText(request.getOperatorName(), "请选择运作方"));
request.setCompetitorName(normalizeOptionalText(request.getCompetitorName())); request.setAmount(requirePositiveAmount(request.getAmount(), "请填写预计金额"));
request.setDescription(normalizeOptionalText(request.getDescription())); request.setDescription(normalizeOptionalText(request.getDescription()));
if (request.getExpectedCloseDate() == null) { if (request.getExpectedCloseDate() == null) {
throw new BusinessException("预计结单日期不能为空"); throw new BusinessException("请选择预计下单时间");
}
if (request.getConfidencePct() == null || request.getConfidencePct() <= 0) {
throw new BusinessException("请选择项目把握度");
} }
if (isBlank(request.getStage())) { if (isBlank(request.getStage())) {
request.setStage("initial_contact"); throw new BusinessException("请选择项目阶段");
} else {
request.setStage(normalizeStageValue(request.getStage()));
} }
request.setStage(normalizeStageValue(request.getStage()));
if (isBlank(request.getOpportunityType())) { if (isBlank(request.getOpportunityType())) {
request.setOpportunityType("新建"); throw new BusinessException("请选择建设类型");
} }
request.setOpportunityType(request.getOpportunityType().trim());
if (isBlank(request.getProductType())) { if (isBlank(request.getProductType())) {
request.setProductType("VDI云桌面"); request.setProductType("VDI云桌面");
} }
@ -255,6 +259,8 @@ public class OpportunityServiceImpl implements OpportunityService {
if (request.getConfidencePct() == null) { if (request.getConfidencePct() == null) {
request.setConfidencePct(50); request.setConfidencePct(50);
} }
request.setCompetitorName(normalizeRequiredText(request.getCompetitorName(), "请选择竞争对手"));
validateOperatorRelations(request.getOperatorName(), request.getSalesExpansionId(), request.getChannelExpansionId());
} }
private String normalizeKeyword(String keyword) { private String normalizeKeyword(String keyword) {
@ -280,6 +286,17 @@ public class OpportunityServiceImpl implements OpportunityService {
return value == null || value.trim().isEmpty(); 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) { private String normalizeOptionalText(String value) {
if (value == null) { if (value == null) {
return null; return null;
@ -288,6 +305,13 @@ public class OpportunityServiceImpl implements OpportunityService {
return trimmed.isEmpty() ? null : trimmed; 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) { private String firstNonBlank(String... values) {
if (values == null) { if (values == null) {
return null; return null;
@ -331,6 +355,40 @@ public class OpportunityServiceImpl implements OpportunityService {
throw new BusinessException("项目阶段无效: " + trimmed); 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) { private record CommunicationRecord(String time, String content) {
} }
} }

View File

@ -115,7 +115,9 @@
else '无' else '无'
end as intent, end as intent,
coalesce(c.has_desktop_exp, false) as hasDesktopExp, 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(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, coalesce(internal_attribute_dict.item_label, c.internal_attribute, '无') as internalAttribute,
c.stage as stageCode, c.stage as stageCode,
case c.stage case c.stage
@ -381,7 +383,7 @@
</delete> </delete>
<update id="updateSalesExpansion"> <update id="updateSalesExpansion">
update crm_sales_expansion update crm_sales_expansion s
set employee_no = #{request.employeeNo}, set employee_no = #{request.employeeNo},
candidate_name = #{request.candidateName}, candidate_name = #{request.candidateName},
office_name = #{request.officeName}, office_name = #{request.officeName},
@ -397,8 +399,7 @@
employment_status = #{request.employmentStatus}, employment_status = #{request.employmentStatus},
expected_join_date = #{request.expectedJoinDate}, expected_join_date = #{request.expectedJoinDate},
remark = #{request.remark} remark = #{request.remark}
where id = #{id} where s.id = #{id}
and owner_user_id = #{userId}
</update> </update>
<select id="countSalesExpansionByEmployeeNo" resultType="int"> <select id="countSalesExpansionByEmployeeNo" resultType="int">
@ -417,7 +418,7 @@
</select> </select>
<update id="updateChannelExpansion"> <update id="updateChannelExpansion">
update crm_channel_expansion update crm_channel_expansion c
set channel_name = #{request.channelName}, set channel_name = #{request.channelName},
province = #{request.province}, province = #{request.province},
office_address = #{request.officeAddress}, office_address = #{request.officeAddress},
@ -436,22 +437,19 @@
landed_flag = #{request.landedFlag}, landed_flag = #{request.landedFlag},
expected_sign_date = #{request.expectedSignDate}, expected_sign_date = #{request.expectedSignDate},
remark = #{request.remark} remark = #{request.remark}
where id = #{id} where c.id = #{id}
and owner_user_id = #{userId}
</update> </update>
<select id="countOwnedSalesExpansion" resultType="int"> <select id="countOwnedSalesExpansion" resultType="int">
select count(1) select count(1)
from crm_sales_expansion from crm_sales_expansion s
where id = #{id} where s.id = #{id}
and owner_user_id = #{userId}
</select> </select>
<select id="countOwnedChannelExpansion" resultType="int"> <select id="countOwnedChannelExpansion" resultType="int">
select count(1) select count(1)
from crm_channel_expansion from crm_channel_expansion c
where id = #{id} where c.id = #{id}
and owner_user_id = #{userId}
</select> </select>
<insert id="insertExpansionFollowUp"> <insert id="insertExpansionFollowUp">

View File

@ -53,6 +53,7 @@
end end
) as stage, ) as stage,
coalesce(o.opportunity_type, '新建') as type, coalesce(o.opportunity_type, '新建') as type,
coalesce(o.archived, false) as archived,
coalesce(o.pushed_to_oms, false) as pushedToOms, coalesce(o.pushed_to_oms, false) as pushedToOms,
coalesce(o.product_type, 'VDI云桌面') as product, coalesce(o.product_type, 'VDI云桌面') as product,
coalesce(o.source, '主动开发') as source, coalesce(o.source, '主动开发') as source,
@ -157,9 +158,8 @@
<select id="selectOwnedCustomerIdByName" resultType="java.lang.Long"> <select id="selectOwnedCustomerIdByName" resultType="java.lang.Long">
select id select id
from crm_customer from crm_customer c
where owner_user_id = #{userId} where c.customer_name = #{customerName}
and customer_name = #{customerName}
limit 1 limit 1
</select> </select>
@ -241,21 +241,19 @@
<select id="countOwnedOpportunity" resultType="int"> <select id="countOwnedOpportunity" resultType="int">
select count(1) select count(1)
from crm_opportunity from crm_opportunity o
where id = #{id} where o.id = #{id}
and owner_user_id = #{userId}
</select> </select>
<select id="selectPushedToOms" resultType="java.lang.Boolean"> <select id="selectPushedToOms" resultType="java.lang.Boolean">
select coalesce(pushed_to_oms, false) select coalesce(pushed_to_oms, false)
from crm_opportunity from crm_opportunity o
where id = #{id} where o.id = #{id}
and owner_user_id = #{userId}
limit 1 limit 1
</select> </select>
<update id="updateOpportunity"> <update id="updateOpportunity">
update crm_opportunity update crm_opportunity o
set opportunity_name = #{request.opportunityName}, set opportunity_name = #{request.opportunityName},
customer_id = #{customerId}, customer_id = #{customerId},
project_location = #{request.projectLocation}, project_location = #{request.projectLocation},
@ -282,17 +280,15 @@
else 'active' else 'active'
end, end,
updated_at = now() updated_at = now()
where id = #{opportunityId} where o.id = #{opportunityId}
and owner_user_id = #{userId}
</update> </update>
<update id="pushOpportunityToOms"> <update id="pushOpportunityToOms">
update crm_opportunity update crm_opportunity o
set pushed_to_oms = true, set pushed_to_oms = true,
oms_push_time = coalesce(oms_push_time, now()), oms_push_time = coalesce(oms_push_time, now()),
updated_at = now() updated_at = now()
where id = #{opportunityId} where o.id = #{opportunityId}
and owner_user_id = #{userId}
and coalesce(pushed_to_oms, false) = false and coalesce(pushed_to_oms, false) = false
</update> </update>

View File

@ -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/`:历史迁移与修数脚本归档目录

View File

@ -9,16 +9,28 @@ export type AdaptiveSelectOption = {
disabled?: boolean; disabled?: boolean;
}; };
type AdaptiveSelectProps = { type AdaptiveSelectBaseProps = {
value?: string;
options: AdaptiveSelectOption[]; options: AdaptiveSelectOption[];
placeholder?: string; placeholder?: string;
sheetTitle?: string; sheetTitle?: string;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
};
type AdaptiveSelectSingleProps = AdaptiveSelectBaseProps & {
multiple?: false;
value?: string;
onChange: (value: string) => void; onChange: (value: string) => void;
}; };
type AdaptiveSelectMultipleProps = AdaptiveSelectBaseProps & {
multiple: true;
value?: string[];
onChange: (value: string[]) => void;
};
type AdaptiveSelectProps = AdaptiveSelectSingleProps | AdaptiveSelectMultipleProps;
function useIsMobileViewport() { function useIsMobileViewport() {
const [isMobile, setIsMobile] = useState(() => { const [isMobile, setIsMobile] = useState(() => {
if (typeof window === "undefined") { if (typeof window === "undefined") {
@ -49,19 +61,27 @@ function useIsMobileViewport() {
} }
export function AdaptiveSelect({ export function AdaptiveSelect({
value = "",
options, options,
placeholder = "请选择", placeholder = "请选择",
sheetTitle, sheetTitle,
disabled = false, disabled = false,
className, className,
value,
multiple = false,
onChange, onChange,
}: AdaptiveSelectProps) { }: AdaptiveSelectProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const isMobile = useIsMobileViewport(); const isMobile = useIsMobileViewport();
const selectedOption = options.find((option) => option.value === value); const selectedValues = multiple
const selectedLabel = value ? selectedOption?.label || placeholder : placeholder; ? 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(() => { useEffect(() => {
if (!open || isMobile) { if (!open || isMobile) {
@ -101,12 +121,23 @@ export function AdaptiveSelect({
}, [isMobile, open]); }, [isMobile, open]);
const handleSelect = (nextValue: string) => { 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); setOpen(false);
}; };
const renderOption = (option: AdaptiveSelectOption) => { const renderOption = (option: AdaptiveSelectOption) => {
const isSelected = option.value === value; const isSelected = multiple
? selectedValues.includes(option.value)
: option.value === value;
return ( return (
<button <button
key={`${option.value}-${option.label}`} key={`${option.value}-${option.label}`}
@ -142,7 +173,7 @@ export function AdaptiveSelect({
className, className,
)} )}
> >
<span className={value ? "text-slate-900 dark:text-white" : "crm-field-note"}> <span className={selectedValues.length > 0 ? "break-anywhere text-slate-900 dark:text-white" : "crm-field-note"}>
{selectedLabel} {selectedLabel}
</span> </span>
<ChevronDown className={cn("h-4 w-4 shrink-0 text-slate-400 transition-transform", open ? "rotate-180" : "")} /> <ChevronDown className={cn("h-4 w-4 shrink-0 text-slate-400 transition-transform", open ? "rotate-180" : "")} />
@ -181,7 +212,7 @@ export function AdaptiveSelect({
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800"> <div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<div> <div>
<p className="text-base font-semibold text-slate-900 dark:text-white">{sheetTitle || placeholder}</p> <p className="text-base font-semibold text-slate-900 dark:text-white">{sheetTitle || placeholder}</p>
<p className="crm-field-note mt-1"></p> <p className="crm-field-note mt-1">{multiple ? "可多选" : "请选择一个选项"}</p>
</div> </div>
<button <button
type="button" type="button"
@ -193,6 +224,15 @@ export function AdaptiveSelect({
</div> </div>
<div className="max-h-[60vh] space-y-2 overflow-y-auto px-4 py-4 pb-[calc(1rem+env(safe-area-inset-bottom))]"> <div className="max-h-[60vh] space-y-2 overflow-y-auto px-4 py-4 pb-[calc(1rem+env(safe-area-inset-bottom))]">
{options.map(renderOption)} {options.map(renderOption)}
{multiple ? (
<button
type="button"
onClick={() => setOpen(false)}
className="mt-2 w-full rounded-2xl bg-violet-600 px-4 py-3 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-violet-500"
>
</button>
) : null}
</div> </div>
</div> </div>
</motion.div> </motion.div>

View File

@ -113,6 +113,12 @@ select {
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.4; line-height: 1.4;
font-weight: 600; 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 { .crm-btn-sm {
@ -122,6 +128,125 @@ select {
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.4; line-height: 1.4;
font-weight: 500; 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 { .crm-card {
@ -149,6 +274,73 @@ select {
background: rgba(30, 41, 59, 0.38); 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 { .crm-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -215,6 +407,35 @@ select {
gap: 1.25rem; 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 { .crm-section-stack {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -235,6 +456,50 @@ select {
padding: 1.25rem; 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 { .crm-modal-stack {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -247,6 +512,10 @@ select {
gap: 1rem; gap: 1rem;
} }
.crm-form-grid > * {
min-width: 0;
}
.crm-form-section { .crm-form-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -305,6 +574,47 @@ select {
background: rgba(30, 41, 59, 0.38); 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 { .dark .crm-detail-label {
color: #94a3b8; color: #94a3b8;
} }
@ -318,6 +628,18 @@ select {
gap: 1.5rem; 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 { .crm-section-stack {
gap: 1.25rem; gap: 1.25rem;
} }
@ -356,6 +678,14 @@ select {
opacity: 1; 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 .crm-input-text::placeholder,
.dark input::placeholder, .dark input::placeholder,
.dark textarea::placeholder { .dark textarea::placeholder {

View File

@ -243,6 +243,7 @@ export interface OpportunityItem {
stageCode?: string; stageCode?: string;
stage?: string; stage?: string;
type?: string; type?: string;
archived?: boolean;
pushedToOms?: boolean; pushedToOms?: boolean;
product?: string; product?: string;
source?: string; source?: string;
@ -363,7 +364,9 @@ export interface ChannelExpansionItem {
intentLevel?: string; intentLevel?: string;
intent?: string; intent?: string;
hasDesktopExp?: boolean; hasDesktopExp?: boolean;
channelAttributeCode?: string;
channelAttribute?: string; channelAttribute?: string;
internalAttributeCode?: string;
internalAttribute?: string; internalAttribute?: string;
stageCode?: string; stageCode?: string;
stage?: string; stage?: string;
@ -436,8 +439,9 @@ export interface CreateChannelExpansionPayload {
contactEstablishedDate?: string; contactEstablishedDate?: string;
intentLevel?: string; intentLevel?: string;
hasDesktopExp?: boolean; hasDesktopExp?: boolean;
channelAttribute?: string; channelAttribute?: string[];
internalAttribute?: string; channelAttributeCustom?: string;
internalAttribute?: string[];
stage?: string; stage?: string;
remark?: string; remark?: string;
contacts?: ChannelExpansionContact[]; contacts?: ChannelExpansionContact[];
@ -447,6 +451,56 @@ export interface UpdateSalesExpansionPayload extends CreateSalesExpansionPayload
export interface UpdateChannelExpansionPayload extends CreateChannelExpansionPayload {} 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 { export interface CreateExpansionFollowUpPayload {
followUpType: string; followUpType: string;
content: string; content: string;
@ -792,7 +846,7 @@ export async function createSalesExpansion(payload: CreateSalesExpansionPayload)
export async function createChannelExpansion(payload: CreateChannelExpansionPayload) { export async function createChannelExpansion(payload: CreateChannelExpansionPayload) {
return request<number>("/api/expansion/channel", { return request<number>("/api/expansion/channel", {
method: "POST", method: "POST",
body: JSON.stringify(payload), body: JSON.stringify(serializeChannelExpansionPayload(payload)),
}, true); }, true);
} }
@ -806,7 +860,7 @@ export async function updateSalesExpansion(id: number, payload: UpdateSalesExpan
export async function updateChannelExpansion(id: number, payload: UpdateChannelExpansionPayload) { export async function updateChannelExpansion(id: number, payload: UpdateChannelExpansionPayload) {
return request<void>(`/api/expansion/channel/${id}`, { return request<void>(`/api/expansion/channel/${id}`, {
method: "PUT", method: "PUT",
body: JSON.stringify(payload), body: JSON.stringify(serializeChannelExpansionPayload(payload)),
}, true); }, true);
} }

View File

@ -116,11 +116,13 @@ export default function Dashboard() {
return ( return (
<div className="crm-page-stack"> <div className="crm-page-stack">
<header className="space-y-1 sm:space-y-2"> <header className="crm-page-header">
<h1 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-2xl"></h1> <div className="crm-page-heading">
<p className="text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm"> <h1 className="crm-page-title"></h1>
<p className="crm-page-subtitle">
{home.realName || "无"} {home.onboardingDays ?? 0} {home.realName || "无"} {home.onboardingDays ?? 0}
</p> </p>
</div>
</header> </header>
{loading ? ( {loading ? (
@ -206,7 +208,7 @@ export default function Dashboard() {
))} ))}
</ul> </ul>
) : ( ) : (
<div className="crm-empty-state rounded-xl border border-dashed border-slate-200 px-4 py-5 dark:border-slate-700"> <div className="crm-empty-panel">
</div> </div>
)} )}
@ -254,7 +256,7 @@ export default function Dashboard() {
) : null} ) : null}
</> </>
) : ( ) : (
<div className="crm-empty-state mt-3 rounded-xl border border-dashed border-slate-200 px-4 py-6 dark:border-slate-700"> <div className="crm-empty-panel mt-3">
</div> </div>
) )

View File

@ -5,6 +5,7 @@ import { useLocation } from "react-router-dom";
import { import {
createChannelExpansion, createChannelExpansion,
createSalesExpansion, createSalesExpansion,
decodeExpansionMultiValue,
getExpansionMeta, getExpansionMeta,
getExpansionOverview, getExpansionOverview,
updateChannelExpansion, updateChannelExpansion,
@ -18,10 +19,34 @@ import {
type SalesExpansionItem, type SalesExpansionItem,
} from "@/lib/auth"; } from "@/lib/auth";
import { AdaptiveSelect } from "@/components/AdaptiveSelect"; import { AdaptiveSelect } from "@/components/AdaptiveSelect";
import { cn } from "@/lib/utils";
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem; type ExpansionItem = SalesExpansionItem | ChannelExpansionItem;
type ExpansionTab = "sales" | "channel"; 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 { function createEmptyChannelContact(): ChannelExpansionContact {
return { return {
@ -52,13 +77,173 @@ const defaultChannelForm: CreateChannelExpansionPayload = {
contactEstablishedDate: "", contactEstablishedDate: "",
intentLevel: "medium", intentLevel: "medium",
hasDesktopExp: false, hasDesktopExp: false,
channelAttribute: "", channelAttribute: [],
internalAttribute: "", channelAttributeCustom: "",
internalAttribute: [],
stage: "initial_contact", stage: "initial_contact",
remark: "", remark: "",
contacts: [createEmptyChannelContact()], 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<Record<SalesCreateField, string>> = {};
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<Record<ChannelField, string>> = {};
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({ function ModalShell({
title, title,
subtitle, subtitle,
@ -129,6 +314,10 @@ function DetailItem({
); );
} }
function RequiredMark() {
return <span className="ml-1 text-rose-500">*</span>;
}
export default function Expansion() { export default function Expansion() {
const location = useLocation(); const location = useLocation();
const [activeTab, setActiveTab] = useState<ExpansionTab>("sales"); const [activeTab, setActiveTab] = useState<ExpansionTab>("sales");
@ -141,6 +330,7 @@ export default function Expansion() {
const [channelAttributeOptions, setChannelAttributeOptions] = useState<ExpansionDictOption[]>([]); const [channelAttributeOptions, setChannelAttributeOptions] = useState<ExpansionDictOption[]>([]);
const [internalAttributeOptions, setInternalAttributeOptions] = useState<ExpansionDictOption[]>([]); const [internalAttributeOptions, setInternalAttributeOptions] = useState<ExpansionDictOption[]>([]);
const [nextChannelCode, setNextChannelCode] = useState(""); const [nextChannelCode, setNextChannelCode] = useState("");
const channelOtherOptionValue = channelAttributeOptions.find(isOtherOption)?.value ?? "";
const [refreshTick, setRefreshTick] = useState(0); const [refreshTick, setRefreshTick] = useState(0);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
@ -148,6 +338,12 @@ export default function Expansion() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [createError, setCreateError] = useState(""); const [createError, setCreateError] = useState("");
const [editError, setEditError] = useState(""); const [editError, setEditError] = useState("");
const [salesCreateFieldErrors, setSalesCreateFieldErrors] = useState<Partial<Record<SalesCreateField, string>>>({});
const [salesEditFieldErrors, setSalesEditFieldErrors] = useState<Partial<Record<SalesCreateField, string>>>({});
const [channelCreateFieldErrors, setChannelCreateFieldErrors] = useState<Partial<Record<ChannelField, string>>>({});
const [channelEditFieldErrors, setChannelEditFieldErrors] = useState<Partial<Record<ChannelField, string>>>({});
const [invalidCreateChannelContactRows, setInvalidCreateChannelContactRows] = useState<number[]>([]);
const [invalidEditChannelContactRows, setInvalidEditChannelContactRows] = useState<number[]>([]);
const [salesDetailTab, setSalesDetailTab] = useState<"projects" | "followups">("projects"); const [salesDetailTab, setSalesDetailTab] = useState<"projects" | "followups">("projects");
const [channelDetailTab, setChannelDetailTab] = useState<"projects" | "contacts" | "followups">("projects"); const [channelDetailTab, setChannelDetailTab] = useState<"projects" | "contacts" | "followups">("projects");
@ -236,18 +432,46 @@ export default function Expansion() {
const handleSalesChange = <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => { const handleSalesChange = <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => {
setSalesForm((current) => ({ ...current, [key]: value })); setSalesForm((current) => ({ ...current, [key]: value }));
if (key in salesCreateFieldErrors) {
setSalesCreateFieldErrors((current) => {
const next = { ...current };
delete next[key as SalesCreateField];
return next;
});
}
}; };
const handleChannelChange = <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => { const handleChannelChange = <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => {
setChannelForm((current) => ({ ...current, [key]: value })); setChannelForm((current) => ({ ...current, [key]: value }));
if (key in channelCreateFieldErrors) {
setChannelCreateFieldErrors((current) => {
const next = { ...current };
delete next[key as ChannelField];
return next;
});
}
}; };
const handleEditSalesChange = <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => { const handleEditSalesChange = <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => {
setEditSalesForm((current) => ({ ...current, [key]: value })); setEditSalesForm((current) => ({ ...current, [key]: value }));
if (key in salesEditFieldErrors) {
setSalesEditFieldErrors((current) => {
const next = { ...current };
delete next[key as SalesCreateField];
return next;
});
}
}; };
const handleEditChannelChange = <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => { const handleEditChannelChange = <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => {
setEditChannelForm((current) => ({ ...current, [key]: value })); 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) => { const handleChannelContactChange = (index: number, key: keyof ChannelExpansionContact, value: string, isEdit = false) => {
@ -258,6 +482,27 @@ export default function Expansion() {
nextContacts[index] = target; nextContacts[index] = target;
return { ...current, contacts: nextContacts }; 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) => { const addChannelContact = (isEdit = false) => {
@ -283,6 +528,9 @@ export default function Expansion() {
const resetCreateState = () => { const resetCreateState = () => {
setCreateOpen(false); setCreateOpen(false);
setCreateError(""); setCreateError("");
setSalesCreateFieldErrors({});
setChannelCreateFieldErrors({});
setInvalidCreateChannelContactRows([]);
setSalesForm(defaultSalesForm); setSalesForm(defaultSalesForm);
setChannelForm(defaultChannelForm); setChannelForm(defaultChannelForm);
}; };
@ -290,12 +538,18 @@ export default function Expansion() {
const resetEditState = () => { const resetEditState = () => {
setEditOpen(false); setEditOpen(false);
setEditError(""); setEditError("");
setSalesEditFieldErrors({});
setChannelEditFieldErrors({});
setInvalidEditChannelContactRows([]);
setEditSalesForm(defaultSalesForm); setEditSalesForm(defaultSalesForm);
setEditChannelForm(defaultChannelForm); setEditChannelForm(defaultChannelForm);
}; };
const handleOpenCreate = () => { const handleOpenCreate = () => {
setCreateError(""); setCreateError("");
setSalesCreateFieldErrors({});
setChannelCreateFieldErrors({});
setInvalidCreateChannelContactRows([]);
setCreateOpen(true); setCreateOpen(true);
}; };
@ -306,6 +560,7 @@ export default function Expansion() {
setEditError(""); setEditError("");
if (selectedItem.type === "sales") { if (selectedItem.type === "sales") {
setSalesEditFieldErrors({});
setEditSalesForm({ setEditSalesForm({
employeeNo: selectedItem.employeeNo === "无" ? "" : selectedItem.employeeNo ?? "", employeeNo: selectedItem.employeeNo === "无" ? "" : selectedItem.employeeNo ?? "",
candidateName: selectedItem.name ?? "", candidateName: selectedItem.name ?? "",
@ -319,6 +574,10 @@ export default function Expansion() {
employmentStatus: selectedItem.active ? "active" : "left", employmentStatus: selectedItem.active ? "active" : "left",
}); });
} else { } else {
const parsedChannelAttributes = decodeExpansionMultiValue(selectedItem.channelAttributeCode);
const parsedInternalAttributes = decodeExpansionMultiValue(selectedItem.internalAttributeCode);
setChannelEditFieldErrors({});
setInvalidEditChannelContactRows([]);
setEditChannelForm({ setEditChannelForm({
channelCode: selectedItem.channelCode ?? "", channelCode: selectedItem.channelCode ?? "",
channelName: selectedItem.name ?? "", channelName: selectedItem.name ?? "",
@ -330,8 +589,9 @@ export default function Expansion() {
contactEstablishedDate: selectedItem.establishedDate === "无" ? "" : selectedItem.establishedDate ?? "", contactEstablishedDate: selectedItem.establishedDate === "无" ? "" : selectedItem.establishedDate ?? "",
intentLevel: selectedItem.intentLevel ?? "medium", intentLevel: selectedItem.intentLevel ?? "medium",
hasDesktopExp: Boolean(selectedItem.hasDesktopExp), hasDesktopExp: Boolean(selectedItem.hasDesktopExp),
channelAttribute: selectedItem.channelAttribute === "无" ? "" : selectedItem.channelAttribute ?? "", channelAttribute: parsedChannelAttributes.values,
internalAttribute: selectedItem.internalAttribute === "无" ? "" : selectedItem.internalAttribute ?? "", channelAttributeCustom: parsedChannelAttributes.customText,
internalAttribute: parsedInternalAttributes.values,
stage: selectedItem.stageCode ?? "initial_contact", stage: selectedItem.stageCode ?? "initial_contact",
remark: selectedItem.notes === "无" ? "" : selectedItem.notes ?? "", remark: selectedItem.notes === "无" ? "" : selectedItem.notes ?? "",
contacts: (selectedItem.contacts?.length ?? 0) > 0 contacts: (selectedItem.contacts?.length ?? 0) > 0
@ -351,21 +611,31 @@ export default function Expansion() {
return; return;
} }
setSubmitting(true);
setCreateError(""); 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 { try {
if (activeTab === "sales") { if (activeTab === "sales") {
await createSalesExpansion({ await createSalesExpansion(normalizeSalesPayload(salesForm));
...salesForm,
targetDept: salesForm.targetDept?.trim() || undefined,
});
} else { } else {
await createChannelExpansion({ await createChannelExpansion(normalizeChannelPayload(channelForm));
...channelForm,
annualRevenue: channelForm.annualRevenue || undefined,
staffSize: channelForm.staffSize || undefined,
});
} }
resetCreateState(); resetCreateState();
@ -382,21 +652,31 @@ export default function Expansion() {
return; return;
} }
setSubmitting(true);
setEditError(""); 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 { try {
if (selectedItem.type === "sales") { if (selectedItem.type === "sales") {
await updateSalesExpansion(selectedItem.id, { await updateSalesExpansion(selectedItem.id, normalizeSalesPayload(editSalesForm));
...editSalesForm,
targetDept: editSalesForm.targetDept?.trim() || undefined,
});
} else { } else {
await updateChannelExpansion(selectedItem.id, { await updateChannelExpansion(selectedItem.id, normalizeChannelPayload(editChannelForm));
...editChannelForm,
annualRevenue: editChannelForm.annualRevenue || undefined,
staffSize: editChannelForm.staffSize || undefined,
});
} }
resetEditState(); resetEditState();
@ -410,15 +690,15 @@ export default function Expansion() {
}; };
const renderEmpty = () => ( const renderEmpty = () => (
<div className="crm-empty-state rounded-2xl border border-slate-100 bg-white p-10 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/50"> <div className="crm-empty-panel">
</div> </div>
); );
const renderFollowUpTimeline = () => { const renderFollowUpTimeline = () => {
if (followUpRecords.length <= 0) { if (followUpRecords.length <= 0) {
return ( return (
<div className="crm-empty-state rounded-xl border border-slate-100 bg-slate-50/50 p-6 dark:border-slate-800 dark:bg-slate-800/20"> <div className="crm-empty-panel">
</div> </div>
); );
@ -453,14 +733,16 @@ export default function Expansion() {
const renderSalesForm = ( const renderSalesForm = (
form: CreateSalesExpansionPayload, form: CreateSalesExpansionPayload,
onChange: <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => void, onChange: <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => void,
fieldErrors?: Partial<Record<SalesCreateField, string>>,
) => ( ) => (
<div className="crm-form-grid"> <div className="crm-form-grid">
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.employeeNo} onChange={(e) => onChange("employeeNo", e.target.value)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input value={form.employeeNo} onChange={(e) => onChange("employeeNo", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.employeeNo))} />
{fieldErrors?.employeeNo ? <p className="text-xs text-rose-500">{fieldErrors.employeeNo}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"> / </span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"> / <RequiredMark /></span>
<AdaptiveSelect <AdaptiveSelect
value={form.officeName || ""} value={form.officeName || ""}
placeholder="请选择" placeholder="请选择"
@ -472,28 +754,35 @@ export default function Expansion() {
label: option.label || "无", label: option.label || "无",
})), })),
]} ]}
className={cn(
fieldErrors?.officeName ? "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" : "",
)}
onChange={(value) => onChange("officeName", value || undefined)} onChange={(value) => onChange("officeName", value || undefined)}
/> />
{fieldErrors?.officeName ? <p className="text-xs text-rose-500">{fieldErrors.officeName}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.candidateName} onChange={(e) => onChange("candidateName", e.target.value)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input value={form.candidateName} onChange={(e) => onChange("candidateName", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.candidateName))} />
{fieldErrors?.candidateName ? <p className="text-xs text-rose-500">{fieldErrors.candidateName}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.mobile} onChange={(e) => onChange("mobile", e.target.value)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input value={form.mobile} onChange={(e) => onChange("mobile", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.mobile))} />
{fieldErrors?.mobile ? <p className="text-xs text-rose-500">{fieldErrors.mobile}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input <input
value={form.targetDept || ""} value={form.targetDept || ""}
onChange={(e) => onChange("targetDept", e.target.value)} onChange={(e) => onChange("targetDept", e.target.value)}
placeholder="办事处/行业系统部/地市" placeholder="办事处/行业系统部/地市"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" className={getFieldInputClass(Boolean(fieldErrors?.targetDept))}
/> />
{fieldErrors?.targetDept ? <p className="text-xs text-rose-500">{fieldErrors.targetDept}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect <AdaptiveSelect
value={form.industry || ""} value={form.industry || ""}
placeholder="请选择" placeholder="请选择"
@ -505,15 +794,20 @@ export default function Expansion() {
label: option.label || "无", label: option.label || "无",
})), })),
]} ]}
className={cn(
fieldErrors?.industry ? "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" : "",
)}
onChange={(value) => onChange("industry", value || undefined)} onChange={(value) => onChange("industry", value || undefined)}
/> />
{fieldErrors?.industry ? <p className="text-xs text-rose-500">{fieldErrors.industry}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.title} onChange={(e) => onChange("title", e.target.value)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input value={form.title} onChange={(e) => onChange("title", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.title))} />
{fieldErrors?.title ? <p className="text-xs text-rose-500">{fieldErrors.title}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect <AdaptiveSelect
value={form.intentLevel} value={form.intentLevel}
sheetTitle="合作意向" sheetTitle="合作意向"
@ -522,11 +816,15 @@ export default function Expansion() {
{ value: "medium", label: "中" }, { value: "medium", label: "中" },
{ value: "low", label: "低" }, { value: "low", label: "低" },
]} ]}
className={cn(
fieldErrors?.intentLevel ? "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" : "",
)}
onChange={(value) => onChange("intentLevel", value)} onChange={(value) => onChange("intentLevel", value)}
/> />
{fieldErrors?.intentLevel ? <p className="text-xs text-rose-500">{fieldErrors.intentLevel}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect <AdaptiveSelect
value={form.employmentStatus} value={form.employmentStatus}
sheetTitle="销售是否在职" sheetTitle="销售是否在职"
@ -534,8 +832,12 @@ export default function Expansion() {
{ value: "active", label: "是" }, { value: "active", label: "是" },
{ value: "left", label: "否" }, { value: "left", label: "否" },
]} ]}
className={cn(
fieldErrors?.employmentStatus ? "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" : "",
)}
onChange={(value) => onChange("employmentStatus", value)} onChange={(value) => onChange("employmentStatus", value)}
/> />
{fieldErrors?.employmentStatus ? <p className="text-xs text-rose-500">{fieldErrors.employmentStatus}</p> : null}
</label> </label>
<label className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 dark:border-slate-800"> <label className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 dark:border-slate-800">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
@ -554,6 +856,8 @@ export default function Expansion() {
form: CreateChannelExpansionPayload, form: CreateChannelExpansionPayload,
onChange: <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => void, onChange: <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => void,
isEdit = false, isEdit = false,
fieldErrors?: Partial<Record<ChannelField, string>>,
invalidContactRows: number[] = [],
) => ( ) => (
<div className="crm-form-grid"> <div className="crm-form-grid">
<label className="space-y-2"> <label className="space-y-2">
@ -565,35 +869,42 @@ export default function Expansion() {
/> />
</label> </label>
<label className="space-y-2 sm:col-span-2"> <label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.channelName} onChange={(e) => onChange("channelName", e.target.value)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input value={form.channelName} onChange={(e) => onChange("channelName", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.channelName))} />
{fieldErrors?.channelName ? <p className="text-xs text-rose-500">{fieldErrors.channelName}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.province} onChange={(e) => onChange("province", e.target.value)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input value={form.province} onChange={(e) => onChange("province", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.province))} />
{fieldErrors?.province ? <p className="text-xs text-rose-500">{fieldErrors.province}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.officeAddress || ""} onChange={(e) => onChange("officeAddress", e.target.value)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input value={form.officeAddress || ""} onChange={(e) => onChange("officeAddress", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.officeAddress))} />
{fieldErrors?.officeAddress ? <p className="text-xs text-rose-500">{fieldErrors.officeAddress}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.channelIndustry || ""} onChange={(e) => onChange("channelIndustry", e.target.value)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input value={form.channelIndustry || ""} onChange={(e) => onChange("channelIndustry", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.channelIndustry))} />
{fieldErrors?.channelIndustry ? <p className="text-xs text-rose-500">{fieldErrors.channelIndustry}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input type="number" value={form.annualRevenue ?? ""} onChange={(e) => onChange("annualRevenue", e.target.value ? Number(e.target.value) : undefined)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input type="number" value={form.annualRevenue ?? ""} onChange={(e) => onChange("annualRevenue", e.target.value ? Number(e.target.value) : undefined)} className={getFieldInputClass(Boolean(fieldErrors?.annualRevenue))} />
{fieldErrors?.annualRevenue ? <p className="text-xs text-rose-500">{fieldErrors.annualRevenue}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input type="number" value={form.staffSize ?? ""} onChange={(e) => onChange("staffSize", e.target.value ? Number(e.target.value) : undefined)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input type="number" value={form.staffSize ?? ""} onChange={(e) => onChange("staffSize", e.target.value ? Number(e.target.value) : undefined)} className={getFieldInputClass(Boolean(fieldErrors?.staffSize))} />
{fieldErrors?.staffSize ? <p className="text-xs text-rose-500">{fieldErrors.staffSize}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input type="date" value={form.contactEstablishedDate || ""} onChange={(e) => onChange("contactEstablishedDate", e.target.value)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input type="date" value={form.contactEstablishedDate || ""} onChange={(e) => onChange("contactEstablishedDate", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.contactEstablishedDate))} />
{fieldErrors?.contactEstablishedDate ? <p className="text-xs text-rose-500">{fieldErrors.contactEstablishedDate}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect <AdaptiveSelect
value={form.intentLevel || "medium"} value={form.intentLevel || "medium"}
sheetTitle="合作意向" sheetTitle="合作意向"
@ -602,40 +913,65 @@ export default function Expansion() {
{ value: "medium", label: "中" }, { value: "medium", label: "中" },
{ value: "low", label: "低" }, { value: "low", label: "低" },
]} ]}
className={cn(
fieldErrors?.intentLevel ? "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" : "",
)}
onChange={(value) => onChange("intentLevel", value)} onChange={(value) => onChange("intentLevel", value)}
/> />
{fieldErrors?.intentLevel ? <p className="text-xs text-rose-500">{fieldErrors.intentLevel}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect <AdaptiveSelect
value={form.channelAttribute || ""} multiple
value={form.channelAttribute || []}
placeholder="请选择" placeholder="请选择"
sheetTitle="渠道属性" sheetTitle="渠道属性"
options={[ options={channelAttributeOptions.map((option) => ({
{ value: "", label: "请选择" }, value: option.value ?? "",
...channelAttributeOptions.map((option) => ({ label: option.label || "无",
value: option.value ?? "", }))}
label: option.label || "无", className={cn(
})), fieldErrors?.channelAttribute ? "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" : "",
]} )}
onChange={(value) => onChange("channelAttribute", value || undefined)} onChange={(value) => {
onChange("channelAttribute", value);
if (!channelOtherOptionValue || !value.includes(channelOtherOptionValue)) {
onChange("channelAttributeCustom", "");
}
}}
/> />
{fieldErrors?.channelAttribute ? <p className="text-xs text-rose-500">{fieldErrors.channelAttribute}</p> : null}
</label> </label>
{channelOtherOptionValue && (form.channelAttribute ?? []).includes(channelOtherOptionValue) ? (
<label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input
value={form.channelAttributeCustom || ""}
onChange={(e) => onChange("channelAttributeCustom", e.target.value)}
placeholder="请输入具体渠道属性"
className={getFieldInputClass(Boolean(fieldErrors?.channelAttributeCustom))}
/>
{fieldErrors?.channelAttributeCustom ? <p className="text-xs text-rose-500">{fieldErrors.channelAttributeCustom}</p> : null}
</label>
) : null}
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect <AdaptiveSelect
value={form.internalAttribute || ""} multiple
value={form.internalAttribute || []}
placeholder="请选择" placeholder="请选择"
sheetTitle="新华三内部属性" sheetTitle="新华三内部属性"
options={[ options={internalAttributeOptions.map((option) => ({
{ value: "", label: "请选择" }, value: option.value ?? "",
...internalAttributeOptions.map((option) => ({ label: option.label || "无",
value: option.value ?? "", }))}
label: option.label || "无", className={cn(
})), fieldErrors?.internalAttribute ? "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" : "",
]} )}
onChange={(value) => onChange("internalAttribute", value || undefined)} onChange={(value) => onChange("internalAttribute", value)}
/> />
{fieldErrors?.internalAttribute ? <p className="text-xs text-rose-500">{fieldErrors.internalAttribute}</p> : null}
</label> </label>
<label className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 dark:border-slate-800"> <label className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 dark:border-slate-800">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
@ -643,7 +979,7 @@ export default function Expansion() {
</label> </label>
<div className="crm-form-section sm:col-span-2"> <div className="crm-form-section sm:col-span-2">
<div className="crm-form-section-header"> <div className="crm-form-section-header">
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200"></span> <span className="text-sm font-semibold text-slate-800 dark:text-slate-200"><RequiredMark /></span>
<button type="button" onClick={() => addChannelContact(isEdit)} className="rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"> <button type="button" onClick={() => addChannelContact(isEdit)} className="rounded-lg bg-white px-3 py-1.5 text-xs font-medium text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400">
</button> </button>
@ -651,15 +987,16 @@ export default function Expansion() {
<div className="crm-section-stack"> <div className="crm-section-stack">
{(form.contacts ?? []).map((contact, index) => ( {(form.contacts ?? []).map((contact, index) => (
<div key={`${isEdit ? "edit" : "create"}-${index}`} className="grid grid-cols-1 gap-3 rounded-xl border border-slate-200 bg-white p-3 sm:grid-cols-[1fr_1fr_1fr_auto] dark:border-slate-700 dark:bg-slate-900/50"> <div key={`${isEdit ? "edit" : "create"}-${index}`} className="grid grid-cols-1 gap-3 rounded-xl border border-slate-200 bg-white p-3 sm:grid-cols-[1fr_1fr_1fr_auto] dark:border-slate-700 dark:bg-slate-900/50">
<input value={contact.name || ""} onChange={(e) => handleChannelContactChange(index, "name", e.target.value, isEdit)} placeholder="人员姓名" className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input value={contact.name || ""} onChange={(e) => handleChannelContactChange(index, "name", e.target.value, isEdit)} placeholder="人员姓名" className={cn("w-full rounded-lg border bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", invalidContactRows.includes(index) ? "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")} />
<input value={contact.mobile || ""} onChange={(e) => handleChannelContactChange(index, "mobile", e.target.value, isEdit)} placeholder="联系电话" className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input value={contact.mobile || ""} onChange={(e) => handleChannelContactChange(index, "mobile", e.target.value, isEdit)} placeholder="联系电话" className={cn("w-full rounded-lg border bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", invalidContactRows.includes(index) ? "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")} />
<input value={contact.title || ""} onChange={(e) => handleChannelContactChange(index, "title", e.target.value, isEdit)} placeholder="职位" className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input value={contact.title || ""} onChange={(e) => handleChannelContactChange(index, "title", e.target.value, isEdit)} placeholder="职位" className={cn("w-full rounded-lg border bg-white px-3 py-2 text-sm outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50", invalidContactRows.includes(index) ? "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")} />
<button type="button" onClick={() => removeChannelContact(index, isEdit)} className="rounded-lg border border-rose-200 px-3 py-2 text-sm font-medium text-rose-500 hover:bg-rose-50 dark:border-rose-900/50 dark:hover:bg-rose-500/10"> <button type="button" onClick={() => removeChannelContact(index, isEdit)} className="crm-btn-danger rounded-lg px-3 py-2 text-sm font-medium">
</button> </button>
</div> </div>
))} ))}
</div> </div>
{fieldErrors?.contacts ? <p className="text-xs text-rose-500">{fieldErrors.contacts}</p> : null}
</div> </div>
<label className="space-y-2 sm:col-span-2"> <label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span>
@ -676,18 +1013,21 @@ export default function Expansion() {
return ( return (
<div className="crm-page-stack"> <div className="crm-page-stack">
<header className="flex flex-wrap items-center justify-between gap-3"> <header className="crm-page-header">
<h1 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-2xl"></h1> <div className="crm-page-heading">
<h1 className="crm-page-title"></h1>
<p className="crm-page-subtitle hidden sm:block"></p>
</div>
<button <button
onClick={handleOpenCreate} onClick={handleOpenCreate}
className="crm-btn-sm flex items-center gap-2 rounded-xl bg-violet-600 text-white shadow-sm transition-all hover:bg-violet-700 active:scale-95" className="crm-btn-sm crm-btn-primary flex items-center gap-2 active:scale-95"
> >
<Plus className="h-4 w-4" /> <Plus className="crm-icon-md" />
<span className="hidden sm:inline"></span> <span className="hidden sm:inline"></span>
</button> </button>
</header> </header>
<div className="flex rounded-xl border border-slate-200/50 bg-slate-100 p-1 backdrop-blur-sm dark:border-slate-800/50 dark:bg-slate-900/50"> <div className="crm-filter-bar flex backdrop-blur-sm">
<button <button
onClick={() => handleTabChange("sales")} onClick={() => handleTabChange("sales")}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-all duration-200 ${ className={`flex-1 rounded-lg py-2 text-sm font-medium transition-all duration-200 ${
@ -751,7 +1091,7 @@ export default function Expansion() {
<div className="mt-4 flex justify-end border-t border-slate-50 pt-3 dark:border-slate-800/50"> <div className="mt-4 flex justify-end border-t border-slate-50 pt-3 dark:border-slate-800/50">
<button type="button" className={`${detailBadgeClass} px-2 py-0.5 text-[10px] sm:px-2.5 sm:py-1 sm:text-[11px]`}> <button type="button" className={`${detailBadgeClass} px-2 py-0.5 text-[10px] sm:px-2.5 sm:py-1 sm:text-[11px]`}>
<ChevronRight className="h-3 w-3" /> <ChevronRight className="crm-icon-sm" />
</button> </button>
</div> </div>
</motion.div> </motion.div>
@ -791,7 +1131,7 @@ export default function Expansion() {
<div className="mt-4 flex justify-end border-t border-slate-50 pt-3 dark:border-slate-800/50"> <div className="mt-4 flex justify-end border-t border-slate-50 pt-3 dark:border-slate-800/50">
<button type="button" className={detailBadgeClass}> <button type="button" className={detailBadgeClass}>
<ChevronRight className="h-3 w-3" /> <ChevronRight className="crm-icon-sm" />
</button> </button>
</div> </div>
</motion.div> </motion.div>
@ -807,13 +1147,13 @@ export default function Expansion() {
onClose={resetCreateState} onClose={resetCreateState}
footer={( footer={(
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button onClick={resetCreateState} className="crm-btn rounded-xl border border-slate-200 bg-white text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"></button> <button onClick={resetCreateState} className="crm-btn crm-btn-secondary"></button>
<button onClick={() => void handleCreateSubmit()} disabled={submitting} className="crm-btn rounded-xl bg-violet-600 text-white shadow-sm transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60">{submitting ? "提交中..." : "确认新增"}</button> <button onClick={() => void handleCreateSubmit()} disabled={submitting} className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60">{submitting ? "提交中..." : "确认新增"}</button>
</div> </div>
)} )}
> >
{activeTab === "sales" ? renderSalesForm(salesForm, handleSalesChange) : renderChannelForm(channelForm, handleChannelChange)} {activeTab === "sales" ? renderSalesForm(salesForm, handleSalesChange, salesCreateFieldErrors) : renderChannelForm(channelForm, handleChannelChange, false, channelCreateFieldErrors, invalidCreateChannelContactRows)}
{createError ? <div className="mt-4 rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-600 dark:border-rose-900/50 dark:bg-rose-500/10 dark:text-rose-300">{createError}</div> : null} {createError ? <div className="crm-alert crm-alert-error mt-4">{createError}</div> : null}
</ModalShell> </ModalShell>
)} )}
</AnimatePresence> </AnimatePresence>
@ -826,13 +1166,13 @@ export default function Expansion() {
onClose={resetEditState} onClose={resetEditState}
footer={( footer={(
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button onClick={resetEditState} className="crm-btn rounded-xl border border-slate-200 bg-white text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"></button> <button onClick={resetEditState} className="crm-btn crm-btn-secondary"></button>
<button onClick={() => void handleEditSubmit()} disabled={submitting} className="crm-btn rounded-xl bg-violet-600 text-white shadow-sm transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60">{submitting ? "保存中..." : "保存修改"}</button> <button onClick={() => void handleEditSubmit()} disabled={submitting} className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60">{submitting ? "保存中..." : "保存修改"}</button>
</div> </div>
)} )}
> >
{selectedItem.type === "sales" ? renderSalesForm(editSalesForm, handleEditSalesChange) : renderChannelForm(editChannelForm, handleEditChannelChange, true)} {selectedItem.type === "sales" ? renderSalesForm(editSalesForm, handleEditSalesChange, salesEditFieldErrors) : renderChannelForm(editChannelForm, handleEditChannelChange, true, channelEditFieldErrors, invalidEditChannelContactRows)}
{editError ? <div className="mt-4 rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-600 dark:border-rose-900/50 dark:bg-rose-500/10 dark:text-rose-300">{editError}</div> : null} {editError ? <div className="crm-alert crm-alert-error mt-4">{editError}</div> : null}
</ModalShell> </ModalShell>
)} )}
</AnimatePresence> </AnimatePresence>
@ -863,8 +1203,8 @@ export default function Expansion() {
<div className="h-1.5 w-10 rounded-full bg-slate-200 sm:hidden dark:bg-slate-700" /> <div className="h-1.5 w-10 rounded-full bg-slate-200 sm:hidden dark:bg-slate-700" />
<h2 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{selectedItem.type === "sales" ? "销售拓展详情" : "渠道拓展详情"}</h2> <h2 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{selectedItem.type === "sales" ? "销售拓展详情" : "渠道拓展详情"}</h2>
</div> </div>
<button onClick={() => setSelectedItem(null)} className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"> <button onClick={() => setSelectedItem(null)} className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800">
<X className="h-5 w-5" /> <X className="crm-icon-lg" />
</button> </button>
</div> </div>
@ -893,7 +1233,7 @@ export default function Expansion() {
<div className="crm-section-stack"> <div className="crm-section-stack">
<h4 className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white"> <h4 className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<FileText className="h-4 w-4 text-violet-500" /> <FileText className="crm-icon-md text-violet-500" />
</h4> </h4>
<div className="crm-detail-grid text-sm sm:grid-cols-2"> <div className="crm-detail-grid text-sm sm:grid-cols-2">
@ -971,7 +1311,7 @@ export default function Expansion() {
))} ))}
</div> </div>
) : ( ) : (
<div className="crm-empty-state rounded-xl border border-slate-100 bg-slate-50/50 p-6 dark:border-slate-800 dark:bg-slate-800/20"> <div className="crm-empty-panel">
</div> </div>
)} )}
@ -979,7 +1319,7 @@ export default function Expansion() {
) : ( ) : (
<div className="crm-section-stack"> <div className="crm-section-stack">
<h4 className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white"> <h4 className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<Clock className="h-4 w-4 text-violet-500" /> <Clock className="crm-icon-md text-violet-500" />
</h4> </h4>
{renderFollowUpTimeline()} {renderFollowUpTimeline()}
@ -1039,7 +1379,7 @@ export default function Expansion() {
))} ))}
</div> </div>
) : ( ) : (
<div className="crm-empty-state rounded-xl border border-slate-100 bg-slate-50/50 p-6 dark:border-slate-800 dark:bg-slate-800/20"> <div className="crm-empty-panel">
</div> </div>
) )
@ -1057,7 +1397,7 @@ export default function Expansion() {
))} ))}
</div> </div>
) : ( ) : (
<div className="crm-empty-state rounded-xl border border-slate-100 bg-slate-50/50 p-6 dark:border-slate-800 dark:bg-slate-800/20"> <div className="crm-empty-panel">
</div> </div>
) )
@ -1072,7 +1412,7 @@ export default function Expansion() {
<div className="sticky bottom-0 bg-slate-50/95 px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 backdrop-blur sm:static sm:p-4 dark:bg-slate-900/90"> <div className="sticky bottom-0 bg-slate-50/95 px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 backdrop-blur sm:static sm:p-4 dark:bg-slate-900/90">
<div className="flex"> <div className="flex">
<button onClick={handleOpenEdit} className="crm-btn-sm flex-1 rounded-xl border border-slate-200 bg-white text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"> <button onClick={handleOpenEdit} className="crm-btn-sm crm-btn-secondary flex-1">
</button> </button>
</div> </div>

View File

@ -1,10 +1,11 @@
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { Search, Plus, ChevronRight, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle } from "lucide-react"; import { Search, Plus, ChevronRight, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter } from "lucide-react";
import { motion, AnimatePresence } from "motion/react"; import { motion, AnimatePresence } from "motion/react";
import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOverview, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type SalesExpansionItem } from "@/lib/auth"; import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOverview, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type SalesExpansionItem } from "@/lib/auth";
import { AdaptiveSelect } from "@/components/AdaptiveSelect"; import { AdaptiveSelect } from "@/components/AdaptiveSelect";
import { cn } from "@/lib/utils";
const detailBadgeClass = "crm-pill crm-pill-violet inline-flex items-center gap-0.5 border border-violet-100 text-[11px] 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"; const detailBadgeClass = "crm-btn-chip text-[11px] font-semibold";
const CONFIDENCE_OPTIONS = [ const CONFIDENCE_OPTIONS = [
{ value: "80", label: "A" }, { value: "80", label: "A" },
@ -24,6 +25,20 @@ const COMPETITOR_OPTIONS = [
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number] | ""; type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number] | "";
type OperatorMode = "none" | "h3c" | "channel" | "both"; type OperatorMode = "none" | "h3c" | "channel" | "both";
type OpportunityArchiveTab = "active" | "archived";
type OpportunityField =
| "projectLocation"
| "opportunityName"
| "customerName"
| "operatorName"
| "salesExpansionId"
| "channelExpansionId"
| "amount"
| "expectedCloseDate"
| "confidencePct"
| "stage"
| "competitorName"
| "opportunityType";
const defaultForm: CreateOpportunityPayload = { const defaultForm: CreateOpportunityPayload = {
opportunityName: "", opportunityName: "",
@ -169,6 +184,75 @@ function validateOperatorRelations(
} }
} }
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 RequiredMark() {
return <span className="ml-1 text-rose-500">*</span>;
}
function validateOpportunityForm(
form: CreateOpportunityPayload,
competitorSelection: CompetitorOption,
operatorMode: OperatorMode,
) {
const errors: Partial<Record<OpportunityField, string>> = {};
if (!form.projectLocation?.trim()) {
errors.projectLocation = "请填写项目地";
}
if (!form.opportunityName?.trim()) {
errors.opportunityName = "请填写项目名称";
}
if (!form.customerName?.trim()) {
errors.customerName = "请填写最终客户";
}
if (!form.operatorName?.trim()) {
errors.operatorName = "请选择运作方";
}
if (!form.amount || form.amount <= 0) {
errors.amount = "请填写预计金额";
}
if (!form.expectedCloseDate?.trim()) {
errors.expectedCloseDate = "请选择预计下单时间";
}
if (!form.confidencePct || form.confidencePct <= 0) {
errors.confidencePct = "请选择项目把握度";
}
if (!form.stage?.trim()) {
errors.stage = "请选择项目阶段";
}
if (!form.opportunityType?.trim()) {
errors.opportunityType = "请选择建设类型";
}
if (!competitorSelection) {
errors.competitorName = "请选择竞争对手";
} else if (competitorSelection === "其他" && !form.competitorName?.trim()) {
errors.competitorName = "请选择“其他”时,请填写具体竞争对手";
}
if (operatorMode === "h3c" && !form.salesExpansionId) {
errors.salesExpansionId = "请选择新华三负责人";
}
if (operatorMode === "channel" && !form.channelExpansionId) {
errors.channelExpansionId = "请选择渠道名称";
}
if (operatorMode === "both" && !form.salesExpansionId) {
errors.salesExpansionId = "请选择新华三负责人";
}
if (operatorMode === "both" && !form.channelExpansionId) {
errors.channelExpansionId = "请选择渠道名称";
}
return errors;
}
function ModalShell({ function ModalShell({
title, title,
subtitle, subtitle,
@ -269,6 +353,7 @@ function SearchableSelect({
placeholder, placeholder,
searchPlaceholder, searchPlaceholder,
emptyText, emptyText,
className,
onChange, onChange,
}: { }: {
value?: number; value?: number;
@ -276,6 +361,7 @@ function SearchableSelect({
placeholder: string; placeholder: string;
searchPlaceholder: string; searchPlaceholder: string;
emptyText: string; emptyText: string;
className?: string;
onChange: (value?: number) => void; onChange: (value?: number) => void;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -392,7 +478,10 @@ function SearchableSelect({
return next; return next;
}); });
}} }}
className="crm-btn-sm crm-input-text flex w-full items-center justify-between rounded-xl border border-slate-200 bg-white text-left outline-none transition-colors hover:border-slate-300 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:hover:border-slate-700" className={cn(
"crm-btn-sm crm-input-text flex w-full items-center justify-between rounded-xl border border-slate-200 bg-white text-left outline-none transition-colors hover:border-slate-300 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:hover:border-slate-700",
className,
)}
> >
<span className={selectedOption ? "text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500"}> <span className={selectedOption ? "text-slate-900 dark:text-white" : "text-slate-400 dark:text-slate-500"}>
{selectedOption?.label || placeholder} {selectedOption?.label || placeholder}
@ -462,7 +551,9 @@ function SearchableSelect({
} }
export default function Opportunities() { export default function Opportunities() {
const [archiveTab, setArchiveTab] = useState<OpportunityArchiveTab>("active");
const [filter, setFilter] = useState("全部"); const [filter, setFilter] = useState("全部");
const [stageFilterOpen, setStageFilterOpen] = useState(false);
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [selectedItem, setSelectedItem] = useState<OpportunityItem | null>(null); const [selectedItem, setSelectedItem] = useState<OpportunityItem | null>(null);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
@ -478,6 +569,7 @@ export default function Opportunities() {
const [operatorOptions, setOperatorOptions] = useState<OpportunityDictOption[]>([]); const [operatorOptions, setOperatorOptions] = useState<OpportunityDictOption[]>([]);
const [form, setForm] = useState<CreateOpportunityPayload>(defaultForm); const [form, setForm] = useState<CreateOpportunityPayload>(defaultForm);
const [competitorSelection, setCompetitorSelection] = useState<CompetitorOption>(""); const [competitorSelection, setCompetitorSelection] = useState<CompetitorOption>("");
const [fieldErrors, setFieldErrors] = useState<Partial<Record<OpportunityField, string>>>({});
const [detailTab, setDetailTab] = useState<"sales" | "channel" | "followups">("sales"); const [detailTab, setDetailTab] = useState<"sales" | "channel" | "followups">("sales");
const hasForegroundModal = createOpen || editOpen || pushConfirmOpen; const hasForegroundModal = createOpen || editOpen || pushConfirmOpen;
@ -563,6 +655,12 @@ export default function Opportunities() {
}, [stageOptions]); }, [stageOptions]);
const followUpRecords: OpportunityFollowUp[] = selectedItem?.followUps ?? []; const followUpRecords: OpportunityFollowUp[] = selectedItem?.followUps ?? [];
const visibleItems = items.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)));
const stageFilterOptions = [
{ label: "全部", value: "全部" },
...stageOptions.map((item) => ({ label: item.label || item.value || "", value: item.value || "" })),
].filter((item) => item.value);
const activeStageFilterLabel = stageFilterOptions.find((item) => item.value === filter)?.label || "全部";
const selectedSalesExpansion = selectedItem?.salesExpansionId const selectedSalesExpansion = selectedItem?.salesExpansionId
? salesExpansionOptions.find((item) => item.id === selectedItem.salesExpansionId) ?? null ? salesExpansionOptions.find((item) => item.id === selectedItem.salesExpansionId) ?? null
: null; : null;
@ -593,6 +691,12 @@ export default function Opportunities() {
} }
}, [selectedItem]); }, [selectedItem]);
useEffect(() => {
if (selectedItem && !visibleItems.some((item) => item.id === selectedItem.id)) {
setSelectedItem(null);
}
}, [archiveTab, selectedItem, visibleItems]);
const getConfidenceColor = (score: number) => { const getConfidenceColor = (score: number) => {
const normalizedScore = Number(getConfidenceOptionValue(score)); const normalizedScore = Number(getConfidenceOptionValue(score));
if (normalizedScore >= 80) return "text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20"; if (normalizedScore >= 80) return "text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10 border-emerald-200 dark:border-emerald-500/20";
@ -602,10 +706,18 @@ export default function Opportunities() {
const handleChange = <K extends keyof CreateOpportunityPayload>(key: K, value: CreateOpportunityPayload[K]) => { const handleChange = <K extends keyof CreateOpportunityPayload>(key: K, value: CreateOpportunityPayload[K]) => {
setForm((current) => ({ ...current, [key]: value })); setForm((current) => ({ ...current, [key]: value }));
if (key in fieldErrors) {
setFieldErrors((current) => {
const next = { ...current };
delete next[key as OpportunityField];
return next;
});
}
}; };
const handleOpenCreate = () => { const handleOpenCreate = () => {
setError(""); setError("");
setFieldErrors({});
setForm(defaultForm); setForm(defaultForm);
setCompetitorSelection(""); setCompetitorSelection("");
setCreateOpen(true); setCreateOpen(true);
@ -616,6 +728,7 @@ export default function Opportunities() {
setEditOpen(false); setEditOpen(false);
setSubmitting(false); setSubmitting(false);
setError(""); setError("");
setFieldErrors({});
setForm(defaultForm); setForm(defaultForm);
setCompetitorSelection(""); setCompetitorSelection("");
}; };
@ -634,15 +747,17 @@ export default function Opportunities() {
return; return;
} }
setSubmitting(true);
setError(""); setError("");
const validationErrors = validateOpportunityForm(form, competitorSelection, operatorMode);
if (Object.keys(validationErrors).length > 0) {
setFieldErrors(validationErrors);
setError("请先完整填写商机必填字段");
return;
}
setSubmitting(true);
try { try {
validateOperatorRelations(operatorMode, form.salesExpansionId, form.channelExpansionId);
if (competitorSelection === "其他" && !form.competitorName?.trim()) {
throw new Error("请选择“其他”时,请填写具体竞争对手");
}
await createOpportunity(buildOpportunitySubmitPayload(form, competitorSelection, operatorMode)); await createOpportunity(buildOpportunitySubmitPayload(form, competitorSelection, operatorMode));
await reload(); await reload();
resetCreateState(); resetCreateState();
@ -661,6 +776,7 @@ export default function Opportunities() {
return; return;
} }
setError(""); setError("");
setFieldErrors({});
setForm(toFormFromItem(selectedItem)); setForm(toFormFromItem(selectedItem));
setCompetitorSelection(getCompetitorSelection(selectedItem.competitorName)); setCompetitorSelection(getCompetitorSelection(selectedItem.competitorName));
setEditOpen(true); setEditOpen(true);
@ -671,15 +787,17 @@ export default function Opportunities() {
return; return;
} }
setSubmitting(true);
setError(""); setError("");
const validationErrors = validateOpportunityForm(form, competitorSelection, operatorMode);
if (Object.keys(validationErrors).length > 0) {
setFieldErrors(validationErrors);
setError("请先完整填写商机必填字段");
return;
}
setSubmitting(true);
try { try {
validateOperatorRelations(operatorMode, form.salesExpansionId, form.channelExpansionId);
if (competitorSelection === "其他" && !form.competitorName?.trim()) {
throw new Error("请选择“其他”时,请填写具体竞争对手");
}
await updateOpportunity(selectedItem.id, buildOpportunitySubmitPayload(form, competitorSelection, operatorMode)); await updateOpportunity(selectedItem.id, buildOpportunitySubmitPayload(form, competitorSelection, operatorMode));
await reload(selectedItem.id); await reload(selectedItem.id);
resetCreateState(); resetCreateState();
@ -720,54 +838,77 @@ export default function Opportunities() {
}; };
const renderEmpty = () => ( const renderEmpty = () => (
<div className="crm-empty-state rounded-2xl border border-slate-100 bg-white p-10 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/50"> <div className="crm-empty-panel">
{archiveTab === "active" ? "暂无未归档商机,先新增一条试试。" : "暂无已归档商机。"}
</div> </div>
); );
return ( return (
<div className="crm-page-stack"> <div className="crm-page-stack">
<header className="flex flex-wrap items-center justify-between gap-3"> <header className="crm-page-header">
<h1 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-2xl"></h1> <div className="crm-page-heading">
<button onClick={handleOpenCreate} className="crm-btn-sm flex items-center gap-2 rounded-xl bg-violet-600 text-white shadow-sm transition-all hover:bg-violet-700 active:scale-95"> <h1 className="crm-page-title"></h1>
<Plus className="h-4 w-4" /> <p className="crm-page-subtitle hidden sm:block"></p>
</div>
<button onClick={handleOpenCreate} className="crm-btn-sm crm-btn-primary flex items-center gap-2 active:scale-95">
<Plus className="crm-icon-md" />
<span className="hidden sm:inline"></span> <span className="hidden sm:inline"></span>
</button> </button>
</header> </header>
<div className="relative group"> <div className="crm-filter-bar flex backdrop-blur-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 group-focus-within:text-violet-500 transition-colors" /> <button
<input onClick={() => setArchiveTab("active")}
type="text" className={`flex-1 rounded-lg py-2 text-sm font-medium transition-all duration-200 ${
placeholder="搜索项目名称、最终客户、编码..." archiveTab === "active"
value={keyword} ? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
onChange={(event) => setKeyword(event.target.value)} : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
className="crm-input-text w-full rounded-xl border border-slate-200 bg-white py-2.5 pl-10 pr-4 text-slate-900 outline-none transition-all focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:text-white" }`}
/> >
</button>
<button
onClick={() => setArchiveTab("archived")}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-all duration-200 ${
archiveTab === "archived"
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
}`}
>
</button>
</div> </div>
<div className="hidden gap-2 overflow-x-auto pb-2 scrollbar-hide sm:flex"> <div className="flex items-center gap-3">
{[ <div className="relative group min-w-0 flex-1">
{ label: "全部", value: "全部" }, <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 group-focus-within:text-violet-500 transition-colors" />
...stageOptions.map((item) => ({ label: item.label || item.value || "", value: item.value || "" })), <input
].filter((item) => item.value).map((stage) => ( type="text"
<button placeholder="搜索项目名称、最终客户、编码..."
key={stage.value} value={keyword}
onClick={() => setFilter(stage.value)} onChange={(event) => setKeyword(event.target.value)}
className={`whitespace-nowrap rounded-full px-4 py-1.5 text-sm font-medium transition-all duration-200 ${ className="crm-input-text w-full rounded-xl border border-slate-200 bg-white py-2.5 pl-10 pr-4 text-slate-900 outline-none transition-all focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:text-white"
filter === stage.value />
? "bg-slate-800 text-white shadow-sm dark:bg-violet-600" </div>
: "border border-slate-200 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900/50 dark:text-slate-400 dark:hover:bg-slate-800" <button
}`} type="button"
> onClick={() => setStageFilterOpen(true)}
{stage.label} className={`relative inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border transition-all ${
</button> filter !== "全部"
))} ? "border-violet-200 bg-violet-50 text-violet-600 shadow-sm dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300"
: "border-slate-200 bg-white text-slate-500 hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900/50 dark:text-slate-400 dark:hover:bg-slate-800"
}`}
aria-label={`项目阶段筛选,当前:${activeStageFilterLabel}`}
title={`项目阶段筛选:${activeStageFilterLabel}`}
>
<ListFilter className="h-5 w-5" />
{filter !== "全部" ? <span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-violet-500" /> : null}
</button>
</div> </div>
<div className="crm-list-stack"> <div className="crm-list-stack">
{items.length > 0 ? ( {visibleItems.length > 0 ? (
items.map((opp, i) => ( visibleItems.map((opp, i) => (
<motion.div <motion.div
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@ -797,7 +938,7 @@ export default function Opportunities() {
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">{opp.date || "待定"}</span> <span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">{opp.date || "待定"}</span>
</div> </div>
<div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300"> <div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="shrink-0 text-slate-400 dark:text-slate-500">:</span> <span className="shrink-0 text-slate-400 dark:text-slate-500">:</span>
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">¥{formatAmount(opp.amount)}</span> <span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">¥{formatAmount(opp.amount)}</span>
</div> </div>
<div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300"> <div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300">
@ -812,7 +953,7 @@ export default function Opportunities() {
<div className="mt-4 flex justify-end border-t border-slate-50 pt-3 dark:border-slate-800/50"> <div className="mt-4 flex justify-end border-t border-slate-50 pt-3 dark:border-slate-800/50">
<button type="button" className={detailBadgeClass}> <button type="button" className={detailBadgeClass}>
<ChevronRight className="ml-0.5 h-3 w-3" /> <ChevronRight className="crm-icon-sm ml-0.5" />
</button> </button>
</div> </div>
</motion.div> </motion.div>
@ -820,6 +961,67 @@ export default function Opportunities() {
) : renderEmpty()} ) : renderEmpty()}
</div> </div>
<AnimatePresence>
{stageFilterOpen ? (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setStageFilterOpen(false)}
className="fixed inset-0 z-[90] bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70"
/>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
className="fixed inset-x-0 bottom-0 z-[100] px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3 sm:inset-0 sm:flex sm:items-center sm:justify-center sm:p-6"
>
<div className="mx-auto w-full max-w-md rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900">
<div className="border-b border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6">
<div className="mx-auto mb-3 h-1.5 w-10 rounded-full bg-slate-200 dark:bg-slate-700 sm:hidden" />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-base font-semibold text-slate-900 dark:text-white"></h3>
<p className="mt-1 text-sm leading-6 text-slate-500 dark:text-slate-400"></p>
</div>
<button
type="button"
onClick={() => setStageFilterOpen(false)}
className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<div className="px-4 py-4">
<div className="space-y-2">
{stageFilterOptions.map((stage) => (
<button
key={stage.value}
type="button"
onClick={() => {
setFilter(stage.value);
setStageFilterOpen(false);
}}
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left text-sm font-medium transition-colors ${
filter === stage.value
? "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300"
: "border-slate-200 bg-white text-slate-700 hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
}`}
>
<span>{stage.label}</span>
{filter === stage.value ? <Check className="h-4 w-4 shrink-0" /> : null}
</button>
))}
</div>
</div>
</div>
</motion.div>
</>
) : null}
</AnimatePresence>
<AnimatePresence> <AnimatePresence>
{(createOpen || editOpen) && ( {(createOpen || editOpen) && (
<ModalShell <ModalShell
@ -828,26 +1030,29 @@ export default function Opportunities() {
onClose={resetCreateState} onClose={resetCreateState}
footer={( footer={(
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button onClick={resetCreateState} className="crm-btn rounded-xl border border-slate-200 bg-white text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"></button> <button onClick={resetCreateState} className="crm-btn crm-btn-secondary"></button>
<button onClick={() => void (editOpen ? handleEditSubmit() : handleCreateSubmit())} disabled={submitting} className="crm-btn rounded-xl bg-violet-600 text-white shadow-sm transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60">{submitting ? "提交中..." : editOpen ? "保存修改" : "确认新增"}</button> <button onClick={() => void (editOpen ? handleEditSubmit() : handleCreateSubmit())} disabled={submitting} className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60">{submitting ? "提交中..." : editOpen ? "保存修改" : "确认新增"}</button>
</div> </div>
)} )}
> >
<div className="crm-form-grid"> <div className="crm-form-grid">
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.projectLocation || ""} onChange={(e) => handleChange("projectLocation", e.target.value)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input value={form.projectLocation || ""} onChange={(e) => handleChange("projectLocation", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.projectLocation))} />
{fieldErrors.projectLocation ? <p className="text-xs text-rose-500">{fieldErrors.projectLocation}</p> : null}
</label> </label>
<label className="space-y-2 sm:col-span-2"> <label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.opportunityName} onChange={(e) => handleChange("opportunityName", e.target.value)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input value={form.opportunityName} onChange={(e) => handleChange("opportunityName", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.opportunityName))} />
{fieldErrors.opportunityName ? <p className="text-xs text-rose-500">{fieldErrors.opportunityName}</p> : null}
</label> </label>
<label className="space-y-2 sm:col-span-2"> <label className="space-y-2 sm:col-span-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input value={form.customerName} onChange={(e) => handleChange("customerName", e.target.value)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input value={form.customerName} onChange={(e) => handleChange("customerName", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.customerName))} />
{fieldErrors.customerName ? <p className="text-xs text-rose-500">{fieldErrors.customerName}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect <AdaptiveSelect
value={form.operatorName || ""} value={form.operatorName || ""}
placeholder="请选择" placeholder="请选择"
@ -859,55 +1064,73 @@ export default function Opportunities() {
label: item.label || item.value || "无", label: item.label || item.value || "无",
})), })),
]} ]}
className={cn(
fieldErrors.operatorName ? "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" : "",
)}
onChange={(value) => handleChange("operatorName", value)} onChange={(value) => handleChange("operatorName", value)}
/> />
{fieldErrors.operatorName ? <p className="text-xs text-rose-500">{fieldErrors.operatorName}</p> : null}
</label> </label>
{showSalesExpansionField ? ( {showSalesExpansionField ? (
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<SearchableSelect <SearchableSelect
value={form.salesExpansionId} value={form.salesExpansionId}
options={salesExpansionSearchOptions} options={salesExpansionSearchOptions}
placeholder="请选择新华三负责人" placeholder="请选择新华三负责人"
searchPlaceholder="搜索姓名、工号、办事处、电话" searchPlaceholder="搜索姓名、工号、办事处、电话"
emptyText="未找到匹配的销售拓展人员" emptyText="未找到匹配的销售拓展人员"
className={cn(
fieldErrors.salesExpansionId ? "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" : "",
)}
onChange={(value) => handleChange("salesExpansionId", value)} onChange={(value) => handleChange("salesExpansionId", value)}
/> />
{fieldErrors.salesExpansionId ? <p className="text-xs text-rose-500">{fieldErrors.salesExpansionId}</p> : null}
</label> </label>
) : null} ) : null}
{showChannelExpansionField ? ( {showChannelExpansionField ? (
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<SearchableSelect <SearchableSelect
value={form.channelExpansionId} value={form.channelExpansionId}
options={channelExpansionSearchOptions} options={channelExpansionSearchOptions}
placeholder="请选择渠道名称" placeholder="请选择渠道名称"
searchPlaceholder="搜索渠道名称、编码、省份、联系人" searchPlaceholder="搜索渠道名称、编码、省份、联系人"
emptyText="未找到匹配的渠道" emptyText="未找到匹配的渠道"
className={cn(
fieldErrors.channelExpansionId ? "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" : "",
)}
onChange={(value) => handleChange("channelExpansionId", value)} onChange={(value) => handleChange("channelExpansionId", value)}
/> />
{fieldErrors.channelExpansionId ? <p className="text-xs text-rose-500">{fieldErrors.channelExpansionId}</p> : null}
</label> </label>
) : null} ) : null}
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input type="number" min="0" value={form.amount || ""} onChange={(e) => handleChange("amount", Number(e.target.value) || 0)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input type="number" min="0" value={form.amount || ""} onChange={(e) => handleChange("amount", Number(e.target.value) || 0)} className={getFieldInputClass(Boolean(fieldErrors.amount))} />
{fieldErrors.amount ? <p className="text-xs text-rose-500">{fieldErrors.amount}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input type="date" value={form.expectedCloseDate} onChange={(e) => handleChange("expectedCloseDate", e.target.value)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <input type="date" value={form.expectedCloseDate} onChange={(e) => handleChange("expectedCloseDate", e.target.value)} className={`${getFieldInputClass(Boolean(fieldErrors.expectedCloseDate))} min-w-0`} />
{fieldErrors.expectedCloseDate ? <p className="text-xs text-rose-500">{fieldErrors.expectedCloseDate}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect <AdaptiveSelect
value={getConfidenceOptionValue(form.confidencePct)} value={getConfidenceOptionValue(form.confidencePct)}
placeholder="请选择" placeholder="请选择"
sheetTitle="项目把握度" sheetTitle="项目把握度"
options={CONFIDENCE_OPTIONS.map((item) => ({ value: item.value, label: item.label }))} options={CONFIDENCE_OPTIONS.map((item) => ({ value: item.value, label: item.label }))}
className={cn(
fieldErrors.confidencePct ? "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" : "",
)}
onChange={(value) => handleChange("confidencePct", Number(value) || 40)} onChange={(value) => handleChange("confidencePct", Number(value) || 40)}
/> />
{fieldErrors.confidencePct ? <p className="text-xs text-rose-500">{fieldErrors.confidencePct}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect <AdaptiveSelect
value={form.stage} value={form.stage}
placeholder="请选择" placeholder="请选择"
@ -916,19 +1139,34 @@ export default function Opportunities() {
value: item.value || "", value: item.value || "",
label: item.label || item.value || "无", label: item.label || item.value || "无",
}))} }))}
className={cn(
fieldErrors.stage ? "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" : "",
)}
onChange={(value) => handleChange("stage", value)} onChange={(value) => handleChange("stage", value)}
/> />
{fieldErrors.stage ? <p className="text-xs text-rose-500">{fieldErrors.stage}</p> : null}
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect <AdaptiveSelect
value={competitorSelection} value={competitorSelection}
placeholder="请选择" placeholder="请选择"
sheetTitle="竞争对手" sheetTitle="竞争对手"
options={COMPETITOR_OPTIONS.map((item) => ({ value: item, label: item }))} options={COMPETITOR_OPTIONS.map((item) => ({ value: item, label: item }))}
className={cn(
fieldErrors.competitorName ? "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" : "",
)}
onChange={(value) => { onChange={(value) => {
const nextSelection = value as CompetitorOption; const nextSelection = value as CompetitorOption;
setCompetitorSelection(nextSelection); setCompetitorSelection(nextSelection);
setFieldErrors((current) => {
if (!current.competitorName) {
return current;
}
const next = { ...current };
delete next.competitorName;
return next;
});
if (nextSelection === "其他") { if (nextSelection === "其他") {
handleChange("competitorName", getCompetitorSelection(form.competitorName) === "其他" ? form.competitorName || "" : ""); handleChange("competitorName", getCompetitorSelection(form.competitorName) === "其他" ? form.competitorName || "" : "");
return; return;
@ -936,30 +1174,36 @@ export default function Opportunities() {
handleChange("competitorName", nextSelection || ""); handleChange("competitorName", nextSelection || "");
}} }}
/> />
{fieldErrors.competitorName && competitorSelection !== "其他" ? <p className="text-xs text-rose-500">{fieldErrors.competitorName}</p> : null}
</label> </label>
{competitorSelection === "其他" ? ( {competitorSelection === "其他" ? (
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"></span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<input <input
value={getCompetitorSelection(form.competitorName) === "其他" ? form.competitorName || "" : ""} value={getCompetitorSelection(form.competitorName) === "其他" ? form.competitorName || "" : ""}
onChange={(e) => handleChange("competitorName", e.target.value)} onChange={(e) => handleChange("competitorName", e.target.value)}
placeholder="请输入竞争对手名称" placeholder="请输入竞争对手名称"
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" className={getFieldInputClass(Boolean(fieldErrors.competitorName))}
/> />
{fieldErrors.competitorName ? <p className="text-xs text-rose-500">{fieldErrors.competitorName}</p> : null}
</label> </label>
) : null} ) : null}
<label className="space-y-2"> <label className="space-y-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">/</span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300"><RequiredMark /></span>
<AdaptiveSelect <AdaptiveSelect
value={form.opportunityType} value={form.opportunityType}
sheetTitle="扩容/新建" sheetTitle="设类型"
options={[ options={[
{ value: "新建", label: "新建" }, { value: "新建", label: "新建" },
{ value: "扩容", label: "扩容" }, { value: "扩容", label: "扩容" },
{ value: "替换", label: "替换" }, { value: "替换", label: "替换" },
]} ]}
className={cn(
fieldErrors.opportunityType ? "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" : "",
)}
onChange={(value) => handleChange("opportunityType", value)} onChange={(value) => handleChange("opportunityType", value)}
/> />
{fieldErrors.opportunityType ? <p className="text-xs text-rose-500">{fieldErrors.opportunityType}</p> : null}
</label> </label>
{selectedItem ? ( {selectedItem ? (
<> <>
@ -990,7 +1234,7 @@ export default function Opportunities() {
<textarea rows={4} value={form.description || ""} onChange={(e) => handleChange("description", e.target.value)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" /> <textarea rows={4} value={form.description || ""} onChange={(e) => handleChange("description", e.target.value)} className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" />
</label> </label>
</div> </div>
{error ? <div className="mt-4 rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-600 dark:border-rose-900/50 dark:bg-rose-500/10 dark:text-rose-300">{error}</div> : null} {error ? <div className="crm-alert crm-alert-error mt-4">{error}</div> : null}
</ModalShell> </ModalShell>
)} )}
</AnimatePresence> </AnimatePresence>
@ -1015,8 +1259,8 @@ export default function Opportunities() {
<div className="border-b border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6"> <div className="border-b border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6">
<div className="mx-auto mb-3 h-1.5 w-10 rounded-full bg-slate-200 dark:bg-slate-700 sm:hidden" /> <div className="mx-auto mb-3 h-1.5 w-10 rounded-full bg-slate-200 dark:bg-slate-700 sm:hidden" />
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-300"> <div className="crm-tone-warning mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl">
<AlertTriangle className="h-5 w-5" /> <AlertTriangle className="crm-icon-lg" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h3 className="text-base font-semibold text-slate-900 dark:text-white"> OMS</h3> <h3 className="text-base font-semibold text-slate-900 dark:text-white"> OMS</h3>
@ -1036,7 +1280,7 @@ export default function Opportunities() {
<button <button
type="button" type="button"
onClick={() => setPushConfirmOpen(false)} onClick={() => setPushConfirmOpen(false)}
className="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700" className="crm-btn crm-btn-secondary min-h-0 rounded-2xl px-4 py-3"
> >
</button> </button>
@ -1044,7 +1288,7 @@ export default function Opportunities() {
type="button" type="button"
onClick={() => void handleConfirmPushToOms()} onClick={() => void handleConfirmPushToOms()}
disabled={pushingOms} disabled={pushingOms}
className="rounded-2xl bg-violet-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60" className="crm-btn crm-btn-primary min-h-0 rounded-2xl px-4 py-3 disabled:cursor-not-allowed disabled:opacity-60"
> >
{pushingOms ? "推送中..." : "确认推送"} {pushingOms ? "推送中..." : "确认推送"}
</button> </button>
@ -1077,8 +1321,8 @@ export default function Opportunities() {
<div className="h-1.5 w-10 rounded-full bg-slate-200 sm:hidden dark:bg-slate-700" /> <div className="h-1.5 w-10 rounded-full bg-slate-200 sm:hidden dark:bg-slate-700" />
<h2 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg"></h2> <h2 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg"></h2>
</div> </div>
<button onClick={() => setSelectedItem(null)} className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"> <button onClick={() => setSelectedItem(null)} className="rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800">
<X className="h-5 w-5" /> <X className="crm-icon-lg" />
</button> </button>
</div> </div>
@ -1098,7 +1342,7 @@ export default function Opportunities() {
<div className="crm-section-stack"> <div className="crm-section-stack">
<h4 className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white"> <h4 className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<FileText className="h-4 w-4 text-violet-500" /> <FileText className="crm-icon-md text-violet-500" />
</h4> </h4>
<div className="crm-detail-grid text-sm md:grid-cols-2"> <div className="crm-detail-grid text-sm md:grid-cols-2">
@ -1106,12 +1350,12 @@ export default function Opportunities() {
<DetailItem label="最终客户" value={selectedItem.client || "无"} icon={<Building className="h-3 w-3" />} /> <DetailItem label="最终客户" value={selectedItem.client || "无"} icon={<Building className="h-3 w-3" />} />
<DetailItem label="运作方" value={selectedItem.operatorName || "无"} /> <DetailItem label="运作方" value={selectedItem.operatorName || "无"} />
<DetailItem label="新华三负责人" value={selectedSalesExpansionName || "未关联"} icon={<User className="h-3 w-3" />} /> <DetailItem label="新华三负责人" value={selectedSalesExpansionName || "未关联"} icon={<User className="h-3 w-3" />} />
<DetailItem label="项目金额(元)" value={<span className="text-rose-600 dark:text-rose-400">¥{formatAmount(selectedItem.amount)}</span>} icon={<DollarSign className="h-3 w-3" />} /> <DetailItem label="预计金额(元)" value={<span className="text-rose-600 dark:text-rose-400">¥{formatAmount(selectedItem.amount)}</span>} icon={<DollarSign className="h-3 w-3" />} />
<DetailItem label="预计下单时间" value={selectedItem.date || "待定"} icon={<Calendar className="h-3 w-3" />} /> <DetailItem label="预计下单时间" value={selectedItem.date || "待定"} icon={<Calendar className="h-3 w-3" />} />
<DetailItem label="项目把握度" value={getConfidenceLabel(selectedItem.confidence)} /> <DetailItem label="项目把握度" value={getConfidenceLabel(selectedItem.confidence)} />
<DetailItem label="项目阶段" value={selectedItem.stage || "无"} icon={<Activity className="h-3 w-3" />} /> <DetailItem label="项目阶段" value={selectedItem.stage || "无"} icon={<Activity className="h-3 w-3" />} />
<DetailItem label="竞争对手" value={selectedItem.competitorName || "无"} /> <DetailItem label="竞争对手" value={selectedItem.competitorName || "无"} />
<DetailItem label="扩容/新建" value={selectedItem.type || "新建"} icon={<Tag className="h-3 w-3" />} /> <DetailItem label="设类型" value={selectedItem.type || "新建"} icon={<Tag className="h-3 w-3" />} />
<DetailItem label="OMS 推送状态" value={selectedItem.pushedToOms ? "已推送 OMS" : "未推送 OMS"} /> <DetailItem label="OMS 推送状态" value={selectedItem.pushedToOms ? "已推送 OMS" : "未推送 OMS"} />
<DetailItem label="项目最新进展" value={selectedItem.latestProgress || "暂无日报回写进展"} className="md:col-span-2" /> <DetailItem label="项目最新进展" value={selectedItem.latestProgress || "暂无日报回写进展"} className="md:col-span-2" />
<DetailItem label="后续规划" value={selectedItem.nextPlan || "暂无日报回写规划"} className="md:col-span-2" /> <DetailItem label="后续规划" value={selectedItem.nextPlan || "暂无日报回写规划"} className="md:col-span-2" />
@ -1164,7 +1408,7 @@ export default function Opportunities() {
<DetailItem label="是否在职" value={selectedSalesExpansion ? (selectedSalesExpansion.active ? "是" : "否") : "待同步"} /> <DetailItem label="是否在职" value={selectedSalesExpansion ? (selectedSalesExpansion.active ? "是" : "否") : "待同步"} />
</div> </div>
) : ( ) : (
<div className="crm-empty-state rounded-xl border border-slate-100 bg-slate-50/50 p-6 dark:border-slate-800 dark:bg-slate-800/20"> <div className="crm-empty-panel">
</div> </div>
) )
@ -1178,7 +1422,7 @@ export default function Opportunities() {
<DetailItem label="建立联系时间" value={selectedChannelExpansion?.establishedDate || "无"} /> <DetailItem label="建立联系时间" value={selectedChannelExpansion?.establishedDate || "无"} />
</div> </div>
) : ( ) : (
<div className="crm-empty-state rounded-xl border border-slate-100 bg-slate-50/50 p-6 dark:border-slate-800 dark:bg-slate-800/20"> <div className="crm-empty-panel">
</div> </div>
) )
@ -1189,7 +1433,7 @@ export default function Opportunities() {
<p className="crm-field-note"></p> <p className="crm-field-note"></p>
<div className="crm-section-stack"> <div className="crm-section-stack">
<h4 className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white"> <h4 className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<Clock className="h-4 w-4 text-violet-500" /> <Clock className="crm-icon-md text-violet-500" />
</h4> </h4>
{followUpRecords.length > 0 ? ( {followUpRecords.length > 0 ? (
@ -1211,7 +1455,7 @@ export default function Opportunities() {
})} })}
</div> </div>
) : ( ) : (
<div className="crm-empty-state rounded-xl border border-slate-100 bg-slate-50/50 p-6 dark:border-slate-800 dark:bg-slate-800/20"> <div className="crm-empty-panel">
</div> </div>
)} )}
@ -1222,19 +1466,19 @@ export default function Opportunities() {
</div> </div>
</div> </div>
<div className="sticky bottom-0 bg-slate-50/95 px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 backdrop-blur sm:static sm:p-4 dark:bg-slate-900/90"> <div className="sticky bottom-0 bg-slate-50/95 px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 backdrop-blur sm:static sm:p-4 dark:bg-slate-900/90">
{error ? <div className="mb-3 rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-600 dark:border-rose-900/50 dark:bg-rose-500/10 dark:text-rose-300">{error}</div> : null} {error ? <div className="crm-alert crm-alert-error mb-3">{error}</div> : null}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<button <button
onClick={handleOpenEdit} onClick={handleOpenEdit}
disabled={Boolean(selectedItem.pushedToOms)} disabled={Boolean(selectedItem.pushedToOms)}
className="inline-flex h-11 items-center justify-center rounded-xl border border-slate-200 bg-white px-4 text-sm font-semibold text-slate-700 transition-colors hover:border-slate-300 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-700" className="crm-btn crm-btn-secondary inline-flex h-11 items-center justify-center disabled:cursor-not-allowed disabled:opacity-50"
> >
</button> </button>
<button <button
onClick={handleOpenPushConfirm} onClick={handleOpenPushConfirm}
disabled={Boolean(selectedItem.pushedToOms) || pushingOms} disabled={Boolean(selectedItem.pushedToOms) || pushingOms}
className="crm-btn-sm inline-flex items-center justify-center rounded-xl bg-violet-600 text-white shadow-sm transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60" className="crm-btn-sm crm-btn-primary inline-flex items-center justify-center disabled:cursor-not-allowed disabled:opacity-60"
> >
{selectedItem.pushedToOms ? "已推送 OMS" : pushingOms ? "推送中..." : "推送 OMS"} {selectedItem.pushedToOms ? "已推送 OMS" : pushingOms ? "推送中..." : "推送 OMS"}
</button> </button>

View File

@ -337,8 +337,11 @@ export default function Profile() {
return ( return (
<div className="crm-page-stack"> <div className="crm-page-stack">
<header className="flex flex-wrap items-center justify-between gap-3"> <header className="crm-page-header">
<h1 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-2xl"></h1> <div className="crm-page-heading">
<h1 className="crm-page-title"></h1>
<p className="crm-page-subtitle"></p>
</div>
</header> </header>
<motion.div <motion.div
@ -350,7 +353,7 @@ export default function Profile() {
onClick={() => void handleOpenProfile()} onClick={() => void handleOpenProfile()}
className="absolute right-4 top-4 rounded-full bg-slate-50 p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:bg-slate-800 dark:text-slate-500 dark:hover:bg-slate-700 dark:hover:text-slate-300 sm:right-6 sm:top-6" className="absolute right-4 top-4 rounded-full bg-slate-50 p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600 dark:bg-slate-800 dark:text-slate-500 dark:hover:bg-slate-700 dark:hover:text-slate-300 sm:right-6 sm:top-6"
> >
<Settings className="h-5 w-5" /> <Settings className="crm-icon-lg" />
</button> </button>
<div className="flex flex-col gap-5 pr-12 sm:flex-row sm:items-start"> <div className="flex flex-col gap-5 pr-12 sm:flex-row sm:items-start">
@ -406,13 +409,13 @@ export default function Profile() {
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 dark:bg-slate-800"> <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 dark:bg-slate-800">
{theme === "dark" ? <Sun className="h-5 w-5 text-amber-500" /> : <Moon className="h-5 w-5 text-indigo-500" />} {theme === "dark" ? <Sun className="crm-icon-lg text-amber-500" /> : <Moon className="crm-icon-lg text-indigo-500" />}
</div> </div>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"> <span className="text-sm font-medium text-slate-700 dark:text-slate-300">
{theme === "dark" ? "切换亮色模式" : "切换暗色模式"} {theme === "dark" ? "切换亮色模式" : "切换暗色模式"}
</span> </span>
</div> </div>
<ChevronRight className="h-5 w-5 text-slate-300 dark:text-slate-600" /> <ChevronRight className="crm-icon-lg text-slate-300 dark:text-slate-600" />
</button> </button>
</li> </li>
{MENU_ITEMS.map((item) => ( {MENU_ITEMS.map((item) => (
@ -431,11 +434,11 @@ export default function Profile() {
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-xl ${item.bg}`}> <div className={`flex h-10 w-10 items-center justify-center rounded-xl ${item.bg}`}>
<item.icon className={`h-5 w-5 ${item.color}`} /> <item.icon className={`crm-icon-lg ${item.color}`} />
</div> </div>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{item.label}</span> <span className="text-sm font-medium text-slate-700 dark:text-slate-300">{item.label}</span>
</div> </div>
<ChevronRight className="h-5 w-5 text-slate-300 dark:text-slate-600" /> <ChevronRight className="crm-icon-lg text-slate-300 dark:text-slate-600" />
</button> </button>
</li> </li>
))} ))}
@ -447,9 +450,9 @@ export default function Profile() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }} transition={{ delay: 0.2 }}
onClick={handleLogout} onClick={handleLogout}
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-rose-100 bg-rose-50 p-4 text-sm font-semibold text-rose-600 transition-all hover:bg-rose-100 active:scale-[0.98] dark:border-rose-900/50 dark:bg-rose-500/10 dark:text-rose-400 dark:hover:bg-rose-500/20" className="crm-btn crm-btn-danger flex w-full items-center justify-center gap-2 active:scale-[0.98]"
> >
<LogOut className="h-5 w-5" /> <LogOut className="crm-icon-lg" />
退 退
</motion.button> </motion.button>
@ -462,14 +465,14 @@ export default function Profile() {
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button <button
onClick={handleCloseProfile} onClick={handleCloseProfile}
className="crm-btn rounded-xl border border-slate-200 bg-white text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700" className="crm-btn crm-btn-secondary"
> >
</button> </button>
<button <button
onClick={() => void handleSave()} onClick={() => void handleSave()}
disabled={saving || detailLoading} disabled={saving || detailLoading}
className="crm-btn rounded-xl bg-violet-600 text-white shadow-sm transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:opacity-60" className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60"
> >
{saving ? "保存中..." : "保存资料"} {saving ? "保存中..." : "保存资料"}
</button> </button>
@ -477,7 +480,7 @@ export default function Profile() {
)} )}
> >
{error && !currentUser ? ( {error && !currentUser ? (
<div className="rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-600 dark:border-rose-900/50 dark:bg-rose-500/10 dark:text-rose-300"> <div className="crm-alert crm-alert-error">
{error} {error}
</div> </div>
) : ( ) : (
@ -513,7 +516,7 @@ export default function Profile() {
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300"> <span className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300">
<Phone className="h-4 w-4 text-slate-400" /> <Phone className="crm-icon-md text-slate-400" />
</span> </span>
<input <input
@ -524,7 +527,7 @@ export default function Profile() {
</label> </label>
<label className="space-y-2"> <label className="space-y-2">
<span className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300"> <span className="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300">
<Mail className="h-4 w-4 text-slate-400" /> <Mail className="crm-icon-md text-slate-400" />
</span> </span>
<input <input
@ -538,7 +541,7 @@ export default function Profile() {
<div className="crm-form-grid"> <div className="crm-form-grid">
<div className="crm-form-section"> <div className="crm-form-section">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white"> <div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<BriefcaseBusiness className="h-4 w-4 text-violet-500" /> <BriefcaseBusiness className="crm-icon-md text-violet-500" />
</div> </div>
<div className="mt-3 space-y-2 text-sm text-slate-500 dark:text-slate-400"> <div className="mt-3 space-y-2 text-sm text-slate-500 dark:text-slate-400">
@ -548,13 +551,13 @@ export default function Profile() {
</div> </div>
<div className="crm-form-section"> <div className="crm-form-section">
<div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white"> <div className="flex items-center gap-2 text-sm font-semibold text-slate-900 dark:text-white">
<MapPinned className="h-4 w-4 text-emerald-500" /> <MapPinned className="crm-icon-md text-emerald-500" />
</div> </div>
<div className="mt-3 space-y-2 text-sm text-slate-500 dark:text-slate-400"> <div className="mt-3 space-y-2 text-sm text-slate-500 dark:text-slate-400">
<p>{orgNames}</p> <p>{orgNames}</p>
<p className="flex items-center gap-2"> <p className="flex items-center gap-2">
<CircleUserRound className="h-4 w-4 text-slate-400" /> <CircleUserRound className="crm-icon-md text-slate-400" />
{numericValue(overview?.onboardingDays)} {numericValue(overview?.onboardingDays)}
</p> </p>
</div> </div>
@ -571,13 +574,13 @@ export default function Profile() {
</div> </div>
{detailLoading ? ( {detailLoading ? (
<div className="rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-800/40 dark:text-slate-400"> <div className="crm-alert crm-alert-info">
... ...
</div> </div>
) : null} ) : null}
{error ? ( {error ? (
<div className="rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-600 dark:border-rose-900/50 dark:bg-rose-500/10 dark:text-rose-300"> <div className="crm-alert crm-alert-error">
{error} {error}
</div> </div>
) : null} ) : null}
@ -593,14 +596,14 @@ export default function Profile() {
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end"> <div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button <button
onClick={handleCloseSecurity} onClick={handleCloseSecurity}
className="crm-btn rounded-xl border border-slate-200 bg-white text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700" className="crm-btn crm-btn-secondary"
> >
</button> </button>
<button <button
onClick={() => void handleUpdatePassword()} onClick={() => void handleUpdatePassword()}
disabled={securitySaving} disabled={securitySaving}
className="crm-btn rounded-xl bg-emerald-600 text-white shadow-sm transition-colors hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-60" className="crm-btn crm-btn-success disabled:cursor-not-allowed disabled:opacity-60"
> >
{securitySaving ? "提交中..." : "更新密码"} {securitySaving ? "提交中..." : "更新密码"}
</button> </button>
@ -631,7 +634,7 @@ export default function Profile() {
className="absolute inset-y-0 right-0 flex w-12 items-center justify-center text-slate-400 transition-colors hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300" className="absolute inset-y-0 right-0 flex w-12 items-center justify-center text-slate-400 transition-colors hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300"
aria-label={showOldPassword ? "隐藏当前密码" : "显示当前密码"} aria-label={showOldPassword ? "隐藏当前密码" : "显示当前密码"}
> >
{showOldPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showOldPassword ? <EyeOff className="crm-icon-md" /> : <Eye className="crm-icon-md" />}
</button> </button>
</div> </div>
</label> </label>
@ -652,7 +655,7 @@ export default function Profile() {
className="absolute inset-y-0 right-0 flex w-12 items-center justify-center text-slate-400 transition-colors hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300" className="absolute inset-y-0 right-0 flex w-12 items-center justify-center text-slate-400 transition-colors hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300"
aria-label={showNewPassword ? "隐藏新密码" : "显示新密码"} aria-label={showNewPassword ? "隐藏新密码" : "显示新密码"}
> >
{showNewPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showNewPassword ? <EyeOff className="crm-icon-md" /> : <Eye className="crm-icon-md" />}
</button> </button>
</div> </div>
</label> </label>
@ -673,20 +676,20 @@ export default function Profile() {
className="absolute inset-y-0 right-0 flex w-12 items-center justify-center text-slate-400 transition-colors hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300" className="absolute inset-y-0 right-0 flex w-12 items-center justify-center text-slate-400 transition-colors hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300"
aria-label={showConfirmPassword ? "隐藏确认密码" : "显示确认密码"} aria-label={showConfirmPassword ? "隐藏确认密码" : "显示确认密码"}
> >
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showConfirmPassword ? <EyeOff className="crm-icon-md" /> : <Eye className="crm-icon-md" />}
</button> </button>
</div> </div>
</label> </label>
</div> </div>
{securityError ? ( {securityError ? (
<div className="rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-sm text-rose-600 dark:border-rose-900/50 dark:bg-rose-500/10 dark:text-rose-300"> <div className="crm-alert crm-alert-error">
{securityError} {securityError}
</div> </div>
) : null} ) : null}
{securitySuccess ? ( {securitySuccess ? (
<div className="rounded-xl border border-emerald-100 bg-emerald-50 px-4 py-3 text-sm text-emerald-600 dark:border-emerald-900/50 dark:bg-emerald-500/10 dark:text-emerald-300"> <div className="crm-alert crm-alert-success">
{securitySuccess} {securitySuccess}
</div> </div>
) : null} ) : null}

View File

@ -736,10 +736,10 @@ export default function Work() {
return ( return (
<div className="crm-page-stack"> <div className="crm-page-stack">
<header className="flex flex-wrap items-center justify-between gap-3"> <header className="crm-page-header">
<div> <div className="crm-page-heading">
<h1 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-2xl"></h1> <h1 className="crm-page-title"></h1>
<p className="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm"> {format(new Date(), "yyyy年MM月dd日 EEEE", { locale: zhCN })}</p> <p className="crm-page-subtitle"> {format(new Date(), "yyyy年MM月dd日 EEEE", { locale: zhCN })}</p>
</div> </div>
</header> </header>
@ -830,7 +830,7 @@ export default function Work() {
))} ))}
{!historyLoading && historyData.length === 0 ? ( {!historyLoading && historyData.length === 0 ? (
<div className="crm-empty-state rounded-2xl border border-slate-100 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900/50"> <div className="crm-empty-panel">
{getHistoryLabelBySection(historySection)} {getHistoryLabelBySection(historySection)}
</div> </div>
) : null} ) : null}
@ -838,7 +838,7 @@ export default function Work() {
{historyHasMore ? ( {historyHasMore ? (
<div <div
ref={historyLoadMoreRef} ref={historyLoadMoreRef}
className="flex items-center justify-center rounded-2xl border border-dashed border-slate-200 bg-white px-3 py-3 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-900/50 dark:text-slate-400" className="crm-empty-panel flex items-center justify-center px-3 py-3"
> >
{historyLoadingMore ? "加载更多记录中..." : "下拉到底自动加载更多"} {historyLoadingMore ? "加载更多记录中..." : "下拉到底自动加载更多"}
</div> </div>
@ -858,7 +858,7 @@ export default function Work() {
style={{ pointerEvents: showHistoryBackToTop ? "auto" : "none" }} style={{ pointerEvents: showHistoryBackToTop ? "auto" : "none" }}
aria-label="回到历史记录顶部" aria-label="回到历史记录顶部"
> >
<ArrowUp className="h-4 w-4" /> <ArrowUp className="crm-icon-md" />
</motion.button> </motion.button>
</div> </div>
</div> </div>
@ -884,7 +884,7 @@ export default function Work() {
onClick={() => setObjectPicker(null)} onClick={() => setObjectPicker(null)}
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800" className="inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
> >
<X className="h-4 w-4" /> <X className="crm-icon-md" />
</button> </button>
</div> </div>
@ -919,7 +919,7 @@ export default function Work() {
<div className="max-h-[calc(82dvh-180px)] overflow-y-auto px-3 py-3 pb-[calc(env(safe-area-inset-bottom)+16px)] md:max-h-[calc(72vh-180px)] md:px-4 md:py-4"> <div className="max-h-[calc(82dvh-180px)] overflow-y-auto px-3 py-3 pb-[calc(env(safe-area-inset-bottom)+16px)] md:max-h-[calc(72vh-180px)] md:px-4 md:py-4">
{reportTargetsLoading && !pickerOptions.length ? ( {reportTargetsLoading && !pickerOptions.length ? (
<div className="crm-empty-state rounded-2xl border border-dashed border-slate-200 px-4 py-8 dark:border-slate-800"> <div className="crm-empty-panel px-4 py-8">
... ...
</div> </div>
) : pickerOptions.length ? ( ) : pickerOptions.length ? (
@ -937,7 +937,7 @@ export default function Work() {
))} ))}
</div> </div>
) : ( ) : (
<div className="crm-empty-state rounded-2xl border border-dashed border-slate-200 px-4 py-8 dark:border-slate-800"> <div className="crm-empty-panel px-4 py-8">
</div> </div>
)} )}
@ -1256,7 +1256,7 @@ function WorkSectionNav({
activeWorkSection: WorkSection; activeWorkSection: WorkSection;
}) { }) {
return ( return (
<div className="flex rounded-xl border border-slate-200/50 bg-slate-100 p-1 backdrop-blur-sm dark:border-slate-800/50 dark:bg-slate-900/50"> <div className="crm-filter-bar flex backdrop-blur-sm">
{workSectionItems.map((item) => { {workSectionItems.map((item) => {
const isActive = item.key === activeWorkSection; const isActive = item.key === activeWorkSection;
return ( return (
@ -1286,7 +1286,7 @@ function MobilePanelToggle({
onChange: (panel: "entry" | "history") => void; onChange: (panel: "entry" | "history") => void;
}) { }) {
return ( return (
<div className="flex rounded-xl border border-slate-200/70 bg-slate-100 p-1 dark:border-slate-800/70 dark:bg-slate-900/50 lg:hidden"> <div className="crm-filter-bar flex lg:hidden">
<button <button
type="button" type="button"
onClick={() => onChange("entry")} onClick={() => onChange("entry")}
@ -1411,8 +1411,8 @@ function HistoryCard({
> >
<div className="mb-3 flex items-start justify-between"> <div className="mb-3 flex items-start justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={`flex h-8 w-8 items-center justify-center rounded-full ${item.type === "日报" ? "bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400" : "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400"}`}> <div className={`flex h-8 w-8 items-center justify-center rounded-full ${item.type === "日报" ? "crm-tone-brand" : "crm-tone-success"}`}>
{item.type === "日报" ? <FileText className="h-4 w-4" /> : <MapPin className="h-4 w-4" />} {item.type === "日报" ? <FileText className="crm-icon-md" /> : <MapPin className="crm-icon-md" />}
</div> </div>
<div> <div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">{item.type}</h3> <h3 className="text-sm font-semibold text-slate-900 dark:text-white">{item.type}</h3>
@ -1546,7 +1546,7 @@ function CheckInPanel({
{checkInForm.bizName || "点击选择本次打卡关联对象"} {checkInForm.bizName || "点击选择本次打卡关联对象"}
</p> </p>
</div> </div>
<AtSign className="h-4 w-4 text-violet-500" /> <AtSign className="crm-icon-md text-violet-500" />
</button> </button>
</div> </div>
</div> </div>
@ -1559,7 +1559,7 @@ function CheckInPanel({
type="button" type="button"
onClick={onOpenLocationAdjust} onClick={onOpenLocationAdjust}
disabled={!checkInForm.latitude || !checkInForm.longitude || refreshingLocation} disabled={!checkInForm.latitude || !checkInForm.longitude || refreshingLocation}
className="crm-pill crm-pill-neutral inline-flex h-8 shrink-0 items-center justify-center border border-slate-200 px-3 transition-colors hover:border-violet-300 hover:text-violet-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-200 dark:hover:border-violet-500/40 dark:hover:text-violet-300" className="crm-btn-chip inline-flex h-8 shrink-0 items-center justify-center border-slate-200 px-3 text-slate-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-200"
> >
</button> </button>
@ -1567,9 +1567,9 @@ function CheckInPanel({
type="button" type="button"
onClick={onRefreshLocation} onClick={onRefreshLocation}
disabled={refreshingLocation} disabled={refreshingLocation}
className="crm-pill crm-pill-violet inline-flex h-8 shrink-0 items-center justify-center gap-1.5 px-3 transition-colors hover:bg-violet-100 disabled:cursor-not-allowed disabled:opacity-60 dark:hover:bg-violet-500/20" className="crm-btn-chip inline-flex h-8 shrink-0 items-center justify-center gap-1.5 px-3 disabled:cursor-not-allowed disabled:opacity-60"
> >
<RefreshCw className={`h-3.5 w-3.5 ${refreshingLocation ? "animate-spin" : ""}`} /> <RefreshCw className={`crm-icon-sm ${refreshingLocation ? "animate-spin" : ""}`} />
{refreshingLocation ? "定位中" : "刷新"} {refreshingLocation ? "定位中" : "刷新"}
</button> </button>
</div> </div>
@ -1625,9 +1625,9 @@ function CheckInPanel({
type="button" type="button"
onClick={onPickPhoto} onClick={onPickPhoto}
disabled={uploadingPhoto || !mobileCameraOnly} disabled={uploadingPhoto || !mobileCameraOnly}
className="crm-btn flex w-full items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white text-slate-700 transition-colors hover:border-violet-300 hover:text-violet-600 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-200 dark:hover:border-violet-500/40 dark:hover:text-violet-300" className="crm-btn crm-btn-secondary flex w-full items-center justify-center gap-2 disabled:cursor-not-allowed disabled:opacity-60"
> >
<Camera className="h-4 w-4" /> <Camera className="crm-icon-md" />
{uploadingPhoto ? "处理中..." : "重新拍照"} {uploadingPhoto ? "处理中..." : "重新拍照"}
</button> </button>
</div> </div>
@ -1638,8 +1638,8 @@ function CheckInPanel({
disabled={uploadingPhoto || !mobileCameraOnly} disabled={uploadingPhoto || !mobileCameraOnly}
className="flex min-h-36 w-full flex-col items-center justify-center rounded-xl border border-dashed border-slate-300 bg-white px-4 py-6 text-center transition-colors hover:border-violet-300 hover:bg-violet-50/50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:bg-slate-900/50 dark:hover:border-violet-500/40 dark:hover:bg-slate-900" className="flex min-h-36 w-full flex-col items-center justify-center rounded-xl border border-dashed border-slate-300 bg-white px-4 py-6 text-center transition-colors hover:border-violet-300 hover:bg-violet-50/50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:bg-slate-900/50 dark:hover:border-violet-500/40 dark:hover:bg-slate-900"
> >
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-300"> <div className="crm-tone-brand mb-3 flex h-12 w-12 items-center justify-center rounded-full">
<Camera className="h-5 w-5" /> <Camera className="crm-icon-lg" />
</div> </div>
<p className="text-sm font-medium text-slate-900 dark:text-white"> <p className="text-sm font-medium text-slate-900 dark:text-white">
{uploadingPhoto ? "照片处理中..." : mobileCameraOnly ? "点击拍摄现场照片" : "请使用手机端拍照打卡"} {uploadingPhoto ? "照片处理中..." : mobileCameraOnly ? "点击拍摄现场照片" : "请使用手机端拍照打卡"}
@ -1664,17 +1664,17 @@ function CheckInPanel({
/> />
</div> </div>
{checkInError ? <p className="mt-4 text-xs text-rose-500">{checkInError}</p> : null} {checkInError ? <div className="crm-alert crm-alert-error mt-4">{checkInError}</div> : null}
{checkInSuccess ? <p className="mt-4 text-xs text-emerald-500">{checkInSuccess}</p> : null} {checkInSuccess ? <div className="crm-alert crm-alert-success mt-4">{checkInSuccess}</div> : null}
{pageError ? <p className="mt-4 text-xs text-rose-500">{pageError}</p> : null} {pageError ? <div className="crm-alert crm-alert-error mt-4">{pageError}</div> : null}
<button <button
type="button" type="button"
onClick={onSubmit} onClick={onSubmit}
disabled={submittingCheckIn || loading || requiresLocationConfirmation} disabled={submittingCheckIn || loading || requiresLocationConfirmation}
className="crm-btn mt-6 flex w-full items-center justify-center gap-2 rounded-xl bg-emerald-600 text-white transition-all hover:bg-emerald-700 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60" className="crm-btn crm-btn-success mt-6 flex w-full items-center justify-center gap-2 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
> >
<CheckCircle2 className="h-4 w-4" /> <CheckCircle2 className="crm-icon-md" />
{submittingCheckIn ? "提交中..." : requiresLocationConfirmation ? "请先确认位置" : "确认打卡"} {submittingCheckIn ? "提交中..." : requiresLocationConfirmation ? "请先确认位置" : "确认打卡"}
</button> </button>
</motion.div> </motion.div>
@ -1842,7 +1842,7 @@ function ReportPanel({
<div className="mb-3 flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center"> <div className="mb-3 flex flex-col items-start justify-between gap-2 sm:flex-row sm:items-center">
<div> <div>
<label className="flex items-center gap-2 text-sm font-medium text-slate-900 dark:text-white"> <label className="flex items-center gap-2 text-sm font-medium text-slate-900 dark:text-white">
<FileText className="h-4 w-4 text-slate-400 dark:text-slate-500" /> <FileText className="crm-icon-md text-slate-400 dark:text-slate-500" />
</label> </label>
<p className="crm-field-note mt-1"> <p className="crm-field-note mt-1">
@ -1859,7 +1859,7 @@ function ReportPanel({
aria-label="添加一行今日工作内容" aria-label="添加一行今日工作内容"
className="crm-pill crm-pill-violet inline-flex h-8 w-8 items-center justify-center rounded-full p-0 transition-colors hover:bg-violet-100 dark:hover:bg-violet-500/20" className="crm-pill crm-pill-violet inline-flex h-8 w-8 items-center justify-center rounded-full p-0 transition-colors hover:bg-violet-100 dark:hover:bg-violet-500/20"
> >
<Plus className="h-4 w-4" /> <Plus className="crm-icon-md" />
</button> </button>
</div> </div>
</div> </div>
@ -1927,7 +1927,7 @@ function ReportPanel({
<div className="mt-6"> <div className="mt-6">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<label className="flex items-center gap-2 text-sm font-medium text-slate-900 dark:text-white"> <label className="flex items-center gap-2 text-sm font-medium text-slate-900 dark:text-white">
<ListTodo className="h-4 w-4 text-slate-400 dark:text-slate-500" /> <ListTodo className="crm-icon-md text-slate-400 dark:text-slate-500" />
</label> </label>
<button <button
@ -1936,7 +1936,7 @@ function ReportPanel({
aria-label="添加一行明日工作计划" aria-label="添加一行明日工作计划"
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-violet-50 text-violet-600 transition-colors hover:bg-violet-100 dark:bg-violet-500/10 dark:text-violet-400 dark:hover:bg-violet-500/20" className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-violet-50 text-violet-600 transition-colors hover:bg-violet-100 dark:bg-violet-500/10 dark:text-violet-400 dark:hover:bg-violet-500/20"
> >
<Plus className="h-4 w-4" /> <Plus className="crm-icon-md" />
</button> </button>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
@ -1989,17 +1989,17 @@ function ReportPanel({
</div> </div>
</div> </div>
{reportError ? <p className="mt-4 text-xs text-rose-500">{reportError}</p> : null} {reportError ? <div className="crm-alert crm-alert-error mt-4">{reportError}</div> : null}
{reportSuccess ? <p className="mt-4 text-xs text-emerald-500">{reportSuccess}</p> : null} {reportSuccess ? <div className="crm-alert crm-alert-success mt-4">{reportSuccess}</div> : null}
{pageError ? <p className="mt-4 text-xs text-rose-500">{pageError}</p> : null} {pageError ? <div className="crm-alert crm-alert-error mt-4">{pageError}</div> : null}
<button <button
type="button" type="button"
onClick={onSubmit} onClick={onSubmit}
disabled={submittingReport || loading} disabled={submittingReport || loading}
className="crm-btn mt-6 flex w-full items-center justify-center gap-2 rounded-xl bg-violet-600 text-white shadow-sm transition-all hover:bg-violet-700 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60" className="crm-btn crm-btn-primary mt-6 flex w-full items-center justify-center gap-2 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
> >
<Send className="h-4 w-4" /> <Send className="crm-icon-md" />
{submittingReport ? "提交中..." : "提交日报"} {submittingReport ? "提交中..." : "提交日报"}
</button> </button>
</motion.div> </motion.div>
@ -2035,7 +2035,7 @@ function HistoryDetailModal({
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800" className="inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
aria-label="关闭" aria-label="关闭"
> >
<X className="h-4 w-4" /> <X className="crm-icon-md" />
</button> </button>
</div> </div>

59
sql/README.md 100644
View File

@ -0,0 +1,59 @@
# SQL Scripts
当前项目的数据库脚本按“一个主初始化脚本 + 一组历史归档脚本”的方式整理:
- 主初始化脚本:`sql/init_full_pg17.sql`
- 历史迁移与修数脚本:`sql/archive/*.sql`
## 推荐用法
### 1. 全新初始化数据库
直接执行主脚本:
```bash
psql -d your_database -f sql/init_full_pg17.sql
```
这份脚本已经吸收了目前项目里所有必要的结构兼容调整,适合作为当前唯一推荐的 PostgreSQL 17 初始化入口。
### 2. 老环境排查或追溯
历史变更脚本保存在:
- `sql/archive/alter_*.sql`
- `sql/archive/fix_*.sql`
- `sql/archive/init_pg17.sql`
这些文件仅用于:
- 追溯某次字段演进
- 排查老环境与当前结构差异
- 定位某次修数逻辑来源
正常部署时,不建议按时间顺序逐个执行 `archive` 里的脚本。
## 当前目录说明
- `sql/init_full_pg17.sql`
当前项目的完整初始化脚本,也是唯一推荐执行的入口。
- `sql/archive/init_pg17.sql`
旧版本初始化快照,仅保留历史参考,不再作为正式入口。
- `sql/archive/alter_*.sql`
历史结构变更脚本。
- `sql/archive/fix_*.sql`
历史修数脚本。
## 维护约定
- 新增正式字段时,优先更新 `sql/init_full_pg17.sql`
- 若该字段来自一次线上增量变更,同时保留对应 `sql/archive/alter_*.sql`
- 若只是历史修复,不要反向拆散主初始化脚本
这样可以保证:
- 新环境只需要执行一份脚本
- 老环境仍然能追溯每次改动来源

View File

@ -0,0 +1,26 @@
# SQL Archive
本目录保留历史迁移脚本、旧版初始化快照与修数脚本,仅用于追溯和老环境排障。
正常部署请直接执行:
- `sql/init_full_pg17.sql`
非特殊场景下,不再逐个单独执行本目录中的脚本。
## 文件类型说明
- `init_pg17.sql`
旧版初始化快照,仅作历史参考,不再作为推荐入口。
- `alter_*.sql`
历史结构演进脚本。
- `fix_*.sql`
历史修数或数据纠偏脚本。
## 使用建议
- 新环境初始化:只执行根目录 `sql/init_full_pg17.sql`
- 老环境追溯:按问题定位具体查看本目录脚本
- 不建议在新环境中按时间顺序逐个补跑 `archive` 下的脚本

View File

@ -0,0 +1,5 @@
alter table crm_opportunity
add column if not exists archived boolean not null default false;
create index if not exists idx_crm_opportunity_archived
on crm_opportunity(archived);

View File

@ -1,6 +1,9 @@
-- PostgreSQL 17 initialization script for public schema -- Legacy PostgreSQL 17 initialization snapshot for public schema
-- Note:
-- This file is kept only for historical reference.
-- For new environments, use: sql/init_full_pg17.sql
-- Usage: -- Usage:
-- psql -d your_database -f sql/init_pg17.sql -- psql -d your_database -f sql/init_full_pg17.sql
begin; begin;
@ -96,6 +99,7 @@ create table if not exists crm_opportunity (
product_type varchar(100), product_type varchar(100),
source varchar(50), source varchar(50),
competitor_name varchar(200), competitor_name varchar(200),
archived boolean not null default false,
pushed_to_oms boolean not null default false, pushed_to_oms boolean not null default false,
oms_push_time timestamptz, oms_push_time timestamptz,
description text, description text,
@ -304,6 +308,7 @@ create index if not exists idx_crm_opportunity_owner on crm_opportunity(owner_us
create index if not exists idx_crm_opportunity_sales_expansion on crm_opportunity(sales_expansion_id); create index if not exists idx_crm_opportunity_sales_expansion on crm_opportunity(sales_expansion_id);
create index if not exists idx_crm_opportunity_stage on crm_opportunity(stage); create index if not exists idx_crm_opportunity_stage on crm_opportunity(stage);
create index if not exists idx_crm_opportunity_expected_close on crm_opportunity(expected_close_date); create index if not exists idx_crm_opportunity_expected_close on crm_opportunity(expected_close_date);
create index if not exists idx_crm_opportunity_archived on crm_opportunity(archived);
create index if not exists idx_crm_opportunity_followup_opportunity_time create index if not exists idx_crm_opportunity_followup_opportunity_time
on crm_opportunity_followup(opportunity_id, followup_time desc); on crm_opportunity_followup(opportunity_id, followup_time desc);
create index if not exists idx_crm_opportunity_followup_user on crm_opportunity_followup(followup_user_id); create index if not exists idx_crm_opportunity_followup_user on crm_opportunity_followup(followup_user_id);

View File

@ -0,0 +1,696 @@
-- PostgreSQL 17 full initialization script for public schema
-- Usage:
-- psql -d your_database -f sql/init_full_pg17.sql
--
-- Structure:
-- 1) base schema objects
-- 2) indexes / triggers / comments
-- 3) compatibility absorption for old environments
--
-- Notes:
-- - This is the current canonical initialization entry.
-- - Historical incremental scripts are archived under sql/archive/.
begin;
set search_path to public;
-- =====================================================================
-- Section 1. Utilities
-- =====================================================================
-- Unified trigger function for updated_at maintenance.
create or replace function set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
create or replace function create_trigger_if_not_exists(trigger_name text, table_name text)
returns void
language plpgsql
as $$
begin
if not exists (
select 1
from pg_trigger t
join pg_class c on c.oid = t.tgrelid
join pg_namespace n on n.oid = c.relnamespace
where t.tgname = trigger_name
and c.relname = table_name
and n.nspname = current_schema()
) then
execute format(
'create trigger %I before update on %I for each row execute function set_updated_at()',
trigger_name,
table_name
);
end if;
end;
$$;
-- =====================================================================
-- Section 2. Base tables
-- =====================================================================
create table if not exists sys_user (
id bigint generated by default as identity primary key,
user_code varchar(50),
username varchar(50) not null,
real_name varchar(50) not null,
mobile varchar(20),
email varchar(100),
org_id bigint,
job_title varchar(100),
status smallint not null default 1,
hire_date date,
avatar_url varchar(255),
password_hash varchar(255),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint uk_sys_user_username unique (username),
constraint uk_sys_user_mobile unique (mobile)
);
create table if not exists crm_customer (
id bigint generated by default as identity primary key,
customer_code varchar(50),
customer_name varchar(200) not null,
customer_type varchar(50),
industry varchar(50),
province varchar(50),
city varchar(50),
address varchar(255),
owner_user_id bigint,
source varchar(50),
status varchar(30) not null default 'potential'
check (status in ('potential', 'following', 'won', 'lost')),
remark text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint uk_crm_customer_code unique (customer_code)
);
create table if not exists crm_opportunity (
id bigint generated by default as identity primary key,
opportunity_code varchar(50) not null,
opportunity_name varchar(200) not null,
customer_id bigint not null,
owner_user_id bigint not null,
sales_expansion_id bigint,
channel_expansion_id bigint,
project_location varchar(100),
operator_name varchar(100),
amount numeric(18, 2) not null default 0,
expected_close_date date,
confidence_pct smallint not null default 0 check (confidence_pct between 0 and 100),
stage varchar(50) not null default 'initial_contact',
opportunity_type varchar(50),
product_type varchar(100),
source varchar(50),
competitor_name varchar(200),
archived boolean not null default false,
pushed_to_oms boolean not null default false,
oms_push_time timestamptz,
description text,
status varchar(30) not null default 'active'
check (status in ('active', 'won', 'lost', 'closed')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint uk_crm_opportunity_code unique (opportunity_code),
constraint fk_crm_opportunity_customer foreign key (customer_id) references crm_customer(id)
);
create table if not exists crm_opportunity_followup (
id bigint generated by default as identity primary key,
opportunity_id bigint not null,
followup_time timestamptz not null,
followup_type varchar(50) not null,
content text not null,
next_action varchar(255),
followup_user_id bigint not null,
source_type varchar(30),
source_id bigint,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint fk_crm_opportunity_followup_opportunity
foreign key (opportunity_id) references crm_opportunity(id) on delete cascade
);
create table if not exists crm_sales_expansion (
id bigint generated by default as identity primary key,
employee_no varchar(50) not null,
candidate_name varchar(50) not null,
office_name varchar(100),
mobile varchar(20),
email varchar(100),
target_dept varchar(100),
industry varchar(50),
title varchar(100),
intent_level varchar(20) not null default 'medium'
check (intent_level in ('high', 'medium', 'low')),
stage varchar(50) not null default 'initial_contact',
has_desktop_exp boolean not null default false,
in_progress boolean not null default true,
employment_status varchar(20) not null default 'active'
check (employment_status in ('active', 'left', 'joined', 'abandoned')),
expected_join_date date,
owner_user_id bigint not null,
remark text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists crm_channel_expansion (
id bigint generated by default as identity primary key,
channel_code varchar(50),
province varchar(50),
channel_name varchar(200) not null,
office_address varchar(255),
channel_industry varchar(100),
annual_revenue numeric(18, 2),
staff_size integer check (staff_size is null or staff_size >= 0),
contact_established_date date,
intent_level varchar(20) not null default 'medium'
check (intent_level in ('high', 'medium', 'low')),
has_desktop_exp boolean not null default false,
contact_name varchar(50),
contact_title varchar(100),
contact_mobile varchar(20),
channel_attribute varchar(100),
internal_attribute varchar(100),
stage varchar(50) not null default 'initial_contact',
landed_flag boolean not null default false,
expected_sign_date date,
owner_user_id bigint not null,
remark text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists crm_channel_expansion_contact (
id bigint generated by default as identity primary key,
channel_expansion_id bigint not null,
contact_name varchar(50),
contact_mobile varchar(20),
contact_title varchar(100),
sort_order integer not null default 1,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint fk_crm_channel_expansion_contact_channel
foreign key (channel_expansion_id) references crm_channel_expansion(id) on delete cascade
);
create table if not exists crm_expansion_followup (
id bigint generated by default as identity primary key,
biz_type varchar(20) not null check (biz_type in ('sales', 'channel')),
biz_id bigint not null,
followup_time timestamptz not null,
followup_type varchar(50) not null,
content text not null,
next_action varchar(255),
followup_user_id bigint not null,
visit_start_time timestamptz,
evaluation_content text,
next_plan text,
source_type varchar(30),
source_id bigint,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists work_checkin (
id bigint generated by default as identity primary key,
user_id bigint not null,
checkin_date date not null,
checkin_time timestamptz not null,
biz_type varchar(20) check (biz_type is null or biz_type in ('sales', 'channel', 'opportunity')),
biz_id bigint,
biz_name varchar(200),
longitude numeric(10, 6),
latitude numeric(10, 6),
location_text varchar(255) not null,
remark varchar(500),
user_name varchar(100),
dept_name varchar(200),
status varchar(30) not null default 'normal'
check (status in ('normal', 'abnormal', 'reissue')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists work_daily_report (
id bigint generated by default as identity primary key,
user_id bigint not null,
report_date date not null,
work_content text,
tomorrow_plan text,
source_type varchar(30) not null default 'manual'
check (source_type in ('manual', 'voice')),
submit_time timestamptz,
status varchar(30) not null default 'draft'
check (status in ('draft', 'submitted', 'read', 'reviewed')),
score integer check (score is null or score between 0 and 100),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint uk_work_daily_report_user_date unique (user_id, report_date)
);
create table if not exists work_daily_report_comment (
id bigint generated by default as identity primary key,
report_id bigint not null,
reviewer_user_id bigint not null,
score integer check (score is null or score between 0 and 100),
comment_content text,
reviewed_at timestamptz not null default now(),
created_at timestamptz not null default now(),
constraint fk_work_daily_report_comment_report
foreign key (report_id) references work_daily_report(id) on delete cascade
);
create table if not exists work_todo (
id bigint generated by default as identity primary key,
user_id bigint not null,
title varchar(200) not null,
biz_type varchar(30) not null default 'other'
check (biz_type in ('opportunity', 'expansion', 'report', 'other')),
biz_id bigint,
due_date timestamptz,
status varchar(20) not null default 'todo'
check (status in ('todo', 'done', 'canceled')),
priority varchar(20) not null default 'medium'
check (priority in ('high', 'medium', 'low')),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table if not exists sys_activity_log (
id bigint generated by default as identity primary key,
biz_type varchar(30) not null,
biz_id bigint,
action_type varchar(50) not null,
title varchar(200) not null,
content varchar(500),
operator_user_id bigint,
created_at timestamptz not null default now()
);
-- =====================================================================
-- Section 3. Indexes / triggers / comments
-- =====================================================================
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'fk_crm_opportunity_channel_expansion'
) then
alter table crm_opportunity
add constraint fk_crm_opportunity_channel_expansion
foreign key (channel_expansion_id) references crm_channel_expansion(id);
end if;
end $$;
create index if not exists idx_crm_customer_owner on crm_customer(owner_user_id);
create index if not exists idx_crm_customer_name on crm_customer(customer_name);
do $$
begin
if exists (
select 1
from information_schema.columns
where table_schema = current_schema()
and table_name = 'sys_user'
and column_name = 'org_id'
) then
execute 'create index if not exists idx_sys_user_org_id on sys_user(org_id)';
end if;
end;
$$;
create unique index if not exists uk_crm_sales_expansion_owner_employee_no
on crm_sales_expansion(owner_user_id, employee_no);
create index if not exists idx_crm_opportunity_customer on crm_opportunity(customer_id);
create index if not exists idx_crm_opportunity_owner on crm_opportunity(owner_user_id);
create index if not exists idx_crm_opportunity_sales_expansion on crm_opportunity(sales_expansion_id);
create index if not exists idx_crm_opportunity_stage on crm_opportunity(stage);
create index if not exists idx_crm_opportunity_expected_close on crm_opportunity(expected_close_date);
create index if not exists idx_crm_opportunity_archived on crm_opportunity(archived);
create index if not exists idx_crm_opportunity_followup_opportunity_time
on crm_opportunity_followup(opportunity_id, followup_time desc);
create index if not exists idx_crm_opportunity_followup_user on crm_opportunity_followup(followup_user_id);
create index if not exists idx_crm_sales_expansion_owner on crm_sales_expansion(owner_user_id);
create index if not exists idx_crm_sales_expansion_stage on crm_sales_expansion(stage);
create index if not exists idx_crm_sales_expansion_mobile on crm_sales_expansion(mobile);
create index if not exists idx_crm_channel_expansion_owner on crm_channel_expansion(owner_user_id);
create index if not exists idx_crm_channel_expansion_stage on crm_channel_expansion(stage);
create index if not exists idx_crm_channel_expansion_name on crm_channel_expansion(channel_name);
create index if not exists idx_crm_channel_expansion_contact_channel on crm_channel_expansion_contact(channel_expansion_id);
create index if not exists idx_crm_opportunity_channel_expansion on crm_opportunity(channel_expansion_id);
create index if not exists idx_crm_expansion_followup_biz_time
on crm_expansion_followup(biz_type, biz_id, followup_time desc);
create index if not exists idx_crm_expansion_followup_user on crm_expansion_followup(followup_user_id);
create index if not exists idx_work_checkin_user_date on work_checkin(user_id, checkin_date desc);
create index if not exists idx_work_daily_report_user_date on work_daily_report(user_id, report_date desc);
create index if not exists idx_work_daily_report_status on work_daily_report(status);
create index if not exists idx_work_daily_report_comment_report on work_daily_report_comment(report_id);
create index if not exists idx_sys_activity_log_created on sys_activity_log(created_at desc);
create index if not exists idx_sys_activity_log_biz on sys_activity_log(biz_type, biz_id);
select create_trigger_if_not_exists('trg_sys_user_updated_at', 'sys_user');
select create_trigger_if_not_exists('trg_crm_customer_updated_at', 'crm_customer');
select create_trigger_if_not_exists('trg_crm_opportunity_updated_at', 'crm_opportunity');
select create_trigger_if_not_exists('trg_crm_opportunity_followup_updated_at', 'crm_opportunity_followup');
select create_trigger_if_not_exists('trg_crm_sales_expansion_updated_at', 'crm_sales_expansion');
select create_trigger_if_not_exists('trg_crm_channel_expansion_updated_at', 'crm_channel_expansion');
select create_trigger_if_not_exists('trg_crm_channel_expansion_contact_updated_at', 'crm_channel_expansion_contact');
select create_trigger_if_not_exists('trg_crm_expansion_followup_updated_at', 'crm_expansion_followup');
select create_trigger_if_not_exists('trg_work_checkin_updated_at', 'work_checkin');
select create_trigger_if_not_exists('trg_work_daily_report_updated_at', 'work_daily_report');
select create_trigger_if_not_exists('trg_work_todo_updated_at', 'work_todo');
comment on table sys_user is '系统用户';
comment on table crm_customer is '客户主表';
comment on table crm_opportunity is '商机主表';
comment on table crm_opportunity_followup is '商机跟进记录';
comment on table crm_sales_expansion is '销售人员拓展';
comment on table crm_channel_expansion is '渠道拓展';
comment on table crm_channel_expansion_contact is '渠道拓展联系人';
comment on table crm_expansion_followup is '拓展跟进记录';
comment on table work_checkin is '外勤打卡';
comment on table work_daily_report is '日报';
comment on table work_daily_report_comment is '日报点评';
comment on table work_todo is '待办事项';
comment on table sys_activity_log is '首页动态日志';
commit;
-- =====================================================================
-- Compatibility DDL section
-- Purpose:
-- 1) make this script usable for both fresh installs and old-environment upgrades
-- 2) absorb historical DDL migration scripts into a single deployment entry
-- 3) keep base-framework tables managed by unisbase itself
--
-- Base framework dependencies not created here:
-- sys_org, sys_dict_item, sys_tenant_user, sys_role, sys_user_role ...
-- =====================================================================
begin;
set search_path to public;
-- sys_user compatibility: dept_id -> org_id
DO $$
BEGIN
IF to_regclass('public.sys_user') IS NOT NULL THEN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'sys_user'
AND column_name = 'org_id'
) THEN
ALTER TABLE public.sys_user ADD COLUMN org_id bigint;
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'sys_user'
AND column_name = 'dept_id'
) THEN
EXECUTE 'update public.sys_user set org_id = coalesce(org_id, dept_id) where dept_id is not null';
ALTER TABLE public.sys_user DROP COLUMN dept_id;
END IF;
END IF;
END $$;
DROP INDEX IF EXISTS public.idx_sys_user_dept_id;
CREATE INDEX IF NOT EXISTS idx_sys_user_org_id ON public.sys_user(org_id);
-- crm_sales_expansion compatibility: ensure employee_no / office_name / target_dept text
ALTER TABLE IF EXISTS crm_sales_expansion
ADD COLUMN IF NOT EXISTS employee_no varchar(50),
ADD COLUMN IF NOT EXISTS office_name varchar(100),
ADD COLUMN IF NOT EXISTS target_dept varchar(100);
DO $$
BEGIN
IF to_regclass('public.crm_sales_expansion') IS NOT NULL THEN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'crm_sales_expansion'
AND column_name = 'target_dept_id'
) THEN
UPDATE crm_sales_expansion s
SET target_dept = COALESCE(
NULLIF(s.target_dept, ''),
(
SELECT d.item_label
FROM sys_dict_item d
WHERE d.type_code = 'tz_ssbm'
AND d.item_value = s.target_dept_id::varchar
AND d.status = 1
AND COALESCE(d.is_deleted, 0) = 0
ORDER BY d.sort_order ASC NULLS LAST, d.dict_item_id ASC
LIMIT 1
),
(
SELECT o.org_name
FROM sys_org o
WHERE o.id = s.target_dept_id
LIMIT 1
),
s.target_dept_id::varchar
)
WHERE s.target_dept_id IS NOT NULL;
ALTER TABLE crm_sales_expansion DROP COLUMN target_dept_id;
END IF;
UPDATE crm_sales_expansion
SET employee_no = concat('EMP', lpad(id::text, 6, '0'))
WHERE employee_no IS NULL OR btrim(employee_no) = '';
WITH duplicated AS (
SELECT
id,
row_number() over (partition by owner_user_id, employee_no order by id asc) AS rn
FROM crm_sales_expansion
WHERE employee_no IS NOT NULL
AND btrim(employee_no) <> ''
)
UPDATE crm_sales_expansion s
SET employee_no = concat(s.employee_no, '-', s.id)
FROM duplicated d
WHERE s.id = d.id
AND d.rn > 1;
IF NOT EXISTS (
SELECT 1 FROM crm_sales_expansion WHERE employee_no IS NULL OR btrim(employee_no) = ''
) THEN
ALTER TABLE crm_sales_expansion
ALTER COLUMN employee_no SET NOT NULL;
END IF;
END IF;
END $$;
DO $$
BEGIN
IF to_regclass('public.crm_sales_expansion') IS NOT NULL THEN
IF NOT EXISTS (
SELECT 1
FROM pg_indexes
WHERE schemaname = 'public'
AND indexname = 'uk_crm_sales_expansion_owner_employee_no'
) THEN
CREATE UNIQUE INDEX uk_crm_sales_expansion_owner_employee_no
ON crm_sales_expansion(owner_user_id, employee_no);
END IF;
END IF;
END $$;
-- crm_opportunity compatibility: absorb old extension fields and relationships
ALTER TABLE IF EXISTS crm_opportunity
ADD COLUMN IF NOT EXISTS sales_expansion_id bigint,
ADD COLUMN IF NOT EXISTS channel_expansion_id bigint,
ADD COLUMN IF NOT EXISTS project_location varchar(100),
ADD COLUMN IF NOT EXISTS operator_name varchar(100),
ADD COLUMN IF NOT EXISTS competitor_name varchar(200);
DO $$
BEGIN
IF to_regclass('public.crm_opportunity') IS NOT NULL THEN
ALTER TABLE public.crm_opportunity
DROP CONSTRAINT IF EXISTS crm_opportunity_stage_check;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_crm_opportunity_sales_expansion'
) THEN
ALTER TABLE public.crm_opportunity
ADD CONSTRAINT fk_crm_opportunity_sales_expansion
FOREIGN KEY (sales_expansion_id) REFERENCES public.crm_sales_expansion(id);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_crm_opportunity_channel_expansion'
) THEN
ALTER TABLE public.crm_opportunity
ADD CONSTRAINT fk_crm_opportunity_channel_expansion
FOREIGN KEY (channel_expansion_id) REFERENCES public.crm_channel_expansion(id);
END IF;
END IF;
END $$;
-- crm_channel_expansion compatibility: absorb detail columns and contact sub-table
ALTER TABLE IF EXISTS crm_channel_expansion
ADD COLUMN IF NOT EXISTS channel_code varchar(50),
ADD COLUMN IF NOT EXISTS office_address varchar(255),
ADD COLUMN IF NOT EXISTS channel_industry varchar(100),
ADD COLUMN IF NOT EXISTS contact_established_date date,
ADD COLUMN IF NOT EXISTS intent_level varchar(20),
ADD COLUMN IF NOT EXISTS has_desktop_exp boolean,
ADD COLUMN IF NOT EXISTS channel_attribute varchar(100),
ADD COLUMN IF NOT EXISTS internal_attribute varchar(100);
DO $$
BEGIN
IF to_regclass('public.crm_channel_expansion') IS NOT NULL THEN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'crm_channel_expansion'
AND column_name = 'industry'
) THEN
EXECUTE 'update public.crm_channel_expansion set channel_industry = coalesce(channel_industry, industry) where channel_industry is null and industry is not null';
END IF;
UPDATE crm_channel_expansion
SET intent_level = COALESCE(intent_level, 'medium'),
has_desktop_exp = COALESCE(has_desktop_exp, false);
ALTER TABLE crm_channel_expansion
ALTER COLUMN intent_level SET DEFAULT 'medium',
ALTER COLUMN has_desktop_exp SET DEFAULT false;
IF EXISTS (
SELECT 1 FROM crm_channel_expansion WHERE intent_level IS NULL
) THEN
RAISE NOTICE 'crm_channel_expansion.intent_level still has null values before not-null enforcement';
ELSE
ALTER TABLE crm_channel_expansion
ALTER COLUMN intent_level SET NOT NULL;
END IF;
IF EXISTS (
SELECT 1 FROM crm_channel_expansion WHERE has_desktop_exp IS NULL
) THEN
RAISE NOTICE 'crm_channel_expansion.has_desktop_exp still has null values before not-null enforcement';
ELSE
ALTER TABLE crm_channel_expansion
ALTER COLUMN has_desktop_exp SET NOT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'crm_channel_expansion_intent_level_check'
) THEN
ALTER TABLE crm_channel_expansion
ADD CONSTRAINT crm_channel_expansion_intent_level_check
CHECK (intent_level IN ('high', 'medium', 'low'));
END IF;
END IF;
END $$;
CREATE TABLE IF NOT EXISTS crm_channel_expansion_contact (
id bigint generated by default as identity primary key,
channel_expansion_id bigint not null,
contact_name varchar(50),
contact_mobile varchar(20),
contact_title varchar(100),
sort_order integer not null default 1,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint fk_crm_channel_expansion_contact_channel
foreign key (channel_expansion_id) references crm_channel_expansion(id) on delete cascade
);
DO $$
BEGIN
IF to_regclass('public.crm_channel_expansion') IS NOT NULL
AND to_regclass('public.crm_channel_expansion_contact') IS NOT NULL THEN
INSERT INTO crm_channel_expansion_contact (
channel_expansion_id,
contact_name,
contact_mobile,
contact_title,
sort_order,
created_at,
updated_at
)
SELECT c.id, c.contact_name, c.contact_mobile, c.contact_title, 1, now(), now()
FROM crm_channel_expansion c
WHERE (COALESCE(btrim(c.contact_name), '') <> ''
OR COALESCE(btrim(c.contact_mobile), '') <> ''
OR COALESCE(btrim(c.contact_title), '') <> '')
AND NOT EXISTS (
SELECT 1
FROM crm_channel_expansion_contact cc
WHERE cc.channel_expansion_id = c.id
);
END IF;
END $$;
-- follow-up compatibility: source fields and structured work-report fields
ALTER TABLE IF EXISTS crm_expansion_followup
ADD COLUMN IF NOT EXISTS visit_start_time timestamptz,
ADD COLUMN IF NOT EXISTS evaluation_content text,
ADD COLUMN IF NOT EXISTS next_plan text,
ADD COLUMN IF NOT EXISTS source_type varchar(30),
ADD COLUMN IF NOT EXISTS source_id bigint;
ALTER TABLE IF EXISTS crm_opportunity_followup
ADD COLUMN IF NOT EXISTS source_type varchar(30),
ADD COLUMN IF NOT EXISTS source_id bigint;
-- work_checkin compatibility: relation fields
ALTER TABLE IF EXISTS work_checkin
ADD COLUMN IF NOT EXISTS biz_type varchar(20),
ADD COLUMN IF NOT EXISTS biz_id bigint,
ADD COLUMN IF NOT EXISTS biz_name varchar(200),
ADD COLUMN IF NOT EXISTS user_name varchar(100),
ADD COLUMN IF NOT EXISTS dept_name varchar(200);
DO $$
BEGIN
IF to_regclass('public.work_checkin') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conrelid = 'public.work_checkin'::regclass
AND conname = 'work_checkin_biz_type_check'
) THEN
ALTER TABLE public.work_checkin
ADD CONSTRAINT work_checkin_biz_type_check
CHECK (biz_type IS NULL OR biz_type IN ('sales', 'channel', 'opportunity'));
END IF;
END $$;
-- additional indexes absorbed from historical DDLs
CREATE INDEX IF NOT EXISTS idx_crm_opportunity_channel_expansion
ON crm_opportunity(channel_expansion_id);
CREATE INDEX IF NOT EXISTS idx_crm_expansion_followup_source
ON crm_expansion_followup(source_type, source_id);
CREATE INDEX IF NOT EXISTS idx_crm_opportunity_followup_source
ON crm_opportunity_followup(source_type, source_id);
CREATE INDEX IF NOT EXISTS idx_crm_channel_expansion_contact_channel
ON crm_channel_expansion_contact(channel_expansion_id);
commit;