feat(projects): replace numeric Intern inputs with picker

This commit is contained in:
sechmachine
2026-08-15 15:02:03 +07:00
parent 64c9370aa0
commit 169da1a9f4
12 changed files with 509 additions and 36 deletions
File diff suppressed because one or more lines are too long
+53
View File
@@ -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();
});
});
@@ -26,10 +26,29 @@
<div class="form-grid form-grid-three">
<div class="field"><label class="field-label" for="startDate">Start date</label><input class="control" id="startDate" type="date" th:field="*{startDate}" required th:attr="aria-invalid=${#fields.hasErrors('startDate')},aria-describedby=${#fields.hasErrors('startDate') ? 'startDate-error' : null}"><p class="field-error" id="startDate-error" role="alert" th:if="${#fields.hasErrors('startDate')}" th:errors="*{startDate}">Start date error</p></div>
<div class="field"><label class="field-label" for="endDate">End date</label><input class="control" id="endDate" type="date" th:field="*{endDate}" required th:attr="aria-invalid=${#fields.hasErrors('endDate') or #fields.hasErrors('dateRangeValid')},aria-describedby=${#fields.hasErrors('endDate') ? 'endDate-error' : (#fields.hasErrors('dateRangeValid') ? 'dateRangeValid-error' : null)}"><p class="field-error" id="endDate-error" role="alert" th:if="${#fields.hasErrors('endDate')}" th:errors="*{endDate}">End date error</p><p class="field-error" id="dateRangeValid-error" role="alert" th:if="${#fields.hasErrors('dateRangeValid')}" th:errors="*{dateRangeValid}">Date range error</p></div>
<div class="field">
<label class="field-label" for="leader">Initial Leader user ID</label>
<input class="control" id="leader" type="number" min="1" th:field="*{initialLeaderUserId}" required th:attr="aria-invalid=${#fields.hasErrors('initialLeaderUserId')},aria-describedby=${#fields.hasErrors('initialLeaderUserId') ? 'initialLeaderUserId-error' : null}">
<div class="field" data-intern-picker>
<span class="field-label">Initial Leader</span>
<button class="button picker-trigger" type="button" data-picker-open
th:disabled="${#lists.isEmpty(eligibleInternOptions)}"
th:attr="aria-invalid=${#fields.hasErrors('initialLeaderUserId')},aria-describedby=${#fields.hasErrors('initialLeaderUserId') ? 'initialLeaderUserId-error' : null}">Choose an eligible Intern</button>
<p class="picker-summary" data-picker-summary aria-live="polite">No Intern selected</p>
<p class="field-help" th:if="${#lists.isEmpty(eligibleInternOptions)}">No eligible Interns are available.</p>
<p class="field-error" id="initialLeaderUserId-error" role="alert" th:if="${#fields.hasErrors('initialLeaderUserId')}" th:errors="*{initialLeaderUserId}">Leader error</p>
<dialog class="picker-drawer" data-picker-dialog aria-labelledby="leader-picker-title">
<div class="picker-header"><div><h2 class="panel-title" id="leader-picker-title">Choose initial Leader</h2><p class="field-help">Only currently eligible Interns are available.</p></div><button class="button" type="button" data-picker-cancel>Cancel</button></div>
<div class="picker-body">
<label class="field-label" for="leader-search">Search by name or Student Code</label>
<input class="control" id="leader-search" type="search" autocomplete="off" data-picker-search>
<div class="picker-options">
<label class="picker-option" data-picker-option th:each="option : ${eligibleInternOptions}" th:attr="data-picker-search=${option.displayName + ' ' + option.studentCode}">
<input type="radio" th:field="*{initialLeaderUserId}" th:value="${option.userId}" required>
<span><strong data-picker-label th:text="|${option.displayName} (${option.studentCode})|">Intern (Code)</strong><small th:text="|${#temporals.format(option.internshipStart, 'dd/MM/yyyy')} ${#temporals.format(option.internshipEnd, 'dd/MM/yyyy')}|">Dates</small></span>
</label>
<p class="picker-empty" data-picker-empty th:hidden="${!#lists.isEmpty(eligibleInternOptions)}">No matching eligible Interns.</p>
</div>
</div>
<div class="picker-footer"><button class="button button-primary" type="button" data-picker-apply>Use selection</button></div>
</dialog>
</div>
</div>
<div class="form-actions"><a class="button" th:href="@{/projects}">Cancel</a><button class="button button-primary" type="submit">Create Project</button></div>
@@ -20,10 +20,32 @@
<tbody><tr th:each="term : ${leadership}"><td th:text="${term.leaderName}">Leader</td><td th:text="${#temporals.format(term.startedAt, 'dd/MM/yyyy HH:mm')}">Started</td><td th:text="${term.endedAt == null ? 'Current' : #temporals.format(term.endedAt, 'dd/MM/yyyy HH:mm')}">Current</td></tr></tbody>
</table></div>
</section>
<form class="panel form-panel filter-form" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}" th:object="${projectMemberForm}">
<form class="panel form-panel form-grid" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/leadership(id=${project.id})}" th:object="${projectMemberForm}">
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}">Please correct the Leader selection.</div>
<div class="field"><label class="field-label" for="leader">New Leader user ID</label><input class="control" id="leader" th:field="*{internUserId}" type="number" min="1" required th:attr="aria-invalid=${#fields.hasErrors('internUserId')},aria-describedby=${#fields.hasErrors('internUserId') ? 'leadership-intern-user-error' : null}"><p class="field-error" id="leadership-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserId')}" th:errors="*{internUserId}"></p></div>
<span></span><button class="button button-primary" type="submit">Change Leader</button>
<div class="field" data-intern-picker>
<span class="field-label">New Leader</span>
<button class="button picker-trigger" type="button" data-picker-open th:disabled="${#lists.isEmpty(eligibleInternOptions)}"
th:attr="aria-invalid=${#fields.hasErrors('internUserId')},aria-describedby=${#fields.hasErrors('internUserId') ? 'leadership-intern-user-error' : null}">Choose a current member</button>
<p class="picker-summary" data-picker-summary aria-live="polite">No Intern selected</p>
<p class="field-help" th:if="${#lists.isEmpty(eligibleInternOptions)}">No eligible current members are available.</p>
<p class="field-error" id="leadership-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserId')}" th:errors="*{internUserId}"></p>
<dialog class="picker-drawer" data-picker-dialog aria-labelledby="leadership-picker-title">
<div class="picker-header"><div><h2 class="panel-title" id="leadership-picker-title">Choose new Leader</h2><p class="field-help">Only eligible current members other than the current Leader are available.</p></div><button class="button" type="button" data-picker-cancel>Cancel</button></div>
<div class="picker-body">
<label class="field-label" for="leadership-search">Search by name or Student Code</label>
<input class="control" id="leadership-search" type="search" autocomplete="off" data-picker-search>
<div class="picker-options">
<label class="picker-option" data-picker-option th:each="option : ${eligibleInternOptions}" th:attr="data-picker-search=${option.displayName + ' ' + option.studentCode}">
<input type="radio" th:field="*{internUserId}" th:value="${option.userId}" required>
<span><strong data-picker-label th:text="|${option.displayName} (${option.studentCode})|">Intern (Code)</strong><small th:text="|${#temporals.format(option.internshipStart, 'dd/MM/yyyy')} ${#temporals.format(option.internshipEnd, 'dd/MM/yyyy')}|">Dates</small></span>
</label>
<p class="picker-empty" data-picker-empty th:hidden="${!#lists.isEmpty(eligibleInternOptions)}">No matching eligible current members.</p>
</div>
</div>
<div class="picker-footer"><button class="button button-primary" type="button" data-picker-apply>Use selection</button></div>
</dialog>
</div>
<div class="form-actions"><button class="button button-primary" type="submit">Change Leader</button></div>
</form>
</main>
</body>
@@ -20,10 +20,32 @@
<tbody><tr th:each="member : ${members}"><td th:text="${member.displayName}">Intern</td><td th:text="${#temporals.format(member.joinedAt, 'dd/MM/yyyy HH:mm')}">Joined</td><td th:text="${member.leftAt == null ? 'Current' : #temporals.format(member.leftAt, 'dd/MM/yyyy HH:mm')}">Current</td><td><span class="badge" th:classappend="${member.currentLeader ? ' badge-success' : ''}" th:text="${member.currentLeader ? 'Leader' : 'Member'}">Member</span></td></tr></tbody>
</table></div>
</section>
<form class="panel form-panel filter-form" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}" th:object="${projectMemberForm}">
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}">Please correct the member selection.</div>
<div class="field"><label class="field-label" for="intern">Intern user ID</label><input class="control" id="intern" th:field="*{internUserId}" type="number" min="1" required th:attr="aria-invalid=${#fields.hasErrors('internUserId')},aria-describedby=${#fields.hasErrors('internUserId') ? 'member-intern-user-error' : null}"><p class="field-error" id="member-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserId')}" th:errors="*{internUserId}"></p></div>
<span></span><button class="button button-primary" type="submit">Add member</button>
<form class="panel form-panel form-grid" th:if="${project.canManage}" method="post" th:action="@{/projects/{id}/members(id=${project.id})}" th:object="${projectMembersForm}">
<div class="alert alert-error" role="alert" th:if="${#fields.hasAnyErrors()}"><strong>Member selection could not be saved.</strong><ul><li th:each="fieldError : ${#fields.allErrors()}" th:text="${fieldError}">Selection error</li></ul></div>
<div class="field" data-intern-picker>
<span class="field-label">Interns to add</span>
<button class="button picker-trigger" type="button" data-picker-open th:disabled="${#lists.isEmpty(eligibleInternOptions)}"
th:attr="aria-invalid=${#fields.hasErrors('internUserIds')},aria-describedby=${#fields.hasErrors('internUserIds') ? 'member-intern-user-error' : null}">Choose eligible Interns</button>
<p class="picker-summary" data-picker-summary aria-live="polite">No Interns selected</p>
<p class="field-help" th:if="${#lists.isEmpty(eligibleInternOptions)}">No eligible nonmembers are available.</p>
<p class="field-error" id="member-intern-user-error" role="alert" th:if="${#fields.hasErrors('internUserIds')}" th:errors="*{internUserIds}"></p>
<dialog class="picker-drawer" data-picker-dialog aria-labelledby="member-picker-title">
<div class="picker-header"><div><h2 class="panel-title" id="member-picker-title">Add Project members</h2><p class="field-help">Select one or more eligible Interns who are not current members.</p></div><button class="button" type="button" data-picker-cancel>Cancel</button></div>
<div class="picker-body">
<label class="field-label" for="member-search">Search by name or Student Code</label>
<input class="control" id="member-search" type="search" autocomplete="off" data-picker-search>
<div class="picker-options">
<label class="picker-option" data-picker-option th:each="option : ${eligibleInternOptions}" th:attr="data-picker-search=${option.displayName + ' ' + option.studentCode}">
<input type="checkbox" th:field="*{internUserIds}" th:value="${option.userId}">
<span><strong data-picker-label th:text="|${option.displayName} (${option.studentCode})|">Intern (Code)</strong><small th:text="|${#temporals.format(option.internshipStart, 'dd/MM/yyyy')} ${#temporals.format(option.internshipEnd, 'dd/MM/yyyy')}|">Dates</small></span>
</label>
<p class="picker-empty" data-picker-empty th:hidden="${!#lists.isEmpty(eligibleInternOptions)}">No matching eligible Interns.</p>
</div>
</div>
<div class="picker-footer"><button class="button button-primary" type="button" data-picker-apply>Use selection</button></div>
</dialog>
</div>
<div class="form-actions"><button class="button button-primary" type="submit">Add selected members</button></div>
</form>
</main>
</body>