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
+10 -19
View File
@@ -11,6 +11,7 @@ import { stagger } from '/utils/ux.js';
import { t, formatDate as formatPreferredDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid, formatTimeInput, parseTimeInput, timeInputPlaceholder } from '/i18n.js';
import { esc, fmtLocation } from '/utils/html.js';
import { refresh as refreshReminders } from '/reminders.js';
import { renderUserMultiSelect, getSelectedUserIds, bindUserMultiSelect, renderAvatarStack } from '/components/user-multi-select.js';
// --------------------------------------------------------
// Konstanten
@@ -1032,11 +1033,8 @@ function renderAgendaEvent(ev) {
: formatTime(ev.start_datetime)
+ (ev.end_datetime ? ` ${formatTime(ev.end_datetime)} ${t('calendar.timeSuffix')}`.trimEnd() : ` ${t('calendar.timeSuffix')}`.trimEnd());
const initials = ev.assigned_name
? ev.assigned_name.split(' ').map((w) => w[0]).join('').toUpperCase().slice(0, 2)
: '';
const displayColor = ev.cal_color || ev.color;
const assignedUsers = ev.assigned_users ?? [];
return `
<div class="agenda-event" data-id="${ev.id}">
<div class="agenda-event__color" style="background-color:${esc(displayColor)};"></div>
@@ -1046,11 +1044,7 @@ function renderAgendaEvent(ev) {
<span>${timeStr}</span>
${ev.location ? `<span>📍 ${esc(fmtLocation(ev.location))}</span>` : ''}
${ev.cal_name ? `<span class="event-cal-label" style="--cal-color:${esc(displayColor)}">${esc(ev.cal_name)}</span>` : ''}
${ev.assigned_name ? `
<span class="agenda-event__assigned">
<span class="agenda-event__avatar" style="background-color:${esc(ev.assigned_color || '#8E8E93')}">${initials}</span>
${esc(ev.assigned_name)}
</span>` : ''}
${assignedUsers.length ? `<span class="agenda-event__assigned">${renderAvatarStack(assignedUsers, { size: 20, maxVisible: 3 })}</span>` : ''}
</div>
</div>
</div>
@@ -1328,6 +1322,7 @@ function openEventModal({ mode, event = null, date = null, reminder = null }) {
onSave(panel) {
// RRULE-Events binden
bindRRuleEvents(panel, 'event');
bindUserMultiSelect(panel, 'cal_assigned');
const selectedColor = isEdit ? (event?.color || EVENT_COLORS[0]) : EVENT_COLORS[0];
@@ -1573,12 +1568,9 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
</div>
</div>`).join('');
const userOpts = [
`<option value="">${t('calendar.assignedNobody')}</option>`,
...state.users.map((u) =>
`<option value="${u.id}" ${isEdit && event.assigned_to === u.id ? 'selected' : ''}>${esc(u.display_name)}</option>`
),
].join('');
const selectedUserIds = isEdit
? (event.assigned_users?.map((u) => u.id) ?? (event.assigned_to ? [event.assigned_to] : []))
: [];
return `
<div class="event-title-picker">
@@ -1660,8 +1652,7 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
</div>
<div class="form-group">
<label class="form-label" for="modal-assigned">${t('calendar.assignedLabel')}</label>
<select class="form-input" id="modal-assigned">${userOpts}</select>
${renderUserMultiSelect(state.users, selectedUserIds, 'cal_assigned', 'calendar.assignedLabel')}
</div>
<div class="form-group">
@@ -1738,7 +1729,7 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null, attach
const color = overlay.querySelector('.color-swatch--active')?.dataset.color || EVENT_COLORS[0];
const icon = eventIconName(overlay.querySelector('#modal-icon')?.value);
const location = overlay.querySelector('#modal-location').value.trim() || null;
const assigned_to = overlay.querySelector('#modal-assigned').value || null;
const assigned_to = getSelectedUserIds(overlay, 'cal_assigned');
const description = overlay.querySelector('#modal-description').value.trim() || null;
let start_datetime, end_datetime;
@@ -1815,7 +1806,7 @@ async function saveEvent(overlay, mode, eventId, existingReminder = null, attach
const body = {
title, description, start_datetime, end_datetime,
all_day: allday ? 1 : 0,
location, color, icon, assigned_to: assigned_to ? parseInt(assigned_to, 10) : null,
location, color, icon, assigned_to,
recurrence_rule: rrule.recurrence_rule,
attachment_name: attachmentPayload.name,
attachment_mime: attachmentPayload.mime,
+7 -19
View File
@@ -11,6 +11,7 @@ import { stagger, vibrate } from '/utils/ux.js';
import { t, formatDate, formatTime, dateInputPlaceholder, formatDateInput, parseDateInput, isDateInputValid, formatTimeInput, parseTimeInput, timeInputPlaceholder } from '/i18n.js';
import { esc } from '/utils/html.js';
import { refresh as refreshReminders } from '/reminders.js';
import { renderUserMultiSelect, getSelectedUserIds, bindUserMultiSelect, renderAvatarStack } from '/components/user-multi-select.js';
// --------------------------------------------------------
// Konstanten
@@ -203,11 +204,7 @@ function renderTaskCard(task, opts = {}) {
</div>
</div>
${task.assigned_color ? `
<div class="task-avatar" style="background-color:${esc(task.assigned_color)}"
title="${esc(task.assigned_name)}">
${esc(initials(task.assigned_name ?? ''))}
</div>` : ''}
${renderAvatarStack(task.assigned_users ?? [], { size: 28 })}
<button class="btn btn--ghost btn--icon btn--icon-sm" data-action="edit-task" data-id="${task.id}"
aria-label="${t('tasks.editButton')}">
@@ -305,9 +302,7 @@ function renderTaskGroups(tasks, groupMode) {
function renderModalContent({ task = null, users = [], reminder = null } = {}) {
const isEdit = !!task;
const userOptions = users.map((u) =>
`<option value="${u.id}" ${task?.assigned_to === u.id ? 'selected' : ''}>${esc(u.display_name)}</option>`
).join('');
const selectedIds = task?.assigned_users?.map((u) => u.id) ?? (task?.assigned_to ? [task.assigned_to] : []);
const catLabels = CATEGORY_LABELS();
const categoryOptions = CATEGORIES.map((c) =>
@@ -374,11 +369,7 @@ function renderModalContent({ task = null, users = [], reminder = null } = {}) {
</div>
<div class="form-group" style="margin-top:var(--space-4)">
<label class="label" for="task-assigned">${t('tasks.assignedLabel')}</label>
<select class="input" id="task-assigned" name="assigned_to">
<option value="">${t('tasks.assignedNobody')}</option>
${userOptions}
</select>
${renderUserMultiSelect(users, selectedIds, 'task_assigned', 'tasks.assignedLabel')}
</div>
${isEdit ? `
@@ -553,6 +544,7 @@ function openTaskModal({ task = null, users = [], reminder = null } = {}, contai
panel.querySelector('.modal-panel__body')?.classList.add('modal-panel__body--tasks-fit');
// RRULE-Events binden
bindRRuleEvents(document, 'task');
bindUserMultiSelect(panel, 'task_assigned');
// Blur-Validierung für required-Felder aktivieren
wireBlurValidation(panel);
@@ -629,7 +621,7 @@ async function handleFormSubmit(e, container) {
priority: form.priority.value,
category: form.category.value,
due_date: dueDate || null,
assigned_to: form.assigned_to.value ? Number(form.assigned_to.value) : null,
assigned_to: getSelectedUserIds(form, 'task_assigned'),
is_recurring: rrule.is_recurring ? 1 : 0,
recurrence_rule: rrule.recurrence_rule,
};
@@ -773,11 +765,7 @@ function renderKanbanCard(task) {
${due ? `<span class="due-date ${due.cls}"><i data-lucide="clock" class="icon-xs" aria-hidden="true"></i> ${due.label}</span>` : ''}
</div>
<div class="kanban-card__footer">
${task.assigned_color ? `
<div class="task-avatar" style="background-color:${task.assigned_color};width:22px;height:22px;font-size:9px"
title="${esc(task.assigned_name ?? '')}">
${initials(task.assigned_name ?? '')}
</div>` : '<span></span>'}
${renderAvatarStack(task.assigned_users ?? [], { size: 22 }) || '<span></span>'}
<button class="kanban-card__status-btn" type="button"
data-next-status="${next}" title="${nextLabel}" aria-label="${nextLabel}">
<i data-lucide="${icon}" aria-hidden="true"></i>