权限与日报
parent
50c1dceccc
commit
19d8cf7e9b
|
|
@ -5,10 +5,21 @@
|
||||||
</component>
|
</component>
|
||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="4c558d98-824e-4a48-ba48-bd2e6172f9f4" name="更改" comment="修改定位信息 0323">
|
<list default="true" id="4c558d98-824e-4a48-ba48-bd2e6172f9f4" name="更改" comment="修改定位信息 0323">
|
||||||
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/ExpansionMapper.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/SalesExpansionItemDTO.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/expansion/SalesExpansionItemDTO.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/ProfileMapper.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/mapper/ProfileMapper.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/ExpansionServiceImpl.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml" afterDir="false" />
|
||||||
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml" afterDir="false" />
|
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/profile/ProfileMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/profile/ProfileMapper.xml" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/backend/src/main/resources/mapper/work/WorkMapper.xml" beforeDir="false" afterPath="$PROJECT_DIR$/backend/src/main/resources/mapper/work/WorkMapper.xml" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/frontend/src/lib/auth.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/lib/auth.ts" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/frontend/src/pages/Expansion.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Expansion.tsx" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/frontend/src/pages/Opportunities.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Opportunities.tsx" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/frontend/src/pages/Work.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/src/pages/Work.tsx" afterDir="false" />
|
||||||
</list>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
|
|
@ -87,6 +98,7 @@
|
||||||
<workItem from="1774244004381" duration="10126000" />
|
<workItem from="1774244004381" duration="10126000" />
|
||||||
<workItem from="1774574410059" duration="200000" />
|
<workItem from="1774574410059" duration="200000" />
|
||||||
<workItem from="1774576185724" duration="1651000" />
|
<workItem from="1774576185724" duration="1651000" />
|
||||||
|
<workItem from="1774763863609" duration="16084000" />
|
||||||
</task>
|
</task>
|
||||||
<task id="LOCAL-00001" summary="修改定位信息 0323">
|
<task id="LOCAL-00001" summary="修改定位信息 0323">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
package com.unis.crm.common;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.springframework.core.MethodParameter;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.converter.HttpMessageConverter;
|
||||||
|
import org.springframework.http.server.ServerHttpRequest;
|
||||||
|
import org.springframework.http.server.ServerHttpResponse;
|
||||||
|
import org.springframework.http.server.ServletServerHttpRequest;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
|
||||||
|
|
||||||
|
@ControllerAdvice(annotations = Controller.class)
|
||||||
|
public class CurrentUserRoleCodesResponseAdvice implements ResponseBodyAdvice<Object> {
|
||||||
|
|
||||||
|
private static final String CURRENT_USER_PATH = "/sys/api/users/me";
|
||||||
|
private static final String USER_ROLE_CODES_SQL = """
|
||||||
|
select r.role_code
|
||||||
|
from sys_user_role ur
|
||||||
|
join sys_role r on r.role_id = ur.role_id
|
||||||
|
where ur.user_id = ?
|
||||||
|
and ur.is_deleted = 0
|
||||||
|
and r.is_deleted = 0
|
||||||
|
order by r.role_id asc
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public CurrentUserRoleCodesResponseAdvice(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object beforeBodyWrite(
|
||||||
|
Object body,
|
||||||
|
MethodParameter returnType,
|
||||||
|
MediaType selectedContentType,
|
||||||
|
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||||
|
ServerHttpRequest request,
|
||||||
|
ServerHttpResponse response) {
|
||||||
|
if (!isCurrentUserRequest(request) || body == null) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> responseBody = objectMapper.convertValue(
|
||||||
|
body,
|
||||||
|
new TypeReference<LinkedHashMap<String, Object>>() {
|
||||||
|
});
|
||||||
|
if (!"0".equals(String.valueOf(responseBody.get("code")))) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object dataValue = responseBody.get("data");
|
||||||
|
if (dataValue == null) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> data = objectMapper.convertValue(
|
||||||
|
dataValue,
|
||||||
|
new TypeReference<LinkedHashMap<String, Object>>() {
|
||||||
|
});
|
||||||
|
Long userId = parseUserId(data.get("userId"));
|
||||||
|
if (userId == null) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> roleCodes = jdbcTemplate.queryForList(USER_ROLE_CODES_SQL, String.class, userId);
|
||||||
|
data.put("roleCodes", roleCodes == null ? List.of() : roleCodes);
|
||||||
|
responseBody.put("data", data);
|
||||||
|
return responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isCurrentUserRequest(ServerHttpRequest request) {
|
||||||
|
if (!(request instanceof ServletServerHttpRequest servletRequest)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return CURRENT_USER_PATH.equals(servletRequest.getServletRequest().getRequestURI());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long parseUserId(Object value) {
|
||||||
|
if (value instanceof Number number) {
|
||||||
|
return number.longValue();
|
||||||
|
}
|
||||||
|
if (value instanceof String text) {
|
||||||
|
try {
|
||||||
|
return Long.parseLong(text.trim());
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import java.util.List;
|
||||||
public class ChannelExpansionItemDTO {
|
public class ChannelExpansionItemDTO {
|
||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
|
private Long ownerUserId;
|
||||||
private String type;
|
private String type;
|
||||||
private String channelCode;
|
private String channelCode;
|
||||||
private String name;
|
private String name;
|
||||||
|
|
@ -48,6 +49,14 @@ public class ChannelExpansionItemDTO {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getOwnerUserId() {
|
||||||
|
return ownerUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOwnerUserId(Long ownerUserId) {
|
||||||
|
this.ownerUserId = ownerUserId;
|
||||||
|
}
|
||||||
|
|
||||||
public String getType() {
|
public String getType() {
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import java.util.List;
|
||||||
public class SalesExpansionItemDTO {
|
public class SalesExpansionItemDTO {
|
||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
|
private Long ownerUserId;
|
||||||
private String type;
|
private String type;
|
||||||
private String employeeNo;
|
private String employeeNo;
|
||||||
private String name;
|
private String name;
|
||||||
|
|
@ -39,6 +40,14 @@ public class SalesExpansionItemDTO {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getOwnerUserId() {
|
||||||
|
return ownerUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOwnerUserId(Long ownerUserId) {
|
||||||
|
this.ownerUserId = ownerUserId;
|
||||||
|
}
|
||||||
|
|
||||||
public String getType() {
|
public String getType() {
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import java.util.List;
|
||||||
public class OpportunityItemDTO {
|
public class OpportunityItemDTO {
|
||||||
|
|
||||||
private Long id;
|
private Long id;
|
||||||
|
private Long ownerUserId;
|
||||||
private String code;
|
private String code;
|
||||||
private String name;
|
private String name;
|
||||||
private String client;
|
private String client;
|
||||||
|
|
@ -44,6 +45,14 @@ public class OpportunityItemDTO {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Long getOwnerUserId() {
|
||||||
|
return ownerUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOwnerUserId(Long ownerUserId) {
|
||||||
|
this.ownerUserId = ownerUserId;
|
||||||
|
}
|
||||||
|
|
||||||
public String getCode() {
|
public String getCode() {
|
||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ public interface ProfileMapper {
|
||||||
|
|
||||||
List<String> selectUserRoleNames(@Param("userId") Long userId);
|
List<String> selectUserRoleNames(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
List<String> selectUserRoleCodes(@Param("userId") Long userId);
|
||||||
|
|
||||||
List<String> selectUserOrgNames(@Param("userId") Long userId);
|
List<String> selectUserOrgNames(@Param("userId") Long userId);
|
||||||
|
|
||||||
Long selectMonthlyOpportunityCount(@Param("userId") Long userId);
|
Long selectMonthlyOpportunityCount(@Param("userId") Long userId);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import com.unis.crm.dto.work.WorkCheckInDTO;
|
||||||
import com.unis.crm.dto.work.WorkDailyReportDTO;
|
import com.unis.crm.dto.work.WorkDailyReportDTO;
|
||||||
import com.unis.crm.dto.work.WorkHistoryItemDTO;
|
import com.unis.crm.dto.work.WorkHistoryItemDTO;
|
||||||
import com.unis.crm.dto.work.WorkSuggestedActionDTO;
|
import com.unis.crm.dto.work.WorkSuggestedActionDTO;
|
||||||
|
import com.unisbase.annotation.DataScope;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
@ -19,13 +20,11 @@ public interface WorkMapper {
|
||||||
|
|
||||||
List<WorkSuggestedActionDTO> selectTodayWorkContentActions(@Param("userId") Long userId);
|
List<WorkSuggestedActionDTO> selectTodayWorkContentActions(@Param("userId") Long userId);
|
||||||
|
|
||||||
List<WorkHistoryItemDTO> selectHistory(@Param("userId") Long userId);
|
@DataScope(tableAlias = "c", ownerColumn = "user_id")
|
||||||
|
List<WorkHistoryItemDTO> selectCheckInHistory(@Param("limit") int limit);
|
||||||
|
|
||||||
List<WorkHistoryItemDTO> selectHistoryPage(
|
@DataScope(tableAlias = "r", ownerColumn = "user_id")
|
||||||
@Param("userId") Long userId,
|
List<WorkHistoryItemDTO> selectReportHistory(@Param("limit") int limit);
|
||||||
@Param("historyType") String historyType,
|
|
||||||
@Param("limit") int limit,
|
|
||||||
@Param("offset") int offset);
|
|
||||||
|
|
||||||
Long selectTodayCheckInId(@Param("userId") Long userId);
|
Long selectTodayCheckInId(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,6 @@ public class ExpansionServiceImpl implements ExpansionService {
|
||||||
@Override
|
@Override
|
||||||
public void updateSalesExpansion(Long userId, Long id, UpdateSalesExpansionRequest request) {
|
public void updateSalesExpansion(Long userId, Long id, UpdateSalesExpansionRequest request) {
|
||||||
fillSalesDefaults(request);
|
fillSalesDefaults(request);
|
||||||
ensureUniqueEmployeeNo(userId, request.getEmployeeNo(), id);
|
|
||||||
int updated = expansionMapper.updateSalesExpansion(userId, id, request);
|
int updated = expansionMapper.updateSalesExpansion(userId, id, request);
|
||||||
if (updated <= 0) {
|
if (updated <= 0) {
|
||||||
throw new BusinessException("未找到可编辑的销售拓展记录");
|
throw new BusinessException("未找到可编辑的销售拓展记录");
|
||||||
|
|
@ -167,7 +166,6 @@ public class ExpansionServiceImpl implements ExpansionService {
|
||||||
@Transactional
|
@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("未找到可编辑的渠道拓展记录");
|
||||||
|
|
|
||||||
|
|
@ -152,9 +152,6 @@ public class OpportunityServiceImpl implements OpportunityService {
|
||||||
if (opportunityMapper.countOwnedOpportunity(userId, opportunityId) <= 0) {
|
if (opportunityMapper.countOwnedOpportunity(userId, opportunityId) <= 0) {
|
||||||
throw new BusinessException("无权操作该商机");
|
throw new BusinessException("无权操作该商机");
|
||||||
}
|
}
|
||||||
if (Boolean.TRUE.equals(opportunityMapper.selectPushedToOms(userId, opportunityId))) {
|
|
||||||
throw new BusinessException("该商机已推送 OMS,请勿重复操作");
|
|
||||||
}
|
|
||||||
|
|
||||||
OpportunityOmsPushDataDTO pushData = opportunityMapper.selectOpportunityOmsPushData(userId, opportunityId);
|
OpportunityOmsPushDataDTO pushData = opportunityMapper.selectOpportunityOmsPushData(userId, opportunityId);
|
||||||
if (pushData == null) {
|
if (pushData == null) {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import java.time.ZoneId;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
@ -112,17 +113,20 @@ public class WorkServiceImpl implements WorkService {
|
||||||
requireUser(userId);
|
requireUser(userId);
|
||||||
int safePage = Math.max(page, 1);
|
int safePage = Math.max(page, 1);
|
||||||
int safeSize = Math.min(Math.max(size, 1), 20);
|
int safeSize = Math.min(Math.max(size, 1), 20);
|
||||||
int querySize = safeSize + 1;
|
|
||||||
int offset = (safePage - 1) * safeSize;
|
int offset = (safePage - 1) * safeSize;
|
||||||
String historyType = mapHistoryType(type);
|
HistoryType historyType = mapHistoryType(type);
|
||||||
List<WorkHistoryItemDTO> historyItems = workMapper.selectHistoryPage(userId, historyType, querySize, offset);
|
int fetchLimit = offset + safeSize + 1;
|
||||||
normalizeHistoryMetadata(historyItems);
|
List<WorkHistoryItemDTO> historyItems = loadHistoryItems(historyType, fetchLimit);
|
||||||
|
|
||||||
boolean hasMore = historyItems.size() > safeSize;
|
boolean hasMore = historyItems.size() > offset + safeSize;
|
||||||
if (hasMore) {
|
if (offset >= historyItems.size()) {
|
||||||
historyItems = new ArrayList<>(historyItems.subList(0, safeSize));
|
return new WorkHistoryPageDTO(List.of(), hasMore, safePage, safeSize);
|
||||||
}
|
}
|
||||||
return new WorkHistoryPageDTO(historyItems, hasMore, safePage, safeSize);
|
|
||||||
|
int toIndex = Math.min(offset + safeSize, historyItems.size());
|
||||||
|
List<WorkHistoryItemDTO> pagedItems = new ArrayList<>(historyItems.subList(offset, toIndex));
|
||||||
|
normalizeHistoryMetadata(pagedItems);
|
||||||
|
return new WorkHistoryPageDTO(pagedItems, hasMore, safePage, safeSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -279,18 +283,55 @@ public class WorkServiceImpl implements WorkService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String mapHistoryType(String type) {
|
private List<WorkHistoryItemDTO> loadHistoryItems(HistoryType historyType, int fetchLimit) {
|
||||||
|
List<WorkHistoryItemDTO> historyItems = new ArrayList<>();
|
||||||
|
if (historyType == null || historyType == HistoryType.CHECK_IN) {
|
||||||
|
historyItems.addAll(workMapper.selectCheckInHistory(fetchLimit));
|
||||||
|
}
|
||||||
|
if (historyType == null || historyType == HistoryType.REPORT) {
|
||||||
|
historyItems.addAll(workMapper.selectReportHistory(fetchLimit));
|
||||||
|
}
|
||||||
|
historyItems.sort(buildHistoryComparator());
|
||||||
|
return historyItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HistoryType mapHistoryType(String type) {
|
||||||
String normalizedType = normalizeOptionalText(type);
|
String normalizedType = normalizeOptionalText(type);
|
||||||
if (normalizedType == null) {
|
if (normalizedType == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return switch (normalizedType) {
|
return switch (normalizedType) {
|
||||||
case "checkin" -> "外勤打卡";
|
case "checkin" -> HistoryType.CHECK_IN;
|
||||||
case "report" -> "日报";
|
case "report" -> HistoryType.REPORT;
|
||||||
default -> throw new BusinessException("历史记录类型不支持");
|
default -> throw new BusinessException("历史记录类型不支持");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Comparator<WorkHistoryItemDTO> buildHistoryComparator() {
|
||||||
|
return Comparator
|
||||||
|
.comparing(this::buildHistorySortKey, Comparator.reverseOrder())
|
||||||
|
.thenComparing(WorkHistoryItemDTO::getId, Comparator.nullsLast(Comparator.reverseOrder()))
|
||||||
|
.thenComparing(WorkHistoryItemDTO::getType, Comparator.nullsLast(Comparator.naturalOrder()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildHistorySortKey(WorkHistoryItemDTO item) {
|
||||||
|
if (item == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String date = normalizeOptionalText(item.getDate());
|
||||||
|
String time = normalizeOptionalText(item.getTime());
|
||||||
|
if (date == null && time == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (date == null) {
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
if (time == null) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
return date + " " + time;
|
||||||
|
}
|
||||||
|
|
||||||
private String buildSuggestedWorkContent(List<WorkSuggestedActionDTO> actions) {
|
private String buildSuggestedWorkContent(List<WorkSuggestedActionDTO> actions) {
|
||||||
if (actions == null || actions.isEmpty()) {
|
if (actions == null || actions.isEmpty()) {
|
||||||
return "";
|
return "";
|
||||||
|
|
@ -510,7 +551,7 @@ public class WorkServiceImpl implements WorkService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ReportLineMetadata metadata = extractReportLineMetadata(todayReport.getWorkContent());
|
ReportLineMetadata metadata = extractReportLineMetadata(todayReport.getWorkContent());
|
||||||
todayReport.setWorkContent(metadata.cleanText());
|
todayReport.setWorkContent(stripVisitTimeFromReportText(metadata.cleanText()));
|
||||||
todayReport.setLineItems(metadata.lineItems());
|
todayReport.setLineItems(metadata.lineItems());
|
||||||
PlanItemMetadata planMetadata = extractPlanItemMetadata(todayReport.getTomorrowPlan());
|
PlanItemMetadata planMetadata = extractPlanItemMetadata(todayReport.getTomorrowPlan());
|
||||||
todayReport.setTomorrowPlan(planMetadata.cleanText());
|
todayReport.setTomorrowPlan(planMetadata.cleanText());
|
||||||
|
|
@ -528,11 +569,20 @@ public class WorkServiceImpl implements WorkService {
|
||||||
PhotoMetadata photoMetadata = extractPhotoMetadata(historyItem.getContent());
|
PhotoMetadata photoMetadata = extractPhotoMetadata(historyItem.getContent());
|
||||||
ReportLineMetadata reportLineMetadata = extractReportLineMetadata(photoMetadata.cleanText());
|
ReportLineMetadata reportLineMetadata = extractReportLineMetadata(photoMetadata.cleanText());
|
||||||
PlanItemMetadata planMetadata = extractPlanItemMetadata(reportLineMetadata.cleanText());
|
PlanItemMetadata planMetadata = extractPlanItemMetadata(reportLineMetadata.cleanText());
|
||||||
historyItem.setContent(planMetadata.cleanText());
|
historyItem.setContent(stripVisitTimeFromReportText(planMetadata.cleanText()));
|
||||||
historyItem.setPhotoUrls(photoMetadata.photoUrls());
|
historyItem.setPhotoUrls(photoMetadata.photoUrls());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String stripVisitTimeFromReportText(String rawText) {
|
||||||
|
String normalized = rawText == null ? "" : rawText;
|
||||||
|
return normalized
|
||||||
|
.replaceAll(":拜访时间:[^;\\n]+;", ":")
|
||||||
|
.replaceAll("\\n拜访时间:[^\\n]+", "")
|
||||||
|
.replaceAll("\\n{3,}", "\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
private PhotoMetadata extractPhotoMetadata(String rawText) {
|
private PhotoMetadata extractPhotoMetadata(String rawText) {
|
||||||
String normalized = rawText == null ? "" : rawText;
|
String normalized = rawText == null ? "" : rawText;
|
||||||
Matcher matcher = PHOTO_METADATA_PATTERN.matcher(normalized);
|
Matcher matcher = PHOTO_METADATA_PATTERN.matcher(normalized);
|
||||||
|
|
@ -684,16 +734,12 @@ public class WorkServiceImpl implements WorkService {
|
||||||
|
|
||||||
private String buildExpansionLineContent(WorkReportLineItemRequest item) {
|
private String buildExpansionLineContent(WorkReportLineItemRequest item) {
|
||||||
String communication = normalizeOptionalText(item.getEvaluationContent());
|
String communication = normalizeOptionalText(item.getEvaluationContent());
|
||||||
String visitStartTime = normalizeOptionalText(item.getVisitStartTime());
|
|
||||||
String nextPlan = normalizeOptionalText(item.getNextPlan());
|
String nextPlan = normalizeOptionalText(item.getNextPlan());
|
||||||
if (communication == null && visitStartTime == null && nextPlan == null) {
|
if (communication == null && nextPlan == null) {
|
||||||
return normalizeOptionalText(item.getContent());
|
return normalizeOptionalText(item.getContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> parts = new ArrayList<>();
|
List<String> parts = new ArrayList<>();
|
||||||
if (visitStartTime != null) {
|
|
||||||
parts.add("拜访时间:" + visitStartTime);
|
|
||||||
}
|
|
||||||
if (communication != null) {
|
if (communication != null) {
|
||||||
parts.add("沟通内容:" + communication);
|
parts.add("沟通内容:" + communication);
|
||||||
}
|
}
|
||||||
|
|
@ -1557,5 +1603,10 @@ public class WorkServiceImpl implements WorkService {
|
||||||
|
|
||||||
private record PlanItemMetadata(String cleanText, List<WorkTomorrowPlanItemDTO> planItems) {}
|
private record PlanItemMetadata(String cleanText, List<WorkTomorrowPlanItemDTO> planItems) {}
|
||||||
|
|
||||||
|
private enum HistoryType {
|
||||||
|
CHECK_IN,
|
||||||
|
REPORT
|
||||||
|
}
|
||||||
|
|
||||||
private record TencentLocationCandidate(String coordType, String locationText, int score) {}
|
private record TencentLocationCandidate(String coordType, String locationText, int score) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
<select id="selectSalesExpansions" resultType="com.unis.crm.dto.expansion.SalesExpansionItemDTO">
|
<select id="selectSalesExpansions" resultType="com.unis.crm.dto.expansion.SalesExpansionItemDTO">
|
||||||
select
|
select
|
||||||
s.id,
|
s.id,
|
||||||
|
s.owner_user_id as ownerUserId,
|
||||||
'sales' as type,
|
'sales' as type,
|
||||||
coalesce(s.employee_no, '无') as employeeNo,
|
coalesce(s.employee_no, '无') as employeeNo,
|
||||||
s.candidate_name as name,
|
s.candidate_name as name,
|
||||||
|
|
@ -115,6 +116,7 @@
|
||||||
<select id="selectChannelExpansions" resultType="com.unis.crm.dto.expansion.ChannelExpansionItemDTO">
|
<select id="selectChannelExpansions" resultType="com.unis.crm.dto.expansion.ChannelExpansionItemDTO">
|
||||||
select
|
select
|
||||||
c.id,
|
c.id,
|
||||||
|
c.owner_user_id as ownerUserId,
|
||||||
'channel' as type,
|
'channel' as type,
|
||||||
coalesce(c.channel_code, '') as channelCode,
|
coalesce(c.channel_code, '') as channelCode,
|
||||||
c.channel_name as name,
|
c.channel_name as name,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
<select id="selectOpportunities" resultType="com.unis.crm.dto.opportunity.OpportunityItemDTO">
|
<select id="selectOpportunities" resultType="com.unis.crm.dto.opportunity.OpportunityItemDTO">
|
||||||
select
|
select
|
||||||
o.id,
|
o.id,
|
||||||
|
o.owner_user_id as ownerUserId,
|
||||||
o.opportunity_code as code,
|
o.opportunity_code as code,
|
||||||
o.opportunity_name as name,
|
o.opportunity_name as name,
|
||||||
coalesce(c.customer_name, '未填写最终客户') as client,
|
coalesce(c.customer_name, '未填写最终客户') as client,
|
||||||
|
|
@ -449,11 +450,10 @@
|
||||||
<update id="markOpportunityOmsPushed">
|
<update id="markOpportunityOmsPushed">
|
||||||
update crm_opportunity o
|
update crm_opportunity o
|
||||||
set pushed_to_oms = true,
|
set pushed_to_oms = true,
|
||||||
oms_push_time = coalesce(oms_push_time, now()),
|
oms_push_time = now(),
|
||||||
opportunity_code = #{opportunityCode},
|
opportunity_code = #{opportunityCode},
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
where o.id = #{opportunityId}
|
where o.id = #{opportunityId}
|
||||||
and coalesce(pushed_to_oms, false) = false
|
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<insert id="insertOpportunityFollowUp">
|
<insert id="insertOpportunityFollowUp">
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,16 @@
|
||||||
order by r.role_id asc
|
order by r.role_id asc
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<select id="selectUserRoleCodes" resultType="java.lang.String">
|
||||||
|
select r.role_code
|
||||||
|
from sys_user_role ur
|
||||||
|
join sys_role r on r.role_id = ur.role_id
|
||||||
|
where ur.user_id = #{userId}
|
||||||
|
and ur.is_deleted = 0
|
||||||
|
and r.is_deleted = 0
|
||||||
|
order by r.role_id asc
|
||||||
|
</select>
|
||||||
|
|
||||||
<select id="selectUserOrgNames" resultType="java.lang.String">
|
<select id="selectUserOrgNames" resultType="java.lang.String">
|
||||||
select o.org_name
|
select o.org_name
|
||||||
from sys_tenant_user tu
|
from sys_tenant_user tu
|
||||||
|
|
|
||||||
|
|
@ -168,17 +168,7 @@
|
||||||
order by action_time asc, group_name asc, detail asc
|
order by action_time asc, group_name asc, detail asc
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="selectHistory" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
|
<select id="selectCheckInHistory" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
|
||||||
select
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
date,
|
|
||||||
time,
|
|
||||||
content,
|
|
||||||
status,
|
|
||||||
score,
|
|
||||||
comment
|
|
||||||
from (
|
|
||||||
select
|
select
|
||||||
c.id,
|
c.id,
|
||||||
'外勤打卡' as type,
|
'外勤打卡' as type,
|
||||||
|
|
@ -192,10 +182,6 @@
|
||||||
when c.user_name is not null and btrim(c.user_name) <> '' then '打卡人:' || c.user_name || E'\n'
|
when c.user_name is not null and btrim(c.user_name) <> '' then '打卡人:' || c.user_name || E'\n'
|
||||||
else ''
|
else ''
|
||||||
end ||
|
end ||
|
||||||
case
|
|
||||||
when c.dept_name is not null and btrim(c.dept_name) <> '' then '所属部门:' || c.dept_name || E'\n'
|
|
||||||
else ''
|
|
||||||
end ||
|
|
||||||
coalesce(c.location_text, '') ||
|
coalesce(c.location_text, '') ||
|
||||||
case
|
case
|
||||||
when c.remark is not null and btrim(c.remark) <> '' then E'\n备注:' || c.remark
|
when c.remark is not null and btrim(c.remark) <> '' then E'\n备注:' || c.remark
|
||||||
|
|
@ -210,119 +196,50 @@
|
||||||
null::text as comment,
|
null::text as comment,
|
||||||
coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) as sort_time
|
coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) as sort_time
|
||||||
from work_checkin c
|
from work_checkin c
|
||||||
where c.user_id = #{userId}
|
|
||||||
|
|
||||||
union all
|
|
||||||
|
|
||||||
select
|
|
||||||
r.id,
|
|
||||||
'日报' as type,
|
|
||||||
to_char(r.report_date, 'YYYY-MM-DD') as date,
|
|
||||||
to_char(r.submit_time, 'HH24:MI') as time,
|
|
||||||
coalesce(r.work_content, '') ||
|
|
||||||
case
|
|
||||||
when r.tomorrow_plan is not null and btrim(r.tomorrow_plan) <> '' then E'\n明日计划:' || r.tomorrow_plan
|
|
||||||
else ''
|
|
||||||
end as content,
|
|
||||||
case coalesce(rc.comment_content, '')
|
|
||||||
when '' then
|
|
||||||
case coalesce(r.status, 'submitted')
|
|
||||||
when 'submitted' then '已提交'
|
|
||||||
when 'reviewed' then '已点评'
|
|
||||||
else coalesce(r.status, '已提交')
|
|
||||||
end
|
|
||||||
else '已点评'
|
|
||||||
end as status,
|
|
||||||
rc.score,
|
|
||||||
rc.comment_content as comment,
|
|
||||||
coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) as sort_time
|
|
||||||
from work_daily_report r
|
|
||||||
left join (
|
|
||||||
select distinct on (report_id)
|
|
||||||
report_id,
|
|
||||||
score,
|
|
||||||
comment_content
|
|
||||||
from work_daily_report_comment
|
|
||||||
order by report_id, reviewed_at desc nulls last, id desc
|
|
||||||
) rc on rc.report_id = r.id
|
|
||||||
where r.user_id = #{userId}
|
|
||||||
) history
|
|
||||||
order by sort_time desc nulls last, id desc
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select id="selectHistoryPage" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
|
|
||||||
select
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
date,
|
|
||||||
time,
|
|
||||||
content,
|
|
||||||
status,
|
|
||||||
score,
|
|
||||||
comment
|
|
||||||
from (
|
|
||||||
select
|
|
||||||
c.id,
|
|
||||||
'外勤打卡' as type,
|
|
||||||
to_char(c.checkin_date, 'YYYY-MM-DD') as date,
|
|
||||||
to_char(c.checkin_time, 'HH24:MI') as time,
|
|
||||||
coalesce(c.location_text, '') ||
|
|
||||||
case
|
|
||||||
when c.remark is not null and btrim(c.remark) <> '' then E'\n备注:' || c.remark
|
|
||||||
else ''
|
|
||||||
end as content,
|
|
||||||
case coalesce(c.status, 'normal')
|
|
||||||
when 'normal' then '正常'
|
|
||||||
when 'updated' then '已更新'
|
|
||||||
else coalesce(c.status, '正常')
|
|
||||||
end as status,
|
|
||||||
null::integer as score,
|
|
||||||
null::text as comment,
|
|
||||||
coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) as sort_time
|
|
||||||
from work_checkin c
|
|
||||||
where c.user_id = #{userId}
|
|
||||||
|
|
||||||
union all
|
|
||||||
|
|
||||||
select
|
|
||||||
r.id,
|
|
||||||
'日报' as type,
|
|
||||||
to_char(r.report_date, 'YYYY-MM-DD') as date,
|
|
||||||
to_char(r.submit_time, 'HH24:MI') as time,
|
|
||||||
coalesce(r.work_content, '') ||
|
|
||||||
case
|
|
||||||
when r.tomorrow_plan is not null and btrim(r.tomorrow_plan) <> '' then E'\n明日计划:' || r.tomorrow_plan
|
|
||||||
else ''
|
|
||||||
end as content,
|
|
||||||
case coalesce(rc.comment_content, '')
|
|
||||||
when '' then
|
|
||||||
case coalesce(r.status, 'submitted')
|
|
||||||
when 'submitted' then '已提交'
|
|
||||||
when 'reviewed' then '已点评'
|
|
||||||
else coalesce(r.status, '已提交')
|
|
||||||
end
|
|
||||||
else '已点评'
|
|
||||||
end as status,
|
|
||||||
rc.score,
|
|
||||||
rc.comment_content as comment,
|
|
||||||
coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) as sort_time
|
|
||||||
from work_daily_report r
|
|
||||||
left join (
|
|
||||||
select distinct on (report_id)
|
|
||||||
report_id,
|
|
||||||
score,
|
|
||||||
comment_content
|
|
||||||
from work_daily_report_comment
|
|
||||||
order by report_id, reviewed_at desc nulls last, id desc
|
|
||||||
) rc on rc.report_id = r.id
|
|
||||||
where r.user_id = #{userId}
|
|
||||||
) history
|
|
||||||
<if test="historyType != null and historyType != ''">
|
|
||||||
where history.type = #{historyType}
|
|
||||||
</if>
|
|
||||||
order by sort_time desc nulls last, id desc
|
order by sort_time desc nulls last, id desc
|
||||||
limit #{limit}
|
limit #{limit}
|
||||||
offset #{offset}
|
</select>
|
||||||
|
|
||||||
|
<select id="selectReportHistory" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
|
||||||
|
select
|
||||||
|
r.id,
|
||||||
|
'日报' as type,
|
||||||
|
to_char(r.report_date, 'YYYY-MM-DD') as date,
|
||||||
|
to_char(r.submit_time, 'HH24:MI') as time,
|
||||||
|
case
|
||||||
|
when coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), '')) is not null
|
||||||
|
then '提交人:' || coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), '')) || E'\n'
|
||||||
|
else ''
|
||||||
|
end ||
|
||||||
|
coalesce(r.work_content, '') ||
|
||||||
|
case
|
||||||
|
when r.tomorrow_plan is not null and btrim(r.tomorrow_plan) <> '' then E'\n明日计划:' || r.tomorrow_plan
|
||||||
|
else ''
|
||||||
|
end as content,
|
||||||
|
case coalesce(rc.comment_content, '')
|
||||||
|
when '' then
|
||||||
|
case coalesce(r.status, 'submitted')
|
||||||
|
when 'submitted' then '已提交'
|
||||||
|
when 'reviewed' then '已点评'
|
||||||
|
else coalesce(r.status, '已提交')
|
||||||
|
end
|
||||||
|
else '已点评'
|
||||||
|
end as status,
|
||||||
|
rc.score,
|
||||||
|
rc.comment_content as comment,
|
||||||
|
coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) as sort_time
|
||||||
|
from work_daily_report r
|
||||||
|
left join sys_user u on u.user_id = r.user_id and u.is_deleted = 0
|
||||||
|
left join (
|
||||||
|
select distinct on (report_id)
|
||||||
|
report_id,
|
||||||
|
score,
|
||||||
|
comment_content
|
||||||
|
from work_daily_report_comment
|
||||||
|
order by report_id, reviewed_at desc nulls last, id desc
|
||||||
|
) rc on rc.report_id = r.id
|
||||||
|
order by sort_time desc nulls last, id desc
|
||||||
|
limit #{limit}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="selectTodayCheckInId" resultType="java.lang.Long">
|
<select id="selectTodayCheckInId" resultType="java.lang.Long">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package com.unis.crm.mapper;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
|
||||||
|
import com.unisbase.annotation.DataScope;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class WorkMapperDataScopeTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void selectCheckInHistory_shouldUseDataScopeOnCheckInUserId() throws Exception {
|
||||||
|
Method method = WorkMapper.class.getMethod("selectCheckInHistory", int.class);
|
||||||
|
|
||||||
|
DataScope dataScope = method.getAnnotation(DataScope.class);
|
||||||
|
|
||||||
|
assertNotNull(dataScope);
|
||||||
|
assertEquals("c", dataScope.tableAlias());
|
||||||
|
assertEquals("user_id", dataScope.ownerColumn());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void selectReportHistory_shouldUseDataScopeOnReportUserId() throws Exception {
|
||||||
|
Method method = WorkMapper.class.getMethod("selectReportHistory", int.class);
|
||||||
|
|
||||||
|
DataScope dataScope = method.getAnnotation(DataScope.class);
|
||||||
|
|
||||||
|
assertNotNull(dataScope);
|
||||||
|
assertEquals("r", dataScope.tableAlias());
|
||||||
|
assertEquals("user_id", dataScope.ownerColumn());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
package com.unis.crm.service.impl;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.unis.crm.common.BusinessException;
|
||||||
|
import com.unis.crm.dto.expansion.ChannelExpansionContactRequest;
|
||||||
|
import com.unis.crm.dto.expansion.CreateChannelExpansionRequest;
|
||||||
|
import com.unis.crm.dto.expansion.CreateSalesExpansionRequest;
|
||||||
|
import com.unis.crm.dto.expansion.UpdateChannelExpansionRequest;
|
||||||
|
import com.unis.crm.dto.expansion.UpdateSalesExpansionRequest;
|
||||||
|
import com.unis.crm.mapper.ExpansionMapper;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ExpansionServiceImplTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ExpansionMapper expansionMapper;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private ExpansionServiceImpl expansionService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createSalesExpansion_shouldRejectDuplicateEmployeeNo() {
|
||||||
|
CreateSalesExpansionRequest request = buildCreateSalesRequest();
|
||||||
|
when(expansionMapper.countSalesExpansionByEmployeeNo("EMP001")).thenReturn(1);
|
||||||
|
|
||||||
|
BusinessException exception = assertThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> expansionService.createSalesExpansion(1L, request));
|
||||||
|
|
||||||
|
assertEquals("工号重复,请确认该人员是否已存在!", exception.getMessage());
|
||||||
|
verify(expansionMapper, never()).insertSalesExpansion(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateSalesExpansion_shouldSkipDuplicateEmployeeNoCheck() {
|
||||||
|
UpdateSalesExpansionRequest request = buildUpdateSalesRequest();
|
||||||
|
when(expansionMapper.updateSalesExpansion(1L, 10L, request)).thenReturn(1);
|
||||||
|
|
||||||
|
expansionService.updateSalesExpansion(1L, 10L, request);
|
||||||
|
|
||||||
|
verify(expansionMapper).updateSalesExpansion(1L, 10L, request);
|
||||||
|
verify(expansionMapper, never()).countSalesExpansionByEmployeeNo(any());
|
||||||
|
verify(expansionMapper, never()).countSalesExpansionByEmployeeNoExcludingId(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createChannelExpansion_shouldRejectDuplicateChannelName() {
|
||||||
|
CreateChannelExpansionRequest request = buildCreateChannelRequest();
|
||||||
|
when(expansionMapper.countChannelExpansionByChannelName("渠道A")).thenReturn(1);
|
||||||
|
|
||||||
|
BusinessException exception = assertThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> expansionService.createChannelExpansion(1L, request));
|
||||||
|
|
||||||
|
assertEquals("渠道重复,请确认该渠道是否已存在!", exception.getMessage());
|
||||||
|
verify(expansionMapper, never()).insertChannelExpansion(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateChannelExpansion_shouldSkipDuplicateChannelNameCheck() {
|
||||||
|
UpdateChannelExpansionRequest request = buildUpdateChannelRequest();
|
||||||
|
when(expansionMapper.updateChannelExpansion(1L, 20L, request)).thenReturn(1);
|
||||||
|
|
||||||
|
expansionService.updateChannelExpansion(1L, 20L, request);
|
||||||
|
|
||||||
|
verify(expansionMapper).updateChannelExpansion(1L, 20L, request);
|
||||||
|
verify(expansionMapper).deleteChannelContacts(20L);
|
||||||
|
verify(expansionMapper).insertChannelContact(eq(20L), eq(1), any(ChannelExpansionContactRequest.class));
|
||||||
|
verify(expansionMapper, never()).countChannelExpansionByChannelName(any());
|
||||||
|
verify(expansionMapper, never()).countChannelExpansionByChannelNameExcludingId(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private CreateSalesExpansionRequest buildCreateSalesRequest() {
|
||||||
|
CreateSalesExpansionRequest request = new CreateSalesExpansionRequest();
|
||||||
|
request.setEmployeeNo("EMP001");
|
||||||
|
request.setCandidateName("张三");
|
||||||
|
request.setIntentLevel("medium");
|
||||||
|
request.setStage("initial_contact");
|
||||||
|
request.setHasDesktopExp(Boolean.FALSE);
|
||||||
|
request.setInProgress(Boolean.TRUE);
|
||||||
|
request.setEmploymentStatus("active");
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UpdateSalesExpansionRequest buildUpdateSalesRequest() {
|
||||||
|
UpdateSalesExpansionRequest request = new UpdateSalesExpansionRequest();
|
||||||
|
request.setEmployeeNo("EMP001");
|
||||||
|
request.setCandidateName("张三");
|
||||||
|
request.setIntentLevel("medium");
|
||||||
|
request.setStage("initial_contact");
|
||||||
|
request.setHasDesktopExp(Boolean.FALSE);
|
||||||
|
request.setInProgress(Boolean.TRUE);
|
||||||
|
request.setEmploymentStatus("active");
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private CreateChannelExpansionRequest buildCreateChannelRequest() {
|
||||||
|
CreateChannelExpansionRequest request = new CreateChannelExpansionRequest();
|
||||||
|
request.setChannelName("渠道A");
|
||||||
|
request.setProvince("浙江省");
|
||||||
|
request.setCity("杭州市");
|
||||||
|
request.setCertificationLevel("A");
|
||||||
|
request.setOfficeAddress("未来科技城");
|
||||||
|
request.setChannelIndustry("制造业");
|
||||||
|
request.setAnnualRevenue(BigDecimal.valueOf(100));
|
||||||
|
request.setStaffSize(20);
|
||||||
|
request.setContactEstablishedDate(LocalDate.of(2024, 1, 1));
|
||||||
|
request.setChannelAttribute("direct");
|
||||||
|
request.setInternalAttribute("partner");
|
||||||
|
request.setIntentLevel("medium");
|
||||||
|
request.setStage("initial_contact");
|
||||||
|
request.setHasDesktopExp(Boolean.FALSE);
|
||||||
|
request.setLandedFlag(Boolean.FALSE);
|
||||||
|
request.setContacts(List.of(buildContact()));
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UpdateChannelExpansionRequest buildUpdateChannelRequest() {
|
||||||
|
UpdateChannelExpansionRequest request = new UpdateChannelExpansionRequest();
|
||||||
|
request.setChannelName("渠道A");
|
||||||
|
request.setProvince("浙江省");
|
||||||
|
request.setCity("杭州市");
|
||||||
|
request.setCertificationLevel("A");
|
||||||
|
request.setOfficeAddress("未来科技城");
|
||||||
|
request.setChannelIndustry("制造业");
|
||||||
|
request.setAnnualRevenue(BigDecimal.valueOf(100));
|
||||||
|
request.setStaffSize(20);
|
||||||
|
request.setContactEstablishedDate(LocalDate.of(2024, 1, 1));
|
||||||
|
request.setChannelAttribute("direct");
|
||||||
|
request.setInternalAttribute("partner");
|
||||||
|
request.setIntentLevel("medium");
|
||||||
|
request.setStage("initial_contact");
|
||||||
|
request.setHasDesktopExp(Boolean.FALSE);
|
||||||
|
request.setLandedFlag(Boolean.FALSE);
|
||||||
|
request.setContacts(List.of(buildContact()));
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChannelExpansionContactRequest buildContact() {
|
||||||
|
ChannelExpansionContactRequest contact = new ChannelExpansionContactRequest();
|
||||||
|
contact.setName("李四");
|
||||||
|
contact.setMobile("13800138000");
|
||||||
|
contact.setTitle("负责人");
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
package com.unis.crm.service.impl;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.unis.crm.common.BusinessException;
|
||||||
|
import com.unis.crm.dto.work.WorkHistoryItemDTO;
|
||||||
|
import com.unis.crm.dto.work.WorkHistoryPageDTO;
|
||||||
|
import com.unis.crm.mapper.WorkMapper;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class WorkServiceImplTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private WorkMapper workMapper;
|
||||||
|
|
||||||
|
private WorkServiceImpl workService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
workService = new WorkServiceImpl(workMapper, new ObjectMapper(), "build/test-uploads", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getHistory_shouldMergeSortAndPaginateCheckInsAndReports() {
|
||||||
|
when(workMapper.selectCheckInHistory(3)).thenReturn(List.of(
|
||||||
|
historyItem(11L, "外勤打卡", "2026-04-02", "08:30", "打卡"),
|
||||||
|
historyItem(10L, "外勤打卡", "2026-04-01", "18:00", "打卡")));
|
||||||
|
when(workMapper.selectReportHistory(3)).thenReturn(List.of(
|
||||||
|
historyItem(21L, "日报", "2026-04-02", "09:15", "日报")));
|
||||||
|
|
||||||
|
WorkHistoryPageDTO result = workService.getHistory(17L, null, 1, 2);
|
||||||
|
|
||||||
|
assertEquals(2, result.getItems().size());
|
||||||
|
assertEquals("日报", result.getItems().get(0).getType());
|
||||||
|
assertEquals(21L, result.getItems().get(0).getId());
|
||||||
|
assertEquals("外勤打卡", result.getItems().get(1).getType());
|
||||||
|
assertEquals(11L, result.getItems().get(1).getId());
|
||||||
|
assertEquals(true, result.isHasMore());
|
||||||
|
verify(workMapper).selectCheckInHistory(3);
|
||||||
|
verify(workMapper).selectReportHistory(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getHistory_shouldOnlyQueryCheckInHistoryWhenTypeIsCheckin() {
|
||||||
|
when(workMapper.selectCheckInHistory(2)).thenReturn(List.of(
|
||||||
|
historyItem(11L, "外勤打卡", "2026-04-02", "08:30", "打卡")));
|
||||||
|
|
||||||
|
WorkHistoryPageDTO result = workService.getHistory(17L, "checkin", 1, 1);
|
||||||
|
|
||||||
|
assertEquals(1, result.getItems().size());
|
||||||
|
assertEquals("外勤打卡", result.getItems().get(0).getType());
|
||||||
|
assertEquals(false, result.isHasMore());
|
||||||
|
verify(workMapper).selectCheckInHistory(2);
|
||||||
|
verify(workMapper, never()).selectReportHistory(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getHistory_shouldReturnSecondPageAfterGlobalSort() {
|
||||||
|
when(workMapper.selectCheckInHistory(3)).thenReturn(List.of(
|
||||||
|
historyItem(11L, "外勤打卡", "2026-04-02", "08:30", "打卡"),
|
||||||
|
historyItem(10L, "外勤打卡", "2026-04-01", "18:00", "打卡")));
|
||||||
|
when(workMapper.selectReportHistory(3)).thenReturn(List.of(
|
||||||
|
historyItem(21L, "日报", "2026-04-02", "09:15", "日报")));
|
||||||
|
|
||||||
|
WorkHistoryPageDTO result = workService.getHistory(17L, null, 2, 1);
|
||||||
|
|
||||||
|
assertEquals(1, result.getItems().size());
|
||||||
|
assertEquals(11L, result.getItems().get(0).getId());
|
||||||
|
assertEquals(true, result.isHasMore());
|
||||||
|
verify(workMapper).selectCheckInHistory(3);
|
||||||
|
verify(workMapper).selectReportHistory(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getHistory_shouldRejectUnsupportedType() {
|
||||||
|
BusinessException exception = assertThrows(
|
||||||
|
BusinessException.class,
|
||||||
|
() -> workService.getHistory(17L, "todo", 1, 10));
|
||||||
|
|
||||||
|
assertEquals("历史记录类型不支持", exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkHistoryItemDTO historyItem(Long id, String type, String date, String time, String content) {
|
||||||
|
WorkHistoryItemDTO item = new WorkHistoryItemDTO();
|
||||||
|
item.setId(id);
|
||||||
|
item.setType(type);
|
||||||
|
item.setDate(date);
|
||||||
|
item.setTime(time);
|
||||||
|
item.setContent(content);
|
||||||
|
item.setStatus("已提交");
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,14 @@ export interface UserProfile {
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
isPlatformAdmin?: boolean;
|
isPlatformAdmin?: boolean;
|
||||||
pwdResetRequired?: number;
|
pwdResetRequired?: number;
|
||||||
|
roleCodes?: string[];
|
||||||
|
roles?: UserRole[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserRole {
|
||||||
|
roleId?: number;
|
||||||
|
roleCode?: string;
|
||||||
|
roleName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlatformConfig {
|
export interface PlatformConfig {
|
||||||
|
|
@ -241,6 +249,7 @@ export interface OpportunityFollowUp {
|
||||||
|
|
||||||
export interface OpportunityItem {
|
export interface OpportunityItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
ownerUserId?: number;
|
||||||
code?: string;
|
code?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
client?: string;
|
client?: string;
|
||||||
|
|
@ -339,6 +348,7 @@ export interface ExpansionFollowUp {
|
||||||
|
|
||||||
export interface SalesExpansionItem {
|
export interface SalesExpansionItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
ownerUserId?: number;
|
||||||
type: "sales";
|
type: "sales";
|
||||||
employeeNo?: string;
|
employeeNo?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
@ -374,6 +384,7 @@ export interface RelatedProjectSummary {
|
||||||
|
|
||||||
export interface ChannelExpansionItem {
|
export interface ChannelExpansionItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
ownerUserId?: number;
|
||||||
type: "channel";
|
type: "channel";
|
||||||
channelCode?: string;
|
channelCode?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
@ -713,6 +724,10 @@ function getStoredUserId() {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStoredCurrentUserId() {
|
||||||
|
return getStoredUserId();
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchCaptcha() {
|
export async function fetchCaptcha() {
|
||||||
return request<CaptchaResponse>("/api/sys/auth/captcha");
|
return request<CaptchaResponse>("/api/sys/auth/captcha");
|
||||||
}
|
}
|
||||||
|
|
@ -751,10 +766,21 @@ export async function getOpenPlatformConfig() {
|
||||||
export async function getCurrentUser() {
|
export async function getCurrentUser() {
|
||||||
return getCachedAuthedRequest<UserProfile>(
|
return getCachedAuthedRequest<UserProfile>(
|
||||||
CURRENT_USER_CACHE_KEY,
|
CURRENT_USER_CACHE_KEY,
|
||||||
() => request<UserProfile>("/api/sys/api/users/me", undefined, true),
|
async () => {
|
||||||
|
const profile = await request<UserProfile>("/api/sys/api/users/me", undefined, true);
|
||||||
|
persistStoredUserProfile(profile);
|
||||||
|
return profile;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshCurrentUser() {
|
||||||
|
clearCachedAuthContext();
|
||||||
|
const profile = await request<UserProfile>("/api/sys/api/users/me", undefined, true);
|
||||||
|
persistStoredUserProfile(profile);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getDashboardHome() {
|
export async function getDashboardHome() {
|
||||||
return request<DashboardHome>("/api/dashboard/home", undefined, true);
|
return request<DashboardHome>("/api/dashboard/home", undefined, true);
|
||||||
}
|
}
|
||||||
|
|
@ -1006,6 +1032,14 @@ function clearCachedAuthContext() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function persistStoredUserProfile(profile: UserProfile) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("userProfile", JSON.stringify(profile));
|
||||||
|
} catch {
|
||||||
|
// Ignore session storage failures and keep request cache only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getCachedAuthedRequest<T>(
|
async function getCachedAuthedRequest<T>(
|
||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
fetcher: () => Promise<T>,
|
fetcher: () => Promise<T>,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
getExpansionCityOptions,
|
getExpansionCityOptions,
|
||||||
getExpansionMeta,
|
getExpansionMeta,
|
||||||
getExpansionOverview,
|
getExpansionOverview,
|
||||||
|
getStoredCurrentUserId,
|
||||||
updateChannelExpansion,
|
updateChannelExpansion,
|
||||||
updateSalesExpansion,
|
updateSalesExpansion,
|
||||||
type ChannelExpansionContact,
|
type ChannelExpansionContact,
|
||||||
|
|
@ -566,6 +567,7 @@ function RequiredMark() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Expansion() {
|
export default function Expansion() {
|
||||||
|
const currentUserId = getStoredCurrentUserId();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isMobileViewport = useIsMobileViewport();
|
const isMobileViewport = useIsMobileViewport();
|
||||||
const isWecomBrowser = useIsWecomBrowser();
|
const isWecomBrowser = useIsWecomBrowser();
|
||||||
|
|
@ -606,6 +608,7 @@ export default function Expansion() {
|
||||||
const [invalidEditChannelContactRows, setInvalidEditChannelContactRows] = useState<number[]>([]);
|
const [invalidEditChannelContactRows, setInvalidEditChannelContactRows] = useState<number[]>([]);
|
||||||
const [salesDetailTab, setSalesDetailTab] = useState<"projects" | "followups">("projects");
|
const [salesDetailTab, setSalesDetailTab] = useState<"projects" | "followups">("projects");
|
||||||
const [channelDetailTab, setChannelDetailTab] = useState<"projects" | "contacts" | "followups">("projects");
|
const [channelDetailTab, setChannelDetailTab] = useState<"projects" | "contacts" | "followups">("projects");
|
||||||
|
const canEditSelectedItem = Boolean(selectedItem && currentUserId !== undefined && selectedItem.ownerUserId === currentUserId);
|
||||||
|
|
||||||
const [salesForm, setSalesForm] = useState<CreateSalesExpansionPayload>(defaultSalesForm);
|
const [salesForm, setSalesForm] = useState<CreateSalesExpansionPayload>(defaultSalesForm);
|
||||||
const [channelForm, setChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm);
|
const [channelForm, setChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm);
|
||||||
|
|
@ -945,6 +948,9 @@ export default function Expansion() {
|
||||||
if (!selectedItem) {
|
if (!selectedItem) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canEditSelectedItem) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setEditError("");
|
setEditError("");
|
||||||
let latestIndustryOptions = industryOptions;
|
let latestIndustryOptions = industryOptions;
|
||||||
|
|
@ -1078,6 +1084,10 @@ export default function Expansion() {
|
||||||
if (!selectedItem || submitting) {
|
if (!selectedItem || submitting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canEditSelectedItem) {
|
||||||
|
setEditError("仅可编辑本人创建的数据");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setEditError("");
|
setEditError("");
|
||||||
if (selectedItem.type === "sales") {
|
if (selectedItem.type === "sales") {
|
||||||
|
|
@ -1669,24 +1679,46 @@ export default function Expansion() {
|
||||||
<div className="crm-list-stack">
|
<div className="crm-list-stack">
|
||||||
{activeTab === "sales" ? (
|
{activeTab === "sales" ? (
|
||||||
salesData.length > 0 ? (
|
salesData.length > 0 ? (
|
||||||
salesData.map((item, i) => (
|
salesData.map((item, i) => {
|
||||||
|
const isOwnedByCurrentUser = currentUserId !== undefined && item.ownerUserId === currentUserId;
|
||||||
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
|
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => setSelectedItem(item)}
|
onClick={() => setSelectedItem(item)}
|
||||||
className="crm-card crm-card-pad relative cursor-pointer rounded-2xl transition-shadow transition-colors hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
|
className={cn(
|
||||||
|
"crm-card crm-card-pad relative rounded-2xl transition-shadow transition-colors",
|
||||||
|
isOwnedByCurrentUser
|
||||||
|
? "cursor-pointer hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
|
||||||
|
: "cursor-pointer border-slate-100 bg-slate-50/45 hover:border-slate-200 hover:shadow-sm dark:border-slate-800/80 dark:bg-slate-900/35 dark:hover:border-slate-700",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
|
{isOwnedByCurrentUser ? (
|
||||||
|
<div className="pointer-events-none absolute inset-x-5 top-0 h-1.5 rounded-b-full bg-gradient-to-r from-violet-400/85 via-fuchsia-400/70 to-indigo-400/85 shadow-[0_6px_16px_rgba(124,58,237,0.18)] dark:from-violet-400/70 dark:via-fuchsia-400/55 dark:to-indigo-400/70" />
|
||||||
|
) : null}
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="break-anywhere text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{item.name || "无"}</h3>
|
<h3 className="break-anywhere text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{item.name || "无"}</h3>
|
||||||
<p className="break-anywhere mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">{item.officeName || "无"} · {item.dept || "无"} · {item.title || "无"}</p>
|
<p className="break-anywhere mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">{item.officeName || "无"} · {item.dept || "无"} · {item.title || "无"}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="shrink-0 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-2.5 py-1 text-[11px] font-semibold",
|
||||||
|
isOwnedByCurrentUser
|
||||||
|
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||||
|
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isOwnedByCurrentUser ? "我的" : "只读"}
|
||||||
|
</span>
|
||||||
<span className={`crm-pill shrink-0 ${item.active ? "crm-pill-emerald" : "crm-pill-neutral"}`}>
|
<span className={`crm-pill shrink-0 ${item.active ? "crm-pill-emerald" : "crm-pill-neutral"}`}>
|
||||||
{item.active ? "在职" : "离职"}
|
{item.active ? "在职" : "离职"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="mt-4 grid grid-cols-1 gap-y-3 text-xs sm:grid-cols-2 sm:text-sm">
|
<div className="mt-4 grid grid-cols-1 gap-y-3 text-xs sm:grid-cols-2 sm:text-sm">
|
||||||
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
|
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
|
||||||
<span className="text-slate-400 dark:text-slate-500">意向:</span>
|
<span className="text-slate-400 dark:text-slate-500">意向:</span>
|
||||||
|
|
@ -1704,18 +1736,29 @@ export default function Expansion() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
) : renderEmpty()
|
) : renderEmpty()
|
||||||
) : channelData.length > 0 ? (
|
) : channelData.length > 0 ? (
|
||||||
channelData.map((item, i) => (
|
channelData.map((item, i) => {
|
||||||
|
const isOwnedByCurrentUser = currentUserId !== undefined && item.ownerUserId === currentUserId;
|
||||||
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
|
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => setSelectedItem(item)}
|
onClick={() => setSelectedItem(item)}
|
||||||
className="crm-card crm-card-pad relative cursor-pointer rounded-2xl transition-shadow transition-colors hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
|
className={cn(
|
||||||
|
"crm-card crm-card-pad relative rounded-2xl transition-shadow transition-colors",
|
||||||
|
isOwnedByCurrentUser
|
||||||
|
? "cursor-pointer hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
|
||||||
|
: "cursor-pointer border-slate-100 bg-slate-50/45 hover:border-slate-200 hover:shadow-sm dark:border-slate-800/80 dark:bg-slate-900/35 dark:hover:border-slate-700",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
|
{isOwnedByCurrentUser ? (
|
||||||
|
<div className="pointer-events-none absolute inset-x-5 top-0 h-1.5 rounded-b-full bg-gradient-to-r from-violet-400/85 via-fuchsia-400/70 to-indigo-400/85 shadow-[0_6px_16px_rgba(124,58,237,0.18)] dark:from-violet-400/70 dark:via-fuchsia-400/55 dark:to-indigo-400/70" />
|
||||||
|
) : null}
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="break-anywhere text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{item.name || "无"}</h3>
|
<h3 className="break-anywhere text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{item.name || "无"}</h3>
|
||||||
|
|
@ -1723,10 +1766,22 @@ export default function Expansion() {
|
||||||
{item.province || "无"} · {item.city || "无"} · {item.certificationLevel || "无"}
|
{item.province || "无"} · {item.city || "无"} · {item.certificationLevel || "无"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="shrink-0 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-2.5 py-1 text-[11px] font-semibold",
|
||||||
|
isOwnedByCurrentUser
|
||||||
|
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||||
|
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isOwnedByCurrentUser ? "我的" : "只读"}
|
||||||
|
</span>
|
||||||
<span className={`crm-pill shrink-0 ${item.intent === "高" ? "crm-pill-rose" : item.intent === "中" ? "crm-pill-amber" : "crm-pill-neutral"}`}>
|
<span className={`crm-pill shrink-0 ${item.intent === "高" ? "crm-pill-rose" : item.intent === "中" ? "crm-pill-amber" : "crm-pill-neutral"}`}>
|
||||||
{item.intent ? `${item.intent}意向` : "未评估"}
|
{item.intent ? `${item.intent}意向` : "未评估"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="mt-4 grid grid-cols-1 gap-y-3 text-xs sm:grid-cols-2 sm:text-sm">
|
<div className="mt-4 grid grid-cols-1 gap-y-3 text-xs sm:grid-cols-2 sm:text-sm">
|
||||||
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
|
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
|
||||||
<span className="text-slate-400 dark:text-slate-500">建立联系:</span>
|
<span className="text-slate-400 dark:text-slate-500">建立联系:</span>
|
||||||
|
|
@ -1744,7 +1799,8 @@ export default function Expansion() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
) : renderEmpty()}
|
) : renderEmpty()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -2022,9 +2078,18 @@ export default function Expansion() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sticky bottom-0 bg-slate-50/95 px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 backdrop-blur sm:static sm:p-4 dark:bg-slate-900/90">
|
<div className="sticky bottom-0 bg-slate-50/95 px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 backdrop-blur sm:static sm:p-4 dark:bg-slate-900/90">
|
||||||
|
{!canEditSelectedItem ? (
|
||||||
|
<p className="mb-3 text-xs text-slate-400 dark:text-slate-500">当前记录非本人创建,仅支持查看详情,不能编辑。</p>
|
||||||
|
) : null}
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<button onClick={handleOpenEdit} className="crm-btn-sm crm-btn-secondary flex-1">
|
<button
|
||||||
编辑资料
|
type="button"
|
||||||
|
onClick={() => void handleOpenEdit()}
|
||||||
|
disabled={!canEditSelectedItem}
|
||||||
|
title={canEditSelectedItem ? "编辑资料" : "仅本人可操作"}
|
||||||
|
className="crm-btn-sm crm-btn-secondary flex-1 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{canEditSelectedItem ? "编辑资料" : "仅本人可操作"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { Search, Plus, Download, 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, getStoredCurrentUserId, 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";
|
||||||
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -331,7 +331,7 @@ function validateOpportunityForm(
|
||||||
errors.opportunityName = "请填写项目名称";
|
errors.opportunityName = "请填写项目名称";
|
||||||
}
|
}
|
||||||
if (!form.customerName?.trim()) {
|
if (!form.customerName?.trim()) {
|
||||||
errors.customerName = "请填写最终客户";
|
errors.customerName = "请填写最终用户";
|
||||||
}
|
}
|
||||||
if (!form.operatorName?.trim()) {
|
if (!form.operatorName?.trim()) {
|
||||||
errors.operatorName = "请选择运作方";
|
errors.operatorName = "请选择运作方";
|
||||||
|
|
@ -914,6 +914,7 @@ function CompetitorMultiSelect({
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Opportunities() {
|
export default function Opportunities() {
|
||||||
|
const currentUserId = getStoredCurrentUserId();
|
||||||
const isMobileViewport = useIsMobileViewport();
|
const isMobileViewport = useIsMobileViewport();
|
||||||
const isWecomBrowser = useIsWecomBrowser();
|
const isWecomBrowser = useIsWecomBrowser();
|
||||||
const disableMobileMotion = isMobileViewport || isWecomBrowser;
|
const disableMobileMotion = isMobileViewport || isWecomBrowser;
|
||||||
|
|
@ -1089,6 +1090,8 @@ export default function Opportunities() {
|
||||||
const selectedSalesExpansionName = selectedItem?.salesExpansionName || selectedSalesExpansion?.name || "";
|
const selectedSalesExpansionName = selectedItem?.salesExpansionName || selectedSalesExpansion?.name || "";
|
||||||
const selectedChannelExpansionName = selectedItem?.channelExpansionName || selectedChannelExpansion?.name || "";
|
const selectedChannelExpansionName = selectedItem?.channelExpansionName || selectedChannelExpansion?.name || "";
|
||||||
const selectedPreSalesName = selectedItem?.preSalesName || "无";
|
const selectedPreSalesName = selectedItem?.preSalesName || "无";
|
||||||
|
const canEditSelectedItem = Boolean(selectedItem && currentUserId !== undefined && selectedItem.ownerUserId === currentUserId);
|
||||||
|
const canPushSelectedItem = canEditSelectedItem;
|
||||||
const operatorMode = resolveOperatorMode(form.operatorName, operatorOptions);
|
const operatorMode = resolveOperatorMode(form.operatorName, operatorOptions);
|
||||||
const showSalesExpansionField = operatorMode === "h3c" || operatorMode === "both";
|
const showSalesExpansionField = operatorMode === "h3c" || operatorMode === "both";
|
||||||
const showChannelExpansionField = operatorMode === "channel" || operatorMode === "both";
|
const showChannelExpansionField = operatorMode === "channel" || operatorMode === "both";
|
||||||
|
|
@ -1158,7 +1161,7 @@ export default function Opportunities() {
|
||||||
"项目编号",
|
"项目编号",
|
||||||
"项目名称",
|
"项目名称",
|
||||||
"项目地",
|
"项目地",
|
||||||
"最终客户",
|
"最终用户",
|
||||||
"建设类型",
|
"建设类型",
|
||||||
"运作方",
|
"运作方",
|
||||||
"项目阶段",
|
"项目阶段",
|
||||||
|
|
@ -1240,7 +1243,7 @@ export default function Opportunities() {
|
||||||
column.width = 42;
|
column.width = 42;
|
||||||
} else if (header.includes("项目最新进展") || header.includes("后续规划") || header.includes("备注")) {
|
} else if (header.includes("项目最新进展") || header.includes("后续规划") || header.includes("备注")) {
|
||||||
column.width = 24;
|
column.width = 24;
|
||||||
} else if (header.includes("项目名称") || header.includes("最终客户")) {
|
} else if (header.includes("项目名称") || header.includes("最终客户") || header.includes("最终用户")) {
|
||||||
column.width = 20;
|
column.width = 20;
|
||||||
} else {
|
} else {
|
||||||
column.width = 16;
|
column.width = 16;
|
||||||
|
|
@ -1347,6 +1350,10 @@ export default function Opportunities() {
|
||||||
if (!selectedItem) {
|
if (!selectedItem) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canEditSelectedItem) {
|
||||||
|
setError("仅可编辑本人负责的商机");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError("");
|
setError("");
|
||||||
setFieldErrors({});
|
setFieldErrors({});
|
||||||
setForm(toFormFromItem(selectedItem));
|
setForm(toFormFromItem(selectedItem));
|
||||||
|
|
@ -1360,6 +1367,10 @@ export default function Opportunities() {
|
||||||
if (!selectedItem || submitting) {
|
if (!selectedItem || submitting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canEditSelectedItem) {
|
||||||
|
setError("仅可编辑本人负责的商机");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setError("");
|
setError("");
|
||||||
const validationErrors = validateOpportunityForm(form, selectedCompetitors, customCompetitorName, operatorMode);
|
const validationErrors = validateOpportunityForm(form, selectedCompetitors, customCompetitorName, operatorMode);
|
||||||
|
|
@ -1382,7 +1393,11 @@ export default function Opportunities() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePushToOms = async () => {
|
const handlePushToOms = async () => {
|
||||||
if (!selectedItem || selectedItem.pushedToOms || pushingOms) {
|
if (!selectedItem || pushingOms) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!canPushSelectedItem) {
|
||||||
|
setError("仅可推送本人负责的商机");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1433,7 +1448,11 @@ export default function Opportunities() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenPushConfirm = async () => {
|
const handleOpenPushConfirm = async () => {
|
||||||
if (!selectedItem || selectedItem.pushedToOms || pushingOms) {
|
if (!selectedItem || pushingOms) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!canPushSelectedItem) {
|
||||||
|
setError("仅可推送本人负责的商机");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1455,6 +1474,10 @@ export default function Opportunities() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmPushToOms = async () => {
|
const handleConfirmPushToOms = async () => {
|
||||||
|
if (!canPushSelectedItem) {
|
||||||
|
setError("仅可推送本人负责的商机");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!pushPreSalesId && !pushPreSalesName.trim()) {
|
if (!pushPreSalesId && !pushPreSalesName.trim()) {
|
||||||
setError("请选择售前人员");
|
setError("请选择售前人员");
|
||||||
return;
|
return;
|
||||||
|
|
@ -1528,7 +1551,7 @@ export default function Opportunities() {
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 group-focus-within:text-violet-500 transition-colors" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 group-focus-within:text-violet-500 transition-colors" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索项目名称、最终客户、编码..."
|
placeholder="搜索项目名称、最终用户、编码..."
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setKeyword(event.target.value);
|
setKeyword(event.target.value);
|
||||||
|
|
@ -1557,27 +1580,57 @@ export default function Opportunities() {
|
||||||
|
|
||||||
<div className="crm-list-stack">
|
<div className="crm-list-stack">
|
||||||
{visibleItems.length > 0 ? (
|
{visibleItems.length > 0 ? (
|
||||||
visibleItems.map((opp, i) => (
|
visibleItems.map((opp, i) => {
|
||||||
|
const isOwnedByCurrentUser = currentUserId !== undefined && opp.ownerUserId === currentUserId;
|
||||||
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
|
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
|
||||||
key={opp.id}
|
key={opp.id}
|
||||||
onClick={() => setSelectedItem(opp)}
|
onClick={() => setSelectedItem(opp)}
|
||||||
className="crm-card crm-card-pad group relative cursor-pointer rounded-2xl transition-shadow transition-colors hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
|
className={cn(
|
||||||
|
"crm-card crm-card-pad group relative rounded-2xl transition-shadow transition-colors",
|
||||||
|
isOwnedByCurrentUser
|
||||||
|
? "cursor-pointer hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
|
||||||
|
: "cursor-pointer border-slate-100 bg-slate-50/45 hover:border-slate-200 hover:shadow-sm dark:border-slate-800/80 dark:bg-slate-900/35 dark:hover:border-slate-700",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
|
{isOwnedByCurrentUser ? (
|
||||||
|
<div className="pointer-events-none absolute inset-x-5 top-0 h-1.5 rounded-b-full bg-gradient-to-r from-violet-400/85 via-fuchsia-400/70 to-indigo-400/85 shadow-[0_6px_16px_rgba(124,58,237,0.18)] dark:from-violet-400/70 dark:via-fuchsia-400/55 dark:to-indigo-400/70" />
|
||||||
|
) : null}
|
||||||
<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 flex-wrap items-center justify-end gap-2 pl-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-2.5 py-1 text-[11px] font-semibold",
|
||||||
|
isOwnedByCurrentUser
|
||||||
|
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||||
|
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isOwnedByCurrentUser ? "我的" : "只读"}
|
||||||
|
</span>
|
||||||
<span className={getConfidenceBadgeClass(opp.confidence)}>
|
<span className={getConfidenceBadgeClass(opp.confidence)}>
|
||||||
{getConfidenceLabel(opp.confidence)}
|
{getConfidenceLabel(opp.confidence)}
|
||||||
</span>
|
</span>
|
||||||
<span className="crm-pill crm-pill-neutral">
|
<span className="crm-pill crm-pill-neutral">
|
||||||
{opp.stage || "初步沟通"}
|
{opp.stage || "初步沟通"}
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-2.5 py-1 text-[11px] font-semibold",
|
||||||
|
opp.pushedToOms
|
||||||
|
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||||
|
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opp.pushedToOms ? "已推送 OMS" : "未推送 OMS"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1606,7 +1659,8 @@ export default function Opportunities() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
) : renderEmpty()}
|
) : renderEmpty()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1706,7 +1760,7 @@ export default function Opportunities() {
|
||||||
{fieldErrors.opportunityName ? <p className="text-xs text-rose-500">{fieldErrors.opportunityName}</p> : null}
|
{fieldErrors.opportunityName ? <p className="text-xs text-rose-500">{fieldErrors.opportunityName}</p> : null}
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-2 sm:col-span-2">
|
<label className="space-y-2 sm:col-span-2">
|
||||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">最终客户<RequiredMark /></span>
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">最终用户<RequiredMark /></span>
|
||||||
<input value={form.customerName} onChange={(e) => handleChange("customerName", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.customerName))} />
|
<input value={form.customerName} onChange={(e) => handleChange("customerName", e.target.value)} className={getFieldInputClass(Boolean(fieldErrors.customerName))} />
|
||||||
{fieldErrors.customerName ? <p className="text-xs text-rose-500">{fieldErrors.customerName}</p> : null}
|
{fieldErrors.customerName ? <p className="text-xs text-rose-500">{fieldErrors.customerName}</p> : null}
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -2018,7 +2072,7 @@ export default function Opportunities() {
|
||||||
</h4>
|
</h4>
|
||||||
<div className="crm-detail-grid text-sm md:grid-cols-2">
|
<div className="crm-detail-grid text-sm md:grid-cols-2">
|
||||||
<DetailItem label="项目地" value={selectedItem.projectLocation || "无"} />
|
<DetailItem label="项目地" value={selectedItem.projectLocation || "无"} />
|
||||||
<DetailItem label="最终客户" value={selectedItem.client || "无"} icon={<Building className="h-3 w-3" />} />
|
<DetailItem label="最终用户" value={selectedItem.client || "无"} icon={<Building className="h-3 w-3" />} />
|
||||||
<DetailItem label="运作方" value={selectedItem.operatorName || "无"} />
|
<DetailItem label="运作方" value={selectedItem.operatorName || "无"} />
|
||||||
<DetailItem label="新华三负责人" value={selectedSalesExpansionName || "未关联"} icon={<User className="h-3 w-3" />} />
|
<DetailItem label="新华三负责人" value={selectedSalesExpansionName || "未关联"} icon={<User className="h-3 w-3" />} />
|
||||||
<DetailItem label="售前" value={selectedPreSalesName} icon={<User className="h-3 w-3" />} />
|
<DetailItem label="售前" value={selectedPreSalesName} icon={<User className="h-3 w-3" />} />
|
||||||
|
|
@ -2026,6 +2080,21 @@ export default function Opportunities() {
|
||||||
<DetailItem label="预计下单时间" value={selectedItem.date || "待定"} icon={<Calendar className="h-3 w-3" />} />
|
<DetailItem label="预计下单时间" value={selectedItem.date || "待定"} icon={<Calendar className="h-3 w-3" />} />
|
||||||
<DetailItem label="项目把握度" value={getConfidenceLabel(selectedItem.confidence)} />
|
<DetailItem label="项目把握度" value={getConfidenceLabel(selectedItem.confidence)} />
|
||||||
<DetailItem label="项目阶段" value={selectedItem.stage || "无"} icon={<Activity className="h-3 w-3" />} />
|
<DetailItem label="项目阶段" value={selectedItem.stage || "无"} icon={<Activity className="h-3 w-3" />} />
|
||||||
|
<DetailItem
|
||||||
|
label="是否已推送OMS"
|
||||||
|
value={(
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex rounded-full px-2.5 py-1 text-xs font-semibold",
|
||||||
|
selectedItem.pushedToOms
|
||||||
|
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||||
|
: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedItem.pushedToOms ? "已推送" : "未推送"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<DetailItem label="竞争对手" value={selectedItem.competitorName || "无"} />
|
<DetailItem label="竞争对手" value={selectedItem.competitorName || "无"} />
|
||||||
<DetailItem label="建设类型" value={selectedItem.type || "新建"} icon={<Tag className="h-3 w-3" />} />
|
<DetailItem label="建设类型" value={selectedItem.type || "新建"} icon={<Tag className="h-3 w-3" />} />
|
||||||
<DetailItem label="项目最新进展" value={selectedItem.latestProgress || "暂无日报回写进展"} className="md:col-span-2" />
|
<DetailItem label="项目最新进展" value={selectedItem.latestProgress || "暂无日报回写进展"} className="md:col-span-2" />
|
||||||
|
|
@ -2138,25 +2207,38 @@ export default function Opportunities() {
|
||||||
</div>
|
</div>
|
||||||
<div className="sticky bottom-0 bg-slate-50/95 px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 backdrop-blur sm:static sm:p-4 dark:bg-slate-900/90">
|
<div className="sticky bottom-0 bg-slate-50/95 px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 backdrop-blur sm:static sm:p-4 dark:bg-slate-900/90">
|
||||||
{error ? <div className="crm-alert crm-alert-error mb-3">{error}</div> : null}
|
{error ? <div className="crm-alert crm-alert-error mb-3">{error}</div> : null}
|
||||||
|
{!canEditSelectedItem ? (
|
||||||
|
<p className="mb-3 text-xs text-slate-400 dark:text-slate-500">当前商机非本人负责,仅支持查看详情,不能编辑或推送。</p>
|
||||||
|
) : null}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleOpenEdit}
|
onClick={handleOpenEdit}
|
||||||
className="crm-btn crm-btn-secondary inline-flex h-11 items-center justify-center"
|
disabled={!canEditSelectedItem}
|
||||||
|
title={canEditSelectedItem ? "编辑商机" : "仅本人可操作"}
|
||||||
|
className="crm-btn crm-btn-secondary inline-flex h-11 items-center justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
编辑商机
|
{canEditSelectedItem ? "编辑商机" : "仅本人可操作"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleOpenPushConfirm()}
|
onClick={() => void handleOpenPushConfirm()}
|
||||||
disabled={Boolean(selectedItem.pushedToOms) || pushingOms}
|
disabled={!canPushSelectedItem || pushingOms}
|
||||||
|
title={
|
||||||
|
!canPushSelectedItem
|
||||||
|
? "仅本人可操作"
|
||||||
|
: selectedItem.pushedToOms
|
||||||
|
? "重新推送 OMS"
|
||||||
|
: "推送 OMS"
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"crm-btn inline-flex h-11 items-center justify-center",
|
"crm-btn inline-flex h-11 items-center justify-center disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
selectedItem.pushedToOms
|
!canPushSelectedItem
|
||||||
? "cursor-not-allowed rounded-2xl border border-slate-200 bg-slate-50 text-slate-400 dark:border-slate-800 dark:bg-slate-800/40 dark:text-slate-500"
|
? "cursor-not-allowed rounded-2xl border border-slate-200 bg-slate-50 text-slate-400 dark:border-slate-800 dark:bg-slate-800/40 dark:text-slate-500"
|
||||||
: "crm-btn-primary",
|
: "crm-btn-primary",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectedItem.pushedToOms ? "已推送 OMS" : pushingOms ? "推送中..." : "推送 OMS"}
|
{!canPushSelectedItem ? "仅本人可操作" : pushingOms ? "推送中..." : selectedItem.pushedToOms ? "重新推送 OMS" : "推送 OMS"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
getExpansionOverview,
|
getExpansionOverview,
|
||||||
getOpportunityOverview,
|
getOpportunityOverview,
|
||||||
getProfileOverview,
|
getProfileOverview,
|
||||||
|
refreshCurrentUser,
|
||||||
getWorkHistory,
|
getWorkHistory,
|
||||||
getWorkOverview,
|
getWorkOverview,
|
||||||
reverseWorkGeocode,
|
reverseWorkGeocode,
|
||||||
|
|
@ -164,7 +165,7 @@ export default function Work() {
|
||||||
const [historyPage, setHistoryPage] = useState(1);
|
const [historyPage, setHistoryPage] = useState(1);
|
||||||
const [showHistoryBackToTop, setShowHistoryBackToTop] = useState(false);
|
const [showHistoryBackToTop, setShowHistoryBackToTop] = useState(false);
|
||||||
const [reportStatus, setReportStatus] = useState<string>();
|
const [reportStatus, setReportStatus] = useState<string>();
|
||||||
const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
|
const [currentUser, setCurrentUser] = useState<UserProfile | null>(() => readStoredUserProfile());
|
||||||
const [profileOverview, setProfileOverview] = useState<ProfileOverview | null>(null);
|
const [profileOverview, setProfileOverview] = useState<ProfileOverview | null>(null);
|
||||||
const [checkInPhotoUrls, setCheckInPhotoUrls] = useState<string[]>([]);
|
const [checkInPhotoUrls, setCheckInPhotoUrls] = useState<string[]>([]);
|
||||||
const [salesOptions, setSalesOptions] = useState<WorkRelationOption[]>([]);
|
const [salesOptions, setSalesOptions] = useState<WorkRelationOption[]>([]);
|
||||||
|
|
@ -182,6 +183,8 @@ export default function Work() {
|
||||||
const activeWorkMeta = activeWorkSection
|
const activeWorkMeta = activeWorkSection
|
||||||
? workSectionItems.find((item) => item.key === activeWorkSection)
|
? workSectionItems.find((item) => item.key === activeWorkSection)
|
||||||
: null;
|
: null;
|
||||||
|
const isOnlySeeRole = hasOnlySeeRole(currentUser);
|
||||||
|
const showEntryPanel = !isOnlySeeRole;
|
||||||
|
|
||||||
const pickerOptions = useMemo(() => {
|
const pickerOptions = useMemo(() => {
|
||||||
if (!objectPicker) {
|
if (!objectPicker) {
|
||||||
|
|
@ -299,7 +302,7 @@ export default function Work() {
|
||||||
async function loadUserContext() {
|
async function loadUserContext() {
|
||||||
try {
|
try {
|
||||||
const [userData, overviewData] = await Promise.all([
|
const [userData, overviewData] = await Promise.all([
|
||||||
getCurrentUser().catch(() => null),
|
((currentUser?.roleCodes?.length ?? 0) > 0 ? getCurrentUser() : refreshCurrentUser()).catch(() => null),
|
||||||
getProfileOverview().catch(() => null),
|
getProfileOverview().catch(() => null),
|
||||||
]);
|
]);
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
|
|
@ -510,6 +513,9 @@ export default function Work() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePickPhoto = () => {
|
const handlePickPhoto = () => {
|
||||||
|
if (isOnlySeeRole) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCheckInError("");
|
setCheckInError("");
|
||||||
if (!supportsMobileCameraCapture()) {
|
if (!supportsMobileCameraCapture()) {
|
||||||
setCheckInError("现场照片仅支持手机端直接拍照,请使用手机打开当前页面。");
|
setCheckInError("现场照片仅支持手机端直接拍照,请使用手机打开当前页面。");
|
||||||
|
|
@ -555,6 +561,9 @@ export default function Work() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenObjectPicker = (mode: PickerMode, lineIndex?: number, bizType: BizType = "sales") => {
|
const handleOpenObjectPicker = (mode: PickerMode, lineIndex?: number, bizType: BizType = "sales") => {
|
||||||
|
if (isOnlySeeRole) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const currentOptions = getOptionsByBizType(bizType, salesOptions, channelOptions, opportunityOptions);
|
const currentOptions = getOptionsByBizType(bizType, salesOptions, channelOptions, opportunityOptions);
|
||||||
if (!currentOptions.length && !reportTargetsLoading) {
|
if (!currentOptions.length && !reportTargetsLoading) {
|
||||||
void loadReportTargets();
|
void loadReportTargets();
|
||||||
|
|
@ -686,6 +695,9 @@ export default function Work() {
|
||||||
setSubmittingCheckIn(true);
|
setSubmittingCheckIn(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (isOnlySeeRole) {
|
||||||
|
throw new Error("当前角色仅可查看打卡历史记录");
|
||||||
|
}
|
||||||
if (!checkInForm.latitude || !checkInForm.longitude || !checkInForm.locationText.trim()) {
|
if (!checkInForm.latitude || !checkInForm.longitude || !checkInForm.locationText.trim()) {
|
||||||
throw new Error("请先完成定位后再提交打卡");
|
throw new Error("请先完成定位后再提交打卡");
|
||||||
}
|
}
|
||||||
|
|
@ -737,6 +749,9 @@ export default function Work() {
|
||||||
setSubmittingReport(true);
|
setSubmittingReport(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (isOnlySeeRole) {
|
||||||
|
throw new Error("当前角色仅可查看日报历史记录");
|
||||||
|
}
|
||||||
const normalizedLineItems = reportForm.lineItems.map((item) => normalizeReportLineItem(item, currentWorkDate));
|
const normalizedLineItems = reportForm.lineItems.map((item) => normalizeReportLineItem(item, currentWorkDate));
|
||||||
const normalizedPlanItems = reportForm.planItems
|
const normalizedPlanItems = reportForm.planItems
|
||||||
.map((item) => ({ content: item.content.trim() }))
|
.map((item) => ({ content: item.content.trim() }))
|
||||||
|
|
@ -785,6 +800,7 @@ export default function Work() {
|
||||||
<WorkSectionNav activeWorkSection={activeWorkSection} disableMobileMotion={disableMobileMotion} />
|
<WorkSectionNav activeWorkSection={activeWorkSection} disableMobileMotion={disableMobileMotion} />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-12 lg:gap-6">
|
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-12 lg:gap-6">
|
||||||
|
{showEntryPanel ? (
|
||||||
<div className={`min-w-0 crm-page-stack lg:col-span-7 xl:col-span-8 ${mobilePanel === "entry" ? "block lg:flex" : "hidden lg:flex"}`}>
|
<div className={`min-w-0 crm-page-stack lg:col-span-7 xl:col-span-8 ${mobilePanel === "entry" ? "block lg:flex" : "hidden lg:flex"}`}>
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<SectionTitle title={activeWorkMeta.title} accent={activeWorkMeta.accent} compact />
|
<SectionTitle title={activeWorkMeta.title} accent={activeWorkMeta.accent} compact />
|
||||||
|
|
@ -846,16 +862,29 @@ export default function Work() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className={`min-w-0 crm-page-stack lg:col-span-5 lg:sticky lg:top-6 xl:col-span-4 ${mobilePanel === "history" ? "block lg:flex" : "hidden lg:flex"}`}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 crm-page-stack",
|
||||||
|
showEntryPanel
|
||||||
|
? mobilePanel === "history"
|
||||||
|
? "block lg:flex lg:col-span-5 lg:sticky lg:top-6 xl:col-span-4"
|
||||||
|
: "hidden lg:flex lg:col-span-5 lg:sticky lg:top-6 xl:col-span-4"
|
||||||
|
: "block lg:col-span-12 xl:col-span-12",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<SectionTitle title="历史记录" accent="bg-slate-300 dark:bg-slate-700" compact />
|
<SectionTitle title={showEntryPanel ? "历史记录" : `${activeWorkMeta.label}历史记录`} accent="bg-slate-300 dark:bg-slate-700" compact />
|
||||||
<MobilePanelToggle mobilePanel={mobilePanel} onChange={setMobilePanel} />
|
{showEntryPanel ? <MobilePanelToggle mobilePanel={mobilePanel} onChange={setMobilePanel} /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={historyScrollContainerRef}
|
ref={historyScrollContainerRef}
|
||||||
className="relative max-h-[calc(100vh-12rem)] space-y-4 overflow-y-auto pr-2 scrollbar-hide"
|
className={cn(
|
||||||
|
"relative space-y-4 overflow-y-auto scrollbar-hide",
|
||||||
|
showEntryPanel ? "max-h-[calc(100vh-12rem)] pr-2" : "max-h-[calc(100vh-10rem)]",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{historyLoading && historyData.length === 0 ? <WorkHistorySkeleton /> : null}
|
{historyLoading && historyData.length === 0 ? <WorkHistorySkeleton /> : null}
|
||||||
|
|
||||||
|
|
@ -1447,6 +1476,12 @@ function HistoryCard({
|
||||||
onPreviewPhoto: (url: string, alt: string) => void;
|
onPreviewPhoto: (url: string, alt: string) => void;
|
||||||
disableMobileMotion: boolean;
|
disableMobileMotion: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const historyPresenter = extractHistoryPresenter(item.content);
|
||||||
|
const contentText = historyPresenter.content || "无";
|
||||||
|
const presenterTone = item.type === "日报"
|
||||||
|
? "border-violet-200 bg-violet-50/90 text-violet-900 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-100"
|
||||||
|
: "border-emerald-200 bg-emerald-50/90 text-emerald-900 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={disableMobileMotion ? false : { opacity: 0, x: 20 }}
|
initial={disableMobileMotion ? false : { opacity: 0, x: 20 }}
|
||||||
|
|
@ -1489,7 +1524,13 @@ function HistoryCard({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pl-11">
|
<div className="pl-11">
|
||||||
<p className="whitespace-pre-line text-xs leading-relaxed text-slate-700 dark:text-slate-300">{item.content}</p>
|
{historyPresenter.label && historyPresenter.name ? (
|
||||||
|
<div className={cn("mb-3 rounded-xl border px-3 py-2.5", presenterTone)}>
|
||||||
|
<p className="text-[11px] font-semibold tracking-[0.08em] opacity-75">{historyPresenter.label}</p>
|
||||||
|
<p className="mt-1 text-sm font-bold">{historyPresenter.name}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<p className="whitespace-pre-line text-xs leading-relaxed text-slate-700 dark:text-slate-300">{contentText}</p>
|
||||||
{item.photoUrls?.length ? (
|
{item.photoUrls?.length ? (
|
||||||
<div className="mt-3 flex gap-2 overflow-x-auto pb-1">
|
<div className="mt-3 flex gap-2 overflow-x-auto pb-1">
|
||||||
{item.photoUrls.map((photoUrl, photoIndex) => (
|
{item.photoUrls.map((photoUrl, photoIndex) => (
|
||||||
|
|
@ -2083,6 +2124,12 @@ function HistoryDetailModal({
|
||||||
onPreviewPhoto: (url: string, alt: string) => void;
|
onPreviewPhoto: (url: string, alt: string) => void;
|
||||||
disableMobileMotion: boolean;
|
disableMobileMotion: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const historyPresenter = extractHistoryPresenter(item.content);
|
||||||
|
const contentText = historyPresenter.content || "无";
|
||||||
|
const presenterTone = item.type === "日报"
|
||||||
|
? "border-violet-200 bg-violet-50/90 text-violet-900 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-100"
|
||||||
|
: "border-emerald-200 bg-emerald-50/90 text-emerald-900 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[95]">
|
<div className="fixed inset-0 z-[95]">
|
||||||
<button
|
<button
|
||||||
|
|
@ -2130,10 +2177,17 @@ function HistoryDetailModal({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{historyPresenter.label && historyPresenter.name ? (
|
||||||
|
<section className={cn("mt-4 rounded-2xl border px-4 py-3.5", presenterTone)}>
|
||||||
|
<p className="text-xs font-semibold tracking-[0.08em] opacity-75">{historyPresenter.label}</p>
|
||||||
|
<p className="mt-1.5 text-lg font-bold">{historyPresenter.name}</p>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="mt-4 rounded-2xl border border-slate-100 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-900/40">
|
<section className="mt-4 rounded-2xl border border-slate-100 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-900/40">
|
||||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">记录内容</p>
|
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">记录内容</p>
|
||||||
<p className="mt-2 whitespace-pre-line text-sm leading-7 text-slate-800 dark:text-slate-200">
|
<p className="mt-2 whitespace-pre-line text-sm leading-7 text-slate-800 dark:text-slate-200">
|
||||||
{item.content || "无"}
|
{contentText}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -2244,6 +2298,34 @@ function buildCollapsedPreviewLines(content: string | undefined, placeholder: st
|
||||||
return previewLines;
|
return previewLines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractHistoryPresenter(content: string | undefined) {
|
||||||
|
if (!content?.trim()) {
|
||||||
|
return { label: "", name: "", content: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let label = "";
|
||||||
|
let name = "";
|
||||||
|
const remainingLines = content
|
||||||
|
.replace(/\r/g, "")
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => {
|
||||||
|
const normalizedLine = line.trim();
|
||||||
|
const match = normalizedLine.match(/^(提交人|打卡人):\s*(.+)$/);
|
||||||
|
if (!match || label) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
label = match[1];
|
||||||
|
name = match[2].trim();
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
content: remainingLines.join("\n").trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function SummaryCell({ label, value }: { label: string; value: string }) {
|
function SummaryCell({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white px-4 py-3 dark:bg-slate-900/50">
|
<div className="bg-white px-4 py-3 dark:bg-slate-900/50">
|
||||||
|
|
@ -2424,6 +2506,26 @@ function getHistoryLabelBySection(section: WorkSection) {
|
||||||
return section === "checkin" ? "打卡" : "日报";
|
return section === "checkin" ? "打卡" : "日报";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readStoredUserProfile() {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawProfile = window.sessionStorage.getItem("userProfile");
|
||||||
|
if (!rawProfile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.parse(rawProfile) as UserProfile;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOnlySeeRole(user: UserProfile | null) {
|
||||||
|
return (user?.roleCodes ?? []).some((roleCode) => roleCode?.trim().toLowerCase() === "only_see");
|
||||||
|
}
|
||||||
|
|
||||||
function buildSalesOptions(items: SalesExpansionItem[]): WorkRelationOption[] {
|
function buildSalesOptions(items: SalesExpansionItem[]): WorkRelationOption[] {
|
||||||
const seenIds = new Set<number>();
|
const seenIds = new Set<number>();
|
||||||
return items
|
return items
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue