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