feat(ui): integrate attendance pages with shared shell
This commit is contained in:
@@ -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