Ready. Request a token or paste one manually, then load dashboard data.
@@ -380,6 +391,19 @@
+
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