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

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -37,13 +37,17 @@ backend/
## 启动前准备
1. 执行数据库脚本
1. 初始化数据库
```bash
psql -h 127.0.0.1 -U postgres -d nex_auth -f sql/init_pg17.sql
psql -h 127.0.0.1 -U postgres -d nex_auth -f sql/init_full_pg17.sql
```
2. 确保 `sys_user`、`work_todo`、`sys_activity_log`、`crm_customer`、`crm_opportunity`、`work_checkin` 中有业务数据。
2. 如果是全新环境,先准备基础框架(`unisbase`)相关表与基础数据,再执行上面的 CRM 脚本。
3. 详细部署顺序见:
- [docs/deployment-guide.md](/Users/kangwenjing/Downloads/crm/unis_crm/docs/deployment-guide.md)
## 启动项目
@ -52,14 +56,14 @@ cd backend
mvn spring-boot:run
```
默认启动在 `8081` 端口,供前端开发环境通过 Vite 代理访问`8080` 可继续保留给现有认证/系统服务
默认启动在 `8080` 端口,供前端开发环境通过 Vite 代理访问。
## 首页接口
请求示例:
```bash
curl -H "X-User-Id: 1" "http://127.0.0.1:8081/api/dashboard/home"
curl -H "X-User-Id: 1" "http://127.0.0.1:8080/api/dashboard/home"
```
首页接口只允许查询当前登录用户自己的数据,必须通过 `X-User-Id` 传入当前用户ID不支持指定其他用户查询。

View File

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

View File

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

View File

@ -57,6 +57,7 @@ public interface ExpansionMapper {
int deleteChannelContacts(@Param("channelExpansionId") Long channelExpansionId);
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
int updateSalesExpansion(@Param("userId") Long userId, @Param("id") Long id, @Param("request") UpdateSalesExpansionRequest request);
int countSalesExpansionByEmployeeNo(@Param("userId") Long userId, @Param("employeeNo") String employeeNo);
@ -66,10 +67,13 @@ public interface ExpansionMapper {
@Param("employeeNo") String employeeNo,
@Param("excludeId") Long excludeId);
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
int updateChannelExpansion(@Param("userId") Long userId, @Param("id") Long id, @Param("request") UpdateChannelExpansionRequest request);
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
int countOwnedSalesExpansion(@Param("userId") Long userId, @Param("id") Long id);
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
int countOwnedChannelExpansion(@Param("userId") Long userId, @Param("id") Long id);
int insertExpansionFollowUp(

View File

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

View File

@ -17,7 +17,12 @@ import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
import com.unis.crm.mapper.ExpansionMapper;
import com.unis.crm.service.ExpansionService;
import java.math.BigDecimal;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@ -35,6 +40,7 @@ public class ExpansionServiceImpl implements ExpansionService {
private static final String INDUSTRY_TYPE_CODE = "tz_sshy";
private static final String CHANNEL_ATTRIBUTE_TYPE_CODE = "tz_qdsx";
private static final String INTERNAL_ATTRIBUTE_TYPE_CODE = "tz_xhsnbsx";
private static final String MULTI_VALUE_CUSTOM_PREFIX = "__custom__:";
private static final DateTimeFormatter FOLLOW_UP_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private static final Logger log = LoggerFactory.getLogger(ExpansionServiceImpl.class);
@ -75,6 +81,7 @@ public class ExpansionServiceImpl implements ExpansionService {
attachChannelFollowUps(userId, channelItems);
attachChannelContacts(userId, channelItems);
attachChannelRelatedProjects(userId, channelItems);
fillChannelAttributeDisplay(channelItems);
return new ExpansionOverviewDTO(salesItems, channelItems);
}
@ -251,6 +258,79 @@ public class ExpansionServiceImpl implements ExpansionService {
return expansionMapper.selectDictItems(typeCode);
}
private void fillChannelAttributeDisplay(List<ChannelExpansionItemDTO> channelItems) {
if (channelItems == null || channelItems.isEmpty()) {
return;
}
Map<String, String> channelAttributeLabels = toDictLabelMap(loadDictOptions(CHANNEL_ATTRIBUTE_TYPE_CODE));
Map<String, String> internalAttributeLabels = toDictLabelMap(loadDictOptions(INTERNAL_ATTRIBUTE_TYPE_CODE));
for (ChannelExpansionItemDTO item : channelItems) {
item.setChannelAttribute(formatMultiValueDisplay(item.getChannelAttributeCode(), channelAttributeLabels));
item.setInternalAttribute(formatMultiValueDisplay(item.getInternalAttributeCode(), internalAttributeLabels));
}
}
private Map<String, String> toDictLabelMap(List<DictOptionDTO> options) {
Map<String, String> labelMap = new LinkedHashMap<>();
for (DictOptionDTO option : options) {
if (option == null || isBlank(option.getValue()) || isBlank(option.getLabel())) {
continue;
}
labelMap.put(option.getValue().trim(), option.getLabel().trim());
}
return labelMap;
}
private String formatMultiValueDisplay(String rawValue, Map<String, String> labelMap) {
if (isBlank(rawValue)) {
return "无";
}
List<String> displayValues = new ArrayList<>();
String customText = null;
for (String token : rawValue.split(",")) {
String normalizedToken = token == null ? "" : token.trim();
if (normalizedToken.isEmpty()) {
continue;
}
if (normalizedToken.startsWith(MULTI_VALUE_CUSTOM_PREFIX)) {
customText = decodeCustomText(normalizedToken.substring(MULTI_VALUE_CUSTOM_PREFIX.length()));
continue;
}
String label = labelMap.getOrDefault(normalizedToken, normalizedToken);
if (!isBlank(customText) && isOtherOption(normalizedToken, label)) {
displayValues.add(label + "" + customText + "");
customText = null;
} else {
displayValues.add(label);
}
}
if (!isBlank(customText)) {
displayValues.add(customText);
}
LinkedHashSet<String> uniqueValues = new LinkedHashSet<>(displayValues);
return uniqueValues.isEmpty() ? "无" : String.join("、", uniqueValues);
}
private String decodeCustomText(String rawValue) {
if (isBlank(rawValue)) {
return "";
}
return URLDecoder.decode(rawValue, StandardCharsets.UTF_8);
}
private boolean isOtherOption(String value, String label) {
String candidate = (Objects.toString(value, "") + Objects.toString(label, "")).toLowerCase();
return candidate.contains("其他") || candidate.contains("其它") || candidate.contains("other");
}
private String normalizeKeyword(String keyword) {
if (keyword == null) {
return null;
@ -325,6 +405,16 @@ public class ExpansionServiceImpl implements ExpansionService {
private void fillChannelDefaults(CreateChannelExpansionRequest request) {
request.setChannelName(normalizeRequiredText(request.getChannelName(), "渠道名称不能为空"));
request.setProvince(normalizeRequiredText(request.getProvince(), "请填写省份"));
request.setOfficeAddress(normalizeRequiredText(request.getOfficeAddress(), "请填写办公地址"));
request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请填写聚焦行业"));
request.setAnnualRevenue(requirePositiveAmount(request.getAnnualRevenue(), "请填写年营收"));
request.setStaffSize(requirePositiveInteger(request.getStaffSize(), "请填写人员规模"));
if (request.getContactEstablishedDate() == null) {
throw new BusinessException("请选择建立联系时间");
}
request.setChannelAttribute(normalizeRequiredText(request.getChannelAttribute(), "请选择渠道属性"));
request.setInternalAttribute(normalizeRequiredText(request.getInternalAttribute(), "请选择新华三内部属性"));
if (isBlank(request.getStage())) {
request.setStage("initial_contact");
}
@ -337,11 +427,21 @@ public class ExpansionServiceImpl implements ExpansionService {
if (request.getHasDesktopExp() == null) {
request.setHasDesktopExp(Boolean.FALSE);
}
request.setContacts(normalizeContacts(request.getContacts()));
request.setContacts(normalizeRequiredContacts(request.getContacts()));
}
private void fillChannelDefaults(UpdateChannelExpansionRequest request) {
request.setChannelName(normalizeRequiredText(request.getChannelName(), "渠道名称不能为空"));
request.setProvince(normalizeRequiredText(request.getProvince(), "请填写省份"));
request.setOfficeAddress(normalizeRequiredText(request.getOfficeAddress(), "请填写办公地址"));
request.setChannelIndustry(normalizeRequiredText(request.getChannelIndustry(), "请填写聚焦行业"));
request.setAnnualRevenue(requirePositiveAmount(request.getAnnualRevenue(), "请填写年营收"));
request.setStaffSize(requirePositiveInteger(request.getStaffSize(), "请填写人员规模"));
if (request.getContactEstablishedDate() == null) {
throw new BusinessException("请选择建立联系时间");
}
request.setChannelAttribute(normalizeRequiredText(request.getChannelAttribute(), "请选择渠道属性"));
request.setInternalAttribute(normalizeRequiredText(request.getInternalAttribute(), "请选择新华三内部属性"));
if (isBlank(request.getStage())) {
request.setStage("initial_contact");
}
@ -354,7 +454,7 @@ public class ExpansionServiceImpl implements ExpansionService {
if (request.getHasDesktopExp() == null) {
request.setHasDesktopExp(Boolean.FALSE);
}
request.setContacts(normalizeContacts(request.getContacts()));
request.setContacts(normalizeRequiredContacts(request.getContacts()));
}
private List<ChannelExpansionContactRequest> normalizeContacts(List<ChannelExpansionContactRequest> contacts) {
@ -368,6 +468,37 @@ public class ExpansionServiceImpl implements ExpansionService {
.toList();
}
private List<ChannelExpansionContactRequest> normalizeRequiredContacts(List<ChannelExpansionContactRequest> contacts) {
if (contacts == null || contacts.isEmpty()) {
throw new BusinessException("请至少填写一位渠道联系人");
}
List<ChannelExpansionContactRequest> normalized = new ArrayList<>();
for (ChannelExpansionContactRequest contact : contacts) {
if (contact == null) {
continue;
}
String name = trimToNull(contact.getName());
String mobile = trimToNull(contact.getMobile());
String title = trimToNull(contact.getTitle());
if (name == null && mobile == null && title == null) {
continue;
}
if (name == null || mobile == null || title == null) {
throw new BusinessException("请完整填写渠道联系人的姓名、联系电话和职位");
}
contact.setName(name);
contact.setMobile(mobile);
contact.setTitle(title);
normalized.add(contact);
}
if (normalized.isEmpty()) {
throw new BusinessException("请至少填写一位渠道联系人");
}
return normalized;
}
private ChannelExpansionContactRequest normalizeContact(ChannelExpansionContactRequest contact) {
if (contact == null) {
return null;
@ -403,6 +534,20 @@ public class ExpansionServiceImpl implements ExpansionService {
return trimmed.isEmpty() ? null : trimmed;
}
private BigDecimal requirePositiveAmount(BigDecimal value, String message) {
if (value == null || value.compareTo(BigDecimal.ZERO) <= 0) {
throw new BusinessException(message);
}
return value;
}
private Integer requirePositiveInteger(Integer value, String message) {
if (value == null || value <= 0) {
throw new BusinessException(message);
}
return value;
}
private String normalizeBizType(String bizType) {
if ("sales".equalsIgnoreCase(bizType)) {
return "sales";

View File

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

View File

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

View File

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

View File

@ -0,0 +1,103 @@
# 全新环境部署说明
## 目标
本仓库已经把 CRM 自有业务表整理为单一入口脚本:
- `sql/init_full_pg17.sql`
后续部署 CRM 表时,优先只执行这一份脚本即可。历史迁移脚本已归档到:
- `sql/archive/`
## 执行顺序
### 1. 准备基础环境
需要先准备:
- PostgreSQL 17
- Redis
- Java 17+
- Maven 3.9+
### 2. 先初始化基础框架UnisBase表和基础数据
这一步不在本仓库维护,需要先由基础框架提供方执行。
当前 CRM 代码运行时会依赖下列基础表或基础数据:
- `sys_user`
- `sys_org`
- `sys_dict_item`
- `sys_tenant_user`
- `sys_role`
- `sys_user_role`
- `sys_param`
- `sys_log`
- `device`
如果这些表或基础数据未准备完成CRM 即使业务表建好了,也会在登录、字典加载、组织信息、角色信息等链路上报错。
### 3. 执行 CRM 单文件初始化脚本
```bash
psql -h 127.0.0.1 -U postgres -d nex_auth -f sql/init_full_pg17.sql
```
说明:
- 这份脚本既适合空库初始化,也兼容大部分旧环境补齐结构
- 它已经吸收了仓库里原来的 DDL 迁移脚本
- `sql/archive/` 中的旧脚本仅保留作历史追溯,正常部署不再单独执行
### 4. 初始化完成后建议检查的 CRM 表
建议至少确认以下表已存在:
- `crm_customer`
- `crm_opportunity`
- `crm_opportunity_followup`
- `crm_sales_expansion`
- `crm_channel_expansion`
- `crm_channel_expansion_contact`
- `crm_expansion_followup`
- `work_checkin`
- `work_daily_report`
- `work_daily_report_comment`
- `work_todo`
- `sys_activity_log`
### 5. 启动后端服务
```bash
cd backend
mvn spring-boot:run
```
默认端口:
- `8080`
### 6. 启动前端并验证
建议至少验证以下链路:
- 登录
- 首页统计、待办、动态
- 拓展列表与详情
- 商机列表与详情
- 工作台打卡、日报、历史记录
## 升级已有环境时的建议
如果不是全新环境,而是已有库升级:
1. 先备份数据库
2. 直接执行 `sql/init_full_pg17.sql`
3. 验证登录、字典、组织、日报、商机、拓展几条主链路
## 当前目录约定
- `sql/init_full_pg17.sql`:唯一部署入口脚本
- `sql/archive/`:历史迁移与修数脚本归档目录

View File

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

View File

@ -113,6 +113,12 @@ select {
font-size: 0.875rem;
line-height: 1.4;
font-weight: 600;
transition:
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.crm-btn-sm {
@ -122,6 +128,125 @@ select {
font-size: 0.875rem;
line-height: 1.4;
font-weight: 500;
transition:
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.crm-btn-primary {
background: #7c3aed;
color: #fff;
box-shadow: 0 10px 22px rgba(124, 58, 237, 0.2);
}
.crm-btn-primary:hover {
background: #6d28d9;
}
.crm-btn-secondary {
border: 1px solid #cbd5e1;
background: rgba(255, 255, 255, 0.96);
color: #334155;
}
.crm-btn-secondary:hover {
border-color: #a5b4fc;
background: #f8fafc;
color: #4c1d95;
}
.crm-btn-success {
background: #059669;
color: #fff;
box-shadow: 0 10px 22px rgba(5, 150, 105, 0.18);
}
.crm-btn-success:hover {
background: #047857;
}
.crm-btn-danger {
border: 1px solid #fecdd3;
background: #fff1f2;
color: #e11d48;
}
.crm-btn-danger:hover {
background: #ffe4e6;
color: #be123c;
}
.crm-btn-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border-radius: 9999px;
border: 1px solid #ddd6fe;
background: #f5f3ff;
color: #7c3aed;
}
.crm-btn-chip:hover {
border-color: #c4b5fd;
background: #ede9fe;
}
.crm-icon-sm,
.crm-icon-md,
.crm-icon-lg {
flex-shrink: 0;
stroke-width: 1.85;
}
.crm-icon-sm {
width: 0.875rem;
height: 0.875rem;
}
.crm-icon-md {
width: 1rem;
height: 1rem;
}
.crm-icon-lg {
width: 1.25rem;
height: 1.25rem;
}
.crm-tone-neutral,
.crm-tone-brand,
.crm-tone-success,
.crm-tone-warning,
.crm-tone-danger {
border-radius: 1rem;
}
.crm-tone-neutral {
background: #f1f5f9;
color: #64748b;
}
.crm-tone-brand {
background: #f5f3ff;
color: #7c3aed;
}
.crm-tone-success {
background: #ecfdf5;
color: #059669;
}
.crm-tone-warning {
background: #fffbeb;
color: #d97706;
}
.crm-tone-danger {
background: #fff1f2;
color: #e11d48;
}
.crm-card {
@ -149,6 +274,73 @@ select {
background: rgba(30, 41, 59, 0.38);
}
.dark .crm-btn-primary {
box-shadow: 0 12px 24px rgba(109, 40, 217, 0.24);
}
.dark .crm-btn-secondary {
border-color: rgba(51, 65, 85, 0.9);
background: rgba(15, 23, 42, 0.72);
color: #cbd5e1;
}
.dark .crm-btn-secondary:hover {
border-color: rgba(139, 92, 246, 0.5);
background: rgba(30, 41, 59, 0.9);
color: #ddd6fe;
}
.dark .crm-btn-success {
box-shadow: 0 12px 24px rgba(5, 150, 105, 0.2);
}
.dark .crm-btn-danger {
border-color: rgba(190, 24, 93, 0.35);
background: rgba(244, 63, 94, 0.12);
color: #fda4af;
}
.dark .crm-btn-danger:hover {
background: rgba(244, 63, 94, 0.18);
color: #fecdd3;
}
.dark .crm-btn-chip {
border-color: rgba(139, 92, 246, 0.22);
background: rgba(139, 92, 246, 0.14);
color: #c4b5fd;
}
.dark .crm-btn-chip:hover {
border-color: rgba(139, 92, 246, 0.35);
background: rgba(139, 92, 246, 0.2);
}
.dark .crm-tone-neutral {
background: rgba(51, 65, 85, 0.85);
color: #cbd5e1;
}
.dark .crm-tone-brand {
background: rgba(139, 92, 246, 0.14);
color: #c4b5fd;
}
.dark .crm-tone-success {
background: rgba(16, 185, 129, 0.14);
color: #6ee7b7;
}
.dark .crm-tone-warning {
background: rgba(245, 158, 11, 0.14);
color: #fcd34d;
}
.dark .crm-tone-danger {
background: rgba(244, 63, 94, 0.14);
color: #fda4af;
}
.crm-pill {
display: inline-flex;
align-items: center;
@ -215,6 +407,35 @@ select {
gap: 1.25rem;
}
.crm-page-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.crm-page-heading {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.25rem;
}
.crm-page-title {
font-size: 1.25rem;
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.02em;
color: #0f172a;
}
.crm-page-subtitle {
font-size: 0.8125rem;
line-height: 1.6;
color: #64748b;
}
.crm-section-stack {
display: flex;
flex-direction: column;
@ -235,6 +456,50 @@ select {
padding: 1.25rem;
}
.crm-filter-bar {
border-radius: 1rem;
border: 1px solid #e2e8f0;
background: rgba(248, 250, 252, 0.82);
padding: 0.25rem;
}
.crm-empty-panel {
border-radius: 1rem;
border: 1px dashed #cbd5e1;
background: rgba(248, 250, 252, 0.72);
padding: 1rem;
text-align: center;
font-size: 0.875rem;
line-height: 1.6;
color: #94a3b8;
}
.crm-alert {
border-radius: 1rem;
border: 1px solid #e2e8f0;
padding: 0.875rem 1rem;
font-size: 0.875rem;
line-height: 1.6;
}
.crm-alert-error {
border-color: #fecdd3;
background: #fff1f2;
color: #e11d48;
}
.crm-alert-success {
border-color: #bbf7d0;
background: #ecfdf5;
color: #059669;
}
.crm-alert-info {
border-color: #cbd5e1;
background: rgba(248, 250, 252, 0.78);
color: #64748b;
}
.crm-modal-stack {
display: flex;
flex-direction: column;
@ -247,6 +512,10 @@ select {
gap: 1rem;
}
.crm-form-grid > * {
min-width: 0;
}
.crm-form-section {
display: flex;
flex-direction: column;
@ -305,6 +574,47 @@ select {
background: rgba(30, 41, 59, 0.38);
}
.dark .crm-page-title {
color: #f8fafc;
}
.dark .crm-page-subtitle {
color: #94a3b8;
}
.dark .crm-filter-bar {
border-color: rgba(51, 65, 85, 0.8);
background: rgba(15, 23, 42, 0.46);
}
.dark .crm-empty-panel {
border-color: rgba(51, 65, 85, 0.9);
background: rgba(30, 41, 59, 0.34);
color: #64748b;
}
.dark .crm-alert {
border-color: rgba(51, 65, 85, 0.8);
}
.dark .crm-alert-error {
border-color: rgba(190, 24, 93, 0.35);
background: rgba(244, 63, 94, 0.12);
color: #fda4af;
}
.dark .crm-alert-success {
border-color: rgba(5, 150, 105, 0.35);
background: rgba(16, 185, 129, 0.12);
color: #6ee7b7;
}
.dark .crm-alert-info {
border-color: rgba(51, 65, 85, 0.8);
background: rgba(30, 41, 59, 0.38);
color: #94a3b8;
}
.dark .crm-detail-label {
color: #94a3b8;
}
@ -318,6 +628,18 @@ select {
gap: 1.5rem;
}
.crm-page-header {
gap: 1rem;
}
.crm-page-title {
font-size: 1.5rem;
}
.crm-page-subtitle {
font-size: 0.875rem;
}
.crm-section-stack {
gap: 1.25rem;
}
@ -356,6 +678,14 @@ select {
opacity: 1;
}
input[type="date"].crm-input-text {
min-width: 0;
width: 100%;
max-width: 100%;
-webkit-appearance: none;
appearance: none;
}
.dark .crm-input-text::placeholder,
.dark input::placeholder,
.dark textarea::placeholder {

View File

@ -243,6 +243,7 @@ export interface OpportunityItem {
stageCode?: string;
stage?: string;
type?: string;
archived?: boolean;
pushedToOms?: boolean;
product?: string;
source?: string;
@ -363,7 +364,9 @@ export interface ChannelExpansionItem {
intentLevel?: string;
intent?: string;
hasDesktopExp?: boolean;
channelAttributeCode?: string;
channelAttribute?: string;
internalAttributeCode?: string;
internalAttribute?: string;
stageCode?: string;
stage?: string;
@ -436,8 +439,9 @@ export interface CreateChannelExpansionPayload {
contactEstablishedDate?: string;
intentLevel?: string;
hasDesktopExp?: boolean;
channelAttribute?: string;
internalAttribute?: string;
channelAttribute?: string[];
channelAttributeCustom?: string;
internalAttribute?: string[];
stage?: string;
remark?: string;
contacts?: ChannelExpansionContact[];
@ -447,6 +451,56 @@ export interface UpdateSalesExpansionPayload extends CreateSalesExpansionPayload
export interface UpdateChannelExpansionPayload extends CreateChannelExpansionPayload {}
const EXPANSION_MULTI_VALUE_CUSTOM_PREFIX = "__custom__:";
function normalizeExpansionMultiValues(values?: string[]) {
return Array.from(new Set((values ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value))));
}
export function encodeExpansionMultiValue(values?: string[], customText?: string) {
const normalizedValues = normalizeExpansionMultiValues(values);
const normalizedCustomText = customText?.trim();
if (normalizedCustomText) {
normalizedValues.push(`${EXPANSION_MULTI_VALUE_CUSTOM_PREFIX}${encodeURIComponent(normalizedCustomText)}`);
}
return normalizedValues.join(",");
}
export function decodeExpansionMultiValue(rawValue?: string) {
const result = {
values: [] as string[],
customText: "",
};
if (!rawValue?.trim()) {
return result;
}
rawValue
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.forEach((item) => {
if (item.startsWith(EXPANSION_MULTI_VALUE_CUSTOM_PREFIX)) {
result.customText = decodeURIComponent(item.slice(EXPANSION_MULTI_VALUE_CUSTOM_PREFIX.length));
return;
}
result.values.push(item);
});
result.values = Array.from(new Set(result.values));
return result;
}
function serializeChannelExpansionPayload(payload: CreateChannelExpansionPayload) {
const { channelAttributeCustom, ...rest } = payload;
return {
...rest,
channelAttribute: encodeExpansionMultiValue(payload.channelAttribute, channelAttributeCustom),
internalAttribute: encodeExpansionMultiValue(payload.internalAttribute),
};
}
export interface CreateExpansionFollowUpPayload {
followUpType: string;
content: string;
@ -792,7 +846,7 @@ export async function createSalesExpansion(payload: CreateSalesExpansionPayload)
export async function createChannelExpansion(payload: CreateChannelExpansionPayload) {
return request<number>("/api/expansion/channel", {
method: "POST",
body: JSON.stringify(payload),
body: JSON.stringify(serializeChannelExpansionPayload(payload)),
}, true);
}
@ -806,7 +860,7 @@ export async function updateSalesExpansion(id: number, payload: UpdateSalesExpan
export async function updateChannelExpansion(id: number, payload: UpdateChannelExpansionPayload) {
return request<void>(`/api/expansion/channel/${id}`, {
method: "PUT",
body: JSON.stringify(payload),
body: JSON.stringify(serializeChannelExpansionPayload(payload)),
}, true);
}

View File

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

View File

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

View File

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

View File

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

View File

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

59
sql/README.md 100644
View File

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

View File

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

View File

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

View File

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

View File

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