forked from admin/innovation-platform
feat: add project operations overview
This commit is contained in:
@@ -8,8 +8,10 @@ Innovation Platform is a Spring Boot based demo for university innovation and en
|
||||
- User management APIs for admin and profile workflows
|
||||
- Project CRUD APIs with pagination
|
||||
- Project dashboard statistics endpoint
|
||||
- Project operations overview endpoint for risk and workload insights
|
||||
- Static admin dashboard prototype at `backend/src/main/resources/static/index.html`
|
||||
- Example SQL schema and seed data
|
||||
- Integration tests running on an H2 test profile
|
||||
|
||||
## Demo accounts
|
||||
|
||||
@@ -32,6 +34,7 @@ Innovation Platform is a Spring Boot based demo for university innovation and en
|
||||
- `PUT /api/users/profile`
|
||||
- `GET /api/projects`
|
||||
- `GET /api/projects/stats`
|
||||
- `GET /api/projects/overview`
|
||||
- `GET /api/projects/{id}`
|
||||
- `POST /api/projects`
|
||||
- `PUT /api/projects/{id}`
|
||||
@@ -44,6 +47,8 @@ When the backend is running, open `/index.html` to use the lightweight dashboard
|
||||
- request a login token
|
||||
- load project statistics
|
||||
- query recent projects with keyword, type, level, and status filters
|
||||
- surface overdue and upcoming project alerts
|
||||
- show advisor workload and college footprint summaries
|
||||
- query the user directory with keyword, role, and status filters
|
||||
- load the active teacher roster
|
||||
- view and update the current user's profile details
|
||||
@@ -54,6 +59,7 @@ When the backend is running, open `/index.html` to use the lightweight dashboard
|
||||
## Notes
|
||||
|
||||
- This repository snapshot was recovered from an archive, so local build and runtime validation depend on the target machine having Java and Maven available.
|
||||
- The current environment used for editing did not include a working Maven installation, so changes were verified statically only.
|
||||
- The current editing environment now includes Java 17 and Maven, and the backend is covered by integration tests on an H2 profile.
|
||||
- Pagination is now backed by a MyBatis-Plus interceptor so `current` and `size` parameters apply correctly.
|
||||
- The SQL bootstrap files were refreshed into a clean UTF-8 seed set and made idempotent with `ON DUPLICATE KEY UPDATE`.
|
||||
- Upstream user-management changes were reconciled into this branch with DTO-based responses so password hashes are not exposed from `/api/users`.
|
||||
|
||||
@@ -71,6 +71,11 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.innovation.platform.config;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class MybatisPlusConfig {
|
||||
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
|
||||
return interceptor;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.innovation.platform.common.PageResult;
|
||||
import com.innovation.platform.common.Result;
|
||||
import com.innovation.platform.dto.ProjectOverviewResponse;
|
||||
import com.innovation.platform.dto.ProjectQueryRequest;
|
||||
import com.innovation.platform.dto.ProjectRequest;
|
||||
import com.innovation.platform.dto.ProjectResponse;
|
||||
@@ -38,6 +39,12 @@ public class ProjectController {
|
||||
return Result.success(projectService.stats());
|
||||
}
|
||||
|
||||
@Operation(summary = "Get project operations overview")
|
||||
@GetMapping("/overview")
|
||||
public Result<ProjectOverviewResponse> overview() {
|
||||
return Result.success(projectService.overview());
|
||||
}
|
||||
|
||||
@Operation(summary = "Get project detail by id")
|
||||
@GetMapping("/{id}")
|
||||
public Result<ProjectResponse> getById(@Parameter(description = "Project id") @PathVariable Long id) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.innovation.platform.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ProjectOverviewResponse {
|
||||
private long overdueProjects;
|
||||
private long endingSoonProjects;
|
||||
private long startingSoonProjects;
|
||||
private long activeLeaders;
|
||||
private long activeAdvisors;
|
||||
private long totalMembers;
|
||||
private List<ProjectStatsResponse.Bucket> collegeBreakdown;
|
||||
private List<ProjectStatsResponse.Bucket> advisorWorkload;
|
||||
private List<ProjectResponse> attentionProjects;
|
||||
private List<ProjectResponse> recentProjects;
|
||||
}
|
||||
@@ -33,15 +33,28 @@ public class ProjectResponse {
|
||||
private LocalDate startTime;
|
||||
private LocalDate endTime;
|
||||
private Long durationDays;
|
||||
private Long memberCount;
|
||||
private String timelineHealthKey;
|
||||
private String timelineHealthLabel;
|
||||
private String college;
|
||||
private LocalDateTime createTime;
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
public static ProjectResponse fromEntity(Project project) {
|
||||
return fromEntity(project, Map.of());
|
||||
return fromEntity(project, Map.of(), Map.of(), LocalDate.now());
|
||||
}
|
||||
|
||||
public static ProjectResponse fromEntity(Project project, Map<Long, String> userNames) {
|
||||
return fromEntity(project, userNames, Map.of(), LocalDate.now());
|
||||
}
|
||||
|
||||
public static ProjectResponse fromEntity(
|
||||
Project project,
|
||||
Map<Long, String> userNames,
|
||||
Map<Long, Long> memberCounts,
|
||||
LocalDate referenceDate
|
||||
) {
|
||||
String timelineHealthKey = timelineHealthKey(project, referenceDate);
|
||||
return ProjectResponse.builder()
|
||||
.id(project.getId())
|
||||
.projectNo(project.getProjectNo())
|
||||
@@ -63,6 +76,9 @@ public class ProjectResponse {
|
||||
.startTime(project.getStartTime())
|
||||
.endTime(project.getEndTime())
|
||||
.durationDays(durationDays(project.getStartTime(), project.getEndTime()))
|
||||
.memberCount(memberCount(project, memberCounts))
|
||||
.timelineHealthKey(timelineHealthKey)
|
||||
.timelineHealthLabel(timelineHealthLabel(timelineHealthKey))
|
||||
.college(project.getCollege())
|
||||
.createTime(project.getCreateTime())
|
||||
.updateTime(project.getUpdateTime())
|
||||
@@ -113,4 +129,64 @@ public class ProjectResponse {
|
||||
}
|
||||
return ChronoUnit.DAYS.between(start, end) + 1;
|
||||
}
|
||||
|
||||
private static long memberCount(Project project, Map<Long, Long> memberCounts) {
|
||||
long count = memberCounts.getOrDefault(project.getId(), 0L);
|
||||
if (count == 0 && project.getLeaderId() != null) {
|
||||
return 1L;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static String timelineHealthKey(Project project, LocalDate referenceDate) {
|
||||
if (statusEquals(project, 3)) {
|
||||
return "completed";
|
||||
}
|
||||
if (statusEquals(project, 4)) {
|
||||
return "rejected";
|
||||
}
|
||||
if (project.getEndTime() != null && project.getEndTime().isBefore(referenceDate) && isOpen(project)) {
|
||||
return "overdue";
|
||||
}
|
||||
if (project.getEndTime() != null && !project.getEndTime().isBefore(referenceDate)
|
||||
&& !project.getEndTime().isAfter(referenceDate.plusDays(30)) && isOpen(project)) {
|
||||
return "ending-soon";
|
||||
}
|
||||
if (project.getStartTime() != null && project.getStartTime().isAfter(referenceDate)
|
||||
&& !project.getStartTime().isAfter(referenceDate.plusDays(30)) && isOpen(project)) {
|
||||
return "starting-soon";
|
||||
}
|
||||
if (statusEquals(project, 0)) {
|
||||
return "draft";
|
||||
}
|
||||
if (statusEquals(project, 1)) {
|
||||
return "pending";
|
||||
}
|
||||
if (statusEquals(project, 2)) {
|
||||
return "on-track";
|
||||
}
|
||||
return "monitor";
|
||||
}
|
||||
|
||||
private static String timelineHealthLabel(String key) {
|
||||
return switch (key) {
|
||||
case "completed" -> "Completed";
|
||||
case "rejected" -> "Rejected";
|
||||
case "overdue" -> "Overdue";
|
||||
case "ending-soon" -> "Ending soon";
|
||||
case "starting-soon" -> "Starting soon";
|
||||
case "draft" -> "Planning";
|
||||
case "pending" -> "Pending review";
|
||||
case "on-track" -> "On track";
|
||||
default -> "Monitor";
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean isOpen(Project project) {
|
||||
return statusEquals(project, 0) || statusEquals(project, 1) || statusEquals(project, 2);
|
||||
}
|
||||
|
||||
private static boolean statusEquals(Project project, int status) {
|
||||
return project.getStatus() != null && project.getStatus() == status;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.innovation.platform.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.innovation.platform.entity.ProjectMember;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface ProjectMemberMapper extends BaseMapper<ProjectMember> {
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.innovation.platform.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.innovation.platform.dto.ProjectOverviewResponse;
|
||||
import com.innovation.platform.dto.ProjectQueryRequest;
|
||||
import com.innovation.platform.dto.ProjectRequest;
|
||||
import com.innovation.platform.dto.ProjectResponse;
|
||||
@@ -22,4 +23,6 @@ public interface ProjectService {
|
||||
Project getEntityById(Long id);
|
||||
|
||||
ProjectStatsResponse stats();
|
||||
|
||||
ProjectOverviewResponse overview();
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@ import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.innovation.platform.dto.ProjectOverviewResponse;
|
||||
import com.innovation.platform.dto.ProjectQueryRequest;
|
||||
import com.innovation.platform.dto.ProjectRequest;
|
||||
import com.innovation.platform.dto.ProjectResponse;
|
||||
import com.innovation.platform.dto.ProjectStatsResponse;
|
||||
import com.innovation.platform.entity.Project;
|
||||
import com.innovation.platform.entity.ProjectMember;
|
||||
import com.innovation.platform.entity.SysUser;
|
||||
import com.innovation.platform.mapper.ProjectMemberMapper;
|
||||
import com.innovation.platform.mapper.ProjectMapper;
|
||||
import com.innovation.platform.mapper.SysUserMapper;
|
||||
import com.innovation.platform.service.ProjectService;
|
||||
@@ -20,10 +23,13 @@ import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Comparator;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -32,6 +38,7 @@ import java.util.stream.Stream;
|
||||
public class ProjectServiceImpl implements ProjectService {
|
||||
|
||||
private final ProjectMapper projectMapper;
|
||||
private final ProjectMemberMapper projectMemberMapper;
|
||||
private final SysUserMapper sysUserMapper;
|
||||
|
||||
@Override
|
||||
@@ -59,7 +66,9 @@ public class ProjectServiceImpl implements ProjectService {
|
||||
|
||||
IPage<Project> projectPage = projectMapper.selectPage(page, wrapper);
|
||||
Map<Long, String> userNames = loadUserNames(projectPage.getRecords());
|
||||
return projectPage.convert(project -> ProjectResponse.fromEntity(project, userNames));
|
||||
Map<Long, Long> memberCounts = loadMemberCounts(projectPage.getRecords());
|
||||
LocalDate today = LocalDate.now();
|
||||
return projectPage.convert(project -> ProjectResponse.fromEntity(project, userNames, memberCounts, today));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -68,7 +77,7 @@ public class ProjectServiceImpl implements ProjectService {
|
||||
if (project == null) {
|
||||
throw new RuntimeException("Project does not exist.");
|
||||
}
|
||||
return ProjectResponse.fromEntity(project, loadUserNames(List.of(project)));
|
||||
return ProjectResponse.fromEntity(project, loadUserNames(List.of(project)), loadMemberCounts(List.of(project)), LocalDate.now());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -165,6 +174,79 @@ public class ProjectServiceImpl implements ProjectService {
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProjectOverviewResponse overview() {
|
||||
LocalDate today = LocalDate.now();
|
||||
List<Project> projects = projectMapper.selectList(
|
||||
new LambdaQueryWrapper<Project>()
|
||||
.orderByDesc(Project::getUpdateTime)
|
||||
.orderByDesc(Project::getId)
|
||||
);
|
||||
|
||||
Map<Long, String> userNames = loadUserNames(projects);
|
||||
Map<Long, Long> memberCounts = loadMemberCounts(projects);
|
||||
|
||||
List<Project> openProjects = projects.stream()
|
||||
.filter(this::isOpen)
|
||||
.toList();
|
||||
|
||||
long overdueProjects = openProjects.stream()
|
||||
.filter(project -> project.getEndTime() != null && project.getEndTime().isBefore(today))
|
||||
.count();
|
||||
long endingSoonProjects = openProjects.stream()
|
||||
.filter(project -> project.getEndTime() != null
|
||||
&& !project.getEndTime().isBefore(today)
|
||||
&& !project.getEndTime().isAfter(today.plusDays(30)))
|
||||
.count();
|
||||
long startingSoonProjects = openProjects.stream()
|
||||
.filter(project -> project.getStartTime() != null
|
||||
&& project.getStartTime().isAfter(today)
|
||||
&& !project.getStartTime().isAfter(today.plusDays(30)))
|
||||
.count();
|
||||
|
||||
List<ProjectResponse> attentionProjects = openProjects.stream()
|
||||
.filter(project -> requiresAttention(project, today))
|
||||
.sorted(Comparator
|
||||
.comparingInt((Project project) -> attentionPriority(project, today))
|
||||
.thenComparing(project -> project.getEndTime() == null ? LocalDate.MAX : project.getEndTime()))
|
||||
.limit(5)
|
||||
.map(project -> ProjectResponse.fromEntity(project, userNames, memberCounts, today))
|
||||
.toList();
|
||||
|
||||
List<ProjectResponse> recentProjects = projects.stream()
|
||||
.limit(5)
|
||||
.map(project -> ProjectResponse.fromEntity(project, userNames, memberCounts, today))
|
||||
.toList();
|
||||
|
||||
return ProjectOverviewResponse.builder()
|
||||
.overdueProjects(overdueProjects)
|
||||
.endingSoonProjects(endingSoonProjects)
|
||||
.startingSoonProjects(startingSoonProjects)
|
||||
.activeLeaders(openProjects.stream().map(Project::getLeaderId).filter(Objects::nonNull).distinct().count())
|
||||
.activeAdvisors(openProjects.stream().map(Project::getAdvisorId).filter(Objects::nonNull).distinct().count())
|
||||
.totalMembers(projects.stream().mapToLong(project -> resolvedMemberCount(project, memberCounts)).sum())
|
||||
.collegeBreakdown(topBuckets(
|
||||
projects.stream()
|
||||
.map(Project::getCollege)
|
||||
.filter(StringUtils::hasText)
|
||||
.collect(Collectors.groupingBy(Function.identity(), Collectors.counting())),
|
||||
6
|
||||
))
|
||||
.advisorWorkload(topBuckets(
|
||||
openProjects.stream()
|
||||
.map(Project::getAdvisorId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.groupingBy(
|
||||
advisorId -> userNames.getOrDefault(advisorId, "Advisor " + advisorId),
|
||||
Collectors.counting()
|
||||
)),
|
||||
6
|
||||
))
|
||||
.attentionProjects(attentionProjects)
|
||||
.recentProjects(recentProjects)
|
||||
.build();
|
||||
}
|
||||
|
||||
private boolean statusEquals(Project project, int status) {
|
||||
return project.getStatus() != null && project.getStatus() == status;
|
||||
}
|
||||
@@ -177,6 +259,45 @@ public class ProjectServiceImpl implements ProjectService {
|
||||
return value == null ? null : value.trim();
|
||||
}
|
||||
|
||||
private boolean isOpen(Project project) {
|
||||
return statusEquals(project, 0) || statusEquals(project, 1) || statusEquals(project, 2);
|
||||
}
|
||||
|
||||
private boolean requiresAttention(Project project, LocalDate today) {
|
||||
if (!isOpen(project)) {
|
||||
return false;
|
||||
}
|
||||
return (project.getEndTime() != null && !project.getEndTime().isAfter(today.plusDays(30)))
|
||||
|| (project.getStartTime() != null && project.getStartTime().isAfter(today)
|
||||
&& !project.getStartTime().isAfter(today.plusDays(30)));
|
||||
}
|
||||
|
||||
private int attentionPriority(Project project, LocalDate today) {
|
||||
if (project.getEndTime() != null && project.getEndTime().isBefore(today)) {
|
||||
return 0;
|
||||
}
|
||||
if (project.getEndTime() != null && !project.getEndTime().isAfter(today.plusDays(30))) {
|
||||
return 1;
|
||||
}
|
||||
if (project.getStartTime() != null && project.getStartTime().isAfter(today)
|
||||
&& !project.getStartTime().isAfter(today.plusDays(30))) {
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
private List<ProjectStatsResponse.Bucket> topBuckets(Map<String, Long> source, int limit) {
|
||||
return source.entrySet().stream()
|
||||
.sorted(Map.Entry.<String, Long>comparingByValue().reversed().thenComparing(Map.Entry::getKey))
|
||||
.limit(limit)
|
||||
.map(entry -> ProjectStatsResponse.Bucket.builder()
|
||||
.key(entry.getKey())
|
||||
.label(entry.getKey())
|
||||
.value(entry.getValue())
|
||||
.build())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Map<Long, String> loadUserNames(List<Project> projects) {
|
||||
List<Long> userIds = projects.stream()
|
||||
.flatMap(project -> Stream.of(project.getLeaderId(), project.getAdvisorId()))
|
||||
@@ -191,4 +312,31 @@ public class ProjectServiceImpl implements ProjectService {
|
||||
return sysUserMapper.selectBatchIds(userIds).stream()
|
||||
.collect(Collectors.toMap(SysUser::getId, SysUser::getRealName));
|
||||
}
|
||||
|
||||
private Map<Long, Long> loadMemberCounts(List<Project> projects) {
|
||||
List<Long> projectIds = projects.stream()
|
||||
.map(Project::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
|
||||
if (projectIds.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
return projectMemberMapper.selectList(
|
||||
new LambdaQueryWrapper<ProjectMember>()
|
||||
.in(ProjectMember::getProjectId, projectIds)
|
||||
.eq(ProjectMember::getDeleted, 0)
|
||||
).stream()
|
||||
.collect(Collectors.groupingBy(ProjectMember::getProjectId, Collectors.counting()));
|
||||
}
|
||||
|
||||
private long resolvedMemberCount(Project project, Map<Long, Long> memberCounts) {
|
||||
long count = memberCounts.getOrDefault(project.getId(), 0L);
|
||||
if (count == 0 && project.getLeaderId() != null) {
|
||||
return 1L;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,8 +69,8 @@ INSERT INTO project (
|
||||
'Working prototype, evaluation report, and student operation handbook.',
|
||||
18000.00,
|
||||
2,
|
||||
'2024-03-01',
|
||||
'2024-12-31',
|
||||
'2026-01-10',
|
||||
'2026-12-20',
|
||||
'School of Computer Science',
|
||||
4,
|
||||
4
|
||||
@@ -88,8 +88,8 @@ INSERT INTO project (
|
||||
'Service blueprint, mini-program prototype, and impact dashboard.',
|
||||
9500.00,
|
||||
1,
|
||||
'2024-04-15',
|
||||
'2024-11-30',
|
||||
'2026-04-15',
|
||||
'2026-11-30',
|
||||
'School of Management',
|
||||
5,
|
||||
5
|
||||
@@ -107,8 +107,8 @@ INSERT INTO project (
|
||||
'Research paper draft, benchmark scripts, and explainability report.',
|
||||
32000.00,
|
||||
3,
|
||||
'2024-01-10',
|
||||
'2025-01-15',
|
||||
'2025-09-01',
|
||||
'2026-02-28',
|
||||
'School of Information Engineering',
|
||||
6,
|
||||
6
|
||||
@@ -126,8 +126,8 @@ INSERT INTO project (
|
||||
'Interactive AR route, narration scripts, and event showcase materials.',
|
||||
12000.00,
|
||||
0,
|
||||
'2024-09-01',
|
||||
'2025-03-01',
|
||||
'2026-09-01',
|
||||
'2027-03-01',
|
||||
'School of Design',
|
||||
4,
|
||||
4
|
||||
@@ -145,8 +145,8 @@ INSERT INTO project (
|
||||
'Sensor kit, field report, and maintenance SOP.',
|
||||
21000.00,
|
||||
4,
|
||||
'2024-02-20',
|
||||
'2024-10-20',
|
||||
'2025-11-01',
|
||||
'2026-05-20',
|
||||
'School of Agronomy',
|
||||
5,
|
||||
5
|
||||
@@ -164,8 +164,8 @@ INSERT INTO project (
|
||||
'Coaching toolkit, matching engine prototype, and adoption report.',
|
||||
28500.00,
|
||||
2,
|
||||
'2024-05-01',
|
||||
'2025-02-28',
|
||||
'2026-02-01',
|
||||
'2026-04-10',
|
||||
'School of Public Affairs',
|
||||
6,
|
||||
6
|
||||
|
||||
@@ -37,8 +37,8 @@ CREATE TABLE IF NOT EXISTS stu_info (
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted TINYINT DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_student_no (student_no),
|
||||
UNIQUE KEY uk_user_id (user_id)
|
||||
UNIQUE KEY uk_stu_info_student_no (student_no),
|
||||
UNIQUE KEY uk_stu_info_user_id (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Teacher profile table
|
||||
@@ -53,8 +53,8 @@ CREATE TABLE IF NOT EXISTS teacher_info (
|
||||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted TINYINT DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_teacher_no (teacher_no),
|
||||
UNIQUE KEY uk_user_id (user_id)
|
||||
UNIQUE KEY uk_teacher_info_teacher_no (teacher_no),
|
||||
UNIQUE KEY uk_teacher_info_user_id (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Project table
|
||||
|
||||
@@ -145,6 +145,17 @@
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.pill.warn { background: #fff3e4; color: #b36a17; }
|
||||
.pill.danger { background: #fff0f0; color: #bc4141; }
|
||||
.pill.success { background: #e9f8f3; color: #107a5c; }
|
||||
.pill.neutral { background: #eef3f8; color: #52667d; }
|
||||
.mini {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.notice {
|
||||
margin-top: 14px;
|
||||
padding: 14px;
|
||||
@@ -197,7 +208,7 @@
|
||||
<div class="chip-list">
|
||||
<span class="pill">Use satoken header</span>
|
||||
<span class="pill">Supports quick login</span>
|
||||
<span class="pill">Reads stats and project pages</span>
|
||||
<span class="pill">Reads live overview insights</span>
|
||||
<span class="pill">Includes user management</span>
|
||||
</div>
|
||||
<div class="notice">
|
||||
@@ -243,7 +254,7 @@
|
||||
</div>
|
||||
<div class="inline-actions" style="margin-top: 14px;">
|
||||
<button class="btn" type="button" id="loginBtn">Request token</button>
|
||||
<button class="btn-soft" type="button" id="statsBtn">Load stats</button>
|
||||
<button class="btn-soft" type="button" id="statsBtn">Load dashboard</button>
|
||||
</div>
|
||||
<div class="status" id="statusBox">Ready. Request a token or paste one manually, then load dashboard data.</div>
|
||||
</section>
|
||||
@@ -380,6 +391,19 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="eyebrow">Operations</div>
|
||||
<h2>Delivery radar</h2>
|
||||
<div class="stats">
|
||||
<div class="stat"><span>Overdue</span><strong id="overdueProjects">-</strong></div>
|
||||
<div class="stat"><span>Ending in 30 days</span><strong id="endingSoonProjects">-</strong></div>
|
||||
<div class="stat"><span>Starting in 30 days</span><strong id="startingSoonProjects">-</strong></div>
|
||||
<div class="stat"><span>Active leaders</span><strong id="activeLeaders">-</strong></div>
|
||||
<div class="stat"><span>Active advisors</span><strong id="activeAdvisors">-</strong></div>
|
||||
<div class="stat"><span>Total members</span><strong id="totalMembers">-</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="eyebrow">Breakdown</div>
|
||||
<h2>Status and level distribution</h2>
|
||||
@@ -389,6 +413,24 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="eyebrow">Actions</div>
|
||||
<h2>Attention queue and advisor workload</h2>
|
||||
<div class="sub-grid">
|
||||
<div class="list" id="attentionList"></div>
|
||||
<div class="list" id="advisorList"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="eyebrow">Portfolio</div>
|
||||
<h2>College footprint and recent updates</h2>
|
||||
<div class="sub-grid">
|
||||
<div class="list" id="collegeList"></div>
|
||||
<div class="list" id="recentProjectList"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="eyebrow">Projects</div>
|
||||
<h2>Recent project records</h2>
|
||||
@@ -474,6 +516,10 @@
|
||||
statusList: document.getElementById('statusList'),
|
||||
levelList: document.getElementById('levelList'),
|
||||
teacherList: document.getElementById('teacherList'),
|
||||
attentionList: document.getElementById('attentionList'),
|
||||
advisorList: document.getElementById('advisorList'),
|
||||
collegeList: document.getElementById('collegeList'),
|
||||
recentProjectList: document.getElementById('recentProjectList'),
|
||||
sessionSummary: document.getElementById('sessionSummary')
|
||||
};
|
||||
|
||||
@@ -486,7 +532,13 @@
|
||||
rejectedProjects: document.getElementById('rejectedProjects'),
|
||||
nationalProjects: document.getElementById('nationalProjects'),
|
||||
totalBudget: document.getElementById('totalBudget'),
|
||||
averageBudget: document.getElementById('averageBudget')
|
||||
averageBudget: document.getElementById('averageBudget'),
|
||||
overdueProjects: document.getElementById('overdueProjects'),
|
||||
endingSoonProjects: document.getElementById('endingSoonProjects'),
|
||||
startingSoonProjects: document.getElementById('startingSoonProjects'),
|
||||
activeLeaders: document.getElementById('activeLeaders'),
|
||||
activeAdvisors: document.getElementById('activeAdvisors'),
|
||||
totalMembers: document.getElementById('totalMembers')
|
||||
};
|
||||
|
||||
const accountPresets = {
|
||||
@@ -557,10 +609,14 @@
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
setStatus('Loading project statistics...');
|
||||
setStatus('Loading dashboard insights...');
|
||||
try {
|
||||
const payload = await apiFetch('/api/projects/stats');
|
||||
const stats = payload.data;
|
||||
const [statsPayload, overviewPayload] = await Promise.all([
|
||||
apiFetch('/api/projects/stats'),
|
||||
apiFetch('/api/projects/overview')
|
||||
]);
|
||||
const stats = statsPayload.data || {};
|
||||
const overview = overviewPayload.data || {};
|
||||
statRefs.totalProjects.textContent = stats.totalProjects ?? '-';
|
||||
statRefs.draftProjects.textContent = stats.draftProjects ?? '-';
|
||||
statRefs.pendingProjects.textContent = stats.pendingProjects ?? '-';
|
||||
@@ -570,9 +626,19 @@
|
||||
statRefs.nationalProjects.textContent = stats.nationalProjects ?? '-';
|
||||
statRefs.totalBudget.textContent = formatMoney(stats.totalBudget);
|
||||
statRefs.averageBudget.textContent = formatMoney(stats.averageBudget);
|
||||
statRefs.overdueProjects.textContent = overview.overdueProjects ?? '-';
|
||||
statRefs.endingSoonProjects.textContent = overview.endingSoonProjects ?? '-';
|
||||
statRefs.startingSoonProjects.textContent = overview.startingSoonProjects ?? '-';
|
||||
statRefs.activeLeaders.textContent = overview.activeLeaders ?? '-';
|
||||
statRefs.activeAdvisors.textContent = overview.activeAdvisors ?? '-';
|
||||
statRefs.totalMembers.textContent = overview.totalMembers ?? '-';
|
||||
renderBucketList(refs.statusList, stats.statusBreakdown || []);
|
||||
renderBucketList(refs.levelList, stats.levelBreakdown || []);
|
||||
setStatus('Statistics loaded successfully.', 'success');
|
||||
renderBucketList(refs.advisorList, overview.advisorWorkload || [], 'No advisor workload data.');
|
||||
renderBucketList(refs.collegeList, overview.collegeBreakdown || [], 'No college footprint data.');
|
||||
renderProjectHighlights(refs.attentionList, overview.attentionProjects || [], 'No projects need immediate attention.');
|
||||
renderProjectHighlights(refs.recentProjectList, overview.recentProjects || [], 'No recent project updates.');
|
||||
setStatus('Dashboard insights loaded successfully.', 'success');
|
||||
} catch (error) {
|
||||
setStatus(error.message, 'error');
|
||||
}
|
||||
@@ -602,10 +668,14 @@
|
||||
</td>
|
||||
<td>
|
||||
<strong>${escapeHtml(item.leaderName || 'Unassigned')}</strong><br />
|
||||
<span>Advisor: ${escapeHtml(item.advisorName || '-')}</span>
|
||||
<span>Advisor: ${escapeHtml(item.advisorName || '-')}</span><br />
|
||||
<span>Team: ${item.memberCount ?? 0} member(s)</span>
|
||||
</td>
|
||||
<td>${escapeHtml(item.projectTypeLabel || '-')}</td>
|
||||
<td>${escapeHtml(item.statusLabel || '-')}</td>
|
||||
<td>
|
||||
${renderPill(item.statusLabel || '-', toneForHealth(item.timelineHealthKey))}
|
||||
<span class="mini">${escapeHtml(item.timelineHealthLabel || '-')}</span>
|
||||
</td>
|
||||
<td>${escapeHtml(item.projectLevelLabel || '-')}</td>
|
||||
<td>${formatDuration(item.startTime, item.endTime, item.durationDays)}</td>
|
||||
<td>${escapeHtml(item.college || '-')}</td>
|
||||
@@ -763,9 +833,9 @@
|
||||
return payload;
|
||||
}
|
||||
|
||||
function renderBucketList(target, items) {
|
||||
function renderBucketList(target, items, emptyMessage = 'No data') {
|
||||
if (!items.length) {
|
||||
target.innerHTML = '<div class="list-item"><span>No data</span><strong>-</strong></div>';
|
||||
target.innerHTML = `<div class="list-item"><span>${escapeHtml(emptyMessage)}</span><strong>-</strong></div>`;
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
@@ -776,6 +846,26 @@
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderProjectHighlights(target, items, emptyMessage) {
|
||||
if (!items.length) {
|
||||
target.innerHTML = `<div class="list-item"><span>${escapeHtml(emptyMessage)}</span><strong>-</strong></div>`;
|
||||
return;
|
||||
}
|
||||
target.innerHTML = items.map((item) => `
|
||||
<div class="list-item">
|
||||
<span>
|
||||
<strong>${escapeHtml(item.projectName || 'Project')}</strong><br />
|
||||
<span class="mini">${escapeHtml(item.projectNo || '-')} · ${escapeHtml(item.leaderName || 'Unassigned')}</span>
|
||||
<span class="mini">${escapeHtml(item.college || '-')} · ${item.memberCount ?? 0} member(s)</span>
|
||||
</span>
|
||||
<span>
|
||||
${renderPill(item.timelineHealthLabel || 'Monitor', toneForHealth(item.timelineHealthKey))}
|
||||
<span class="mini">${escapeHtml(item.endTime || item.startTime || '-')}</span>
|
||||
</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderTeacherList(items) {
|
||||
if (!items.length) {
|
||||
refs.teacherList.innerHTML = '<div class="list-item"><span>No teachers loaded</span><strong>-</strong></div>';
|
||||
@@ -847,6 +937,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toneForHealth(value) {
|
||||
switch (value) {
|
||||
case 'overdue': return 'danger';
|
||||
case 'ending-soon':
|
||||
case 'starting-soon':
|
||||
case 'pending': return 'warn';
|
||||
case 'completed':
|
||||
case 'on-track': return 'success';
|
||||
case 'rejected':
|
||||
case 'draft':
|
||||
default: return 'neutral';
|
||||
}
|
||||
}
|
||||
|
||||
function renderPill(label, tone = 'neutral') {
|
||||
return `<span class="pill ${tone}">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
function applyPresetAccount() {
|
||||
const preset = accountPresets[refs.accountPreset.value] || accountPresets.admin;
|
||||
refs.username.value = preset.username;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.innovation.platform;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.hamcrest.Matchers.greaterThan;
|
||||
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
|
||||
import static org.hamcrest.Matchers.hasSize;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class InnovationPlatformIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Test
|
||||
void dashboardEndpointsExposePortfolioInsights() throws Exception {
|
||||
String token = registerAndLogin();
|
||||
|
||||
mockMvc.perform(get("/api/projects/stats").header("satoken", token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.totalProjects").value(greaterThanOrEqualTo(6)))
|
||||
.andExpect(jsonPath("$.data.statusBreakdown", hasSize(5)))
|
||||
.andExpect(jsonPath("$.data.levelBreakdown", hasSize(3)));
|
||||
|
||||
mockMvc.perform(get("/api/projects/overview").header("satoken", token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.totalMembers", greaterThan(0)))
|
||||
.andExpect(jsonPath("$.data.collegeBreakdown").isArray())
|
||||
.andExpect(jsonPath("$.data.advisorWorkload").isArray())
|
||||
.andExpect(jsonPath("$.data.recentProjects", hasSize(5)))
|
||||
.andExpect(jsonPath("$.data.recentProjects[0].memberCount").exists())
|
||||
.andExpect(jsonPath("$.data.recentProjects[0].timelineHealthLabel").isNotEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void projectListingAndCreationExposeEnrichedProjectFields() throws Exception {
|
||||
String token = registerAndLogin();
|
||||
|
||||
mockMvc.perform(get("/api/projects").param("size", "3").header("satoken", token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.records", hasSize(3)))
|
||||
.andExpect(jsonPath("$.data.records[0].leaderName").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.records[0].memberCount").exists())
|
||||
.andExpect(jsonPath("$.data.records[0].timelineHealthKey").isNotEmpty());
|
||||
|
||||
String projectNo = "PRJ-TEST-" + System.nanoTime();
|
||||
String createPayload = """
|
||||
{
|
||||
"projectNo": "%s",
|
||||
"projectName": "AI Mentoring Copilot",
|
||||
"projectType": 1,
|
||||
"projectLevel": 2,
|
||||
"leaderId": 4,
|
||||
"advisorId": 2,
|
||||
"description": "Guided project support for innovation teams.",
|
||||
"researchPlan": "Ship a guided planning prototype and validation runbook.",
|
||||
"expectedResult": "Prototype, handbook, and evaluation summary.",
|
||||
"budget": "16800.00",
|
||||
"status": 1,
|
||||
"startTime": "2026-04-01",
|
||||
"endTime": "2026-12-15",
|
||||
"college": "School of Computer Science"
|
||||
}
|
||||
""".formatted(projectNo);
|
||||
|
||||
String createResponse = mockMvc.perform(post("/api/projects")
|
||||
.header("satoken", token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createPayload))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
Long projectId = objectMapper.readTree(createResponse).path("data").asLong();
|
||||
|
||||
mockMvc.perform(get("/api/projects/" + projectId).header("satoken", token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.projectNo").value(projectNo))
|
||||
.andExpect(jsonPath("$.data.memberCount").value(1))
|
||||
.andExpect(jsonPath("$.data.timelineHealthLabel").isNotEmpty());
|
||||
}
|
||||
|
||||
private String registerAndLogin() throws Exception {
|
||||
String suffix = String.valueOf(System.nanoTime());
|
||||
String username = "tester" + suffix;
|
||||
String password = "admin123";
|
||||
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"username": "%s",
|
||||
"password": "%s",
|
||||
"realName": "Test Operator",
|
||||
"phone": "139%08d",
|
||||
"email": "tester%s@example.com",
|
||||
"gender": 1,
|
||||
"roleType": 3
|
||||
}
|
||||
""".formatted(username, password, Math.floorMod(System.nanoTime(), 100000000L), suffix)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200));
|
||||
|
||||
String response = mockMvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"username": "%s",
|
||||
"password": "%s"
|
||||
}
|
||||
""".formatted(username, password)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
||||
JsonNode jsonNode = objectMapper.readTree(response);
|
||||
return jsonNode.path("data").path("token").asText();
|
||||
}
|
||||
}
|
||||
14
backend/src/test/resources/application-test.yml
Normal file
14
backend/src/test/resources/application-test.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:h2:mem:innovation_platform;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE
|
||||
username: sa
|
||||
password:
|
||||
driver-class-name: org.h2.Driver
|
||||
sql:
|
||||
init:
|
||||
mode: always
|
||||
schema-locations: classpath:schema.sql
|
||||
data-locations: classpath:data.sql
|
||||
|
||||
sa-token:
|
||||
is-log: false
|
||||
Reference in New Issue
Block a user