feat: multi-person assignment for tasks and calendar events

- DB migration v32: task_assignments and event_assignments join tables
  with CASCADE delete; existing assigned_to data migrated automatically
- Tasks API: accepts assigned_to as array, returns assigned_users[]
  with json_group_array; filter uses EXISTS on task_assignments
- Calendar API: same pattern via event_assignments; serializeEvent
  includes assigned_users array
- Recurring task completion copies all assignments to the new instance
- Frontend: shared UserMultiSelect component with avatar stack display
  (renderAvatarStack, renderUserMultiSelect, getSelectedUserIds,
  bindUserMultiSelect); tasks.js and calendar.js use it in modals
  and card/agenda views
- CSS: user-multi-select.css with avatar-stack and user-ms classes
- 14 new tests covering CRUD, JSON aggregation, EXISTS filter,
  and CASCADE behavior for both task and event assignments

Closes #125
This commit is contained in:
Ulas Kalayci
2026-05-06 10:04:41 +02:00
parent f0503f3df1
commit 2a48fb7af0
12 changed files with 648 additions and 146 deletions
+124
View File
@@ -0,0 +1,124 @@
/**
* Modul: User Multi-Select
* Zweck: Wiederverwendbare Mehrfachauswahl-Komponente für Benutzer (Tasks & Kalender)
* Abhängigkeiten: public/utils/html.js, public/i18n.js
*/
import { esc } from '/utils/html.js';
import { t } from '/i18n.js';
/**
* Rendert einen Avatar-Stack für mehrere zugewiesene Benutzer.
* @param {Array<{id, display_name, color}>} users
* @param {object} opts
* @param {number} [opts.size=28] Avatar-Größe in px
* @param {number} [opts.maxVisible=3] Maximale Avatare vor "+N"-Anzeige
* @returns {string} HTML-String
*/
export function renderAvatarStack(users, { size = 28, maxVisible = 3 } = {}) {
if (!users?.length) return '';
const visible = users.slice(0, maxVisible);
const overflow = users.length - visible.length;
const fs = Math.round(size * 0.4);
const avatars = visible.map((u) => {
const initials = (u.display_name ?? '')
.split(' ')
.map((w) => w[0] ?? '')
.join('')
.toUpperCase()
.slice(0, 2);
return `<span class="avatar-stack__item"
style="width:${size}px;height:${size}px;font-size:${fs}px;background-color:${esc(u.color ?? '#8E8E93')}"
title="${esc(u.display_name ?? '')}">
${esc(initials)}
</span>`;
});
if (overflow > 0) {
avatars.push(`<span class="avatar-stack__item avatar-stack__overflow"
style="width:${size}px;height:${size}px;font-size:${fs}px"
title="${overflow} ${t('userMultiSelect.moreUsers')}">+${overflow}</span>`);
}
return `<span class="avatar-stack">${avatars.join('')}</span>`;
}
/**
* Rendert das Multi-Select-Widget als Dropdown-Checkbox-Liste.
* @param {Array<{id, display_name, avatar_color}>} allUsers Alle verfügbaren Benutzer
* @param {number[]} selectedIds Bereits ausgewählte IDs
* @param {string} inputName Name-Attribut des Widgets
* @param {string} labelKey i18n-Schlüssel für das Label
* @returns {string} HTML-String
*/
export function renderUserMultiSelect(allUsers, selectedIds, inputName, labelKey) {
const selectedSet = new Set(selectedIds ?? []);
const items = allUsers.map((u) => {
const checked = selectedSet.has(u.id) ? 'checked' : '';
const initials = (u.display_name ?? '')
.split(' ')
.map((w) => w[0] ?? '')
.join('')
.toUpperCase()
.slice(0, 2);
return `
<label class="user-ms__option">
<input type="checkbox" class="user-ms__checkbox" value="${u.id}" ${checked}
data-ms-input="${esc(inputName)}">
<span class="user-ms__avatar" style="background-color:${esc(u.avatar_color ?? '#8E8E93')}">
${esc(initials)}
</span>
<span class="user-ms__name">${esc(u.display_name)}</span>
</label>`;
});
const noneLabel = t('userMultiSelect.nobody');
return `
<div class="user-ms" data-ms-name="${esc(inputName)}">
<label class="label">${t(labelKey)}</label>
<div class="user-ms__options">
<label class="user-ms__option">
<input type="checkbox" class="user-ms__checkbox user-ms__none" value=""
data-ms-input="${esc(inputName)}" ${selectedSet.size === 0 ? 'checked' : ''}>
<span class="user-ms__avatar user-ms__avatar--none"></span>
<span class="user-ms__name">${noneLabel}</span>
</label>
${items.join('')}
</div>
</div>`;
}
/**
* Liest die ausgewählten User-IDs aus einem gerenderten Multi-Select-Widget.
* @param {Element} container DOM-Element, das das Widget enthält
* @param {string} inputName Name-Attribut des Widgets
* @returns {number[]}
*/
export function getSelectedUserIds(container, inputName) {
const checkboxes = container.querySelectorAll(
`[data-ms-input="${CSS.escape(inputName)}"]:not(.user-ms__none):checked`
);
return Array.from(checkboxes).map((cb) => Number(cb.value)).filter(Boolean);
}
/**
* Bindet die Checkbox-Logik:
* - "Niemand" deselektiert alle anderen
* - Andere Auswahl deselektiert "Niemand"
* @param {Element} container
* @param {string} inputName
*/
export function bindUserMultiSelect(container, inputName) {
const widget = container.querySelector(`.user-ms[data-ms-name="${CSS.escape(inputName)}"]`);
if (!widget) return;
widget.addEventListener('change', (e) => {
const cb = e.target;
if (!cb.matches('.user-ms__checkbox')) return;
if (cb.classList.contains('user-ms__none') && cb.checked) {
widget.querySelectorAll('.user-ms__checkbox:not(.user-ms__none)').forEach((c) => { c.checked = false; });
} else if (!cb.classList.contains('user-ms__none') && cb.checked) {
const none = widget.querySelector('.user-ms__none');
if (none) none.checked = false;
}
});
}