feat: add project operations overview

This commit is contained in:
Codex
2026-03-19 12:34:00 +08:00
parent a44f726c97
commit bba2e877a2
14 changed files with 594 additions and 31 deletions

View File

@@ -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 - User management APIs for admin and profile workflows
- Project CRUD APIs with pagination - Project CRUD APIs with pagination
- Project dashboard statistics endpoint - 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` - Static admin dashboard prototype at `backend/src/main/resources/static/index.html`
- Example SQL schema and seed data - Example SQL schema and seed data
- Integration tests running on an H2 test profile
## Demo accounts ## Demo accounts
@@ -32,6 +34,7 @@ Innovation Platform is a Spring Boot based demo for university innovation and en
- `PUT /api/users/profile` - `PUT /api/users/profile`
- `GET /api/projects` - `GET /api/projects`
- `GET /api/projects/stats` - `GET /api/projects/stats`
- `GET /api/projects/overview`
- `GET /api/projects/{id}` - `GET /api/projects/{id}`
- `POST /api/projects` - `POST /api/projects`
- `PUT /api/projects/{id}` - `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 - request a login token
- load project statistics - load project statistics
- query recent projects with keyword, type, level, and status filters - 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 - query the user directory with keyword, role, and status filters
- load the active teacher roster - load the active teacher roster
- view and update the current user's profile details - 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 ## 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. - 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`. - 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`. - Upstream user-management changes were reconciled into this branch with DTO-based responses so password hashes are not exposed from `/api/users`.

View File

@@ -71,6 +71,11 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -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;
}
}

View File

@@ -4,6 +4,7 @@ import cn.dev33.satoken.annotation.SaCheckLogin;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.innovation.platform.common.PageResult; import com.innovation.platform.common.PageResult;
import com.innovation.platform.common.Result; import com.innovation.platform.common.Result;
import com.innovation.platform.dto.ProjectOverviewResponse;
import com.innovation.platform.dto.ProjectQueryRequest; import com.innovation.platform.dto.ProjectQueryRequest;
import com.innovation.platform.dto.ProjectRequest; import com.innovation.platform.dto.ProjectRequest;
import com.innovation.platform.dto.ProjectResponse; import com.innovation.platform.dto.ProjectResponse;
@@ -38,6 +39,12 @@ public class ProjectController {
return Result.success(projectService.stats()); 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") @Operation(summary = "Get project detail by id")
@GetMapping("/{id}") @GetMapping("/{id}")
public Result<ProjectResponse> getById(@Parameter(description = "Project id") @PathVariable Long id) { public Result<ProjectResponse> getById(@Parameter(description = "Project id") @PathVariable Long id) {

View File

@@ -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;
}

View File

@@ -33,15 +33,28 @@ public class ProjectResponse {
private LocalDate startTime; private LocalDate startTime;
private LocalDate endTime; private LocalDate endTime;
private Long durationDays; private Long durationDays;
private Long memberCount;
private String timelineHealthKey;
private String timelineHealthLabel;
private String college; private String college;
private LocalDateTime createTime; private LocalDateTime createTime;
private LocalDateTime updateTime; private LocalDateTime updateTime;
public static ProjectResponse fromEntity(Project project) { 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) { 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() return ProjectResponse.builder()
.id(project.getId()) .id(project.getId())
.projectNo(project.getProjectNo()) .projectNo(project.getProjectNo())
@@ -63,6 +76,9 @@ public class ProjectResponse {
.startTime(project.getStartTime()) .startTime(project.getStartTime())
.endTime(project.getEndTime()) .endTime(project.getEndTime())
.durationDays(durationDays(project.getStartTime(), project.getEndTime())) .durationDays(durationDays(project.getStartTime(), project.getEndTime()))
.memberCount(memberCount(project, memberCounts))
.timelineHealthKey(timelineHealthKey)
.timelineHealthLabel(timelineHealthLabel(timelineHealthKey))
.college(project.getCollege()) .college(project.getCollege())
.createTime(project.getCreateTime()) .createTime(project.getCreateTime())
.updateTime(project.getUpdateTime()) .updateTime(project.getUpdateTime())
@@ -113,4 +129,64 @@ public class ProjectResponse {
} }
return ChronoUnit.DAYS.between(start, end) + 1; 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;
}
} }

View File

@@ -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> {
}

View File

@@ -1,6 +1,7 @@
package com.innovation.platform.service; package com.innovation.platform.service;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.innovation.platform.dto.ProjectOverviewResponse;
import com.innovation.platform.dto.ProjectQueryRequest; import com.innovation.platform.dto.ProjectQueryRequest;
import com.innovation.platform.dto.ProjectRequest; import com.innovation.platform.dto.ProjectRequest;
import com.innovation.platform.dto.ProjectResponse; import com.innovation.platform.dto.ProjectResponse;
@@ -22,4 +23,6 @@ public interface ProjectService {
Project getEntityById(Long id); Project getEntityById(Long id);
ProjectStatsResponse stats(); ProjectStatsResponse stats();
ProjectOverviewResponse overview();
} }

View File

@@ -4,12 +4,15 @@ import cn.dev33.satoken.stp.StpUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; 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.ProjectQueryRequest;
import com.innovation.platform.dto.ProjectRequest; import com.innovation.platform.dto.ProjectRequest;
import com.innovation.platform.dto.ProjectResponse; import com.innovation.platform.dto.ProjectResponse;
import com.innovation.platform.dto.ProjectStatsResponse; import com.innovation.platform.dto.ProjectStatsResponse;
import com.innovation.platform.entity.Project; import com.innovation.platform.entity.Project;
import com.innovation.platform.entity.ProjectMember;
import com.innovation.platform.entity.SysUser; import com.innovation.platform.entity.SysUser;
import com.innovation.platform.mapper.ProjectMemberMapper;
import com.innovation.platform.mapper.ProjectMapper; import com.innovation.platform.mapper.ProjectMapper;
import com.innovation.platform.mapper.SysUserMapper; import com.innovation.platform.mapper.SysUserMapper;
import com.innovation.platform.service.ProjectService; import com.innovation.platform.service.ProjectService;
@@ -20,10 +23,13 @@ import org.springframework.util.StringUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@@ -32,6 +38,7 @@ import java.util.stream.Stream;
public class ProjectServiceImpl implements ProjectService { public class ProjectServiceImpl implements ProjectService {
private final ProjectMapper projectMapper; private final ProjectMapper projectMapper;
private final ProjectMemberMapper projectMemberMapper;
private final SysUserMapper sysUserMapper; private final SysUserMapper sysUserMapper;
@Override @Override
@@ -59,7 +66,9 @@ public class ProjectServiceImpl implements ProjectService {
IPage<Project> projectPage = projectMapper.selectPage(page, wrapper); IPage<Project> projectPage = projectMapper.selectPage(page, wrapper);
Map<Long, String> userNames = loadUserNames(projectPage.getRecords()); 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 @Override
@@ -68,7 +77,7 @@ public class ProjectServiceImpl implements ProjectService {
if (project == null) { if (project == null) {
throw new RuntimeException("Project does not exist."); 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 @Override
@@ -165,6 +174,79 @@ public class ProjectServiceImpl implements ProjectService {
.build(); .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) { private boolean statusEquals(Project project, int status) {
return project.getStatus() != null && project.getStatus() == status; return project.getStatus() != null && project.getStatus() == status;
} }
@@ -177,6 +259,45 @@ public class ProjectServiceImpl implements ProjectService {
return value == null ? null : value.trim(); 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) { private Map<Long, String> loadUserNames(List<Project> projects) {
List<Long> userIds = projects.stream() List<Long> userIds = projects.stream()
.flatMap(project -> Stream.of(project.getLeaderId(), project.getAdvisorId())) .flatMap(project -> Stream.of(project.getLeaderId(), project.getAdvisorId()))
@@ -191,4 +312,31 @@ public class ProjectServiceImpl implements ProjectService {
return sysUserMapper.selectBatchIds(userIds).stream() return sysUserMapper.selectBatchIds(userIds).stream()
.collect(Collectors.toMap(SysUser::getId, SysUser::getRealName)); .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;
}
} }

View File

@@ -69,8 +69,8 @@ INSERT INTO project (
'Working prototype, evaluation report, and student operation handbook.', 'Working prototype, evaluation report, and student operation handbook.',
18000.00, 18000.00,
2, 2,
'2024-03-01', '2026-01-10',
'2024-12-31', '2026-12-20',
'School of Computer Science', 'School of Computer Science',
4, 4,
4 4
@@ -88,8 +88,8 @@ INSERT INTO project (
'Service blueprint, mini-program prototype, and impact dashboard.', 'Service blueprint, mini-program prototype, and impact dashboard.',
9500.00, 9500.00,
1, 1,
'2024-04-15', '2026-04-15',
'2024-11-30', '2026-11-30',
'School of Management', 'School of Management',
5, 5,
5 5
@@ -107,8 +107,8 @@ INSERT INTO project (
'Research paper draft, benchmark scripts, and explainability report.', 'Research paper draft, benchmark scripts, and explainability report.',
32000.00, 32000.00,
3, 3,
'2024-01-10', '2025-09-01',
'2025-01-15', '2026-02-28',
'School of Information Engineering', 'School of Information Engineering',
6, 6,
6 6
@@ -126,8 +126,8 @@ INSERT INTO project (
'Interactive AR route, narration scripts, and event showcase materials.', 'Interactive AR route, narration scripts, and event showcase materials.',
12000.00, 12000.00,
0, 0,
'2024-09-01', '2026-09-01',
'2025-03-01', '2027-03-01',
'School of Design', 'School of Design',
4, 4,
4 4
@@ -145,8 +145,8 @@ INSERT INTO project (
'Sensor kit, field report, and maintenance SOP.', 'Sensor kit, field report, and maintenance SOP.',
21000.00, 21000.00,
4, 4,
'2024-02-20', '2025-11-01',
'2024-10-20', '2026-05-20',
'School of Agronomy', 'School of Agronomy',
5, 5,
5 5
@@ -164,8 +164,8 @@ INSERT INTO project (
'Coaching toolkit, matching engine prototype, and adoption report.', 'Coaching toolkit, matching engine prototype, and adoption report.',
28500.00, 28500.00,
2, 2,
'2024-05-01', '2026-02-01',
'2025-02-28', '2026-04-10',
'School of Public Affairs', 'School of Public Affairs',
6, 6,
6 6

View File

@@ -37,8 +37,8 @@ CREATE TABLE IF NOT EXISTS stu_info (
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT DEFAULT 0, deleted TINYINT DEFAULT 0,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_student_no (student_no), UNIQUE KEY uk_stu_info_student_no (student_no),
UNIQUE KEY uk_user_id (user_id) UNIQUE KEY uk_stu_info_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Teacher profile table -- Teacher profile table
@@ -53,8 +53,8 @@ CREATE TABLE IF NOT EXISTS teacher_info (
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT DEFAULT 0, deleted TINYINT DEFAULT 0,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_teacher_no (teacher_no), UNIQUE KEY uk_teacher_info_teacher_no (teacher_no),
UNIQUE KEY uk_user_id (user_id) UNIQUE KEY uk_teacher_info_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Project table -- Project table

View File

@@ -145,6 +145,17 @@
font-size: 12px; font-size: 12px;
font-weight: 700; 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 { .notice {
margin-top: 14px; margin-top: 14px;
padding: 14px; padding: 14px;
@@ -197,7 +208,7 @@
<div class="chip-list"> <div class="chip-list">
<span class="pill">Use satoken header</span> <span class="pill">Use satoken header</span>
<span class="pill">Supports quick login</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> <span class="pill">Includes user management</span>
</div> </div>
<div class="notice"> <div class="notice">
@@ -243,7 +254,7 @@
</div> </div>
<div class="inline-actions" style="margin-top: 14px;"> <div class="inline-actions" style="margin-top: 14px;">
<button class="btn" type="button" id="loginBtn">Request token</button> <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>
<div class="status" id="statusBox">Ready. Request a token or paste one manually, then load dashboard data.</div> <div class="status" id="statusBox">Ready. Request a token or paste one manually, then load dashboard data.</div>
</section> </section>
@@ -380,6 +391,19 @@
</div> </div>
</section> </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"> <section class="card">
<div class="eyebrow">Breakdown</div> <div class="eyebrow">Breakdown</div>
<h2>Status and level distribution</h2> <h2>Status and level distribution</h2>
@@ -389,6 +413,24 @@
</div> </div>
</section> </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"> <section class="card">
<div class="eyebrow">Projects</div> <div class="eyebrow">Projects</div>
<h2>Recent project records</h2> <h2>Recent project records</h2>
@@ -474,6 +516,10 @@
statusList: document.getElementById('statusList'), statusList: document.getElementById('statusList'),
levelList: document.getElementById('levelList'), levelList: document.getElementById('levelList'),
teacherList: document.getElementById('teacherList'), teacherList: document.getElementById('teacherList'),
attentionList: document.getElementById('attentionList'),
advisorList: document.getElementById('advisorList'),
collegeList: document.getElementById('collegeList'),
recentProjectList: document.getElementById('recentProjectList'),
sessionSummary: document.getElementById('sessionSummary') sessionSummary: document.getElementById('sessionSummary')
}; };
@@ -486,7 +532,13 @@
rejectedProjects: document.getElementById('rejectedProjects'), rejectedProjects: document.getElementById('rejectedProjects'),
nationalProjects: document.getElementById('nationalProjects'), nationalProjects: document.getElementById('nationalProjects'),
totalBudget: document.getElementById('totalBudget'), 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 = { const accountPresets = {
@@ -557,10 +609,14 @@
} }
async function loadStats() { async function loadStats() {
setStatus('Loading project statistics...'); setStatus('Loading dashboard insights...');
try { try {
const payload = await apiFetch('/api/projects/stats'); const [statsPayload, overviewPayload] = await Promise.all([
const stats = payload.data; apiFetch('/api/projects/stats'),
apiFetch('/api/projects/overview')
]);
const stats = statsPayload.data || {};
const overview = overviewPayload.data || {};
statRefs.totalProjects.textContent = stats.totalProjects ?? '-'; statRefs.totalProjects.textContent = stats.totalProjects ?? '-';
statRefs.draftProjects.textContent = stats.draftProjects ?? '-'; statRefs.draftProjects.textContent = stats.draftProjects ?? '-';
statRefs.pendingProjects.textContent = stats.pendingProjects ?? '-'; statRefs.pendingProjects.textContent = stats.pendingProjects ?? '-';
@@ -570,9 +626,19 @@
statRefs.nationalProjects.textContent = stats.nationalProjects ?? '-'; statRefs.nationalProjects.textContent = stats.nationalProjects ?? '-';
statRefs.totalBudget.textContent = formatMoney(stats.totalBudget); statRefs.totalBudget.textContent = formatMoney(stats.totalBudget);
statRefs.averageBudget.textContent = formatMoney(stats.averageBudget); 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.statusList, stats.statusBreakdown || []);
renderBucketList(refs.levelList, stats.levelBreakdown || []); 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) { } catch (error) {
setStatus(error.message, 'error'); setStatus(error.message, 'error');
} }
@@ -602,10 +668,14 @@
</td> </td>
<td> <td>
<strong>${escapeHtml(item.leaderName || 'Unassigned')}</strong><br /> <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>
<td>${escapeHtml(item.projectTypeLabel || '-')}</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>${escapeHtml(item.projectLevelLabel || '-')}</td>
<td>${formatDuration(item.startTime, item.endTime, item.durationDays)}</td> <td>${formatDuration(item.startTime, item.endTime, item.durationDays)}</td>
<td>${escapeHtml(item.college || '-')}</td> <td>${escapeHtml(item.college || '-')}</td>
@@ -763,9 +833,9 @@
return payload; return payload;
} }
function renderBucketList(target, items) { function renderBucketList(target, items, emptyMessage = 'No data') {
if (!items.length) { 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; return;
} }
target.innerHTML = items.map((item) => ` target.innerHTML = items.map((item) => `
@@ -776,6 +846,26 @@
`).join(''); `).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) { function renderTeacherList(items) {
if (!items.length) { if (!items.length) {
refs.teacherList.innerHTML = '<div class="list-item"><span>No teachers loaded</span><strong>-</strong></div>'; 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() { function applyPresetAccount() {
const preset = accountPresets[refs.accountPreset.value] || accountPresets.admin; const preset = accountPresets[refs.accountPreset.value] || accountPresets.admin;
refs.username.value = preset.username; refs.username.value = preset.username;

View File

@@ -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();
}
}

View 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