diff --git a/docs/tests/integration/project-batch-member-add.md b/docs/tests/integration/project-batch-member-add.md new file mode 100644 index 0000000..3049c67 --- /dev/null +++ b/docs/tests/integration/project-batch-member-add.md @@ -0,0 +1,45 @@ +# Integration Test Evidence + +## Requirement and scenario IDs + +- AUTH-001, AUTH-002, AUTH-011; PRJ-003, PRJ-004, PRJ-017; ERR-001, ERR-003; TST-001 through TST-010. +- AC-AUTH-001, AC-AUTH-010, AC-PRJ-001, AC-TST-001. + +## Behavior under test + +The owning Mentor adds several eligible nonmembers under one Project lock and transaction. Null, empty, duplicate, current-member, invalid, or stale/noneligible selections reject the whole batch; no valid prefix becomes a membership. + +## Expected result derivation + +The fixture begins with one Leader. A successful two-Intern batch must yield three current memberships. Every rejected batch leaves the eligible and stale candidate membership count at zero. + +## RED + +`env JAVA_HOME=/opt/homebrew/opt/openjdk@25 PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ./mvnw '-Dtest=ProjectControllerTest,ProjectServiceIntegrationTest' test` failed during test compilation with eight `cannot find symbol` errors for the requested `ProjectService.addMembers(long,long,List)` API. Production compiled first; the failure was the missing behavior boundary rather than the environment or fixture. + +## GREEN + +The focused PostgreSQL command was: + +`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=ProjectServiceIntegrationTest#ownerAddsSeveralEligibleMembersInOneLockedTransaction+memberBatchRejectsMissingDuplicateCurrentAndStaleSelectionsWithoutPartialMutation' test` + +Result: 2 tests, 0 failures, 0 errors, 0 skipped against PostgreSQL 18.4. The +successful case added two memberships; the rejection case covered null, empty, duplicate, +invalid, current-member, and one-valid-plus-one-stale selections without partial persistence. + +## 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=ProjectServiceIntegrationTest' test` +passed 9/9 tests with no failures, errors, or skips. + +The complete Project plus layer-architecture command was: + +`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` + +Result: 38 tests, 0 failures, 0 errors, 0 skipped. + +## External boundaries + +PostgreSQL 18.4 Testcontainers provides the real schema, constraints, JPA transaction, and Project pessimistic lock path. The test does not exercise concurrent requests; existing Project locking coverage remains unchanged. + +After merging exact reviewed `main` `32c8a2d315d2175760c5d4792988cd0aa5ab6dd0`, the affected command was rerun with `UiContractWebTest` included. It passed 45/45 tests with no failures, errors, or skips; the Project service portion remained 9/9 against PostgreSQL 18.4. diff --git a/docs/tests/web/project-intern-picker.md b/docs/tests/web/project-intern-picker.md new file mode 100644 index 0000000..36f96af --- /dev/null +++ b/docs/tests/web/project-intern-picker.md @@ -0,0 +1,48 @@ +# 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. + +Independent review added rendered regressions before the correction. The same focused controller command ran 22 tests with exactly 3 failures and no errors: both closed-dialog radio contracts detected browser `required`, and stale batch recovery lacked the count-only replacement message. The new missing-selection POST contracts already passed through server Bean Validation. + +## 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. + +After the review correction, the focused controller command passed 22/22. Creation and leadership radios no longer use closed-dialog browser constraint validation; missing selections re-render their server field errors. A failed member batch retains submitted option 21 when refreshed eligibility contains only 21, omits all rendered value/ID markup for stale option 22, and reports one unavailable selection without exposing its identifier. + +## 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. + +After merging exact reviewed `main` `32c8a2d315d2175760c5d4792988cd0aa5ab6dd0`, `npm ci`, the 1/1 UI test, frontend build, compile, Project-scoped Javadoc/doclint, and diff check all succeeded. The first affected Java command added the updated shared `UiContractWebTest` and passed 45/45 tests with no failures, errors, or skips. + +The bounded post-review affected command reran `ProjectControllerTest,ProjectEntityTest,ProjectPersistenceStructureTest,ProjectServiceIntegrationTest,ProjectTaskMutationContextTest,LayerStructureTest,UiContractWebTest` and passed 47/47 with no failures, errors, or skips, including 9/9 Project service tests against PostgreSQL 18.4. The UI test remained 1/1; frontend build, compile, Project-scoped Javadoc/doclint, and `git diff --check` also succeeded. 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..4f01201 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,51 @@ 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 every + * still-eligible selection retained and a count of unavailable choices. * * @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); + boolean rejectedByService = false; 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()); + rejectedByService = true; + bindingResult.rejectValue( + "internUserIds", "project.members.ineligible", exception.getMessage()); } } - model.addAttribute("project", pages.detail(actorId, projectId)); - model.addAttribute("members", pages.members(actorId, projectId)); + var refreshedOptions = populateMembersModel(actorId, projectId, model); + if (rejectedByService) { + Set refreshedIds = refreshedOptions.stream() + .map(EligibleInternOption::userId) + .collect(Collectors.toUnmodifiableSet()); + long unavailableSelectionCount = membersForm.internUserIds().stream() + .filter(userId -> !refreshedIds.contains(userId)) + .count(); + model.addAttribute("unavailableSelectionCount", unavailableSelectionCount); + } return "projects/members"; } @@ -189,8 +218,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 +249,48 @@ 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 List 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()); + var options = eligibleInternOptions().stream() + .filter(option -> !currentMemberIds.contains(option.userId())) + .toList(); + model.addAttribute("eligibleInternOptions", options); + return options; + } + return List.of(); + } + + 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/java/com/lab/labtimesheet/feature/project/service/ProjectService.java b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java index c20489c..c370cfe 100644 --- a/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java +++ b/src/main/java/com/lab/labtimesheet/feature/project/service/ProjectService.java @@ -1,6 +1,7 @@ package com.lab.labtimesheet.feature.project.service; import com.lab.labtimesheet.feature.project.exception.ProjectAccessDeniedException; +import com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException; import com.lab.labtimesheet.feature.account.service.AccountService; import com.lab.labtimesheet.feature.project.model.ProjectInternEligibility; import com.lab.labtimesheet.feature.project.model.dto.ProjectCreateCommand; @@ -9,6 +10,9 @@ import com.lab.labtimesheet.feature.project.model.entity.ProjectEntity; import com.lab.labtimesheet.feature.project.repository.ProjectRepository; import com.lab.labtimesheet.feature.task.service.TaskQueryService; import java.time.Clock; +import java.time.LocalDate; +import java.util.HashSet; +import java.util.List; import java.util.Set; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; @@ -68,9 +72,42 @@ public class ProjectService { */ @Transactional public void addMember(long actorUserId, long projectId, long internUserId) { + addMembers(actorUserId, projectId, List.of(internUserId)); + } + + /** + * Adds a complete selection of eligible nonmembers while holding one Project write lock. + * Every identifier is revalidated after owner authorization and before the aggregate changes, + * so missing, duplicate, stale, ineligible, or current-member selections leave membership + * unchanged. + * + * @param actorUserId authenticated owning Mentor + * @param projectId Project to update + * @param internUserIds distinct eligible Intern account identifiers + * @throws com.lab.labtimesheet.feature.project.exception.ProjectRuleViolationException when + * the selection is null, empty, malformed, duplicate, stale, ineligible, or already + * contains a current member + */ + @Transactional + public void addMembers(long actorUserId, long projectId, List internUserIds) { var project = lockedProject(projectId); project.authorizeOwner(actorUserId); - project.addMember(actorUserId, eligibleIntern(internUserId), clock.instant()); + if (internUserIds == null || internUserIds.isEmpty()) { + throw new ProjectRuleViolationException("Select at least one Intern"); + } + if (internUserIds.stream().anyMatch(userId -> userId == null || userId <= 0) + || new HashSet<>(internUserIds).size() != internUserIds.size()) { + throw new ProjectRuleViolationException("Intern selection is invalid"); + } + + var selectedInterns = internUserIds.stream().map(this::eligibleIntern).toList(); + if (selectedInterns.stream().anyMatch(intern -> !intern.isEligible()) + || selectedInterns.stream().anyMatch(intern -> project.hasCurrentMember(intern.userId()))) { + throw new ProjectRuleViolationException("One or more selected Interns are no longer eligible"); + } + + var addedAt = clock.instant(); + selectedInterns.forEach(intern -> project.addMember(actorUserId, intern, addedAt)); projects.flush(); } @@ -145,7 +182,7 @@ public class ProjectService { } private ProjectInternEligibility eligibleIntern(long userId) { - return new ProjectInternEligibility(userId, accounts.isEligibleIntern(userId)); + return new ProjectInternEligibility(userId, accounts.isEligibleIntern(userId, LocalDate.now(clock))); } private void requireActiveMentor(long userId) { 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..9b09333 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..868c70b 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..be855cb 100644 --- a/src/main/resources/templates/projects/members.html +++ b/src/main/resources/templates/projects/members.html @@ -20,10 +20,34 @@ InternJoinedCurrentMember -
- -
- + + +
+ Interns to add + +

No Interns selected

+

No eligible nonmembers are available.

+ +

Unavailable selection recovery

+ +

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..d844b12 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,13 @@ 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 java.util.regex.Pattern; 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 +58,120 @@ 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"))); + + String html = 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")))) + .andReturn().getResponse().getContentAsString(); + assertFalse(containsRequiredRadio(html)); + } + + @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")); + assertFalse(containsRequiredRadio(leadershipHtml)); + } + + @Test + @WithMockUser(username = "mentor@example.test") + void rejectedMemberBatchRetainsEligibleSelectionsAndExplainsUnavailableCountWithoutIds() 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"))); + 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("1 previously selected Intern is no longer eligible; choose a replacement."))) + .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(not(containsString("value=\"22\"")))); + } + @Test @WithMockUser(username = "mentor@example.test") void listsOnlyTheAuthenticatedUsersAuthorizedProjects() throws Exception { @@ -144,7 +263,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( @@ -166,6 +286,50 @@ class ProjectControllerTest { .andExpect(redirectedUrl("/projects/30")); } + @Test + @WithMockUser(username = "mentor@example.test") + void missingInitialLeaderReRendersServerFieldError() throws Exception { + when(pages.authenticatedActor("mentor@example.test")) + .thenReturn(new ProjectActorView(10L, "MENTOR")); + + mvc.perform(post("/projects") + .with(csrf()) + .param("name", "Intern Portal Refresh") + .param("startDate", "2026-08-15") + .param("endDate", "2026-09-30")) + .andExpect(status().isOk()) + .andExpect(view().name("projects/form")) + .andExpect(model().attributeHasFieldErrors("projectForm", "initialLeaderUserId")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("id=\"initialLeaderUserId-error\""))); + + verify(projects, never()).create(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any()); + } + + @Test + @WithMockUser(username = "mentor@example.test") + void missingReplacementLeaderReRendersServerFieldError() throws Exception { + when(pages.authenticatedUserId("mentor@example.test")).thenReturn(10L); + when(pages.detail(10L, 30L)).thenReturn(plannedOwnerDetail()); + when(pages.leadership(10L, 30L)).thenReturn(List.of()); + when(pages.members(10L, 30L)).thenReturn(List.of(new ProjectMemberView( + 41L, 21L, "Current Member", Instant.parse("2026-08-15T00:00:00Z"), null, false))); + when(accounts.eligibleInternOptions(LocalDate.of(2026, 8, 15))).thenReturn(List.of( + option(21L, "Current Member", "STU-021"))); + + mvc.perform(post("/projects/30/leadership").with(csrf())) + .andExpect(status().isOk()) + .andExpect(view().name("projects/leadership")) + .andExpect(model().attributeHasFieldErrors("projectMemberForm", "internUserId")) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.content() + .string(containsString("id=\"leadership-intern-user-error\""))); + + verify(projects, never()).changeLeader( + org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyLong()); + } + @Test @WithMockUser(username = "mentor@example.test") void owningMentorCanActivateAPlannedProject() throws Exception { @@ -217,6 +381,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 +406,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 +424,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 +443,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 +459,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 +561,19 @@ 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)); + } + + private static boolean containsRequiredRadio(String html) { + return Pattern.compile("]*type=\\\"radio\\\")(?=[^>]*required(?:=|\\s|>))[^>]*>") + .matcher(html) + .find(); + } } diff --git a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java index f4a12ff..61ccd26 100644 --- a/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java +++ b/src/test/java/com/lab/labtimesheet/feature/project/service/ProjectServiceIntegrationTest.java @@ -106,6 +106,53 @@ class ProjectServiceIntegrationTest { user("other-mentor@example.test", "MENTOR"), projectId, Long.MAX_VALUE)); } + @Test + void ownerAddsSeveralEligibleMembersInOneLockedTransaction() { + long mentorId = user("mentor-batch-add@example.test", "MENTOR"); + long leaderId = intern("leader-batch-add@example.test", "I017"); + long firstMemberId = intern("first-batch-add@example.test", "I018"); + long secondMemberId = intern("second-batch-add@example.test", "I019"); + long projectId = createProject(mentorId, leaderId, "Batch membership"); + + projectService.addMembers(mentorId, projectId, List.of(firstMemberId, secondMemberId)); + + assertEquals(3, count(""" + select count(*) from project_memberships + where project_id = ? and left_at is null + """, projectId)); + } + + @Test + void memberBatchRejectsMissingDuplicateCurrentAndStaleSelectionsWithoutPartialMutation() { + long mentorId = user("mentor-batch-guard@example.test", "MENTOR"); + long leaderId = intern("leader-batch-guard@example.test", "I020"); + long eligibleId = intern("eligible-batch-guard@example.test", "I021"); + long staleId = intern("stale-batch-guard@example.test", "I022"); + long projectId = createProject(mentorId, leaderId, "Batch guard"); + jdbc.update("update intern_profiles set internship_end_date = date '2026-08-13' where user_id = ?", staleId); + entityManager.clear(); + + assertThrows(ProjectRuleViolationException.class, + () -> projectService.addMembers(mentorId, projectId, null)); + assertThrows(ProjectRuleViolationException.class, + () -> projectService.addMembers(mentorId, projectId, List.of())); + assertThrows(ProjectRuleViolationException.class, + () -> projectService.addMembers(mentorId, projectId, List.of(eligibleId, eligibleId))); + assertThrows(ProjectRuleViolationException.class, + () -> projectService.addMembers(mentorId, projectId, List.of(Long.MAX_VALUE))); + assertThrows(ProjectRuleViolationException.class, + () -> projectService.addMembers(mentorId, projectId, List.of(mentorId))); + assertThrows(ProjectRuleViolationException.class, + () -> projectService.addMembers(mentorId, projectId, List.of(leaderId))); + assertThrows(ProjectRuleViolationException.class, + () -> projectService.addMembers(mentorId, projectId, List.of(eligibleId, staleId))); + + assertEquals(0, count(""" + select count(*) from project_memberships + where project_id = ? and intern_user_id in (?, ?) and left_at is null + """, projectId, eligibleId, staleId)); + } + @Test void leaderChangeClosesOneTermAndDoesNotMoveTaskAssignments() { long mentorId = user("mentor-leader@example.test", "MENTOR"); 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; + }, + }; +}