导出功能调整
parent
4f8c4bbd70
commit
9733de9f22
|
|
@ -90,6 +90,7 @@
|
|||
<workItem from="1775197343053" duration="963000" />
|
||||
<workItem from="1775198320186" duration="1451000" />
|
||||
<workItem from="1775200047785" duration="3902000" />
|
||||
<workItem from="1775541962012" duration="805000" />
|
||||
</task>
|
||||
<task id="LOCAL-00001" summary="修改定位信息 0323">
|
||||
<option name="closed" value="true" />
|
||||
|
|
|
|||
|
|
@ -5,12 +5,17 @@ import com.unis.crm.common.BusinessException;
|
|||
import com.unis.crm.common.CurrentUserUtils;
|
||||
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
|
||||
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
|
||||
import com.unis.crm.dto.work.WorkCheckInExportDTO;
|
||||
import com.unis.crm.dto.work.WorkDailyReportExportDTO;
|
||||
import com.unis.crm.dto.work.WorkHistoryPageDTO;
|
||||
import com.unis.crm.dto.work.WorkOverviewDTO;
|
||||
import com.unis.crm.service.WorkService;
|
||||
import com.unisbase.common.annotation.Log;
|
||||
import jakarta.validation.Valid;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.MediaType;
|
||||
|
|
@ -53,6 +58,44 @@ public class WorkController {
|
|||
return ApiResponse.success(workService.getHistory(CurrentUserUtils.requireCurrentUserId(userId), type, page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/checkins/export-data")
|
||||
public ApiResponse<List<WorkCheckInExportDTO>> exportCheckIns(
|
||||
@RequestHeader("X-User-Id") Long userId,
|
||||
@RequestParam(value = "startDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam(value = "endDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "deptName", required = false) String deptName,
|
||||
@RequestParam(value = "bizType", required = false) String bizType,
|
||||
@RequestParam(value = "status", required = false) String status) {
|
||||
return ApiResponse.success(workService.exportCheckIns(
|
||||
CurrentUserUtils.requireCurrentUserId(userId),
|
||||
startDate,
|
||||
endDate,
|
||||
keyword,
|
||||
deptName,
|
||||
bizType,
|
||||
status));
|
||||
}
|
||||
|
||||
@GetMapping("/daily-reports/export-data")
|
||||
public ApiResponse<List<WorkDailyReportExportDTO>> exportDailyReports(
|
||||
@RequestHeader("X-User-Id") Long userId,
|
||||
@RequestParam(value = "startDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@RequestParam(value = "endDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "deptName", required = false) String deptName,
|
||||
@RequestParam(value = "bizType", required = false) String bizType,
|
||||
@RequestParam(value = "status", required = false) String status) {
|
||||
return ApiResponse.success(workService.exportDailyReports(
|
||||
CurrentUserUtils.requireCurrentUserId(userId),
|
||||
startDate,
|
||||
endDate,
|
||||
keyword,
|
||||
deptName,
|
||||
bizType,
|
||||
status));
|
||||
}
|
||||
|
||||
@GetMapping("/reverse-geocode")
|
||||
public ApiResponse<String> reverseGeocode(
|
||||
@RequestHeader(value = "X-User-Id", required = false) Long userId,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
package com.unis.crm.dto.work;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class WorkCheckInExportDTO {
|
||||
|
||||
private String checkinDate;
|
||||
private String checkinTime;
|
||||
private String userName;
|
||||
private String deptName;
|
||||
private String bizType;
|
||||
private String bizName;
|
||||
private String locationText;
|
||||
private BigDecimal longitude;
|
||||
private BigDecimal latitude;
|
||||
private String remark;
|
||||
private String status;
|
||||
private String createdAt;
|
||||
private String updatedAt;
|
||||
private List<String> photoUrls = new ArrayList<>();
|
||||
|
||||
public String getCheckinDate() {
|
||||
return checkinDate;
|
||||
}
|
||||
|
||||
public void setCheckinDate(String checkinDate) {
|
||||
this.checkinDate = checkinDate;
|
||||
}
|
||||
|
||||
public String getCheckinTime() {
|
||||
return checkinTime;
|
||||
}
|
||||
|
||||
public void setCheckinTime(String checkinTime) {
|
||||
this.checkinTime = checkinTime;
|
||||
}
|
||||
|
||||
public String getUserName() {
|
||||
return userName;
|
||||
}
|
||||
|
||||
public void setUserName(String userName) {
|
||||
this.userName = userName;
|
||||
}
|
||||
|
||||
public String getDeptName() {
|
||||
return deptName;
|
||||
}
|
||||
|
||||
public void setDeptName(String deptName) {
|
||||
this.deptName = deptName;
|
||||
}
|
||||
|
||||
public String getBizType() {
|
||||
return bizType;
|
||||
}
|
||||
|
||||
public void setBizType(String bizType) {
|
||||
this.bizType = bizType;
|
||||
}
|
||||
|
||||
public String getBizName() {
|
||||
return bizName;
|
||||
}
|
||||
|
||||
public void setBizName(String bizName) {
|
||||
this.bizName = bizName;
|
||||
}
|
||||
|
||||
public String getLocationText() {
|
||||
return locationText;
|
||||
}
|
||||
|
||||
public void setLocationText(String locationText) {
|
||||
this.locationText = locationText;
|
||||
}
|
||||
|
||||
public BigDecimal getLongitude() {
|
||||
return longitude;
|
||||
}
|
||||
|
||||
public void setLongitude(BigDecimal longitude) {
|
||||
this.longitude = longitude;
|
||||
}
|
||||
|
||||
public BigDecimal getLatitude() {
|
||||
return latitude;
|
||||
}
|
||||
|
||||
public void setLatitude(BigDecimal latitude) {
|
||||
this.latitude = latitude;
|
||||
}
|
||||
|
||||
public String getRemark() {
|
||||
return remark;
|
||||
}
|
||||
|
||||
public void setRemark(String remark) {
|
||||
this.remark = remark;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(String createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public String getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(String updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public List<String> getPhotoUrls() {
|
||||
return photoUrls;
|
||||
}
|
||||
|
||||
public void setPhotoUrls(List<String> photoUrls) {
|
||||
this.photoUrls = photoUrls;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
package com.unis.crm.dto.work;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class WorkDailyReportExportDTO {
|
||||
|
||||
private String reportDate;
|
||||
private String submitTime;
|
||||
private String userName;
|
||||
private String deptName;
|
||||
private String sourceType;
|
||||
private String status;
|
||||
private String workContent;
|
||||
private String tomorrowPlan;
|
||||
private Integer score;
|
||||
private String comment;
|
||||
private String reviewerName;
|
||||
private String reviewedAt;
|
||||
private String createdAt;
|
||||
private String updatedAt;
|
||||
private List<WorkReportLineItemDTO> lineItems = new ArrayList<>();
|
||||
private List<WorkTomorrowPlanItemDTO> planItems = new ArrayList<>();
|
||||
|
||||
public String getReportDate() {
|
||||
return reportDate;
|
||||
}
|
||||
|
||||
public void setReportDate(String reportDate) {
|
||||
this.reportDate = reportDate;
|
||||
}
|
||||
|
||||
public String getSubmitTime() {
|
||||
return submitTime;
|
||||
}
|
||||
|
||||
public void setSubmitTime(String submitTime) {
|
||||
this.submitTime = submitTime;
|
||||
}
|
||||
|
||||
public String getUserName() {
|
||||
return userName;
|
||||
}
|
||||
|
||||
public void setUserName(String userName) {
|
||||
this.userName = userName;
|
||||
}
|
||||
|
||||
public String getDeptName() {
|
||||
return deptName;
|
||||
}
|
||||
|
||||
public void setDeptName(String deptName) {
|
||||
this.deptName = deptName;
|
||||
}
|
||||
|
||||
public String getSourceType() {
|
||||
return sourceType;
|
||||
}
|
||||
|
||||
public void setSourceType(String sourceType) {
|
||||
this.sourceType = sourceType;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getWorkContent() {
|
||||
return workContent;
|
||||
}
|
||||
|
||||
public void setWorkContent(String workContent) {
|
||||
this.workContent = workContent;
|
||||
}
|
||||
|
||||
public String getTomorrowPlan() {
|
||||
return tomorrowPlan;
|
||||
}
|
||||
|
||||
public void setTomorrowPlan(String tomorrowPlan) {
|
||||
this.tomorrowPlan = tomorrowPlan;
|
||||
}
|
||||
|
||||
public Integer getScore() {
|
||||
return score;
|
||||
}
|
||||
|
||||
public void setScore(Integer score) {
|
||||
this.score = score;
|
||||
}
|
||||
|
||||
public String getComment() {
|
||||
return comment;
|
||||
}
|
||||
|
||||
public void setComment(String comment) {
|
||||
this.comment = comment;
|
||||
}
|
||||
|
||||
public String getReviewerName() {
|
||||
return reviewerName;
|
||||
}
|
||||
|
||||
public void setReviewerName(String reviewerName) {
|
||||
this.reviewerName = reviewerName;
|
||||
}
|
||||
|
||||
public String getReviewedAt() {
|
||||
return reviewedAt;
|
||||
}
|
||||
|
||||
public void setReviewedAt(String reviewedAt) {
|
||||
this.reviewedAt = reviewedAt;
|
||||
}
|
||||
|
||||
public String getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(String createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public String getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(String updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public List<WorkReportLineItemDTO> getLineItems() {
|
||||
return lineItems;
|
||||
}
|
||||
|
||||
public void setLineItems(List<WorkReportLineItemDTO> lineItems) {
|
||||
this.lineItems = lineItems;
|
||||
}
|
||||
|
||||
public List<WorkTomorrowPlanItemDTO> getPlanItems() {
|
||||
return planItems;
|
||||
}
|
||||
|
||||
public void setPlanItems(List<WorkTomorrowPlanItemDTO> planItems) {
|
||||
this.planItems = planItems;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,10 +3,13 @@ package com.unis.crm.mapper;
|
|||
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
|
||||
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
|
||||
import com.unis.crm.dto.work.WorkCheckInDTO;
|
||||
import com.unis.crm.dto.work.WorkCheckInExportDTO;
|
||||
import com.unis.crm.dto.work.WorkDailyReportDTO;
|
||||
import com.unis.crm.dto.work.WorkDailyReportExportDTO;
|
||||
import com.unis.crm.dto.work.WorkHistoryItemDTO;
|
||||
import com.unis.crm.dto.work.WorkSuggestedActionDTO;
|
||||
import com.unisbase.annotation.DataScope;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
|
@ -26,6 +29,25 @@ public interface WorkMapper {
|
|||
@DataScope(tableAlias = "r", ownerColumn = "user_id")
|
||||
List<WorkHistoryItemDTO> selectReportHistory(@Param("limit") int limit);
|
||||
|
||||
@DataScope(tableAlias = "c", ownerColumn = "user_id")
|
||||
List<WorkCheckInExportDTO> selectCheckInExportRows(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
@Param("keyword") String keyword,
|
||||
@Param("deptName") String deptName,
|
||||
@Param("bizType") String bizType,
|
||||
@Param("status") String status,
|
||||
@Param("limit") int limit);
|
||||
|
||||
@DataScope(tableAlias = "r", ownerColumn = "user_id")
|
||||
List<WorkDailyReportExportDTO> selectDailyReportExportRows(
|
||||
@Param("startDate") LocalDate startDate,
|
||||
@Param("endDate") LocalDate endDate,
|
||||
@Param("keyword") String keyword,
|
||||
@Param("deptName") String deptName,
|
||||
@Param("status") String status,
|
||||
@Param("limit") int limit);
|
||||
|
||||
Long selectTodayCheckInId(@Param("userId") Long userId);
|
||||
|
||||
int insertCheckIn(@Param("userId") Long userId, @Param("request") CreateWorkCheckInRequest request);
|
||||
|
|
|
|||
|
|
@ -2,9 +2,13 @@ package com.unis.crm.service;
|
|||
|
||||
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
|
||||
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
|
||||
import com.unis.crm.dto.work.WorkCheckInExportDTO;
|
||||
import com.unis.crm.dto.work.WorkDailyReportExportDTO;
|
||||
import com.unis.crm.dto.work.WorkHistoryPageDTO;
|
||||
import com.unis.crm.dto.work.WorkOverviewDTO;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
|
|
@ -14,6 +18,24 @@ public interface WorkService {
|
|||
|
||||
WorkHistoryPageDTO getHistory(Long userId, String type, int page, int size);
|
||||
|
||||
List<WorkCheckInExportDTO> exportCheckIns(
|
||||
Long userId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String keyword,
|
||||
String deptName,
|
||||
String bizType,
|
||||
String status);
|
||||
|
||||
List<WorkDailyReportExportDTO> exportDailyReports(
|
||||
Long userId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String keyword,
|
||||
String deptName,
|
||||
String bizType,
|
||||
String status);
|
||||
|
||||
Long saveCheckIn(Long userId, CreateWorkCheckInRequest request);
|
||||
|
||||
Long saveDailyReport(Long userId, CreateWorkDailyReportRequest request);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import com.unis.crm.common.BusinessException;
|
|||
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
|
||||
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
|
||||
import com.unis.crm.dto.work.WorkCheckInDTO;
|
||||
import com.unis.crm.dto.work.WorkCheckInExportDTO;
|
||||
import com.unis.crm.dto.work.WorkDailyReportDTO;
|
||||
import com.unis.crm.dto.work.WorkDailyReportExportDTO;
|
||||
import com.unis.crm.dto.work.WorkHistoryItemDTO;
|
||||
import com.unis.crm.dto.work.WorkHistoryPageDTO;
|
||||
import com.unis.crm.dto.work.WorkReportLineItemDTO;
|
||||
|
|
@ -70,6 +72,7 @@ public class WorkServiceImpl implements WorkService {
|
|||
private static final Pattern PLAN_ITEMS_METADATA_PATTERN = Pattern.compile("\\[\\[WORK_PLAN_ITEMS]](.*?)\\[\\[/WORK_PLAN_ITEMS]]", Pattern.DOTALL);
|
||||
private static final String ONLY_SEE_ROLE_CODE = "only_see";
|
||||
private static final String WORK_REPORT_FOLLOW_UP_TYPE = "工作日报";
|
||||
private static final int EXPORT_LIMIT = 5000;
|
||||
private static final String TENCENT_COORD_TYPE_GPS = "1";
|
||||
private static final String TENCENT_COORD_TYPE_GCJ02 = "2";
|
||||
private static final String OPEN_STREET_MAP_USER_AGENT = "unis-crm/1.0";
|
||||
|
|
@ -134,6 +137,58 @@ public class WorkServiceImpl implements WorkService {
|
|||
return new WorkHistoryPageDTO(pagedItems, hasMore, safePage, safeSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<WorkCheckInExportDTO> exportCheckIns(
|
||||
Long userId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String keyword,
|
||||
String deptName,
|
||||
String bizType,
|
||||
String status) {
|
||||
requireUser(userId);
|
||||
validateDateRange(startDate, endDate);
|
||||
String normalizedBizType = normalizeBizType(bizType);
|
||||
List<WorkCheckInExportDTO> rows = workMapper.selectCheckInExportRows(
|
||||
startDate,
|
||||
endDate,
|
||||
normalizeOptionalText(keyword),
|
||||
normalizeOptionalText(deptName),
|
||||
normalizedBizType,
|
||||
normalizeOptionalText(status),
|
||||
EXPORT_LIMIT);
|
||||
normalizeCheckInExportRows(rows);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<WorkDailyReportExportDTO> exportDailyReports(
|
||||
Long userId,
|
||||
LocalDate startDate,
|
||||
LocalDate endDate,
|
||||
String keyword,
|
||||
String deptName,
|
||||
String bizType,
|
||||
String status) {
|
||||
requireUser(userId);
|
||||
validateDateRange(startDate, endDate);
|
||||
String normalizedBizType = normalizeBizType(bizType);
|
||||
List<WorkDailyReportExportDTO> rows = workMapper.selectDailyReportExportRows(
|
||||
startDate,
|
||||
endDate,
|
||||
normalizeOptionalText(keyword),
|
||||
normalizeOptionalText(deptName),
|
||||
normalizeOptionalText(status),
|
||||
EXPORT_LIMIT);
|
||||
normalizeDailyReportExportRows(rows);
|
||||
if (normalizedBizType == null) {
|
||||
return rows;
|
||||
}
|
||||
return rows.stream()
|
||||
.filter(row -> hasReportLineOfType(row, normalizedBizType))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long saveCheckIn(Long userId, CreateWorkCheckInRequest request) {
|
||||
requireUser(userId);
|
||||
|
|
@ -593,6 +648,51 @@ public class WorkServiceImpl implements WorkService {
|
|||
}
|
||||
}
|
||||
|
||||
private void normalizeCheckInExportRows(List<WorkCheckInExportDTO> rows) {
|
||||
if (rows == null) {
|
||||
return;
|
||||
}
|
||||
for (WorkCheckInExportDTO row : rows) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
PhotoMetadata photoMetadata = extractPhotoMetadata(row.getRemark());
|
||||
row.setRemark(photoMetadata.cleanText());
|
||||
row.setPhotoUrls(photoMetadata.photoUrls());
|
||||
}
|
||||
}
|
||||
|
||||
private void normalizeDailyReportExportRows(List<WorkDailyReportExportDTO> rows) {
|
||||
if (rows == null) {
|
||||
return;
|
||||
}
|
||||
for (WorkDailyReportExportDTO row : rows) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
ReportLineMetadata reportLineMetadata = extractReportLineMetadata(row.getWorkContent());
|
||||
row.setWorkContent(stripVisitTimeFromReportText(reportLineMetadata.cleanText()));
|
||||
row.setLineItems(reportLineMetadata.lineItems());
|
||||
PlanItemMetadata planItemMetadata = extractPlanItemMetadata(row.getTomorrowPlan());
|
||||
row.setTomorrowPlan(planItemMetadata.cleanText());
|
||||
row.setPlanItems(planItemMetadata.planItems());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasReportLineOfType(WorkDailyReportExportDTO row, String bizType) {
|
||||
if (row == null || bizType == null || row.getLineItems() == null) {
|
||||
return false;
|
||||
}
|
||||
return row.getLineItems().stream()
|
||||
.anyMatch(item -> item != null && bizType.equals(item.getBizType()));
|
||||
}
|
||||
|
||||
private void validateDateRange(LocalDate startDate, LocalDate endDate) {
|
||||
if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
|
||||
throw new BusinessException("开始日期不能晚于结束日期");
|
||||
}
|
||||
}
|
||||
|
||||
private String stripVisitTimeFromReportText(String rawText) {
|
||||
String normalized = rawText == null ? "" : rawText;
|
||||
return normalized
|
||||
|
|
|
|||
|
|
@ -242,6 +242,124 @@
|
|||
limit #{limit}
|
||||
</select>
|
||||
|
||||
<select id="selectCheckInExportRows" resultType="com.unis.crm.dto.work.WorkCheckInExportDTO">
|
||||
select
|
||||
to_char(c.checkin_date, 'YYYY-MM-DD') as checkinDate,
|
||||
to_char(c.checkin_time, 'HH24:MI') as checkinTime,
|
||||
coalesce(nullif(btrim(c.user_name), ''), nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as userName,
|
||||
coalesce(nullif(btrim(c.dept_name), ''), nullif(btrim(org_info.org_names), ''), '') as deptName,
|
||||
coalesce(c.biz_type, '') as bizType,
|
||||
coalesce(c.biz_name, '') as bizName,
|
||||
coalesce(c.location_text, '') as locationText,
|
||||
c.longitude,
|
||||
c.latitude,
|
||||
coalesce(c.remark, '') as remark,
|
||||
coalesce(c.status, 'normal') as status,
|
||||
to_char(c.created_at, 'YYYY-MM-DD HH24:MI') as createdAt,
|
||||
to_char(c.updated_at, 'YYYY-MM-DD HH24:MI') as updatedAt
|
||||
from work_checkin c
|
||||
left join sys_user u on u.user_id = c.user_id and u.is_deleted = 0
|
||||
left join lateral (
|
||||
select string_agg(distinct o.org_name, '、' order by o.org_name) as org_names
|
||||
from sys_tenant_user tu
|
||||
join sys_org o on o.id = tu.org_id
|
||||
where tu.user_id = c.user_id
|
||||
and tu.is_deleted = 0
|
||||
and o.is_deleted = 0
|
||||
) org_info on true
|
||||
where 1 = 1
|
||||
<if test="startDate != null">
|
||||
and c.checkin_date >= #{startDate}
|
||||
</if>
|
||||
<if test="endDate != null">
|
||||
and c.checkin_date <= #{endDate}
|
||||
</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
and (
|
||||
coalesce(c.user_name, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(u.display_name, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(u.username, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(c.biz_name, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(c.location_text, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(c.remark, '') ilike concat('%', #{keyword}, '%')
|
||||
)
|
||||
</if>
|
||||
<if test="deptName != null and deptName != ''">
|
||||
and coalesce(nullif(btrim(c.dept_name), ''), nullif(btrim(org_info.org_names), ''), '') ilike concat('%', #{deptName}, '%')
|
||||
</if>
|
||||
<if test="bizType != null and bizType != ''">
|
||||
and c.biz_type = #{bizType}
|
||||
</if>
|
||||
<if test="status != null and status != ''">
|
||||
and coalesce(c.status, 'normal') = #{status}
|
||||
</if>
|
||||
order by coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) desc nulls last, c.id desc
|
||||
limit #{limit}
|
||||
</select>
|
||||
|
||||
<select id="selectDailyReportExportRows" resultType="com.unis.crm.dto.work.WorkDailyReportExportDTO">
|
||||
select
|
||||
to_char(r.report_date, 'YYYY-MM-DD') as reportDate,
|
||||
to_char(r.submit_time, 'YYYY-MM-DD HH24:MI') as submitTime,
|
||||
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as userName,
|
||||
coalesce(nullif(btrim(org_info.org_names), ''), '') as deptName,
|
||||
coalesce(r.source_type, 'manual') as sourceType,
|
||||
coalesce(r.status, 'submitted') as status,
|
||||
coalesce(r.work_content, '') as workContent,
|
||||
coalesce(r.tomorrow_plan, '') as tomorrowPlan,
|
||||
rc.score,
|
||||
rc.comment_content as comment,
|
||||
coalesce(nullif(btrim(reviewer.display_name), ''), nullif(btrim(reviewer.username), ''), '') as reviewerName,
|
||||
to_char(rc.reviewed_at, 'YYYY-MM-DD HH24:MI') as reviewedAt,
|
||||
to_char(r.created_at, 'YYYY-MM-DD HH24:MI') as createdAt,
|
||||
to_char(r.updated_at, 'YYYY-MM-DD HH24:MI') as updatedAt
|
||||
from work_daily_report r
|
||||
left join sys_user u on u.user_id = r.user_id and u.is_deleted = 0
|
||||
left join lateral (
|
||||
select string_agg(distinct o.org_name, '、' order by o.org_name) as org_names
|
||||
from sys_tenant_user tu
|
||||
join sys_org o on o.id = tu.org_id
|
||||
where tu.user_id = r.user_id
|
||||
and tu.is_deleted = 0
|
||||
and o.is_deleted = 0
|
||||
) org_info on true
|
||||
left join (
|
||||
select distinct on (report_id)
|
||||
report_id,
|
||||
reviewer_user_id,
|
||||
score,
|
||||
comment_content,
|
||||
reviewed_at
|
||||
from work_daily_report_comment
|
||||
order by report_id, reviewed_at desc nulls last, id desc
|
||||
) rc on rc.report_id = r.id
|
||||
left join sys_user reviewer on reviewer.user_id = rc.reviewer_user_id and reviewer.is_deleted = 0
|
||||
where 1 = 1
|
||||
<if test="startDate != null">
|
||||
and r.report_date >= #{startDate}
|
||||
</if>
|
||||
<if test="endDate != null">
|
||||
and r.report_date <= #{endDate}
|
||||
</if>
|
||||
<if test="keyword != null and keyword != ''">
|
||||
and (
|
||||
coalesce(u.display_name, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(u.username, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(r.work_content, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(r.tomorrow_plan, '') ilike concat('%', #{keyword}, '%')
|
||||
or coalesce(rc.comment_content, '') ilike concat('%', #{keyword}, '%')
|
||||
)
|
||||
</if>
|
||||
<if test="deptName != null and deptName != ''">
|
||||
and coalesce(nullif(btrim(org_info.org_names), ''), '') ilike concat('%', #{deptName}, '%')
|
||||
</if>
|
||||
<if test="status != null and status != ''">
|
||||
and coalesce(r.status, 'submitted') = #{status}
|
||||
</if>
|
||||
order by coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) desc nulls last, r.id desc
|
||||
limit #{limit}
|
||||
</select>
|
||||
|
||||
<select id="selectTodayCheckInId" resultType="java.lang.Long">
|
||||
select id
|
||||
from work_checkin
|
||||
|
|
|
|||
|
|
@ -30,4 +30,41 @@ class WorkMapperDataScopeTest {
|
|||
assertEquals("r", dataScope.tableAlias());
|
||||
assertEquals("user_id", dataScope.ownerColumn());
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectCheckInExportRows_shouldUseDataScopeOnCheckInUserId() throws Exception {
|
||||
Method method = WorkMapper.class.getMethod(
|
||||
"selectCheckInExportRows",
|
||||
java.time.LocalDate.class,
|
||||
java.time.LocalDate.class,
|
||||
String.class,
|
||||
String.class,
|
||||
String.class,
|
||||
String.class,
|
||||
int.class);
|
||||
|
||||
DataScope dataScope = method.getAnnotation(DataScope.class);
|
||||
|
||||
assertNotNull(dataScope);
|
||||
assertEquals("c", dataScope.tableAlias());
|
||||
assertEquals("user_id", dataScope.ownerColumn());
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectDailyReportExportRows_shouldUseDataScopeOnReportUserId() throws Exception {
|
||||
Method method = WorkMapper.class.getMethod(
|
||||
"selectDailyReportExportRows",
|
||||
java.time.LocalDate.class,
|
||||
java.time.LocalDate.class,
|
||||
String.class,
|
||||
String.class,
|
||||
String.class,
|
||||
int.class);
|
||||
|
||||
DataScope dataScope = method.getAnnotation(DataScope.class);
|
||||
|
||||
assertNotNull(dataScope);
|
||||
assertEquals("r", dataScope.tableAlias());
|
||||
assertEquals("user_id", dataScope.ownerColumn());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||
import com.unis.crm.common.BusinessException;
|
||||
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
|
||||
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
|
||||
import com.unis.crm.dto.work.WorkCheckInExportDTO;
|
||||
import com.unis.crm.dto.work.WorkDailyReportExportDTO;
|
||||
import com.unis.crm.dto.work.WorkHistoryItemDTO;
|
||||
import com.unis.crm.dto.work.WorkHistoryPageDTO;
|
||||
import com.unis.crm.mapper.ProfileMapper;
|
||||
|
|
@ -135,6 +137,53 @@ class WorkServiceImplTest {
|
|||
assertEquals("当前角色仅可查看打卡历史记录", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportCheckIns_shouldCleanPhotoMetadata() {
|
||||
WorkCheckInExportDTO row = new WorkCheckInExportDTO();
|
||||
row.setRemark("已完成现场拜访\n[[CHECKIN_PHOTOS]]/api/work/checkin-photos/a.jpg||/api/work/checkin-photos/b.jpg[[/CHECKIN_PHOTOS]]");
|
||||
when(workMapper.selectCheckInExportRows(null, null, null, null, null, null, 5000))
|
||||
.thenReturn(List.of(row));
|
||||
|
||||
List<WorkCheckInExportDTO> result = workService.exportCheckIns(17L, null, null, null, null, null, null);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("已完成现场拜访", result.get(0).getRemark());
|
||||
assertEquals(List.of("/api/work/checkin-photos/a.jpg", "/api/work/checkin-photos/b.jpg"), result.get(0).getPhotoUrls());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportDailyReports_shouldCleanMetadataAndFilterByBizType() {
|
||||
WorkDailyReportExportDTO opportunityReport = new WorkDailyReportExportDTO();
|
||||
opportunityReport.setWorkContent("""
|
||||
1. 2026-04-02 跟进商机“项目A”:项目最新进展:推进中;后续规划:继续推进
|
||||
[[WORK_REPORT_LINES]][{"workDate":"2026-04-02","bizType":"opportunity","bizId":1,"bizName":"项目A","content":"项目最新进展:推进中\\n后续规划:继续推进","latestProgress":"推进中","nextPlan":"继续推进","editorText":"@商机 项目A\\n# 项目最新进展:推进中\\n# 后续规划:继续推进"}][[/WORK_REPORT_LINES]]
|
||||
""");
|
||||
opportunityReport.setTomorrowPlan("""
|
||||
1. 跟进客户
|
||||
[[WORK_PLAN_ITEMS]][{"content":"跟进客户"}][[/WORK_PLAN_ITEMS]]
|
||||
""");
|
||||
WorkDailyReportExportDTO salesReport = new WorkDailyReportExportDTO();
|
||||
salesReport.setWorkContent("""
|
||||
1. 2026-04-02 跟进销售拓展“张三”:沟通内容:已沟通
|
||||
[[WORK_REPORT_LINES]][{"workDate":"2026-04-02","bizType":"sales","bizId":2,"bizName":"张三","content":"沟通内容:已沟通","evaluationContent":"已沟通"}][[/WORK_REPORT_LINES]]
|
||||
""");
|
||||
salesReport.setTomorrowPlan("""
|
||||
1. 继续沟通
|
||||
[[WORK_PLAN_ITEMS]][{"content":"继续沟通"}][[/WORK_PLAN_ITEMS]]
|
||||
""");
|
||||
when(workMapper.selectDailyReportExportRows(null, null, null, null, null, 5000))
|
||||
.thenReturn(List.of(opportunityReport, salesReport));
|
||||
|
||||
List<WorkDailyReportExportDTO> result = workService.exportDailyReports(17L, null, null, null, null, "opportunity", null);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("1. 2026-04-02 跟进商机“项目A”:项目最新进展:推进中;后续规划:继续推进", result.get(0).getWorkContent());
|
||||
assertEquals(1, result.get(0).getLineItems().size());
|
||||
assertEquals("项目A", result.get(0).getLineItems().get(0).getBizName());
|
||||
assertEquals(1, result.get(0).getPlanItems().size());
|
||||
assertEquals("跟进客户", result.get(0).getPlanItems().get(0).getContent());
|
||||
}
|
||||
|
||||
private WorkHistoryItemDTO historyItem(Long id, String type, String date, String time, String content) {
|
||||
WorkHistoryItemDTO item = new WorkHistoryItemDTO();
|
||||
item.setId(id);
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
mock-maker-subclass
|
||||
|
|
@ -180,6 +180,42 @@ export interface WorkHistoryItem {
|
|||
photoUrls?: string[];
|
||||
}
|
||||
|
||||
export interface WorkCheckInExportRow {
|
||||
checkinDate?: string;
|
||||
checkinTime?: string;
|
||||
userName?: string;
|
||||
deptName?: string;
|
||||
bizType?: "sales" | "channel" | "opportunity" | string;
|
||||
bizName?: string;
|
||||
locationText?: string;
|
||||
longitude?: number;
|
||||
latitude?: number;
|
||||
photoUrls?: string[];
|
||||
remark?: string;
|
||||
status?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface WorkDailyReportExportRow {
|
||||
reportDate?: string;
|
||||
submitTime?: string;
|
||||
userName?: string;
|
||||
deptName?: string;
|
||||
sourceType?: string;
|
||||
status?: string;
|
||||
workContent?: string;
|
||||
tomorrowPlan?: string;
|
||||
score?: number;
|
||||
comment?: string;
|
||||
reviewerName?: string;
|
||||
reviewedAt?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
lineItems?: WorkReportLineItem[];
|
||||
planItems?: WorkTomorrowPlanItem[];
|
||||
}
|
||||
|
||||
export interface WorkOverview {
|
||||
todayCheckIn?: WorkCheckIn;
|
||||
todayReport?: WorkDailyReport;
|
||||
|
|
@ -194,6 +230,15 @@ export interface WorkHistoryPage {
|
|||
size?: number;
|
||||
}
|
||||
|
||||
export interface WorkExportQuery {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
keyword?: string;
|
||||
deptName?: string;
|
||||
bizType?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface CreateWorkCheckInPayload {
|
||||
locationText: string;
|
||||
remark?: string;
|
||||
|
|
@ -836,6 +881,26 @@ export async function getWorkHistory(type: "checkin" | "report", page = 1, size
|
|||
return request<WorkHistoryPage>(`/api/work/history?${params.toString()}`, undefined, true);
|
||||
}
|
||||
|
||||
function buildWorkExportQuery(params?: WorkExportQuery) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params ?? {}).forEach(([key, value]) => {
|
||||
const normalized = value?.trim();
|
||||
if (normalized) {
|
||||
searchParams.set(key, normalized);
|
||||
}
|
||||
});
|
||||
const query = searchParams.toString();
|
||||
return query ? `?${query}` : "";
|
||||
}
|
||||
|
||||
export async function getWorkCheckInExportData(params?: WorkExportQuery) {
|
||||
return request<WorkCheckInExportRow[]>(`/api/work/checkins/export-data${buildWorkExportQuery(params)}`, undefined, true);
|
||||
}
|
||||
|
||||
export async function getWorkDailyReportExportData(params?: WorkExportQuery) {
|
||||
return request<WorkDailyReportExportRow[]>(`/api/work/daily-reports/export-data${buildWorkExportQuery(params)}`, undefined, true);
|
||||
}
|
||||
|
||||
export async function reverseWorkGeocode(latitude: number, longitude: number) {
|
||||
return request<string>(`/api/work/reverse-geocode?lat=${latitude}&lon=${longitude}`, undefined, true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,20 @@ import { cn } from "@/lib/utils";
|
|||
|
||||
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem;
|
||||
type ExpansionTab = "sales" | "channel";
|
||||
type ExpansionExportFilters = {
|
||||
keyword?: string;
|
||||
intent?: string;
|
||||
officeName?: string;
|
||||
industry?: string;
|
||||
employmentStatus?: string;
|
||||
province?: string;
|
||||
certificationLevel?: string;
|
||||
channelIndustry?: string;
|
||||
channelAttribute?: string;
|
||||
establishedStartDate?: string;
|
||||
establishedEndDate?: string;
|
||||
hasRelatedProject?: string;
|
||||
};
|
||||
type SalesCreateField =
|
||||
| "employeeNo"
|
||||
| "officeName"
|
||||
|
|
@ -137,6 +151,131 @@ function normalizeExportText(value?: string | number | boolean | null) {
|
|||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeExportFilterText(value?: string | number | boolean | null) {
|
||||
return normalizeExportText(value).toLowerCase();
|
||||
}
|
||||
|
||||
function matchesExportKeyword(value: string, keyword?: string) {
|
||||
const normalizedKeyword = normalizeExportFilterText(keyword);
|
||||
return !normalizedKeyword || value.toLowerCase().includes(normalizedKeyword);
|
||||
}
|
||||
|
||||
function matchesTextFilter(value: string | undefined, filterValue?: string) {
|
||||
const normalizedFilter = normalizeExportFilterText(filterValue);
|
||||
if (!normalizedFilter) {
|
||||
return true;
|
||||
}
|
||||
return normalizeExportFilterText(value).includes(normalizedFilter);
|
||||
}
|
||||
|
||||
function matchesDateRange(value?: string, startDate?: string, endDate?: string) {
|
||||
const normalizedValue = normalizeExportText(value).slice(0, 10);
|
||||
if (!normalizedValue) {
|
||||
return !startDate && !endDate;
|
||||
}
|
||||
if (startDate && normalizedValue < startDate) {
|
||||
return false;
|
||||
}
|
||||
if (endDate && normalizedValue > endDate) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasRelatedProjects(projects?: Array<{ amount?: number }>) {
|
||||
return Boolean(projects?.length);
|
||||
}
|
||||
|
||||
function matchesRelatedProjectFilter(projects: Array<{ amount?: number }> | undefined, filterValue?: string) {
|
||||
if (filterValue === "yes") {
|
||||
return hasRelatedProjects(projects);
|
||||
}
|
||||
if (filterValue === "no") {
|
||||
return !hasRelatedProjects(projects);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function matchesSalesExportFilters(item: SalesExpansionItem, filters: ExpansionExportFilters) {
|
||||
const keywordText = [
|
||||
item.employeeNo,
|
||||
item.name,
|
||||
item.owner,
|
||||
item.phone,
|
||||
item.officeName,
|
||||
item.dept,
|
||||
item.title,
|
||||
item.industry,
|
||||
item.intent,
|
||||
item.relatedProjects?.map((project) => `${project.opportunityCode ?? ""} ${project.opportunityName ?? ""}`).join(" "),
|
||||
item.followUps?.map((followUp) => `${followUp.content ?? ""} ${followUp.evaluationContent ?? ""} ${followUp.nextPlan ?? ""}`).join(" "),
|
||||
].map(normalizeExportText).filter(Boolean).join(" ");
|
||||
|
||||
if (!matchesExportKeyword(keywordText, filters.keyword)) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesTextFilter(item.intent || item.intentLevel, filters.intent)) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesTextFilter(item.officeName, filters.officeName)) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesTextFilter(item.industry, filters.industry)) {
|
||||
return false;
|
||||
}
|
||||
if (filters.employmentStatus === "active" && item.active !== true && item.employmentStatus !== "active") {
|
||||
return false;
|
||||
}
|
||||
if (filters.employmentStatus === "inactive" && item.active !== false && item.employmentStatus !== "inactive") {
|
||||
return false;
|
||||
}
|
||||
return matchesRelatedProjectFilter(item.relatedProjects, filters.hasRelatedProject);
|
||||
}
|
||||
|
||||
function matchesChannelExportFilters(item: ChannelExpansionItem, filters: ExpansionExportFilters) {
|
||||
const keywordText = [
|
||||
item.channelCode,
|
||||
item.name,
|
||||
item.owner,
|
||||
item.province,
|
||||
item.city,
|
||||
item.officeAddress,
|
||||
item.certificationLevel,
|
||||
item.channelIndustry,
|
||||
item.channelAttribute,
|
||||
item.internalAttribute,
|
||||
item.intent,
|
||||
item.primaryContactName,
|
||||
item.primaryContactMobile,
|
||||
item.contacts?.map((contact) => `${contact.name ?? ""} ${contact.mobile ?? ""} ${contact.title ?? ""}`).join(" "),
|
||||
item.relatedProjects?.map((project) => `${project.opportunityCode ?? ""} ${project.opportunityName ?? ""}`).join(" "),
|
||||
item.followUps?.map((followUp) => `${followUp.content ?? ""} ${followUp.evaluationContent ?? ""} ${followUp.nextPlan ?? ""}`).join(" "),
|
||||
].map(normalizeExportText).filter(Boolean).join(" ");
|
||||
|
||||
if (!matchesExportKeyword(keywordText, filters.keyword)) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesTextFilter(item.intent || item.intentLevel, filters.intent)) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesTextFilter(item.province, filters.province)) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesTextFilter(item.certificationLevel, filters.certificationLevel)) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesTextFilter(item.channelIndustry, filters.channelIndustry)) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesTextFilter(item.channelAttribute, filters.channelAttribute)) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesDateRange(item.establishedDate, filters.establishedStartDate, filters.establishedEndDate)) {
|
||||
return false;
|
||||
}
|
||||
return matchesRelatedProjectFilter(item.relatedProjects, filters.hasRelatedProject);
|
||||
}
|
||||
|
||||
function formatExportBoolean(value?: boolean, trueLabel = "是", falseLabel = "否") {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
|
|
@ -568,6 +707,248 @@ function RequiredMark() {
|
|||
return <span className="ml-1 text-rose-500">*</span>;
|
||||
}
|
||||
|
||||
function ExpansionExportFilterModal({
|
||||
activeTab,
|
||||
initialFilters,
|
||||
exporting,
|
||||
exportError,
|
||||
officeOptions,
|
||||
industryOptions,
|
||||
provinceOptions,
|
||||
certificationLevelOptions,
|
||||
channelAttributeOptions,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
activeTab: ExpansionTab;
|
||||
initialFilters: ExpansionExportFilters;
|
||||
exporting: boolean;
|
||||
exportError: string;
|
||||
officeOptions: ExpansionDictOption[];
|
||||
industryOptions: ExpansionDictOption[];
|
||||
provinceOptions: ExpansionDictOption[];
|
||||
certificationLevelOptions: ExpansionDictOption[];
|
||||
channelAttributeOptions: ExpansionDictOption[];
|
||||
onClose: () => void;
|
||||
onConfirm: (filters: ExpansionExportFilters) => void;
|
||||
}) {
|
||||
const [draftFilters, setDraftFilters] = useState<ExpansionExportFilters>(initialFilters);
|
||||
const isSalesTab = activeTab === "sales";
|
||||
const hasDraftFilters = Boolean(
|
||||
draftFilters.keyword
|
||||
|| draftFilters.intent
|
||||
|| draftFilters.officeName
|
||||
|| draftFilters.industry
|
||||
|| draftFilters.employmentStatus
|
||||
|| draftFilters.province
|
||||
|| draftFilters.certificationLevel
|
||||
|| draftFilters.channelIndustry
|
||||
|| draftFilters.channelAttribute
|
||||
|| draftFilters.establishedStartDate
|
||||
|| draftFilters.establishedEndDate
|
||||
|| draftFilters.hasRelatedProject,
|
||||
);
|
||||
const handleFilterChange = (key: keyof ExpansionExportFilters, value: string) => {
|
||||
setDraftFilters((current) => ({ ...current, [key]: value }));
|
||||
};
|
||||
const renderOption = (option: ExpansionDictOption) => {
|
||||
const value = option.label || option.value || "";
|
||||
return value ? <option key={value} value={value}>{value}</option> : null;
|
||||
};
|
||||
const toSearchableOptions = (options: ExpansionDictOption[], allLabel: string) => [
|
||||
{ value: "", label: allLabel },
|
||||
...options
|
||||
.map((option) => {
|
||||
const value = option.label || option.value || "";
|
||||
return value ? { value, label: value } : null;
|
||||
})
|
||||
.filter((option): option is { value: string; label: string } => Boolean(option)),
|
||||
];
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
title={`导出${isSalesTab ? "销售人员拓展" : "渠道拓展"}记录`}
|
||||
subtitle="选择条件后导出 Excel;不填条件则导出全部可见权限范围内的数据。"
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftFilters({})}
|
||||
disabled={!hasDraftFilters}
|
||||
className="crm-btn crm-btn-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
清空条件
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm(draftFilters)}
|
||||
disabled={exporting}
|
||||
className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{exporting ? "导出中..." : "确认导出"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className="space-y-1.5 sm:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">关键词</span>
|
||||
<input
|
||||
value={draftFilters.keyword ?? ""}
|
||||
onChange={(event) => handleFilterChange("keyword", event.target.value)}
|
||||
placeholder={isSalesTab ? "搜索姓名、工号、电话、行业、项目" : "搜索渠道、联系人、地区、项目"}
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">合作意向</span>
|
||||
<AdaptiveSelect
|
||||
value={draftFilters.intent ?? ""}
|
||||
options={[
|
||||
{ value: "", label: "全部合作意向" },
|
||||
{ value: "高", label: "高意向" },
|
||||
{ value: "中", label: "中意向" },
|
||||
{ value: "低", label: "低意向" },
|
||||
]}
|
||||
placeholder="全部合作意向"
|
||||
sheetTitle="选择合作意向"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
onChange={(value) => handleFilterChange("intent", value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">关联项目</span>
|
||||
<AdaptiveSelect
|
||||
value={draftFilters.hasRelatedProject ?? ""}
|
||||
options={[
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "yes", label: "有关联项目" },
|
||||
{ value: "no", label: "无关联项目" },
|
||||
]}
|
||||
placeholder="全部"
|
||||
sheetTitle="选择关联项目"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
onChange={(value) => handleFilterChange("hasRelatedProject", value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{isSalesTab ? (
|
||||
<>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">代表处 / 办事处</span>
|
||||
<AdaptiveSelect
|
||||
value={draftFilters.officeName ?? ""}
|
||||
options={toSearchableOptions(officeOptions, "全部代表处 / 办事处")}
|
||||
placeholder="全部代表处 / 办事处"
|
||||
sheetTitle="选择代表处 / 办事处"
|
||||
searchable
|
||||
searchPlaceholder="搜索代表处 / 办事处"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
onChange={(value) => handleFilterChange("officeName", value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">所属行业</span>
|
||||
<AdaptiveSelect
|
||||
value={draftFilters.industry ?? ""}
|
||||
options={toSearchableOptions(industryOptions, "全部行业")}
|
||||
placeholder="全部行业"
|
||||
sheetTitle="选择所属行业"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
onChange={(value) => handleFilterChange("industry", value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5 sm:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">销售是否在职</span>
|
||||
<AdaptiveSelect
|
||||
value={draftFilters.employmentStatus ?? ""}
|
||||
options={[
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "active", label: "在职" },
|
||||
{ value: "inactive", label: "离职" },
|
||||
]}
|
||||
placeholder="全部"
|
||||
sheetTitle="选择在职状态"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
onChange={(value) => handleFilterChange("employmentStatus", value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">省份</span>
|
||||
<AdaptiveSelect
|
||||
value={draftFilters.province ?? ""}
|
||||
options={toSearchableOptions(provinceOptions, "全部省份")}
|
||||
placeholder="全部省份"
|
||||
sheetTitle="选择省份"
|
||||
searchable
|
||||
searchPlaceholder="搜索省份"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
onChange={(value) => handleFilterChange("province", value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">认证级别</span>
|
||||
<AdaptiveSelect
|
||||
value={draftFilters.certificationLevel ?? ""}
|
||||
options={toSearchableOptions(certificationLevelOptions, "全部认证级别")}
|
||||
placeholder="全部认证级别"
|
||||
sheetTitle="选择认证级别"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
onChange={(value) => handleFilterChange("certificationLevel", value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">聚焦行业</span>
|
||||
<AdaptiveSelect
|
||||
value={draftFilters.channelIndustry ?? ""}
|
||||
options={toSearchableOptions(industryOptions, "全部行业")}
|
||||
placeholder="全部行业"
|
||||
sheetTitle="选择聚焦行业"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
onChange={(value) => handleFilterChange("channelIndustry", value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">渠道属性</span>
|
||||
<AdaptiveSelect
|
||||
value={draftFilters.channelAttribute ?? ""}
|
||||
options={toSearchableOptions(channelAttributeOptions, "全部渠道属性")}
|
||||
placeholder="全部渠道属性"
|
||||
sheetTitle="选择渠道属性"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
onChange={(value) => handleFilterChange("channelAttribute", value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">建立联系开始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={draftFilters.establishedStartDate ?? ""}
|
||||
onChange={(event) => handleFilterChange("establishedStartDate", event.target.value)}
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">建立联系结束日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={draftFilters.establishedEndDate ?? ""}
|
||||
onChange={(event) => handleFilterChange("establishedEndDate", event.target.value)}
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{exportError ? <div className="crm-alert crm-alert-error mt-4">{exportError}</div> : null}
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Expansion() {
|
||||
const currentUserId = getStoredCurrentUserId();
|
||||
const location = useLocation();
|
||||
|
|
@ -595,6 +976,8 @@ export default function Expansion() {
|
|||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportFilterOpen, setExportFilterOpen] = useState(false);
|
||||
const [exportFilters, setExportFilters] = useState<ExpansionExportFilters>({});
|
||||
const [salesDuplicateChecking, setSalesDuplicateChecking] = useState(false);
|
||||
const [channelDuplicateChecking, setChannelDuplicateChecking] = useState(false);
|
||||
const [createError, setCreateError] = useState("");
|
||||
|
|
@ -616,7 +999,7 @@ export default function Expansion() {
|
|||
const [channelForm, setChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm);
|
||||
const [editSalesForm, setEditSalesForm] = useState<CreateSalesExpansionPayload>(defaultSalesForm);
|
||||
const [editChannelForm, setEditChannelForm] = useState<CreateChannelExpansionPayload>(defaultChannelForm);
|
||||
const hasForegroundModal = createOpen || editOpen;
|
||||
const hasForegroundModal = createOpen || editOpen || exportFilterOpen;
|
||||
|
||||
const loadMeta = useCallback(async () => {
|
||||
const data = await getExpansionMeta();
|
||||
|
|
@ -1170,27 +1553,30 @@ export default function Expansion() {
|
|||
setExportError("");
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
const handleExport = async (filters: ExpansionExportFilters) => {
|
||||
if (exporting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSalesTab = activeTab === "sales";
|
||||
const items = isSalesTab ? salesData : channelData;
|
||||
if (items.length <= 0) {
|
||||
setExportError(`当前${isSalesTab ? "销售人员拓展" : "渠道拓展"}暂无可导出数据`);
|
||||
return;
|
||||
}
|
||||
|
||||
setExporting(true);
|
||||
setExportError("");
|
||||
setExportFilters(filters);
|
||||
|
||||
try {
|
||||
const overview = await getExpansionOverview("");
|
||||
const exportSalesItems = dedupeExpansionItemsById(overview.salesItems ?? []).filter((item) => matchesSalesExportFilters(item, filters));
|
||||
const exportChannelItems = dedupeExpansionItemsById(overview.channelItems ?? []).filter((item) => matchesChannelExportFilters(item, filters));
|
||||
const exportItems = isSalesTab ? exportSalesItems : exportChannelItems;
|
||||
if (exportItems.length <= 0) {
|
||||
throw new Error(`当前筛选条件下暂无可导出的${isSalesTab ? "销售人员拓展" : "渠道拓展"}数据`);
|
||||
}
|
||||
|
||||
const ExcelJS = await import("exceljs");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet(isSalesTab ? "销售人员拓展" : "渠道拓展");
|
||||
const headers = isSalesTab ? buildSalesExportHeaders(salesData) : buildChannelExportHeaders(channelData);
|
||||
const rows = isSalesTab ? buildSalesExportData(salesData) : buildChannelExportData(channelData);
|
||||
const headers = isSalesTab ? buildSalesExportHeaders(exportSalesItems) : buildChannelExportHeaders(exportChannelItems);
|
||||
const rows = isSalesTab ? buildSalesExportData(exportSalesItems) : buildChannelExportData(exportChannelItems);
|
||||
|
||||
worksheet.addRow(headers);
|
||||
rows.forEach((row) => {
|
||||
|
|
@ -1242,6 +1628,7 @@ export default function Expansion() {
|
|||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
const filename = `${isSalesTab ? "销售人员拓展" : "渠道拓展"}_${formatExportFilenameTime()}.xlsx`;
|
||||
downloadExcelFile(filename, buffer);
|
||||
setExportFilterOpen(false);
|
||||
} catch (error) {
|
||||
setExportError(error instanceof Error ? error.message : "导出失败,请稍后重试");
|
||||
} finally {
|
||||
|
|
@ -1626,7 +2013,10 @@ export default function Expansion() {
|
|||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
onClick={() => {
|
||||
setExportError("");
|
||||
setExportFilterOpen(true);
|
||||
}}
|
||||
disabled={exporting}
|
||||
className={cn("crm-btn-sm crm-btn-secondary flex items-center gap-2 disabled:cursor-not-allowed disabled:opacity-60", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
|
||||
>
|
||||
|
|
@ -1803,6 +2193,22 @@ export default function Expansion() {
|
|||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{exportFilterOpen && (
|
||||
<ExpansionExportFilterModal
|
||||
activeTab={activeTab}
|
||||
initialFilters={exportFilters}
|
||||
exporting={exporting}
|
||||
exportError={exportError}
|
||||
officeOptions={officeOptions}
|
||||
industryOptions={industryOptions}
|
||||
provinceOptions={provinceOptions}
|
||||
certificationLevelOptions={certificationLevelOptions}
|
||||
channelAttributeOptions={channelAttributeOptions}
|
||||
onClose={() => setExportFilterOpen(false)}
|
||||
onConfirm={(filters) => void handleExport(filters)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{createOpen && (
|
||||
<ModalShell
|
||||
title={`新增${activeTab === "sales" ? "销售人员拓展" : "渠道拓展"}`}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,18 @@ const COMPETITOR_OPTIONS = [
|
|||
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number];
|
||||
type OperatorMode = "none" | "h3c" | "channel" | "both";
|
||||
type OpportunityArchiveTab = "active" | "archived";
|
||||
type OpportunityExportFilters = {
|
||||
keyword?: string;
|
||||
expectedStartDate?: string;
|
||||
expectedEndDate?: string;
|
||||
stage?: string;
|
||||
confidence?: string;
|
||||
projectLocation?: string;
|
||||
opportunityType?: string;
|
||||
operatorName?: string;
|
||||
hasSalesExpansion?: string;
|
||||
hasChannelExpansion?: string;
|
||||
};
|
||||
type OpportunityField =
|
||||
| "projectLocation"
|
||||
| "opportunityName"
|
||||
|
|
@ -106,6 +118,94 @@ function formatOpportunityExportFilenameTime(date = new Date()) {
|
|||
return `${year}${month}${day}_${hours}${minutes}${seconds}`;
|
||||
}
|
||||
|
||||
function normalizeOpportunityExportFilterText(value?: string | number | boolean | null) {
|
||||
return normalizeOpportunityExportText(value).toLowerCase();
|
||||
}
|
||||
|
||||
function matchesOpportunityExportKeyword(value: string, keyword?: string) {
|
||||
const normalizedKeyword = normalizeOpportunityExportFilterText(keyword);
|
||||
return !normalizedKeyword || value.toLowerCase().includes(normalizedKeyword);
|
||||
}
|
||||
|
||||
function matchesOpportunityTextFilter(values: Array<string | number | boolean | null | undefined>, filterValue?: string) {
|
||||
const normalizedFilter = normalizeOpportunityExportFilterText(filterValue);
|
||||
if (!normalizedFilter) {
|
||||
return true;
|
||||
}
|
||||
return values.some((value) => normalizeOpportunityExportFilterText(value).includes(normalizedFilter));
|
||||
}
|
||||
|
||||
function matchesOpportunityDateRange(value?: string, startDate?: string, endDate?: string) {
|
||||
const normalizedValue = normalizeOpportunityExportText(value).slice(0, 10);
|
||||
if (!normalizedValue) {
|
||||
return !startDate && !endDate;
|
||||
}
|
||||
if (startDate && normalizedValue < startDate) {
|
||||
return false;
|
||||
}
|
||||
if (endDate && normalizedValue > endDate) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function matchesOpportunityRelationFilter(hasRelation: boolean, filterValue?: string) {
|
||||
if (filterValue === "yes") {
|
||||
return hasRelation;
|
||||
}
|
||||
if (filterValue === "no") {
|
||||
return !hasRelation;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function matchesOpportunityExportFilters(item: OpportunityItem, filters: OpportunityExportFilters) {
|
||||
const keywordText = [
|
||||
item.code,
|
||||
item.name,
|
||||
item.client,
|
||||
item.owner,
|
||||
item.projectLocation,
|
||||
item.operatorName,
|
||||
item.stage,
|
||||
item.type,
|
||||
item.salesExpansionName,
|
||||
item.channelExpansionName,
|
||||
item.preSalesName,
|
||||
item.competitorName,
|
||||
item.latestProgress,
|
||||
item.nextPlan,
|
||||
item.notes,
|
||||
item.followUps?.map((followUp) => `${followUp.content ?? ""} ${followUp.latestProgress ?? ""} ${followUp.nextAction ?? ""}`).join(" "),
|
||||
].map(normalizeOpportunityExportText).filter(Boolean).join(" ");
|
||||
|
||||
if (!matchesOpportunityExportKeyword(keywordText, filters.keyword)) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesOpportunityDateRange(item.date, filters.expectedStartDate, filters.expectedEndDate)) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesOpportunityTextFilter([item.stageCode, item.stage], filters.stage)) {
|
||||
return false;
|
||||
}
|
||||
if (filters.confidence && normalizeConfidenceGrade(item.confidence) !== filters.confidence) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesOpportunityTextFilter([item.projectLocation], filters.projectLocation)) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesOpportunityTextFilter([item.type], filters.opportunityType)) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesOpportunityTextFilter([item.operatorCode, item.operatorName], filters.operatorName)) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesOpportunityRelationFilter(Boolean(item.salesExpansionId || item.salesExpansionName), filters.hasSalesExpansion)) {
|
||||
return false;
|
||||
}
|
||||
return matchesOpportunityRelationFilter(Boolean(item.channelExpansionId || item.channelExpansionName), filters.hasChannelExpansion);
|
||||
}
|
||||
|
||||
function toFormFromItem(item: OpportunityItem): CreateOpportunityPayload {
|
||||
return {
|
||||
opportunityName: item.name || "",
|
||||
|
|
@ -447,6 +547,211 @@ function DetailItem({
|
|||
);
|
||||
}
|
||||
|
||||
function OpportunityExportFilterModal({
|
||||
initialFilters,
|
||||
exporting,
|
||||
exportError,
|
||||
archiveTab,
|
||||
stageOptions,
|
||||
projectLocationOptions,
|
||||
opportunityTypeOptions,
|
||||
operatorOptions,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
initialFilters: OpportunityExportFilters;
|
||||
exporting: boolean;
|
||||
exportError: string;
|
||||
archiveTab: OpportunityArchiveTab;
|
||||
stageOptions: OpportunityDictOption[];
|
||||
projectLocationOptions: OpportunityDictOption[];
|
||||
opportunityTypeOptions: OpportunityDictOption[];
|
||||
operatorOptions: OpportunityDictOption[];
|
||||
onClose: () => void;
|
||||
onConfirm: (filters: OpportunityExportFilters) => void;
|
||||
}) {
|
||||
const [draftFilters, setDraftFilters] = useState<OpportunityExportFilters>(initialFilters);
|
||||
const hasDraftFilters = Boolean(
|
||||
draftFilters.keyword
|
||||
|| draftFilters.expectedStartDate
|
||||
|| draftFilters.expectedEndDate
|
||||
|| draftFilters.stage
|
||||
|| draftFilters.confidence
|
||||
|| draftFilters.projectLocation
|
||||
|| draftFilters.opportunityType
|
||||
|| draftFilters.operatorName
|
||||
|| draftFilters.hasSalesExpansion
|
||||
|| draftFilters.hasChannelExpansion,
|
||||
);
|
||||
const handleFilterChange = (key: keyof OpportunityExportFilters, value: string) => {
|
||||
setDraftFilters((current) => ({ ...current, [key]: value }));
|
||||
};
|
||||
const renderOption = (option: OpportunityDictOption) => {
|
||||
const value = option.value || option.label || "";
|
||||
const label = option.label || option.value || "";
|
||||
return value ? <option key={value} value={value}>{label}</option> : null;
|
||||
};
|
||||
const toSearchableOptions = (options: OpportunityDictOption[], allLabel: string) => [
|
||||
{ value: "", label: allLabel },
|
||||
...options
|
||||
.map((option) => {
|
||||
const value = option.value || option.label || "";
|
||||
const label = option.label || option.value || "";
|
||||
return value ? { value, label } : null;
|
||||
})
|
||||
.filter((option): option is { value: string; label: string } => Boolean(option)),
|
||||
];
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
title={`导出${archiveTab === "active" ? "未归档" : "已归档"}商机`}
|
||||
subtitle="选择条件后导出 Excel;不填条件则导出当前归档页签下的全部可见商机。"
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftFilters({})}
|
||||
disabled={!hasDraftFilters}
|
||||
className="crm-btn crm-btn-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
清空条件
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm(draftFilters)}
|
||||
disabled={exporting}
|
||||
className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{exporting ? "导出中..." : "确认导出"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className="space-y-1.5 sm:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">关键词</span>
|
||||
<input
|
||||
value={draftFilters.keyword ?? ""}
|
||||
onChange={(event) => handleFilterChange("keyword", event.target.value)}
|
||||
placeholder="搜索项目编号、项目名称、最终用户、负责人、售前"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">预计下单开始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={draftFilters.expectedStartDate ?? ""}
|
||||
onChange={(event) => handleFilterChange("expectedStartDate", event.target.value)}
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">预计下单结束日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={draftFilters.expectedEndDate ?? ""}
|
||||
onChange={(event) => handleFilterChange("expectedEndDate", event.target.value)}
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目阶段</span>
|
||||
<AdaptiveSelect
|
||||
value={draftFilters.stage ?? ""}
|
||||
options={toSearchableOptions(stageOptions, "全部阶段")}
|
||||
placeholder="全部阶段"
|
||||
sheetTitle="选择项目阶段"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
onChange={(value) => handleFilterChange("stage", value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目把握度</span>
|
||||
<AdaptiveSelect
|
||||
value={draftFilters.confidence ?? ""}
|
||||
options={[
|
||||
{ value: "", label: "全部把握度" },
|
||||
...CONFIDENCE_OPTIONS.map((option) => ({ value: option.value, label: option.label })),
|
||||
]}
|
||||
placeholder="全部把握度"
|
||||
sheetTitle="选择项目把握度"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
onChange={(value) => handleFilterChange("confidence", value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目地</span>
|
||||
<AdaptiveSelect
|
||||
value={draftFilters.projectLocation ?? ""}
|
||||
options={toSearchableOptions(projectLocationOptions, "全部项目地")}
|
||||
placeholder="全部项目地"
|
||||
sheetTitle="选择项目地"
|
||||
searchable
|
||||
searchPlaceholder="搜索项目地"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
onChange={(value) => handleFilterChange("projectLocation", value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">建设类型</span>
|
||||
<AdaptiveSelect
|
||||
value={draftFilters.opportunityType ?? ""}
|
||||
options={toSearchableOptions(opportunityTypeOptions, "全部建设类型")}
|
||||
placeholder="全部建设类型"
|
||||
sheetTitle="选择建设类型"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
onChange={(value) => handleFilterChange("opportunityType", value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5 sm:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">运作方</span>
|
||||
<AdaptiveSelect
|
||||
value={draftFilters.operatorName ?? ""}
|
||||
options={toSearchableOptions(operatorOptions, "全部运作方")}
|
||||
placeholder="全部运作方"
|
||||
sheetTitle="选择运作方"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
onChange={(value) => handleFilterChange("operatorName", value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">新华三负责人</span>
|
||||
<AdaptiveSelect
|
||||
value={draftFilters.hasSalesExpansion ?? ""}
|
||||
options={[
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "yes", label: "已关联" },
|
||||
{ value: "no", label: "未关联" },
|
||||
]}
|
||||
placeholder="全部"
|
||||
sheetTitle="选择新华三负责人关联状态"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
onChange={(value) => handleFilterChange("hasSalesExpansion", value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">拓展渠道</span>
|
||||
<AdaptiveSelect
|
||||
value={draftFilters.hasChannelExpansion ?? ""}
|
||||
options={[
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "yes", label: "已关联" },
|
||||
{ value: "no", label: "未关联" },
|
||||
]}
|
||||
placeholder="全部"
|
||||
sheetTitle="选择拓展渠道关联状态"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
onChange={(value) => handleFilterChange("hasChannelExpansion", value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{exportError ? <div className="crm-alert crm-alert-error mt-4">{exportError}</div> : null}
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
type SearchableOption = {
|
||||
value: number | string;
|
||||
label: string;
|
||||
|
|
@ -998,6 +1303,8 @@ export default function Opportunities() {
|
|||
const [submitting, setSubmitting] = useState(false);
|
||||
const [pushingOms, setPushingOms] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportFilterOpen, setExportFilterOpen] = useState(false);
|
||||
const [exportFilters, setExportFilters] = useState<OpportunityExportFilters>({});
|
||||
const [error, setError] = useState("");
|
||||
const [exportError, setExportError] = useState("");
|
||||
const [items, setItems] = useState<OpportunityItem[]>([]);
|
||||
|
|
@ -1016,7 +1323,7 @@ export default function Opportunities() {
|
|||
const [customCompetitorName, setCustomCompetitorName] = useState("");
|
||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<OpportunityField, string>>>({});
|
||||
const [detailTab, setDetailTab] = useState<"sales" | "channel" | "followups">("sales");
|
||||
const hasForegroundModal = createOpen || editOpen || pushConfirmOpen;
|
||||
const hasForegroundModal = createOpen || editOpen || pushConfirmOpen || exportFilterOpen;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -1209,20 +1516,24 @@ export default function Opportunities() {
|
|||
return "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20";
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
const handleExport = async (filters: OpportunityExportFilters) => {
|
||||
if (exporting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (visibleItems.length <= 0) {
|
||||
setExportError(`当前${archiveTab === "active" ? "未归档" : "已归档"}商机暂无可导出数据`);
|
||||
return;
|
||||
}
|
||||
|
||||
setExporting(true);
|
||||
setExportError("");
|
||||
setExportFilters(filters);
|
||||
|
||||
try {
|
||||
const overview = await getOpportunityOverview("", "全部");
|
||||
const exportItems = (overview.items ?? [])
|
||||
.filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived)))
|
||||
.filter((item) => matchesOpportunityExportFilters(item, filters));
|
||||
if (exportItems.length <= 0) {
|
||||
throw new Error(`当前筛选条件下暂无可导出的${archiveTab === "active" ? "未归档" : "已归档"}商机`);
|
||||
}
|
||||
|
||||
const ExcelJS = await import("exceljs");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet("商机储备");
|
||||
|
|
@ -1255,7 +1566,7 @@ export default function Opportunities() {
|
|||
|
||||
worksheet.addRow(headers);
|
||||
|
||||
visibleItems.forEach((item) => {
|
||||
exportItems.forEach((item) => {
|
||||
const relatedSales = item.salesExpansionId
|
||||
? salesExpansionOptions.find((option) => option.id === item.salesExpansionId) ?? null
|
||||
: null;
|
||||
|
|
@ -1345,6 +1656,7 @@ export default function Opportunities() {
|
|||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
const filename = `商机储备_${archiveTab === "active" ? "未归档" : "已归档"}_${formatOpportunityExportFilenameTime()}.xlsx`;
|
||||
downloadOpportunityExcelFile(filename, buffer);
|
||||
setExportFilterOpen(false);
|
||||
} catch (exportErr) {
|
||||
setExportError(exportErr instanceof Error ? exportErr.message : "导出失败,请稍后重试");
|
||||
} finally {
|
||||
|
|
@ -1571,7 +1883,10 @@ export default function Opportunities() {
|
|||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => void handleExport()}
|
||||
onClick={() => {
|
||||
setExportError("");
|
||||
setExportFilterOpen(true);
|
||||
}}
|
||||
disabled={exporting}
|
||||
className={cn("crm-btn-sm crm-btn-secondary flex items-center gap-2 disabled:cursor-not-allowed disabled:opacity-60", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
|
||||
>
|
||||
|
|
@ -1735,6 +2050,21 @@ export default function Opportunities() {
|
|||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{exportFilterOpen ? (
|
||||
<OpportunityExportFilterModal
|
||||
initialFilters={exportFilters}
|
||||
exporting={exporting}
|
||||
exportError={exportError}
|
||||
archiveTab={archiveTab}
|
||||
stageOptions={stageOptions}
|
||||
projectLocationOptions={projectLocationOptions}
|
||||
opportunityTypeOptions={opportunityTypeOptions}
|
||||
operatorOptions={operatorOptions}
|
||||
onClose={() => setExportFilterOpen(false)}
|
||||
onConfirm={(filters) => void handleExport(filters)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{stageFilterOpen ? (
|
||||
<>
|
||||
<motion.div
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty
|
|||
import { format } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowUp, AtSign, Camera, CheckCircle2, ChevronDown, FileText, ListTodo, MapPin, NotebookPen, Plus, RefreshCw, Search, Send, Trash2, X } from "lucide-react";
|
||||
import { ArrowUp, AtSign, Camera, CheckCircle2, ChevronDown, Download, FileText, ListTodo, MapPin, NotebookPen, Plus, RefreshCw, Search, Send, Trash2, X } from "lucide-react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { Link, Navigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
|
|
@ -10,6 +10,8 @@ import {
|
|||
getExpansionOverview,
|
||||
getOpportunityOverview,
|
||||
getProfileOverview,
|
||||
getWorkCheckInExportData,
|
||||
getWorkDailyReportExportData,
|
||||
refreshCurrentUser,
|
||||
getWorkHistory,
|
||||
getWorkOverview,
|
||||
|
|
@ -25,9 +27,13 @@ import {
|
|||
type SalesExpansionItem,
|
||||
type UserProfile,
|
||||
type WorkHistoryItem,
|
||||
type WorkCheckInExportRow,
|
||||
type WorkDailyReportExportRow,
|
||||
type WorkExportQuery,
|
||||
type WorkReportLineItem,
|
||||
type WorkTomorrowPlanItem,
|
||||
} from "@/lib/auth";
|
||||
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
||||
import { ProtectedImage } from "@/components/ProtectedImage";
|
||||
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
|
||||
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||
|
|
@ -124,6 +130,112 @@ function createEmptyPlanItem(): WorkTomorrowPlanItem {
|
|||
return { content: "" };
|
||||
}
|
||||
|
||||
async function exportCheckInRowsToExcel(rows: WorkCheckInExportRow[]) {
|
||||
const ExcelJS = await import("exceljs");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet("打卡记录");
|
||||
const headers = [
|
||||
"序号",
|
||||
"打卡日期",
|
||||
"打卡时间",
|
||||
"打卡人",
|
||||
"所属部门",
|
||||
"关联类型",
|
||||
"关联对象",
|
||||
"打卡地点",
|
||||
"现场照片",
|
||||
"备注",
|
||||
"打卡状态",
|
||||
];
|
||||
|
||||
worksheet.addRow(headers);
|
||||
rows.forEach((row, index) => {
|
||||
worksheet.addRow([
|
||||
index + 1,
|
||||
normalizeWorkExportText(row.checkinDate),
|
||||
normalizeWorkExportText(row.checkinTime),
|
||||
normalizeWorkExportText(row.userName),
|
||||
normalizeWorkExportText(row.deptName),
|
||||
formatWorkBizType(row.bizType),
|
||||
normalizeWorkExportText(row.bizName),
|
||||
normalizeWorkExportText(row.locationText),
|
||||
row.photoUrls?.map(normalizeWorkExportText).filter(Boolean).join("\n") || "",
|
||||
normalizeWorkExportText(row.remark),
|
||||
formatCheckInStatus(row.status),
|
||||
]);
|
||||
});
|
||||
styleWorkExportWorksheet(worksheet, headers, ["现场照片", "备注", "打卡地点"]);
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
await downloadWorkExcelFile(`打卡记录_${formatWorkExportFilenameTime()}.xlsx`, buffer);
|
||||
}
|
||||
|
||||
async function exportDailyReportRowsToExcel(rows: WorkDailyReportExportRow[]) {
|
||||
const ExcelJS = await import("exceljs");
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const summaryWorksheet = workbook.addWorksheet("日报汇总");
|
||||
const detailWorksheet = workbook.addWorksheet("日报明细");
|
||||
const summaryHeaders = [
|
||||
"序号",
|
||||
"日报日期",
|
||||
"提交时间",
|
||||
"提交人",
|
||||
"所属部门",
|
||||
"今日工作内容",
|
||||
"明日工作计划",
|
||||
"今日工作条数",
|
||||
"明日计划条数",
|
||||
];
|
||||
const detailHeaders = [
|
||||
"序号",
|
||||
"日报日期",
|
||||
"提交时间",
|
||||
"提交人",
|
||||
"明细序号",
|
||||
"工作日期",
|
||||
"跟进对象类型",
|
||||
"跟进对象名称",
|
||||
"工作内容/项目进展",
|
||||
"后续规划",
|
||||
];
|
||||
|
||||
summaryWorksheet.addRow(summaryHeaders);
|
||||
detailWorksheet.addRow(detailHeaders);
|
||||
rows.forEach((row, index) => {
|
||||
summaryWorksheet.addRow([
|
||||
index + 1,
|
||||
normalizeWorkExportText(row.reportDate),
|
||||
normalizeWorkExportText(row.submitTime),
|
||||
normalizeWorkExportText(row.userName),
|
||||
normalizeWorkExportText(row.deptName),
|
||||
normalizeWorkExportText(row.workContent),
|
||||
normalizeWorkExportText(row.tomorrowPlan),
|
||||
row.lineItems?.length ?? 0,
|
||||
row.planItems?.length ?? 0,
|
||||
]);
|
||||
|
||||
const lineItems = row.lineItems?.length ? row.lineItems : [undefined];
|
||||
lineItems.forEach((lineItem, lineIndex) => {
|
||||
detailWorksheet.addRow([
|
||||
detailWorksheet.rowCount,
|
||||
normalizeWorkExportText(row.reportDate),
|
||||
normalizeWorkExportText(row.submitTime),
|
||||
normalizeWorkExportText(row.userName),
|
||||
lineIndex + 1,
|
||||
normalizeWorkExportText(lineItem?.workDate),
|
||||
formatWorkBizType(lineItem?.bizType),
|
||||
normalizeWorkExportText(lineItem?.bizName),
|
||||
normalizeWorkExportDetailContent(lineItem),
|
||||
normalizeWorkExportText(lineItem?.nextPlan || extractContentByLabel(lineItem?.content, "后续规划")),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
styleWorkExportWorksheet(summaryWorksheet, summaryHeaders, ["今日工作内容", "明日工作计划"]);
|
||||
styleWorkExportWorksheet(detailWorksheet, detailHeaders, ["工作内容/项目进展", "后续规划"]);
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
await downloadWorkExcelFile(`销售日报_${formatWorkExportFilenameTime()}.xlsx`, buffer);
|
||||
}
|
||||
|
||||
function getReportStatus(status?: string) {
|
||||
if (!status) {
|
||||
return "待提交";
|
||||
|
|
@ -131,6 +243,155 @@ function getReportStatus(status?: string) {
|
|||
return status === "reviewed" || status === "已点评" ? "已点评" : "已提交";
|
||||
}
|
||||
|
||||
function normalizeWorkExportText(value?: string | number | null) {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
const normalized = String(value).replace(/\r\n/g, "\n").trim();
|
||||
return !normalized || normalized === "无" ? "" : normalized;
|
||||
}
|
||||
|
||||
function normalizeWorkExportDetailContent(lineItem?: WorkReportLineItem) {
|
||||
return normalizeWorkExportText(
|
||||
lineItem?.latestProgress
|
||||
|| extractContentByLabel(lineItem?.content, "项目最新进展")
|
||||
|| lineItem?.evaluationContent
|
||||
|| extractContentByLabel(lineItem?.content, "沟通内容")
|
||||
|| lineItem?.editorText
|
||||
|| lineItem?.content,
|
||||
);
|
||||
}
|
||||
|
||||
function formatWorkBizType(value?: string) {
|
||||
if (value === "sales") {
|
||||
return "销售人员拓展";
|
||||
}
|
||||
if (value === "channel") {
|
||||
return "渠道拓展";
|
||||
}
|
||||
if (value === "opportunity") {
|
||||
return "商机";
|
||||
}
|
||||
return normalizeWorkExportText(value);
|
||||
}
|
||||
|
||||
function formatCheckInStatus(value?: string) {
|
||||
if (value === "normal") {
|
||||
return "正常";
|
||||
}
|
||||
if (value === "abnormal") {
|
||||
return "异常";
|
||||
}
|
||||
if (value === "reissue") {
|
||||
return "补卡";
|
||||
}
|
||||
if (value === "updated") {
|
||||
return "已更新";
|
||||
}
|
||||
return normalizeWorkExportText(value);
|
||||
}
|
||||
|
||||
function formatWorkExportFilenameTime(date = new Date()) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${year}${month}${day}_${hours}${minutes}`;
|
||||
}
|
||||
|
||||
async function downloadWorkExcelFile(filename: string, content: BlobPart) {
|
||||
const blob = new Blob([content], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
|
||||
if (shouldUseDataUrlDownloadFallback()) {
|
||||
const dataUrl = await readBlobAsDataUrl(blob);
|
||||
triggerWorkExcelDownload(filename, dataUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
triggerWorkExcelDownload(filename, objectUrl);
|
||||
window.URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
function triggerWorkExcelDownload(filename: string, url: string) {
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function shouldUseDataUrlDownloadFallback() {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
const hostname = window.location.hostname;
|
||||
const localHosts = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
||||
return window.location.protocol === "http:" && !localHosts.has(hostname);
|
||||
}
|
||||
|
||||
function readBlobAsDataUrl(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error("导出文件生成失败,请稍后重试"));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error("导出文件生成失败,请稍后重试"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function styleWorkExportWorksheet(worksheet: any, headers: string[], wrapHeaders: string[]) {
|
||||
worksheet.views = [{ state: "frozen", ySplit: 1 }];
|
||||
worksheet.getRow(1).height = 24;
|
||||
worksheet.getRow(1).font = { bold: true };
|
||||
worksheet.getRow(1).alignment = { vertical: "middle", horizontal: "center" };
|
||||
|
||||
headers.forEach((header, index) => {
|
||||
const column = worksheet.getColumn(index + 1);
|
||||
if (wrapHeaders.includes(header)) {
|
||||
column.width = 32;
|
||||
} else if (header.includes("内容") || header.includes("计划") || header.includes("地点")) {
|
||||
column.width = 24;
|
||||
} else if (header.includes("时间") || header.includes("日期")) {
|
||||
column.width = 18;
|
||||
} else {
|
||||
column.width = 16;
|
||||
}
|
||||
});
|
||||
|
||||
worksheet.eachRow((row: any, rowNumber: number) => {
|
||||
row.eachCell((cell: any, columnNumber: number) => {
|
||||
cell.border = {
|
||||
top: { style: "thin", color: { argb: "FFE2E8F0" } },
|
||||
left: { style: "thin", color: { argb: "FFE2E8F0" } },
|
||||
bottom: { style: "thin", color: { argb: "FFE2E8F0" } },
|
||||
right: { style: "thin", color: { argb: "FFE2E8F0" } },
|
||||
};
|
||||
cell.alignment = {
|
||||
vertical: "top",
|
||||
horizontal: rowNumber === 1 ? "center" : "left",
|
||||
wrapText: wrapHeaders.includes(headers[columnNumber - 1]),
|
||||
};
|
||||
});
|
||||
if (rowNumber > 1) {
|
||||
const maxLines = headers.reduce((lineCount, header, index) => {
|
||||
if (!wrapHeaders.includes(header)) {
|
||||
return lineCount;
|
||||
}
|
||||
const text = normalizeWorkExportText(row.getCell(index + 1).value as string | number | null | undefined);
|
||||
return Math.max(lineCount, text ? text.split("\n").length : 1);
|
||||
}, 1);
|
||||
row.height = Math.max(22, maxLines * 16);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default function Work() {
|
||||
const routerLocation = useLocation();
|
||||
const isMobileViewport = useIsMobileViewport();
|
||||
|
|
@ -163,6 +424,10 @@ export default function Work() {
|
|||
const [historyLoadingMore, setHistoryLoadingMore] = useState(false);
|
||||
const [historyHasMore, setHistoryHasMore] = useState(false);
|
||||
const [historyPage, setHistoryPage] = useState(1);
|
||||
const [exportingHistory, setExportingHistory] = useState(false);
|
||||
const [historyExportError, setHistoryExportError] = useState("");
|
||||
const [historyExportFilters, setHistoryExportFilters] = useState<WorkExportQuery>({});
|
||||
const [historyExportModalOpen, setHistoryExportModalOpen] = useState(false);
|
||||
const [showHistoryBackToTop, setShowHistoryBackToTop] = useState(false);
|
||||
const [historyPresenterFilter, setHistoryPresenterFilter] = useState("all");
|
||||
const [historyPresenterKeyword, setHistoryPresenterKeyword] = useState("");
|
||||
|
|
@ -864,6 +1129,37 @@ export default function Work() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleHistoryExport = async (filters: WorkExportQuery) => {
|
||||
if (exportingHistory) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExportingHistory(true);
|
||||
setHistoryExportError("");
|
||||
setHistoryExportFilters(filters);
|
||||
|
||||
try {
|
||||
if (historySection === "checkin") {
|
||||
const rows = await getWorkCheckInExportData(filters);
|
||||
if (!rows.length) {
|
||||
throw new Error("当前筛选条件下暂无可导出数据");
|
||||
}
|
||||
await exportCheckInRowsToExcel(rows);
|
||||
} else {
|
||||
const rows = await getWorkDailyReportExportData(filters);
|
||||
if (!rows.length) {
|
||||
throw new Error("当前筛选条件下暂无可导出数据");
|
||||
}
|
||||
await exportDailyReportRowsToExcel(rows);
|
||||
}
|
||||
setHistoryExportModalOpen(false);
|
||||
} catch (error) {
|
||||
setHistoryExportError(error instanceof Error ? error.message : "导出失败,请稍后重试");
|
||||
} finally {
|
||||
setExportingHistory(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeWorkSection || !activeWorkMeta) {
|
||||
return <Navigate to="/work/checkin" replace />;
|
||||
}
|
||||
|
|
@ -954,9 +1250,31 @@ export default function Work() {
|
|||
: "block lg:col-span-12 xl:col-span-12",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<SectionTitle title={showEntryPanel ? "历史记录" : `${activeWorkMeta.label}历史记录`} accent="bg-slate-300 dark:bg-slate-700" compact />
|
||||
{showEntryPanel ? <MobilePanelToggle mobilePanel={mobilePanel} onChange={setMobilePanel} /> : null}
|
||||
<div className="mb-2 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<SectionTitle title={showEntryPanel ? "历史记录" : `${activeWorkMeta.label}历史记录`} accent="bg-slate-300 dark:bg-slate-700" compact />
|
||||
{!isMobileViewport && historyExportError ? (
|
||||
<p className="mt-1 text-xs text-rose-500 dark:text-rose-300">{historyExportError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{!isMobileViewport ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setHistoryExportError("");
|
||||
setHistoryExportModalOpen(true);
|
||||
}}
|
||||
disabled={exportingHistory}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm transition-colors hover:border-violet-200 hover:bg-violet-50 hover:text-violet-600 disabled:cursor-not-allowed disabled:border-slate-100 disabled:bg-slate-50 disabled:text-slate-300 dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-400 dark:hover:border-violet-500/40 dark:hover:bg-slate-800 dark:hover:text-violet-300 dark:disabled:border-slate-800 dark:disabled:bg-slate-900/50 dark:disabled:text-slate-700"
|
||||
aria-label={exportingHistory ? "导出中" : "导出 Excel"}
|
||||
title={exportingHistory ? "导出中" : "导出 Excel"}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{showEntryPanel ? <MobilePanelToggle mobilePanel={mobilePanel} onChange={setMobilePanel} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -970,16 +1288,9 @@ export default function Work() {
|
|||
|
||||
{!historyLoading || historyData.length > 0 ? (
|
||||
<HistoryToolbar
|
||||
section={historySection}
|
||||
presenterFilter={historyPresenterFilter}
|
||||
presenterOptions={historyPresenterOptions}
|
||||
filteredCount={filteredHistoryData.length}
|
||||
totalCount={historyData.length}
|
||||
onOpenPresenterPicker={() => setHistoryPresenterPickerOpen(true)}
|
||||
onPresenterFilterChange={(value) => {
|
||||
setHistoryPresenterFilter(value);
|
||||
setHistoryPresenterPickerOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
|
@ -1176,6 +1487,17 @@ export default function Work() {
|
|||
/>
|
||||
) : null}
|
||||
|
||||
{historyExportModalOpen && !isMobileViewport ? (
|
||||
<HistoryExportFilterModal
|
||||
section={historySection}
|
||||
initialFilters={historyExportFilters}
|
||||
exporting={exportingHistory}
|
||||
exportError={historyExportError}
|
||||
onClose={() => setHistoryExportModalOpen(false)}
|
||||
onConfirm={(filters) => void handleHistoryExport(filters)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{previewPhoto ? (
|
||||
<PhotoPreviewModal
|
||||
url={previewPhoto.url}
|
||||
|
|
@ -1599,21 +1921,13 @@ function WorkHistorySkeleton() {
|
|||
}
|
||||
|
||||
function HistoryToolbar({
|
||||
section,
|
||||
presenterFilter,
|
||||
presenterOptions,
|
||||
filteredCount,
|
||||
totalCount,
|
||||
onOpenPresenterPicker,
|
||||
onPresenterFilterChange,
|
||||
}: {
|
||||
section: WorkSection;
|
||||
presenterFilter: string;
|
||||
presenterOptions: Array<{ value: string; label: string; count: number }>;
|
||||
filteredCount: number;
|
||||
totalCount: number;
|
||||
onOpenPresenterPicker: () => void;
|
||||
onPresenterFilterChange: (value: string) => void;
|
||||
}) {
|
||||
const currentPresenterLabel = presenterFilter === "all"
|
||||
? "全部人员"
|
||||
|
|
@ -1646,6 +1960,144 @@ function HistoryToolbar({
|
|||
);
|
||||
}
|
||||
|
||||
function HistoryExportFilterModal({
|
||||
section,
|
||||
initialFilters,
|
||||
exporting,
|
||||
exportError,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
section: WorkSection;
|
||||
initialFilters: WorkExportQuery;
|
||||
exporting: boolean;
|
||||
exportError: string;
|
||||
onClose: () => void;
|
||||
onConfirm: (filters: WorkExportQuery) => void;
|
||||
}) {
|
||||
const [draftFilters, setDraftFilters] = useState<WorkExportQuery>({ ...initialFilters, deptName: undefined, status: undefined });
|
||||
const hasDraftFilters = Boolean(
|
||||
draftFilters.keyword
|
||||
|| draftFilters.startDate
|
||||
|| draftFilters.endDate
|
||||
|| draftFilters.bizType,
|
||||
);
|
||||
const handleFilterChange = (key: keyof WorkExportQuery, value: string) => {
|
||||
setDraftFilters((current) => ({
|
||||
...current,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[92]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70"
|
||||
aria-label="关闭导出条件"
|
||||
/>
|
||||
<div className="absolute left-1/2 top-1/2 flex max-h-[82vh] w-[min(560px,calc(100vw-32px))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="shrink-0 border-b border-slate-100 px-5 py-4 dark:border-slate-800">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="px-1">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">导出{getHistoryLabelBySection(section)}记录</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400">选择条件后导出 Excel;不填条件则导出全部可见记录。</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label className="space-y-1.5 sm:col-span-2">
|
||||
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">人员/对象/内容关键词</span>
|
||||
<input
|
||||
type="text"
|
||||
value={draftFilters.keyword ?? ""}
|
||||
onChange={(event) => handleFilterChange("keyword", event.target.value)}
|
||||
placeholder="输入人员、对象、地点或内容关键词"
|
||||
className="h-10 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition-colors placeholder:text-slate-400 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100 dark:placeholder:text-slate-500"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">开始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={draftFilters.startDate ?? ""}
|
||||
onChange={(event) => handleFilterChange("startDate", event.target.value)}
|
||||
className="h-10 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100"
|
||||
aria-label="导出开始日期"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">结束日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={draftFilters.endDate ?? ""}
|
||||
onChange={(event) => handleFilterChange("endDate", event.target.value)}
|
||||
className="h-10 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100"
|
||||
aria-label="导出结束日期"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5 sm:col-span-2">
|
||||
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">关联对象类型</span>
|
||||
<AdaptiveSelect
|
||||
value={draftFilters.bizType ?? ""}
|
||||
options={[
|
||||
{ value: "", label: "全部对象类型" },
|
||||
{ value: "sales", label: "销售人员拓展" },
|
||||
{ value: "channel", label: "渠道拓展" },
|
||||
{ value: "opportunity", label: "商机" },
|
||||
]}
|
||||
placeholder="全部对象类型"
|
||||
sheetTitle="选择关联对象类型"
|
||||
className="h-10 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100"
|
||||
onChange={(value) => handleFilterChange("bizType", value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{exportError ? <p className="mt-3 text-xs text-rose-500 dark:text-rose-300">{exportError}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-slate-100 px-5 py-4 dark:border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftFilters({})}
|
||||
disabled={!hasDraftFilters}
|
||||
className="h-10 rounded-xl border border-slate-200 bg-white px-3 text-sm font-medium text-slate-600 transition-colors hover:border-violet-200 hover:text-violet-600 disabled:cursor-not-allowed disabled:text-slate-300 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300 dark:hover:border-violet-500/40 dark:hover:text-violet-200 dark:disabled:text-slate-600"
|
||||
>
|
||||
清空条件
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm(draftFilters)}
|
||||
disabled={exporting}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl bg-violet-600 px-4 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:bg-violet-300 dark:bg-violet-500 dark:hover:bg-violet-400 dark:disabled:bg-violet-500/40"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{exporting ? "导出中..." : "确认导出"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryPresenterPickerModal({
|
||||
section,
|
||||
presenterFilter,
|
||||
|
|
|
|||
Loading…
Reference in New Issue