diff --git a/README.md b/README.md index 7b1ec50..41c85cf 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/backend/pom.xml b/backend/pom.xml index dc28c09..0a09911 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -71,6 +71,11 @@ spring-boot-starter-test test + + com.h2database + h2 + test + diff --git a/backend/src/main/java/com/innovation/platform/config/MybatisPlusConfig.java b/backend/src/main/java/com/innovation/platform/config/MybatisPlusConfig.java new file mode 100644 index 0000000..8f010e7 --- /dev/null +++ b/backend/src/main/java/com/innovation/platform/config/MybatisPlusConfig.java @@ -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; + } +} diff --git a/backend/src/main/java/com/innovation/platform/controller/ProjectController.java b/backend/src/main/java/com/innovation/platform/controller/ProjectController.java index e2d3ce8..2ed9636 100644 --- a/backend/src/main/java/com/innovation/platform/controller/ProjectController.java +++ b/backend/src/main/java/com/innovation/platform/controller/ProjectController.java @@ -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 overview() { + return Result.success(projectService.overview()); + } + @Operation(summary = "Get project detail by id") @GetMapping("/{id}") public Result getById(@Parameter(description = "Project id") @PathVariable Long id) { diff --git a/backend/src/main/java/com/innovation/platform/dto/ProjectOverviewResponse.java b/backend/src/main/java/com/innovation/platform/dto/ProjectOverviewResponse.java new file mode 100644 index 0000000..1c5889c --- /dev/null +++ b/backend/src/main/java/com/innovation/platform/dto/ProjectOverviewResponse.java @@ -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 collegeBreakdown; + private List advisorWorkload; + private List attentionProjects; + private List recentProjects; +} diff --git a/backend/src/main/java/com/innovation/platform/dto/ProjectResponse.java b/backend/src/main/java/com/innovation/platform/dto/ProjectResponse.java index 40b16a0..6d5c7c5 100644 --- a/backend/src/main/java/com/innovation/platform/dto/ProjectResponse.java +++ b/backend/src/main/java/com/innovation/platform/dto/ProjectResponse.java @@ -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 userNames) { + return fromEntity(project, userNames, Map.of(), LocalDate.now()); + } + + public static ProjectResponse fromEntity( + Project project, + Map userNames, + Map 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 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; + } } diff --git a/backend/src/main/java/com/innovation/platform/mapper/ProjectMemberMapper.java b/backend/src/main/java/com/innovation/platform/mapper/ProjectMemberMapper.java new file mode 100644 index 0000000..b12391c --- /dev/null +++ b/backend/src/main/java/com/innovation/platform/mapper/ProjectMemberMapper.java @@ -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 { +} diff --git a/backend/src/main/java/com/innovation/platform/service/ProjectService.java b/backend/src/main/java/com/innovation/platform/service/ProjectService.java index e470bf0..d189c47 100644 --- a/backend/src/main/java/com/innovation/platform/service/ProjectService.java +++ b/backend/src/main/java/com/innovation/platform/service/ProjectService.java @@ -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(); } diff --git a/backend/src/main/java/com/innovation/platform/service/impl/ProjectServiceImpl.java b/backend/src/main/java/com/innovation/platform/service/impl/ProjectServiceImpl.java index b74763f..94a5c76 100644 --- a/backend/src/main/java/com/innovation/platform/service/impl/ProjectServiceImpl.java +++ b/backend/src/main/java/com/innovation/platform/service/impl/ProjectServiceImpl.java @@ -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 projectPage = projectMapper.selectPage(page, wrapper); Map userNames = loadUserNames(projectPage.getRecords()); - return projectPage.convert(project -> ProjectResponse.fromEntity(project, userNames)); + Map 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 projects = projectMapper.selectList( + new LambdaQueryWrapper() + .orderByDesc(Project::getUpdateTime) + .orderByDesc(Project::getId) + ); + + Map userNames = loadUserNames(projects); + Map memberCounts = loadMemberCounts(projects); + + List 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 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 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 topBuckets(Map source, int limit) { + return source.entrySet().stream() + .sorted(Map.Entry.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 loadUserNames(List projects) { List 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 loadMemberCounts(List projects) { + List projectIds = projects.stream() + .map(Project::getId) + .filter(Objects::nonNull) + .distinct() + .toList(); + + if (projectIds.isEmpty()) { + return Collections.emptyMap(); + } + + return projectMemberMapper.selectList( + new LambdaQueryWrapper() + .in(ProjectMember::getProjectId, projectIds) + .eq(ProjectMember::getDeleted, 0) + ).stream() + .collect(Collectors.groupingBy(ProjectMember::getProjectId, Collectors.counting())); + } + + private long resolvedMemberCount(Project project, Map memberCounts) { + long count = memberCounts.getOrDefault(project.getId(), 0L); + if (count == 0 && project.getLeaderId() != null) { + return 1L; + } + return count; + } } diff --git a/backend/src/main/resources/data.sql b/backend/src/main/resources/data.sql index 8380a3e..ae08f20 100644 --- a/backend/src/main/resources/data.sql +++ b/backend/src/main/resources/data.sql @@ -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 diff --git a/backend/src/main/resources/schema.sql b/backend/src/main/resources/schema.sql index ca7db21..1b64657 100644 --- a/backend/src/main/resources/schema.sql +++ b/backend/src/main/resources/schema.sql @@ -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 diff --git a/backend/src/main/resources/static/index.html b/backend/src/main/resources/static/index.html index 0e07d11..c59c19f 100644 --- a/backend/src/main/resources/static/index.html +++ b/backend/src/main/resources/static/index.html @@ -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 @@
Use satoken header Supports quick login - Reads stats and project pages + Reads live overview insights Includes user management
@@ -243,7 +254,7 @@
- +
Ready. Request a token or paste one manually, then load dashboard data.
@@ -380,6 +391,19 @@ +
+
Operations
+

Delivery radar

+
+
Overdue-
+
Ending in 30 days-
+
Starting in 30 days-
+
Active leaders-
+
Active advisors-
+
Total members-
+
+
+
Breakdown

Status and level distribution

@@ -389,6 +413,24 @@
+
+
Actions
+

Attention queue and advisor workload

+
+
+
+
+
+ +
+
Portfolio
+

College footprint and recent updates

+
+
+
+
+
+
Projects

Recent project records

@@ -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 @@ ${escapeHtml(item.leaderName || 'Unassigned')}
- Advisor: ${escapeHtml(item.advisorName || '-')} + Advisor: ${escapeHtml(item.advisorName || '-')}
+ Team: ${item.memberCount ?? 0} member(s) ${escapeHtml(item.projectTypeLabel || '-')} - ${escapeHtml(item.statusLabel || '-')} + + ${renderPill(item.statusLabel || '-', toneForHealth(item.timelineHealthKey))} + ${escapeHtml(item.timelineHealthLabel || '-')} + ${escapeHtml(item.projectLevelLabel || '-')} ${formatDuration(item.startTime, item.endTime, item.durationDays)} ${escapeHtml(item.college || '-')} @@ -763,9 +833,9 @@ return payload; } - function renderBucketList(target, items) { + function renderBucketList(target, items, emptyMessage = 'No data') { if (!items.length) { - target.innerHTML = '
No data-
'; + target.innerHTML = `
${escapeHtml(emptyMessage)}-
`; return; } target.innerHTML = items.map((item) => ` @@ -776,6 +846,26 @@ `).join(''); } + function renderProjectHighlights(target, items, emptyMessage) { + if (!items.length) { + target.innerHTML = `
${escapeHtml(emptyMessage)}-
`; + return; + } + target.innerHTML = items.map((item) => ` +
+ + ${escapeHtml(item.projectName || 'Project')}
+ ${escapeHtml(item.projectNo || '-')} · ${escapeHtml(item.leaderName || 'Unassigned')} + ${escapeHtml(item.college || '-')} · ${item.memberCount ?? 0} member(s) +
+ + ${renderPill(item.timelineHealthLabel || 'Monitor', toneForHealth(item.timelineHealthKey))} + ${escapeHtml(item.endTime || item.startTime || '-')} + +
+ `).join(''); + } + function renderTeacherList(items) { if (!items.length) { refs.teacherList.innerHTML = '
No teachers loaded-
'; @@ -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 `${escapeHtml(label)}`; + } + function applyPresetAccount() { const preset = accountPresets[refs.accountPreset.value] || accountPresets.admin; refs.username.value = preset.username; diff --git a/backend/src/test/java/com/innovation/platform/InnovationPlatformIntegrationTest.java b/backend/src/test/java/com/innovation/platform/InnovationPlatformIntegrationTest.java new file mode 100644 index 0000000..23653e2 --- /dev/null +++ b/backend/src/test/java/com/innovation/platform/InnovationPlatformIntegrationTest.java @@ -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(); + } +} diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml new file mode 100644 index 0000000..9366b70 --- /dev/null +++ b/backend/src/test/resources/application-test.yml @@ -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