0327v2
parent
2b0e477e39
commit
c5c0652088
|
|
@ -37,13 +37,17 @@ backend/
|
|||
|
||||
## 启动前准备
|
||||
|
||||
1. 执行数据库脚本:
|
||||
1. 初始化数据库:
|
||||
|
||||
```bash
|
||||
psql -h 127.0.0.1 -U postgres -d nex_auth -f sql/init_pg17.sql
|
||||
psql -h 127.0.0.1 -U postgres -d nex_auth -f sql/init_full_pg17.sql
|
||||
```
|
||||
|
||||
2. 确保 `sys_user`、`work_todo`、`sys_activity_log`、`crm_customer`、`crm_opportunity`、`work_checkin` 中有业务数据。
|
||||
2. 如果是全新环境,先准备基础框架(`unisbase`)相关表与基础数据,再执行上面的 CRM 脚本。
|
||||
|
||||
3. 详细部署顺序见:
|
||||
|
||||
- [docs/deployment-guide.md](/Users/kangwenjing/Downloads/crm/unis_crm/docs/deployment-guide.md)
|
||||
|
||||
## 启动项目
|
||||
|
||||
|
|
@ -52,14 +56,14 @@ cd backend
|
|||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
默认启动在 `8081` 端口,供前端开发环境通过 Vite 代理访问;`8080` 可继续保留给现有认证/系统服务。
|
||||
默认启动在 `8080` 端口,供前端开发环境通过 Vite 代理访问。
|
||||
|
||||
## 首页接口
|
||||
|
||||
请求示例:
|
||||
|
||||
```bash
|
||||
curl -H "X-User-Id: 1" "http://127.0.0.1:8081/api/dashboard/home"
|
||||
curl -H "X-User-Id: 1" "http://127.0.0.1:8080/api/dashboard/home"
|
||||
```
|
||||
|
||||
首页接口只允许查询当前登录用户自己的数据,必须通过 `X-User-Id` 传入当前用户ID,不支持指定其他用户查询。
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ public class ChannelExpansionItemDTO {
|
|||
private String intentLevel;
|
||||
private String intent;
|
||||
private Boolean hasDesktopExp;
|
||||
private String channelAttributeCode;
|
||||
private String channelAttribute;
|
||||
private String internalAttributeCode;
|
||||
private String internalAttribute;
|
||||
private String stageCode;
|
||||
private String stage;
|
||||
|
|
@ -173,6 +175,14 @@ public class ChannelExpansionItemDTO {
|
|||
return channelAttribute;
|
||||
}
|
||||
|
||||
public String getChannelAttributeCode() {
|
||||
return channelAttributeCode;
|
||||
}
|
||||
|
||||
public void setChannelAttributeCode(String channelAttributeCode) {
|
||||
this.channelAttributeCode = channelAttributeCode;
|
||||
}
|
||||
|
||||
public void setChannelAttribute(String channelAttribute) {
|
||||
this.channelAttribute = channelAttribute;
|
||||
}
|
||||
|
|
@ -181,6 +191,14 @@ public class ChannelExpansionItemDTO {
|
|||
return internalAttribute;
|
||||
}
|
||||
|
||||
public String getInternalAttributeCode() {
|
||||
return internalAttributeCode;
|
||||
}
|
||||
|
||||
public void setInternalAttributeCode(String internalAttributeCode) {
|
||||
this.internalAttributeCode = internalAttributeCode;
|
||||
}
|
||||
|
||||
public void setInternalAttribute(String internalAttribute) {
|
||||
this.internalAttribute = internalAttribute;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ public class OpportunityItemDTO {
|
|||
private String stageCode;
|
||||
private String stage;
|
||||
private String type;
|
||||
private Boolean archived;
|
||||
private Boolean pushedToOms;
|
||||
private String product;
|
||||
private String source;
|
||||
|
|
@ -145,6 +146,14 @@ public class OpportunityItemDTO {
|
|||
this.type = type;
|
||||
}
|
||||
|
||||
public Boolean getArchived() {
|
||||
return archived;
|
||||
}
|
||||
|
||||
public void setArchived(Boolean archived) {
|
||||
this.archived = archived;
|
||||
}
|
||||
|
||||
public Boolean getPushedToOms() {
|
||||
return pushedToOms;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ public interface ExpansionMapper {
|
|||
|
||||
int deleteChannelContacts(@Param("channelExpansionId") Long channelExpansionId);
|
||||
|
||||
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
|
||||
int updateSalesExpansion(@Param("userId") Long userId, @Param("id") Long id, @Param("request") UpdateSalesExpansionRequest request);
|
||||
|
||||
int countSalesExpansionByEmployeeNo(@Param("userId") Long userId, @Param("employeeNo") String employeeNo);
|
||||
|
|
@ -66,10 +67,13 @@ public interface ExpansionMapper {
|
|||
@Param("employeeNo") String employeeNo,
|
||||
@Param("excludeId") Long excludeId);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
|
||||
int updateChannelExpansion(@Param("userId") Long userId, @Param("id") Long id, @Param("request") UpdateChannelExpansionRequest request);
|
||||
|
||||
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
|
||||
int countOwnedSalesExpansion(@Param("userId") Long userId, @Param("id") Long id);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
|
||||
int countOwnedChannelExpansion(@Param("userId") Long userId, @Param("id") Long id);
|
||||
|
||||
int insertExpansionFollowUp(
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ public interface OpportunityMapper {
|
|||
@Param("userId") Long userId,
|
||||
@Param("opportunityIds") List<Long> opportunityIds);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
|
||||
Long selectOwnedCustomerIdByName(@Param("userId") Long userId, @Param("customerName") String customerName);
|
||||
|
||||
int insertCustomer(
|
||||
|
|
@ -43,16 +44,20 @@ public interface OpportunityMapper {
|
|||
@Param("customerId") Long customerId,
|
||||
@Param("request") CreateOpportunityRequest request);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
int countOwnedOpportunity(@Param("userId") Long userId, @Param("id") Long id);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
Boolean selectPushedToOms(@Param("userId") Long userId, @Param("id") Long id);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
int updateOpportunity(
|
||||
@Param("userId") Long userId,
|
||||
@Param("opportunityId") Long opportunityId,
|
||||
@Param("customerId") Long customerId,
|
||||
@Param("request") CreateOpportunityRequest request);
|
||||
|
||||
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
|
||||
int pushOpportunityToOms(
|
||||
@Param("userId") Long userId,
|
||||
@Param("opportunityId") Long opportunityId);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@ import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
|
|||
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
|
||||
import com.unis.crm.mapper.ExpansionMapper;
|
||||
import com.unis.crm.service.ExpansionService;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
|
@ -35,6 +40,7 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
private static final String INDUSTRY_TYPE_CODE = "tz_sshy";
|
||||
private static final String CHANNEL_ATTRIBUTE_TYPE_CODE = "tz_qdsx";
|
||||
private static final String INTERNAL_ATTRIBUTE_TYPE_CODE = "tz_xhsnbsx";
|
||||
private static final String MULTI_VALUE_CUSTOM_PREFIX = "__custom__:";
|
||||
private static final DateTimeFormatter FOLLOW_UP_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||
private static final Logger log = LoggerFactory.getLogger(ExpansionServiceImpl.class);
|
||||
|
||||
|
|
@ -75,6 +81,7 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
attachChannelFollowUps(userId, channelItems);
|
||||
attachChannelContacts(userId, channelItems);
|
||||
attachChannelRelatedProjects(userId, channelItems);
|
||||
fillChannelAttributeDisplay(channelItems);
|
||||
|
||||
return new ExpansionOverviewDTO(salesItems, channelItems);
|
||||
}
|
||||
|
|
@ -251,6 +258,79 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
return expansionMapper.selectDictItems(typeCode);
|
||||
}
|
||||
|
||||
private void fillChannelAttributeDisplay(List<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) {
|
||||
if (keyword == null) {
|
||||
return null;
|
||||
|
|
@ -325,6 +405,16 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
|
||||
private void fillChannelDefaults(CreateChannelExpansionRequest request) {
|
||||
request.setChannelName(normalizeRequiredText(request.getChannelName(), "渠道名称不能为空"));
|
||||
request.setProvince(normalizeRequiredText(request.getProvince(), "请填写省份"));
|
||||
request.setOfficeAddress(normalizeRequiredText(request.getOfficeAddress(), "请填写办公地址"));
|
||||
request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请填写聚焦行业"));
|
||||
request.setAnnualRevenue(requirePositiveAmount(request.getAnnualRevenue(), "请填写年营收"));
|
||||
request.setStaffSize(requirePositiveInteger(request.getStaffSize(), "请填写人员规模"));
|
||||
if (request.getContactEstablishedDate() == null) {
|
||||
throw new BusinessException("请选择建立联系时间");
|
||||
}
|
||||
request.setChannelAttribute(normalizeRequiredText(request.getChannelAttribute(), "请选择渠道属性"));
|
||||
request.setInternalAttribute(normalizeRequiredText(request.getInternalAttribute(), "请选择新华三内部属性"));
|
||||
if (isBlank(request.getStage())) {
|
||||
request.setStage("initial_contact");
|
||||
}
|
||||
|
|
@ -337,11 +427,21 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
if (request.getHasDesktopExp() == null) {
|
||||
request.setHasDesktopExp(Boolean.FALSE);
|
||||
}
|
||||
request.setContacts(normalizeContacts(request.getContacts()));
|
||||
request.setContacts(normalizeRequiredContacts(request.getContacts()));
|
||||
}
|
||||
|
||||
private void fillChannelDefaults(UpdateChannelExpansionRequest request) {
|
||||
request.setChannelName(normalizeRequiredText(request.getChannelName(), "渠道名称不能为空"));
|
||||
request.setProvince(normalizeRequiredText(request.getProvince(), "请填写省份"));
|
||||
request.setOfficeAddress(normalizeRequiredText(request.getOfficeAddress(), "请填写办公地址"));
|
||||
request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请填写聚焦行业"));
|
||||
request.setAnnualRevenue(requirePositiveAmount(request.getAnnualRevenue(), "请填写年营收"));
|
||||
request.setStaffSize(requirePositiveInteger(request.getStaffSize(), "请填写人员规模"));
|
||||
if (request.getContactEstablishedDate() == null) {
|
||||
throw new BusinessException("请选择建立联系时间");
|
||||
}
|
||||
request.setChannelAttribute(normalizeRequiredText(request.getChannelAttribute(), "请选择渠道属性"));
|
||||
request.setInternalAttribute(normalizeRequiredText(request.getInternalAttribute(), "请选择新华三内部属性"));
|
||||
if (isBlank(request.getStage())) {
|
||||
request.setStage("initial_contact");
|
||||
}
|
||||
|
|
@ -354,7 +454,7 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
if (request.getHasDesktopExp() == null) {
|
||||
request.setHasDesktopExp(Boolean.FALSE);
|
||||
}
|
||||
request.setContacts(normalizeContacts(request.getContacts()));
|
||||
request.setContacts(normalizeRequiredContacts(request.getContacts()));
|
||||
}
|
||||
|
||||
private List<ChannelExpansionContactRequest> normalizeContacts(List<ChannelExpansionContactRequest> contacts) {
|
||||
|
|
@ -368,6 +468,37 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
.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) {
|
||||
if (contact == null) {
|
||||
return null;
|
||||
|
|
@ -403,6 +534,20 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private BigDecimal requirePositiveAmount(BigDecimal value, String message) {
|
||||
if (value == null || value.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new BusinessException(message);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private Integer requirePositiveInteger(Integer value, String message) {
|
||||
if (value == null || value <= 0) {
|
||||
throw new BusinessException(message);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String normalizeBizType(String bizType) {
|
||||
if ("sales".equalsIgnoreCase(bizType)) {
|
||||
return "sales";
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import com.unis.crm.dto.opportunity.OpportunityItemDTO;
|
|||
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
|
||||
import com.unis.crm.mapper.OpportunityMapper;
|
||||
import com.unis.crm.service.OpportunityService;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
|
@ -226,23 +227,26 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
}
|
||||
|
||||
private void fillDefaults(CreateOpportunityRequest request) {
|
||||
request.setCustomerName(request.getCustomerName().trim());
|
||||
request.setOpportunityName(request.getOpportunityName().trim());
|
||||
request.setProjectLocation(normalizeOptionalText(request.getProjectLocation()));
|
||||
request.setOperatorName(normalizeOptionalText(request.getOperatorName()));
|
||||
request.setCompetitorName(normalizeOptionalText(request.getCompetitorName()));
|
||||
request.setCustomerName(normalizeRequiredText(request.getCustomerName(), "最终客户不能为空"));
|
||||
request.setOpportunityName(normalizeRequiredText(request.getOpportunityName(), "项目名称不能为空"));
|
||||
request.setProjectLocation(normalizeRequiredText(request.getProjectLocation(), "请填写项目地"));
|
||||
request.setOperatorName(normalizeRequiredText(request.getOperatorName(), "请选择运作方"));
|
||||
request.setAmount(requirePositiveAmount(request.getAmount(), "请填写预计金额"));
|
||||
request.setDescription(normalizeOptionalText(request.getDescription()));
|
||||
if (request.getExpectedCloseDate() == null) {
|
||||
throw new BusinessException("预计结单日期不能为空");
|
||||
throw new BusinessException("请选择预计下单时间");
|
||||
}
|
||||
if (request.getConfidencePct() == null || request.getConfidencePct() <= 0) {
|
||||
throw new BusinessException("请选择项目把握度");
|
||||
}
|
||||
if (isBlank(request.getStage())) {
|
||||
request.setStage("initial_contact");
|
||||
} else {
|
||||
request.setStage(normalizeStageValue(request.getStage()));
|
||||
throw new BusinessException("请选择项目阶段");
|
||||
}
|
||||
request.setStage(normalizeStageValue(request.getStage()));
|
||||
if (isBlank(request.getOpportunityType())) {
|
||||
request.setOpportunityType("新建");
|
||||
throw new BusinessException("请选择建设类型");
|
||||
}
|
||||
request.setOpportunityType(request.getOpportunityType().trim());
|
||||
if (isBlank(request.getProductType())) {
|
||||
request.setProductType("VDI云桌面");
|
||||
}
|
||||
|
|
@ -255,6 +259,8 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
if (request.getConfidencePct() == null) {
|
||||
request.setConfidencePct(50);
|
||||
}
|
||||
request.setCompetitorName(normalizeRequiredText(request.getCompetitorName(), "请选择竞争对手"));
|
||||
validateOperatorRelations(request.getOperatorName(), request.getSalesExpansionId(), request.getChannelExpansionId());
|
||||
}
|
||||
|
||||
private String normalizeKeyword(String keyword) {
|
||||
|
|
@ -280,6 +286,17 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
return value == null || value.trim().isEmpty();
|
||||
}
|
||||
|
||||
private String normalizeRequiredText(String value, String message) {
|
||||
if (value == null) {
|
||||
throw new BusinessException(message);
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new BusinessException(message);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private String normalizeOptionalText(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
|
|
@ -288,6 +305,13 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private BigDecimal requirePositiveAmount(BigDecimal value, String message) {
|
||||
if (value == null || value.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new BusinessException(message);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values) {
|
||||
if (values == null) {
|
||||
return null;
|
||||
|
|
@ -331,6 +355,40 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
throw new BusinessException("项目阶段无效: " + trimmed);
|
||||
}
|
||||
|
||||
private void validateOperatorRelations(String operatorName, Long salesExpansionId, Long channelExpansionId) {
|
||||
String normalizedOperator = normalizeOperatorToken(operatorName);
|
||||
boolean hasH3c = normalizedOperator.contains("新华三") || normalizedOperator.contains("h3c");
|
||||
boolean hasChannel = normalizedOperator.contains("渠道") || normalizedOperator.contains("channel");
|
||||
|
||||
if (hasH3c && hasChannel) {
|
||||
if (salesExpansionId == null && channelExpansionId == null) {
|
||||
throw new BusinessException("运作方选择“新华三+渠道”时,新华三负责人和渠道名称都必须填写");
|
||||
}
|
||||
if (salesExpansionId == null) {
|
||||
throw new BusinessException("运作方选择“新华三+渠道”时,新华三负责人必须填写");
|
||||
}
|
||||
if (channelExpansionId == null) {
|
||||
throw new BusinessException("运作方选择“新华三+渠道”时,渠道名称必须填写");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (hasH3c && salesExpansionId == null) {
|
||||
throw new BusinessException("运作方选择“新华三”时,新华三负责人必须填写");
|
||||
}
|
||||
if (hasChannel && channelExpansionId == null) {
|
||||
throw new BusinessException("运作方选择“渠道”时,渠道名称必须填写");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeOperatorToken(String value) {
|
||||
return value == null
|
||||
? ""
|
||||
: value.trim()
|
||||
.toLowerCase()
|
||||
.replaceAll("\\s+", "")
|
||||
.replace('+', '+');
|
||||
}
|
||||
|
||||
private record CommunicationRecord(String time, String content) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,7 +115,9 @@
|
|||
else '无'
|
||||
end as intent,
|
||||
coalesce(c.has_desktop_exp, false) as hasDesktopExp,
|
||||
coalesce(c.channel_attribute, '') as channelAttributeCode,
|
||||
coalesce(channel_attribute_dict.item_label, c.channel_attribute, '无') as channelAttribute,
|
||||
coalesce(c.internal_attribute, '') as internalAttributeCode,
|
||||
coalesce(internal_attribute_dict.item_label, c.internal_attribute, '无') as internalAttribute,
|
||||
c.stage as stageCode,
|
||||
case c.stage
|
||||
|
|
@ -381,7 +383,7 @@
|
|||
</delete>
|
||||
|
||||
<update id="updateSalesExpansion">
|
||||
update crm_sales_expansion
|
||||
update crm_sales_expansion s
|
||||
set employee_no = #{request.employeeNo},
|
||||
candidate_name = #{request.candidateName},
|
||||
office_name = #{request.officeName},
|
||||
|
|
@ -397,8 +399,7 @@
|
|||
employment_status = #{request.employmentStatus},
|
||||
expected_join_date = #{request.expectedJoinDate},
|
||||
remark = #{request.remark}
|
||||
where id = #{id}
|
||||
and owner_user_id = #{userId}
|
||||
where s.id = #{id}
|
||||
</update>
|
||||
|
||||
<select id="countSalesExpansionByEmployeeNo" resultType="int">
|
||||
|
|
@ -417,7 +418,7 @@
|
|||
</select>
|
||||
|
||||
<update id="updateChannelExpansion">
|
||||
update crm_channel_expansion
|
||||
update crm_channel_expansion c
|
||||
set channel_name = #{request.channelName},
|
||||
province = #{request.province},
|
||||
office_address = #{request.officeAddress},
|
||||
|
|
@ -436,22 +437,19 @@
|
|||
landed_flag = #{request.landedFlag},
|
||||
expected_sign_date = #{request.expectedSignDate},
|
||||
remark = #{request.remark}
|
||||
where id = #{id}
|
||||
and owner_user_id = #{userId}
|
||||
where c.id = #{id}
|
||||
</update>
|
||||
|
||||
<select id="countOwnedSalesExpansion" resultType="int">
|
||||
select count(1)
|
||||
from crm_sales_expansion
|
||||
where id = #{id}
|
||||
and owner_user_id = #{userId}
|
||||
from crm_sales_expansion s
|
||||
where s.id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="countOwnedChannelExpansion" resultType="int">
|
||||
select count(1)
|
||||
from crm_channel_expansion
|
||||
where id = #{id}
|
||||
and owner_user_id = #{userId}
|
||||
from crm_channel_expansion c
|
||||
where c.id = #{id}
|
||||
</select>
|
||||
|
||||
<insert id="insertExpansionFollowUp">
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@
|
|||
end
|
||||
) as stage,
|
||||
coalesce(o.opportunity_type, '新建') as type,
|
||||
coalesce(o.archived, false) as archived,
|
||||
coalesce(o.pushed_to_oms, false) as pushedToOms,
|
||||
coalesce(o.product_type, 'VDI云桌面') as product,
|
||||
coalesce(o.source, '主动开发') as source,
|
||||
|
|
@ -157,9 +158,8 @@
|
|||
|
||||
<select id="selectOwnedCustomerIdByName" resultType="java.lang.Long">
|
||||
select id
|
||||
from crm_customer
|
||||
where owner_user_id = #{userId}
|
||||
and customer_name = #{customerName}
|
||||
from crm_customer c
|
||||
where c.customer_name = #{customerName}
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
|
|
@ -241,21 +241,19 @@
|
|||
|
||||
<select id="countOwnedOpportunity" resultType="int">
|
||||
select count(1)
|
||||
from crm_opportunity
|
||||
where id = #{id}
|
||||
and owner_user_id = #{userId}
|
||||
from crm_opportunity o
|
||||
where o.id = #{id}
|
||||
</select>
|
||||
|
||||
<select id="selectPushedToOms" resultType="java.lang.Boolean">
|
||||
select coalesce(pushed_to_oms, false)
|
||||
from crm_opportunity
|
||||
where id = #{id}
|
||||
and owner_user_id = #{userId}
|
||||
from crm_opportunity o
|
||||
where o.id = #{id}
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<update id="updateOpportunity">
|
||||
update crm_opportunity
|
||||
update crm_opportunity o
|
||||
set opportunity_name = #{request.opportunityName},
|
||||
customer_id = #{customerId},
|
||||
project_location = #{request.projectLocation},
|
||||
|
|
@ -282,17 +280,15 @@
|
|||
else 'active'
|
||||
end,
|
||||
updated_at = now()
|
||||
where id = #{opportunityId}
|
||||
and owner_user_id = #{userId}
|
||||
where o.id = #{opportunityId}
|
||||
</update>
|
||||
|
||||
<update id="pushOpportunityToOms">
|
||||
update crm_opportunity
|
||||
update crm_opportunity o
|
||||
set pushed_to_oms = true,
|
||||
oms_push_time = coalesce(oms_push_time, now()),
|
||||
updated_at = now()
|
||||
where id = #{opportunityId}
|
||||
and owner_user_id = #{userId}
|
||||
where o.id = #{opportunityId}
|
||||
and coalesce(pushed_to_oms, false) = false
|
||||
</update>
|
||||
|
||||
|
|
|
|||
|
|
@ -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/`:历史迁移与修数脚本归档目录
|
||||
|
|
@ -9,16 +9,28 @@ export type AdaptiveSelectOption = {
|
|||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type AdaptiveSelectProps = {
|
||||
value?: string;
|
||||
type AdaptiveSelectBaseProps = {
|
||||
options: AdaptiveSelectOption[];
|
||||
placeholder?: string;
|
||||
sheetTitle?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type AdaptiveSelectSingleProps = AdaptiveSelectBaseProps & {
|
||||
multiple?: false;
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
type AdaptiveSelectMultipleProps = AdaptiveSelectBaseProps & {
|
||||
multiple: true;
|
||||
value?: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
};
|
||||
|
||||
type AdaptiveSelectProps = AdaptiveSelectSingleProps | AdaptiveSelectMultipleProps;
|
||||
|
||||
function useIsMobileViewport() {
|
||||
const [isMobile, setIsMobile] = useState(() => {
|
||||
if (typeof window === "undefined") {
|
||||
|
|
@ -49,19 +61,27 @@ function useIsMobileViewport() {
|
|||
}
|
||||
|
||||
export function AdaptiveSelect({
|
||||
value = "",
|
||||
options,
|
||||
placeholder = "请选择",
|
||||
sheetTitle,
|
||||
disabled = false,
|
||||
className,
|
||||
value,
|
||||
multiple = false,
|
||||
onChange,
|
||||
}: AdaptiveSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useIsMobileViewport();
|
||||
const selectedOption = options.find((option) => option.value === value);
|
||||
const selectedLabel = value ? selectedOption?.label || placeholder : placeholder;
|
||||
const selectedValues = multiple
|
||||
? Array.isArray(value) ? value : []
|
||||
: typeof value === "string" && value ? [value] : [];
|
||||
const selectedLabel = selectedValues.length > 0
|
||||
? selectedValues
|
||||
.map((selectedValue) => options.find((option) => option.value === selectedValue)?.label)
|
||||
.filter((label): label is string => Boolean(label))
|
||||
.join("、")
|
||||
: placeholder;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || isMobile) {
|
||||
|
|
@ -101,12 +121,23 @@ export function AdaptiveSelect({
|
|||
}, [isMobile, open]);
|
||||
|
||||
const handleSelect = (nextValue: string) => {
|
||||
onChange(nextValue);
|
||||
if (multiple) {
|
||||
const currentValues = Array.isArray(value) ? value : [];
|
||||
const nextValues = currentValues.includes(nextValue)
|
||||
? currentValues.filter((item) => item !== nextValue)
|
||||
: [...currentValues, nextValue];
|
||||
(onChange as (value: string[]) => void)(nextValues);
|
||||
return;
|
||||
}
|
||||
|
||||
(onChange as (value: string) => void)(nextValue);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const renderOption = (option: AdaptiveSelectOption) => {
|
||||
const isSelected = option.value === value;
|
||||
const isSelected = multiple
|
||||
? selectedValues.includes(option.value)
|
||||
: option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={`${option.value}-${option.label}`}
|
||||
|
|
@ -142,7 +173,7 @@ export function AdaptiveSelect({
|
|||
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}
|
||||
</span>
|
||||
<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>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -193,6 +224,15 @@ export function AdaptiveSelect({
|
|||
</div>
|
||||
<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)}
|
||||
{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>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -113,6 +113,12 @@ select {
|
|||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
font-weight: 600;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.crm-btn-sm {
|
||||
|
|
@ -122,6 +128,125 @@ select {
|
|||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
font-weight: 500;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.crm-btn-primary {
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 22px rgba(124, 58, 237, 0.2);
|
||||
}
|
||||
|
||||
.crm-btn-primary:hover {
|
||||
background: #6d28d9;
|
||||
}
|
||||
|
||||
.crm-btn-secondary {
|
||||
border: 1px solid #cbd5e1;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.crm-btn-secondary:hover {
|
||||
border-color: #a5b4fc;
|
||||
background: #f8fafc;
|
||||
color: #4c1d95;
|
||||
}
|
||||
|
||||
.crm-btn-success {
|
||||
background: #059669;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 22px rgba(5, 150, 105, 0.18);
|
||||
}
|
||||
|
||||
.crm-btn-success:hover {
|
||||
background: #047857;
|
||||
}
|
||||
|
||||
.crm-btn-danger {
|
||||
border: 1px solid #fecdd3;
|
||||
background: #fff1f2;
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
.crm-btn-danger:hover {
|
||||
background: #ffe4e6;
|
||||
color: #be123c;
|
||||
}
|
||||
|
||||
.crm-btn-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid #ddd6fe;
|
||||
background: #f5f3ff;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.crm-btn-chip:hover {
|
||||
border-color: #c4b5fd;
|
||||
background: #ede9fe;
|
||||
}
|
||||
|
||||
.crm-icon-sm,
|
||||
.crm-icon-md,
|
||||
.crm-icon-lg {
|
||||
flex-shrink: 0;
|
||||
stroke-width: 1.85;
|
||||
}
|
||||
|
||||
.crm-icon-sm {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
.crm-icon-md {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.crm-icon-lg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.crm-tone-neutral,
|
||||
.crm-tone-brand,
|
||||
.crm-tone-success,
|
||||
.crm-tone-warning,
|
||||
.crm-tone-danger {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.crm-tone-neutral {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.crm-tone-brand {
|
||||
background: #f5f3ff;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.crm-tone-success {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.crm-tone-warning {
|
||||
background: #fffbeb;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.crm-tone-danger {
|
||||
background: #fff1f2;
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
.crm-card {
|
||||
|
|
@ -149,6 +274,73 @@ select {
|
|||
background: rgba(30, 41, 59, 0.38);
|
||||
}
|
||||
|
||||
.dark .crm-btn-primary {
|
||||
box-shadow: 0 12px 24px rgba(109, 40, 217, 0.24);
|
||||
}
|
||||
|
||||
.dark .crm-btn-secondary {
|
||||
border-color: rgba(51, 65, 85, 0.9);
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.dark .crm-btn-secondary:hover {
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
background: rgba(30, 41, 59, 0.9);
|
||||
color: #ddd6fe;
|
||||
}
|
||||
|
||||
.dark .crm-btn-success {
|
||||
box-shadow: 0 12px 24px rgba(5, 150, 105, 0.2);
|
||||
}
|
||||
|
||||
.dark .crm-btn-danger {
|
||||
border-color: rgba(190, 24, 93, 0.35);
|
||||
background: rgba(244, 63, 94, 0.12);
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
.dark .crm-btn-danger:hover {
|
||||
background: rgba(244, 63, 94, 0.18);
|
||||
color: #fecdd3;
|
||||
}
|
||||
|
||||
.dark .crm-btn-chip {
|
||||
border-color: rgba(139, 92, 246, 0.22);
|
||||
background: rgba(139, 92, 246, 0.14);
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.dark .crm-btn-chip:hover {
|
||||
border-color: rgba(139, 92, 246, 0.35);
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
}
|
||||
|
||||
.dark .crm-tone-neutral {
|
||||
background: rgba(51, 65, 85, 0.85);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.dark .crm-tone-brand {
|
||||
background: rgba(139, 92, 246, 0.14);
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.dark .crm-tone-success {
|
||||
background: rgba(16, 185, 129, 0.14);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.dark .crm-tone-warning {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.dark .crm-tone-danger {
|
||||
background: rgba(244, 63, 94, 0.14);
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
.crm-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -215,6 +407,35 @@ select {
|
|||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.crm-page-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.crm-page-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.crm-page-title {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.crm-page-subtitle {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.crm-section-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -235,6 +456,50 @@ select {
|
|||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.crm-filter-bar {
|
||||
border-radius: 1rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.crm-empty-panel {
|
||||
border-radius: 1rem;
|
||||
border: 1px dashed #cbd5e1;
|
||||
background: rgba(248, 250, 252, 0.72);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.crm-alert {
|
||||
border-radius: 1rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.crm-alert-error {
|
||||
border-color: #fecdd3;
|
||||
background: #fff1f2;
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
.crm-alert-success {
|
||||
border-color: #bbf7d0;
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.crm-alert-info {
|
||||
border-color: #cbd5e1;
|
||||
background: rgba(248, 250, 252, 0.78);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.crm-modal-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -247,6 +512,10 @@ select {
|
|||
gap: 1rem;
|
||||
}
|
||||
|
||||
.crm-form-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.crm-form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -305,6 +574,47 @@ select {
|
|||
background: rgba(30, 41, 59, 0.38);
|
||||
}
|
||||
|
||||
.dark .crm-page-title {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.dark .crm-page-subtitle {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.dark .crm-filter-bar {
|
||||
border-color: rgba(51, 65, 85, 0.8);
|
||||
background: rgba(15, 23, 42, 0.46);
|
||||
}
|
||||
|
||||
.dark .crm-empty-panel {
|
||||
border-color: rgba(51, 65, 85, 0.9);
|
||||
background: rgba(30, 41, 59, 0.34);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.dark .crm-alert {
|
||||
border-color: rgba(51, 65, 85, 0.8);
|
||||
}
|
||||
|
||||
.dark .crm-alert-error {
|
||||
border-color: rgba(190, 24, 93, 0.35);
|
||||
background: rgba(244, 63, 94, 0.12);
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
.dark .crm-alert-success {
|
||||
border-color: rgba(5, 150, 105, 0.35);
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.dark .crm-alert-info {
|
||||
border-color: rgba(51, 65, 85, 0.8);
|
||||
background: rgba(30, 41, 59, 0.38);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.dark .crm-detail-label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
|
@ -318,6 +628,18 @@ select {
|
|||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.crm-page-header {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.crm-page-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.crm-page-subtitle {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.crm-section-stack {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
|
@ -356,6 +678,14 @@ select {
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
input[type="date"].crm-input-text {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.dark .crm-input-text::placeholder,
|
||||
.dark input::placeholder,
|
||||
.dark textarea::placeholder {
|
||||
|
|
|
|||
|
|
@ -243,6 +243,7 @@ export interface OpportunityItem {
|
|||
stageCode?: string;
|
||||
stage?: string;
|
||||
type?: string;
|
||||
archived?: boolean;
|
||||
pushedToOms?: boolean;
|
||||
product?: string;
|
||||
source?: string;
|
||||
|
|
@ -363,7 +364,9 @@ export interface ChannelExpansionItem {
|
|||
intentLevel?: string;
|
||||
intent?: string;
|
||||
hasDesktopExp?: boolean;
|
||||
channelAttributeCode?: string;
|
||||
channelAttribute?: string;
|
||||
internalAttributeCode?: string;
|
||||
internalAttribute?: string;
|
||||
stageCode?: string;
|
||||
stage?: string;
|
||||
|
|
@ -436,8 +439,9 @@ export interface CreateChannelExpansionPayload {
|
|||
contactEstablishedDate?: string;
|
||||
intentLevel?: string;
|
||||
hasDesktopExp?: boolean;
|
||||
channelAttribute?: string;
|
||||
internalAttribute?: string;
|
||||
channelAttribute?: string[];
|
||||
channelAttributeCustom?: string;
|
||||
internalAttribute?: string[];
|
||||
stage?: string;
|
||||
remark?: string;
|
||||
contacts?: ChannelExpansionContact[];
|
||||
|
|
@ -447,6 +451,56 @@ export interface UpdateSalesExpansionPayload extends CreateSalesExpansionPayload
|
|||
|
||||
export interface UpdateChannelExpansionPayload extends CreateChannelExpansionPayload {}
|
||||
|
||||
const EXPANSION_MULTI_VALUE_CUSTOM_PREFIX = "__custom__:";
|
||||
|
||||
function normalizeExpansionMultiValues(values?: string[]) {
|
||||
return Array.from(new Set((values ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value))));
|
||||
}
|
||||
|
||||
export function encodeExpansionMultiValue(values?: string[], customText?: string) {
|
||||
const normalizedValues = normalizeExpansionMultiValues(values);
|
||||
const normalizedCustomText = customText?.trim();
|
||||
if (normalizedCustomText) {
|
||||
normalizedValues.push(`${EXPANSION_MULTI_VALUE_CUSTOM_PREFIX}${encodeURIComponent(normalizedCustomText)}`);
|
||||
}
|
||||
return normalizedValues.join(",");
|
||||
}
|
||||
|
||||
export function decodeExpansionMultiValue(rawValue?: string) {
|
||||
const result = {
|
||||
values: [] as string[],
|
||||
customText: "",
|
||||
};
|
||||
|
||||
if (!rawValue?.trim()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
rawValue
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((item) => {
|
||||
if (item.startsWith(EXPANSION_MULTI_VALUE_CUSTOM_PREFIX)) {
|
||||
result.customText = decodeURIComponent(item.slice(EXPANSION_MULTI_VALUE_CUSTOM_PREFIX.length));
|
||||
return;
|
||||
}
|
||||
result.values.push(item);
|
||||
});
|
||||
|
||||
result.values = Array.from(new Set(result.values));
|
||||
return result;
|
||||
}
|
||||
|
||||
function serializeChannelExpansionPayload(payload: CreateChannelExpansionPayload) {
|
||||
const { channelAttributeCustom, ...rest } = payload;
|
||||
return {
|
||||
...rest,
|
||||
channelAttribute: encodeExpansionMultiValue(payload.channelAttribute, channelAttributeCustom),
|
||||
internalAttribute: encodeExpansionMultiValue(payload.internalAttribute),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateExpansionFollowUpPayload {
|
||||
followUpType: string;
|
||||
content: string;
|
||||
|
|
@ -792,7 +846,7 @@ export async function createSalesExpansion(payload: CreateSalesExpansionPayload)
|
|||
export async function createChannelExpansion(payload: CreateChannelExpansionPayload) {
|
||||
return request<number>("/api/expansion/channel", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify(serializeChannelExpansionPayload(payload)),
|
||||
}, true);
|
||||
}
|
||||
|
||||
|
|
@ -806,7 +860,7 @@ export async function updateSalesExpansion(id: number, payload: UpdateSalesExpan
|
|||
export async function updateChannelExpansion(id: number, payload: UpdateChannelExpansionPayload) {
|
||||
return request<void>(`/api/expansion/channel/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify(serializeChannelExpansionPayload(payload)),
|
||||
}, true);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -116,11 +116,13 @@ export default function Dashboard() {
|
|||
|
||||
return (
|
||||
<div className="crm-page-stack">
|
||||
<header className="space-y-1 sm:space-y-2">
|
||||
<h1 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-2xl">工作台</h1>
|
||||
<p className="text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">
|
||||
<header className="crm-page-header">
|
||||
<div className="crm-page-heading">
|
||||
<h1 className="crm-page-title">工作台</h1>
|
||||
<p className="crm-page-subtitle">
|
||||
欢迎回来,{home.realName || "无"}。今天是你入职的第 {home.onboardingDays ?? 0} 天。
|
||||
</p>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
|
|
@ -206,7 +208,7 @@ export default function Dashboard() {
|
|||
))}
|
||||
</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>
|
||||
)}
|
||||
|
|
@ -254,7 +256,7 @@ export default function Dashboard() {
|
|||
) : 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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useLocation } from "react-router-dom";
|
|||
import {
|
||||
createChannelExpansion,
|
||||
createSalesExpansion,
|
||||
decodeExpansionMultiValue,
|
||||
getExpansionMeta,
|
||||
getExpansionOverview,
|
||||
updateChannelExpansion,
|
||||
|
|
@ -18,10 +19,34 @@ import {
|
|||
type SalesExpansionItem,
|
||||
} from "@/lib/auth";
|
||||
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem;
|
||||
type ExpansionTab = "sales" | "channel";
|
||||
const detailBadgeClass = "crm-pill crm-pill-violet inline-flex items-center gap-0.5 border border-violet-100 transition-all hover:border-violet-200 hover:bg-violet-100 dark:border-violet-500/20 dark:hover:border-violet-500/30 dark:hover:bg-violet-500/15";
|
||||
type SalesCreateField =
|
||||
| "employeeNo"
|
||||
| "officeName"
|
||||
| "candidateName"
|
||||
| "mobile"
|
||||
| "targetDept"
|
||||
| "industry"
|
||||
| "title"
|
||||
| "intentLevel"
|
||||
| "employmentStatus";
|
||||
type ChannelField =
|
||||
| "channelName"
|
||||
| "province"
|
||||
| "officeAddress"
|
||||
| "channelIndustry"
|
||||
| "annualRevenue"
|
||||
| "staffSize"
|
||||
| "contactEstablishedDate"
|
||||
| "intentLevel"
|
||||
| "channelAttribute"
|
||||
| "channelAttributeCustom"
|
||||
| "internalAttribute"
|
||||
| "contacts";
|
||||
const detailBadgeClass = "crm-btn-chip text-[11px] font-semibold";
|
||||
|
||||
function createEmptyChannelContact(): ChannelExpansionContact {
|
||||
return {
|
||||
|
|
@ -52,13 +77,173 @@ const defaultChannelForm: CreateChannelExpansionPayload = {
|
|||
contactEstablishedDate: "",
|
||||
intentLevel: "medium",
|
||||
hasDesktopExp: false,
|
||||
channelAttribute: "",
|
||||
internalAttribute: "",
|
||||
channelAttribute: [],
|
||||
channelAttributeCustom: "",
|
||||
internalAttribute: [],
|
||||
stage: "initial_contact",
|
||||
remark: "",
|
||||
contacts: [createEmptyChannelContact()],
|
||||
};
|
||||
|
||||
function isOtherOption(option?: ExpansionDictOption) {
|
||||
const candidate = `${option?.label ?? ""}${option?.value ?? ""}`.toLowerCase();
|
||||
return candidate.includes("其他") || candidate.includes("其它") || candidate.includes("other");
|
||||
}
|
||||
|
||||
function normalizeOptionalText(value?: string) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function getFieldInputClass(hasError: boolean) {
|
||||
return cn(
|
||||
"crm-input-box crm-input-text w-full border bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:bg-slate-900/50",
|
||||
hasError
|
||||
? "border-rose-400 bg-rose-50/60 focus:border-rose-500 focus:ring-rose-500 dark:border-rose-500/70 dark:bg-rose-500/10"
|
||||
: "border-slate-200 dark:border-slate-800",
|
||||
);
|
||||
}
|
||||
|
||||
function validateSalesCreateForm(form: CreateSalesExpansionPayload) {
|
||||
const errors: Partial<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({
|
||||
title,
|
||||
subtitle,
|
||||
|
|
@ -129,6 +314,10 @@ function DetailItem({
|
|||
);
|
||||
}
|
||||
|
||||
function RequiredMark() {
|
||||
return <span className="ml-1 text-rose-500">*</span>;
|
||||
}
|
||||
|
||||
export default function Expansion() {
|
||||
const location = useLocation();
|
||||
const [activeTab, setActiveTab] = useState<ExpansionTab>("sales");
|
||||
|
|
@ -141,6 +330,7 @@ export default function Expansion() {
|
|||
const [channelAttributeOptions, setChannelAttributeOptions] = useState<ExpansionDictOption[]>([]);
|
||||
const [internalAttributeOptions, setInternalAttributeOptions] = useState<ExpansionDictOption[]>([]);
|
||||
const [nextChannelCode, setNextChannelCode] = useState("");
|
||||
const channelOtherOptionValue = channelAttributeOptions.find(isOtherOption)?.value ?? "";
|
||||
const [refreshTick, setRefreshTick] = useState(0);
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
|
@ -148,6 +338,12 @@ export default function Expansion() {
|
|||
const [submitting, setSubmitting] = useState(false);
|
||||
const [createError, setCreateError] = useState("");
|
||||
const [editError, setEditError] = useState("");
|
||||
const [salesCreateFieldErrors, setSalesCreateFieldErrors] = useState<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 [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]) => {
|
||||
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]) => {
|
||||
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]) => {
|
||||
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]) => {
|
||||
setEditChannelForm((current) => ({ ...current, [key]: value }));
|
||||
if (key in channelEditFieldErrors) {
|
||||
setChannelEditFieldErrors((current) => {
|
||||
const next = { ...current };
|
||||
delete next[key as ChannelField];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleChannelContactChange = (index: number, key: keyof ChannelExpansionContact, value: string, isEdit = false) => {
|
||||
|
|
@ -258,6 +482,27 @@ export default function Expansion() {
|
|||
nextContacts[index] = target;
|
||||
return { ...current, contacts: nextContacts };
|
||||
});
|
||||
if (isEdit) {
|
||||
setChannelEditFieldErrors((current) => {
|
||||
if (!current.contacts) {
|
||||
return current;
|
||||
}
|
||||
const next = { ...current };
|
||||
delete next.contacts;
|
||||
return next;
|
||||
});
|
||||
setInvalidEditChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index));
|
||||
return;
|
||||
}
|
||||
setChannelCreateFieldErrors((current) => {
|
||||
if (!current.contacts) {
|
||||
return current;
|
||||
}
|
||||
const next = { ...current };
|
||||
delete next.contacts;
|
||||
return next;
|
||||
});
|
||||
setInvalidCreateChannelContactRows((current) => current.filter((rowIndex) => rowIndex !== index));
|
||||
};
|
||||
|
||||
const addChannelContact = (isEdit = false) => {
|
||||
|
|
@ -283,6 +528,9 @@ export default function Expansion() {
|
|||
const resetCreateState = () => {
|
||||
setCreateOpen(false);
|
||||
setCreateError("");
|
||||
setSalesCreateFieldErrors({});
|
||||
setChannelCreateFieldErrors({});
|
||||
setInvalidCreateChannelContactRows([]);
|
||||
setSalesForm(defaultSalesForm);
|
||||
setChannelForm(defaultChannelForm);
|
||||
};
|
||||
|
|
@ -290,12 +538,18 @@ export default function Expansion() {
|
|||
const resetEditState = () => {
|
||||
setEditOpen(false);
|
||||
setEditError("");
|
||||
setSalesEditFieldErrors({});
|
||||
setChannelEditFieldErrors({});
|
||||
setInvalidEditChannelContactRows([]);
|
||||
setEditSalesForm(defaultSalesForm);
|
||||
setEditChannelForm(defaultChannelForm);
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setCreateError("");
|
||||
setSalesCreateFieldErrors({});
|
||||
setChannelCreateFieldErrors({});
|
||||
setInvalidCreateChannelContactRows([]);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
|
|
@ -306,6 +560,7 @@ export default function Expansion() {
|
|||
|
||||
setEditError("");
|
||||
if (selectedItem.type === "sales") {
|
||||
setSalesEditFieldErrors({});
|
||||
setEditSalesForm({
|
||||
employeeNo: selectedItem.employeeNo === "无" ? "" : selectedItem.employeeNo ?? "",
|
||||
candidateName: selectedItem.name ?? "",
|
||||
|
|
@ -319,6 +574,10 @@ export default function Expansion() {
|
|||
employmentStatus: selectedItem.active ? "active" : "left",
|
||||
});
|
||||
} else {
|
||||
const parsedChannelAttributes = decodeExpansionMultiValue(selectedItem.channelAttributeCode);
|
||||
const parsedInternalAttributes = decodeExpansionMultiValue(selectedItem.internalAttributeCode);
|
||||
setChannelEditFieldErrors({});
|
||||
setInvalidEditChannelContactRows([]);
|
||||
setEditChannelForm({
|
||||
channelCode: selectedItem.channelCode ?? "",
|
||||
channelName: selectedItem.name ?? "",
|
||||
|
|
@ -330,8 +589,9 @@ export default function Expansion() {
|
|||
contactEstablishedDate: selectedItem.establishedDate === "无" ? "" : selectedItem.establishedDate ?? "",
|
||||
intentLevel: selectedItem.intentLevel ?? "medium",
|
||||
hasDesktopExp: Boolean(selectedItem.hasDesktopExp),
|
||||
channelAttribute: selectedItem.channelAttribute === "无" ? "" : selectedItem.channelAttribute ?? "",
|
||||
internalAttribute: selectedItem.internalAttribute === "无" ? "" : selectedItem.internalAttribute ?? "",
|
||||
channelAttribute: parsedChannelAttributes.values,
|
||||
channelAttributeCustom: parsedChannelAttributes.customText,
|
||||
internalAttribute: parsedInternalAttributes.values,
|
||||
stage: selectedItem.stageCode ?? "initial_contact",
|
||||
remark: selectedItem.notes === "无" ? "" : selectedItem.notes ?? "",
|
||||
contacts: (selectedItem.contacts?.length ?? 0) > 0
|
||||
|
|
@ -351,21 +611,31 @@ export default function Expansion() {
|
|||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setCreateError("");
|
||||
if (activeTab === "sales") {
|
||||
const validationErrors = validateSalesCreateForm(salesForm);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setSalesCreateFieldErrors(validationErrors);
|
||||
setCreateError("请先完整填写销售人员拓展必填字段");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const { errors: validationErrors, invalidContactRows } = validateChannelForm(channelForm, channelOtherOptionValue);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setChannelCreateFieldErrors(validationErrors);
|
||||
setInvalidCreateChannelContactRows(invalidContactRows);
|
||||
setCreateError("请先完整填写渠道拓展必填字段");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
if (activeTab === "sales") {
|
||||
await createSalesExpansion({
|
||||
...salesForm,
|
||||
targetDept: salesForm.targetDept?.trim() || undefined,
|
||||
});
|
||||
await createSalesExpansion(normalizeSalesPayload(salesForm));
|
||||
} else {
|
||||
await createChannelExpansion({
|
||||
...channelForm,
|
||||
annualRevenue: channelForm.annualRevenue || undefined,
|
||||
staffSize: channelForm.staffSize || undefined,
|
||||
});
|
||||
await createChannelExpansion(normalizeChannelPayload(channelForm));
|
||||
}
|
||||
|
||||
resetCreateState();
|
||||
|
|
@ -382,21 +652,31 @@ export default function Expansion() {
|
|||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setEditError("");
|
||||
if (selectedItem.type === "sales") {
|
||||
const validationErrors = validateSalesCreateForm(editSalesForm);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setSalesEditFieldErrors(validationErrors);
|
||||
setEditError("请先完整填写销售人员拓展必填字段");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const { errors: validationErrors, invalidContactRows } = validateChannelForm(editChannelForm, channelOtherOptionValue);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setChannelEditFieldErrors(validationErrors);
|
||||
setInvalidEditChannelContactRows(invalidContactRows);
|
||||
setEditError("请先完整填写渠道拓展必填字段");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
if (selectedItem.type === "sales") {
|
||||
await updateSalesExpansion(selectedItem.id, {
|
||||
...editSalesForm,
|
||||
targetDept: editSalesForm.targetDept?.trim() || undefined,
|
||||
});
|
||||
await updateSalesExpansion(selectedItem.id, normalizeSalesPayload(editSalesForm));
|
||||
} else {
|
||||
await updateChannelExpansion(selectedItem.id, {
|
||||
...editChannelForm,
|
||||
annualRevenue: editChannelForm.annualRevenue || undefined,
|
||||
staffSize: editChannelForm.staffSize || undefined,
|
||||
});
|
||||
await updateChannelExpansion(selectedItem.id, normalizeChannelPayload(editChannelForm));
|
||||
}
|
||||
|
||||
resetEditState();
|
||||
|
|
@ -410,15 +690,15 @@ export default function Expansion() {
|
|||
};
|
||||
|
||||
const renderEmpty = () => (
|
||||
<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>
|
||||
);
|
||||
|
||||
const renderFollowUpTimeline = () => {
|
||||
if (followUpRecords.length <= 0) {
|
||||
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>
|
||||
);
|
||||
|
|
@ -453,14 +733,16 @@ export default function Expansion() {
|
|||
const renderSalesForm = (
|
||||
form: CreateSalesExpansionPayload,
|
||||
onChange: <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => void,
|
||||
fieldErrors?: Partial<Record<SalesCreateField, string>>,
|
||||
) => (
|
||||
<div className="crm-form-grid">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">工号</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" />
|
||||
<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={getFieldInputClass(Boolean(fieldErrors?.employeeNo))} />
|
||||
{fieldErrors?.employeeNo ? <p className="text-xs text-rose-500">{fieldErrors.employeeNo}</p> : null}
|
||||
</label>
|
||||
<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
|
||||
value={form.officeName || ""}
|
||||
placeholder="请选择"
|
||||
|
|
@ -472,28 +754,35 @@ export default function Expansion() {
|
|||
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)}
|
||||
/>
|
||||
{fieldErrors?.officeName ? <p className="text-xs text-rose-500">{fieldErrors.officeName}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">姓名</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" />
|
||||
<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={getFieldInputClass(Boolean(fieldErrors?.candidateName))} />
|
||||
{fieldErrors?.candidateName ? <p className="text-xs text-rose-500">{fieldErrors.candidateName}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">联系方式</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" />
|
||||
<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={getFieldInputClass(Boolean(fieldErrors?.mobile))} />
|
||||
{fieldErrors?.mobile ? <p className="text-xs text-rose-500">{fieldErrors.mobile}</p> : null}
|
||||
</label>
|
||||
<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.targetDept || ""}
|
||||
onChange={(e) => onChange("targetDept", e.target.value)}
|
||||
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 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
|
||||
value={form.industry || ""}
|
||||
placeholder="请选择"
|
||||
|
|
@ -505,15 +794,20 @@ export default function Expansion() {
|
|||
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)}
|
||||
/>
|
||||
{fieldErrors?.industry ? <p className="text-xs text-rose-500">{fieldErrors.industry}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">职务</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" />
|
||||
<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={getFieldInputClass(Boolean(fieldErrors?.title))} />
|
||||
{fieldErrors?.title ? <p className="text-xs text-rose-500">{fieldErrors.title}</p> : null}
|
||||
</label>
|
||||
<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
|
||||
value={form.intentLevel}
|
||||
sheetTitle="合作意向"
|
||||
|
|
@ -522,11 +816,15 @@ export default function Expansion() {
|
|||
{ value: "medium", 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)}
|
||||
/>
|
||||
{fieldErrors?.intentLevel ? <p className="text-xs text-rose-500">{fieldErrors.intentLevel}</p> : null}
|
||||
</label>
|
||||
<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
|
||||
value={form.employmentStatus}
|
||||
sheetTitle="销售是否在职"
|
||||
|
|
@ -534,8 +832,12 @@ export default function Expansion() {
|
|||
{ value: "active", 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)}
|
||||
/>
|
||||
{fieldErrors?.employmentStatus ? <p className="text-xs text-rose-500">{fieldErrors.employmentStatus}</p> : null}
|
||||
</label>
|
||||
<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>
|
||||
|
|
@ -554,6 +856,8 @@ export default function Expansion() {
|
|||
form: CreateChannelExpansionPayload,
|
||||
onChange: <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => void,
|
||||
isEdit = false,
|
||||
fieldErrors?: Partial<Record<ChannelField, string>>,
|
||||
invalidContactRows: number[] = [],
|
||||
) => (
|
||||
<div className="crm-form-grid">
|
||||
<label className="space-y-2">
|
||||
|
|
@ -565,35 +869,42 @@ export default function Expansion() {
|
|||
/>
|
||||
</label>
|
||||
<label className="space-y-2 sm:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">渠道名称</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" />
|
||||
<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={getFieldInputClass(Boolean(fieldErrors?.channelName))} />
|
||||
{fieldErrors?.channelName ? <p className="text-xs text-rose-500">{fieldErrors.channelName}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">省份</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" />
|
||||
<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={getFieldInputClass(Boolean(fieldErrors?.province))} />
|
||||
{fieldErrors?.province ? <p className="text-xs text-rose-500">{fieldErrors.province}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">办公地址</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" />
|
||||
<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={getFieldInputClass(Boolean(fieldErrors?.officeAddress))} />
|
||||
{fieldErrors?.officeAddress ? <p className="text-xs text-rose-500">{fieldErrors.officeAddress}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">聚焦行业</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" />
|
||||
<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={getFieldInputClass(Boolean(fieldErrors?.channelIndustry))} />
|
||||
{fieldErrors?.channelIndustry ? <p className="text-xs text-rose-500">{fieldErrors.channelIndustry}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">年营收</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" />
|
||||
<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={getFieldInputClass(Boolean(fieldErrors?.annualRevenue))} />
|
||||
{fieldErrors?.annualRevenue ? <p className="text-xs text-rose-500">{fieldErrors.annualRevenue}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">人员规模</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" />
|
||||
<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={getFieldInputClass(Boolean(fieldErrors?.staffSize))} />
|
||||
{fieldErrors?.staffSize ? <p className="text-xs text-rose-500">{fieldErrors.staffSize}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">建立联系时间</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" />
|
||||
<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={getFieldInputClass(Boolean(fieldErrors?.contactEstablishedDate))} />
|
||||
{fieldErrors?.contactEstablishedDate ? <p className="text-xs text-rose-500">{fieldErrors.contactEstablishedDate}</p> : null}
|
||||
</label>
|
||||
<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
|
||||
value={form.intentLevel || "medium"}
|
||||
sheetTitle="合作意向"
|
||||
|
|
@ -602,40 +913,65 @@ export default function Expansion() {
|
|||
{ value: "medium", 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)}
|
||||
/>
|
||||
{fieldErrors?.intentLevel ? <p className="text-xs text-rose-500">{fieldErrors.intentLevel}</p> : null}
|
||||
</label>
|
||||
<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
|
||||
value={form.channelAttribute || ""}
|
||||
multiple
|
||||
value={form.channelAttribute || []}
|
||||
placeholder="请选择"
|
||||
sheetTitle="渠道属性"
|
||||
options={[
|
||||
{ value: "", label: "请选择" },
|
||||
...channelAttributeOptions.map((option) => ({
|
||||
value: option.value ?? "",
|
||||
label: option.label || "无",
|
||||
})),
|
||||
]}
|
||||
onChange={(value) => onChange("channelAttribute", value || undefined)}
|
||||
options={channelAttributeOptions.map((option) => ({
|
||||
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);
|
||||
if (!channelOtherOptionValue || !value.includes(channelOtherOptionValue)) {
|
||||
onChange("channelAttributeCustom", "");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{fieldErrors?.channelAttribute ? <p className="text-xs text-rose-500">{fieldErrors.channelAttribute}</p> : null}
|
||||
</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">
|
||||
<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
|
||||
value={form.internalAttribute || ""}
|
||||
multiple
|
||||
value={form.internalAttribute || []}
|
||||
placeholder="请选择"
|
||||
sheetTitle="新华三内部属性"
|
||||
options={[
|
||||
{ value: "", label: "请选择" },
|
||||
...internalAttributeOptions.map((option) => ({
|
||||
value: option.value ?? "",
|
||||
label: option.label || "无",
|
||||
})),
|
||||
]}
|
||||
onChange={(value) => onChange("internalAttribute", value || undefined)}
|
||||
options={internalAttributeOptions.map((option) => ({
|
||||
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)}
|
||||
/>
|
||||
{fieldErrors?.internalAttribute ? <p className="text-xs text-rose-500">{fieldErrors.internalAttribute}</p> : null}
|
||||
</label>
|
||||
<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>
|
||||
|
|
@ -643,7 +979,7 @@ export default function Expansion() {
|
|||
</label>
|
||||
<div className="crm-form-section sm:col-span-2">
|
||||
<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>
|
||||
|
|
@ -651,15 +987,16 @@ export default function Expansion() {
|
|||
<div className="crm-section-stack">
|
||||
{(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">
|
||||
<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.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.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" />
|
||||
<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">
|
||||
<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={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={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="crm-btn-danger rounded-lg px-3 py-2 text-sm font-medium">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{fieldErrors?.contacts ? <p className="text-xs text-rose-500">{fieldErrors.contacts}</p> : null}
|
||||
</div>
|
||||
<label className="space-y-2 sm:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">跟进的云桌面项目</span>
|
||||
|
|
@ -676,18 +1013,21 @@ export default function Expansion() {
|
|||
|
||||
return (
|
||||
<div className="crm-page-stack">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-2xl">拓展管理</h1>
|
||||
<header className="crm-page-header">
|
||||
<div className="crm-page-heading">
|
||||
<h1 className="crm-page-title">拓展管理</h1>
|
||||
<p className="crm-page-subtitle hidden sm:block">维护销售拓展与渠道拓展的基础资料,并查看关联项目与跟进记录。</p>
|
||||
</div>
|
||||
<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"
|
||||
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>
|
||||
</button>
|
||||
</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
|
||||
onClick={() => handleTabChange("sales")}
|
||||
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">
|
||||
<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>
|
||||
</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">
|
||||
<button type="button" className={detailBadgeClass}>
|
||||
查看详情
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<ChevronRight className="crm-icon-sm" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
@ -807,13 +1147,13 @@ export default function Expansion() {
|
|||
onClose={resetCreateState}
|
||||
footer={(
|
||||
<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={() => 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={resetCreateState} className="crm-btn crm-btn-secondary">取消</button>
|
||||
<button onClick={() => void handleCreateSubmit()} disabled={submitting} className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60">{submitting ? "提交中..." : "确认新增"}</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{activeTab === "sales" ? renderSalesForm(salesForm, handleSalesChange) : renderChannelForm(channelForm, handleChannelChange)}
|
||||
{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}
|
||||
{activeTab === "sales" ? renderSalesForm(salesForm, handleSalesChange, salesCreateFieldErrors) : renderChannelForm(channelForm, handleChannelChange, false, channelCreateFieldErrors, invalidCreateChannelContactRows)}
|
||||
{createError ? <div className="crm-alert crm-alert-error mt-4">{createError}</div> : null}
|
||||
</ModalShell>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
@ -826,13 +1166,13 @@ export default function Expansion() {
|
|||
onClose={resetEditState}
|
||||
footer={(
|
||||
<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={() => 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={resetEditState} className="crm-btn crm-btn-secondary">取消</button>
|
||||
<button onClick={() => void handleEditSubmit()} disabled={submitting} className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60">{submitting ? "保存中..." : "保存修改"}</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{selectedItem.type === "sales" ? renderSalesForm(editSalesForm, handleEditSalesChange) : renderChannelForm(editChannelForm, handleEditChannelChange, true)}
|
||||
{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}
|
||||
{selectedItem.type === "sales" ? renderSalesForm(editSalesForm, handleEditSalesChange, salesEditFieldErrors) : renderChannelForm(editChannelForm, handleEditChannelChange, true, channelEditFieldErrors, invalidEditChannelContactRows)}
|
||||
{editError ? <div className="crm-alert crm-alert-error mt-4">{editError}</div> : null}
|
||||
</ModalShell>
|
||||
)}
|
||||
</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" />
|
||||
<h2 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{selectedItem.type === "sales" ? "销售拓展详情" : "渠道拓展详情"}</h2>
|
||||
</div>
|
||||
<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" />
|
||||
<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="crm-icon-lg" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -893,7 +1233,7 @@ export default function Expansion() {
|
|||
|
||||
<div className="crm-section-stack">
|
||||
<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>
|
||||
<div className="crm-detail-grid text-sm sm:grid-cols-2">
|
||||
|
|
@ -971,7 +1311,7 @@ export default function Expansion() {
|
|||
))}
|
||||
</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>
|
||||
)}
|
||||
|
|
@ -979,7 +1319,7 @@ export default function Expansion() {
|
|||
) : (
|
||||
<div className="crm-section-stack">
|
||||
<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>
|
||||
{renderFollowUpTimeline()}
|
||||
|
|
@ -1039,7 +1379,7 @@ export default function Expansion() {
|
|||
))}
|
||||
</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>
|
||||
)
|
||||
|
|
@ -1057,7 +1397,7 @@ export default function Expansion() {
|
|||
))}
|
||||
</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>
|
||||
)
|
||||
|
|
@ -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="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>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
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 { 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 { 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 = [
|
||||
{ value: "80", label: "A" },
|
||||
|
|
@ -24,6 +25,20 @@ const COMPETITOR_OPTIONS = [
|
|||
|
||||
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number] | "";
|
||||
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 = {
|
||||
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({
|
||||
title,
|
||||
subtitle,
|
||||
|
|
@ -269,6 +353,7 @@ function SearchableSelect({
|
|||
placeholder,
|
||||
searchPlaceholder,
|
||||
emptyText,
|
||||
className,
|
||||
onChange,
|
||||
}: {
|
||||
value?: number;
|
||||
|
|
@ -276,6 +361,7 @@ function SearchableSelect({
|
|||
placeholder: string;
|
||||
searchPlaceholder: string;
|
||||
emptyText: string;
|
||||
className?: string;
|
||||
onChange: (value?: number) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -392,7 +478,10 @@ function SearchableSelect({
|
|||
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"}>
|
||||
{selectedOption?.label || placeholder}
|
||||
|
|
@ -462,7 +551,9 @@ function SearchableSelect({
|
|||
}
|
||||
|
||||
export default function Opportunities() {
|
||||
const [archiveTab, setArchiveTab] = useState<OpportunityArchiveTab>("active");
|
||||
const [filter, setFilter] = useState("全部");
|
||||
const [stageFilterOpen, setStageFilterOpen] = useState(false);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [selectedItem, setSelectedItem] = useState<OpportunityItem | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
|
@ -478,6 +569,7 @@ export default function Opportunities() {
|
|||
const [operatorOptions, setOperatorOptions] = useState<OpportunityDictOption[]>([]);
|
||||
const [form, setForm] = useState<CreateOpportunityPayload>(defaultForm);
|
||||
const [competitorSelection, setCompetitorSelection] = useState<CompetitorOption>("");
|
||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<OpportunityField, string>>>({});
|
||||
const [detailTab, setDetailTab] = useState<"sales" | "channel" | "followups">("sales");
|
||||
const hasForegroundModal = createOpen || editOpen || pushConfirmOpen;
|
||||
|
||||
|
|
@ -563,6 +655,12 @@ export default function Opportunities() {
|
|||
}, [stageOptions]);
|
||||
|
||||
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
|
||||
? salesExpansionOptions.find((item) => item.id === selectedItem.salesExpansionId) ?? null
|
||||
: null;
|
||||
|
|
@ -593,6 +691,12 @@ export default function Opportunities() {
|
|||
}
|
||||
}, [selectedItem]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedItem && !visibleItems.some((item) => item.id === selectedItem.id)) {
|
||||
setSelectedItem(null);
|
||||
}
|
||||
}, [archiveTab, selectedItem, visibleItems]);
|
||||
|
||||
const getConfidenceColor = (score: number) => {
|
||||
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";
|
||||
|
|
@ -602,10 +706,18 @@ export default function Opportunities() {
|
|||
|
||||
const handleChange = <K extends keyof CreateOpportunityPayload>(key: K, value: CreateOpportunityPayload[K]) => {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
if (key in fieldErrors) {
|
||||
setFieldErrors((current) => {
|
||||
const next = { ...current };
|
||||
delete next[key as OpportunityField];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setError("");
|
||||
setFieldErrors({});
|
||||
setForm(defaultForm);
|
||||
setCompetitorSelection("");
|
||||
setCreateOpen(true);
|
||||
|
|
@ -616,6 +728,7 @@ export default function Opportunities() {
|
|||
setEditOpen(false);
|
||||
setSubmitting(false);
|
||||
setError("");
|
||||
setFieldErrors({});
|
||||
setForm(defaultForm);
|
||||
setCompetitorSelection("");
|
||||
};
|
||||
|
|
@ -634,15 +747,17 @@ export default function Opportunities() {
|
|||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
const validationErrors = validateOpportunityForm(form, competitorSelection, operatorMode);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setFieldErrors(validationErrors);
|
||||
setError("请先完整填写商机必填字段");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
validateOperatorRelations(operatorMode, form.salesExpansionId, form.channelExpansionId);
|
||||
if (competitorSelection === "其他" && !form.competitorName?.trim()) {
|
||||
throw new Error("请选择“其他”时,请填写具体竞争对手");
|
||||
}
|
||||
|
||||
await createOpportunity(buildOpportunitySubmitPayload(form, competitorSelection, operatorMode));
|
||||
await reload();
|
||||
resetCreateState();
|
||||
|
|
@ -661,6 +776,7 @@ export default function Opportunities() {
|
|||
return;
|
||||
}
|
||||
setError("");
|
||||
setFieldErrors({});
|
||||
setForm(toFormFromItem(selectedItem));
|
||||
setCompetitorSelection(getCompetitorSelection(selectedItem.competitorName));
|
||||
setEditOpen(true);
|
||||
|
|
@ -671,15 +787,17 @@ export default function Opportunities() {
|
|||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
const validationErrors = validateOpportunityForm(form, competitorSelection, operatorMode);
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setFieldErrors(validationErrors);
|
||||
setError("请先完整填写商机必填字段");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
validateOperatorRelations(operatorMode, form.salesExpansionId, form.channelExpansionId);
|
||||
if (competitorSelection === "其他" && !form.competitorName?.trim()) {
|
||||
throw new Error("请选择“其他”时,请填写具体竞争对手");
|
||||
}
|
||||
|
||||
await updateOpportunity(selectedItem.id, buildOpportunitySubmitPayload(form, competitorSelection, operatorMode));
|
||||
await reload(selectedItem.id);
|
||||
resetCreateState();
|
||||
|
|
@ -720,54 +838,77 @@ export default function Opportunities() {
|
|||
};
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="crm-page-stack">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-2xl">商机储备</h1>
|
||||
<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">
|
||||
<Plus className="h-4 w-4" />
|
||||
<header className="crm-page-header">
|
||||
<div className="crm-page-heading">
|
||||
<h1 className="crm-page-title">商机储备</h1>
|
||||
<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>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="relative group">
|
||||
<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" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索项目名称、最终客户、编码..."
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
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"
|
||||
/>
|
||||
<div className="crm-filter-bar flex backdrop-blur-sm">
|
||||
<button
|
||||
onClick={() => setArchiveTab("active")}
|
||||
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-all duration-200 ${
|
||||
archiveTab === "active"
|
||||
? "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>
|
||||
<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 className="hidden gap-2 overflow-x-auto pb-2 scrollbar-hide sm:flex">
|
||||
{[
|
||||
{ label: "全部", value: "全部" },
|
||||
...stageOptions.map((item) => ({ label: item.label || item.value || "", value: item.value || "" })),
|
||||
].filter((item) => item.value).map((stage) => (
|
||||
<button
|
||||
key={stage.value}
|
||||
onClick={() => setFilter(stage.value)}
|
||||
className={`whitespace-nowrap rounded-full px-4 py-1.5 text-sm font-medium transition-all duration-200 ${
|
||||
filter === stage.value
|
||||
? "bg-slate-800 text-white shadow-sm dark:bg-violet-600"
|
||||
: "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"
|
||||
}`}
|
||||
>
|
||||
{stage.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative group min-w-0 flex-1">
|
||||
<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" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索项目名称、最终客户、编码..."
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStageFilterOpen(true)}
|
||||
className={`relative inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border transition-all ${
|
||||
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 className="crm-list-stack">
|
||||
{items.length > 0 ? (
|
||||
items.map((opp, i) => (
|
||||
{visibleItems.length > 0 ? (
|
||||
visibleItems.map((opp, i) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
<button type="button" className={detailBadgeClass}>
|
||||
查看详情 <ChevronRight className="ml-0.5 h-3 w-3" />
|
||||
查看详情 <ChevronRight className="crm-icon-sm ml-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
@ -820,6 +961,67 @@ export default function Opportunities() {
|
|||
) : renderEmpty()}
|
||||
</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>
|
||||
{(createOpen || editOpen) && (
|
||||
<ModalShell
|
||||
|
|
@ -828,26 +1030,29 @@ export default function Opportunities() {
|
|||
onClose={resetCreateState}
|
||||
footer={(
|
||||
<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={() => 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={resetCreateState} className="crm-btn crm-btn-secondary">取消</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 className="crm-form-grid">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目地</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" />
|
||||
<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={getFieldInputClass(Boolean(fieldErrors.projectLocation))} />
|
||||
{fieldErrors.projectLocation ? <p className="text-xs text-rose-500">{fieldErrors.projectLocation}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2 sm:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目名称</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" />
|
||||
<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={getFieldInputClass(Boolean(fieldErrors.opportunityName))} />
|
||||
{fieldErrors.opportunityName ? <p className="text-xs text-rose-500">{fieldErrors.opportunityName}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2 sm:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">最终客户</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" />
|
||||
<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={getFieldInputClass(Boolean(fieldErrors.customerName))} />
|
||||
{fieldErrors.customerName ? <p className="text-xs text-rose-500">{fieldErrors.customerName}</p> : null}
|
||||
</label>
|
||||
<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
|
||||
value={form.operatorName || ""}
|
||||
placeholder="请选择"
|
||||
|
|
@ -859,55 +1064,73 @@ export default function Opportunities() {
|
|||
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)}
|
||||
/>
|
||||
{fieldErrors.operatorName ? <p className="text-xs text-rose-500">{fieldErrors.operatorName}</p> : null}
|
||||
</label>
|
||||
{showSalesExpansionField ? (
|
||||
<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
|
||||
value={form.salesExpansionId}
|
||||
options={salesExpansionSearchOptions}
|
||||
placeholder="请选择新华三负责人"
|
||||
searchPlaceholder="搜索姓名、工号、办事处、电话"
|
||||
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)}
|
||||
/>
|
||||
{fieldErrors.salesExpansionId ? <p className="text-xs text-rose-500">{fieldErrors.salesExpansionId}</p> : null}
|
||||
</label>
|
||||
) : null}
|
||||
{showChannelExpansionField ? (
|
||||
<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
|
||||
value={form.channelExpansionId}
|
||||
options={channelExpansionSearchOptions}
|
||||
placeholder="请选择渠道名称"
|
||||
searchPlaceholder="搜索渠道名称、编码、省份、联系人"
|
||||
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)}
|
||||
/>
|
||||
{fieldErrors.channelExpansionId ? <p className="text-xs text-rose-500">{fieldErrors.channelExpansionId}</p> : null}
|
||||
</label>
|
||||
) : null}
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目金额(元)</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" />
|
||||
<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={getFieldInputClass(Boolean(fieldErrors.amount))} />
|
||||
{fieldErrors.amount ? <p className="text-xs text-rose-500">{fieldErrors.amount}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">预计下单时间</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" />
|
||||
<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={`${getFieldInputClass(Boolean(fieldErrors.expectedCloseDate))} min-w-0`} />
|
||||
{fieldErrors.expectedCloseDate ? <p className="text-xs text-rose-500">{fieldErrors.expectedCloseDate}</p> : null}
|
||||
</label>
|
||||
<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
|
||||
value={getConfidenceOptionValue(form.confidencePct)}
|
||||
placeholder="请选择"
|
||||
sheetTitle="项目把握度"
|
||||
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)}
|
||||
/>
|
||||
{fieldErrors.confidencePct ? <p className="text-xs text-rose-500">{fieldErrors.confidencePct}</p> : null}
|
||||
</label>
|
||||
<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
|
||||
value={form.stage}
|
||||
placeholder="请选择"
|
||||
|
|
@ -916,19 +1139,34 @@ export default function Opportunities() {
|
|||
value: 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)}
|
||||
/>
|
||||
{fieldErrors.stage ? <p className="text-xs text-rose-500">{fieldErrors.stage}</p> : null}
|
||||
</label>
|
||||
<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
|
||||
value={competitorSelection}
|
||||
placeholder="请选择"
|
||||
sheetTitle="竞争对手"
|
||||
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) => {
|
||||
const nextSelection = value as CompetitorOption;
|
||||
setCompetitorSelection(nextSelection);
|
||||
setFieldErrors((current) => {
|
||||
if (!current.competitorName) {
|
||||
return current;
|
||||
}
|
||||
const next = { ...current };
|
||||
delete next.competitorName;
|
||||
return next;
|
||||
});
|
||||
if (nextSelection === "其他") {
|
||||
handleChange("competitorName", getCompetitorSelection(form.competitorName) === "其他" ? form.competitorName || "" : "");
|
||||
return;
|
||||
|
|
@ -936,30 +1174,36 @@ export default function Opportunities() {
|
|||
handleChange("competitorName", nextSelection || "");
|
||||
}}
|
||||
/>
|
||||
{fieldErrors.competitorName && competitorSelection !== "其他" ? <p className="text-xs text-rose-500">{fieldErrors.competitorName}</p> : null}
|
||||
</label>
|
||||
{competitorSelection === "其他" ? (
|
||||
<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={getCompetitorSelection(form.competitorName) === "其他" ? form.competitorName || "" : ""}
|
||||
onChange={(e) => handleChange("competitorName", e.target.value)}
|
||||
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>
|
||||
) : null}
|
||||
<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
|
||||
value={form.opportunityType}
|
||||
sheetTitle="扩容/新建"
|
||||
sheetTitle="建设类型"
|
||||
options={[
|
||||
{ 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)}
|
||||
/>
|
||||
{fieldErrors.opportunityType ? <p className="text-xs text-rose-500">{fieldErrors.opportunityType}</p> : null}
|
||||
</label>
|
||||
{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" />
|
||||
</label>
|
||||
</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>
|
||||
)}
|
||||
</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="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="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">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<div className="crm-tone-warning mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl">
|
||||
<AlertTriangle className="crm-icon-lg" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">确认推送 OMS</h3>
|
||||
|
|
@ -1036,7 +1280,7 @@ export default function Opportunities() {
|
|||
<button
|
||||
type="button"
|
||||
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>
|
||||
|
|
@ -1044,7 +1288,7 @@ export default function Opportunities() {
|
|||
type="button"
|
||||
onClick={() => void handleConfirmPushToOms()}
|
||||
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 ? "推送中..." : "确认推送"}
|
||||
</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" />
|
||||
<h2 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg">商机详情</h2>
|
||||
</div>
|
||||
<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" />
|
||||
<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="crm-icon-lg" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -1098,7 +1342,7 @@ export default function Opportunities() {
|
|||
|
||||
<div className="crm-section-stack">
|
||||
<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>
|
||||
<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.operatorName || "无"} />
|
||||
<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={getConfidenceLabel(selectedItem.confidence)} />
|
||||
<DetailItem label="项目阶段" value={selectedItem.stage || "无"} icon={<Activity className="h-3 w-3" />} />
|
||||
<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="项目最新进展" value={selectedItem.latestProgress || "暂无日报回写进展"} 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 ? "是" : "否") : "待同步"} />
|
||||
</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>
|
||||
)
|
||||
|
|
@ -1178,7 +1422,7 @@ export default function Opportunities() {
|
|||
<DetailItem label="建立联系时间" value={selectedChannelExpansion?.establishedDate || "无"} />
|
||||
</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>
|
||||
)
|
||||
|
|
@ -1189,7 +1433,7 @@ export default function Opportunities() {
|
|||
<p className="crm-field-note">工作台日报里关联到该商机的工作内容,会自动回写到这里。</p>
|
||||
<div className="crm-section-stack">
|
||||
<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>
|
||||
{followUpRecords.length > 0 ? (
|
||||
|
|
@ -1211,7 +1455,7 @@ export default function Opportunities() {
|
|||
})}
|
||||
</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>
|
||||
)}
|
||||
|
|
@ -1222,19 +1466,19 @@ export default function Opportunities() {
|
|||
</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">
|
||||
{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">
|
||||
<button
|
||||
onClick={handleOpenEdit}
|
||||
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
|
||||
onClick={handleOpenPushConfirm}
|
||||
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"}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -337,8 +337,11 @@ export default function Profile() {
|
|||
|
||||
return (
|
||||
<div className="crm-page-stack">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-2xl">我的</h1>
|
||||
<header className="crm-page-header">
|
||||
<div className="crm-page-heading">
|
||||
<h1 className="crm-page-title">我的</h1>
|
||||
<p className="crm-page-subtitle">查看个人信息、账号安全设置和本月工作概览。</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<motion.div
|
||||
|
|
@ -350,7 +353,7 @@ export default function Profile() {
|
|||
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"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
<Settings className="crm-icon-lg" />
|
||||
</button>
|
||||
|
||||
<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 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>
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{theme === "dark" ? "切换亮色模式" : "切换暗色模式"}
|
||||
</span>
|
||||
</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>
|
||||
</li>
|
||||
{MENU_ITEMS.map((item) => (
|
||||
|
|
@ -431,11 +434,11 @@ export default function Profile() {
|
|||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{item.label}</span>
|
||||
</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>
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -447,9 +450,9 @@ export default function Profile() {
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
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>
|
||||
|
||||
|
|
@ -462,14 +465,14 @@ export default function Profile() {
|
|||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
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
|
||||
onClick={() => void handleSave()}
|
||||
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 ? "保存中..." : "保存资料"}
|
||||
</button>
|
||||
|
|
@ -477,7 +480,7 @@ export default function Profile() {
|
|||
)}
|
||||
>
|
||||
{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}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -513,7 +516,7 @@ export default function Profile() {
|
|||
</label>
|
||||
<label className="space-y-2">
|
||||
<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>
|
||||
<input
|
||||
|
|
@ -524,7 +527,7 @@ export default function Profile() {
|
|||
</label>
|
||||
<label className="space-y-2">
|
||||
<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>
|
||||
<input
|
||||
|
|
@ -538,7 +541,7 @@ export default function Profile() {
|
|||
<div className="crm-form-grid">
|
||||
<div className="crm-form-section">
|
||||
<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 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 className="crm-form-section">
|
||||
<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 className="mt-3 space-y-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
<p>部门:{orgNames}</p>
|
||||
<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)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -571,13 +574,13 @@ export default function Profile() {
|
|||
</div>
|
||||
|
||||
{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>
|
||||
) : null}
|
||||
|
||||
{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}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -593,14 +596,14 @@ export default function Profile() {
|
|||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
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
|
||||
onClick={() => void handleUpdatePassword()}
|
||||
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 ? "提交中..." : "更新密码"}
|
||||
</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"
|
||||
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>
|
||||
</div>
|
||||
</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"
|
||||
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>
|
||||
</div>
|
||||
</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"
|
||||
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>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{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}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -736,10 +736,10 @@ export default function Work() {
|
|||
|
||||
return (
|
||||
<div className="crm-page-stack">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-2xl">销售工作台</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>
|
||||
<header className="crm-page-header">
|
||||
<div className="crm-page-heading">
|
||||
<h1 className="crm-page-title">销售工作台</h1>
|
||||
<p className="crm-page-subtitle">今天是 {format(new Date(), "yyyy年MM月dd日 EEEE", { locale: zhCN })}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -830,7 +830,7 @@ export default function Work() {
|
|||
))}
|
||||
|
||||
{!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)}记录
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -838,7 +838,7 @@ export default function Work() {
|
|||
{historyHasMore ? (
|
||||
<div
|
||||
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 ? "加载更多记录中..." : "下拉到底自动加载更多"}
|
||||
</div>
|
||||
|
|
@ -858,7 +858,7 @@ export default function Work() {
|
|||
style={{ pointerEvents: showHistoryBackToTop ? "auto" : "none" }}
|
||||
aria-label="回到历史记录顶部"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
<ArrowUp className="crm-icon-md" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -884,7 +884,7 @@ export default function Work() {
|
|||
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"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="crm-icon-md" />
|
||||
</button>
|
||||
</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">
|
||||
{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>
|
||||
) : pickerOptions.length ? (
|
||||
|
|
@ -937,7 +937,7 @@ export default function Work() {
|
|||
))}
|
||||
</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>
|
||||
)}
|
||||
|
|
@ -1256,7 +1256,7 @@ function WorkSectionNav({
|
|||
activeWorkSection: WorkSection;
|
||||
}) {
|
||||
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) => {
|
||||
const isActive = item.key === activeWorkSection;
|
||||
return (
|
||||
|
|
@ -1286,7 +1286,7 @@ function MobilePanelToggle({
|
|||
onChange: (panel: "entry" | "history") => void;
|
||||
}) {
|
||||
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
|
||||
type="button"
|
||||
onClick={() => onChange("entry")}
|
||||
|
|
@ -1411,8 +1411,8 @@ function HistoryCard({
|
|||
>
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<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"}`}>
|
||||
{item.type === "日报" ? <FileText className="h-4 w-4" /> : <MapPin className="h-4 w-4" />}
|
||||
<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="crm-icon-md" /> : <MapPin className="crm-icon-md" />}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">{item.type}</h3>
|
||||
|
|
@ -1546,7 +1546,7 @@ function CheckInPanel({
|
|||
{checkInForm.bizName || "点击选择本次打卡关联对象"}
|
||||
</p>
|
||||
</div>
|
||||
<AtSign className="h-4 w-4 text-violet-500" />
|
||||
<AtSign className="crm-icon-md text-violet-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1559,7 +1559,7 @@ function CheckInPanel({
|
|||
type="button"
|
||||
onClick={onOpenLocationAdjust}
|
||||
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>
|
||||
|
|
@ -1567,9 +1567,9 @@ function CheckInPanel({
|
|||
type="button"
|
||||
onClick={onRefreshLocation}
|
||||
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 ? "定位中" : "刷新"}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -1625,9 +1625,9 @@ function CheckInPanel({
|
|||
type="button"
|
||||
onClick={onPickPhoto}
|
||||
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 ? "处理中..." : "重新拍照"}
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -1638,8 +1638,8 @@ function CheckInPanel({
|
|||
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"
|
||||
>
|
||||
<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">
|
||||
<Camera className="h-5 w-5" />
|
||||
<div className="crm-tone-brand mb-3 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<Camera className="crm-icon-lg" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-white">
|
||||
{uploadingPhoto ? "照片处理中..." : mobileCameraOnly ? "点击拍摄现场照片" : "请使用手机端拍照打卡"}
|
||||
|
|
@ -1664,17 +1664,17 @@ function CheckInPanel({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{checkInError ? <p className="mt-4 text-xs text-rose-500">{checkInError}</p> : null}
|
||||
{checkInSuccess ? <p className="mt-4 text-xs text-emerald-500">{checkInSuccess}</p> : null}
|
||||
{pageError ? <p className="mt-4 text-xs text-rose-500">{pageError}</p> : null}
|
||||
{checkInError ? <div className="crm-alert crm-alert-error mt-4">{checkInError}</div> : null}
|
||||
{checkInSuccess ? <div className="crm-alert crm-alert-success mt-4">{checkInSuccess}</div> : null}
|
||||
{pageError ? <div className="crm-alert crm-alert-error mt-4">{pageError}</div> : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
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 ? "请先确认位置" : "确认打卡"}
|
||||
</button>
|
||||
</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>
|
||||
<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>
|
||||
<p className="crm-field-note mt-1">
|
||||
|
|
@ -1859,7 +1859,7 @@ function ReportPanel({
|
|||
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"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<Plus className="crm-icon-md" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1927,7 +1927,7 @@ function ReportPanel({
|
|||
<div className="mt-6">
|
||||
<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">
|
||||
<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>
|
||||
<button
|
||||
|
|
@ -1936,7 +1936,7 @@ function ReportPanel({
|
|||
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"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<Plus className="crm-icon-md" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
|
|
@ -1989,17 +1989,17 @@ function ReportPanel({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{reportError ? <p className="mt-4 text-xs text-rose-500">{reportError}</p> : null}
|
||||
{reportSuccess ? <p className="mt-4 text-xs text-emerald-500">{reportSuccess}</p> : null}
|
||||
{pageError ? <p className="mt-4 text-xs text-rose-500">{pageError}</p> : null}
|
||||
{reportError ? <div className="crm-alert crm-alert-error mt-4">{reportError}</div> : null}
|
||||
{reportSuccess ? <div className="crm-alert crm-alert-success mt-4">{reportSuccess}</div> : null}
|
||||
{pageError ? <div className="crm-alert crm-alert-error mt-4">{pageError}</div> : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
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 ? "提交中..." : "提交日报"}
|
||||
</button>
|
||||
</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"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="crm-icon-md" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
- 若只是历史修复,不要反向拆散主初始化脚本
|
||||
|
||||
这样可以保证:
|
||||
|
||||
- 新环境只需要执行一份脚本
|
||||
- 老环境仍然能追溯每次改动来源
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# SQL Archive
|
||||
|
||||
本目录保留历史迁移脚本、旧版初始化快照与修数脚本,仅用于追溯和老环境排障。
|
||||
|
||||
正常部署请直接执行:
|
||||
|
||||
- `sql/init_full_pg17.sql`
|
||||
|
||||
非特殊场景下,不再逐个单独执行本目录中的脚本。
|
||||
|
||||
## 文件类型说明
|
||||
|
||||
- `init_pg17.sql`
|
||||
旧版初始化快照,仅作历史参考,不再作为推荐入口。
|
||||
|
||||
- `alter_*.sql`
|
||||
历史结构演进脚本。
|
||||
|
||||
- `fix_*.sql`
|
||||
历史修数或数据纠偏脚本。
|
||||
|
||||
## 使用建议
|
||||
|
||||
- 新环境初始化:只执行根目录 `sql/init_full_pg17.sql`
|
||||
- 老环境追溯:按问题定位具体查看本目录脚本
|
||||
- 不建议在新环境中按时间顺序逐个补跑 `archive` 下的脚本
|
||||
|
|
@ -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);
|
||||
|
|
@ -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:
|
||||
-- psql -d your_database -f sql/init_pg17.sql
|
||||
-- psql -d your_database -f sql/init_full_pg17.sql
|
||||
|
||||
begin;
|
||||
|
||||
|
|
@ -96,6 +99,7 @@ create table if not exists crm_opportunity (
|
|||
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,
|
||||
|
|
@ -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_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);
|
||||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue