控制首页展示的权限添加
parent
7b1c424ba2
commit
04e7422438
|
|
@ -12,6 +12,7 @@ public class DashboardActivityDTO {
|
||||||
private String content;
|
private String content;
|
||||||
private Long operatorUserId;
|
private Long operatorUserId;
|
||||||
private String operatorName;
|
private String operatorName;
|
||||||
|
private String targetTab;
|
||||||
private OffsetDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
private String timeText;
|
private String timeText;
|
||||||
|
|
||||||
|
|
@ -79,6 +80,14 @@ public class DashboardActivityDTO {
|
||||||
this.operatorName = operatorName;
|
this.operatorName = operatorName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getTargetTab() {
|
||||||
|
return targetTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTargetTab(String targetTab) {
|
||||||
|
this.targetTab = targetTab;
|
||||||
|
}
|
||||||
|
|
||||||
public OffsetDateTime getCreatedAt() {
|
public OffsetDateTime getCreatedAt() {
|
||||||
return createdAt;
|
return createdAt;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ public class DashboardHomeDTO {
|
||||||
private String jobTitle;
|
private String jobTitle;
|
||||||
private String deptName;
|
private String deptName;
|
||||||
private Long onboardingDays;
|
private Long onboardingDays;
|
||||||
|
private Boolean todoCardVisible;
|
||||||
|
private Boolean activityCardVisible;
|
||||||
private List<DashboardStatDTO> stats;
|
private List<DashboardStatDTO> stats;
|
||||||
private List<DashboardTodoDTO> todos;
|
private List<DashboardTodoDTO> todos;
|
||||||
private List<DashboardActivityDTO> activities;
|
private List<DashboardActivityDTO> activities;
|
||||||
|
|
@ -17,6 +19,7 @@ public class DashboardHomeDTO {
|
||||||
}
|
}
|
||||||
|
|
||||||
public DashboardHomeDTO(Long userId, String realName, String jobTitle, String deptName, Long onboardingDays,
|
public DashboardHomeDTO(Long userId, String realName, String jobTitle, String deptName, Long onboardingDays,
|
||||||
|
Boolean todoCardVisible, Boolean activityCardVisible,
|
||||||
List<DashboardStatDTO> stats, List<DashboardTodoDTO> todos,
|
List<DashboardStatDTO> stats, List<DashboardTodoDTO> todos,
|
||||||
List<DashboardActivityDTO> activities) {
|
List<DashboardActivityDTO> activities) {
|
||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
|
|
@ -24,6 +27,8 @@ public class DashboardHomeDTO {
|
||||||
this.jobTitle = jobTitle;
|
this.jobTitle = jobTitle;
|
||||||
this.deptName = deptName;
|
this.deptName = deptName;
|
||||||
this.onboardingDays = onboardingDays;
|
this.onboardingDays = onboardingDays;
|
||||||
|
this.todoCardVisible = todoCardVisible;
|
||||||
|
this.activityCardVisible = activityCardVisible;
|
||||||
this.stats = stats;
|
this.stats = stats;
|
||||||
this.todos = todos;
|
this.todos = todos;
|
||||||
this.activities = activities;
|
this.activities = activities;
|
||||||
|
|
@ -69,6 +74,22 @@ public class DashboardHomeDTO {
|
||||||
this.onboardingDays = onboardingDays;
|
this.onboardingDays = onboardingDays;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Boolean getTodoCardVisible() {
|
||||||
|
return todoCardVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTodoCardVisible(Boolean todoCardVisible) {
|
||||||
|
this.todoCardVisible = todoCardVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getActivityCardVisible() {
|
||||||
|
return activityCardVisible;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setActivityCardVisible(Boolean activityCardVisible) {
|
||||||
|
this.activityCardVisible = activityCardVisible;
|
||||||
|
}
|
||||||
|
|
||||||
public List<DashboardStatDTO> getStats() {
|
public List<DashboardStatDTO> getStats() {
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,5 +26,12 @@ public interface DashboardMapper {
|
||||||
|
|
||||||
int markTodoDone(@Param("userId") Long userId, @Param("todoId") Long todoId);
|
int markTodoDone(@Param("userId") Long userId, @Param("todoId") Long todoId);
|
||||||
|
|
||||||
List<DashboardActivityDTO> selectLatestActivities(@Param("userId") Long userId);
|
@DataScope(tableAlias = "a", ownerColumn = "owner_user_id")
|
||||||
|
List<DashboardActivityDTO> selectLatestOpportunityActivities(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
@DataScope(tableAlias = "a", ownerColumn = "owner_user_id")
|
||||||
|
List<DashboardActivityDTO> selectLatestSalesActivities(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
@DataScope(tableAlias = "a", ownerColumn = "owner_user_id")
|
||||||
|
List<DashboardActivityDTO> selectLatestChannelActivities(@Param("userId") Long userId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,21 +8,34 @@ import com.unis.crm.dto.dashboard.DashboardTodoDTO;
|
||||||
import com.unis.crm.dto.dashboard.UserWelcomeDTO;
|
import com.unis.crm.dto.dashboard.UserWelcomeDTO;
|
||||||
import com.unis.crm.mapper.DashboardMapper;
|
import com.unis.crm.mapper.DashboardMapper;
|
||||||
import com.unis.crm.service.DashboardService;
|
import com.unis.crm.service.DashboardService;
|
||||||
|
import com.unisbase.service.SysPermissionService;
|
||||||
|
import com.unisbase.spi.UnisBaseTenantProvider;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class DashboardServiceImpl implements DashboardService {
|
public class DashboardServiceImpl implements DashboardService {
|
||||||
|
|
||||||
private final DashboardMapper dashboardMapper;
|
private static final String DASHBOARD_TODO_CARD_VIEW_PERMISSION = "dashboard_todo_card:view";
|
||||||
|
private static final String DASHBOARD_ACTIVITY_CARD_VIEW_PERMISSION = "dashboard_activity_card:view";
|
||||||
|
|
||||||
public DashboardServiceImpl(DashboardMapper dashboardMapper) {
|
private final DashboardMapper dashboardMapper;
|
||||||
|
private final SysPermissionService sysPermissionService;
|
||||||
|
private final UnisBaseTenantProvider tenantProvider;
|
||||||
|
|
||||||
|
public DashboardServiceImpl(DashboardMapper dashboardMapper,
|
||||||
|
SysPermissionService sysPermissionService,
|
||||||
|
UnisBaseTenantProvider tenantProvider) {
|
||||||
this.dashboardMapper = dashboardMapper;
|
this.dashboardMapper = dashboardMapper;
|
||||||
|
this.sysPermissionService = sysPermissionService;
|
||||||
|
this.tenantProvider = tenantProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -41,8 +54,11 @@ public class DashboardServiceImpl implements DashboardService {
|
||||||
if (monthlyChannelStat != null) {
|
if (monthlyChannelStat != null) {
|
||||||
stats.add(monthlyChannelStat);
|
stats.add(monthlyChannelStat);
|
||||||
}
|
}
|
||||||
List<DashboardTodoDTO> todos = dashboardMapper.selectTodos(userId);
|
Set<String> permissionCodes = loadPermissionCodes(userId);
|
||||||
List<DashboardActivityDTO> activities = dashboardMapper.selectLatestActivities(userId);
|
boolean todoCardVisible = permissionCodes.contains(DASHBOARD_TODO_CARD_VIEW_PERMISSION);
|
||||||
|
boolean activityCardVisible = permissionCodes.contains(DASHBOARD_ACTIVITY_CARD_VIEW_PERMISSION);
|
||||||
|
List<DashboardTodoDTO> todos = todoCardVisible ? dashboardMapper.selectTodos(userId) : List.of();
|
||||||
|
List<DashboardActivityDTO> activities = activityCardVisible ? loadLatestActivities(userId) : List.of();
|
||||||
enrichActivityTimeText(activities);
|
enrichActivityTimeText(activities);
|
||||||
|
|
||||||
long onboardingDays = 0;
|
long onboardingDays = 0;
|
||||||
|
|
@ -57,6 +73,8 @@ public class DashboardServiceImpl implements DashboardService {
|
||||||
user.getJobTitle(),
|
user.getJobTitle(),
|
||||||
user.getDeptName(),
|
user.getDeptName(),
|
||||||
onboardingDays,
|
onboardingDays,
|
||||||
|
todoCardVisible,
|
||||||
|
activityCardVisible,
|
||||||
stats,
|
stats,
|
||||||
todos,
|
todos,
|
||||||
activities
|
activities
|
||||||
|
|
@ -77,6 +95,35 @@ public class DashboardServiceImpl implements DashboardService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Set<String> loadPermissionCodes(Long userId) {
|
||||||
|
try {
|
||||||
|
Long tenantId = tenantProvider.getCurrentTenantId();
|
||||||
|
if (tenantId == null) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
Set<String> permissionCodes = sysPermissionService.listPermissionCodesByUserId(userId, tenantId);
|
||||||
|
if (permissionCodes == null) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
return permissionCodes;
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<DashboardActivityDTO> loadLatestActivities(Long userId) {
|
||||||
|
List<DashboardActivityDTO> activities = new ArrayList<>();
|
||||||
|
activities.addAll(dashboardMapper.selectLatestOpportunityActivities(userId));
|
||||||
|
activities.addAll(dashboardMapper.selectLatestSalesActivities(userId));
|
||||||
|
activities.addAll(dashboardMapper.selectLatestChannelActivities(userId));
|
||||||
|
activities.sort(Comparator.comparing(DashboardActivityDTO::getCreatedAt,
|
||||||
|
Comparator.nullsLast(Comparator.reverseOrder())));
|
||||||
|
if (activities.size() > 8) {
|
||||||
|
return new ArrayList<>(activities.subList(0, 8));
|
||||||
|
}
|
||||||
|
return activities;
|
||||||
|
}
|
||||||
|
|
||||||
private void enrichActivityTimeText(List<DashboardActivityDTO> activities) {
|
private void enrichActivityTimeText(List<DashboardActivityDTO> activities) {
|
||||||
if (activities == null || activities.isEmpty()) {
|
if (activities == null || activities.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -116,110 +116,7 @@
|
||||||
and status in ('todo', 'done')
|
and status in ('todo', 'done')
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<select id="selectLatestActivities" resultType="com.unis.crm.dto.dashboard.DashboardActivityDTO">
|
<select id="selectLatestOpportunityActivities" resultType="com.unis.crm.dto.dashboard.DashboardActivityDTO">
|
||||||
with latest_report_comment as (
|
|
||||||
select distinct on (c.report_id)
|
|
||||||
c.report_id,
|
|
||||||
c.reviewer_user_id,
|
|
||||||
c.score,
|
|
||||||
c.comment_content,
|
|
||||||
c.reviewed_at
|
|
||||||
from work_daily_report_comment c
|
|
||||||
order by c.report_id, c.reviewed_at desc, c.id desc
|
|
||||||
),
|
|
||||||
activity_union as (
|
|
||||||
select
|
|
||||||
l.id,
|
|
||||||
l.biz_type as bizType,
|
|
||||||
l.biz_id as bizId,
|
|
||||||
l.action_type as actionType,
|
|
||||||
l.title,
|
|
||||||
l.content,
|
|
||||||
l.operator_user_id as operatorUserId,
|
|
||||||
l.created_at as createdAt
|
|
||||||
from sys_activity_log l
|
|
||||||
where l.operator_user_id = #{userId}
|
|
||||||
or l.operator_user_id is null
|
|
||||||
|
|
||||||
union all
|
|
||||||
|
|
||||||
select
|
|
||||||
(1000000000 + o.id) as id,
|
|
||||||
'opportunity' as bizType,
|
|
||||||
o.id as bizId,
|
|
||||||
'stage_update' as actionType,
|
|
||||||
'商机阶段更新' as title,
|
|
||||||
o.opportunity_name || ' 已推进至' ||
|
|
||||||
case o.stage
|
|
||||||
when 'initial_contact' then '初步沟通'
|
|
||||||
when 'solution_discussion' then '方案交流'
|
|
||||||
when 'bidding' then '招投标'
|
|
||||||
when 'business_negotiation' then '商务谈判'
|
|
||||||
when 'won' then '已成交'
|
|
||||||
when 'lost' then '已输单'
|
|
||||||
else o.stage
|
|
||||||
end || '阶段' as content,
|
|
||||||
o.owner_user_id as operatorUserId,
|
|
||||||
o.updated_at as createdAt
|
|
||||||
from crm_opportunity o
|
|
||||||
where o.owner_user_id = #{userId}
|
|
||||||
and o.updated_at > o.created_at
|
|
||||||
|
|
||||||
union all
|
|
||||||
|
|
||||||
select
|
|
||||||
(2000000000 + r.id) as id,
|
|
||||||
'report' as bizType,
|
|
||||||
r.id as bizId,
|
|
||||||
case
|
|
||||||
when r.status = 'reviewed' or lc.score is not null then 'report_reviewed'
|
|
||||||
else 'report_read'
|
|
||||||
end as actionType,
|
|
||||||
case
|
|
||||||
when r.status = 'reviewed' or lc.score is not null then '日报已点评'
|
|
||||||
else '日报已阅'
|
|
||||||
end as title,
|
|
||||||
case
|
|
||||||
when lc.score is not null then '主管对你' || to_char(r.report_date, 'MM-DD') || '的日报给出了 ' || lc.score || ' 分'
|
|
||||||
when r.status = 'reviewed' then '你的' || to_char(r.report_date, 'MM-DD') || '日报已完成主管点评'
|
|
||||||
else '你的' || to_char(r.report_date, 'MM-DD') || '日报已被查阅'
|
|
||||||
end as content,
|
|
||||||
coalesce(lc.reviewer_user_id, r.user_id) as operatorUserId,
|
|
||||||
coalesce(lc.reviewed_at, r.updated_at, r.created_at) as createdAt
|
|
||||||
from work_daily_report r
|
|
||||||
left join latest_report_comment lc on lc.report_id = r.id
|
|
||||||
where r.user_id = #{userId}
|
|
||||||
and r.status in ('read', 'reviewed')
|
|
||||||
|
|
||||||
union all
|
|
||||||
|
|
||||||
select
|
|
||||||
(3000000000 + c.id) as id,
|
|
||||||
'channel' as bizType,
|
|
||||||
c.id as bizId,
|
|
||||||
'channel_created' as actionType,
|
|
||||||
'新渠道录入' as title,
|
|
||||||
'成功录入 ' || c.channel_name || ' 渠道商信息' as content,
|
|
||||||
c.owner_user_id as operatorUserId,
|
|
||||||
c.created_at as createdAt
|
|
||||||
from crm_channel_expansion c
|
|
||||||
where c.owner_user_id = #{userId}
|
|
||||||
|
|
||||||
union all
|
|
||||||
|
|
||||||
select
|
|
||||||
(4000000000 + f.id) as id,
|
|
||||||
'opportunity_followup' as bizType,
|
|
||||||
f.opportunity_id as bizId,
|
|
||||||
'opportunity_followup' as actionType,
|
|
||||||
'商机跟进新增' as title,
|
|
||||||
o.opportunity_name || ' 新增了一条' || f.followup_type || '跟进记录' as content,
|
|
||||||
f.followup_user_id as operatorUserId,
|
|
||||||
f.followup_time as createdAt
|
|
||||||
from crm_opportunity_followup f
|
|
||||||
join crm_opportunity o on o.id = f.opportunity_id
|
|
||||||
where f.followup_user_id = #{userId}
|
|
||||||
)
|
|
||||||
select
|
select
|
||||||
a.id,
|
a.id,
|
||||||
a.bizType,
|
a.bizType,
|
||||||
|
|
@ -229,10 +126,180 @@
|
||||||
a.content,
|
a.content,
|
||||||
a.operatorUserId,
|
a.operatorUserId,
|
||||||
u.display_name as operatorName,
|
u.display_name as operatorName,
|
||||||
|
a.targetTab,
|
||||||
a.createdAt
|
a.createdAt
|
||||||
from activity_union a
|
from (
|
||||||
|
select
|
||||||
|
(1000000000 + o.id) as id,
|
||||||
|
'opportunity' as bizType,
|
||||||
|
o.id as bizId,
|
||||||
|
'status_update' as actionType,
|
||||||
|
'商机状态更新' as title,
|
||||||
|
o.opportunity_name || ' · 当前阶段:' ||
|
||||||
|
case o.stage
|
||||||
|
when 'initial_contact' then '初步沟通'
|
||||||
|
when 'solution_discussion' then '方案交流'
|
||||||
|
when 'bidding' then '招投标'
|
||||||
|
when 'business_negotiation' then '商务谈判'
|
||||||
|
when 'won' then '已成交'
|
||||||
|
when 'lost' then '已放弃'
|
||||||
|
else coalesce(o.stage, '未知')
|
||||||
|
end ||
|
||||||
|
case when coalesce(o.archived, false) then ' · 已签单' else '' end ||
|
||||||
|
case when coalesce(o.pushed_to_oms, false) then ' · 已推送OMS' else '' end as content,
|
||||||
|
o.owner_user_id as operatorUserId,
|
||||||
|
case when coalesce(o.archived, false) then 'archived' else 'active' end as targetTab,
|
||||||
|
o.owner_user_id as owner_user_id,
|
||||||
|
o.updated_at as createdAt
|
||||||
|
from crm_opportunity o
|
||||||
|
where o.updated_at > o.created_at
|
||||||
|
|
||||||
|
union all
|
||||||
|
|
||||||
|
select
|
||||||
|
(1100000000 + f.id) as id,
|
||||||
|
'opportunity' as bizType,
|
||||||
|
f.opportunity_id as bizId,
|
||||||
|
'followup_update' as actionType,
|
||||||
|
'商机跟进更新' as title,
|
||||||
|
o.opportunity_name || ' · ' || coalesce(f.followup_type, '跟进') || ' · ' ||
|
||||||
|
left(coalesce(nullif(btrim(f.next_action), ''), nullif(btrim(f.content), ''), '已新增跟进记录'), 80) as content,
|
||||||
|
f.followup_user_id as operatorUserId,
|
||||||
|
case when coalesce(o.archived, false) then 'archived' else 'active' end as targetTab,
|
||||||
|
o.owner_user_id as owner_user_id,
|
||||||
|
f.followup_time as createdAt
|
||||||
|
from crm_opportunity_followup f
|
||||||
|
join crm_opportunity o on o.id = f.opportunity_id
|
||||||
|
) a
|
||||||
left join sys_user u on u.user_id = a.operatorUserId
|
left join sys_user u on u.user_id = a.operatorUserId
|
||||||
|
where 1 = 1
|
||||||
order by a.createdAt desc nulls last
|
order by a.createdAt desc nulls last
|
||||||
limit 8
|
limit 12
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectLatestSalesActivities" resultType="com.unis.crm.dto.dashboard.DashboardActivityDTO">
|
||||||
|
select
|
||||||
|
a.id,
|
||||||
|
a.bizType,
|
||||||
|
a.bizId,
|
||||||
|
a.actionType,
|
||||||
|
a.title,
|
||||||
|
a.content,
|
||||||
|
a.operatorUserId,
|
||||||
|
u.display_name as operatorName,
|
||||||
|
a.targetTab,
|
||||||
|
a.createdAt
|
||||||
|
from (
|
||||||
|
select
|
||||||
|
(2000000000 + s.id) as id,
|
||||||
|
'sales' as bizType,
|
||||||
|
s.id as bizId,
|
||||||
|
'status_update' as actionType,
|
||||||
|
'销售拓展状态更新' as title,
|
||||||
|
s.candidate_name || ' · 当前阶段:' ||
|
||||||
|
case s.stage
|
||||||
|
when 'initial_contact' then '初步沟通'
|
||||||
|
when 'solution_discussion' then '方案交流'
|
||||||
|
when 'bidding' then '招投标'
|
||||||
|
when 'business_negotiation' then '商务谈判'
|
||||||
|
when 'won' then '已成交'
|
||||||
|
when 'lost' then '已放弃'
|
||||||
|
else coalesce(s.stage, '未知')
|
||||||
|
end ||
|
||||||
|
' · ' || case when coalesce(s.employment_status, 'active') = 'active' then '在职' else '离职' end as content,
|
||||||
|
s.owner_user_id as operatorUserId,
|
||||||
|
'sales' as targetTab,
|
||||||
|
s.owner_user_id as owner_user_id,
|
||||||
|
s.updated_at as createdAt
|
||||||
|
from crm_sales_expansion s
|
||||||
|
where s.updated_at > s.created_at
|
||||||
|
|
||||||
|
union all
|
||||||
|
|
||||||
|
select
|
||||||
|
(2100000000 + f.id) as id,
|
||||||
|
'sales' as bizType,
|
||||||
|
f.biz_id as bizId,
|
||||||
|
'followup_update' as actionType,
|
||||||
|
'销售拓展跟进更新' as title,
|
||||||
|
s.candidate_name || ' · ' || coalesce(f.followup_type, '跟进') || ' · ' ||
|
||||||
|
left(coalesce(nullif(btrim(f.next_plan), ''), nullif(btrim(f.content), ''), '已新增跟进记录'), 80) as content,
|
||||||
|
f.followup_user_id as operatorUserId,
|
||||||
|
'sales' as targetTab,
|
||||||
|
s.owner_user_id as owner_user_id,
|
||||||
|
f.followup_time as createdAt
|
||||||
|
from crm_expansion_followup f
|
||||||
|
join crm_sales_expansion s on s.id = f.biz_id and f.biz_type = 'sales'
|
||||||
|
) a
|
||||||
|
left join sys_user u on u.user_id = a.operatorUserId
|
||||||
|
where 1 = 1
|
||||||
|
order by a.createdAt desc nulls last
|
||||||
|
limit 12
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectLatestChannelActivities" resultType="com.unis.crm.dto.dashboard.DashboardActivityDTO">
|
||||||
|
select
|
||||||
|
a.id,
|
||||||
|
a.bizType,
|
||||||
|
a.bizId,
|
||||||
|
a.actionType,
|
||||||
|
a.title,
|
||||||
|
a.content,
|
||||||
|
a.operatorUserId,
|
||||||
|
u.display_name as operatorName,
|
||||||
|
a.targetTab,
|
||||||
|
a.createdAt
|
||||||
|
from (
|
||||||
|
select
|
||||||
|
(3000000000 + c.id) as id,
|
||||||
|
'channel' as bizType,
|
||||||
|
c.id as bizId,
|
||||||
|
'status_update' as actionType,
|
||||||
|
'渠道拓展状态更新' as title,
|
||||||
|
c.channel_name || ' · 当前阶段:' ||
|
||||||
|
case c.stage
|
||||||
|
when 'initial_contact' then '初步接触'
|
||||||
|
when 'solution_discussion' then '方案交流'
|
||||||
|
when 'bidding' then '招投标'
|
||||||
|
when 'business_negotiation' then '合作洽谈'
|
||||||
|
when 'won' then '已合作'
|
||||||
|
when 'lost' then '已终止'
|
||||||
|
else coalesce(c.stage, '未知')
|
||||||
|
end ||
|
||||||
|
' · 合作意向:' ||
|
||||||
|
case c.intent_level
|
||||||
|
when 'high' then '高'
|
||||||
|
when 'medium' then '中'
|
||||||
|
when 'low' then '低'
|
||||||
|
else '未知'
|
||||||
|
end as content,
|
||||||
|
c.owner_user_id as operatorUserId,
|
||||||
|
'channel' as targetTab,
|
||||||
|
c.owner_user_id as owner_user_id,
|
||||||
|
c.updated_at as createdAt
|
||||||
|
from crm_channel_expansion c
|
||||||
|
where c.updated_at > c.created_at
|
||||||
|
|
||||||
|
union all
|
||||||
|
|
||||||
|
select
|
||||||
|
(3100000000 + f.id) as id,
|
||||||
|
'channel' as bizType,
|
||||||
|
f.biz_id as bizId,
|
||||||
|
'followup_update' as actionType,
|
||||||
|
'渠道拓展跟进更新' as title,
|
||||||
|
c.channel_name || ' · ' || coalesce(f.followup_type, '跟进') || ' · ' ||
|
||||||
|
left(coalesce(nullif(btrim(f.next_plan), ''), nullif(btrim(f.content), ''), '已新增跟进记录'), 80) as content,
|
||||||
|
f.followup_user_id as operatorUserId,
|
||||||
|
'channel' as targetTab,
|
||||||
|
c.owner_user_id as owner_user_id,
|
||||||
|
f.followup_time as createdAt
|
||||||
|
from crm_expansion_followup f
|
||||||
|
join crm_channel_expansion c on c.id = f.biz_id and f.biz_type = 'channel'
|
||||||
|
) a
|
||||||
|
left join sys_user u on u.user_id = a.operatorUserId
|
||||||
|
where 1 = 1
|
||||||
|
order by a.createdAt desc nulls last
|
||||||
|
limit 12
|
||||||
</select>
|
</select>
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ export interface DashboardActivity {
|
||||||
content?: string;
|
content?: string;
|
||||||
operatorUserId?: number;
|
operatorUserId?: number;
|
||||||
operatorName?: string;
|
operatorName?: string;
|
||||||
|
targetTab?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
timeText?: string;
|
timeText?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +105,8 @@ export interface DashboardHome {
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
deptName?: string;
|
deptName?: string;
|
||||||
onboardingDays?: number;
|
onboardingDays?: number;
|
||||||
|
todoCardVisible?: boolean;
|
||||||
|
activityCardVisible?: boolean;
|
||||||
stats?: DashboardStat[];
|
stats?: DashboardStat[];
|
||||||
todos?: DashboardTodo[];
|
todos?: DashboardTodo[];
|
||||||
activities?: DashboardActivity[];
|
activities?: DashboardActivity[];
|
||||||
|
|
@ -650,6 +653,11 @@ function applyAuthHeaders(headers: Headers) {
|
||||||
headers.set("X-User-Id", String(userId));
|
headers.set("X-User-Id", String(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeTenantId = localStorage.getItem("activeTenantId");
|
||||||
|
if (activeTenantId?.trim()) {
|
||||||
|
headers.set("X-Tenant-Id", activeTenantId.trim());
|
||||||
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,9 @@ export default function Dashboard() {
|
||||||
const visibleActivities = showAllActivities ? activities : activities.slice(0, DASHBOARD_PREVIEW_COUNT);
|
const visibleActivities = showAllActivities ? activities : activities.slice(0, DASHBOARD_PREVIEW_COUNT);
|
||||||
const hasMoreActivities = activities.length > DASHBOARD_PREVIEW_COUNT && activities[0]?.id !== 0;
|
const hasMoreActivities = activities.length > DASHBOARD_PREVIEW_COUNT && activities[0]?.id !== 0;
|
||||||
const hasMoreHistoryTodos = historyTodos.length > DASHBOARD_HISTORY_PREVIEW_COUNT;
|
const hasMoreHistoryTodos = historyTodos.length > DASHBOARD_HISTORY_PREVIEW_COUNT;
|
||||||
|
const showTodoCard = home.todoCardVisible !== false;
|
||||||
|
const showActivityCard = home.activityCardVisible !== false;
|
||||||
|
const dashboardPanelCount = Number(showTodoCard) + Number(showActivityCard);
|
||||||
|
|
||||||
const handleCompleteTodo = async (todoId: string) => {
|
const handleCompleteTodo = async (todoId: string) => {
|
||||||
if (!todoId || completingTodoId === todoId) {
|
if (!todoId || completingTodoId === todoId) {
|
||||||
|
|
@ -151,6 +154,23 @@ export default function Dashboard() {
|
||||||
navigate(target.pathname, target.state ? { state: target.state } : undefined);
|
navigate(target.pathname, target.state ? { state: target.state } : undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleActivityClick = (activity: DashboardActivity) => {
|
||||||
|
if (!activity?.bizId || !activity.bizType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.bizType === "opportunity") {
|
||||||
|
const archiveTab = activity.targetTab === "archived" ? "archived" : "active";
|
||||||
|
navigate("/opportunities", { state: { selectedId: activity.bizId, archiveTab } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.bizType === "sales" || activity.bizType === "channel") {
|
||||||
|
const tab = activity.targetTab === "channel" ? "channel" : "sales";
|
||||||
|
navigate("/expansion", { state: { tab, selectedId: activity.bizId } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="crm-page-stack">
|
<div className="crm-page-stack">
|
||||||
<header className="crm-page-header">
|
<header className="crm-page-header">
|
||||||
|
|
@ -223,7 +243,8 @@ export default function Dashboard() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 grid gap-5 md:mt-6 md:grid-cols-2 md:gap-6">
|
<div className={`mt-5 grid gap-5 md:mt-6 ${dashboardPanelCount > 1 ? "md:grid-cols-2" : "md:grid-cols-1"} md:gap-6`}>
|
||||||
|
{showTodoCard ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
|
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
|
@ -320,7 +341,9 @@ export default function Dashboard() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showActivityCard ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
|
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
|
@ -330,17 +353,35 @@ export default function Dashboard() {
|
||||||
<h2 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white">最新动态</h2>
|
<h2 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white">最新动态</h2>
|
||||||
<div className="crm-section-stack sm:gap-5">
|
<div className="crm-section-stack sm:gap-5">
|
||||||
{visibleActivities.map((news: DashboardActivity, i: number) => (
|
{visibleActivities.map((news: DashboardActivity, i: number) => (
|
||||||
<div key={news.id ?? i} className="flex gap-4">
|
<button
|
||||||
<div className="relative mt-1 flex h-3 w-3 items-center justify-center">
|
key={news.id ?? i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleActivityClick(news)}
|
||||||
|
disabled={!news?.bizId || !news?.bizType}
|
||||||
|
className="group flex w-full gap-4 rounded-xl border border-transparent px-2 py-2 text-left transition-colors hover:border-violet-100 hover:bg-violet-50/60 disabled:cursor-default disabled:hover:border-transparent disabled:hover:bg-transparent dark:hover:border-violet-900/40 dark:hover:bg-slate-900/50"
|
||||||
|
>
|
||||||
|
<div className="relative mt-1 flex h-3 w-3 shrink-0 items-center justify-center">
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-violet-400 opacity-20"></span>
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-violet-400 opacity-20"></span>
|
||||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-violet-500"></span>
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-violet-500"></span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium text-slate-900 dark:text-white">{news.title || "无"}</p>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<p className="crm-field-note mt-0.5">{news.content || "无"}</p>
|
<p className="text-sm font-medium text-slate-900 transition-colors group-hover:text-violet-700 dark:text-white dark:group-hover:text-violet-300">
|
||||||
<p className="mt-1 text-[10px] text-slate-400 dark:text-slate-500">{news.timeText || "无"}</p>
|
{news.title || "无"}
|
||||||
|
</p>
|
||||||
|
{news.bizId ? (
|
||||||
|
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-500 group-hover:bg-violet-100 group-hover:text-violet-700 dark:bg-slate-800 dark:text-slate-400 dark:group-hover:bg-violet-500/10 dark:group-hover:text-violet-300">
|
||||||
|
查看
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="crm-field-note mt-0.5 line-clamp-2">{news.content || "无"}</p>
|
||||||
|
<div className="mt-1 flex items-center gap-2 text-[10px] text-slate-400 dark:text-slate-500">
|
||||||
|
<span>{news.timeText || "无"}</span>
|
||||||
|
{news.operatorName ? <span>· {news.operatorName}</span> : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{hasMoreActivities && !showAllActivities ? (
|
{hasMoreActivities && !showAllActivities ? (
|
||||||
|
|
@ -353,6 +394,7 @@ export default function Dashboard() {
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem;
|
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem;
|
||||||
type ExpansionTab = "sales" | "channel";
|
type ExpansionTab = "sales" | "channel";
|
||||||
|
type ExpansionLocationState = { tab?: ExpansionTab; selectedId?: number } | null;
|
||||||
type ExpansionExportFilters = {
|
type ExpansionExportFilters = {
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
intent?: string;
|
intent?: string;
|
||||||
|
|
@ -1038,12 +1039,42 @@ export default function Expansion() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const requestedTab = (location.state as { tab?: ExpansionTab } | null)?.tab;
|
const requestedTab = (location.state as ExpansionLocationState)?.tab;
|
||||||
if (requestedTab === "sales" || requestedTab === "channel") {
|
if (requestedTab === "sales" || requestedTab === "channel") {
|
||||||
setActiveTab(requestedTab);
|
setActiveTab(requestedTab);
|
||||||
}
|
}
|
||||||
}, [location.state]);
|
}, [location.state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const requestedState = location.state as ExpansionLocationState;
|
||||||
|
const requestedTab = requestedState?.tab;
|
||||||
|
const requestedId = requestedState?.selectedId;
|
||||||
|
if (!requestedId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedTab === "sales") {
|
||||||
|
const matched = salesData.find((item) => item.id === requestedId);
|
||||||
|
if (matched) {
|
||||||
|
setSelectedItem(matched);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedTab === "channel") {
|
||||||
|
const matched = channelData.find((item) => item.id === requestedId);
|
||||||
|
if (matched) {
|
||||||
|
setSelectedItem(matched);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = salesData.find((item) => item.id === requestedId) ?? channelData.find((item) => item.id === requestedId) ?? null;
|
||||||
|
if (matched) {
|
||||||
|
setSelectedItem(matched);
|
||||||
|
}
|
||||||
|
}, [location.state, salesData, channelData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { Search, Plus, Download, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter } from "lucide-react";
|
import { Search, Plus, Download, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter } from "lucide-react";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOmsPreSalesOptions, getOpportunityOverview, getStoredCurrentUserId, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OmsPreSalesOption, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type PushOpportunityToOmsPayload, type SalesExpansionItem } from "@/lib/auth";
|
import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOmsPreSalesOptions, getOpportunityOverview, getStoredCurrentUserId, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OmsPreSalesOption, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type PushOpportunityToOmsPayload, type SalesExpansionItem } from "@/lib/auth";
|
||||||
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
||||||
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||||
|
|
@ -32,6 +33,7 @@ const COMPETITOR_OPTIONS = [
|
||||||
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number];
|
type CompetitorOption = (typeof COMPETITOR_OPTIONS)[number];
|
||||||
type OperatorMode = "none" | "h3c" | "channel" | "both";
|
type OperatorMode = "none" | "h3c" | "channel" | "both";
|
||||||
type OpportunityArchiveTab = "active" | "archived";
|
type OpportunityArchiveTab = "active" | "archived";
|
||||||
|
type OpportunityLocationState = { selectedId?: number; archiveTab?: OpportunityArchiveTab } | null;
|
||||||
type OpportunityExportFilters = {
|
type OpportunityExportFilters = {
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
expectedStartDate?: string;
|
expectedStartDate?: string;
|
||||||
|
|
@ -1342,6 +1344,7 @@ function CompetitorMultiSelect({
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Opportunities() {
|
export default function Opportunities() {
|
||||||
|
const location = useLocation();
|
||||||
const currentUserId = getStoredCurrentUserId();
|
const currentUserId = getStoredCurrentUserId();
|
||||||
const isMobileViewport = useIsMobileViewport();
|
const isMobileViewport = useIsMobileViewport();
|
||||||
const isWecomBrowser = useIsWecomBrowser();
|
const isWecomBrowser = useIsWecomBrowser();
|
||||||
|
|
@ -1404,6 +1407,27 @@ export default function Opportunities() {
|
||||||
};
|
};
|
||||||
}, [keyword, filter]);
|
}, [keyword, filter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const requestedState = location.state as OpportunityLocationState;
|
||||||
|
const requestedArchiveTab = requestedState?.archiveTab;
|
||||||
|
if (requestedArchiveTab === "active" || requestedArchiveTab === "archived") {
|
||||||
|
setArchiveTab(requestedArchiveTab);
|
||||||
|
}
|
||||||
|
}, [location.state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const requestedState = location.state as OpportunityLocationState;
|
||||||
|
const requestedId = requestedState?.selectedId;
|
||||||
|
if (!requestedId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = items.find((item) => item.id === requestedId);
|
||||||
|
if (matched) {
|
||||||
|
setSelectedItem(matched);
|
||||||
|
}
|
||||||
|
}, [location.state, items]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue