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
+2
View File
@@ -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>
@@ -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";
}
}
}