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:
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -44,6 +44,7 @@
|
||||
<link rel="stylesheet" href="/styles/glass.css" />
|
||||
<link rel="stylesheet" href="/styles/sub-tabs.css" />
|
||||
<link rel="stylesheet" href="/styles/kitchen-tabs.css" />
|
||||
<link rel="stylesheet" href="/styles/user-multi-select.css" />
|
||||
<link rel="stylesheet" href="/styles/login.css" />
|
||||
|
||||
<!-- Lucide Icons (lokal, v0.469.0) -->
|
||||
|
||||
@@ -1293,5 +1293,9 @@
|
||||
"dropzoneTitle": "Datei hier ablegen oder klicken",
|
||||
"dropzoneHint": "Ziehe eine Datei in diesen Bereich oder nutze die Dateiauswahl.",
|
||||
"selectedFileLabel": "Ausgewählt: {{name}}"
|
||||
},
|
||||
"userMultiSelect": {
|
||||
"nobody": "- Niemand -",
|
||||
"moreUsers": "weitere"
|
||||
}
|
||||
}
|
||||
|
||||
+10
-19
@@ -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
@@ -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>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Avatar-Stack (Mehrfach-Zuweisung Anzeige) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.avatar-stack {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.avatar-stack__item {
|
||||
border-radius: var(--radius-full);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-on-accent);
|
||||
border: 2px solid var(--color-surface);
|
||||
flex-shrink: 0;
|
||||
margin-left: -6px;
|
||||
}
|
||||
|
||||
.avatar-stack__item:last-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.avatar-stack__overflow {
|
||||
background-color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* User Multi-Select Widget */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.user-ms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.user-ms__options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-1);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.user-ms__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.user-ms__option:hover {
|
||||
background: var(--color-surface-alt);
|
||||
}
|
||||
|
||||
.user-ms__checkbox {
|
||||
accent-color: var(--color-accent);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-ms__avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius-full);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-on-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-ms__avatar--none {
|
||||
background-color: var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.user-ms__name {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-primary);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
Reference in New Issue
Block a user