feat(ui): integrate attendance pages with shared shell
This commit is contained in:
@@ -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.
|
||||
@@ -119,6 +119,8 @@
|
||||
.form-section legend { padding: 0 .35rem; font-weight: 700; }
|
||||
.field-help { margin: 0 0 .8rem; color: var(--muted); font-size: .78rem; }
|
||||
.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; }
|
||||
.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); }
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,42 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Global calendar</title>
|
||||
</head>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{fragments/layout :: shell(
|
||||
pageTitle='Global calendar',
|
||||
section='Admin',
|
||||
activeNav='calendar',
|
||||
primaryAction=~{::#primary-action},
|
||||
content=~{::main})}">
|
||||
<body>
|
||||
<a id="primary-action" class="button" th:href="@{/dashboard}">Back to overview</a>
|
||||
<main>
|
||||
<h1>Global calendar</h1>
|
||||
<p th:if="${message}" role="status" th:text="${message}"></p>
|
||||
<p class="page-description">Maintain the global events and day-off rules applied to attendance.</p>
|
||||
<p class="alert" th:if="${message}" role="status" th:text="${message}"></p>
|
||||
|
||||
<form method="post" th:action="@{/attendance/calendar}">
|
||||
<label for="date">Date</label>
|
||||
<input id="date" name="date" type="date" required th:min="${today}">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" type="text" maxlength="200" required>
|
||||
<label><input name="dayOff" type="checkbox" value="true"> Day off</label>
|
||||
<button type="submit">Add event</button>
|
||||
<form class="panel form-panel form-grid" method="post" th:action="@{/attendance/calendar}">
|
||||
<div class="form-grid form-grid-three">
|
||||
<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>
|
||||
<div class="field"><label class="field-label" for="name">Name</label><input class="control" id="name" name="name" type="text" maxlength="200" required></div>
|
||||
<label class="checkbox"><input name="dayOff" type="checkbox" value="true"> <span>Day off</span></label>
|
||||
</div>
|
||||
<div class="form-actions"><button class="button button-primary" type="submit">Add event</button></div>
|
||||
</form>
|
||||
|
||||
<p th:if="${#lists.isEmpty(events)}">No upcoming calendar events.</p>
|
||||
<table th:unless="${#lists.isEmpty(events)}">
|
||||
<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>
|
||||
<div class="panel table-scroll" th:unless="${#lists.isEmpty(events)}"><table class="data-table">
|
||||
<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>
|
||||
<tbody>
|
||||
<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 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="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="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>
|
||||
<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}">
|
||||
<button type="submit">Save</button>
|
||||
<button class="button" type="submit">Save</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table></div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Attendance history</title>
|
||||
</head>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org"
|
||||
th:replace="~{fragments/layout :: shell(
|
||||
pageTitle=${ownHistory ? 'My attendance' : 'Intern attendance'},
|
||||
section=${ownHistory ? 'Intern' : 'Mentor'},
|
||||
activeNav='attendance',
|
||||
primaryAction=~{::#primary-action},
|
||||
content=~{::main})}">
|
||||
<body>
|
||||
<a id="primary-action" class="button" th:href="@{/dashboard}">Back to overview</a>
|
||||
<main>
|
||||
<h1 th:text="${ownHistory} ? 'My attendance' : 'Intern attendance'">Attendance</h1>
|
||||
<p th:if="${message}" role="status" th:text="${message}"></p>
|
||||
<p th:if="${error}" role="alert" th:text="${error}"></p>
|
||||
<p class="page-description">Review recorded punches and the policy snapshot applied to each work date.</p>
|
||||
<p class="alert" th:if="${message}" role="status" th:text="${message}"></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}">
|
||||
<button type="submit">Check in</button>
|
||||
</form>
|
||||
<form th:if="${ownHistory}" method="post" th:action="@{/attendance/check-out}">
|
||||
<button type="submit">Check out</button>
|
||||
<div class="inline-actions" th:if="${ownHistory}">
|
||||
<form method="post" th:action="@{/attendance/check-in}"><button class="button button-primary" type="submit">Check in</button></form>
|
||||
<form method="post" th:action="@{/attendance/check-out}"><button class="button" type="submit">Check out</button></form>
|
||||
</div>
|
||||
|
||||
<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 method="get">
|
||||
<label for="from">From</label>
|
||||
<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)}">
|
||||
<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>
|
||||
<div class="panel table-scroll" th:unless="${#lists.isEmpty(items)}"><table class="data-table">
|
||||
<caption>Attendance records and applied policy</caption>
|
||||
<thead>
|
||||
<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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table></div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -22,10 +22,10 @@
|
||||
<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>
|
||||
<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="@{/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('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('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}" 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="@{/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>
|
||||
|
||||
+73
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user