diff --git a/backend/build/test-uploads/work-report-attachments/17-441493a9535a4812a7f198ab3983eccd.zip b/backend/build/test-uploads/work-report-attachments/17-441493a9535a4812a7f198ab3983eccd.zip new file mode 100644 index 00000000..82090ee2 --- /dev/null +++ b/backend/build/test-uploads/work-report-attachments/17-441493a9535a4812a7f198ab3983eccd.zip @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/build/test-uploads/work-report-attachments/17-9751a655ab214580af3c834ccec6f1b3.zip b/backend/build/test-uploads/work-report-attachments/17-9751a655ab214580af3c834ccec6f1b3.zip new file mode 100644 index 00000000..82090ee2 --- /dev/null +++ b/backend/build/test-uploads/work-report-attachments/17-9751a655ab214580af3c834ccec6f1b3.zip @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/build/test-uploads/work-report-attachments/17-d66dd072ba514f9e97dde4f2b834202b.zip b/backend/build/test-uploads/work-report-attachments/17-d66dd072ba514f9e97dde4f2b834202b.zip new file mode 100644 index 00000000..82090ee2 --- /dev/null +++ b/backend/build/test-uploads/work-report-attachments/17-d66dd072ba514f9e97dde4f2b834202b.zip @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java b/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java index e57bc50d..c796b881 100644 --- a/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java +++ b/backend/src/main/java/com/unis/crm/UnisCrmBackendApplication.java @@ -3,6 +3,7 @@ package com.unis.crm; import com.unis.crm.config.WecomProperties; import com.unis.crm.config.InternalAuthProperties; import com.unis.crm.config.OmsProperties; +import com.unis.crm.config.WorkReportProperties; import java.util.TimeZone; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; @@ -12,7 +13,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication(scanBasePackages = "com.unis.crm") @MapperScan({"com.unis.crm.mapper", "com.unis.crm.llm.mapper"}) -@EnableConfigurationProperties({WecomProperties.class, OmsProperties.class, InternalAuthProperties.class}) +@EnableConfigurationProperties({WecomProperties.class, OmsProperties.class, InternalAuthProperties.class, WorkReportProperties.class}) @EnableScheduling public class UnisCrmBackendApplication { diff --git a/backend/src/main/java/com/unis/crm/config/WorkReportProperties.java b/backend/src/main/java/com/unis/crm/config/WorkReportProperties.java new file mode 100644 index 00000000..1f7d7f17 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/config/WorkReportProperties.java @@ -0,0 +1,97 @@ +package com.unis.crm.config; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "unisbase.app.work-report") +public class WorkReportProperties { + + private DailyReport dailyReport = new DailyReport(); + + public DailyReport getDailyReport() { + return dailyReport; + } + + public void setDailyReport(DailyReport dailyReport) { + this.dailyReport = dailyReport == null ? new DailyReport() : dailyReport; + } + + public static class DailyReport { + private boolean requiredByDefault = true; + private List requiredRoleCodes = new ArrayList<>(); + private List optionalRoleCodes = new ArrayList<>(); + private FeatureVisibility notifyUsers = new FeatureVisibility(); + private FeatureVisibility attachments = new FeatureVisibility(); + + public boolean isRequiredByDefault() { + return requiredByDefault; + } + + public void setRequiredByDefault(boolean requiredByDefault) { + this.requiredByDefault = requiredByDefault; + } + + public List getRequiredRoleCodes() { + return requiredRoleCodes; + } + + public void setRequiredRoleCodes(List requiredRoleCodes) { + this.requiredRoleCodes = requiredRoleCodes == null ? new ArrayList<>() : requiredRoleCodes; + } + + public List getOptionalRoleCodes() { + return optionalRoleCodes; + } + + public void setOptionalRoleCodes(List optionalRoleCodes) { + this.optionalRoleCodes = optionalRoleCodes == null ? new ArrayList<>() : optionalRoleCodes; + } + + public FeatureVisibility getNotifyUsers() { + return notifyUsers; + } + + public void setNotifyUsers(FeatureVisibility notifyUsers) { + this.notifyUsers = notifyUsers == null ? new FeatureVisibility() : notifyUsers; + } + + public FeatureVisibility getAttachments() { + return attachments; + } + + public void setAttachments(FeatureVisibility attachments) { + this.attachments = attachments == null ? new FeatureVisibility() : attachments; + } + } + + public static class FeatureVisibility { + private boolean visibleByDefault = true; + private List visibleRoleCodes = new ArrayList<>(); + private List hiddenRoleCodes = new ArrayList<>(); + + public boolean isVisibleByDefault() { + return visibleByDefault; + } + + public void setVisibleByDefault(boolean visibleByDefault) { + this.visibleByDefault = visibleByDefault; + } + + public List getVisibleRoleCodes() { + return visibleRoleCodes; + } + + public void setVisibleRoleCodes(List visibleRoleCodes) { + this.visibleRoleCodes = visibleRoleCodes == null ? new ArrayList<>() : visibleRoleCodes; + } + + public List getHiddenRoleCodes() { + return hiddenRoleCodes; + } + + public void setHiddenRoleCodes(List hiddenRoleCodes) { + this.hiddenRoleCodes = hiddenRoleCodes == null ? new ArrayList<>() : hiddenRoleCodes; + } + } +} diff --git a/backend/src/main/java/com/unis/crm/controller/WorkController.java b/backend/src/main/java/com/unis/crm/controller/WorkController.java index 97139507..4ee4c053 100644 --- a/backend/src/main/java/com/unis/crm/controller/WorkController.java +++ b/backend/src/main/java/com/unis/crm/controller/WorkController.java @@ -7,6 +7,7 @@ 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.WorkDailyReportRequirementDTO; import com.unis.crm.dto.work.WorkHistoryItemDTO; import com.unis.crm.dto.work.WorkHistoryPageDTO; import com.unis.crm.dto.work.WorkOverviewDTO; @@ -52,6 +53,11 @@ public class WorkController { return ApiResponse.success(workService.getOverview(CurrentUserUtils.requireCurrentUserId(userId))); } + @GetMapping("/daily-report-requirement") + public ApiResponse getDailyReportRequirement(@RequestHeader("X-User-Id") Long userId) { + return ApiResponse.success(workService.getDailyReportRequirement(CurrentUserUtils.requireCurrentUserId(userId))); + } + @GetMapping("/report-message-users") public ApiResponse> listReportMessageUsers( @RequestHeader("X-User-Id") Long userId, diff --git a/backend/src/main/java/com/unis/crm/dto/work/CreateWorkDailyReportRequest.java b/backend/src/main/java/com/unis/crm/dto/work/CreateWorkDailyReportRequest.java index a95dfb72..41acb040 100644 --- a/backend/src/main/java/com/unis/crm/dto/work/CreateWorkDailyReportRequest.java +++ b/backend/src/main/java/com/unis/crm/dto/work/CreateWorkDailyReportRequest.java @@ -1,7 +1,5 @@ package com.unis.crm.dto.work; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Size; import jakarta.validation.Valid; import java.util.ArrayList; @@ -9,11 +7,9 @@ import java.util.List; public class CreateWorkDailyReportRequest { - @NotBlank(message = "今日工作内容不能为空") @Size(max = 4000, message = "今日工作内容不能超过4000字符") private String workContent; - @NotEmpty(message = "请至少填写一条今日工作内容") @Valid private List lineItems = new ArrayList<>(); diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportRequirementDTO.java b/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportRequirementDTO.java new file mode 100644 index 00000000..5b257f23 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportRequirementDTO.java @@ -0,0 +1,41 @@ +package com.unis.crm.dto.work; + +public class WorkDailyReportRequirementDTO { + + private boolean objectSelectionRequired; + private boolean notifyUsersVisible; + private boolean attachmentsVisible; + + public WorkDailyReportRequirementDTO() { + } + + public WorkDailyReportRequirementDTO(boolean objectSelectionRequired, boolean notifyUsersVisible, boolean attachmentsVisible) { + this.objectSelectionRequired = objectSelectionRequired; + this.notifyUsersVisible = notifyUsersVisible; + this.attachmentsVisible = attachmentsVisible; + } + + public boolean isObjectSelectionRequired() { + return objectSelectionRequired; + } + + public void setObjectSelectionRequired(boolean objectSelectionRequired) { + this.objectSelectionRequired = objectSelectionRequired; + } + + public boolean isNotifyUsersVisible() { + return notifyUsersVisible; + } + + public void setNotifyUsersVisible(boolean notifyUsersVisible) { + this.notifyUsersVisible = notifyUsersVisible; + } + + public boolean isAttachmentsVisible() { + return attachmentsVisible; + } + + public void setAttachmentsVisible(boolean attachmentsVisible) { + this.attachmentsVisible = attachmentsVisible; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemRequest.java b/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemRequest.java index 50ce26dc..2a1d2237 100644 --- a/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemRequest.java +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemRequest.java @@ -1,6 +1,5 @@ package com.unis.crm.dto.work; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import jakarta.validation.Valid; import java.util.ArrayList; @@ -8,10 +7,8 @@ import java.util.List; public class WorkReportLineItemRequest { - @NotBlank(message = "工作日期不能为空") private String workDate; - @NotBlank(message = "跟进对象类型不能为空") private String bizType; private Long bizId; @@ -19,11 +16,9 @@ public class WorkReportLineItemRequest { @Size(max = 200, message = "对象名称不能超过200字符") private String bizName; - @NotBlank(message = "日报内容不能为空") @Size(max = 4000, message = "日报内容不能超过4000字符") private String editorText; - @NotBlank(message = "工作内容不能为空") @Size(max = 1000, message = "工作内容不能超过1000字符") private String content; private String visitStartTime; diff --git a/backend/src/main/java/com/unis/crm/service/WorkService.java b/backend/src/main/java/com/unis/crm/service/WorkService.java index 498f39ad..3434a004 100644 --- a/backend/src/main/java/com/unis/crm/service/WorkService.java +++ b/backend/src/main/java/com/unis/crm/service/WorkService.java @@ -7,6 +7,7 @@ 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.WorkOverviewDTO; +import com.unis.crm.dto.work.WorkDailyReportRequirementDTO; import com.unis.crm.dto.work.WorkReportAttachmentDTO; import com.unis.crm.dto.work.WorkReportToUserDTO; import java.math.BigDecimal; @@ -19,6 +20,8 @@ public interface WorkService { WorkOverviewDTO getOverview(Long userId); + WorkDailyReportRequirementDTO getDailyReportRequirement(Long userId); + List listReportMessageUsers(Long userId, String keyword); WorkHistoryPageDTO getHistory(Long userId, String type, int page, int size); diff --git a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java index a0ea7b04..e78fad97 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java @@ -3,12 +3,14 @@ package com.unis.crm.service.impl; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.fasterxml.jackson.core.type.TypeReference; import com.unis.crm.common.BusinessException; +import com.unis.crm.config.WorkReportProperties; 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.WorkDailyReportRequirementDTO; import com.unis.crm.dto.work.WorkHistoryItemDTO; import com.unis.crm.dto.work.WorkHistoryPageDTO; import com.unis.crm.dto.work.WorkReportLineItemDTO; @@ -87,7 +89,7 @@ public class WorkServiceImpl implements WorkService { private static final String OPPORTUNITY_STAGE_LABEL = "项目阶段"; private static final String OPPORTUNITY_STAGE_TYPE_CODE = "sj_xmjd"; private static final int EXPORT_LIMIT = 5000; - private static final long REPORT_ATTACHMENT_MAX_SIZE_BYTES = 20L * 1024 * 1024; + private static final long REPORT_ATTACHMENT_MAX_SIZE_BYTES = 500L * 1024 * 1024; 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"; @@ -104,6 +106,7 @@ public class WorkServiceImpl implements WorkService { private final ObjectMapper objectMapper; private final UnisBaseTenantProvider tenantProvider; private final ReportReminderService reportReminderService; + private final WorkReportProperties workReportProperties; private final Path checkInPhotoDirectory; private final Path reportAttachmentDirectory; private final String tencentMapKey; @@ -114,6 +117,7 @@ public class WorkServiceImpl implements WorkService { OpportunityMapper opportunityMapper, ProfileMapper profileMapper, ReportReminderService reportReminderService, + WorkReportProperties workReportProperties, ObjectMapper objectMapper, UnisBaseTenantProvider tenantProvider, @Value("${unisbase.app.upload-path}") String uploadPath, @@ -122,6 +126,7 @@ public class WorkServiceImpl implements WorkService { this.opportunityMapper = opportunityMapper; this.profileMapper = profileMapper; this.reportReminderService = reportReminderService; + this.workReportProperties = workReportProperties == null ? new WorkReportProperties() : workReportProperties; this.objectMapper = objectMapper; this.tenantProvider = tenantProvider; this.httpClient = HttpClient.newBuilder() @@ -146,9 +151,22 @@ public class WorkServiceImpl implements WorkService { @Override public List listReportMessageUsers(Long userId, String keyword) { requireUser(userId); + if (!isDailyReportNotifyUsersVisible(loadUserRoleCodes(userId))) { + throw new BusinessException("当前角色不可使用日报通知用户功能"); + } return workMapper.selectReportMessageUsers(resolveCurrentTenantId(), normalizeOptionalText(keyword), 50); } + @Override + public WorkDailyReportRequirementDTO getDailyReportRequirement(Long userId) { + requireUser(userId); + List roleCodes = loadUserRoleCodes(userId); + return new WorkDailyReportRequirementDTO( + isDailyReportObjectSelectionRequired(roleCodes), + isDailyReportNotifyUsersVisible(roleCodes), + isDailyReportAttachmentsVisible(roleCodes)); + } + @Override public WorkHistoryPageDTO getHistory(Long userId, String type, int page, int size) { requireUser(userId); @@ -272,9 +290,12 @@ public class WorkServiceImpl implements WorkService { @Transactional public Long saveDailyReport(Long userId, CreateWorkDailyReportRequest request) { requireUser(userId); - ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录"); - normalizeDailyReportRequest(request); - request.setTomorrowPlan(request.getTomorrowPlan().trim()); + List roleCodes = ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录"); + normalizeDailyReportRequest( + request, + isDailyReportObjectSelectionRequired(roleCodes), + isDailyReportNotifyUsersVisible(roleCodes), + isDailyReportAttachmentsVisible(roleCodes)); request.setSourceType(normalizeOptionalText(request.getSourceType())); if (request.getSourceType() == null) { request.setSourceType("manual"); @@ -405,12 +426,15 @@ public class WorkServiceImpl implements WorkService { @Override public WorkReportAttachmentDTO uploadReportAttachment(Long userId, MultipartFile file) { requireUser(userId); - ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录"); + List roleCodes = ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录"); + if (!isDailyReportAttachmentsVisible(roleCodes)) { + throw new BusinessException("当前角色不可使用日报附件功能"); + } if (file == null || file.isEmpty()) { throw new BusinessException("请先选择附件"); } if (file.getSize() > REPORT_ATTACHMENT_MAX_SIZE_BYTES) { - throw new BusinessException("单个附件不能超过20MB"); + throw new BusinessException("单个附件不能超过500MB"); } String originalName = normalizeAttachmentName(file.getOriginalFilename()); @@ -471,15 +495,77 @@ public class WorkServiceImpl implements WorkService { } } - private void ensureWorkWriteAllowed(Long userId, String deniedMessage) { - List roleCodes = profileMapper.selectUserRoleCodes(userId); + private List ensureWorkWriteAllowed(Long userId, String deniedMessage) { + List roleCodes = loadUserRoleCodes(userId); boolean onlySee = roleCodes != null && roleCodes.stream() - .filter(roleCode -> roleCode != null && !roleCode.isBlank()) - .map(roleCode -> roleCode.trim().toLowerCase()) .anyMatch(ONLY_SEE_ROLE_CODE::equals); if (onlySee) { throw new BusinessException(deniedMessage); } + return roleCodes; + } + + private List loadUserRoleCodes(Long userId) { + List roleCodes = profileMapper.selectUserRoleCodes(userId); + if (roleCodes == null || roleCodes.isEmpty()) { + return List.of(); + } + return roleCodes.stream() + .map(this::normalizeRoleCode) + .filter(roleCode -> roleCode != null) + .distinct() + .toList(); + } + + private boolean isDailyReportObjectSelectionRequired(List roleCodes) { + WorkReportProperties.DailyReport dailyReport = workReportProperties.getDailyReport(); + if (matchesConfiguredRole(roleCodes, dailyReport.getRequiredRoleCodes())) { + return true; + } + if (matchesConfiguredRole(roleCodes, dailyReport.getOptionalRoleCodes())) { + return false; + } + return dailyReport.isRequiredByDefault(); + } + + private boolean isDailyReportNotifyUsersVisible(List roleCodes) { + return isFeatureVisible(roleCodes, workReportProperties.getDailyReport().getNotifyUsers()); + } + + private boolean isDailyReportAttachmentsVisible(List roleCodes) { + return isFeatureVisible(roleCodes, workReportProperties.getDailyReport().getAttachments()); + } + + private boolean isFeatureVisible(List roleCodes, WorkReportProperties.FeatureVisibility visibility) { + WorkReportProperties.FeatureVisibility safeVisibility = visibility == null + ? new WorkReportProperties.FeatureVisibility() + : visibility; + if (matchesConfiguredRole(roleCodes, safeVisibility.getHiddenRoleCodes())) { + return false; + } + if (matchesConfiguredRole(roleCodes, safeVisibility.getVisibleRoleCodes())) { + return true; + } + return safeVisibility.isVisibleByDefault(); + } + + private boolean matchesConfiguredRole(List userRoleCodes, List configuredRoleCodes) { + if (userRoleCodes == null || userRoleCodes.isEmpty() || configuredRoleCodes == null || configuredRoleCodes.isEmpty()) { + return false; + } + List normalizedConfiguredRoles = configuredRoleCodes.stream() + .map(this::normalizeRoleCode) + .filter(roleCode -> roleCode != null) + .toList(); + if (normalizedConfiguredRoles.isEmpty()) { + return false; + } + return userRoleCodes.stream().anyMatch(normalizedConfiguredRoles::contains); + } + + private String normalizeRoleCode(String roleCode) { + String normalized = normalizeOptionalText(roleCode); + return normalized == null ? null : normalized.toLowerCase(); } private List loadHistoryItems(HistoryType historyType, int fetchLimit) { @@ -1056,9 +1142,14 @@ public class WorkServiceImpl implements WorkService { return new PlanItemMetadata(buffer.toString().trim(), planItems); } - private void normalizeDailyReportRequest(CreateWorkDailyReportRequest request) { + private void normalizeDailyReportRequest( + CreateWorkDailyReportRequest request, + boolean objectSelectionRequired, + boolean notifyUsersVisible, + boolean attachmentsVisible) { List normalizedItems = new ArrayList<>(); - for (WorkReportLineItemRequest item : request.getLineItems()) { + List lineItems = request.getLineItems() == null ? List.of() : request.getLineItems(); + for (WorkReportLineItemRequest item : lineItems) { if (item == null) { continue; } @@ -1073,18 +1164,26 @@ public class WorkServiceImpl implements WorkService { item.setStage(normalizeOptionalText(item.getStage())); item.setCommunicationTime(normalizeOptionalText(item.getCommunicationTime())); item.setCommunicationContent(normalizeOptionalText(item.getCommunicationContent())); - item.setAttachments(normalizeReportAttachments(item.getAttachments())); - item.setToUsers(normalizeReportToUsers(item.getToUsers())); + item.setAttachments(attachmentsVisible ? normalizeReportAttachments(item.getAttachments()) : List.of()); + item.setToUsers(notifyUsersVisible ? normalizeReportToUsers(item.getToUsers()) : List.of()); hydrateLineItemFromEditorText(item); - if (item.getWorkDate() == null || item.getContent() == null || item.getEditorText() == null) { + item.setContent(normalizeOptionalText(item.getContent())); + item.setEditorText(normalizeOptionalText(item.getEditorText())); + if (item.getWorkDate() == null || item.getContent() == null) { throw new BusinessException("请完整填写每一条今日工作内容"); } + if (objectSelectionRequired && !hasLinkedReportTarget(item)) { + throw new BusinessException("请使用 # 选择日报关联对象"); + } + if (item.getEditorText() == null) { + item.setEditorText(item.getContent()); + } normalizedItems.add(item); } if (normalizedItems.isEmpty()) { throw new BusinessException("请至少填写一条今日工作内容"); } - request.setAttachments(normalizeReportAttachments(request.getAttachments())); + request.setAttachments(attachmentsVisible ? normalizeReportAttachments(request.getAttachments()) : List.of()); List normalizedPlanItems = new ArrayList<>(); List planItems = request.getPlanItems() == null ? List.of() : request.getPlanItems(); for (WorkTomorrowPlanItemRequest item : planItems) { @@ -1099,9 +1198,8 @@ public class WorkServiceImpl implements WorkService { } request.setLineItems(normalizedItems); request.setPlanItems(normalizedPlanItems); - request.setWorkContent(appendReportAttachmentMetadata( - appendReportLineMetadata(buildReportPlainText(normalizedItems), normalizedItems), - request.getAttachments())); + String normalizedWorkContent = appendReportLineMetadata(buildReportPlainText(normalizedItems), normalizedItems); + request.setWorkContent(appendReportAttachmentMetadata(normalizedWorkContent, request.getAttachments())); request.setTomorrowPlan(normalizedPlanItems.isEmpty() ? "" : appendPlanItemMetadata(buildTomorrowPlanPlainText(normalizedPlanItems), normalizedPlanItems)); diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 04a895ab..af80c8b3 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -3,8 +3,8 @@ spring: name: unis-crm-backend servlet: multipart: - max-file-size: 20MB - max-request-size: 25MB + max-file-size: 500MB + max-request-size: 600MB datasource: url: jdbc:postgresql://192.168.124.202:5432/unis_crm username: postgres @@ -81,3 +81,25 @@ unisbase: pre-sales-role-name: ${OMS_PRE_SALES_ROLE_NAME:售前} connect-timeout-seconds: ${OMS_CONNECT_TIMEOUT_SECONDS:5} read-timeout-seconds: ${OMS_READ_TIMEOUT_SECONDS:15} + work-report: + daily-report: + # 未命中下方任何角色配置时,是否默认要求日报使用 # 选择并关联对象。 + required-by-default: false + # 明确要求使用 # 选择并关联对象的角色编码;命中后优先级高于 optional-role-codes。 + required-role-codes: [crm_xs] + # 允许不使用 #,可直接填写文字日报的角色编码。 + optional-role-codes: [] + notify-users: + # 未命中下方任何角色配置时,是否默认显示“选择通知用户”按钮。 + visible-by-default: true + # 明确显示“选择通知用户”按钮的角色编码。 + visible-role-codes: [] + # 明确隐藏“选择通知用户”按钮的角色编码;命中后优先级高于 visible-role-codes。 + hidden-role-codes: [] + attachments: + # 未命中下方任何角色配置时,是否默认显示日报附件功能。 + visible-by-default: true + # 明确显示日报附件功能的角色编码。 + visible-role-codes: [] + # 明确隐藏日报附件功能的角色编码;命中后优先级高于 visible-role-codes。 + hidden-role-codes: [crm_xs] diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index c911ad73..bc7f2817 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -8,8 +8,8 @@ spring: time-zone: Asia/Shanghai servlet: multipart: - max-file-size: 20MB - max-request-size: 25MB + max-file-size: 500MB + max-request-size: 600MB datasource: url: jdbc:postgresql://127.0.0.1:5432/unis_crm username: postgres @@ -90,3 +90,25 @@ unisbase: pre-sales-role-name: ${OMS_PRE_SALES_ROLE_NAME:售前} connect-timeout-seconds: ${OMS_CONNECT_TIMEOUT_SECONDS:5} read-timeout-seconds: ${OMS_READ_TIMEOUT_SECONDS:15} + work-report: + daily-report: + # 未命中下方任何角色配置时,是否默认要求日报使用 # 选择并关联对象。 + required-by-default: false + # 明确要求使用 # 选择并关联对象的角色编码;命中后优先级高于 optional-role-codes。 + required-role-codes: [crm_xs] + # 允许不使用 #,可直接填写文字日报的角色编码。 + optional-role-codes: [] + notify-users: + # 未命中下方任何角色配置时,是否默认显示“选择通知用户”按钮。 + visible-by-default: true + # 明确显示“选择通知用户”按钮的角色编码。 + visible-role-codes: [] + # 明确隐藏“选择通知用户”按钮的角色编码;命中后优先级高于 visible-role-codes。 + hidden-role-codes: [] + attachments: + # 未命中下方任何角色配置时,是否默认显示日报附件功能。 + visible-by-default: true + # 明确显示日报附件功能的角色编码。 + visible-role-codes: [] + # 明确隐藏日报附件功能的角色编码;命中后优先级高于 visible-role-codes。 + hidden-role-codes: [crm_xs] diff --git a/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java index 362aa487..8f2e79b4 100644 --- a/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java +++ b/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java @@ -8,6 +8,7 @@ import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.ObjectMapper; import com.unis.crm.common.BusinessException; +import com.unis.crm.config.WorkReportProperties; import com.unis.crm.dto.work.CreateWorkCheckInRequest; import com.unis.crm.dto.work.CreateWorkDailyReportRequest; import com.unis.crm.dto.work.WorkDailyReportDTO; @@ -57,9 +58,12 @@ class WorkServiceImplTest { private WorkServiceImpl workService; + private WorkReportProperties workReportProperties; + @BeforeEach void setUp() { - workService = new WorkServiceImpl(workMapper, opportunityMapper, profileMapper, reportReminderService, new ObjectMapper(), tenantProvider, "build/test-uploads", ""); + workReportProperties = new WorkReportProperties(); + workService = new WorkServiceImpl(workMapper, opportunityMapper, profileMapper, reportReminderService, workReportProperties, new ObjectMapper(), tenantProvider, "build/test-uploads", ""); } @Test @@ -150,8 +154,65 @@ class WorkServiceImplTest { org.mockito.ArgumentMatchers.any()); } + @Test + void listReportMessageUsers_shouldRejectHiddenRole() { + workReportProperties.getDailyReport().getNotifyUsers().setHiddenRoleCodes(List.of("sales")); + when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("sales")); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> workService.listReportMessageUsers(17L, "")); + + assertEquals("当前角色不可使用日报通知用户功能", exception.getMessage()); + verify(workMapper, never()).selectReportMessageUsers(any(), any(), eq(50)); + } + + @Test + void saveDailyReport_shouldRejectEmptyContentByDefault() { + when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("sales")); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> workService.saveDailyReport(17L, new CreateWorkDailyReportRequest())); + + assertEquals("请至少填写一条今日工作内容", exception.getMessage()); + verify(workMapper, never()).insertDailyReport( + org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + void saveDailyReport_shouldRejectManualTextWhenObjectSelectionRequiredByDefault() { + when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("sales")); + + CreateWorkDailyReportRequest request = new CreateWorkDailyReportRequest(); + WorkReportLineItemRequest lineItem = new WorkReportLineItemRequest(); + lineItem.setWorkDate("2026-04-28"); + lineItem.setBizType("sales"); + lineItem.setBizId(0L); + lineItem.setEditorText("整理客户资料,完成重点项目复盘"); + lineItem.setContent("整理客户资料,完成重点项目复盘"); + request.setLineItems(List.of(lineItem)); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> workService.saveDailyReport(17L, request)); + + assertEquals("请使用 # 选择日报关联对象", exception.getMessage()); + verify(workMapper, never()).insertDailyReport( + org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + @Test void saveDailyReport_shouldAllowManualTextWithoutLinkedObject() { + workReportProperties.getDailyReport().setOptionalRoleCodes(List.of("sales_assistant")); + when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("sales_assistant")); + CreateWorkDailyReportRequest request = new CreateWorkDailyReportRequest(); WorkReportLineItemRequest lineItem = new WorkReportLineItemRequest(); lineItem.setWorkDate("2026-04-28"); @@ -203,6 +264,59 @@ class WorkServiceImplTest { assertEquals("当前角色仅可查看打卡历史记录", exception.getMessage()); } + @Test + void uploadReportAttachment_shouldAllowLargeZipWithinLimit() { + when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("sales")); + + MockMultipartFile file = new MockMultipartFile( + "file", + "project-pack.zip", + "application/zip", + new byte[] {1, 2, 3, 4}); + + WorkReportAttachmentDTO attachment = workService.uploadReportAttachment(17L, file); + + assertEquals("project-pack.zip", attachment.getName()); + assertEquals("application/zip", attachment.getContentType()); + assertEquals(4L, attachment.getSize()); + } + + @Test + void uploadReportAttachment_shouldRejectHiddenRole() { + workReportProperties.getDailyReport().getAttachments().setHiddenRoleCodes(List.of("sales")); + when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("sales")); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> workService.uploadReportAttachment( + 17L, + new MockMultipartFile("file", "project-pack.zip", "application/zip", new byte[] {1, 2, 3}))); + + assertEquals("当前角色不可使用日报附件功能", exception.getMessage()); + } + + @Test + void uploadReportAttachment_shouldRejectOverLimitFiles() { + when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("sales")); + + MockMultipartFile file = new MockMultipartFile( + "file", + "project-pack.zip", + "application/zip", + new byte[] {1}) { + @Override + public long getSize() { + return 500L * 1024 * 1024 + 1; + } + }; + + BusinessException exception = assertThrows( + BusinessException.class, + () -> workService.uploadReportAttachment(17L, file)); + + assertEquals("单个附件不能超过500MB", exception.getMessage()); + } + @Test void exportCheckIns_shouldCleanPhotoMetadata() { WorkCheckInExportDTO row = new WorkCheckInExportDTO(); diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 68a41185..98a3aa3d 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -318,6 +318,10 @@ export interface WorkReportAttachment { size?: number; } +export interface UploadReportAttachmentOptions { + onProgress?: (progress: number) => void; +} + export interface WorkReportToUser { userId: number; name: string; @@ -368,6 +372,12 @@ export interface WorkOverview { history?: WorkHistoryItem[]; } +export interface WorkDailyReportRequirement { + objectSelectionRequired?: boolean; + notifyUsersVisible?: boolean; + attachmentsVisible?: boolean; +} + export interface WorkHistoryPage { items?: WorkHistoryItem[]; hasMore?: boolean; @@ -1311,6 +1321,10 @@ export async function getWorkOverview() { return request("/api/work/overview", undefined, true); } +export async function getWorkDailyReportRequirement() { + return request("/api/work/daily-report-requirement", undefined, true); +} + export async function listWorkReportMessageUsers(keyword?: string) { const params = new URLSearchParams(); const normalizedKeyword = keyword?.trim() || ""; @@ -1396,13 +1410,61 @@ export async function uploadWorkCheckInPhoto(file: File) { }, true); } -export async function uploadWorkReportAttachment(file: File) { - const formData = new FormData(); - formData.append("file", file); - return request("/api/work/report-attachments", { - method: "POST", - body: formData, - }, true); +export async function uploadWorkReportAttachment(file: File, options?: UploadReportAttachmentOptions) { + const token = localStorage.getItem("accessToken"); + const userId = getStoredUserId(); + const activeTenantId = localStorage.getItem("activeTenantId"); + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/api/work/report-attachments"); + if (token) { + xhr.setRequestHeader("Authorization", `Bearer ${token}`); + } + if (userId !== undefined) { + xhr.setRequestHeader("X-User-Id", String(userId)); + } + if (activeTenantId?.trim()) { + xhr.setRequestHeader("X-Tenant-Id", activeTenantId.trim()); + } + + xhr.upload.onprogress = (event) => { + if (!event.lengthComputable || !options?.onProgress) { + return; + } + options.onProgress(Math.min(100, Math.round((event.loaded / event.total) * 100))); + }; + + xhr.onerror = () => reject(new Error("附件上传失败,请稍后重试")); + xhr.onabort = () => reject(new Error("附件上传已取消")); + xhr.onload = () => { + const rawText = xhr.responseText || ""; + const body = tryParseApiBody(rawText, xhr.getResponseHeader("content-type")); + const errorMessage = buildApiErrorMessage(rawText, xhr.status, body); + + if (xhr.status === 401 || (xhr.status >= 300 && xhr.status < 400)) { + handleUnauthorizedResponse(); + reject(new Error("登录已失效,请重新登录")); + return; + } + + if (xhr.status < 200 || xhr.status >= 300) { + reject(new Error(errorMessage)); + return; + } + + if (!body || !isSuccessCode(body.code)) { + reject(new Error(errorMessage)); + return; + } + + resolve(body.data); + }; + + const formData = new FormData(); + formData.append("file", file); + xhr.send(formData); + }); } export async function saveWorkDailyReport(payload: CreateWorkDailyReportPayload) { diff --git a/frontend/src/pages/Work.tsx b/frontend/src/pages/Work.tsx index e5e0ec30..85d1c0f2 100644 --- a/frontend/src/pages/Work.tsx +++ b/frontend/src/pages/Work.tsx @@ -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, Camera, CheckCircle2, ChevronDown, Download, FileText, Hash, ListTodo, MapPin, NotebookPen, Paperclip, Plus, RefreshCw, Search, Send, Trash2, UserRoundPlus, X } from "lucide-react"; +import { ArrowUp, Camera, CheckCircle2, ChevronDown, Download, FileText, Hash, ListTodo, MapPin, NotebookPen, Paperclip, Plus, RefreshCw, RotateCcw, Search, Send, Trash2, UserRoundPlus, X } from "lucide-react"; import { flushSync } from "react-dom"; import { Link, Navigate, useLocation, useNavigate } from "react-router-dom"; import { @@ -20,6 +20,7 @@ import { getProfileOverview, getWorkCheckInExportData, getWorkDailyReportExportData, + getWorkDailyReportRequirement, getWorkReportHistoryItem, refreshCurrentUser, getWorkHistory, @@ -80,6 +81,7 @@ const LEGACY_NEXT_PLAN_LABEL = "后续规划"; const OPPORTUNITY_NEXT_PLAN_LABEL = "下一步销售计划"; const OPPORTUNITY_STAGE_LABEL = "项目阶段"; const WORK_DETAIL_NEXT_PLAN_HEADER = "后续规划 / 下一步销售计划"; +const REPORT_ATTACHMENT_MAX_SIZE_BYTES = 500 * 1024 * 1024; const reportFieldLabels = { sales: ["沟通内容", LEGACY_NEXT_PLAN_LABEL], @@ -797,6 +799,9 @@ export default function Work() { const [submittingReport, setSubmittingReport] = useState(false); const [uploadingPhoto, setUploadingPhoto] = useState(false); const [uploadingReportAttachmentIndex, setUploadingReportAttachmentIndex] = useState(null); + const [uploadingReportAttachmentProgress, setUploadingReportAttachmentProgress] = useState>({}); + const [reportAttachmentUploadErrors, setReportAttachmentUploadErrors] = useState>({}); + const reportAttachmentFileRef = useRef>({}); const [mobilePanel, setMobilePanel] = useState<"entry" | "history">("entry"); const [locationHint, setLocationHint] = useState(""); const [locationAdjustOpen, setLocationAdjustOpen] = useState(false); @@ -823,6 +828,10 @@ export default function Work() { const [historyPresenterPickerOpen, setHistoryPresenterPickerOpen] = useState(false); const [expandedHistoryGroupKey, setExpandedHistoryGroupKey] = useState(null); const [reportStatus, setReportStatus] = useState(); + const [dailyReportFeatureLoaded, setDailyReportFeatureLoaded] = useState(false); + const [dailyReportObjectSelectionRequired, setDailyReportObjectSelectionRequired] = useState(true); + const [dailyReportNotifyUsersVisible, setDailyReportNotifyUsersVisible] = useState(false); + const [dailyReportAttachmentsVisible, setDailyReportAttachmentsVisible] = useState(false); const [currentUser, setCurrentUser] = useState(() => readStoredUserProfile()); const [profileOverview, setProfileOverview] = useState(null); const [checkInPhotoUrls, setCheckInPhotoUrls] = useState([]); @@ -1196,19 +1205,31 @@ export default function Work() { async function loadUserContext() { try { - const [userData, overviewData] = await Promise.all([ + const [userData, overviewData, requirementData] = await Promise.all([ ((currentUser?.roleCodes?.length ?? 0) > 0 ? getCurrentUser() : refreshCurrentUser()).catch(() => null), getProfileOverview().catch(() => null), + getWorkDailyReportRequirement().catch(() => null), ]); if (cancelled) { return; } setCurrentUser(userData); setProfileOverview(overviewData); + if (requirementData && typeof requirementData.objectSelectionRequired === "boolean") { + setDailyReportObjectSelectionRequired(requirementData.objectSelectionRequired); + } + if (requirementData && typeof requirementData.notifyUsersVisible === "boolean") { + setDailyReportNotifyUsersVisible(requirementData.notifyUsersVisible); + } + if (requirementData && typeof requirementData.attachmentsVisible === "boolean") { + setDailyReportAttachmentsVisible(requirementData.attachmentsVisible); + } + setDailyReportFeatureLoaded(true); } catch { if (!cancelled) { setCurrentUser(null); setProfileOverview(null); + setDailyReportFeatureLoaded(true); } } } @@ -1925,12 +1946,31 @@ export default function Work() { return; } + if (file.size > REPORT_ATTACHMENT_MAX_SIZE_BYTES) { + const message = `单个附件不能超过${formatAttachmentSize(REPORT_ATTACHMENT_MAX_SIZE_BYTES)}`; + setReportError(message); + setReportAttachmentUploadErrors((current) => ({ ...current, [index]: message })); + event.target.value = ""; + return; + } + + reportAttachmentFileRef.current[index] = file; setReportError(""); setReportSuccess(""); setUploadingReportAttachmentIndex(index); + setReportAttachmentUploadErrors((current) => { + const next = { ...current }; + delete next[index]; + return next; + }); + setUploadingReportAttachmentProgress((current) => ({ ...current, [index]: 0 })); try { - const attachment = await uploadWorkReportAttachment(file); + const attachment = await uploadWorkReportAttachment(file, { + onProgress: (progress) => { + setUploadingReportAttachmentProgress((current) => ({ ...current, [index]: progress })); + }, + }); setReportForm((current) => ({ ...current, lineItems: current.lineItems.map((item, itemIndex) => { @@ -1947,10 +1987,18 @@ export default function Work() { }; }), })); + reportAttachmentFileRef.current[index] = null; } catch (error) { - setReportError(error instanceof Error ? error.message : "附件上传失败"); + const message = error instanceof Error ? error.message : "附件上传失败"; + setReportError(message); + setReportAttachmentUploadErrors((current) => ({ ...current, [index]: message })); } finally { setUploadingReportAttachmentIndex(null); + setUploadingReportAttachmentProgress((current) => { + const next = { ...current }; + delete next[index]; + return next; + }); event.target.value = ""; } }; @@ -1966,6 +2014,18 @@ export default function Work() { })); }; + const handleRetryReportAttachment = (index: number) => { + const file = reportAttachmentFileRef.current[index]; + if (!file) { + setReportError("请重新选择附件后再试"); + return; + } + const fakeEvent = { + target: { files: [file], value: "" }, + } as unknown as ChangeEvent; + void handleReportAttachmentChange(index, fakeEvent); + }; + const handlePlanItemChange = (index: number, value: string) => { setReportForm((current) => ({ ...current, @@ -2056,11 +2116,16 @@ export default function Work() { setSubmittingReport(true); try { + if (!dailyReportFeatureLoaded) { + throw new Error("日报功能配置加载中,请稍后重试"); + } if (isOnlySeeRole) { throw new Error("当前角色仅可查看日报历史记录"); } const normalizedLineItems = reportForm.lineItems.map((item) => ( normalizeReportLineItem(item, currentWorkDate, reportOpportunityStageOptions) + )).map((item) => ( + normalizeLineFeatureVisibility(item, dailyReportNotifyUsersVisible, dailyReportAttachmentsVisible) )); const normalizedPlanItems = reportForm.planItems .map((item) => ({ content: item.content.trim() })) @@ -2069,7 +2134,7 @@ export default function Work() { if (!normalizedLineItems.length) { throw new Error("请至少填写一条今日工作内容"); } - validateReportLineItems(normalizedLineItems, reportOpportunityStageOptions); + validateReportLineItems(normalizedLineItems, reportOpportunityStageOptions, dailyReportObjectSelectionRequired); await saveWorkDailyReport({ workContent: buildReportSummary(normalizedLineItems), @@ -2191,7 +2256,13 @@ export default function Work() { onReportLineChange={handleReportLineChange} onReportLineStageChange={handleReportLineStageChange} uploadingReportAttachmentIndex={uploadingReportAttachmentIndex} + uploadingReportAttachmentProgress={uploadingReportAttachmentProgress} + reportAttachmentUploadErrors={reportAttachmentUploadErrors} + dailyReportFeatureLoaded={dailyReportFeatureLoaded} + notifyUsersVisible={dailyReportNotifyUsersVisible} + attachmentsVisible={dailyReportAttachmentsVisible} onReportAttachmentChange={(index, event) => void handleReportAttachmentChange(index, event)} + onRetryReportAttachment={handleRetryReportAttachment} onRemoveReportAttachment={handleRemoveReportAttachment} onPreviewAttachment={setPreviewAttachment} onAddPlanItem={handleAddPlanItem} @@ -3734,18 +3805,52 @@ function ReportAttachmentList({ attachments, onPreview, onRemove, + uploadProgress, + uploadError, + onRetryUpload, }: { attachments?: WorkReportAttachment[]; onPreview: (attachment: WorkReportAttachment) => void; onRemove?: (attachment: WorkReportAttachment) => void; + uploadProgress?: number; + uploadError?: string; + onRetryUpload?: () => void; }) { - if (!attachments?.length) { + if (!attachments?.length && typeof uploadProgress !== "number" && !uploadError) { return null; } return (
- {attachments.map((attachment) => ( + {typeof uploadProgress === "number" ? ( +
+ + 上传中 {uploadProgress}% +
+
+
+
+ ) : null} + {uploadError ? ( +
+ {uploadError} + {onRetryUpload ? ( + + ) : null} +
+ ) : null} + {(attachments ?? []).map((attachment) => (
void; onReportLineStageChange: (index: number, value: string) => void; uploadingReportAttachmentIndex: number | null; + uploadingReportAttachmentProgress: Record; + reportAttachmentUploadErrors: Record; + dailyReportFeatureLoaded: boolean; + notifyUsersVisible: boolean; + attachmentsVisible: boolean; onReportAttachmentChange: (index: number, event: ChangeEvent) => void; + onRetryReportAttachment: (index: number) => void; onRemoveReportAttachment: (lineIndex: number, attachmentUrl: string) => void; onPreviewAttachment: (attachment: WorkReportAttachment) => void; onAddPlanItem: () => void; @@ -4215,6 +4332,8 @@ function ReportPanel({ : ""; const attachments = item.attachments ?? []; const isUploadingAttachment = uploadingReportAttachmentIndex === index; + const attachmentProgress = uploadingReportAttachmentProgress[index] ?? 0; + const attachmentUploadError = reportAttachmentUploadErrors[index]; return (
- onRemoveReportAttachment(index, attachment.url)} - /> + {attachmentsVisible ? ( + onRemoveReportAttachment(index, attachment.url)} + uploadProgress={isUploadingAttachment ? attachmentProgress : undefined} + uploadError={attachmentUploadError} + onRetryUpload={attachmentUploadError ? () => onRetryReportAttachment(index) : undefined} + /> + ) : null}
) : (
@@ -4341,22 +4465,29 @@ function ReportPanel({
- onRemoveReportAttachment(index, attachment.url)} - /> + {attachmentsVisible ? ( + onRemoveReportAttachment(index, attachment.url)} + uploadProgress={isUploadingAttachment ? attachmentProgress : undefined} + uploadError={attachmentUploadError} + onRetryUpload={attachmentUploadError ? () => onRetryReportAttachment(index) : undefined} + /> + ) : null}
)}
- { - reportAttachmentInputRefs.current[index] = element; - }} - type="file" - className="hidden" - onChange={(event) => onReportAttachmentChange(index, event)} - /> + {attachmentsVisible ? ( + { + reportAttachmentInputRefs.current[index] = element; + }} + type="file" + className="hidden" + onChange={(event) => onReportAttachmentChange(index, event)} + /> + ) : null} - - + {notifyUsersVisible ? ( + + ) : null} + {attachmentsVisible ? ( + + ) : null}