feat(ui): integrate attendance pages with shared shell

This commit is contained in:
sechmachine
2026-08-15 01:16:43 +07:00
parent f8db8a3e8b
commit 3064485007
7 changed files with 205 additions and 50 deletions
@@ -0,0 +1,80 @@
# Test Evidence: attendance shell integration
- **Test type:** Web
- **Requirement IDs:** `UI-001`, `UI-002`, `UI-003`, `UI-008`, `I1-ATT-03`, `I1-UI-04`
- **Scenario IDs:** `AC-ATT-003`, `AC-ATT-004`, `AC-UI-001`, `AC-UI-005`
- **Test class/method:** `com.lab.labtimesheet.feature.reporting.controller.AttendanceTemplateIntegrationTest`
- **Implementation commit:** `pending`
## Protected behavior
The Intern attendance-history and Admin global-calendar pages consume the role-aware shared shell while preserving their existing routes, CSRF-protected mutation forms, filter values, empty states, and local theme assets.
## Test method
A focused MockMvc slice supplies empty production-shaped models to the two production Attendance templates and renders them with role-specific Spring Security principals. The owning feature's `AttendanceControllerTest` remains the affected behavioral suite for authorization, punch actions, calendar mutation, and view selection.
## Hand-derived expected result
Both responses contain `app-shell` and `/assets/theme.js`. Intern history posts to `/attendance/check-in` and `/attendance/check-out` and renders its empty period state. Admin calendar posts to `/attendance/calendar` and renders its empty upcoming-events state. The shell highlights the real attendance/calendar route for the current role.
## RED
**Command**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceTemplateIntegrationTest test
```
**Observed result**
```text
Tests run: 2, Failures: 2, Errors: 0, Skipped: 0
AttendanceTemplateIntegrationTest.adminCalendarUsesSharedShellAndPreservesEventForm expected class="app-shell"
AttendanceTemplateIntegrationTest.internHistoryUsesSharedShellAndPreservesPunchActions expected class="app-shell"
BUILD FAILURE
Total time: 4.977 s
```
Both Attendance templates were standalone HTML documents.
## GREEN
**Command**
```text
export PATH="/opt/homebrew/opt/node@24/bin:$PATH"
npm run build
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceTemplateIntegrationTest test
```
**Observed result**
```text
Tailwind CSS v4.3.3: Done in 72ms
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 3.832 s
```
## Affected suite
**Command and result**
```text
export JAVA_HOME=/opt/homebrew/opt/openjdk@25
export PATH="/opt/homebrew/opt/node@24/bin:$JAVA_HOME/bin:$PATH"
./mvnw -Dtest=AttendanceTemplateIntegrationTest,AttendanceControllerTest test
Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Total time: 3.974 s
```
## External-test boundaries
The checks prove server-rendered shell integration and preserve the owning controller's tested contracts. They do not exercise PostgreSQL attendance persistence, live punch timing, browser overflow, or the visual state of populated editable calendar rows; those remain covered by the Attendance feature suite and final integrated UI checks.
+2
View File
@@ -119,6 +119,8 @@
.form-section legend { padding: 0 .35rem; font-weight: 700; } .form-section legend { padding: 0 .35rem; font-weight: 700; }
.field-help { margin: 0 0 .8rem; color: var(--muted); font-size: .78rem; } .field-help { margin: 0 0 .8rem; color: var(--muted); font-size: .78rem; }
.form-actions { display: flex; justify-content: flex-end; gap: .6rem; } .form-actions { display: flex; justify-content: flex-end; gap: .6rem; }
.inline-actions { display: flex; gap: .6rem; margin: 1rem 0; }
.filter-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; align-items: end; gap: .8rem; margin: 1rem 0; }
.field-label { font-size: .78rem; font-weight: 650; } .field-label { font-size: .78rem; font-weight: 650; }
.control { min-height: 2.45rem; width: 100%; border: 1px solid var(--border-strong); border-radius: .5rem; background: var(--panel); color: var(--ink); padding: .55rem .65rem; } .control { min-height: 2.45rem; width: 100%; border: 1px solid var(--border-strong); border-radius: .5rem; background: var(--panel); color: var(--ink); padding: .55rem .65rem; }
.control[aria-invalid="true"] { border-color: var(--danger); } .control[aria-invalid="true"] { border-color: var(--danger); }
File diff suppressed because one or more lines are too long
@@ -1,42 +1,44 @@
<!doctype html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html lang="en" xmlns:th="http://www.thymeleaf.org"
<head> th:replace="~{fragments/layout :: shell(
<meta charset="utf-8"> pageTitle='Global calendar',
<meta name="viewport" content="width=device-width, initial-scale=1"> section='Admin',
<title>Global calendar</title> activeNav='calendar',
</head> primaryAction=~{::#primary-action},
content=~{::main})}">
<body> <body>
<a id="primary-action" class="button" th:href="@{/dashboard}">Back to overview</a>
<main> <main>
<h1>Global calendar</h1> <p class="page-description">Maintain the global events and day-off rules applied to attendance.</p>
<p th:if="${message}" role="status" th:text="${message}"></p> <p class="alert" th:if="${message}" role="status" th:text="${message}"></p>
<form method="post" th:action="@{/attendance/calendar}"> <form class="panel form-panel form-grid" method="post" th:action="@{/attendance/calendar}">
<label for="date">Date</label> <div class="form-grid form-grid-three">
<input id="date" name="date" type="date" required th:min="${today}"> <div class="field"><label class="field-label" for="date">Date</label><input class="control" id="date" name="date" type="date" required th:min="${today}"></div>
<label for="name">Name</label> <div class="field"><label class="field-label" for="name">Name</label><input class="control" id="name" name="name" type="text" maxlength="200" required></div>
<input id="name" name="name" type="text" maxlength="200" required> <label class="checkbox"><input name="dayOff" type="checkbox" value="true"> <span>Day off</span></label>
<label><input name="dayOff" type="checkbox" value="true"> Day off</label> </div>
<button type="submit">Add event</button> <div class="form-actions"><button class="button button-primary" type="submit">Add event</button></div>
</form> </form>
<p th:if="${#lists.isEmpty(events)}">No upcoming calendar events.</p> <section class="panel" th:if="${#lists.isEmpty(events)}"><th:block th:replace="~{fragments/components :: empty('No upcoming calendar events', 'Added events and global days off will appear here.')} "></th:block></section>
<table th:unless="${#lists.isEmpty(events)}"> <div class="panel table-scroll" th:unless="${#lists.isEmpty(events)}"><table class="data-table">
<caption>Upcoming global events</caption> <caption>Upcoming global events</caption>
<thead><tr><th scope="col">Date</th><th scope="col">Name</th><th scope="col">Day off</th><th scope="col">Save</th></tr></thead> <thead><tr><th scope="col">Date</th><th scope="col">Name</th><th scope="col">Day off</th><th scope="col">Save</th></tr></thead>
<tbody> <tbody>
<tr th:each="event : ${events}"> <tr th:each="event : ${events}">
<td><input name="date" type="date" required th:min="${today}" th:value="${event.date}" th:attr="form=|event-${event.id}|" aria-label="Event date"></td> <td><input class="control" name="date" type="date" required th:min="${today}" th:value="${event.date}" th:attr="form=|event-${event.id}|" aria-label="Event date"></td>
<td><input name="name" type="text" maxlength="200" required th:value="${event.name}" th:attr="form=|event-${event.id}|" aria-label="Event name"></td> <td><input class="control" name="name" type="text" maxlength="200" required th:value="${event.name}" th:attr="form=|event-${event.id}|" aria-label="Event name"></td>
<td><input name="dayOff" type="checkbox" value="true" th:checked="${event.dayOff}" th:attr="form=|event-${event.id}|" aria-label="Day off"></td> <td><input name="dayOff" type="checkbox" value="true" th:checked="${event.dayOff}" th:attr="form=|event-${event.id}|" aria-label="Day off"></td>
<td> <td>
<form method="post" th:id="|event-${event.id}|" th:action="@{/attendance/calendar/{id}(id=${event.id})}"> <form method="post" th:id="|event-${event.id}|" th:action="@{/attendance/calendar/{id}(id=${event.id})}">
<input name="version" type="hidden" th:value="${event.version}"> <input name="version" type="hidden" th:value="${event.version}">
<button type="submit">Save</button> <button class="button" type="submit">Save</button>
</form> </form>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table></div>
</main> </main>
</body> </body>
</html> </html>
@@ -1,33 +1,31 @@
<!doctype html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html lang="en" xmlns:th="http://www.thymeleaf.org"
<head> th:replace="~{fragments/layout :: shell(
<meta charset="utf-8"> pageTitle=${ownHistory ? 'My attendance' : 'Intern attendance'},
<meta name="viewport" content="width=device-width, initial-scale=1"> section=${ownHistory ? 'Intern' : 'Mentor'},
<title>Attendance history</title> activeNav='attendance',
</head> primaryAction=~{::#primary-action},
content=~{::main})}">
<body> <body>
<a id="primary-action" class="button" th:href="@{/dashboard}">Back to overview</a>
<main> <main>
<h1 th:text="${ownHistory} ? 'My attendance' : 'Intern attendance'">Attendance</h1> <p class="page-description">Review recorded punches and the policy snapshot applied to each work date.</p>
<p th:if="${message}" role="status" th:text="${message}"></p> <p class="alert" th:if="${message}" role="status" th:text="${message}"></p>
<p th:if="${error}" role="alert" th:text="${error}"></p> <p class="alert alert-error" th:if="${error}" role="alert" th:text="${error}"></p>
<form th:if="${ownHistory}" method="post" th:action="@{/attendance/check-in}"> <div class="inline-actions" th:if="${ownHistory}">
<button type="submit">Check in</button> <form method="post" th:action="@{/attendance/check-in}"><button class="button button-primary" type="submit">Check in</button></form>
</form> <form method="post" th:action="@{/attendance/check-out}"><button class="button" type="submit">Check out</button></form>
<form th:if="${ownHistory}" method="post" th:action="@{/attendance/check-out}"> </div>
<button type="submit">Check out</button>
<form class="panel form-panel filter-form" method="get">
<div class="field"><label class="field-label" for="from">From</label><input class="control" id="from" name="from" type="date" th:value="${from}"></div>
<div class="field"><label class="field-label" for="to">To</label><input class="control" id="to" name="to" type="date" th:value="${to}"></div>
<button class="button" type="submit">Filter</button>
</form> </form>
<form method="get"> <section class="panel" th:if="${#lists.isEmpty(items)}"><th:block th:replace="~{fragments/components :: empty('No attendance records in this period', 'Choose another date range or record your first punch.')} "></th:block></section>
<label for="from">From</label> <div class="panel table-scroll" th:unless="${#lists.isEmpty(items)}"><table class="data-table">
<input id="from" name="from" type="date" th:value="${from}">
<label for="to">To</label>
<input id="to" name="to" type="date" th:value="${to}">
<button type="submit">Filter</button>
</form>
<p th:if="${#lists.isEmpty(items)}">No attendance records in this period.</p>
<table th:unless="${#lists.isEmpty(items)}">
<caption>Attendance records and applied policy</caption> <caption>Attendance records and applied policy</caption>
<thead> <thead>
<tr> <tr>
@@ -49,7 +47,7 @@
<td th:text="${item.violations.missingCheckout ? 'Missing checkout' : (item.violations.earlyDeparture ? 'Early departure' : (item.violations.late ? 'Late' : 'On time'))}"></td> <td th:text="${item.violations.missingCheckout ? 'Missing checkout' : (item.violations.earlyDeparture ? 'Early departure' : (item.violations.late ? 'Late' : 'On time'))}"></td>
</tr> </tr>
</tbody> </tbody>
</table> </table></div>
</main> </main>
</body> </body>
</html> </html>
@@ -22,10 +22,10 @@
<li><a class="nav-link" th:href="@{/dashboard}" th:attr="aria-current=${activeNav == 'dashboard'} ? 'page' : null"> <li><a class="nav-link" th:href="@{/dashboard}" th:attr="aria-current=${activeNav == 'dashboard'} ? 'page' : null">
<svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#layout-dashboard}"></use></svg><span class="sidebar-label">Overview</span></a></li> <svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#layout-dashboard}"></use></svg><span class="sidebar-label">Overview</span></a></li>
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/admin/accounts/new}" th:attr="aria-current=${activeNav == 'accounts'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#users}"></use></svg><span class="sidebar-label">Accounts</span></a></li> <li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/admin/accounts/new}" th:attr="aria-current=${activeNav == 'accounts'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#users}"></use></svg><span class="sidebar-label">Accounts</span></a></li>
<li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/admin/calendar}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#calendar-days}"></use></svg><span class="sidebar-label">Global calendar</span></a></li> <li sec:authorize="hasRole('ADMIN')"><a class="nav-link" th:href="@{/attendance/calendar}" th:attr="aria-current=${activeNav == 'calendar'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#calendar-days}"></use></svg><span class="sidebar-label">Global calendar</span></a></li>
<li sec:authorize="hasRole('MENTOR')"><a class="nav-link" th:href="@{/projects}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">Owned Projects</span></a></li> <li sec:authorize="hasRole('MENTOR')"><a class="nav-link" th:href="@{/projects}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">Owned Projects</span></a></li>
<li sec:authorize="hasRole('MENTOR')"><a class="nav-link" th:href="@{/attendance}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">Intern attendance</span></a></li> <li sec:authorize="hasRole('MENTOR')"><a class="nav-link" th:href="@{/attendance}" th:attr="aria-current=${activeNav == 'attendance'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">Intern attendance</span></a></li>
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/attendance/me}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">My attendance</span></a></li> <li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/attendance/me}" th:attr="aria-current=${activeNav == 'attendance'} ? 'page' : null"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#clock}"></use></svg><span class="sidebar-label">My attendance</span></a></li>
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/projects}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">My Projects</span></a></li> <li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/projects}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#folder-kanban}"></use></svg><span class="sidebar-label">My Projects</span></a></li>
<li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/tasks}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#list-check}"></use></svg><span class="sidebar-label">Assigned Tasks</span></a></li> <li sec:authorize="hasRole('INTERN')"><a class="nav-link" th:href="@{/tasks}"><svg class="nav-icon" aria-hidden="true"><use th:href="@{/assets/icons.svg#list-check}"></use></svg><span class="sidebar-label">Assigned Tasks</span></a></li>
</ul> </ul>
@@ -0,0 +1,73 @@
package com.lab.labtimesheet.feature.reporting.controller;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.stereotype.Controller;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@WebMvcTest(AttendanceTemplateIntegrationTest.TemplateController.class)
@Import(AttendanceTemplateIntegrationTest.TemplateController.class)
class AttendanceTemplateIntegrationTest {
private final MockMvc mvc;
@Autowired
AttendanceTemplateIntegrationTest(MockMvc mvc) {
this.mvc = mvc;
}
@Test
void internHistoryUsesSharedShellAndPreservesPunchActions() throws Exception {
mvc.perform(get("/template-contract/attendance/history")
.with(user("intern@example.test").roles("INTERN")))
.andExpect(status().isOk())
.andExpect(content().string(containsString("class=\"app-shell\"")))
.andExpect(content().string(containsString("action=\"/attendance/check-in\"")))
.andExpect(content().string(containsString("action=\"/attendance/check-out\"")))
.andExpect(content().string(containsString("No attendance records in this period")))
.andExpect(content().string(containsString("src=\"/assets/theme.js\"")));
}
@Test
void adminCalendarUsesSharedShellAndPreservesEventForm() throws Exception {
mvc.perform(get("/template-contract/attendance/calendar")
.with(user("admin@example.test").roles("ADMIN")))
.andExpect(status().isOk())
.andExpect(content().string(containsString("class=\"app-shell\"")))
.andExpect(content().string(containsString("action=\"/attendance/calendar\"")))
.andExpect(content().string(containsString("No upcoming calendar events")))
.andExpect(content().string(containsString("src=\"/assets/theme.js\"")));
}
@Controller
public static class TemplateController {
@GetMapping("/template-contract/attendance/history")
String history(Model model) {
model.addAttribute("ownHistory", true);
model.addAttribute("from", LocalDate.of(2026, 8, 1));
model.addAttribute("to", LocalDate.of(2026, 8, 31));
model.addAttribute("items", List.of());
return "attendance/history";
}
@GetMapping("/template-contract/attendance/calendar")
String calendar(Model model) {
model.addAttribute("today", LocalDate.of(2026, 8, 15));
model.addAttribute("events", List.of());
return "attendance/calendar";
}
}
}