diff --git a/package.json b/package.json
index 3958f22..6d1303b 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"setup": "node --import dotenv/config setup.js",
"test:db": "node --experimental-sqlite test-db.js",
"test:dashboard": "node --experimental-sqlite test-dashboard.js",
+ "test:multi-assignment": "node --experimental-sqlite test-multi-assignment.js",
"test:tasks": "node --experimental-sqlite test-tasks.js",
"test:shopping": "node --experimental-sqlite test-shopping.js",
"test:meals": "node --experimental-sqlite test-meals.js",
@@ -29,7 +30,7 @@
"test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js",
"test:caldav": "node --experimental-sqlite test-caldav-sync.js",
"test:carddav": "node --experimental-sqlite test-carddav.js",
- "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav && npm run test:carddav"
+ "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-multi-assignment.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav && npm run test:carddav"
},
"dependencies": {
"bcrypt": "^6.0.0",
diff --git a/public/components/user-multi-select.js b/public/components/user-multi-select.js
new file mode 100644
index 0000000..61c8721
--- /dev/null
+++ b/public/components/user-multi-select.js
@@ -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 `
+ ${esc(initials)}
+ `;
+ });
+ if (overflow > 0) {
+ avatars.push(`+${overflow}`);
+ }
+ return `${avatars.join('')}`;
+}
+
+/**
+ * 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 `
+ `;
+ });
+
+ const noneLabel = t('userMultiSelect.nobody');
+ return `
+
+
+
+
+ ${items.join('')}
+
+
`;
+}
+
+/**
+ * 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;
+ }
+ });
+}
diff --git a/public/index.html b/public/index.html
index 5423e07..c48f7dd 100644
--- a/public/index.html
+++ b/public/index.html
@@ -44,6 +44,7 @@
+
diff --git a/public/locales/de.json b/public/locales/de.json
index 36b7830..161aedb 100644
--- a/public/locales/de.json
+++ b/public/locales/de.json
@@ -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"
}
}
diff --git a/public/pages/calendar.js b/public/pages/calendar.js
index 0aeccdf..fffc6b0 100644
--- a/public/pages/calendar.js
+++ b/public/pages/calendar.js
@@ -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 `
@@ -1046,11 +1044,7 @@ function renderAgendaEvent(ev) {
${timeStr}
${ev.location ? `
📍 ${esc(fmtLocation(ev.location))}` : ''}
${ev.cal_name ? `
${esc(ev.cal_name)}` : ''}
- ${ev.assigned_name ? `
-
- ${initials}
- ${esc(ev.assigned_name)}
- ` : ''}
+ ${assignedUsers.length ? `
${renderAvatarStack(assignedUsers, { size: 20, maxVisible: 3 })}` : ''}
@@ -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 }) {
`).join('');
- const userOpts = [
- ``,
- ...state.users.map((u) =>
- ``
- ),
- ].join('');
+ const selectedUserIds = isEdit
+ ? (event.assigned_users?.map((u) => u.id) ?? (event.assigned_to ? [event.assigned_to] : []))
+ : [];
return `
@@ -1660,8 +1652,7 @@ function buildEventModalContent({ mode, event, date, reminder = null }) {
-
-
+ ${renderUserMultiSelect(state.users, selectedUserIds, 'cal_assigned', 'calendar.assignedLabel')}
@@ -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,
diff --git a/public/pages/tasks.js b/public/pages/tasks.js
index 062a62b..c1d05fa 100644
--- a/public/pages/tasks.js
+++ b/public/pages/tasks.js
@@ -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 = {}) {
- ${task.assigned_color ? `
-
- ${esc(initials(task.assigned_name ?? ''))}
-
` : ''}
+ ${renderAvatarStack(task.assigned_users ?? [], { size: 28 })}