diff --git a/docs/tests/web/project-intern-picker.md b/docs/tests/web/project-intern-picker.md new file mode 100644 index 0000000..5925541 --- /dev/null +++ b/docs/tests/web/project-intern-picker.md @@ -0,0 +1,40 @@ +# Web Test Evidence + +## Requirement and scenario IDs + +- AUTH-001, AUTH-002, AUTH-011; PRJ-001, PRJ-004, PRJ-005, PRJ-006, PRJ-017; UI-001, UI-005, UI-014, UI-018; TST-001 through TST-010. +- AC-AUTH-001, AC-AUTH-010, AC-PRJ-001, AC-PRJ-003, AC-PRJ-009, AC-UI-005, AC-TST-001. + +## Behavior under test + +Project creation, direct member addition, and leadership reassignment render only server-provided eligible Intern choices. The native dialog picker exposes name, student code, and internship dates while numeric identifiers remain form values rather than visible labels. Local search, selection summaries, focus, apply, cancel, empty results, and retained server errors remain usable without adding a client API. + +## Expected result derivation + +The expected options are literal fixtures from the Account public DTO. Project membership history independently determines which eligible users are valid nonmembers or current-member leadership candidates. Native dialog controls keep server forms and CSRF as the mutation boundary. + +## RED + +`env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run test:ui` executed the dependency-free interaction contract first: 1 test, 1 failure. Opening the picker left `dialog.open` undefined because no picker behavior existed. + +The combined Java RED command was `env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest,ProjectServiceIntegrationTest' test`. After correcting test-only assertion imports, test compilation failed only because the requested `ProjectService.addMembers(long,long,List)` API did not exist. Controller rendering RED will be rerun after that producer API compiles. + +After the producer API compiled, `env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest' test` ran 19 tests with 4 expected assertion failures for the missing eligible-option model, filtered multi-select markup, and retained selection rendering. A separate no-roster regression ran 1 test with 1 assertion failure because the disabled picker trigger had no reachable explanatory copy. + +## GREEN + +`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest' test` passed the initial rendered picker suite at 19/19. After adding the no-roster regression, the affected Project command below passed the expanded controller suite at 20/20. + +`env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run test:ui` passed 1/1 executable tests with no failures, proving local name/student-code filtering, summary updates, initial search focus, apply retention, cancel rollback, and opener focus restoration. + +`env PATH=/opt/homebrew/opt/node@24/bin:$PATH npm run build` succeeded with Tailwind CSS 4.3.3 and the existing local icon builder. No dependency was added. + +## Affected suite + +`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH DOCKER_HOST=unix:///Users/sechmachine/.orbstack/run/docker.sock ./mvnw '-Dtest=ProjectControllerTest,ProjectEntityTest,ProjectPersistenceStructureTest,ProjectServiceIntegrationTest,ProjectTaskMutationContextTest,LayerStructureTest' test` passed 38/38 tests with no failures, errors, or skips. + +`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw -DskipTests compile` succeeded. Project-scoped `javadoc:javadoc` with `-Ddoclint=all` succeeded; it retained four non-fatal default-constructor warnings, including pre-existing advice/query types. `git diff --check` passed. + +## External boundaries + +No browser loop or Impeccable detector is run on this branch; the root owner performs one integrated pass. MockMvc proves rendered semantics and a dependency-free Node test executes the dialog/search/selection behavior with controlled DOM boundaries. These pre-merge results will be rerun after merging the taskmaster-specified exact `main` SHA. diff --git a/package.json b/package.json index ae3df74..c28b60a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "scripts": { "build": "npm run build:css && npm run build:icons", "build:css": "tailwindcss -i src/main/frontend/app.css -o src/main/resources/static/assets/app.css --minify", - "build:icons": "node src/main/frontend/build-icons.mjs" + "build:icons": "node src/main/frontend/build-icons.mjs", + "test:ui": "node --test src/test/js/*.test.mjs" }, "devDependencies": { "@tailwindcss/cli": "4.3.3", diff --git a/src/main/frontend/app.css b/src/main/frontend/app.css index 7fd935b..4ff7cb6 100644 --- a/src/main/frontend/app.css +++ b/src/main/frontend/app.css @@ -120,6 +120,7 @@ .primary-action { margin-left: auto; } .button { display: inline-flex; min-height: 2.35rem; align-items: center; justify-content: center; gap: .45rem; border: 1px solid var(--border-strong); border-radius: .5rem; padding: .5rem .8rem; background: var(--panel); color: var(--ink); font-weight: 650; text-decoration: none; cursor: pointer; } .button-primary { border-color: var(--ink); background: var(--ink); color: var(--panel); } + .button:disabled { cursor: not-allowed; opacity: .55; } .button-danger { border-color: color-mix(in srgb, var(--danger), transparent 65%); background: color-mix(in srgb, var(--danger), transparent 90%); color: var(--danger); } .panel { border: 1px solid var(--border); border-radius: .75rem; background: var(--panel); box-shadow: 0 10px 28px rgb(20 25 35 / .06); } .panel-header { padding: .9rem 1rem; border-bottom: 1px solid var(--border); } @@ -170,6 +171,21 @@ .notification-menu { min-width: 18rem; padding: .75rem; } dialog { max-width: 30rem; border: 1px solid var(--border); border-radius: .9rem; background: var(--panel); color: var(--ink); padding: 1.25rem; } dialog::backdrop { background: rgb(0 0 0 / .45); } + .picker-trigger { justify-content: flex-start; } + .picker-summary { margin: 0; color: var(--muted); font-size: .78rem; } + .picker-drawer { width: min(32rem, 100%); max-width: 32rem; height: 100dvh; max-height: 100dvh; margin: 0 0 0 auto; border-radius: .9rem 0 0 .9rem; padding: 0; } + .picker-header, .picker-footer { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; } + .picker-header { border-bottom: 1px solid var(--border); } + .picker-header .field-help { margin: .2rem 0 0; } + .picker-body { display: grid; gap: .5rem; padding: 1rem; } + .picker-options { display: grid; gap: .5rem; margin-top: .5rem; } + .picker-option { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: .75rem; border: 1px solid var(--border); border-radius: .65rem; padding: .75rem; cursor: pointer; } + .picker-option:hover { border-color: var(--border-strong); background: var(--panel-muted); } + .picker-option input { margin-top: .2rem; } + .picker-option span { display: grid; gap: .18rem; min-width: 0; } + .picker-option small, .picker-empty { color: var(--muted); } + .picker-empty { margin: 1rem 0; text-align: center; } + .picker-footer { border-top: 1px solid var(--border); justify-content: flex-end; } @keyframes pulse { 50% { opacity: .45; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; } } } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java index feacced..3f713b8 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/controller/ProjectController.java @@ -1,13 +1,21 @@ package com.lab.labtimesheet.feature.project.controller; +import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption; +import com.lab.labtimesheet.feature.account.service.AccountService; import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException; import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateForm; import com.lab.labtimesheet.feature.project.model.dto.ProjectMemberForm; +import com.lab.labtimesheet.feature.project.model.dto.ProjectMembersForm; import com.lab.labtimesheet.feature.project.service.ProjectQueryService; import com.lab.labtimesheet.feature.project.service.ProjectService; import jakarta.validation.Valid; import java.security.Principal; +import java.time.Clock; +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -33,6 +41,8 @@ public class ProjectController { private final ProjectQueryService pages; private final ProjectService projects; + private final AccountService accounts; + private final Clock clock; /** * Lists only Projects visible to the authenticated actor and exposes Project creation only @@ -64,6 +74,7 @@ public class ProjectController { throw new ProjectAccessDeniedException(); } model.addAttribute("projectForm", new ProjectCreateForm()); + model.addAttribute("eligibleInternOptions", eligibleInternOptions()); return "projects/form"; } @@ -73,22 +84,30 @@ public class ProjectController { * @param principal authenticated user * @param projectForm validated browser input * @param bindingResult binding and domain validation results + * @param model response model used when validation fails * @return a redirect to the created Project, or the creation form on validation failure */ @PostMapping public String create( Principal principal, @Valid @ModelAttribute("projectForm") ProjectCreateForm projectForm, - BindingResult bindingResult) { + BindingResult bindingResult, + Model model) { + var actor = pages.authenticatedActor(principal.getName()); + if (!"MENTOR".equals(actor.role())) { + throw new ProjectAccessDeniedException(); + } if (bindingResult.hasErrors()) { + model.addAttribute("eligibleInternOptions", eligibleInternOptions()); return "projects/form"; } try { - long projectId = projects.create(actorId(principal), projectForm.toCommand()); + long projectId = projects.create(actor.userId(), projectForm.toCommand()); return "redirect:/projects/" + projectId; } catch (ProjectRuleViolationException exception) { bindingResult.rejectValue( "initialLeaderUserId", "project.initialLeader.ineligible", exception.getMessage()); + model.addAttribute("eligibleInternOptions", eligibleInternOptions()); return "projects/form"; } } @@ -139,41 +158,40 @@ public class ProjectController { @GetMapping("/{projectId}/members") public String members(Principal principal, @PathVariable long projectId, Model model) { long actorId = actorId(principal); - model.addAttribute("project", pages.detail(actorId, projectId)); - model.addAttribute("members", pages.members(actorId, projectId)); - model.addAttribute("projectMemberForm", new ProjectMemberForm(null)); + populateMembersModel(actorId, projectId, model); + model.addAttribute("projectMembersForm", new ProjectMembersForm()); return "projects/members"; } /** - * Adds an eligible Intern or re-renders membership history with the submitted identifier - * and a safe validation message. + * Adds all selected eligible Interns atomically or re-renders membership history with the + * complete retained selection and a safe validation message. * * @param principal authenticated user * @param projectId owning Project identifier - * @param memberForm validated Intern selection + * @param membersForm validated Intern selection * @param bindingResult binding and domain validation results * @param model response model used on failure * @return a membership redirect after success, or the membership view on validation failure */ @PostMapping("/{projectId}/members") - public String addMember( + public String addMembers( Principal principal, @PathVariable long projectId, - @Valid @ModelAttribute("projectMemberForm") ProjectMemberForm memberForm, + @Valid @ModelAttribute("projectMembersForm") ProjectMembersForm membersForm, BindingResult bindingResult, Model model) { long actorId = actorId(principal); if (!bindingResult.hasErrors()) { try { - projects.addMember(actorId, projectId, memberForm.internUserId()); + projects.addMembers(actorId, projectId, membersForm.internUserIds()); return "redirect:/projects/" + projectId + "/members"; } catch (ProjectRuleViolationException exception) { - bindingResult.rejectValue("internUserId", "project.member.ineligible", exception.getMessage()); + bindingResult.rejectValue( + "internUserIds", "project.members.ineligible", exception.getMessage()); } } - model.addAttribute("project", pages.detail(actorId, projectId)); - model.addAttribute("members", pages.members(actorId, projectId)); + populateMembersModel(actorId, projectId, model); return "projects/members"; } @@ -189,8 +207,7 @@ public class ProjectController { @GetMapping("/{projectId}/leadership") public String leadership(Principal principal, @PathVariable long projectId, Model model) { long actorId = actorId(principal); - model.addAttribute("project", pages.detail(actorId, projectId)); - model.addAttribute("leadership", pages.leadership(actorId, projectId)); + populateLeadershipModel(actorId, projectId, model); model.addAttribute("projectMemberForm", new ProjectMemberForm(null)); return "projects/leadership"; } @@ -221,11 +238,45 @@ public class ProjectController { bindingResult.rejectValue("internUserId", "project.leader.ineligible", exception.getMessage()); } } - model.addAttribute("project", pages.detail(actorId, projectId)); - model.addAttribute("leadership", pages.leadership(actorId, projectId)); + populateLeadershipModel(actorId, projectId, model); return "projects/leadership"; } + private void populateMembersModel(long actorId, long projectId, Model model) { + var project = pages.detail(actorId, projectId); + var members = pages.members(actorId, projectId); + model.addAttribute("project", project); + model.addAttribute("members", members); + if (project.canManage()) { + Set currentMemberIds = members.stream() + .filter(member -> member.leftAt() == null) + .map(member -> member.internUserId()) + .collect(Collectors.toUnmodifiableSet()); + model.addAttribute("eligibleInternOptions", eligibleInternOptions().stream() + .filter(option -> !currentMemberIds.contains(option.userId())) + .toList()); + } + } + + private void populateLeadershipModel(long actorId, long projectId, Model model) { + var project = pages.detail(actorId, projectId); + model.addAttribute("project", project); + model.addAttribute("leadership", pages.leadership(actorId, projectId)); + if (project.canManage()) { + Set replacementIds = pages.members(actorId, projectId).stream() + .filter(member -> member.leftAt() == null && !member.currentLeader()) + .map(member -> member.internUserId()) + .collect(Collectors.toUnmodifiableSet()); + model.addAttribute("eligibleInternOptions", eligibleInternOptions().stream() + .filter(option -> replacementIds.contains(option.userId())) + .toList()); + } + } + + private List eligibleInternOptions() { + return accounts.eligibleInternOptions(LocalDate.now(clock)); + } + private long actorId(Principal principal) { return pages.authenticatedUserId(principal.getName()); } diff --git a/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMembersForm.java b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMembersForm.java new file mode 100644 index 0000000..e7de406 --- /dev/null +++ b/src/main/java/com/lab/labtimesheet/feature/project/model/dto/ProjectMembersForm.java @@ -0,0 +1,18 @@ +package com.lab.labtimesheet.feature.project.model.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; +import java.util.List; + +/** + * Browser form for one atomic owning-Mentor direct-add selection. + * + * @param internUserIds distinct positive Intern account identifiers selected in the picker + */ +public record ProjectMembersForm(@NotEmpty List<@Positive Long> internUserIds) { + + /** Creates an empty form for the initial membership page. */ + public ProjectMembersForm() { + this(List.of()); + } +} diff --git a/src/main/resources/static/assets/app.css b/src/main/resources/static/assets/app.css index 9bf6130..05a9fc5 100644 --- a/src/main/resources/static/assets/app.css +++ b/src/main/resources/static/assets/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}.auth-shell{min-height:100vh}.auth-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;min-height:4rem;padding:.75rem 1.25rem;display:flex}.auth-theme{width:9rem}.auth-main{place-items:center;min-height:calc(100vh - 4rem);padding:2rem;display:grid}.auth-card{border:1px solid var(--border);background:var(--panel);border-radius:.85rem;width:min(100%,28rem);padding:1.5rem;box-shadow:0 16px 42px #14192314}.auth-eyebrow{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin:0 0 .35rem;font-size:.72rem;font-weight:750}.auth-form{margin-top:1.25rem}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-link[data-tooltip]{position:relative}[data-sidebar-collapsed=true] .nav-link[data-tooltip]:hover:after,[data-sidebar-collapsed=true] .nav-link[data-tooltip]:focus-visible:after{z-index:20;border:1px solid var(--border-strong);background:var(--ink);color:var(--panel);content:attr(data-tooltip);pointer-events:none;white-space:nowrap;border-radius:.4rem;padding:.38rem .55rem;font-size:.75rem;line-height:1;position:absolute;top:50%;left:calc(100% + .75rem);transform:translateY(-50%)}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}[data-sidebar-collapsed=true] .theme-field select{width:2.5rem;padding-inline:.25rem;font-size:0}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric-strip-three{grid-template-columns:repeat(3,minmax(0,1fr))}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.form-panel{margin-top:1rem;padding:1rem}.form-grid{gap:1rem;display:grid}.form-grid-three{grid-template-columns:repeat(3,minmax(0,1fr))}.form-section{border:1px solid var(--border);border-radius:.65rem;padding:1rem}.form-section legend{padding:0 .35rem;font-weight:700}.field-help{color:var(--muted);margin:0 0 .8rem;font-size:.78rem}.form-actions{justify-content:flex-end;gap:.6rem;display:flex}.inline-actions{gap:.6rem;margin:1rem 0;display:flex}.filter-form{grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;align-items:end;gap:.8rem;margin:1rem 0;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-warning{border-color:var(--warning)}@supports (color:color-mix(in lab, red, red)){.alert-warning{border-color:color-mix(in srgb, var(--warning), transparent 55%)}}.alert-warning{color:var(--warning)}.alert-action{margin-left:.6rem;font-weight:700}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.hidden{display:none}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#858c96;--border-strong:#747d89;--muted:#626a75;--subtle:#626a75;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#626b78;--border-strong:#707987;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{box-sizing:border-box}html{background:var(--canvas);min-width:64rem}body{background:var(--canvas);color:var(--ink);margin:0;font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow-x:hidden}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline:none}:focus-visible{outline:3px solid var(--focus);outline-offset:2px}a{color:inherit}}@layer components{.app-shell{grid-template-columns:16rem minmax(0,1fr);min-height:100vh;display:grid}.auth-shell{min-height:100vh}.auth-header{border-bottom:1px solid var(--border);justify-content:space-between;align-items:center;min-height:4rem;padding:.75rem 1.25rem;display:flex}.auth-theme{width:9rem}.auth-main{place-items:center;min-height:calc(100vh - 4rem);padding:2rem;display:grid}.auth-card{border:1px solid var(--border);background:var(--panel);border-radius:.85rem;width:min(100%,28rem);padding:1.5rem;box-shadow:0 16px 42px #14192314}.auth-eyebrow{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;margin:0 0 .35rem;font-size:.72rem;font-weight:750}.auth-form{margin-top:1.25rem}[data-sidebar-collapsed=true] .app-shell{grid-template-columns:4rem minmax(0,1fr)}.sidebar{border-right:1px solid var(--border);background:var(--sidebar);flex-direction:column;height:100vh;padding:1rem .75rem;display:flex;position:sticky;top:0}.brand,.account{align-items:center;gap:.7rem;min-width:0;padding:.25rem .4rem;display:flex}.brand-mark{background:var(--ink);width:2rem;height:2rem;color:var(--panel);border-radius:.55rem;flex:none;place-items:center;display:grid}.sidebar-label{white-space:nowrap;overflow:hidden}[data-sidebar-collapsed=true] .sidebar-label{opacity:0;width:0}.nav-label{color:var(--subtle);letter-spacing:.08em;text-transform:uppercase;margin:1.6rem .6rem .4rem;font-size:.68rem;font-weight:750}.nav-list{gap:.2rem;margin:0;padding:0;list-style:none;display:grid}.nav-link{min-height:2.5rem;color:var(--muted);border-radius:.55rem;align-items:center;gap:.7rem;padding:.55rem .7rem;font-weight:600;text-decoration:none;display:flex}.nav-link:hover,.nav-link[aria-current=page]{background:var(--panel);color:var(--ink);box-shadow:0 1px 2px #14192314}.nav-link[data-tooltip]{position:relative}[data-sidebar-collapsed=true] .nav-link[data-tooltip]:hover:after,[data-sidebar-collapsed=true] .nav-link[data-tooltip]:focus-visible:after{z-index:20;border:1px solid var(--border-strong);background:var(--ink);color:var(--panel);content:attr(data-tooltip);pointer-events:none;white-space:nowrap;border-radius:.4rem;padding:.38rem .55rem;font-size:.75rem;line-height:1;position:absolute;top:50%;left:calc(100% + .75rem);transform:translateY(-50%)}.nav-icon{flex:none;width:1.05rem;height:1.05rem}.sidebar-footer{gap:.7rem;margin-top:auto;display:grid}.theme-field{gap:.25rem;display:grid}.theme-field select{border:1px solid var(--border-strong);background:var(--panel);min-height:2.4rem;color:var(--ink);border-radius:.5rem;padding:.35rem .55rem}[data-sidebar-collapsed=true] .theme-field select{width:2.5rem;padding-inline:.25rem;font-size:0}.logout-form button{text-align:left;background:0 0;border:0;width:100%}.app-column{min-width:0}.app-header{border-bottom:1px solid var(--border);align-items:center;gap:.8rem;min-height:3.75rem;padding:0 1.5rem;display:flex}.header-title{min-width:0;font-weight:700}.breadcrumb{color:var(--muted);font-weight:400}.header-actions{align-items:center;gap:.55rem;margin-left:auto;display:flex}.icon-button{border:1px solid var(--border-strong);background:var(--panel);width:2.5rem;height:2.5rem;color:var(--ink);cursor:pointer;border-radius:.5rem;place-items:center;display:inline-grid}.page{min-width:0;padding:1.55rem}.page-heading{align-items:end;gap:1rem;margin-bottom:1.1rem;display:flex}.page-heading-copy{min-width:0}.page-title{letter-spacing:-.025em;margin:0;font-size:1.56rem;line-height:1.2}.page-description{max-width:72ch;color:var(--muted);margin:.3rem 0 0}.primary-action{margin-left:auto}.button{border:1px solid var(--border-strong);background:var(--panel);min-height:2.35rem;color:var(--ink);cursor:pointer;border-radius:.5rem;justify-content:center;align-items:center;gap:.45rem;padding:.5rem .8rem;font-weight:650;text-decoration:none;display:inline-flex}.button-primary{border-color:var(--ink);background:var(--ink);color:var(--panel)}.button:disabled{cursor:not-allowed;opacity:.55}.button-danger{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{border-color:color-mix(in srgb, var(--danger), transparent 65%)}}.button-danger{background:var(--danger)}@supports (color:color-mix(in lab, red, red)){.button-danger{background:color-mix(in srgb, var(--danger), transparent 90%)}}.button-danger{color:var(--danger)}.panel{border:1px solid var(--border);background:var(--panel);border-radius:.75rem;box-shadow:0 10px 28px #1419230f}.panel-header{border-bottom:1px solid var(--border);padding:.9rem 1rem}.panel-title{margin:0;font-size:1rem}.metric-strip{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:1rem;display:grid;overflow:hidden}.metric-strip-three{grid-template-columns:repeat(3,minmax(0,1fr))}.metric{min-width:0;padding:1rem}.metric+.metric{border-left:1px solid var(--border)}.metric-label{color:var(--muted);font-size:.78rem}.metric-value{font-variant-numeric:tabular-nums;margin-top:.35rem;font-size:1.4rem;font-weight:700}.metric-detail{color:var(--muted);margin-top:.18rem;font-size:.78rem}.field{gap:.35rem;display:grid}.form-panel{margin-top:1rem;padding:1rem}.form-grid{gap:1rem;display:grid}.form-grid-three{grid-template-columns:repeat(3,minmax(0,1fr))}.form-section{border:1px solid var(--border);border-radius:.65rem;padding:1rem}.form-section legend{padding:0 .35rem;font-weight:700}.field-help{color:var(--muted);margin:0 0 .8rem;font-size:.78rem}.form-actions{justify-content:flex-end;gap:.6rem;display:flex}.inline-actions{gap:.6rem;margin:1rem 0;display:flex}.filter-form{grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;align-items:end;gap:.8rem;margin:1rem 0;display:grid}.field-label{font-size:.78rem;font-weight:650}.control{border:1px solid var(--border-strong);background:var(--panel);width:100%;min-height:2.45rem;color:var(--ink);border-radius:.5rem;padding:.55rem .65rem}.control[aria-invalid=true]{border-color:var(--danger)}.field-error{color:var(--danger);margin:0;font-size:.78rem}.checkbox{align-items:center;gap:.5rem;display:flex}.badge{border:1px solid var(--border);border-radius:999px;align-items:center;gap:.32rem;padding:.15rem .45rem;font-size:.72rem;font-weight:700;display:inline-flex}.badge:before{content:"";background:currentColor;border-radius:50%;width:.38rem;height:.38rem}.badge-success{color:var(--success)}.badge-warning{color:var(--warning)}.badge-danger{color:var(--danger)}.alert{border:1px solid var(--border);border-radius:.6rem;margin:.75rem 0;padding:.75rem .9rem}.alert-warning{border-color:var(--warning)}@supports (color:color-mix(in lab, red, red)){.alert-warning{border-color:color-mix(in srgb, var(--warning), transparent 55%)}}.alert-warning{color:var(--warning)}.alert-action{margin-left:.6rem;font-weight:700}.alert-error{border-color:var(--danger)}@supports (color:color-mix(in lab, red, red)){.alert-error{border-color:color-mix(in srgb, var(--danger), transparent 60%)}}.alert-error{color:var(--danger)}.empty-state{text-align:center;padding:2.5rem 1rem}.empty-state p{color:var(--muted);margin:.3rem auto 0}.table-scroll{max-width:100%;overflow-x:auto}.data-table{border-collapse:collapse;width:100%;min-width:42rem}.data-table th{background:var(--panel-muted);color:var(--muted);letter-spacing:.06em;text-align:left;text-transform:uppercase;font-size:.69rem}.data-table th,.data-table td{border-bottom:1px solid var(--border);padding:.7rem 1rem}.data-table tr:last-child td{border-bottom:0}.tabs{border:1px solid var(--border);background:var(--panel-muted);border-radius:.55rem;gap:.2rem;padding:.2rem;display:inline-flex}.tab{border-radius:.4rem;padding:.4rem .65rem;text-decoration:none}.tab[aria-current=page]{background:var(--panel);box-shadow:0 1px 2px #14192314}.pagination{justify-content:flex-end;align-items:center;gap:.4rem;padding:.8rem 1rem;display:flex}.skeleton{background:var(--panel-muted);border-radius:.35rem;height:1rem;animation:1.5s ease-in-out infinite pulse}.notification-menu{min-width:18rem;padding:.75rem}dialog{border:1px solid var(--border);background:var(--panel);max-width:30rem;color:var(--ink);border-radius:.9rem;padding:1.25rem}dialog::backdrop{background:#00000073}.picker-trigger{justify-content:flex-start}.picker-summary{color:var(--muted);margin:0;font-size:.78rem}.picker-drawer{border-radius:.9rem 0 0 .9rem;width:min(32rem,100%);max-width:32rem;height:100dvh;max-height:100dvh;margin:0 0 0 auto;padding:0}.picker-header,.picker-footer{justify-content:space-between;align-items:center;gap:1rem;padding:1rem;display:flex}.picker-header{border-bottom:1px solid var(--border)}.picker-header .field-help{margin:.2rem 0 0}.picker-body{gap:.5rem;padding:1rem;display:grid}.picker-options{gap:.5rem;margin-top:.5rem;display:grid}.picker-option{border:1px solid var(--border);cursor:pointer;border-radius:.65rem;grid-template-columns:auto minmax(0,1fr);align-items:start;gap:.75rem;padding:.75rem;display:grid}.picker-option:hover{border-color:var(--border-strong);background:var(--panel-muted)}.picker-option input{margin-top:.2rem}.picker-option span{gap:.18rem;min-width:0;display:grid}.picker-option small,.picker-empty{color:var(--muted)}.picker-empty{text-align:center;margin:1rem 0}.picker-footer{border-top:1px solid var(--border);justify-content:flex-end}@keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;animation-duration:.01ms!important}}}@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.hidden{display:none}}:root{color-scheme:light;--ink:#15171a;--canvas:#f6f7f8;--sidebar:#f0f1f2;--panel:#fff;--panel-muted:#f7f8f9;--border:#858c96;--border-strong:#747d89;--muted:#626a75;--subtle:#626a75;--accent:#3157e7;--focus:#3157e7;--success:#087a48;--warning:#7a4d00;--danger:#b42318}:root[data-theme=dark]{color-scheme:dark;--ink:#eceef1;--canvas:#0b0c0e;--sidebar:#111317;--panel:#17191e;--panel-muted:#1d2026;--border:#626b78;--border-strong:#707987;--muted:#b2b7c0;--subtle:#969da8;--accent:#8ca4ff;--focus:#9eb2ff;--success:#4fd19b;--warning:#f0bc63;--danger:#ff8e88}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/src/main/resources/static/assets/app.js b/src/main/resources/static/assets/app.js index b443b1b..9ca37f0 100644 --- a/src/main/resources/static/assets/app.js +++ b/src/main/resources/static/assets/app.js @@ -37,4 +37,57 @@ document.addEventListener('DOMContentLoaded', () => { try { localStorage.setItem('labtimesheet-sidebar', collapsed ? 'collapsed' : 'expanded'); } catch (_) { /* Collapse still works for this page. */ } }); + + document.querySelectorAll('[data-intern-picker]').forEach((picker) => { + const open = picker.querySelector('[data-picker-open]'); + const dialog = picker.querySelector('[data-picker-dialog]'); + const search = picker.querySelector('[data-picker-search]'); + const summary = picker.querySelector('[data-picker-summary]'); + const empty = picker.querySelector('[data-picker-empty]'); + const cancel = picker.querySelector('[data-picker-cancel]'); + const apply = picker.querySelector('[data-picker-apply]'); + const options = [...picker.querySelectorAll('[data-picker-option]')]; + let initialSelection = []; + + const inputs = () => options.map((option) => option.querySelector('input')); + const updateSummary = () => { + const selected = options + .filter((option) => option.querySelector('input').checked) + .map((option) => option.querySelector('[data-picker-label]').textContent.trim()); + summary.textContent = selected.length === 0 + ? `No Intern${inputs()[0]?.type === 'radio' ? '' : 's'} selected` + : `${selected.length} Intern${selected.length === 1 ? '' : 's'} selected: ${selected.join(', ')}`; + }; + const filter = () => { + const query = search.value.trim().toLocaleLowerCase(); + let visible = 0; + options.forEach((option) => { + option.hidden = !option.dataset.pickerSearch.toLocaleLowerCase().includes(query); + if (!option.hidden) visible += 1; + }); + empty.hidden = visible !== 0; + }; + const restore = () => { + inputs().forEach((input, index) => { input.checked = initialSelection[index]; }); + updateSummary(); + }; + + inputs().forEach((input) => input.addEventListener('change', updateSummary)); + search.addEventListener('input', filter); + open.addEventListener('click', () => { + initialSelection = inputs().map((input) => input.checked); + search.value = ''; + filter(); + dialog.showModal(); + search.focus(); + }); + cancel.addEventListener('click', () => { + restore(); + dialog.close(); + }); + dialog.addEventListener('cancel', restore); + dialog.addEventListener('close', () => open.focus()); + apply.addEventListener('click', () => dialog.close()); + updateSummary(); + }); }); diff --git a/src/main/resources/templates/projects/form.html b/src/main/resources/templates/projects/form.html index 6918eb7..40696e0 100644 --- a/src/main/resources/templates/projects/form.html +++ b/src/main/resources/templates/projects/form.html @@ -26,10 +26,29 @@
-
- - +
+ Initial Leader + +

No Intern selected

+

No eligible Interns are available.

+ +

Choose initial Leader

Only currently eligible Interns are available.

+
+ + +
+ +

No matching eligible Interns.

+
+
+ +
Cancel
diff --git a/src/main/resources/templates/projects/leadership.html b/src/main/resources/templates/projects/leadership.html index 141a331..708c5ee 100644 --- a/src/main/resources/templates/projects/leadership.html +++ b/src/main/resources/templates/projects/leadership.html @@ -20,10 +20,32 @@ LeaderStartedCurrent
-
+ -
- +
+ New Leader + +

No Intern selected

+

No eligible current members are available.

+ + +

Choose new Leader

Only eligible current members other than the current Leader are available.

+
+ + +
+ +

No matching eligible current members.

+
+
+ +
+
+
diff --git a/src/main/resources/templates/projects/members.html b/src/main/resources/templates/projects/members.html index 3f1a0d1..394a0ee 100644 --- a/src/main/resources/templates/projects/members.html +++ b/src/main/resources/templates/projects/members.html @@ -20,10 +20,32 @@ InternJoinedCurrentMember -
- -
- + + +
+ Interns to add + +

No Interns selected

+

No eligible nonmembers are available.

+ + +

Add Project members

Select one or more eligible Interns who are not current members.

+
+ + +
+ +

No matching eligible Interns.

+
+
+ +
+
+
diff --git a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java index 3cfc1e7..c66fa1f 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/controller/ProjectControllerTest.java @@ -1,5 +1,7 @@ package com.lab.labtimesheet.feature.project.controller; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -17,6 +19,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException; +import com.lab.labtimesheet.feature.account.model.dto.EligibleInternOption; +import com.lab.labtimesheet.feature.account.service.AccountService; import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand; import com.lab.labtimesheet.feature.project.model.dto.ProjectActorView; import com.lab.labtimesheet.feature.project.model.dto.ProjectDetail; @@ -27,9 +31,12 @@ import com.lab.labtimesheet.feature.project.service.ProjectQueryService; import com.lab.labtimesheet.feature.project.service.ProjectService; import com.lab.labtimesheet.feature.integration.service.SmtpConfigurationService; import java.time.Instant; +import java.time.Clock; import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; @@ -50,9 +57,116 @@ class ProjectControllerTest { @MockitoBean private ProjectService projects; + @MockitoBean + private AccountService accounts; + + @MockitoBean + private Clock clock; + @MockitoBean private SmtpConfigurationService smtpConfiguration; + @BeforeEach + void serverBusinessDate() { + when(clock.instant()).thenReturn(Instant.parse("2026-08-15T01:00:00Z")); + when(clock.getZone()).thenReturn(ZoneId.of("Asia/Ho_Chi_Minh")); + } + + @Test + @WithMockUser(username = "mentor@example.test") + void projectCreationRendersSearchableEligibleLeaderOptionsWithoutVisibleNumericIds() throws Exception { + when(pages.authenticatedActor("mentor@example.test")) + .thenReturn(new ProjectActorView(10L, "MENTOR")); + when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of( + option(20L, "Nguyen An", "STU-020"), + option(21L, "Tran Binh", "STU-021"))); + + mvc.perform(get("/projects/new")) + .andExpect(status().isOk()) + .andExpect(model().attributeExists("eligibleInternOptions")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("data-intern-picker"))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("type=\"radio\""))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("Nguyen An"))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("STU-020"))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("01/08/2026 – 31/12/2026"))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(not(containsString("Initial Leader user ID")))); + } + + @Test + @WithMockUser(username = "mentor@example.test") + void projectCreationExplainsWhenNoEligibleLeaderIsAvailable() throws Exception { + when(pages.authenticatedActor("mentor@example.test")) + .thenReturn(new ProjectActorView(10L, "MENTOR")); + when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of()); + + mvc.perform(get("/projects/new")) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("No eligible Interns are available."))); + } + + @Test + @WithMockUser(username = "mentor@example.test") + void memberAndLeadershipPickersExposeOnlyValidServerFilteredOptions() throws Exception { + when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L); + when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail()); + when(pages.members(10L, 30L)).thenReturn(List.of( + new ProjectMemberView(40L, 20L, "Current Leader", Instant.parse("2026-08-15T00:00:00Z"), null, true), + new ProjectMemberView(41L, 21L, "Current Member", Instant.parse("2026-08-15T00:00:00Z"), null, false))); + when(pages.leadership(10L, 30L)).thenReturn(List.of()); + when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of( + option(20L, "Current Leader", "STU-020"), + option(21L, "Current Member", "STU-021"), + option(22L, "Eligible Nonmember", "STU-022"))); + + String membersHtml = mvc.perform(get("/projects/30/members")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + assertTrue(membersHtml.contains("name=\"internUserIds\"")); + assertTrue(membersHtml.contains("Eligible Nonmember")); + assertFalse(membersHtml.contains("data-picker-label>Current Member")); + + String leadershipHtml = mvc.perform(get("/projects/30/leadership")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + assertTrue(leadershipHtml.contains("type=\"radio\"")); + assertTrue(leadershipHtml.contains("Current Member")); + assertFalse(leadershipHtml.contains("Eligible Nonmember")); + assertFalse(leadershipHtml.contains("data-picker-label>Current Leader")); + } + + @Test + @WithMockUser(username = "mentor@example.test") + void rejectedMemberBatchRetainsEverySelectionAndShowsRecoveryCopy() throws Exception { + when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L); + when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail()); + when(pages.members(10L, 30L)).thenReturn(List.of()); + when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of( + option(21L, "First Intern", "STU-021"), + option(22L, "Second Intern", "STU-022"))); + doThrow(new ProjectRuleViolationException("One or more selected Interns are no longer eligible")) + .when(projects).addMembers(10L, 30L, List.of(21L, 22L)); + + mvc.perform(post("/projects/30/members") + .with(csrf()) + .param("internUserIds", "21", "22")) + .andExpect(status().isOk()) + .andExpect(view().name("projects/members")) + .andExpect(model().attributeHasFieldErrors("projectMembersForm", "internUserIds")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("One or more selected Interns are no longer eligible"))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("value=\"21\" id=\"internUserIds1\" name=\"internUserIds\" checked=\"checked\""))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("value=\"22\" id=\"internUserIds2\" name=\"internUserIds\" checked=\"checked\""))); + } + @Test @WithMockUser(username = "mentor@example.test") void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception { @@ -144,7 +258,8 @@ class ProjectControllerTest { @Test @WithMockUser(username = "mentor@example.test") void validCreateSubmissionUsesAuthenticatedMentorAndRedirectsToDetail() throws Exception { - when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L); + when(pages.authenticatedActor("mentor@example.test")) + .thenReturn(new ProjectActorView(10L, "MENTOR")); when(projects.create( 10L, new ProjectCreateCommand( @@ -217,6 +332,9 @@ class ProjectControllerTest { @Test @WithMockUser(username = "mentor@example.test") void invalidCreateSubmissionStaysOnSafeFormWithoutMutation() throws Exception { + when(pages.authenticatedActor("mentor@example.test")) + .thenReturn(new ProjectActorView(10L, "MENTOR")); + mvc.perform(post("/projects") .with(csrf()) .param("name", " ") @@ -239,6 +357,8 @@ class ProjectControllerTest { @Test @WithMockUser(username = "mentor@example.test") void domainValidationErrorsStayOnTheirSafeFormsWithRetainedInput() throws Exception { + when(pages.authenticatedActor("mentor@example.test")) + .thenReturn(new ProjectActorView(10L, "MENTOR")); when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L); when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail()); when(pages.members(10L, 30L)).thenReturn(List.of(new ProjectMemberView( @@ -255,7 +375,7 @@ class ProjectControllerTest { 99L))) .thenThrow(new ProjectRuleViolationException("Intern must have an active account and internship")); doThrow(new ProjectRuleViolationException("Intern is already a current Project member")) - .when(projects).addMember(10L, 30L, 20L); + .when(projects).addMembers(10L, 30L, List.of(20L)); doThrow(new ProjectRuleViolationException("Selected Intern is already the current Leader")) .when(projects).changeLeader(10L, 30L, 20L); @@ -274,12 +394,14 @@ class ProjectControllerTest { mvc.perform(post("/projects/30/members") .with(csrf()) - .param("internUserId", "20")) + .param("internUserIds", "20")) .andExpect(status().isOk()) .andExpect(view().name("projects/members")) - .andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId")) + .andExpect(model().attributeHasFieldErrors("projectMembersForm", "internUserIds")) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() - .string(containsString("value=\"20\""))); + .string(containsString("Intern is already a current Project member"))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(not(containsString("value=\"20\"")))); mvc.perform(post("/projects/30/leadership") .with(csrf()) @@ -288,7 +410,9 @@ class ProjectControllerTest { .andExpect(view().name("projects/leadership")) .andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId")) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() - .string(containsString("value=\"20\""))); + .string(containsString("Selected Intern is already the current Leader"))) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(not(containsString("value=\"20\"")))); } @Test @@ -388,4 +512,13 @@ class ProjectControllerTest { "Current Leader", true); } + + private static EligibleInternOption option(long userId, String name, String studentCode) { + return new EligibleInternOption( + userId, + name, + studentCode, + LocalDate.of(2026, 8, 1), + LocalDate.of(2026, 12, 31)); + } } diff --git a/src/test/js/project-picker.test.mjs b/src/test/js/project-picker.test.mjs new file mode 100644 index 0000000..15f9947 --- /dev/null +++ b/src/test/js/project-picker.test.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; + +class Target { + listeners = new Map(); + + addEventListener(type, listener) { + this.listeners.set(type, listener); + } + + dispatch(type) { + this.listeners.get(type)?.({preventDefault() {}, target: this}); + } +} + +test('picker searches name and student code, summarizes selection, and cancels safely', () => { + const open = Object.assign(new Target(), {focus() { this.focused = true; }}); + const cancel = new Target(); + const apply = new Target(); + const search = Object.assign(new Target(), {value: '', focus() { this.focused = true; }}); + const summary = {textContent: ''}; + const empty = {hidden: true}; + const firstInput = Object.assign(new Target(), {checked: false, type: 'checkbox'}); + const secondInput = Object.assign(new Target(), {checked: false, type: 'checkbox'}); + const options = [ + option('Nguyen An STU-020', 'Nguyen An (STU-020)', firstInput), + option('Tran Binh STU-021', 'Tran Binh (STU-021)', secondInput), + ]; + const dialog = Object.assign(new Target(), { + showModal() { this.open = true; }, + close() { this.open = false; this.dispatch('close'); }, + }); + const picker = { + querySelector(selector) { + return new Map([ + ['[data-picker-open]', open], ['[data-picker-dialog]', dialog], + ['[data-picker-search]', search], ['[data-picker-summary]', summary], + ['[data-picker-empty]', empty], ['[data-picker-cancel]', cancel], + ['[data-picker-apply]', apply], + ]).get(selector) ?? null; + }, + querySelectorAll(selector) { + return selector === '[data-picker-option]' ? options : []; + }, + }; + let ready; + const document = { + documentElement: {dataset: {}, style: {}}, + addEventListener(type, listener) { if (type === 'DOMContentLoaded') ready = listener; }, + querySelector() { return null; }, + querySelectorAll(selector) { return selector === '[data-intern-picker]' ? [picker] : []; }, + }; + vm.runInNewContext(readFileSync('src/main/resources/static/assets/app.js', 'utf8'), { + document, + localStorage: {getItem() { return null; }, setItem() {}, removeItem() {}}, + matchMedia() { return {matches: false}; }, + }); + ready(); + + open.dispatch('click'); + assert.equal(dialog.open, true); + assert.equal(search.focused, true); + + search.value = 'stu-021'; + search.dispatch('input'); + assert.equal(options[0].hidden, true); + assert.equal(options[1].hidden, false); + assert.equal(empty.hidden, true); + + secondInput.checked = true; + secondInput.dispatch('change'); + assert.equal(summary.textContent, '1 Intern selected: Tran Binh (STU-021)'); + + cancel.dispatch('click'); + assert.equal(secondInput.checked, false); + assert.equal(summary.textContent, 'No Interns selected'); + assert.equal(open.focused, true); + + open.dispatch('click'); + secondInput.checked = true; + secondInput.dispatch('change'); + apply.dispatch('click'); + assert.equal(secondInput.checked, true); +}); + +function option(searchValue, label, input) { + return { + hidden: false, + dataset: {pickerSearch: searchValue}, + querySelector(selector) { + if (selector === 'input') return input; + if (selector === '[data-picker-label]') return {textContent: label}; + return null; + }, + }; +}