权限与日报
parent
50c1dceccc
commit
19d8cf7e9b
|
|
@ -5,10 +5,21 @@
|
|||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<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/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/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/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/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>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
|
|
@ -87,6 +98,7 @@
|
|||
<workItem from="1774244004381" duration="10126000" />
|
||||
<workItem from="1774574410059" duration="200000" />
|
||||
<workItem from="1774576185724" duration="1651000" />
|
||||
<workItem from="1774763863609" duration="16084000" />
|
||||
</task>
|
||||
<task id="LOCAL-00001" summary="修改定位信息 0323">
|
||||
<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 {
|
||||
|
||||
private Long id;
|
||||
private Long ownerUserId;
|
||||
private String type;
|
||||
private String channelCode;
|
||||
private String name;
|
||||
|
|
@ -48,6 +49,14 @@ public class ChannelExpansionItemDTO {
|
|||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getOwnerUserId() {
|
||||
return ownerUserId;
|
||||
}
|
||||
|
||||
public void setOwnerUserId(Long ownerUserId) {
|
||||
this.ownerUserId = ownerUserId;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import java.util.List;
|
|||
public class SalesExpansionItemDTO {
|
||||
|
||||
private Long id;
|
||||
private Long ownerUserId;
|
||||
private String type;
|
||||
private String employeeNo;
|
||||
private String name;
|
||||
|
|
@ -39,6 +40,14 @@ public class SalesExpansionItemDTO {
|
|||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getOwnerUserId() {
|
||||
return ownerUserId;
|
||||
}
|
||||
|
||||
public void setOwnerUserId(Long ownerUserId) {
|
||||
this.ownerUserId = ownerUserId;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import java.util.List;
|
|||
public class OpportunityItemDTO {
|
||||
|
||||
private Long id;
|
||||
private Long ownerUserId;
|
||||
private String code;
|
||||
private String name;
|
||||
private String client;
|
||||
|
|
@ -44,6 +45,14 @@ public class OpportunityItemDTO {
|
|||
this.id = id;
|
||||
}
|
||||
|
||||
public Long getOwnerUserId() {
|
||||
return ownerUserId;
|
||||
}
|
||||
|
||||
public void setOwnerUserId(Long ownerUserId) {
|
||||
this.ownerUserId = ownerUserId;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ public interface ProfileMapper {
|
|||
|
||||
List<String> selectUserRoleNames(@Param("userId") Long userId);
|
||||
|
||||
List<String> selectUserRoleCodes(@Param("userId") Long userId);
|
||||
|
||||
List<String> selectUserOrgNames(@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.WorkHistoryItemDTO;
|
||||
import com.unis.crm.dto.work.WorkSuggestedActionDTO;
|
||||
import com.unisbase.annotation.DataScope;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
|
@ -19,13 +20,11 @@ public interface WorkMapper {
|
|||
|
||||
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(
|
||||
@Param("userId") Long userId,
|
||||
@Param("historyType") String historyType,
|
||||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
@DataScope(tableAlias = "r", ownerColumn = "user_id")
|
||||
List<WorkHistoryItemDTO> selectReportHistory(@Param("limit") int limit);
|
||||
|
||||
Long selectTodayCheckInId(@Param("userId") Long userId);
|
||||
|
||||
|
|
|
|||
|
|
@ -156,7 +156,6 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
@Override
|
||||
public void updateSalesExpansion(Long userId, Long id, UpdateSalesExpansionRequest request) {
|
||||
fillSalesDefaults(request);
|
||||
ensureUniqueEmployeeNo(userId, request.getEmployeeNo(), id);
|
||||
int updated = expansionMapper.updateSalesExpansion(userId, id, request);
|
||||
if (updated <= 0) {
|
||||
throw new BusinessException("未找到可编辑的销售拓展记录");
|
||||
|
|
@ -167,7 +166,6 @@ public class ExpansionServiceImpl implements ExpansionService {
|
|||
@Transactional
|
||||
public void updateChannelExpansion(Long userId, Long id, UpdateChannelExpansionRequest request) {
|
||||
fillChannelDefaults(request);
|
||||
ensureUniqueChannelName(userId, request.getChannelName(), id);
|
||||
int updated = expansionMapper.updateChannelExpansion(userId, id, request);
|
||||
if (updated <= 0) {
|
||||
throw new BusinessException("未找到可编辑的渠道拓展记录");
|
||||
|
|
|
|||
|
|
@ -152,9 +152,6 @@ public class OpportunityServiceImpl implements OpportunityService {
|
|||
if (opportunityMapper.countOwnedOpportunity(userId, opportunityId) <= 0) {
|
||||
throw new BusinessException("无权操作该商机");
|
||||
}
|
||||
if (Boolean.TRUE.equals(opportunityMapper.selectPushedToOms(userId, opportunityId))) {
|
||||
throw new BusinessException("该商机已推送 OMS,请勿重复操作");
|
||||
}
|
||||
|
||||
OpportunityOmsPushDataDTO pushData = opportunityMapper.selectOpportunityOmsPushData(userId, opportunityId);
|
||||
if (pushData == null) {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import java.time.ZoneId;
|
|||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
|
@ -112,17 +113,20 @@ public class WorkServiceImpl implements WorkService {
|
|||
requireUser(userId);
|
||||
int safePage = Math.max(page, 1);
|
||||
int safeSize = Math.min(Math.max(size, 1), 20);
|
||||
int querySize = safeSize + 1;
|
||||
int offset = (safePage - 1) * safeSize;
|
||||
String historyType = mapHistoryType(type);
|
||||
List<WorkHistoryItemDTO> historyItems = workMapper.selectHistoryPage(userId, historyType, querySize, offset);
|
||||
normalizeHistoryMetadata(historyItems);
|
||||
HistoryType historyType = mapHistoryType(type);
|
||||
int fetchLimit = offset + safeSize + 1;
|
||||
List<WorkHistoryItemDTO> historyItems = loadHistoryItems(historyType, fetchLimit);
|
||||
|
||||
boolean hasMore = historyItems.size() > safeSize;
|
||||
if (hasMore) {
|
||||
historyItems = new ArrayList<>(historyItems.subList(0, safeSize));
|
||||
boolean hasMore = historyItems.size() > offset + safeSize;
|
||||
if (offset >= historyItems.size()) {
|
||||
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
|
||||
|
|
@ -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);
|
||||
if (normalizedType == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (normalizedType) {
|
||||
case "checkin" -> "外勤打卡";
|
||||
case "report" -> "日报";
|
||||
case "checkin" -> HistoryType.CHECK_IN;
|
||||
case "report" -> HistoryType.REPORT;
|
||||
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) {
|
||||
if (actions == null || actions.isEmpty()) {
|
||||
return "";
|
||||
|
|
@ -510,7 +551,7 @@ public class WorkServiceImpl implements WorkService {
|
|||
return;
|
||||
}
|
||||
ReportLineMetadata metadata = extractReportLineMetadata(todayReport.getWorkContent());
|
||||
todayReport.setWorkContent(metadata.cleanText());
|
||||
todayReport.setWorkContent(stripVisitTimeFromReportText(metadata.cleanText()));
|
||||
todayReport.setLineItems(metadata.lineItems());
|
||||
PlanItemMetadata planMetadata = extractPlanItemMetadata(todayReport.getTomorrowPlan());
|
||||
todayReport.setTomorrowPlan(planMetadata.cleanText());
|
||||
|
|
@ -528,11 +569,20 @@ public class WorkServiceImpl implements WorkService {
|
|||
PhotoMetadata photoMetadata = extractPhotoMetadata(historyItem.getContent());
|
||||
ReportLineMetadata reportLineMetadata = extractReportLineMetadata(photoMetadata.cleanText());
|
||||
PlanItemMetadata planMetadata = extractPlanItemMetadata(reportLineMetadata.cleanText());
|
||||
historyItem.setContent(planMetadata.cleanText());
|
||||
historyItem.setContent(stripVisitTimeFromReportText(planMetadata.cleanText()));
|
||||
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) {
|
||||
String normalized = rawText == null ? "" : rawText;
|
||||
Matcher matcher = PHOTO_METADATA_PATTERN.matcher(normalized);
|
||||
|
|
@ -684,16 +734,12 @@ public class WorkServiceImpl implements WorkService {
|
|||
|
||||
private String buildExpansionLineContent(WorkReportLineItemRequest item) {
|
||||
String communication = normalizeOptionalText(item.getEvaluationContent());
|
||||
String visitStartTime = normalizeOptionalText(item.getVisitStartTime());
|
||||
String nextPlan = normalizeOptionalText(item.getNextPlan());
|
||||
if (communication == null && visitStartTime == null && nextPlan == null) {
|
||||
if (communication == null && nextPlan == null) {
|
||||
return normalizeOptionalText(item.getContent());
|
||||
}
|
||||
|
||||
List<String> parts = new ArrayList<>();
|
||||
if (visitStartTime != null) {
|
||||
parts.add("拜访时间:" + visitStartTime);
|
||||
}
|
||||
if (communication != null) {
|
||||
parts.add("沟通内容:" + communication);
|
||||
}
|
||||
|
|
@ -1557,5 +1603,10 @@ public class WorkServiceImpl implements WorkService {
|
|||
|
||||
private record PlanItemMetadata(String cleanText, List<WorkTomorrowPlanItemDTO> planItems) {}
|
||||
|
||||
private enum HistoryType {
|
||||
CHECK_IN,
|
||||
REPORT
|
||||
}
|
||||
|
||||
private record TencentLocationCandidate(String coordType, String locationText, int score) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@
|
|||
<select id="selectSalesExpansions" resultType="com.unis.crm.dto.expansion.SalesExpansionItemDTO">
|
||||
select
|
||||
s.id,
|
||||
s.owner_user_id as ownerUserId,
|
||||
'sales' as type,
|
||||
coalesce(s.employee_no, '无') as employeeNo,
|
||||
s.candidate_name as name,
|
||||
|
|
@ -115,6 +116,7 @@
|
|||
<select id="selectChannelExpansions" resultType="com.unis.crm.dto.expansion.ChannelExpansionItemDTO">
|
||||
select
|
||||
c.id,
|
||||
c.owner_user_id as ownerUserId,
|
||||
'channel' as type,
|
||||
coalesce(c.channel_code, '') as channelCode,
|
||||
c.channel_name as name,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
<select id="selectOpportunities" resultType="com.unis.crm.dto.opportunity.OpportunityItemDTO">
|
||||
select
|
||||
o.id,
|
||||
o.owner_user_id as ownerUserId,
|
||||
o.opportunity_code as code,
|
||||
o.opportunity_name as name,
|
||||
coalesce(c.customer_name, '未填写最终客户') as client,
|
||||
|
|
@ -449,11 +450,10 @@
|
|||
<update id="markOpportunityOmsPushed">
|
||||
update crm_opportunity o
|
||||
set pushed_to_oms = true,
|
||||
oms_push_time = coalesce(oms_push_time, now()),
|
||||
oms_push_time = now(),
|
||||
opportunity_code = #{opportunityCode},
|
||||
updated_at = now()
|
||||
where o.id = #{opportunityId}
|
||||
and coalesce(pushed_to_oms, false) = false
|
||||
</update>
|
||||
|
||||
<insert id="insertOpportunityFollowUp">
|
||||
|
|
|
|||
|
|
@ -38,6 +38,16 @@
|
|||
order by r.role_id asc
|
||||
</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 o.org_name
|
||||
from sys_tenant_user tu
|
||||
|
|
|
|||
|
|
@ -168,17 +168,7 @@
|
|||
order by action_time asc, group_name asc, detail asc
|
||||
</select>
|
||||
|
||||
<select id="selectHistory" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
|
||||
select
|
||||
id,
|
||||
type,
|
||||
date,
|
||||
time,
|
||||
content,
|
||||
status,
|
||||
score,
|
||||
comment
|
||||
from (
|
||||
<select id="selectCheckInHistory" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
|
||||
select
|
||||
c.id,
|
||||
'外勤打卡' as type,
|
||||
|
|
@ -192,10 +182,6 @@
|
|||
when c.user_name is not null and btrim(c.user_name) <> '' then '打卡人:' || c.user_name || E'\n'
|
||||
else ''
|
||||
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, '') ||
|
||||
case
|
||||
when c.remark is not null and btrim(c.remark) <> '' then E'\n备注:' || c.remark
|
||||
|
|
@ -210,119 +196,50 @@
|
|||
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
|
||||
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
|
||||
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 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;
|
||||
isPlatformAdmin?: boolean;
|
||||
pwdResetRequired?: number;
|
||||
roleCodes?: string[];
|
||||
roles?: UserRole[];
|
||||
}
|
||||
|
||||
export interface UserRole {
|
||||
roleId?: number;
|
||||
roleCode?: string;
|
||||
roleName?: string;
|
||||
}
|
||||
|
||||
export interface PlatformConfig {
|
||||
|
|
@ -241,6 +249,7 @@ export interface OpportunityFollowUp {
|
|||
|
||||
export interface OpportunityItem {
|
||||
id: number;
|
||||
ownerUserId?: number;
|
||||
code?: string;
|
||||
name?: string;
|
||||
client?: string;
|
||||
|
|
@ -339,6 +348,7 @@ export interface ExpansionFollowUp {
|
|||
|
||||
export interface SalesExpansionItem {
|
||||
id: number;
|
||||
ownerUserId?: number;
|
||||
type: "sales";
|
||||
employeeNo?: string;
|
||||
name?: string;
|
||||
|
|
@ -374,6 +384,7 @@ export interface RelatedProjectSummary {
|
|||
|
||||
export interface ChannelExpansionItem {
|
||||
id: number;
|
||||
ownerUserId?: number;
|
||||
type: "channel";
|
||||
channelCode?: string;
|
||||
name?: string;
|
||||
|
|
@ -713,6 +724,10 @@ function getStoredUserId() {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
export function getStoredCurrentUserId() {
|
||||
return getStoredUserId();
|
||||
}
|
||||
|
||||
export async function fetchCaptcha() {
|
||||
return request<CaptchaResponse>("/api/sys/auth/captcha");
|
||||
}
|
||||
|
|
@ -751,10 +766,21 @@ export async function getOpenPlatformConfig() {
|
|||
export async function getCurrentUser() {
|
||||
return getCachedAuthedRequest<UserProfile>(
|
||||
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() {
|
||||
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>(
|
||||
cacheKey: string,
|
||||
fetcher: () => Promise<T>,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
getExpansionCityOptions,
|
||||
getExpansionMeta,
|
||||
getExpansionOverview,
|
||||
getStoredCurrentUserId,
|
||||
updateChannelExpansion,
|
||||
updateSalesExpansion,
|
||||
type ChannelExpansionContact,
|
||||
|
|
@ -566,6 +567,7 @@ function RequiredMark() {
|
|||
}
|
||||
|
||||
export default function Expansion() {
|
||||
const currentUserId = getStoredCurrentUserId();
|
||||
const location = useLocation();
|
||||
const isMobileViewport = useIsMobileViewport();
|
||||
const isWecomBrowser = useIsWecomBrowser();
|
||||
|
|
@ -606,6 +608,7 @@ export default function Expansion() {
|
|||
const [invalidEditChannelContactRows, setInvalidEditChannelContactRows] = useState<number[]>([]);
|
||||
const [salesDetailTab, setSalesDetailTab] = useState<"projects" | "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 [channelForm, setChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm);
|
||||
|
|
@ -945,6 +948,9 @@ export default function Expansion() {
|
|||
if (!selectedItem) {
|
||||
return;
|
||||
}
|
||||
if (!canEditSelectedItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditError("");
|
||||
let latestIndustryOptions = industryOptions;
|
||||
|
|
@ -1078,6 +1084,10 @@ export default function Expansion() {
|
|||
if (!selectedItem || submitting) {
|
||||
return;
|
||||
}
|
||||
if (!canEditSelectedItem) {
|
||||
setEditError("仅可编辑本人创建的数据");
|
||||
return;
|
||||
}
|
||||
|
||||
setEditError("");
|
||||
if (selectedItem.type === "sales") {
|
||||
|
|
@ -1669,24 +1679,46 @@ export default function Expansion() {
|
|||
<div className="crm-list-stack">
|
||||
{activeTab === "sales" ? (
|
||||
salesData.length > 0 ? (
|
||||
salesData.map((item, i) => (
|
||||
salesData.map((item, i) => {
|
||||
const isOwnedByCurrentUser = currentUserId !== undefined && item.ownerUserId === currentUserId;
|
||||
return (
|
||||
<motion.div
|
||||
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
|
||||
key={item.id}
|
||||
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="min-w-0">
|
||||
<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>
|
||||
</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"}`}>
|
||||
{item.active ? "在职" : "离职"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<span className="text-slate-400 dark:text-slate-500">意向:</span>
|
||||
|
|
@ -1704,18 +1736,29 @@ export default function Expansion() {
|
|||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
);
|
||||
})
|
||||
) : renderEmpty()
|
||||
) : channelData.length > 0 ? (
|
||||
channelData.map((item, i) => (
|
||||
channelData.map((item, i) => {
|
||||
const isOwnedByCurrentUser = currentUserId !== undefined && item.ownerUserId === currentUserId;
|
||||
return (
|
||||
<motion.div
|
||||
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
|
||||
key={item.id}
|
||||
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="min-w-0">
|
||||
<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 || "无"}
|
||||
</p>
|
||||
</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"}`}>
|
||||
{item.intent ? `${item.intent}意向` : "未评估"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<span className="text-slate-400 dark:text-slate-500">建立联系:</span>
|
||||
|
|
@ -1744,7 +1799,8 @@ export default function Expansion() {
|
|||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
);
|
||||
})
|
||||
) : renderEmpty()}
|
||||
</div>
|
||||
|
||||
|
|
@ -2022,9 +2078,18 @@ export default function Expansion() {
|
|||
</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">
|
||||
{!canEditSelectedItem ? (
|
||||
<p className="mb-3 text-xs text-slate-400 dark:text-slate-500">当前记录非本人创建,仅支持查看详情,不能编辑。</p>
|
||||
) : null}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
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 { 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 { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -331,7 +331,7 @@ function validateOpportunityForm(
|
|||
errors.opportunityName = "请填写项目名称";
|
||||
}
|
||||
if (!form.customerName?.trim()) {
|
||||
errors.customerName = "请填写最终客户";
|
||||
errors.customerName = "请填写最终用户";
|
||||
}
|
||||
if (!form.operatorName?.trim()) {
|
||||
errors.operatorName = "请选择运作方";
|
||||
|
|
@ -914,6 +914,7 @@ function CompetitorMultiSelect({
|
|||
}
|
||||
|
||||
export default function Opportunities() {
|
||||
const currentUserId = getStoredCurrentUserId();
|
||||
const isMobileViewport = useIsMobileViewport();
|
||||
const isWecomBrowser = useIsWecomBrowser();
|
||||
const disableMobileMotion = isMobileViewport || isWecomBrowser;
|
||||
|
|
@ -1089,6 +1090,8 @@ export default function Opportunities() {
|
|||
const selectedSalesExpansionName = selectedItem?.salesExpansionName || selectedSalesExpansion?.name || "";
|
||||
const selectedChannelExpansionName = selectedItem?.channelExpansionName || selectedChannelExpansion?.name || "";
|
||||
const selectedPreSalesName = selectedItem?.preSalesName || "无";
|
||||
const canEditSelectedItem = Boolean(selectedItem && currentUserId !== undefined && selectedItem.ownerUserId === currentUserId);
|
||||
const canPushSelectedItem = canEditSelectedItem;
|
||||
const operatorMode = resolveOperatorMode(form.operatorName, operatorOptions);
|
||||
const showSalesExpansionField = operatorMode === "h3c" || 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;
|
||||
} else if (header.includes("项目最新进展") || header.includes("后续规划") || header.includes("备注")) {
|
||||
column.width = 24;
|
||||
} else if (header.includes("项目名称") || header.includes("最终客户")) {
|
||||
} else if (header.includes("项目名称") || header.includes("最终客户") || header.includes("最终用户")) {
|
||||
column.width = 20;
|
||||
} else {
|
||||
column.width = 16;
|
||||
|
|
@ -1347,6 +1350,10 @@ export default function Opportunities() {
|
|||
if (!selectedItem) {
|
||||
return;
|
||||
}
|
||||
if (!canEditSelectedItem) {
|
||||
setError("仅可编辑本人负责的商机");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setFieldErrors({});
|
||||
setForm(toFormFromItem(selectedItem));
|
||||
|
|
@ -1360,6 +1367,10 @@ export default function Opportunities() {
|
|||
if (!selectedItem || submitting) {
|
||||
return;
|
||||
}
|
||||
if (!canEditSelectedItem) {
|
||||
setError("仅可编辑本人负责的商机");
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
const validationErrors = validateOpportunityForm(form, selectedCompetitors, customCompetitorName, operatorMode);
|
||||
|
|
@ -1382,7 +1393,11 @@ export default function Opportunities() {
|
|||
};
|
||||
|
||||
const handlePushToOms = async () => {
|
||||
if (!selectedItem || selectedItem.pushedToOms || pushingOms) {
|
||||
if (!selectedItem || pushingOms) {
|
||||
return;
|
||||
}
|
||||
if (!canPushSelectedItem) {
|
||||
setError("仅可推送本人负责的商机");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1433,7 +1448,11 @@ export default function Opportunities() {
|
|||
};
|
||||
|
||||
const handleOpenPushConfirm = async () => {
|
||||
if (!selectedItem || selectedItem.pushedToOms || pushingOms) {
|
||||
if (!selectedItem || pushingOms) {
|
||||
return;
|
||||
}
|
||||
if (!canPushSelectedItem) {
|
||||
setError("仅可推送本人负责的商机");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1455,6 +1474,10 @@ export default function Opportunities() {
|
|||
};
|
||||
|
||||
const handleConfirmPushToOms = async () => {
|
||||
if (!canPushSelectedItem) {
|
||||
setError("仅可推送本人负责的商机");
|
||||
return;
|
||||
}
|
||||
if (!pushPreSalesId && !pushPreSalesName.trim()) {
|
||||
setError("请选择售前人员");
|
||||
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" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索项目名称、最终客户、编码..."
|
||||
placeholder="搜索项目名称、最终用户、编码..."
|
||||
value={keyword}
|
||||
onChange={(event) => {
|
||||
setKeyword(event.target.value);
|
||||
|
|
@ -1557,27 +1580,57 @@ export default function Opportunities() {
|
|||
|
||||
<div className="crm-list-stack">
|
||||
{visibleItems.length > 0 ? (
|
||||
visibleItems.map((opp, i) => (
|
||||
visibleItems.map((opp, i) => {
|
||||
const isOwnedByCurrentUser = currentUserId !== undefined && opp.ownerUserId === currentUserId;
|
||||
return (
|
||||
<motion.div
|
||||
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
|
||||
key={opp.id}
|
||||
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="min-w-0 flex-1">
|
||||
<h3 className="truncate text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{opp.name || "未命名商机"}</h3>
|
||||
<p className="mt-1 truncate text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">项目编号:{opp.code || "待生成"}</p>
|
||||
</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)}>
|
||||
{getConfidenceLabel(opp.confidence)}
|
||||
</span>
|
||||
<span className="crm-pill crm-pill-neutral">
|
||||
{opp.stage || "初步沟通"}
|
||||
</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>
|
||||
|
||||
|
|
@ -1606,7 +1659,8 @@ export default function Opportunities() {
|
|||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
);
|
||||
})
|
||||
) : renderEmpty()}
|
||||
</div>
|
||||
|
||||
|
|
@ -1706,7 +1760,7 @@ export default function Opportunities() {
|
|||
{fieldErrors.opportunityName ? <p className="text-xs text-rose-500">{fieldErrors.opportunityName}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2 sm:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">最终客户<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))} />
|
||||
{fieldErrors.customerName ? <p className="text-xs text-rose-500">{fieldErrors.customerName}</p> : null}
|
||||
</label>
|
||||
|
|
@ -2018,7 +2072,7 @@ export default function Opportunities() {
|
|||
</h4>
|
||||
<div className="crm-detail-grid text-sm md:grid-cols-2">
|
||||
<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={selectedSalesExpansionName || "未关联"} 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={getConfidenceLabel(selectedItem.confidence)} />
|
||||
<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.type || "新建"} icon={<Tag className="h-3 w-3" />} />
|
||||
<DetailItem label="项目最新进展" value={selectedItem.latestProgress || "暂无日报回写进展"} className="md:col-span-2" />
|
||||
|
|
@ -2138,25 +2207,38 @@ export default function Opportunities() {
|
|||
</div>
|
||||
<div className="sticky bottom-0 bg-slate-50/95 px-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pt-4 backdrop-blur sm:static sm:p-4 dark:bg-slate-900/90">
|
||||
{error ? <div className="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">
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
type="button"
|
||||
onClick={() => void handleOpenPushConfirm()}
|
||||
disabled={Boolean(selectedItem.pushedToOms) || pushingOms}
|
||||
disabled={!canPushSelectedItem || pushingOms}
|
||||
title={
|
||||
!canPushSelectedItem
|
||||
? "仅本人可操作"
|
||||
: selectedItem.pushedToOms
|
||||
? "重新推送 OMS"
|
||||
: "推送 OMS"
|
||||
}
|
||||
className={cn(
|
||||
"crm-btn inline-flex h-11 items-center justify-center",
|
||||
selectedItem.pushedToOms
|
||||
"crm-btn inline-flex h-11 items-center justify-center disabled:cursor-not-allowed disabled:opacity-60",
|
||||
!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"
|
||||
: "crm-btn-primary",
|
||||
)}
|
||||
>
|
||||
{selectedItem.pushedToOms ? "已推送 OMS" : pushingOms ? "推送中..." : "推送 OMS"}
|
||||
{!canPushSelectedItem ? "仅本人可操作" : pushingOms ? "推送中..." : selectedItem.pushedToOms ? "重新推送 OMS" : "推送 OMS"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
getExpansionOverview,
|
||||
getOpportunityOverview,
|
||||
getProfileOverview,
|
||||
refreshCurrentUser,
|
||||
getWorkHistory,
|
||||
getWorkOverview,
|
||||
reverseWorkGeocode,
|
||||
|
|
@ -164,7 +165,7 @@ export default function Work() {
|
|||
const [historyPage, setHistoryPage] = useState(1);
|
||||
const [showHistoryBackToTop, setShowHistoryBackToTop] = useState(false);
|
||||
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 [checkInPhotoUrls, setCheckInPhotoUrls] = useState<string[]>([]);
|
||||
const [salesOptions, setSalesOptions] = useState<WorkRelationOption[]>([]);
|
||||
|
|
@ -182,6 +183,8 @@ export default function Work() {
|
|||
const activeWorkMeta = activeWorkSection
|
||||
? workSectionItems.find((item) => item.key === activeWorkSection)
|
||||
: null;
|
||||
const isOnlySeeRole = hasOnlySeeRole(currentUser);
|
||||
const showEntryPanel = !isOnlySeeRole;
|
||||
|
||||
const pickerOptions = useMemo(() => {
|
||||
if (!objectPicker) {
|
||||
|
|
@ -299,7 +302,7 @@ export default function Work() {
|
|||
async function loadUserContext() {
|
||||
try {
|
||||
const [userData, overviewData] = await Promise.all([
|
||||
getCurrentUser().catch(() => null),
|
||||
((currentUser?.roleCodes?.length ?? 0) > 0 ? getCurrentUser() : refreshCurrentUser()).catch(() => null),
|
||||
getProfileOverview().catch(() => null),
|
||||
]);
|
||||
if (cancelled) {
|
||||
|
|
@ -510,6 +513,9 @@ export default function Work() {
|
|||
};
|
||||
|
||||
const handlePickPhoto = () => {
|
||||
if (isOnlySeeRole) {
|
||||
return;
|
||||
}
|
||||
setCheckInError("");
|
||||
if (!supportsMobileCameraCapture()) {
|
||||
setCheckInError("现场照片仅支持手机端直接拍照,请使用手机打开当前页面。");
|
||||
|
|
@ -555,6 +561,9 @@ export default function Work() {
|
|||
};
|
||||
|
||||
const handleOpenObjectPicker = (mode: PickerMode, lineIndex?: number, bizType: BizType = "sales") => {
|
||||
if (isOnlySeeRole) {
|
||||
return;
|
||||
}
|
||||
const currentOptions = getOptionsByBizType(bizType, salesOptions, channelOptions, opportunityOptions);
|
||||
if (!currentOptions.length && !reportTargetsLoading) {
|
||||
void loadReportTargets();
|
||||
|
|
@ -686,6 +695,9 @@ export default function Work() {
|
|||
setSubmittingCheckIn(true);
|
||||
|
||||
try {
|
||||
if (isOnlySeeRole) {
|
||||
throw new Error("当前角色仅可查看打卡历史记录");
|
||||
}
|
||||
if (!checkInForm.latitude || !checkInForm.longitude || !checkInForm.locationText.trim()) {
|
||||
throw new Error("请先完成定位后再提交打卡");
|
||||
}
|
||||
|
|
@ -737,6 +749,9 @@ export default function Work() {
|
|||
setSubmittingReport(true);
|
||||
|
||||
try {
|
||||
if (isOnlySeeRole) {
|
||||
throw new Error("当前角色仅可查看日报历史记录");
|
||||
}
|
||||
const normalizedLineItems = reportForm.lineItems.map((item) => normalizeReportLineItem(item, currentWorkDate));
|
||||
const normalizedPlanItems = reportForm.planItems
|
||||
.map((item) => ({ content: item.content.trim() }))
|
||||
|
|
@ -785,6 +800,7 @@ export default function Work() {
|
|||
<WorkSectionNav activeWorkSection={activeWorkSection} disableMobileMotion={disableMobileMotion} />
|
||||
|
||||
<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="mb-2 flex items-center justify-between gap-3">
|
||||
<SectionTitle title={activeWorkMeta.title} accent={activeWorkMeta.accent} compact />
|
||||
|
|
@ -846,16 +862,29 @@ export default function Work() {
|
|||
/>
|
||||
)}
|
||||
</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">
|
||||
<SectionTitle title="历史记录" accent="bg-slate-300 dark:bg-slate-700" compact />
|
||||
<MobilePanelToggle mobilePanel={mobilePanel} onChange={setMobilePanel} />
|
||||
<SectionTitle title={showEntryPanel ? "历史记录" : `${activeWorkMeta.label}历史记录`} accent="bg-slate-300 dark:bg-slate-700" compact />
|
||||
{showEntryPanel ? <MobilePanelToggle mobilePanel={mobilePanel} onChange={setMobilePanel} /> : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
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}
|
||||
|
||||
|
|
@ -1447,6 +1476,12 @@ function HistoryCard({
|
|||
onPreviewPhoto: (url: string, alt: string) => void;
|
||||
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 (
|
||||
<motion.div
|
||||
initial={disableMobileMotion ? false : { opacity: 0, x: 20 }}
|
||||
|
|
@ -1489,7 +1524,13 @@ function HistoryCard({
|
|||
</div>
|
||||
|
||||
<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 ? (
|
||||
<div className="mt-3 flex gap-2 overflow-x-auto pb-1">
|
||||
{item.photoUrls.map((photoUrl, photoIndex) => (
|
||||
|
|
@ -2083,6 +2124,12 @@ function HistoryDetailModal({
|
|||
onPreviewPhoto: (url: string, alt: string) => void;
|
||||
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 (
|
||||
<div className="fixed inset-0 z-[95]">
|
||||
<button
|
||||
|
|
@ -2130,10 +2177,17 @@ function HistoryDetailModal({
|
|||
) : null}
|
||||
</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">
|
||||
<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">
|
||||
{item.content || "无"}
|
||||
{contentText}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
|
@ -2244,6 +2298,34 @@ function buildCollapsedPreviewLines(content: string | undefined, placeholder: st
|
|||
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 }) {
|
||||
return (
|
||||
<div className="bg-white px-4 py-3 dark:bg-slate-900/50">
|
||||
|
|
@ -2424,6 +2506,26 @@ function getHistoryLabelBySection(section: WorkSection) {
|
|||
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[] {
|
||||
const seenIds = new Set<number>();
|
||||
return items
|
||||
|
|
|
|||
Loading…
Reference in New Issue