工号与渠道名称校验重复
parent
efd3370519
commit
7080391f3a
|
|
@ -0,0 +1,109 @@
|
||||||
|
package com.unis.crm.common;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class ExpansionSchemaInitializer implements ApplicationRunner {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(ExpansionSchemaInitializer.class);
|
||||||
|
private static final String CHANNEL_CODE_SEQUENCE = "crm_channel_expansion_code_seq";
|
||||||
|
|
||||||
|
private final DataSource dataSource;
|
||||||
|
|
||||||
|
public ExpansionSchemaInitializer(DataSource dataSource) {
|
||||||
|
this.dataSource = dataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
try (Connection connection = dataSource.getConnection()) {
|
||||||
|
if (!tableExists(connection, "crm_channel_expansion")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureChannelCodeSequence(connection);
|
||||||
|
ensureChannelCodeUniqueIndex(connection);
|
||||||
|
log.info("Ensured channel expansion sequence compatibility");
|
||||||
|
} catch (SQLException exception) {
|
||||||
|
throw new IllegalStateException("Failed to initialize crm_channel_expansion schema compatibility", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureChannelCodeSequence(Connection connection) throws SQLException {
|
||||||
|
try (Statement statement = connection.createStatement()) {
|
||||||
|
statement.execute("create sequence if not exists " + CHANNEL_CODE_SEQUENCE + " start with 1 increment by 1 minvalue 1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureChannelCodeUniqueIndex(Connection connection) throws SQLException {
|
||||||
|
if (indexExists(connection, "uk_crm_channel_expansion_code")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasDuplicateChannelCodes(connection)) {
|
||||||
|
log.warn("Skip creating unique index uk_crm_channel_expansion_code because duplicate channel_code data already exists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (Statement statement = connection.createStatement()) {
|
||||||
|
statement.execute("""
|
||||||
|
create unique index if not exists uk_crm_channel_expansion_code
|
||||||
|
on crm_channel_expansion(channel_code)
|
||||||
|
where channel_code is not null
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasDuplicateChannelCodes(Connection connection) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
select 1
|
||||||
|
from crm_channel_expansion
|
||||||
|
where channel_code is not null
|
||||||
|
and btrim(channel_code) <> ''
|
||||||
|
group by channel_code
|
||||||
|
having count(1) > 1
|
||||||
|
limit 1
|
||||||
|
""";
|
||||||
|
try (Statement statement = connection.createStatement();
|
||||||
|
ResultSet resultSet = statement.executeQuery(sql)) {
|
||||||
|
return resultSet.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean indexExists(Connection connection, String indexName) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
select 1
|
||||||
|
from pg_indexes
|
||||||
|
where schemaname = current_schema()
|
||||||
|
and indexname = ?
|
||||||
|
limit 1
|
||||||
|
""";
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
|
statement.setString(1, indexName);
|
||||||
|
try (ResultSet resultSet = statement.executeQuery()) {
|
||||||
|
return resultSet.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean tableExists(Connection connection, String tableName) throws SQLException {
|
||||||
|
if (existsInSchema(connection, null, tableName)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return existsInSchema(connection, "public", tableName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean existsInSchema(Connection connection, String schemaName, String tableName) throws SQLException {
|
||||||
|
try (ResultSet resultSet = connection.getMetaData()
|
||||||
|
.getTables(connection.getCatalog(), schemaName, tableName, new String[]{"TABLE"})) {
|
||||||
|
return resultSet.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
|
||||||
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
|
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
|
||||||
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
|
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
|
||||||
import com.unis.crm.dto.expansion.DictOptionDTO;
|
import com.unis.crm.dto.expansion.DictOptionDTO;
|
||||||
|
import com.unis.crm.dto.expansion.ExpansionDuplicateCheckDTO;
|
||||||
import com.unis.crm.dto.expansion.ExpansionMetaDTO;
|
import com.unis.crm.dto.expansion.ExpansionMetaDTO;
|
||||||
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
|
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
|
||||||
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
|
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
|
||||||
|
|
@ -21,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestHeader;
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMethod;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
|
|
@ -54,6 +56,24 @@ public class ExpansionController {
|
||||||
return ApiResponse.success(expansionService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword));
|
return ApiResponse.success(expansionService.getOverview(CurrentUserUtils.requireCurrentUserId(userId), keyword));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequestMapping(value = "/sales/duplicate-check", method = {RequestMethod.GET, RequestMethod.POST})
|
||||||
|
public ApiResponse<ExpansionDuplicateCheckDTO> checkSalesDuplicate(
|
||||||
|
@RequestHeader("X-User-Id") Long userId,
|
||||||
|
@RequestParam("employeeNo") String employeeNo,
|
||||||
|
@RequestParam(value = "excludeId", required = false) Long excludeId) {
|
||||||
|
return ApiResponse.success(expansionService.checkSalesEmployeeNoDuplicate(
|
||||||
|
CurrentUserUtils.requireCurrentUserId(userId), employeeNo, excludeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequestMapping(value = "/channel/duplicate-check", method = {RequestMethod.GET, RequestMethod.POST})
|
||||||
|
public ApiResponse<ExpansionDuplicateCheckDTO> checkChannelDuplicate(
|
||||||
|
@RequestHeader("X-User-Id") Long userId,
|
||||||
|
@RequestParam("channelName") String channelName,
|
||||||
|
@RequestParam(value = "excludeId", required = false) Long excludeId) {
|
||||||
|
return ApiResponse.success(expansionService.checkChannelNameDuplicate(
|
||||||
|
CurrentUserUtils.requireCurrentUserId(userId), channelName, excludeId));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/sales")
|
@PostMapping("/sales")
|
||||||
public ApiResponse<Long> createSales(
|
public ApiResponse<Long> createSales(
|
||||||
@RequestHeader("X-User-Id") Long userId,
|
@RequestHeader("X-User-Id") Long userId,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package com.unis.crm.dto.expansion;
|
||||||
|
|
||||||
|
public class ExpansionDuplicateCheckDTO {
|
||||||
|
|
||||||
|
private boolean duplicated;
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
public ExpansionDuplicateCheckDTO() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExpansionDuplicateCheckDTO(boolean duplicated, String message) {
|
||||||
|
this.duplicated = duplicated;
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDuplicated() {
|
||||||
|
return duplicated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDuplicated(boolean duplicated) {
|
||||||
|
this.duplicated = duplicated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessage() {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessage(String message) {
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -64,13 +64,18 @@ public interface ExpansionMapper {
|
||||||
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
|
@DataScope(tableAlias = "s", ownerColumn = "owner_user_id")
|
||||||
int updateSalesExpansion(@Param("userId") Long userId, @Param("id") Long id, @Param("request") UpdateSalesExpansionRequest request);
|
int updateSalesExpansion(@Param("userId") Long userId, @Param("id") Long id, @Param("request") UpdateSalesExpansionRequest request);
|
||||||
|
|
||||||
int countSalesExpansionByEmployeeNo(@Param("userId") Long userId, @Param("employeeNo") String employeeNo);
|
int countSalesExpansionByEmployeeNo(@Param("employeeNo") String employeeNo);
|
||||||
|
|
||||||
int countSalesExpansionByEmployeeNoExcludingId(
|
int countSalesExpansionByEmployeeNoExcludingId(
|
||||||
@Param("userId") Long userId,
|
|
||||||
@Param("employeeNo") String employeeNo,
|
@Param("employeeNo") String employeeNo,
|
||||||
@Param("excludeId") Long excludeId);
|
@Param("excludeId") Long excludeId);
|
||||||
|
|
||||||
|
int countChannelExpansionByChannelName(@Param("channelName") String channelName);
|
||||||
|
|
||||||
|
int countChannelExpansionByChannelNameExcludingId(
|
||||||
|
@Param("channelName") String channelName,
|
||||||
|
@Param("excludeId") Long excludeId);
|
||||||
|
|
||||||
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
|
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
|
||||||
int updateChannelExpansion(@Param("userId") Long userId, @Param("id") Long id, @Param("request") UpdateChannelExpansionRequest request);
|
int updateChannelExpansion(@Param("userId") Long userId, @Param("id") Long id, @Param("request") UpdateChannelExpansionRequest request);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
|
||||||
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
|
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
|
||||||
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
|
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
|
||||||
import com.unis.crm.dto.expansion.DictOptionDTO;
|
import com.unis.crm.dto.expansion.DictOptionDTO;
|
||||||
|
import com.unis.crm.dto.expansion.ExpansionDuplicateCheckDTO;
|
||||||
import com.unis.crm.dto.expansion.ExpansionMetaDTO;
|
import com.unis.crm.dto.expansion.ExpansionMetaDTO;
|
||||||
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
|
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
|
||||||
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
|
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
|
||||||
|
|
@ -18,6 +19,10 @@ public interface ExpansionService {
|
||||||
|
|
||||||
ExpansionOverviewDTO getOverview(Long userId, String keyword);
|
ExpansionOverviewDTO getOverview(Long userId, String keyword);
|
||||||
|
|
||||||
|
ExpansionDuplicateCheckDTO checkSalesEmployeeNoDuplicate(Long userId, String employeeNo, Long excludeId);
|
||||||
|
|
||||||
|
ExpansionDuplicateCheckDTO checkChannelNameDuplicate(Long userId, String channelName, Long excludeId);
|
||||||
|
|
||||||
Long createSalesExpansion(Long userId, CreateSalesExpansionRequest request);
|
Long createSalesExpansion(Long userId, CreateSalesExpansionRequest request);
|
||||||
|
|
||||||
Long createChannelExpansion(Long userId, CreateChannelExpansionRequest request);
|
Long createChannelExpansion(Long userId, CreateChannelExpansionRequest request);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
|
||||||
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
|
import com.unis.crm.dto.expansion.CreateExpansionFollowUpRequest;
|
||||||
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
|
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
|
||||||
import com.unis.crm.dto.expansion.DictOptionDTO;
|
import com.unis.crm.dto.expansion.DictOptionDTO;
|
||||||
|
import com.unis.crm.dto.expansion.ExpansionDuplicateCheckDTO;
|
||||||
import com.unis.crm.dto.expansion.ExpansionMetaDTO;
|
import com.unis.crm.dto.expansion.ExpansionMetaDTO;
|
||||||
import com.unis.crm.dto.expansion.ExpansionFollowUpDTO;
|
import com.unis.crm.dto.expansion.ExpansionFollowUpDTO;
|
||||||
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
|
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
|
||||||
|
|
@ -32,10 +33,13 @@ import java.util.stream.Collectors;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ExpansionServiceImpl implements ExpansionService {
|
public class ExpansionServiceImpl implements ExpansionService {
|
||||||
|
|
||||||
|
private static final String SALES_DUPLICATE_MESSAGE = "工号重复,请确认该人员是否已存在!";
|
||||||
|
private static final String CHANNEL_DUPLICATE_MESSAGE = "渠道重复,请确认该渠道是否已存在!";
|
||||||
private static final String OFFICE_TYPE_CODE = "tz_bsc";
|
private static final String OFFICE_TYPE_CODE = "tz_bsc";
|
||||||
private static final String INDUSTRY_TYPE_CODE = "tz_sshy";
|
private static final String INDUSTRY_TYPE_CODE = "tz_sshy";
|
||||||
private static final String CERTIFICATION_LEVEL_TYPE_CODE = "tz_rzjb";
|
private static final String CERTIFICATION_LEVEL_TYPE_CODE = "tz_rzjb";
|
||||||
|
|
@ -99,6 +103,32 @@ public class ExpansionServiceImpl implements ExpansionService {
|
||||||
return new ExpansionOverviewDTO(salesItems, channelItems);
|
return new ExpansionOverviewDTO(salesItems, channelItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ExpansionDuplicateCheckDTO checkSalesEmployeeNoDuplicate(Long userId, String employeeNo, Long excludeId) {
|
||||||
|
String normalizedEmployeeNo = trimToNull(employeeNo);
|
||||||
|
if (normalizedEmployeeNo == null) {
|
||||||
|
return new ExpansionDuplicateCheckDTO(false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
int count = excludeId == null
|
||||||
|
? expansionMapper.countSalesExpansionByEmployeeNo(normalizedEmployeeNo)
|
||||||
|
: expansionMapper.countSalesExpansionByEmployeeNoExcludingId(normalizedEmployeeNo, excludeId);
|
||||||
|
return new ExpansionDuplicateCheckDTO(count > 0, count > 0 ? SALES_DUPLICATE_MESSAGE : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ExpansionDuplicateCheckDTO checkChannelNameDuplicate(Long userId, String channelName, Long excludeId) {
|
||||||
|
String normalizedChannelName = trimToNull(channelName);
|
||||||
|
if (normalizedChannelName == null) {
|
||||||
|
return new ExpansionDuplicateCheckDTO(false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
int count = excludeId == null
|
||||||
|
? expansionMapper.countChannelExpansionByChannelName(normalizedChannelName)
|
||||||
|
: expansionMapper.countChannelExpansionByChannelNameExcludingId(normalizedChannelName, excludeId);
|
||||||
|
return new ExpansionDuplicateCheckDTO(count > 0, count > 0 ? CHANNEL_DUPLICATE_MESSAGE : null);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Long createSalesExpansion(Long userId, CreateSalesExpansionRequest request) {
|
public Long createSalesExpansion(Long userId, CreateSalesExpansionRequest request) {
|
||||||
fillSalesDefaults(request);
|
fillSalesDefaults(request);
|
||||||
|
|
@ -111,8 +141,10 @@ public class ExpansionServiceImpl implements ExpansionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional
|
||||||
public Long createChannelExpansion(Long userId, CreateChannelExpansionRequest request) {
|
public Long createChannelExpansion(Long userId, CreateChannelExpansionRequest request) {
|
||||||
fillChannelDefaults(request);
|
fillChannelDefaults(request);
|
||||||
|
ensureUniqueChannelName(userId, request.getChannelName(), null);
|
||||||
expansionMapper.insertChannelExpansion(userId, request);
|
expansionMapper.insertChannelExpansion(userId, request);
|
||||||
if (request.getId() == null) {
|
if (request.getId() == null) {
|
||||||
throw new BusinessException("渠道拓展新增失败");
|
throw new BusinessException("渠道拓展新增失败");
|
||||||
|
|
@ -132,8 +164,10 @@ public class ExpansionServiceImpl implements ExpansionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional
|
||||||
public void updateChannelExpansion(Long userId, Long id, UpdateChannelExpansionRequest request) {
|
public void updateChannelExpansion(Long userId, Long id, UpdateChannelExpansionRequest request) {
|
||||||
fillChannelDefaults(request);
|
fillChannelDefaults(request);
|
||||||
|
ensureUniqueChannelName(userId, request.getChannelName(), id);
|
||||||
int updated = expansionMapper.updateChannelExpansion(userId, id, request);
|
int updated = expansionMapper.updateChannelExpansion(userId, id, request);
|
||||||
if (updated <= 0) {
|
if (updated <= 0) {
|
||||||
throw new BusinessException("未找到可编辑的渠道拓展记录");
|
throw new BusinessException("未找到可编辑的渠道拓展记录");
|
||||||
|
|
@ -380,10 +414,19 @@ public class ExpansionServiceImpl implements ExpansionService {
|
||||||
|
|
||||||
private void ensureUniqueEmployeeNo(Long userId, String employeeNo, Long excludeId) {
|
private void ensureUniqueEmployeeNo(Long userId, String employeeNo, Long excludeId) {
|
||||||
int count = excludeId == null
|
int count = excludeId == null
|
||||||
? expansionMapper.countSalesExpansionByEmployeeNo(userId, employeeNo)
|
? expansionMapper.countSalesExpansionByEmployeeNo(employeeNo)
|
||||||
: expansionMapper.countSalesExpansionByEmployeeNoExcludingId(userId, employeeNo, excludeId);
|
: expansionMapper.countSalesExpansionByEmployeeNoExcludingId(employeeNo, excludeId);
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
throw new BusinessException("该工号已存在,请检查后再提交");
|
throw new BusinessException(SALES_DUPLICATE_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureUniqueChannelName(Long userId, String channelName, Long excludeId) {
|
||||||
|
int count = excludeId == null
|
||||||
|
? expansionMapper.countChannelExpansionByChannelName(channelName)
|
||||||
|
: expansionMapper.countChannelExpansionByChannelNameExcludingId(channelName, excludeId);
|
||||||
|
if (count > 0) {
|
||||||
|
throw new BusinessException(CHANNEL_DUPLICATE_MESSAGE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,13 +42,12 @@
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="selectNextChannelCode" resultType="java.lang.String">
|
<select id="selectNextChannelCode" resultType="java.lang.String">
|
||||||
select 'QD-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad((
|
select 'QD-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad(
|
||||||
coalesce((
|
(case when seq.is_called then seq.last_value + 1 else seq.last_value end)::text,
|
||||||
select count(1)
|
6,
|
||||||
from crm_channel_expansion
|
'0'
|
||||||
where created_at::date = current_date
|
)
|
||||||
), 0) + 1
|
from crm_channel_expansion_code_seq seq
|
||||||
)::text, 3, '0')
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="selectSalesExpansions" resultType="com.unis.crm.dto.expansion.SalesExpansionItemDTO">
|
<select id="selectSalesExpansions" resultType="com.unis.crm.dto.expansion.SalesExpansionItemDTO">
|
||||||
|
|
@ -380,13 +379,7 @@
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
remark
|
remark
|
||||||
) values (
|
) values (
|
||||||
'QD-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad((
|
'QD-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad(nextval('crm_channel_expansion_code_seq')::text, 6, '0'),
|
||||||
coalesce((
|
|
||||||
select count(1)
|
|
||||||
from crm_channel_expansion
|
|
||||||
where created_at::date = current_date
|
|
||||||
), 0) + 1
|
|
||||||
)::text, 3, '0'),
|
|
||||||
#{request.province},
|
#{request.province},
|
||||||
#{request.city},
|
#{request.city},
|
||||||
#{request.channelName},
|
#{request.channelName},
|
||||||
|
|
@ -459,15 +452,26 @@
|
||||||
<select id="countSalesExpansionByEmployeeNo" resultType="int">
|
<select id="countSalesExpansionByEmployeeNo" resultType="int">
|
||||||
select count(1)
|
select count(1)
|
||||||
from crm_sales_expansion
|
from crm_sales_expansion
|
||||||
where owner_user_id = #{userId}
|
where employee_no = #{employeeNo}
|
||||||
and employee_no = #{employeeNo}
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="countSalesExpansionByEmployeeNoExcludingId" resultType="int">
|
<select id="countSalesExpansionByEmployeeNoExcludingId" resultType="int">
|
||||||
select count(1)
|
select count(1)
|
||||||
from crm_sales_expansion
|
from crm_sales_expansion
|
||||||
where owner_user_id = #{userId}
|
where employee_no = #{employeeNo}
|
||||||
and employee_no = #{employeeNo}
|
and id <> #{excludeId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countChannelExpansionByChannelName" resultType="int">
|
||||||
|
select count(1)
|
||||||
|
from crm_channel_expansion
|
||||||
|
where channel_name = #{channelName}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countChannelExpansionByChannelNameExcludingId" resultType="int">
|
||||||
|
select count(1)
|
||||||
|
from crm_channel_expansion
|
||||||
|
where channel_name = #{channelName}
|
||||||
and id <> #{excludeId}
|
and id <> #{excludeId}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,80 +1,86 @@
|
||||||
{
|
{
|
||||||
"hash": "9b703341",
|
"hash": "4ed97220",
|
||||||
"configHash": "4d48f89c",
|
"configHash": "4d48f89c",
|
||||||
"lockfileHash": "446f7b50",
|
"lockfileHash": "25980767",
|
||||||
"browserHash": "97389217",
|
"browserHash": "0ac37406",
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"react": {
|
"react": {
|
||||||
"src": "../../react/index.js",
|
"src": "../../react/index.js",
|
||||||
"file": "react.js",
|
"file": "react.js",
|
||||||
"fileHash": "0e15d179",
|
"fileHash": "5499e597",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react-dom": {
|
"react-dom": {
|
||||||
"src": "../../react-dom/index.js",
|
"src": "../../react-dom/index.js",
|
||||||
"file": "react-dom.js",
|
"file": "react-dom.js",
|
||||||
"fileHash": "56e0ac1a",
|
"fileHash": "ee512445",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react/jsx-dev-runtime": {
|
"react/jsx-dev-runtime": {
|
||||||
"src": "../../react/jsx-dev-runtime.js",
|
"src": "../../react/jsx-dev-runtime.js",
|
||||||
"file": "react_jsx-dev-runtime.js",
|
"file": "react_jsx-dev-runtime.js",
|
||||||
"fileHash": "3873dbfb",
|
"fileHash": "b416d357",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react/jsx-runtime": {
|
"react/jsx-runtime": {
|
||||||
"src": "../../react/jsx-runtime.js",
|
"src": "../../react/jsx-runtime.js",
|
||||||
"file": "react_jsx-runtime.js",
|
"file": "react_jsx-runtime.js",
|
||||||
"fileHash": "36bae069",
|
"fileHash": "d2d6e3f0",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"clsx": {
|
"clsx": {
|
||||||
"src": "../../clsx/dist/clsx.mjs",
|
"src": "../../clsx/dist/clsx.mjs",
|
||||||
"file": "clsx.js",
|
"file": "clsx.js",
|
||||||
"fileHash": "ef2b5b9d",
|
"fileHash": "48844b26",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"date-fns": {
|
"date-fns": {
|
||||||
"src": "../../date-fns/index.js",
|
"src": "../../date-fns/index.js",
|
||||||
"file": "date-fns.js",
|
"file": "date-fns.js",
|
||||||
"fileHash": "b5d072ca",
|
"fileHash": "e0776950",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"lucide-react": {
|
"lucide-react": {
|
||||||
"src": "../../lucide-react/dist/esm/lucide-react.js",
|
"src": "../../lucide-react/dist/esm/lucide-react.js",
|
||||||
"file": "lucide-react.js",
|
"file": "lucide-react.js",
|
||||||
"fileHash": "938b715f",
|
"fileHash": "2b6331af",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"motion/react": {
|
"motion/react": {
|
||||||
"src": "../../motion/dist/es/react.mjs",
|
"src": "../../motion/dist/es/react.mjs",
|
||||||
"file": "motion_react.js",
|
"file": "motion_react.js",
|
||||||
"fileHash": "fb0e4ab1",
|
"fileHash": "6ad27c2c",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"react-dom/client": {
|
"react-dom/client": {
|
||||||
"src": "../../react-dom/client.js",
|
"src": "../../react-dom/client.js",
|
||||||
"file": "react-dom_client.js",
|
"file": "react-dom_client.js",
|
||||||
"fileHash": "c4c41e0d",
|
"fileHash": "105dfe43",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"react-router-dom": {
|
"react-router-dom": {
|
||||||
"src": "../../react-router-dom/dist/index.mjs",
|
"src": "../../react-router-dom/dist/index.mjs",
|
||||||
"file": "react-router-dom.js",
|
"file": "react-router-dom.js",
|
||||||
"fileHash": "5254f612",
|
"fileHash": "8926882a",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"tailwind-merge": {
|
"tailwind-merge": {
|
||||||
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
|
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
|
||||||
"file": "tailwind-merge.js",
|
"file": "tailwind-merge.js",
|
||||||
"fileHash": "9fbd3db9",
|
"fileHash": "2c815557",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
},
|
},
|
||||||
"date-fns/locale": {
|
"date-fns/locale": {
|
||||||
"src": "../../date-fns/locale.js",
|
"src": "../../date-fns/locale.js",
|
||||||
"file": "date-fns_locale.js",
|
"file": "date-fns_locale.js",
|
||||||
"fileHash": "31cc6364",
|
"fileHash": "00bef214",
|
||||||
"needsInterop": false
|
"needsInterop": false
|
||||||
|
},
|
||||||
|
"exceljs": {
|
||||||
|
"src": "../../exceljs/dist/exceljs.min.js",
|
||||||
|
"file": "exceljs.js",
|
||||||
|
"fileHash": "7f44c50c",
|
||||||
|
"needsInterop": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chunks": {
|
"chunks": {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -17,6 +17,7 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
|
|
|
||||||
|
|
@ -443,6 +443,11 @@ export interface ExpansionMeta {
|
||||||
nextChannelCode?: string;
|
nextChannelCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExpansionDuplicateCheck {
|
||||||
|
duplicated?: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateSalesExpansionPayload {
|
export interface CreateSalesExpansionPayload {
|
||||||
employeeNo: string;
|
employeeNo: string;
|
||||||
candidateName: string;
|
candidateName: string;
|
||||||
|
|
@ -896,6 +901,22 @@ export async function getExpansionCityOptions(provinceName: string) {
|
||||||
return request<ExpansionDictOption[]>(`/api/expansion/areas/cities?${params.toString()}`, undefined, true);
|
return request<ExpansionDictOption[]>(`/api/expansion/areas/cities?${params.toString()}`, undefined, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function checkSalesExpansionDuplicate(employeeNo: string, excludeId?: number) {
|
||||||
|
const params = new URLSearchParams({ employeeNo });
|
||||||
|
if (excludeId) {
|
||||||
|
params.set("excludeId", String(excludeId));
|
||||||
|
}
|
||||||
|
return request<ExpansionDuplicateCheck>(`/api/expansion/sales/duplicate-check?${params.toString()}`, undefined, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkChannelExpansionDuplicate(channelName: string, excludeId?: number) {
|
||||||
|
const params = new URLSearchParams({ channelName });
|
||||||
|
if (excludeId) {
|
||||||
|
params.set("excludeId", String(excludeId));
|
||||||
|
}
|
||||||
|
return request<ExpansionDuplicateCheck>(`/api/expansion/channel/duplicate-check?${params.toString()}`, undefined, true);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSalesExpansion(payload: CreateSalesExpansionPayload) {
|
export async function createSalesExpansion(payload: CreateSalesExpansionPayload) {
|
||||||
return request<number>("/api/expansion/sales", {
|
return request<number>("/api/expansion/sales", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
import { Search, Plus, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar, ChevronRight } from "lucide-react";
|
import { Search, Plus, Download, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar, ChevronRight } from "lucide-react";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
checkChannelExpansionDuplicate,
|
||||||
|
checkSalesExpansionDuplicate,
|
||||||
createChannelExpansion,
|
createChannelExpansion,
|
||||||
createSalesExpansion,
|
createSalesExpansion,
|
||||||
decodeExpansionMultiValue,
|
decodeExpansionMultiValue,
|
||||||
|
|
@ -125,6 +127,202 @@ function getFieldInputClass(hasError: boolean) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeExportText(value?: string | number | boolean | null) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const normalized = String(value).replace(/\r?\n/g, " ").trim();
|
||||||
|
if (!normalized || normalized === "无") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExportBoolean(value?: boolean, trueLabel = "是", falseLabel = "否") {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value ? trueLabel : falseLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExportFollowUps(followUps?: ExpansionFollowUp[]) {
|
||||||
|
if (!followUps?.length) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return followUps
|
||||||
|
.map((followUp) => {
|
||||||
|
const summary = getExpansionFollowUpSummary(followUp);
|
||||||
|
const lines = [
|
||||||
|
[normalizeExportText(followUp.date), normalizeExportText(followUp.type)].filter(Boolean).join(" "),
|
||||||
|
normalizeExportText(summary.visitStartTime) ? `拜访时间:${normalizeExportText(summary.visitStartTime)}` : "",
|
||||||
|
normalizeExportText(summary.evaluationContent) ? `沟通内容:${normalizeExportText(summary.evaluationContent)}` : "",
|
||||||
|
normalizeExportText(summary.nextPlan) ? `后续规划:${normalizeExportText(summary.nextPlan)}` : "",
|
||||||
|
].filter(Boolean);
|
||||||
|
return lines.join("\n");
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExportFilenameTime(date = new Date()) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||||
|
return `${year}${month}${day}_${hours}${minutes}${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadExcelFile(filename: string, content: BlobPart) {
|
||||||
|
const blob = new Blob([content], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||||
|
const objectUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = objectUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSalesExportHeaders(items: SalesExpansionItem[]) {
|
||||||
|
const maxProjects = Math.max(0, ...items.map((item) => item.relatedProjects?.length ?? 0));
|
||||||
|
const headers = [
|
||||||
|
"工号",
|
||||||
|
"姓名",
|
||||||
|
"联系方式",
|
||||||
|
"代表处 / 办事处",
|
||||||
|
"所属部门",
|
||||||
|
"职务",
|
||||||
|
"所属行业",
|
||||||
|
"合作意向",
|
||||||
|
"销售是否在职",
|
||||||
|
"销售以前是否做过云桌面项目",
|
||||||
|
"跟进项目金额",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let index = 0; index < maxProjects; index += 1) {
|
||||||
|
headers.push(`项目${index + 1}编码`, `项目${index + 1}名称`, `项目${index + 1}金额`);
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.push("跟进记录");
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSalesExportData(items: SalesExpansionItem[]) {
|
||||||
|
const maxProjects = Math.max(0, ...items.map((item) => item.relatedProjects?.length ?? 0));
|
||||||
|
|
||||||
|
return items.map((item) => {
|
||||||
|
const row = [
|
||||||
|
normalizeExportText(item.employeeNo),
|
||||||
|
normalizeExportText(item.name),
|
||||||
|
normalizeExportText(item.phone),
|
||||||
|
normalizeExportText(item.officeName),
|
||||||
|
normalizeExportText(item.dept),
|
||||||
|
normalizeExportText(item.title),
|
||||||
|
normalizeExportText(item.industry),
|
||||||
|
normalizeExportText(item.intent),
|
||||||
|
item.active === null || item.active === undefined ? "" : item.active ? "是" : "否",
|
||||||
|
formatExportBoolean(item.hasExp),
|
||||||
|
normalizeExportText(formatRelatedProjectAmount(item.relatedProjects)),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let index = 0; index < maxProjects; index += 1) {
|
||||||
|
const project = item.relatedProjects?.[index];
|
||||||
|
row.push(
|
||||||
|
normalizeExportText(project?.opportunityCode),
|
||||||
|
normalizeExportText(project?.opportunityName),
|
||||||
|
project?.amount === null || project?.amount === undefined ? "" : formatAmount(Number(project.amount)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.push(formatExportFollowUps(item.followUps));
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChannelExportHeaders(items: ChannelExpansionItem[]) {
|
||||||
|
const maxProjects = Math.max(0, ...items.map((item) => item.relatedProjects?.length ?? 0));
|
||||||
|
const maxContacts = Math.max(0, ...items.map((item) => item.contacts?.length ?? 0));
|
||||||
|
const headers = [
|
||||||
|
"编码",
|
||||||
|
"渠道名称",
|
||||||
|
"省份",
|
||||||
|
"市",
|
||||||
|
"办公地址",
|
||||||
|
"认证级别",
|
||||||
|
"聚焦行业",
|
||||||
|
"渠道属性",
|
||||||
|
"新华三内部属性",
|
||||||
|
"合作意向",
|
||||||
|
"建立联系时间",
|
||||||
|
"营收规模",
|
||||||
|
"人员规模",
|
||||||
|
"以前是否做过云桌面项目",
|
||||||
|
"跟进项目金额",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let index = 0; index < maxProjects; index += 1) {
|
||||||
|
headers.push(`项目${index + 1}编码`, `项目${index + 1}名称`, `项目${index + 1}金额`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < maxContacts; index += 1) {
|
||||||
|
headers.push(`人员${index + 1}姓名`, `人员${index + 1}联系电话`, `人员${index + 1}职位`);
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.push("备注说明");
|
||||||
|
headers.push("跟进记录");
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChannelExportData(items: ChannelExpansionItem[]) {
|
||||||
|
const maxProjects = Math.max(0, ...items.map((item) => item.relatedProjects?.length ?? 0));
|
||||||
|
const maxContacts = Math.max(0, ...items.map((item) => item.contacts?.length ?? 0));
|
||||||
|
|
||||||
|
return items.map((item) => {
|
||||||
|
const row = [
|
||||||
|
normalizeExportText(item.channelCode),
|
||||||
|
normalizeExportText(item.name),
|
||||||
|
normalizeExportText(item.province),
|
||||||
|
normalizeExportText(item.city),
|
||||||
|
normalizeExportText(item.officeAddress),
|
||||||
|
normalizeExportText(item.certificationLevel),
|
||||||
|
normalizeExportText(item.channelIndustry),
|
||||||
|
normalizeExportText(item.channelAttribute),
|
||||||
|
normalizeExportText(item.internalAttribute),
|
||||||
|
normalizeExportText(item.intent),
|
||||||
|
normalizeExportText(item.establishedDate),
|
||||||
|
normalizeExportText(item.revenue),
|
||||||
|
item.size ? `${item.size}人` : "",
|
||||||
|
formatExportBoolean(item.hasDesktopExp),
|
||||||
|
normalizeExportText(formatRelatedProjectAmount(item.relatedProjects)),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let index = 0; index < maxProjects; index += 1) {
|
||||||
|
const project = item.relatedProjects?.[index];
|
||||||
|
row.push(
|
||||||
|
normalizeExportText(project?.opportunityCode),
|
||||||
|
normalizeExportText(project?.opportunityName),
|
||||||
|
project?.amount === null || project?.amount === undefined ? "" : formatAmount(Number(project.amount)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < maxContacts; index += 1) {
|
||||||
|
const contact = item.contacts?.[index];
|
||||||
|
row.push(
|
||||||
|
normalizeExportText(contact?.name),
|
||||||
|
normalizeExportText(contact?.mobile),
|
||||||
|
normalizeExportText(contact?.title),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.push(normalizeExportText(item.notes));
|
||||||
|
row.push(formatExportFollowUps(item.followUps));
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function validateSalesCreateForm(form: CreateSalesExpansionPayload) {
|
function validateSalesCreateForm(form: CreateSalesExpansionPayload) {
|
||||||
const errors: Partial<Record<SalesCreateField, string>> = {};
|
const errors: Partial<Record<SalesCreateField, string>> = {};
|
||||||
|
|
||||||
|
|
@ -392,8 +590,14 @@ export default function Expansion() {
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [salesDuplicateChecking, setSalesDuplicateChecking] = useState(false);
|
||||||
|
const [channelDuplicateChecking, setChannelDuplicateChecking] = useState(false);
|
||||||
const [createError, setCreateError] = useState("");
|
const [createError, setCreateError] = useState("");
|
||||||
const [editError, setEditError] = useState("");
|
const [editError, setEditError] = useState("");
|
||||||
|
const [exportError, setExportError] = useState("");
|
||||||
|
const [salesDuplicateMessage, setSalesDuplicateMessage] = useState("");
|
||||||
|
const [channelDuplicateMessage, setChannelDuplicateMessage] = useState("");
|
||||||
const [salesCreateFieldErrors, setSalesCreateFieldErrors] = useState<Partial<Record<SalesCreateField, string>>>({});
|
const [salesCreateFieldErrors, setSalesCreateFieldErrors] = useState<Partial<Record<SalesCreateField, string>>>({});
|
||||||
const [salesEditFieldErrors, setSalesEditFieldErrors] = useState<Partial<Record<SalesCreateField, string>>>({});
|
const [salesEditFieldErrors, setSalesEditFieldErrors] = useState<Partial<Record<SalesCreateField, string>>>({});
|
||||||
const [channelCreateFieldErrors, setChannelCreateFieldErrors] = useState<Partial<Record<ChannelField, string>>>({});
|
const [channelCreateFieldErrors, setChannelCreateFieldErrors] = useState<Partial<Record<ChannelField, string>>>({});
|
||||||
|
|
@ -520,8 +724,88 @@ export default function Expansion() {
|
||||||
}
|
}
|
||||||
}, [selectedItem]);
|
}, [selectedItem]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!createOpen || activeTab !== "sales") {
|
||||||
|
setSalesDuplicateChecking(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmployeeNo = salesForm.employeeNo.trim();
|
||||||
|
if (!normalizedEmployeeNo) {
|
||||||
|
setSalesDuplicateChecking(false);
|
||||||
|
setSalesDuplicateMessage("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const timer = window.setTimeout(async () => {
|
||||||
|
setSalesDuplicateChecking(true);
|
||||||
|
try {
|
||||||
|
const result = await checkSalesExpansionDuplicate(normalizedEmployeeNo);
|
||||||
|
if (!cancelled) {
|
||||||
|
setSalesDuplicateMessage(result.duplicated ? result.message || "工号重复,请确认该人员是否已存在!" : "");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSalesDuplicateMessage("");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSalesDuplicateChecking(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [activeTab, createOpen, salesForm.employeeNo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!createOpen || activeTab !== "channel") {
|
||||||
|
setChannelDuplicateChecking(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedChannelName = channelForm.channelName.trim();
|
||||||
|
if (!normalizedChannelName) {
|
||||||
|
setChannelDuplicateChecking(false);
|
||||||
|
setChannelDuplicateMessage("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const timer = window.setTimeout(async () => {
|
||||||
|
setChannelDuplicateChecking(true);
|
||||||
|
try {
|
||||||
|
const result = await checkChannelExpansionDuplicate(normalizedChannelName);
|
||||||
|
if (!cancelled) {
|
||||||
|
setChannelDuplicateMessage(result.duplicated ? result.message || "渠道重复,请确认该渠道是否已存在!" : "");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setChannelDuplicateMessage("");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setChannelDuplicateChecking(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [activeTab, channelForm.channelName, createOpen]);
|
||||||
|
|
||||||
const handleSalesChange = <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => {
|
const handleSalesChange = <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => {
|
||||||
setSalesForm((current) => ({ ...current, [key]: value }));
|
setSalesForm((current) => ({ ...current, [key]: value }));
|
||||||
|
if (key === "employeeNo") {
|
||||||
|
setSalesDuplicateMessage("");
|
||||||
|
setSalesDuplicateChecking(false);
|
||||||
|
}
|
||||||
if (key in salesCreateFieldErrors) {
|
if (key in salesCreateFieldErrors) {
|
||||||
setSalesCreateFieldErrors((current) => {
|
setSalesCreateFieldErrors((current) => {
|
||||||
const next = { ...current };
|
const next = { ...current };
|
||||||
|
|
@ -533,6 +817,10 @@ export default function Expansion() {
|
||||||
|
|
||||||
const handleChannelChange = <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => {
|
const handleChannelChange = <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => {
|
||||||
setChannelForm((current) => ({ ...current, [key]: value }));
|
setChannelForm((current) => ({ ...current, [key]: value }));
|
||||||
|
if (key === "channelName") {
|
||||||
|
setChannelDuplicateMessage("");
|
||||||
|
setChannelDuplicateChecking(false);
|
||||||
|
}
|
||||||
if (key in channelCreateFieldErrors) {
|
if (key in channelCreateFieldErrors) {
|
||||||
setChannelCreateFieldErrors((current) => {
|
setChannelCreateFieldErrors((current) => {
|
||||||
const next = { ...current };
|
const next = { ...current };
|
||||||
|
|
@ -618,6 +906,10 @@ export default function Expansion() {
|
||||||
const resetCreateState = () => {
|
const resetCreateState = () => {
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
setCreateError("");
|
setCreateError("");
|
||||||
|
setSalesDuplicateChecking(false);
|
||||||
|
setChannelDuplicateChecking(false);
|
||||||
|
setSalesDuplicateMessage("");
|
||||||
|
setChannelDuplicateMessage("");
|
||||||
setSalesCreateFieldErrors({});
|
setSalesCreateFieldErrors({});
|
||||||
setChannelCreateFieldErrors({});
|
setChannelCreateFieldErrors({});
|
||||||
setInvalidCreateChannelContactRows([]);
|
setInvalidCreateChannelContactRows([]);
|
||||||
|
|
@ -744,6 +1036,26 @@ export default function Expansion() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeTab === "sales") {
|
||||||
|
const duplicateResult = await checkSalesExpansionDuplicate(salesForm.employeeNo.trim());
|
||||||
|
if (duplicateResult.duplicated) {
|
||||||
|
const duplicateMessage = duplicateResult.message || "工号重复,请确认该人员是否已存在!";
|
||||||
|
setSalesDuplicateMessage(duplicateMessage);
|
||||||
|
setSalesCreateFieldErrors((current) => ({ ...current, employeeNo: duplicateMessage }));
|
||||||
|
setCreateError(duplicateMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const duplicateResult = await checkChannelExpansionDuplicate(channelForm.channelName.trim());
|
||||||
|
if (duplicateResult.duplicated) {
|
||||||
|
const duplicateMessage = duplicateResult.message || "渠道重复,请确认该渠道是否已存在!";
|
||||||
|
setChannelDuplicateMessage(duplicateMessage);
|
||||||
|
setChannelCreateFieldErrors((current) => ({ ...current, channelName: duplicateMessage }));
|
||||||
|
setCreateError(duplicateMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -843,18 +1155,100 @@ export default function Expansion() {
|
||||||
const handleTabChange = (tab: ExpansionTab) => {
|
const handleTabChange = (tab: ExpansionTab) => {
|
||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
setSelectedItem(null);
|
setSelectedItem(null);
|
||||||
|
setExportError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (exporting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSalesTab = activeTab === "sales";
|
||||||
|
const items = isSalesTab ? salesData : channelData;
|
||||||
|
if (items.length <= 0) {
|
||||||
|
setExportError(`当前${isSalesTab ? "销售人员拓展" : "渠道拓展"}暂无可导出数据`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExporting(true);
|
||||||
|
setExportError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ExcelJS = await import("exceljs");
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const worksheet = workbook.addWorksheet(isSalesTab ? "销售人员拓展" : "渠道拓展");
|
||||||
|
const headers = isSalesTab ? buildSalesExportHeaders(salesData) : buildChannelExportHeaders(channelData);
|
||||||
|
const rows = isSalesTab ? buildSalesExportData(salesData) : buildChannelExportData(channelData);
|
||||||
|
|
||||||
|
worksheet.addRow(headers);
|
||||||
|
rows.forEach((row) => {
|
||||||
|
worksheet.addRow(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
worksheet.views = [{ state: "frozen", ySplit: 1 }];
|
||||||
|
const followUpColumnIndex = headers.indexOf("跟进记录") + 1;
|
||||||
|
worksheet.getRow(1).height = 24;
|
||||||
|
worksheet.getRow(1).font = { bold: true };
|
||||||
|
worksheet.getRow(1).alignment = { vertical: "middle", horizontal: "center" };
|
||||||
|
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
const column = worksheet.getColumn(index + 1);
|
||||||
|
if (header === "跟进记录") {
|
||||||
|
column.width = 42;
|
||||||
|
} else if (header.includes("办公地址") || header.includes("备注")) {
|
||||||
|
column.width = 24;
|
||||||
|
} else if (header.includes("项目") && header.includes("名称")) {
|
||||||
|
column.width = 24;
|
||||||
|
} else if (header.includes("渠道属性") || header.includes("内部属性") || header.includes("聚焦行业")) {
|
||||||
|
column.width = 18;
|
||||||
|
} else {
|
||||||
|
column.width = 16;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
worksheet.eachRow((row, rowNumber) => {
|
||||||
|
row.eachCell((cell, columnNumber) => {
|
||||||
|
cell.border = {
|
||||||
|
top: { style: "thin", color: { argb: "FFE2E8F0" } },
|
||||||
|
left: { style: "thin", color: { argb: "FFE2E8F0" } },
|
||||||
|
bottom: { style: "thin", color: { argb: "FFE2E8F0" } },
|
||||||
|
right: { style: "thin", color: { argb: "FFE2E8F0" } },
|
||||||
|
};
|
||||||
|
cell.alignment = {
|
||||||
|
vertical: "top",
|
||||||
|
horizontal: rowNumber === 1 ? "center" : "left",
|
||||||
|
wrapText: headers[columnNumber - 1] === "跟进记录",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (rowNumber > 1 && followUpColumnIndex > 0) {
|
||||||
|
const followUpText = normalizeExportText(row.getCell(followUpColumnIndex).value as string | null | undefined);
|
||||||
|
const lineCount = followUpText ? followUpText.split("\n").length : 1;
|
||||||
|
row.height = Math.max(22, lineCount * 16);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
const filename = `${isSalesTab ? "销售人员拓展" : "渠道拓展"}_${formatExportFilenameTime()}.xlsx`;
|
||||||
|
downloadExcelFile(filename, buffer);
|
||||||
|
} catch (error) {
|
||||||
|
setExportError(error instanceof Error ? error.message : "导出失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderSalesForm = (
|
const renderSalesForm = (
|
||||||
form: CreateSalesExpansionPayload,
|
form: CreateSalesExpansionPayload,
|
||||||
onChange: <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => void,
|
onChange: <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => void,
|
||||||
fieldErrors?: Partial<Record<SalesCreateField, string>>,
|
fieldErrors?: Partial<Record<SalesCreateField, string>>,
|
||||||
|
isEdit = false,
|
||||||
) => (
|
) => (
|
||||||
<div className="crm-form-grid">
|
<div className="crm-form-grid">
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">工号<RequiredMark /></span>
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">工号<RequiredMark /></span>
|
||||||
<input value={form.employeeNo} onChange={(e) => onChange("employeeNo", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.employeeNo))} />
|
<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}
|
{fieldErrors?.employeeNo ? <p className="text-xs text-rose-500">{fieldErrors.employeeNo}</p> : null}
|
||||||
|
{!fieldErrors?.employeeNo && !isEdit && salesDuplicateMessage ? <p className="text-xs text-rose-500">{salesDuplicateMessage}</p> : null}
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">代表处 / 办事处<RequiredMark /></span>
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">代表处 / 办事处<RequiredMark /></span>
|
||||||
|
|
@ -993,6 +1387,7 @@ export default function Expansion() {
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">渠道名称<RequiredMark /></span>
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">渠道名称<RequiredMark /></span>
|
||||||
<input value={form.channelName} onChange={(e) => onChange("channelName", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.channelName))} />
|
<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}
|
{fieldErrors?.channelName ? <p className="text-xs text-rose-500">{fieldErrors.channelName}</p> : null}
|
||||||
|
{!fieldErrors?.channelName && !isEdit && channelDuplicateMessage ? <p className="text-xs text-rose-500">{channelDuplicateMessage}</p> : null}
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">省份<RequiredMark /></span>
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">省份<RequiredMark /></span>
|
||||||
|
|
@ -1216,15 +1611,24 @@ export default function Expansion() {
|
||||||
<header className="crm-page-header">
|
<header className="crm-page-header">
|
||||||
<div className="crm-page-heading">
|
<div className="crm-page-heading">
|
||||||
<h1 className="crm-page-title">拓展管理</h1>
|
<h1 className="crm-page-title">拓展管理</h1>
|
||||||
<p className="crm-page-subtitle hidden sm:block">维护销售拓展与渠道拓展的基础资料,并查看关联项目与跟进记录。</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={handleOpenCreate}
|
<button
|
||||||
className={cn("crm-btn-sm crm-btn-primary flex items-center gap-2", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
|
onClick={handleExport}
|
||||||
>
|
disabled={exporting}
|
||||||
<Plus className="crm-icon-md" />
|
className={cn("crm-btn-sm crm-btn-secondary flex items-center gap-2 disabled:cursor-not-allowed disabled:opacity-60", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
|
||||||
<span className="hidden sm:inline">新增</span>
|
>
|
||||||
</button>
|
<Download className="crm-icon-md" />
|
||||||
|
<span className="hidden sm:inline">{exporting ? "导出中..." : "导出"}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenCreate}
|
||||||
|
className={cn("crm-btn-sm crm-btn-primary flex items-center gap-2", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
|
||||||
|
>
|
||||||
|
<Plus className="crm-icon-md" />
|
||||||
|
<span className="hidden sm:inline">新增</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className={cn("crm-filter-bar flex", !disableMobileMotion && "backdrop-blur-sm")}>
|
<div className={cn("crm-filter-bar flex", !disableMobileMotion && "backdrop-blur-sm")}>
|
||||||
|
|
@ -1252,11 +1656,16 @@ export default function Expansion() {
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索工号、姓名、渠道名称、行业..."
|
placeholder="搜索工号、姓名、渠道名称、行业..."
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={(event) => setKeyword(event.target.value)}
|
onChange={(event) => {
|
||||||
|
setKeyword(event.target.value);
|
||||||
|
setExportError("");
|
||||||
|
}}
|
||||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white pl-10 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"
|
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white pl-10 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>
|
</div>
|
||||||
|
|
||||||
|
{exportError ? <div className="crm-alert crm-alert-error">{exportError}</div> : null}
|
||||||
|
|
||||||
<div className="crm-list-stack">
|
<div className="crm-list-stack">
|
||||||
{activeTab === "sales" ? (
|
{activeTab === "sales" ? (
|
||||||
salesData.length > 0 ? (
|
salesData.length > 0 ? (
|
||||||
|
|
@ -1352,7 +1761,7 @@ export default function Expansion() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{activeTab === "sales" ? renderSalesForm(salesForm, handleSalesChange, salesCreateFieldErrors) : renderChannelForm(channelForm, handleChannelChange, false, channelCreateFieldErrors, invalidCreateChannelContactRows)}
|
{activeTab === "sales" ? renderSalesForm(salesForm, handleSalesChange, salesCreateFieldErrors, false) : renderChannelForm(channelForm, handleChannelChange, false, channelCreateFieldErrors, invalidCreateChannelContactRows)}
|
||||||
{createError ? <div className="crm-alert crm-alert-error mt-4">{createError}</div> : null}
|
{createError ? <div className="crm-alert crm-alert-error mt-4">{createError}</div> : null}
|
||||||
</ModalShell>
|
</ModalShell>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1371,7 +1780,7 @@ export default function Expansion() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectedItem.type === "sales" ? renderSalesForm(editSalesForm, handleEditSalesChange, salesEditFieldErrors) : renderChannelForm(editChannelForm, handleEditChannelChange, true, channelEditFieldErrors, invalidEditChannelContactRows)}
|
{selectedItem.type === "sales" ? renderSalesForm(editSalesForm, handleEditSalesChange, salesEditFieldErrors, true) : renderChannelForm(editChannelForm, handleEditChannelChange, true, channelEditFieldErrors, invalidEditChannelContactRows)}
|
||||||
{editError ? <div className="crm-alert crm-alert-error mt-4">{editError}</div> : null}
|
{editError ? <div className="crm-alert crm-alert-error mt-4">{editError}</div> : null}
|
||||||
</ModalShell>
|
</ModalShell>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -262,22 +262,11 @@ export default function LoginPage() {
|
||||||
<h1>{appName}</h1>
|
<h1>{appName}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="login-intro-copy">
|
|
||||||
<p className="login-panel-eyebrow">欢迎回来</p>
|
|
||||||
<h2>账号登录</h2>
|
|
||||||
<p>{systemDescription}</p>
|
|
||||||
</div>
|
|
||||||
<div className="login-intro-points">
|
|
||||||
<span>客户拓展</span>
|
|
||||||
<span>商机推进</span>
|
|
||||||
<span>销售协同</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="login-panel-formWrap">
|
<div className="login-panel-formWrap">
|
||||||
<div className="login-panel-header">
|
<div className="login-panel-header">
|
||||||
<p className="login-panel-eyebrow">欢迎回来</p>
|
<p className="login-panel-eyebrow">欢迎回来</p>
|
||||||
<h3>{appName}</h3>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="login-form" onSubmit={handleSubmit}>
|
<form className="login-form" onSubmit={handleSubmit}>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { Search, Plus, ChevronRight, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter } from "lucide-react";
|
import { Search, Plus, Download, ChevronRight, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter } from "lucide-react";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOmsPreSalesOptions, getOpportunityOverview, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OmsPreSalesOption, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type PushOpportunityToOmsPayload, type SalesExpansionItem } from "@/lib/auth";
|
import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOmsPreSalesOptions, getOpportunityOverview, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OmsPreSalesOption, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type PushOpportunityToOmsPayload, type SalesExpansionItem } from "@/lib/auth";
|
||||||
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
||||||
|
|
@ -74,6 +74,39 @@ function formatAmount(value?: number) {
|
||||||
return new Intl.NumberFormat("zh-CN").format(Number(value));
|
return new Intl.NumberFormat("zh-CN").format(Number(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOpportunityExportText(value?: string | number | boolean | null) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const normalized = String(value).replace(/\r?\n/g, " ").trim();
|
||||||
|
if (!normalized || normalized === "无" || normalized === "待定" || normalized === "未关联") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadOpportunityExcelFile(filename: string, content: BlobPart) {
|
||||||
|
const blob = new Blob([content], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||||
|
const objectUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = objectUrl;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOpportunityExportFilenameTime(date = new Date()) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||||
|
return `${year}${month}${day}_${hours}${minutes}${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
function toFormFromItem(item: OpportunityItem): CreateOpportunityPayload {
|
function toFormFromItem(item: OpportunityItem): CreateOpportunityPayload {
|
||||||
return {
|
return {
|
||||||
opportunityName: item.name || "",
|
opportunityName: item.name || "",
|
||||||
|
|
@ -480,6 +513,8 @@ function SearchableSelect({
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [desktopDropdownPlacement, setDesktopDropdownPlacement] = useState<"top" | "bottom">("bottom");
|
||||||
|
const [desktopDropdownMaxHeight, setDesktopDropdownMaxHeight] = useState(256);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const isMobile = useIsMobileViewport();
|
const isMobile = useIsMobileViewport();
|
||||||
const normalizedOptions = dedupeSearchableOptions(options);
|
const normalizedOptions = dedupeSearchableOptions(options);
|
||||||
|
|
@ -502,6 +537,25 @@ function SearchableSelect({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateDesktopDropdownLayout = () => {
|
||||||
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect || typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const safePadding = 24;
|
||||||
|
const panelPadding = 96;
|
||||||
|
const availableBelow = Math.max(160, viewportHeight - rect.bottom - safePadding - panelPadding);
|
||||||
|
const availableAbove = Math.max(160, rect.top - safePadding - panelPadding);
|
||||||
|
const shouldOpenUpward = availableBelow < 280 && availableAbove > availableBelow;
|
||||||
|
|
||||||
|
setDesktopDropdownPlacement(shouldOpenUpward ? "top" : "bottom");
|
||||||
|
setDesktopDropdownMaxHeight(Math.min(320, shouldOpenUpward ? availableAbove : availableBelow));
|
||||||
|
};
|
||||||
|
|
||||||
|
updateDesktopDropdownLayout();
|
||||||
|
|
||||||
const handlePointerDown = (event: MouseEvent) => {
|
const handlePointerDown = (event: MouseEvent) => {
|
||||||
if (!containerRef.current?.contains(event.target as Node)) {
|
if (!containerRef.current?.contains(event.target as Node)) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
@ -509,9 +563,17 @@ function SearchableSelect({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleViewportChange = () => {
|
||||||
|
updateDesktopDropdownLayout();
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener("mousedown", handlePointerDown);
|
document.addEventListener("mousedown", handlePointerDown);
|
||||||
|
window.addEventListener("resize", handleViewportChange);
|
||||||
|
window.addEventListener("scroll", handleViewportChange, true);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", handlePointerDown);
|
document.removeEventListener("mousedown", handlePointerDown);
|
||||||
|
window.removeEventListener("resize", handleViewportChange);
|
||||||
|
window.removeEventListener("scroll", handleViewportChange, true);
|
||||||
};
|
};
|
||||||
}, [isMobile, open]);
|
}, [isMobile, open]);
|
||||||
|
|
||||||
|
|
@ -540,7 +602,7 @@ function SearchableSelect({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 max-h-64 space-y-1 overflow-y-auto">
|
<div className="mt-3 max-h-64 space-y-1 overflow-y-auto overscroll-contain pr-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -610,9 +672,17 @@ function SearchableSelect({
|
||||||
initial={{ opacity: 0, y: 8 }}
|
initial={{ opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: 8 }}
|
exit={{ opacity: 0, y: 8 }}
|
||||||
className="absolute z-20 mt-2 w-full rounded-2xl border border-slate-200 bg-white p-3 shadow-xl dark:border-slate-800 dark:bg-slate-900"
|
className={cn(
|
||||||
|
"absolute z-20 w-full rounded-2xl border border-slate-200 bg-white p-3 shadow-xl dark:border-slate-800 dark:bg-slate-900",
|
||||||
|
desktopDropdownPlacement === "top" ? "bottom-full mb-2" : "top-full mt-2",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{renderSearchBody()}
|
<div
|
||||||
|
style={{ maxHeight: `${desktopDropdownMaxHeight}px` }}
|
||||||
|
className="overflow-y-auto overscroll-contain pr-1"
|
||||||
|
>
|
||||||
|
{renderSearchBody()}
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
@ -857,7 +927,9 @@ export default function Opportunities() {
|
||||||
const [pushConfirmOpen, setPushConfirmOpen] = useState(false);
|
const [pushConfirmOpen, setPushConfirmOpen] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [pushingOms, setPushingOms] = useState(false);
|
const [pushingOms, setPushingOms] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [exportError, setExportError] = useState("");
|
||||||
const [items, setItems] = useState<OpportunityItem[]>([]);
|
const [items, setItems] = useState<OpportunityItem[]>([]);
|
||||||
const [salesExpansionOptions, setSalesExpansionOptions] = useState<SalesExpansionItem[]>([]);
|
const [salesExpansionOptions, setSalesExpansionOptions] = useState<SalesExpansionItem[]>([]);
|
||||||
const [channelExpansionOptions, setChannelExpansionOptions] = useState<ChannelExpansionItem[]>([]);
|
const [channelExpansionOptions, setChannelExpansionOptions] = useState<ChannelExpansionItem[]>([]);
|
||||||
|
|
@ -1065,6 +1137,147 @@ export default function Opportunities() {
|
||||||
return "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20";
|
return "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (exporting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibleItems.length <= 0) {
|
||||||
|
setExportError(`当前${archiveTab === "active" ? "未归档" : "已归档"}商机暂无可导出数据`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExporting(true);
|
||||||
|
setExportError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ExcelJS = await import("exceljs");
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const worksheet = workbook.addWorksheet("商机储备");
|
||||||
|
const headers = [
|
||||||
|
"项目编号",
|
||||||
|
"项目名称",
|
||||||
|
"项目地",
|
||||||
|
"最终客户",
|
||||||
|
"建设类型",
|
||||||
|
"运作方",
|
||||||
|
"项目阶段",
|
||||||
|
"项目把握度",
|
||||||
|
"预计金额(元)",
|
||||||
|
"预计下单时间",
|
||||||
|
"销售拓展人员姓名",
|
||||||
|
"销售拓展人员合作意向",
|
||||||
|
"销售拓展人员是否在职",
|
||||||
|
"拓展渠道名称",
|
||||||
|
"拓展渠道合作意向",
|
||||||
|
"拓展渠道建立联系时间",
|
||||||
|
"新华三负责人",
|
||||||
|
"售前",
|
||||||
|
"竞争对手",
|
||||||
|
"项目最新进展",
|
||||||
|
"后续规划",
|
||||||
|
"备注说明",
|
||||||
|
"跟进记录",
|
||||||
|
];
|
||||||
|
|
||||||
|
worksheet.addRow(headers);
|
||||||
|
|
||||||
|
visibleItems.forEach((item) => {
|
||||||
|
const relatedSales = item.salesExpansionId
|
||||||
|
? salesExpansionOptions.find((option) => option.id === item.salesExpansionId) ?? null
|
||||||
|
: null;
|
||||||
|
const relatedChannel = item.channelExpansionId
|
||||||
|
? channelExpansionOptions.find((option) => option.id === item.channelExpansionId) ?? null
|
||||||
|
: null;
|
||||||
|
const followUpText = (item.followUps ?? [])
|
||||||
|
.map((record) => {
|
||||||
|
const summary = getOpportunityFollowUpSummary(record);
|
||||||
|
const lines = [
|
||||||
|
[normalizeOpportunityExportText(record.date), normalizeOpportunityExportText(record.user)].filter(Boolean).join(" / "),
|
||||||
|
normalizeOpportunityExportText(summary.communicationContent),
|
||||||
|
].filter(Boolean);
|
||||||
|
return lines.join("\n");
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
worksheet.addRow([
|
||||||
|
normalizeOpportunityExportText(item.code),
|
||||||
|
normalizeOpportunityExportText(item.name),
|
||||||
|
normalizeOpportunityExportText(item.projectLocation),
|
||||||
|
normalizeOpportunityExportText(item.client),
|
||||||
|
normalizeOpportunityExportText(item.type || "新建"),
|
||||||
|
normalizeOpportunityExportText(item.operatorName),
|
||||||
|
normalizeOpportunityExportText(item.stage),
|
||||||
|
getConfidenceLabel(item.confidence),
|
||||||
|
item.amount === null || item.amount === undefined ? "" : `¥${formatAmount(item.amount)}`,
|
||||||
|
normalizeOpportunityExportText(item.date),
|
||||||
|
normalizeOpportunityExportText(item.salesExpansionName || relatedSales?.name),
|
||||||
|
normalizeOpportunityExportText(relatedSales?.intent),
|
||||||
|
relatedSales ? (relatedSales.active ? "是" : "否") : "",
|
||||||
|
normalizeOpportunityExportText(item.channelExpansionName || relatedChannel?.name),
|
||||||
|
normalizeOpportunityExportText(relatedChannel?.intent),
|
||||||
|
normalizeOpportunityExportText(relatedChannel?.establishedDate),
|
||||||
|
normalizeOpportunityExportText(item.salesExpansionName || relatedSales?.name),
|
||||||
|
normalizeOpportunityExportText(item.preSalesName),
|
||||||
|
normalizeOpportunityExportText(item.competitorName),
|
||||||
|
normalizeOpportunityExportText(item.latestProgress),
|
||||||
|
normalizeOpportunityExportText(item.nextPlan),
|
||||||
|
normalizeOpportunityExportText(item.notes),
|
||||||
|
followUpText,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
worksheet.views = [{ state: "frozen", ySplit: 1 }];
|
||||||
|
const followUpColumnIndex = headers.indexOf("跟进记录") + 1;
|
||||||
|
worksheet.getRow(1).height = 24;
|
||||||
|
worksheet.getRow(1).font = { bold: true };
|
||||||
|
worksheet.getRow(1).alignment = { vertical: "middle", horizontal: "center" };
|
||||||
|
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
const column = worksheet.getColumn(index + 1);
|
||||||
|
if (header === "跟进记录") {
|
||||||
|
column.width = 42;
|
||||||
|
} else if (header.includes("项目最新进展") || header.includes("后续规划") || header.includes("备注")) {
|
||||||
|
column.width = 24;
|
||||||
|
} else if (header.includes("项目名称") || header.includes("最终客户")) {
|
||||||
|
column.width = 20;
|
||||||
|
} else {
|
||||||
|
column.width = 16;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
worksheet.eachRow((row, rowNumber) => {
|
||||||
|
row.eachCell((cell, columnNumber) => {
|
||||||
|
cell.border = {
|
||||||
|
top: { style: "thin", color: { argb: "FFE2E8F0" } },
|
||||||
|
left: { style: "thin", color: { argb: "FFE2E8F0" } },
|
||||||
|
bottom: { style: "thin", color: { argb: "FFE2E8F0" } },
|
||||||
|
right: { style: "thin", color: { argb: "FFE2E8F0" } },
|
||||||
|
};
|
||||||
|
cell.alignment = {
|
||||||
|
vertical: "top",
|
||||||
|
horizontal: rowNumber === 1 ? "center" : "left",
|
||||||
|
wrapText: headers[columnNumber - 1] === "跟进记录",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (rowNumber > 1 && followUpColumnIndex > 0) {
|
||||||
|
const followUpText = normalizeOpportunityExportText(row.getCell(followUpColumnIndex).value as string | null | undefined);
|
||||||
|
const lineCount = followUpText ? followUpText.split("\n").length : 1;
|
||||||
|
row.height = Math.max(22, lineCount * 16);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
const filename = `商机储备_${archiveTab === "active" ? "未归档" : "已归档"}_${formatOpportunityExportFilenameTime()}.xlsx`;
|
||||||
|
downloadOpportunityExcelFile(filename, buffer);
|
||||||
|
} catch (exportErr) {
|
||||||
|
setExportError(exportErr instanceof Error ? exportErr.message : "导出失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleChange = <K extends keyof CreateOpportunityPayload>(key: K, value: CreateOpportunityPayload[K]) => {
|
const handleChange = <K extends keyof CreateOpportunityPayload>(key: K, value: CreateOpportunityPayload[K]) => {
|
||||||
setForm((current) => ({ ...current, [key]: value }));
|
setForm((current) => ({ ...current, [key]: value }));
|
||||||
if (key in fieldErrors) {
|
if (key in fieldErrors) {
|
||||||
|
|
@ -1261,20 +1474,32 @@ export default function Opportunities() {
|
||||||
<header className="crm-page-header">
|
<header className="crm-page-header">
|
||||||
<div className="crm-page-heading">
|
<div className="crm-page-heading">
|
||||||
<h1 className="crm-page-title">商机储备</h1>
|
<h1 className="crm-page-title">商机储备</h1>
|
||||||
<p className="crm-page-subtitle hidden sm:block">维护商机基础信息,查看关联拓展对象和日报自动回写的跟进记录。</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={handleOpenCreate}
|
<button
|
||||||
className={cn("crm-btn-sm crm-btn-primary flex items-center gap-2", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
|
onClick={() => void handleExport()}
|
||||||
>
|
disabled={exporting}
|
||||||
<Plus className="crm-icon-md" />
|
className={cn("crm-btn-sm crm-btn-secondary flex items-center gap-2 disabled:cursor-not-allowed disabled:opacity-60", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
|
||||||
<span className="hidden sm:inline">新增商机</span>
|
>
|
||||||
</button>
|
<Download className="crm-icon-md" />
|
||||||
|
<span className="hidden sm:inline">{exporting ? "导出中..." : "导出"}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenCreate}
|
||||||
|
className={cn("crm-btn-sm crm-btn-primary flex items-center gap-2", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
|
||||||
|
>
|
||||||
|
<Plus className="crm-icon-md" />
|
||||||
|
<span className="hidden sm:inline">新增商机</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className={cn("crm-filter-bar flex", !disableMobileMotion && "backdrop-blur-sm")}>
|
<div className={cn("crm-filter-bar flex", !disableMobileMotion && "backdrop-blur-sm")}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setArchiveTab("active")}
|
onClick={() => {
|
||||||
|
setArchiveTab("active");
|
||||||
|
setExportError("");
|
||||||
|
}}
|
||||||
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
|
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
|
||||||
archiveTab === "active"
|
archiveTab === "active"
|
||||||
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
|
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
|
||||||
|
|
@ -1284,7 +1509,10 @@ export default function Opportunities() {
|
||||||
未归档
|
未归档
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setArchiveTab("archived")}
|
onClick={() => {
|
||||||
|
setArchiveTab("archived");
|
||||||
|
setExportError("");
|
||||||
|
}}
|
||||||
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
|
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
|
||||||
archiveTab === "archived"
|
archiveTab === "archived"
|
||||||
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
|
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
|
||||||
|
|
@ -1302,7 +1530,10 @@ export default function Opportunities() {
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索项目名称、最终客户、编码..."
|
placeholder="搜索项目名称、最终客户、编码..."
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={(event) => setKeyword(event.target.value)}
|
onChange={(event) => {
|
||||||
|
setKeyword(event.target.value);
|
||||||
|
setExportError("");
|
||||||
|
}}
|
||||||
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"
|
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>
|
</div>
|
||||||
|
|
@ -1322,6 +1553,8 @@ export default function Opportunities() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{exportError ? <div className="crm-alert crm-alert-error">{exportError}</div> : null}
|
||||||
|
|
||||||
<div className="crm-list-stack">
|
<div className="crm-list-stack">
|
||||||
{visibleItems.length > 0 ? (
|
{visibleItems.length > 0 ? (
|
||||||
visibleItems.map((opp, i) => (
|
visibleItems.map((opp, i) => (
|
||||||
|
|
@ -1336,7 +1569,7 @@ export default function Opportunities() {
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="truncate text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{opp.name || "未命名商机"}</h3>
|
<h3 className="truncate text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{opp.name || "未命名商机"}</h3>
|
||||||
<p className="mt-1 truncate text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">商机编号:{opp.code || "待生成"}</p>
|
<p className="mt-1 truncate text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">项目编号:{opp.code || "待生成"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 flex items-center gap-2 pl-2">
|
<div className="shrink-0 flex items-center gap-2 pl-2">
|
||||||
<span className={getConfidenceBadgeClass(opp.confidence)}>
|
<span className={getConfidenceBadgeClass(opp.confidence)}>
|
||||||
|
|
@ -1418,6 +1651,7 @@ export default function Opportunities() {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFilter(stage.value);
|
setFilter(stage.value);
|
||||||
|
setExportError("");
|
||||||
setStageFilterOpen(false);
|
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 ${
|
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left text-sm font-medium transition-colors ${
|
||||||
|
|
|
||||||
|
|
@ -376,29 +376,6 @@ export default function Profile() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-3 gap-2.5 border-t border-slate-50 pt-6 dark:border-slate-800/50 sm:gap-0 sm:divide-x sm:divide-slate-100 dark:sm:divide-slate-800">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleNavigateToMonthlyOpportunity}
|
|
||||||
className="rounded-2xl bg-slate-50 px-2 py-4 text-center transition-colors hover:bg-slate-100 active:scale-100 dark:bg-slate-800/40 dark:hover:bg-slate-800 sm:rounded-none sm:bg-transparent sm:px-2 sm:active:scale-[0.99]"
|
|
||||||
>
|
|
||||||
<p className="text-lg font-bold text-slate-900 dark:text-white sm:text-2xl">{numericValue(overview?.monthlyOpportunityCount)}</p>
|
|
||||||
<p className="mt-1 text-[11px] text-slate-500 dark:text-slate-400">本月商机</p>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleNavigateToMonthlyExpansion}
|
|
||||||
className="rounded-2xl bg-slate-50 px-2 py-4 text-center transition-colors hover:bg-slate-100 active:scale-100 dark:bg-slate-800/40 dark:hover:bg-slate-800 sm:rounded-none sm:bg-transparent sm:px-2 sm:active:scale-[0.99]"
|
|
||||||
>
|
|
||||||
<p className="text-lg font-bold text-slate-900 dark:text-white sm:text-2xl">{numericValue(overview?.monthlyExpansionCount)}</p>
|
|
||||||
<p className="mt-1 text-[11px] text-slate-500 dark:text-slate-400">本月拓展</p>
|
|
||||||
</button>
|
|
||||||
<div className="rounded-2xl bg-slate-50 px-2 py-4 text-center dark:bg-slate-800/40 sm:rounded-none sm:bg-transparent sm:px-2">
|
|
||||||
<p className="text-lg font-bold text-slate-900 dark:text-white sm:text-2xl">{numericValue(overview?.averageScore)}</p>
|
|
||||||
<p className="mt-1 text-[11px] text-slate-500 dark:text-slate-400">平均分</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
|
||||||
|
|
@ -2425,15 +2425,45 @@ function getHistoryLabelBySection(section: WorkSection) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSalesOptions(items: SalesExpansionItem[]): WorkRelationOption[] {
|
function buildSalesOptions(items: SalesExpansionItem[]): WorkRelationOption[] {
|
||||||
return items.filter((item) => item.id).map((item) => ({ id: item.id, label: item.name || `人员拓展#${item.id}` }));
|
const seenIds = new Set<number>();
|
||||||
|
return items
|
||||||
|
.filter((item): item is SalesExpansionItem & { id: number } => typeof item.id === "number")
|
||||||
|
.filter((item) => {
|
||||||
|
if (seenIds.has(item.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seenIds.add(item.id);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((item) => ({ id: item.id, label: item.name || `人员拓展#${item.id}` }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChannelOptions(items: ChannelExpansionItem[]): WorkRelationOption[] {
|
function buildChannelOptions(items: ChannelExpansionItem[]): WorkRelationOption[] {
|
||||||
return items.filter((item) => item.id).map((item) => ({ id: item.id, label: item.name || `拓展渠道#${item.id}` }));
|
const seenIds = new Set<number>();
|
||||||
|
return items
|
||||||
|
.filter((item): item is ChannelExpansionItem & { id: number } => typeof item.id === "number")
|
||||||
|
.filter((item) => {
|
||||||
|
if (seenIds.has(item.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seenIds.add(item.id);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((item) => ({ id: item.id, label: item.name || `拓展渠道#${item.id}` }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOpportunityOptions(items: OpportunityItem[]): WorkRelationOption[] {
|
function buildOpportunityOptions(items: OpportunityItem[]): WorkRelationOption[] {
|
||||||
return items.filter((item) => item.id).map((item) => ({ id: item.id, label: item.name || `商机#${item.id}` }));
|
const seenIds = new Set<number>();
|
||||||
|
return items
|
||||||
|
.filter((item): item is OpportunityItem & { id: number } => typeof item.id === "number")
|
||||||
|
.filter((item) => {
|
||||||
|
if (seenIds.has(item.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seenIds.add(item.id);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((item) => ({ id: item.id, label: item.name || `商机#${item.id}` }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBizTypeLabel(bizType: BizType) {
|
function getBizTypeLabel(bizType: BizType) {
|
||||||
|
|
|
||||||
|
|
@ -75,8 +75,10 @@
|
||||||
|
|
||||||
.login-brand-lockup h1 {
|
.login-brand-lockup h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: clamp(1.8rem, 3vw, 2.7rem);
|
font-size: clamp(1.7rem, 2.4vw, 2.45rem);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
line-height: 1.08;
|
||||||
|
white-space: nowrap;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,8 +111,8 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: center;
|
||||||
gap: 32px;
|
gap: 0;
|
||||||
padding: 36px;
|
padding: 36px;
|
||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
background:
|
background:
|
||||||
|
|
@ -119,37 +121,6 @@
|
||||||
border: 1px solid rgba(226, 232, 240, 0.84);
|
border: 1px solid rgba(226, 232, 240, 0.84);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-intro-copy h2 {
|
|
||||||
margin: 10px 0 12px;
|
|
||||||
font-size: clamp(2rem, 3vw, 3rem);
|
|
||||||
line-height: 1.04;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-intro-copy p:last-child {
|
|
||||||
margin: 0;
|
|
||||||
max-width: 30rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.8;
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-intro-points {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-intro-points span {
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(255, 255, 255, 0.74);
|
|
||||||
border: 1px solid rgba(139, 92, 246, 0.12);
|
|
||||||
color: #475569;
|
|
||||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-panel-formWrap {
|
.login-panel-formWrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,7 @@ create index if not exists idx_crm_customer_name on crm_customer(customer_name);
|
||||||
create index if not exists idx_sys_user_org_id on sys_user(org_id);
|
create index if not exists idx_sys_user_org_id on sys_user(org_id);
|
||||||
create unique index if not exists uk_crm_sales_expansion_owner_employee_no
|
create unique index if not exists uk_crm_sales_expansion_owner_employee_no
|
||||||
on crm_sales_expansion(owner_user_id, employee_no);
|
on crm_sales_expansion(owner_user_id, employee_no);
|
||||||
|
create sequence if not exists crm_channel_expansion_code_seq start with 1 increment by 1 minvalue 1;
|
||||||
create index if not exists idx_crm_opportunity_customer on crm_opportunity(customer_id);
|
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_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_sales_expansion on crm_opportunity(sales_expansion_id);
|
||||||
|
|
@ -318,6 +319,9 @@ create index if not exists idx_crm_sales_expansion_mobile on crm_sales_expansion
|
||||||
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_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_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_name on crm_channel_expansion(channel_name);
|
||||||
|
create unique index if not exists uk_crm_channel_expansion_code
|
||||||
|
on crm_channel_expansion(channel_code)
|
||||||
|
where channel_code is not null;
|
||||||
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_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_opportunity_channel_expansion on crm_opportunity(channel_expansion_id);
|
||||||
create index if not exists idx_crm_expansion_followup_biz_time
|
create index if not exists idx_crm_expansion_followup_biz_time
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,7 @@ end;
|
||||||
$$;
|
$$;
|
||||||
create unique index if not exists uk_crm_sales_expansion_owner_employee_no
|
create unique index if not exists uk_crm_sales_expansion_owner_employee_no
|
||||||
on crm_sales_expansion(owner_user_id, employee_no);
|
on crm_sales_expansion(owner_user_id, employee_no);
|
||||||
|
create sequence if not exists crm_channel_expansion_code_seq start with 1 increment by 1 minvalue 1;
|
||||||
create index if not exists idx_crm_opportunity_customer on crm_opportunity(customer_id);
|
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_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_sales_expansion on crm_opportunity(sales_expansion_id);
|
||||||
|
|
@ -374,6 +375,9 @@ create index if not exists idx_crm_sales_expansion_mobile on crm_sales_expansion
|
||||||
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_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_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_name on crm_channel_expansion(channel_name);
|
||||||
|
create unique index if not exists uk_crm_channel_expansion_code
|
||||||
|
on crm_channel_expansion(channel_code)
|
||||||
|
where channel_code is not null;
|
||||||
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_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_opportunity_channel_expansion on crm_opportunity(channel_expansion_id);
|
||||||
create index if not exists idx_crm_expansion_followup_biz_time
|
create index if not exists idx_crm_expansion_followup_biz_time
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue