工号与渠道名称校验重复
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.CreateSalesExpansionRequest;
|
||||
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.ExpansionOverviewDTO;
|
||||
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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
|
|
@ -54,6 +56,24 @@ public class ExpansionController {
|
|||
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")
|
||||
public ApiResponse<Long> createSales(
|
||||
@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")
|
||||
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(
|
||||
@Param("userId") Long userId,
|
||||
@Param("employeeNo") String employeeNo,
|
||||
@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")
|
||||
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.CreateSalesExpansionRequest;
|
||||
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.ExpansionOverviewDTO;
|
||||
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
|
||||
|
|
@ -18,6 +19,10 @@ public interface ExpansionService {
|
|||
|
||||
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 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.CreateSalesExpansionRequest;
|
||||
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.ExpansionFollowUpDTO;
|
||||
import com.unis.crm.dto.expansion.ExpansionOverviewDTO;
|
||||
|
|
@ -32,10 +33,13 @@ import java.util.stream.Collectors;
|
|||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
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 INDUSTRY_TYPE_CODE = "tz_sshy";
|
||||
private static final String CERTIFICATION_LEVEL_TYPE_CODE = "tz_rzjb";
|
||||
|
|
@ -99,6 +103,32 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
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
|
||||
public Long createSalesExpansion(Long userId, CreateSalesExpansionRequest request) {
|
||||
fillSalesDefaults(request);
|
||||
|
|
@ -111,8 +141,10 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public Long createChannelExpansion(Long userId, CreateChannelExpansionRequest request) {
|
||||
fillChannelDefaults(request);
|
||||
ensureUniqueChannelName(userId, request.getChannelName(), null);
|
||||
expansionMapper.insertChannelExpansion(userId, request);
|
||||
if (request.getId() == null) {
|
||||
throw new BusinessException("渠道拓展新增失败");
|
||||
|
|
@ -132,8 +164,10 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void updateChannelExpansion(Long userId, Long id, UpdateChannelExpansionRequest request) {
|
||||
fillChannelDefaults(request);
|
||||
ensureUniqueChannelName(userId, request.getChannelName(), id);
|
||||
int updated = expansionMapper.updateChannelExpansion(userId, id, request);
|
||||
if (updated <= 0) {
|
||||
throw new BusinessException("未找到可编辑的渠道拓展记录");
|
||||
|
|
@ -380,10 +414,19 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
|
||||
private void ensureUniqueEmployeeNo(Long userId, String employeeNo, Long excludeId) {
|
||||
int count = excludeId == null
|
||||
? expansionMapper.countSalesExpansionByEmployeeNo(userId, employeeNo)
|
||||
: expansionMapper.countSalesExpansionByEmployeeNoExcludingId(userId, employeeNo, excludeId);
|
||||
? expansionMapper.countSalesExpansionByEmployeeNo(employeeNo)
|
||||
: expansionMapper.countSalesExpansionByEmployeeNoExcludingId(employeeNo, excludeId);
|
||||
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 id="selectNextChannelCode" resultType="java.lang.String">
|
||||
select 'QD-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad((
|
||||
coalesce((
|
||||
select count(1)
|
||||
from crm_channel_expansion
|
||||
where created_at::date = current_date
|
||||
), 0) + 1
|
||||
)::text, 3, '0')
|
||||
select 'QD-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad(
|
||||
(case when seq.is_called then seq.last_value + 1 else seq.last_value end)::text,
|
||||
6,
|
||||
'0'
|
||||
)
|
||||
from crm_channel_expansion_code_seq seq
|
||||
</select>
|
||||
|
||||
<select id="selectSalesExpansions" resultType="com.unis.crm.dto.expansion.SalesExpansionItemDTO">
|
||||
|
|
@ -380,13 +379,7 @@
|
|||
owner_user_id,
|
||||
remark
|
||||
) values (
|
||||
'QD-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad((
|
||||
coalesce((
|
||||
select count(1)
|
||||
from crm_channel_expansion
|
||||
where created_at::date = current_date
|
||||
), 0) + 1
|
||||
)::text, 3, '0'),
|
||||
'QD-' || to_char(current_date, 'YYYYMMDD') || '-' || lpad(nextval('crm_channel_expansion_code_seq')::text, 6, '0'),
|
||||
#{request.province},
|
||||
#{request.city},
|
||||
#{request.channelName},
|
||||
|
|
@ -459,15 +452,26 @@
|
|||
<select id="countSalesExpansionByEmployeeNo" resultType="int">
|
||||
select count(1)
|
||||
from crm_sales_expansion
|
||||
where owner_user_id = #{userId}
|
||||
and employee_no = #{employeeNo}
|
||||
where employee_no = #{employeeNo}
|
||||
</select>
|
||||
|
||||
<select id="countSalesExpansionByEmployeeNoExcludingId" resultType="int">
|
||||
select count(1)
|
||||
from crm_sales_expansion
|
||||
where owner_user_id = #{userId}
|
||||
and employee_no = #{employeeNo}
|
||||
where 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}
|
||||
</select>
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,80 +1,86 @@
|
|||
{
|
||||
"hash": "9b703341",
|
||||
"hash": "4ed97220",
|
||||
"configHash": "4d48f89c",
|
||||
"lockfileHash": "446f7b50",
|
||||
"browserHash": "97389217",
|
||||
"lockfileHash": "25980767",
|
||||
"browserHash": "0ac37406",
|
||||
"optimized": {
|
||||
"react": {
|
||||
"src": "../../react/index.js",
|
||||
"file": "react.js",
|
||||
"fileHash": "0e15d179",
|
||||
"fileHash": "5499e597",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react-dom": {
|
||||
"src": "../../react-dom/index.js",
|
||||
"file": "react-dom.js",
|
||||
"fileHash": "56e0ac1a",
|
||||
"fileHash": "ee512445",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react/jsx-dev-runtime": {
|
||||
"src": "../../react/jsx-dev-runtime.js",
|
||||
"file": "react_jsx-dev-runtime.js",
|
||||
"fileHash": "3873dbfb",
|
||||
"fileHash": "b416d357",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react/jsx-runtime": {
|
||||
"src": "../../react/jsx-runtime.js",
|
||||
"file": "react_jsx-runtime.js",
|
||||
"fileHash": "36bae069",
|
||||
"fileHash": "d2d6e3f0",
|
||||
"needsInterop": true
|
||||
},
|
||||
"clsx": {
|
||||
"src": "../../clsx/dist/clsx.mjs",
|
||||
"file": "clsx.js",
|
||||
"fileHash": "ef2b5b9d",
|
||||
"fileHash": "48844b26",
|
||||
"needsInterop": false
|
||||
},
|
||||
"date-fns": {
|
||||
"src": "../../date-fns/index.js",
|
||||
"file": "date-fns.js",
|
||||
"fileHash": "b5d072ca",
|
||||
"fileHash": "e0776950",
|
||||
"needsInterop": false
|
||||
},
|
||||
"lucide-react": {
|
||||
"src": "../../lucide-react/dist/esm/lucide-react.js",
|
||||
"file": "lucide-react.js",
|
||||
"fileHash": "938b715f",
|
||||
"fileHash": "2b6331af",
|
||||
"needsInterop": false
|
||||
},
|
||||
"motion/react": {
|
||||
"src": "../../motion/dist/es/react.mjs",
|
||||
"file": "motion_react.js",
|
||||
"fileHash": "fb0e4ab1",
|
||||
"fileHash": "6ad27c2c",
|
||||
"needsInterop": false
|
||||
},
|
||||
"react-dom/client": {
|
||||
"src": "../../react-dom/client.js",
|
||||
"file": "react-dom_client.js",
|
||||
"fileHash": "c4c41e0d",
|
||||
"fileHash": "105dfe43",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react-router-dom": {
|
||||
"src": "../../react-router-dom/dist/index.mjs",
|
||||
"file": "react-router-dom.js",
|
||||
"fileHash": "5254f612",
|
||||
"fileHash": "8926882a",
|
||||
"needsInterop": false
|
||||
},
|
||||
"tailwind-merge": {
|
||||
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
|
||||
"file": "tailwind-merge.js",
|
||||
"fileHash": "9fbd3db9",
|
||||
"fileHash": "2c815557",
|
||||
"needsInterop": false
|
||||
},
|
||||
"date-fns/locale": {
|
||||
"src": "../../date-fns/locale.js",
|
||||
"file": "date-fns_locale.js",
|
||||
"fileHash": "31cc6364",
|
||||
"fileHash": "00bef214",
|
||||
"needsInterop": false
|
||||
},
|
||||
"exceljs": {
|
||||
"src": "../../exceljs/dist/exceljs.min.js",
|
||||
"file": "exceljs.js",
|
||||
"fileHash": "7f44c50c",
|
||||
"needsInterop": true
|
||||
}
|
||||
},
|
||||
"chunks": {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -17,6 +17,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.21.2",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
|
|
|
|||
|
|
@ -443,6 +443,11 @@ export interface ExpansionMeta {
|
|||
nextChannelCode?: string;
|
||||
}
|
||||
|
||||
export interface ExpansionDuplicateCheck {
|
||||
duplicated?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CreateSalesExpansionPayload {
|
||||
employeeNo: string;
|
||||
candidateName: string;
|
||||
|
|
@ -896,6 +901,22 @@ export async function getExpansionCityOptions(provinceName: string) {
|
|||
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) {
|
||||
return request<number>("/api/expansion/sales", {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
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 { useLocation } from "react-router-dom";
|
||||
import {
|
||||
checkChannelExpansionDuplicate,
|
||||
checkSalesExpansionDuplicate,
|
||||
createChannelExpansion,
|
||||
createSalesExpansion,
|
||||
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) {
|
||||
const errors: Partial<Record<SalesCreateField, string>> = {};
|
||||
|
||||
|
|
@ -392,8 +590,14 @@ export default function Expansion() {
|
|||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = 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 [editError, setEditError] = useState("");
|
||||
const [exportError, setExportError] = useState("");
|
||||
const [salesDuplicateMessage, setSalesDuplicateMessage] = useState("");
|
||||
const [channelDuplicateMessage, setChannelDuplicateMessage] = useState("");
|
||||
const [salesCreateFieldErrors, setSalesCreateFieldErrors] = useState<Partial<Record<SalesCreateField, string>>>({});
|
||||
const [salesEditFieldErrors, setSalesEditFieldErrors] = useState<Partial<Record<SalesCreateField, string>>>({});
|
||||
const [channelCreateFieldErrors, setChannelCreateFieldErrors] = useState<Partial<Record<ChannelField, string>>>({});
|
||||
|
|
@ -520,8 +724,88 @@ export default function Expansion() {
|
|||
}
|
||||
}, [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]) => {
|
||||
setSalesForm((current) => ({ ...current, [key]: value }));
|
||||
if (key === "employeeNo") {
|
||||
setSalesDuplicateMessage("");
|
||||
setSalesDuplicateChecking(false);
|
||||
}
|
||||
if (key in salesCreateFieldErrors) {
|
||||
setSalesCreateFieldErrors((current) => {
|
||||
const next = { ...current };
|
||||
|
|
@ -533,6 +817,10 @@ export default function Expansion() {
|
|||
|
||||
const handleChannelChange = <K extends keyof CreateChannelExpansionPayload>(key: K, value: CreateChannelExpansionPayload[K]) => {
|
||||
setChannelForm((current) => ({ ...current, [key]: value }));
|
||||
if (key === "channelName") {
|
||||
setChannelDuplicateMessage("");
|
||||
setChannelDuplicateChecking(false);
|
||||
}
|
||||
if (key in channelCreateFieldErrors) {
|
||||
setChannelCreateFieldErrors((current) => {
|
||||
const next = { ...current };
|
||||
|
|
@ -618,6 +906,10 @@ export default function Expansion() {
|
|||
const resetCreateState = () => {
|
||||
setCreateOpen(false);
|
||||
setCreateError("");
|
||||
setSalesDuplicateChecking(false);
|
||||
setChannelDuplicateChecking(false);
|
||||
setSalesDuplicateMessage("");
|
||||
setChannelDuplicateMessage("");
|
||||
setSalesCreateFieldErrors({});
|
||||
setChannelCreateFieldErrors({});
|
||||
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);
|
||||
|
||||
try {
|
||||
|
|
@ -843,18 +1155,100 @@ export default function Expansion() {
|
|||
const handleTabChange = (tab: ExpansionTab) => {
|
||||
setActiveTab(tab);
|
||||
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 = (
|
||||
form: CreateSalesExpansionPayload,
|
||||
onChange: <K extends keyof CreateSalesExpansionPayload>(key: K, value: CreateSalesExpansionPayload[K]) => void,
|
||||
fieldErrors?: Partial<Record<SalesCreateField, string>>,
|
||||
isEdit = false,
|
||||
) => (
|
||||
<div className="crm-form-grid">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">工号<RequiredMark /></span>
|
||||
<input value={form.employeeNo} onChange={(e) => onChange("employeeNo", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors?.employeeNo))} />
|
||||
{fieldErrors?.employeeNo ? <p className="text-xs text-rose-500">{fieldErrors.employeeNo}</p> : null}
|
||||
{!fieldErrors?.employeeNo && !isEdit && salesDuplicateMessage ? <p className="text-xs text-rose-500">{salesDuplicateMessage}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<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>
|
||||
<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 && !isEdit && channelDuplicateMessage ? <p className="text-xs text-rose-500">{channelDuplicateMessage}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<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">
|
||||
<div className="crm-page-heading">
|
||||
<h1 className="crm-page-title">拓展管理</h1>
|
||||
<p className="crm-page-subtitle hidden sm:block">维护销售拓展与渠道拓展的基础资料,并查看关联项目与跟进记录。</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenCreate}
|
||||
className={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 className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
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")}
|
||||
>
|
||||
<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>
|
||||
|
||||
<div className={cn("crm-filter-bar flex", !disableMobileMotion && "backdrop-blur-sm")}>
|
||||
|
|
@ -1252,11 +1656,16 @@ export default function Expansion() {
|
|||
type="text"
|
||||
placeholder="搜索工号、姓名、渠道名称、行业..."
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{exportError ? <div className="crm-alert crm-alert-error">{exportError}</div> : null}
|
||||
|
||||
<div className="crm-list-stack">
|
||||
{activeTab === "sales" ? (
|
||||
salesData.length > 0 ? (
|
||||
|
|
@ -1352,7 +1761,7 @@ export default function Expansion() {
|
|||
</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}
|
||||
</ModalShell>
|
||||
)}
|
||||
|
|
@ -1371,7 +1780,7 @@ export default function Expansion() {
|
|||
</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}
|
||||
</ModalShell>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -262,22 +262,11 @@ export default function LoginPage() {
|
|||
<h1>{appName}</h1>
|
||||
</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 className="login-panel-formWrap">
|
||||
<div className="login-panel-header">
|
||||
<p className="login-panel-eyebrow">欢迎回来</p>
|
||||
<h3>{appName}</h3>
|
||||
</div>
|
||||
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { 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";
|
||||
|
|
@ -74,6 +74,39 @@ function formatAmount(value?: number) {
|
|||
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 {
|
||||
return {
|
||||
opportunityName: item.name || "",
|
||||
|
|
@ -480,6 +513,8 @@ function SearchableSelect({
|
|||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [desktopDropdownPlacement, setDesktopDropdownPlacement] = useState<"top" | "bottom">("bottom");
|
||||
const [desktopDropdownMaxHeight, setDesktopDropdownMaxHeight] = useState(256);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const isMobile = useIsMobileViewport();
|
||||
const normalizedOptions = dedupeSearchableOptions(options);
|
||||
|
|
@ -502,6 +537,25 @@ function SearchableSelect({
|
|||
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) => {
|
||||
if (!containerRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
|
|
@ -509,9 +563,17 @@ function SearchableSelect({
|
|||
}
|
||||
};
|
||||
|
||||
const handleViewportChange = () => {
|
||||
updateDesktopDropdownLayout();
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
window.addEventListener("resize", handleViewportChange);
|
||||
window.addEventListener("scroll", handleViewportChange, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
window.removeEventListener("resize", handleViewportChange);
|
||||
window.removeEventListener("scroll", handleViewportChange, true);
|
||||
};
|
||||
}, [isMobile, open]);
|
||||
|
||||
|
|
@ -540,7 +602,7 @@ function SearchableSelect({
|
|||
/>
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
|
@ -610,9 +672,17 @@ function SearchableSelect({
|
|||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
|
@ -857,7 +927,9 @@ export default function Opportunities() {
|
|||
const [pushConfirmOpen, setPushConfirmOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [pushingOms, setPushingOms] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [exportError, setExportError] = useState("");
|
||||
const [items, setItems] = useState<OpportunityItem[]>([]);
|
||||
const [salesExpansionOptions, setSalesExpansionOptions] = useState<SalesExpansionItem[]>([]);
|
||||
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";
|
||||
};
|
||||
|
||||
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]) => {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
if (key in fieldErrors) {
|
||||
|
|
@ -1261,20 +1474,32 @@ export default function Opportunities() {
|
|||
<header className="crm-page-header">
|
||||
<div className="crm-page-heading">
|
||||
<h1 className="crm-page-title">商机储备</h1>
|
||||
<p className="crm-page-subtitle hidden sm:block">维护商机基础信息,查看关联拓展对象和日报自动回写的跟进记录。</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenCreate}
|
||||
className={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 className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => void handleExport()}
|
||||
disabled={exporting}
|
||||
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")}
|
||||
>
|
||||
<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>
|
||||
|
||||
<div className={cn("crm-filter-bar flex", !disableMobileMotion && "backdrop-blur-sm")}>
|
||||
<button
|
||||
onClick={() => setArchiveTab("active")}
|
||||
onClick={() => {
|
||||
setArchiveTab("active");
|
||||
setExportError("");
|
||||
}}
|
||||
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
|
||||
archiveTab === "active"
|
||||
? "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
|
||||
onClick={() => setArchiveTab("archived")}
|
||||
onClick={() => {
|
||||
setArchiveTab("archived");
|
||||
setExportError("");
|
||||
}}
|
||||
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
|
||||
archiveTab === "archived"
|
||||
? "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"
|
||||
placeholder="搜索项目名称、最终客户、编码..."
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1322,6 +1553,8 @@ export default function Opportunities() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{exportError ? <div className="crm-alert crm-alert-error">{exportError}</div> : null}
|
||||
|
||||
<div className="crm-list-stack">
|
||||
{visibleItems.length > 0 ? (
|
||||
visibleItems.map((opp, i) => (
|
||||
|
|
@ -1336,7 +1569,7 @@ export default function Opportunities() {
|
|||
<div className="flex items-start justify-between gap-3">
|
||||
<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>
|
||||
<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 className="shrink-0 flex items-center gap-2 pl-2">
|
||||
<span className={getConfidenceBadgeClass(opp.confidence)}>
|
||||
|
|
@ -1418,6 +1651,7 @@ export default function Opportunities() {
|
|||
type="button"
|
||||
onClick={() => {
|
||||
setFilter(stage.value);
|
||||
setExportError("");
|
||||
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 ${
|
||||
|
|
|
|||
|
|
@ -376,29 +376,6 @@ export default function Profile() {
|
|||
</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
|
||||
|
|
|
|||
|
|
@ -2425,15 +2425,45 @@ function getHistoryLabelBySection(section: WorkSection) {
|
|||
}
|
||||
|
||||
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[] {
|
||||
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[] {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -75,8 +75,10 @@
|
|||
|
||||
.login-brand-lockup h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.8rem, 3vw, 2.7rem);
|
||||
font-size: clamp(1.7rem, 2.4vw, 2.45rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.08;
|
||||
white-space: nowrap;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
|
|
@ -109,8 +111,8 @@
|
|||
display: flex;
|
||||
min-height: 100%;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 32px;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding: 36px;
|
||||
border-radius: 28px;
|
||||
background:
|
||||
|
|
@ -119,37 +121,6 @@
|
|||
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 {
|
||||
display: flex;
|
||||
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 unique index if not exists uk_crm_sales_expansion_owner_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_owner on crm_opportunity(owner_user_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_stage on crm_channel_expansion(stage);
|
||||
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_opportunity_channel_expansion on crm_opportunity(channel_expansion_id);
|
||||
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
|
||||
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_owner on crm_opportunity(owner_user_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_stage on crm_channel_expansion(stage);
|
||||
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_opportunity_channel_expansion on crm_opportunity(channel_expansion_id);
|
||||
create index if not exists idx_crm_expansion_followup_biz_time
|
||||
|
|
|
|||
Loading…
Reference in New Issue