iobroker.kanban 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -0
  3. package/admin/i18n/de.json +100 -0
  4. package/admin/i18n/en.json +100 -0
  5. package/admin/i18n/es.json +100 -0
  6. package/admin/i18n/fr.json +100 -0
  7. package/admin/i18n/it.json +100 -0
  8. package/admin/i18n/nl.json +100 -0
  9. package/admin/i18n/pl.json +100 -0
  10. package/admin/i18n/pt.json +100 -0
  11. package/admin/i18n/ru.json +100 -0
  12. package/admin/i18n/uk.json +100 -0
  13. package/admin/i18n/zh-cn.json +100 -0
  14. package/admin/jsonConfig.json +668 -0
  15. package/admin/kanban.png +0 -0
  16. package/io-package.json +299 -0
  17. package/lib/cron.js +203 -0
  18. package/lib/dueat.js +77 -0
  19. package/lib/events.js +24 -0
  20. package/lib/holidays.js +167 -0
  21. package/lib/i18n-server.js +301 -0
  22. package/lib/notify.js +627 -0
  23. package/lib/scheduler.js +198 -0
  24. package/lib/server.js +799 -0
  25. package/lib/store.js +1500 -0
  26. package/main.js +382 -0
  27. package/package.json +58 -0
  28. package/www/css/app.css +1022 -0
  29. package/www/i18n/de.json +238 -0
  30. package/www/i18n/en.json +238 -0
  31. package/www/i18n/es.json +238 -0
  32. package/www/i18n/fr.json +238 -0
  33. package/www/i18n/it.json +238 -0
  34. package/www/i18n/nl.json +238 -0
  35. package/www/i18n/pl.json +238 -0
  36. package/www/i18n/pt.json +238 -0
  37. package/www/i18n/ru.json +238 -0
  38. package/www/i18n/uk.json +238 -0
  39. package/www/i18n/zh-cn.json +238 -0
  40. package/www/index.html +171 -0
  41. package/www/js/api.js +98 -0
  42. package/www/js/app.js +411 -0
  43. package/www/js/board.js +983 -0
  44. package/www/js/colorpicker.js +165 -0
  45. package/www/js/dialogs.js +1419 -0
  46. package/www/js/i18n.js +43 -0
  47. package/www/vendor/marked.min.js +69 -0
  48. package/www/vendor/purify.min.js +3 -0
  49. package/www/vendor/sortable.min.js +2 -0
@@ -0,0 +1,1419 @@
1
+ // Karten-Dialog + Board-/Spalten-Verwaltung
2
+
3
+ import { openColorPicker, closeColorPicker, colorPickerOpen } from './colorpicker.js';
4
+ import { api } from './api.js';
5
+ import { t } from './i18n.js';
6
+ import { boardUsers, boardMembers, contrastText, mdiIcon, MDI } from './board.js';
7
+
8
+ const CARD_COLORS = ['', '#e57373', '#ffb74d', '#fff176', '#aed581', '#4fc3f7', '#9575cd', '#f06292', '#a1887f'];
9
+ const WEEKDAYS = [['Mo', 1], ['Di', 2], ['Mi', 3], ['Do', 4], ['Fr', 5], ['Sa', 6], ['So', 7]];
10
+
11
+ function el(tag, cls, text) {
12
+ const e = document.createElement(tag);
13
+ if (cls) e.className = cls;
14
+ if (text !== undefined) e.textContent = text;
15
+ return e;
16
+ }
17
+
18
+ // Klickbarer Farb-Swatch, der den eingebetteten Colorpicker öffnet.
19
+ // Aktuelle Farbe liegt in dataset.color; onChange(col) wird live aufgerufen.
20
+ function makeColorTrigger(initial, onChange, opts = {}) {
21
+ const t = el('span', 'cp-trigger');
22
+ t.tabIndex = 0;
23
+ const set = col => { t.dataset.color = col || ''; t.style.background = col || ''; t.classList.toggle('empty', !col); };
24
+ set(initial || '');
25
+ const open = () => openColorPicker(t, t.dataset.color, col => { set(col); if (onChange) onChange(col); },
26
+ { presets: opts.presets || CARD_COLORS });
27
+ t.addEventListener('click', open);
28
+ t.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } });
29
+ return t;
30
+ }
31
+
32
+ function initials(name) {
33
+ return String(name || '?').split(/[\s_-]+/).map(p => p[0] || '').join('').slice(0, 2).toUpperCase();
34
+ }
35
+
36
+ // Bilddatei quadratisch zuschneiden + auf `size` px verkleinern → PNG-Data-URL
37
+ function fileToSquareDataUrl(file, size) {
38
+ return loadImageSource(file).then(src => {
39
+ const c = document.createElement('canvas');
40
+ c.width = c.height = size;
41
+ const ctx = c.getContext('2d');
42
+ const dim = Math.min(src.width, src.height);
43
+ ctx.drawImage(src, (src.width - dim) / 2, (src.height - dim) / 2, dim, dim, 0, 0, size, size);
44
+ if (src.close) src.close();
45
+ return c.toDataURL('image/png');
46
+ });
47
+ }
48
+
49
+ // Laedt eine Bilddatei OHNE blob:-URL (CSP-konform): bevorzugt createImageBitmap,
50
+ // Fallback ueber eine data:-URL (von der CSP erlaubt), da img-src kein blob: zulaesst.
51
+ function loadImageSource(file) {
52
+ if (window.createImageBitmap) return createImageBitmap(file);
53
+ return new Promise(function (resolve, reject) {
54
+ var fr = new FileReader();
55
+ fr.onload = function () {
56
+ var img = new Image();
57
+ img.onload = function () { resolve(img); };
58
+ img.onerror = function () { reject(new Error('Bild konnte nicht gelesen werden')); };
59
+ img.src = fr.result;
60
+ };
61
+ fr.onerror = function () { reject(new Error('Datei konnte nicht gelesen werden')); };
62
+ fr.readAsDataURL(file);
63
+ });
64
+ }
65
+
66
+ function slugify(text) {
67
+ return String(text || '').toLowerCase()
68
+ .replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
69
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'x';
70
+ }
71
+
72
+ export function initDialogs(state, actions) {
73
+ const dlg = document.getElementById('cardDialog');
74
+ const form = document.getElementById('cardForm');
75
+ // Monats-Select einmalig lokalisiert befüllen (defensiv: bricht nicht die
76
+ // ganze App ab, falls veraltetes HTML gecacht wurde und das Feld fehlt)
77
+ const recMonthSel = form && form.elements ? form.elements.recMonth : null;
78
+ if (recMonthSel && !recMonthSel.options.length) {
79
+ for (let m = 1; m <= 12; m++) recMonthSel.appendChild(new Option(t('month.' + m), String(m)));
80
+ }
81
+ // Listener nur setzen, wenn das Feld existiert (robust gegen veraltetes HTML)
82
+ const on = (name, ev, fn) => { const e = form.elements[name]; if (e) e.addEventListener(ev, fn); };
83
+ let editingCardId = null;
84
+ let selAssignees = new Set();
85
+ let selLabels = new Set();
86
+ let selColor = '';
87
+
88
+ // ---------------------------------------------------------- Karten-Dialog
89
+
90
+ function renderAssigneePick() {
91
+ const box = document.getElementById('assigneePick');
92
+ box.textContent = '';
93
+ const list = boardUsers(state).slice();
94
+ for (const u of (state.users || [])) if (selAssignees.has(u.name) && !list.some(x => x.name === u.name)) list.push(u);
95
+ for (const u of list) {
96
+ const chip = el('span', 'pick-chip', u.displayName);
97
+ chip.style.borderColor = selAssignees.has(u.name) ? (u.color || '') : '';
98
+ if (selAssignees.has(u.name)) chip.classList.add('selected');
99
+ chip.addEventListener('click', () => {
100
+ selAssignees.has(u.name) ? selAssignees.delete(u.name) : selAssignees.add(u.name);
101
+ renderAssigneePick();
102
+ });
103
+ box.appendChild(chip);
104
+ }
105
+ updateAssigneeValidity();
106
+ }
107
+
108
+ // Pflicht: mindestens ein Zustaendiger. Proxy-Input traegt die (lokalisierte)
109
+ // Fehlermeldung, damit die native Browser-Blase erscheint - wie beim Titel.
110
+ function updateAssigneeValidity() {
111
+ const p = document.getElementById('assigneeValidity');
112
+ if (p) p.setCustomValidity(selAssignees.size ? '' : t('card.assigneeRequired'));
113
+ }
114
+
115
+ function renderLabelPick() {
116
+ const box = document.getElementById('labelPick');
117
+ box.textContent = '';
118
+ for (const l of (state.board && state.board.labels) || []) {
119
+ const chip = el('span', 'pick-chip', l.title);
120
+ if (selLabels.has(l.id)) {
121
+ chip.classList.add('selected');
122
+ chip.style.background = l.color || '';
123
+ chip.style.color = contrastText(l.color || '#888');
124
+ }
125
+ chip.addEventListener('click', () => {
126
+ selLabels.has(l.id) ? selLabels.delete(l.id) : selLabels.add(l.id);
127
+ renderLabelPick();
128
+ });
129
+ box.appendChild(chip);
130
+ }
131
+ const add = el('span', 'pick-chip', t('label.new'));
132
+ add.addEventListener('click', () => showLabelCreator(box));
133
+ box.appendChild(add);
134
+ }
135
+
136
+ // Inline-Mini-Form zum Anlegen eines Labels (Name + echter Colorpicker)
137
+ function showLabelCreator(box) {
138
+ if (box.querySelector('.label-new')) return;
139
+ const form = el('span', 'label-new');
140
+ const color = makeColorTrigger('#4CAF50');
141
+ const name = document.createElement('input');
142
+ name.type = 'text';
143
+ name.placeholder = t('label.namePlaceholder');
144
+ name.maxLength = 40;
145
+ const ok = el('button', 'mini', '✓'); ok.type = 'button';
146
+ const cancel = el('button', 'mini', '×'); cancel.type = 'button';
147
+ const submit = async () => {
148
+ const title = name.value.trim();
149
+ if (!title) { name.focus(); return; }
150
+ const id = slugify(title);
151
+ const col = color.dataset.color || '#4CAF50';
152
+ const existing = (state.board && state.board.labels) || [];
153
+ const labels = existing.some(l => l.id === id)
154
+ ? existing.map(l => l.id === id ? { ...l, title, color: col } : l)
155
+ : [...existing, { id, title, color: col }];
156
+ await actions.patchBoard({ labels });
157
+ selLabels.add(id);
158
+ renderLabelPick();
159
+ };
160
+ ok.addEventListener('click', submit);
161
+ name.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); submit(); } });
162
+ cancel.addEventListener('click', () => renderLabelPick());
163
+ form.append(color, name, ok, cancel);
164
+ box.insertBefore(form, box.lastChild); // vor den "+ Neu"-Chip
165
+ name.focus();
166
+ }
167
+
168
+ function renderColorPick() {
169
+ const box = document.getElementById('colorPick');
170
+ box.textContent = '';
171
+ const swatches = [];
172
+ for (const c of CARD_COLORS) {
173
+ const sw = el('span', 'color-swatch' + (c ? '' : ' none'));
174
+ if (c) sw.style.background = c;
175
+ sw.dataset.color = c;
176
+ sw.addEventListener('click', () => { selColor = c; updateSelection(); });
177
+ box.appendChild(sw);
178
+ swatches.push(sw);
179
+ }
180
+ // Freie Farbwahl (voller Farbraum) über den eigenen Colorpicker
181
+ const custom = el('span', 'color-swatch custom');
182
+ custom.title = t('color.own');
183
+ custom.tabIndex = 0;
184
+ const openCustom = () => openColorPicker(custom, selColor, col => { selColor = col; updateSelection(); }, { presets: CARD_COLORS });
185
+ custom.addEventListener('click', openCustom);
186
+ custom.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openCustom(); } });
187
+ box.appendChild(custom);
188
+
189
+ function updateSelection() {
190
+ const isCustom = selColor && !CARD_COLORS.includes(selColor);
191
+ for (const sw of swatches) sw.classList.toggle('selected', sw.dataset.color === selColor);
192
+ custom.classList.toggle('selected', !!isCustom);
193
+ custom.style.background = isCustom ? selColor : '';
194
+ }
195
+ updateSelection();
196
+ }
197
+
198
+ // Checklisten-Punkte lassen sich per Anfasser umsortieren (ab 2 Punkten sichtbar).
199
+ function ensureChecklistSortable(box) {
200
+ if (!box || box._sortable || typeof Sortable === 'undefined') return;
201
+ // Gleiche Konfiguration wie die Spaltenliste im Board-Dialog: natives
202
+ // Drag & Drop. (forceFallback hängt den Drag-Klon in den Container und
203
+ // verschiebt damit die Trefferberechnung – der Punkt landete immer unten.)
204
+ box._sortable = Sortable.create(box, {
205
+ handle: '.ck-grip',
206
+ draggable: '.check-item',
207
+ animation: 150,
208
+ ghostClass: 'ck-ghost',
209
+ delay: 150, // Touch: kurz halten zum Ziehen, Scrollen bleibt möglich
210
+ delayOnTouchOnly: true,
211
+ });
212
+ }
213
+ function updateCheckGrips() {
214
+ const box = document.getElementById('checklistEdit');
215
+ if (box) box.classList.toggle('multi', box.querySelectorAll('.check-item').length > 1);
216
+ }
217
+
218
+ function addCheckRow(item) {
219
+ const box = document.getElementById('checklistEdit');
220
+ const row = el('div', 'check-item');
221
+ const grip = el('span', 'ck-grip');
222
+ const gicon = mdiIcon(MDI.sortGrid);
223
+ gicon.setAttribute('width', '14');
224
+ gicon.setAttribute('height', '14');
225
+ grip.appendChild(gicon);
226
+ grip.title = t('card.checkDrag');
227
+ const cb = document.createElement('input');
228
+ cb.type = 'checkbox';
229
+ cb.checked = !!(item && item.done);
230
+ const txt = document.createElement('input');
231
+ txt.type = 'text';
232
+ txt.value = (item && item.text) || '';
233
+ txt.placeholder = t('card.checkPlaceholder');
234
+ const rm = el('button', 'rm', '×');
235
+ rm.type = 'button';
236
+ rm.addEventListener('click', () => { row.remove(); updateCheckGrips(); });
237
+ row.append(grip, cb, txt, rm);
238
+ box.appendChild(row);
239
+ ensureChecklistSortable(box);
240
+ updateCheckGrips();
241
+ return row;
242
+ }
243
+
244
+ function readChecklist() {
245
+ return [...document.querySelectorAll('#checklistEdit .check-item')]
246
+ .map(row => ({
247
+ text: row.querySelector('input[type=text]').value.trim(),
248
+ done: row.querySelector('input[type=checkbox]').checked,
249
+ }))
250
+ .filter(i => i.text);
251
+ }
252
+
253
+ function fillColumnSelect(selected) {
254
+ const sel = form.elements.columnId;
255
+ sel.textContent = '';
256
+ for (const c of (state.board && state.board.columns) || []) {
257
+ if (c.isTrash) continue; // Papierkorb ist kein wählbares Ziel
258
+ const o = document.createElement('option');
259
+ o.value = c.id;
260
+ o.textContent = c.title;
261
+ sel.appendChild(o);
262
+ }
263
+ if (selected) sel.value = selected;
264
+ }
265
+
266
+ function isoTodayStr() {
267
+ const d = new Date();
268
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
269
+ }
270
+
271
+ /**
272
+ * Markdown rendern und sicher einfuegen. Links oeffnen immer in einem neuen
273
+ * Tab (target=_blank + rel), damit das Board nicht verlassen wird.
274
+ */
275
+ function renderMarkdownInto(target, text) {
276
+ const txt = String(text || '').trim();
277
+ if (!txt) { target.textContent = ''; return; }
278
+ // eslint-disable-next-line no-undef
279
+ const html = marked.parse(txt);
280
+ // Markdown darf rohes HTML enthalten → vor dem Einfügen säubern (verhindert gespeichertes XSS).
281
+ // eslint-disable-next-line no-undef
282
+ if (window.DOMPurify) target.innerHTML = DOMPurify.sanitize(html);
283
+ else { target.textContent = txt; return; } // Fallback ohne Sanitizer: nur Text
284
+ for (const a of target.querySelectorAll('a[href]')) {
285
+ if (/^(mailto:|tel:)/i.test(a.getAttribute('href') || '')) continue;
286
+ a.target = '_blank';
287
+ a.rel = 'noopener noreferrer';
288
+ }
289
+ }
290
+
291
+ // Beschreibung nur lesend anzeigen (Klick auf das Notiz-Icon der Karte).
292
+ function openDescription(cardId) {
293
+ const card = state.board && state.board.cards.find(c => c.id === cardId);
294
+ if (!card || !card.description) return;
295
+ const dd = document.getElementById('descDialog');
296
+ const body = document.getElementById('descDialogBody');
297
+ body.textContent = '';
298
+ body.appendChild(el('h3', 'desc-title', card.title || ''));
299
+ const md = el('div', 'md-preview desc-body');
300
+ renderMarkdownInto(md, card.description);
301
+ body.appendChild(md);
302
+ const foot = el('div', 'desc-foot');
303
+ const close = el('button', null, t('desc.close'));
304
+ close.type = 'button';
305
+ close.addEventListener('click', () => dd.close());
306
+ foot.appendChild(close);
307
+ body.appendChild(foot);
308
+ // Klick auf den Bereich ausserhalb (der Dialog selbst fuellt nur den
309
+ // Rahmen, der Inhalt liegt in #descDialogBody) schliesst das Fenster.
310
+ if (!dd.dataset.wired) {
311
+ dd.dataset.wired = '1';
312
+ dd.addEventListener('mousedown', ev => { if (ev.target === dd) dd.close(); });
313
+ }
314
+ dd.showModal();
315
+ }
316
+
317
+ // Feature 3: erledigte Karte kopieren → Editor als NEUE Karte mit gleichen Inhalten.
318
+ function copyCard(cardId) {
319
+ const src = state.board && state.board.cards.find(c => c.id === cardId);
320
+ if (!src) return;
321
+ editingCardId = null;
322
+ document.getElementById('cardDialogTitle').textContent = t('card.titleNew');
323
+ document.getElementById('deleteCardBtn').hidden = true;
324
+ document.getElementById('transferCardBtn').hidden = true;
325
+ form.elements.title.value = src.title || '';
326
+ form.elements.description.value = src.description || '';
327
+ // Fälligkeit: heutiges Datum, wenn das Original eins hatte (Uhrzeit behalten), sonst leer
328
+ const hadDue = !!src.due;
329
+ form.elements.due.value = hadDue ? isoTodayStr() : '';
330
+ form.elements.dueTime.value = (hadDue && src.dueTime) ? src.dueTime : '';
331
+ form.elements.dueTimeEnabled.checked = !!(hadDue && src.dueTime);
332
+ updateDueTimeUI();
333
+ form.elements.priority.value = String(src.priority || 0);
334
+ form.elements.link.value = src.link || '';
335
+ form.elements.location.value = src.location || '';
336
+ form.elements.calendarInvite.checked = !!src.calendarInvite;
337
+ form.elements.calendarDuration.value = src.calendarDuration || '01:00';
338
+ updateCalDurUI();
339
+ // Zielspalte: erste „Neu"-Spalte, sonst erste normale Spalte
340
+ const cols = (state.board.columns || []).filter(c => !c.isTrash);
341
+ const target = cols.find(c => c.allowAdd) || cols[0];
342
+ fillColumnSelect(target && target.id);
343
+ selAssignees = new Set(src.assignees || []);
344
+ selLabels = new Set(src.labels || []);
345
+ selColor = src.color || '';
346
+ renderAssigneePick();
347
+ renderLabelPick();
348
+ renderColorPick();
349
+ const box = document.getElementById('checklistEdit');
350
+ box.textContent = '';
351
+ for (const item of (src.checklist || [])) addCheckRow({ text: item.text, done: false });
352
+ updateCheckGrips();
353
+ loadRecurrence(src.recurrence);
354
+ updatePreview();
355
+ dlg.showModal();
356
+ }
357
+
358
+ function openCard(cardId, defaultColumnId) {
359
+ const card = cardId && state.board ? state.board.cards.find(c => c.id === cardId) : null;
360
+ editingCardId = card ? card.id : null;
361
+ document.getElementById('cardDialogTitle').textContent = card ? t('card.titleEdit') : t('card.titleNew');
362
+ document.getElementById('deleteCardBtn').hidden = !card;
363
+ // Auch bei nur einem Board sinnvoll: der Dialog kann die Karte klonen
364
+ document.getElementById('transferCardBtn').hidden = !card;
365
+ form.elements.title.value = card ? card.title : '';
366
+ form.elements.description.value = card ? card.description : '';
367
+ form.elements.due.value = (card && card.due) || '';
368
+ form.elements.dueTime.value = (card && card.dueTime) || '';
369
+ form.elements.dueTimeEnabled.checked = !!(card && card.dueTime);
370
+ updateDueTimeUI();
371
+ form.elements.priority.value = String((card && card.priority) || 0);
372
+ form.elements.link.value = (card && card.link) || '';
373
+ form.elements.location.value = (card && card.location) || '';
374
+ form.elements.calendarInvite.checked = !!(card && card.calendarInvite);
375
+ form.elements.calendarDuration.value = (card && card.calendarDuration) || '01:00';
376
+ updateCalDurUI();
377
+ fillColumnSelect(card ? card.columnId : defaultColumnId);
378
+ selAssignees = new Set(card ? card.assignees : []);
379
+ selLabels = new Set(card ? card.labels : []);
380
+ selColor = (card && card.color) || '';
381
+ renderAssigneePick();
382
+ renderLabelPick();
383
+ renderColorPick();
384
+ const box = document.getElementById('checklistEdit');
385
+ box.textContent = '';
386
+ for (const item of (card && card.checklist) || []) addCheckRow(item);
387
+ updateCheckGrips();
388
+ loadRecurrence(card && card.recurrence);
389
+ updatePreview();
390
+ dlg.showModal();
391
+ }
392
+
393
+ // ---- Wiederholung ------------------------------------------------------
394
+ let selWeekdays = new Set();
395
+
396
+ function renderWeekdayPick() {
397
+ const box = document.getElementById('recWeekdayWrap');
398
+ box.textContent = '';
399
+ // Bei "Monatlich (Wochentag)" nur EIN Wochentag, sonst Mehrfachauswahl.
400
+ const single = form.elements.recType.value === 'monthly_weekday';
401
+ for (const [, iso] of WEEKDAYS) {
402
+ const chip = el('span', 'pick-chip' + (selWeekdays.has(iso) ? ' selected' : ''), t('weekday.' + iso));
403
+ chip.addEventListener('click', () => {
404
+ if (single) selWeekdays = new Set([iso]);
405
+ else if (selWeekdays.has(iso)) selWeekdays.delete(iso);
406
+ else selWeekdays.add(iso);
407
+ renderWeekdayPick();
408
+ updateRecUI();
409
+ });
410
+ box.appendChild(chip);
411
+ }
412
+ }
413
+
414
+ function loadRecurrence(rec) {
415
+ rec = rec || {};
416
+ form.elements.recType.value = rec.type || 'none';
417
+ form.elements.recInterval.value = rec.interval || 2;
418
+ form.elements.recDom.value = rec.dayOfMonth || (new Date().getDate());
419
+ form.elements.recMonth.value = String(rec.month || (new Date().getMonth() + 1));
420
+ form.elements.recOrdinal.value = String(rec.ordinal || 1);
421
+ form.elements.recWorkdayPos.value = rec.workdayPos || 'first';
422
+ form.elements.recWorkdayN.value = rec.n || 1;
423
+ form.elements.recCron.value = rec.cron || '';
424
+ const initWd = (rec.dayOfWeek && rec.dayOfWeek.length) ? rec.dayOfWeek : [isoToday()];
425
+ selWeekdays = new Set(rec.type === 'monthly_weekday' ? [initWd[0]] : initWd);
426
+ renderWeekdayPick();
427
+ updateRecUI();
428
+ }
429
+
430
+ function isoToday() { const d = new Date().getDay(); return d === 0 ? 7 : d; }
431
+
432
+ function updateRecUI() {
433
+ const type = form.elements.recType.value;
434
+ const pos = form.elements.recWorkdayPos.value;
435
+ document.getElementById('recWeekdayWrap').hidden = !(type === 'weekly' || type === 'monthly_weekday');
436
+ document.getElementById('recIntervalWrap').hidden = type !== 'every_n_days';
437
+ document.getElementById('recDomWrap').hidden = !(type === 'monthly' || type === 'yearly');
438
+ document.getElementById('recMonthWrap').hidden = type !== 'yearly';
439
+ document.getElementById('recOrdinalWrap').hidden = type !== 'monthly_weekday';
440
+ document.getElementById('recWorkdayPosWrap').hidden = type !== 'workday';
441
+ document.getElementById('recWorkdayNWrap').hidden = !(type === 'workday' && (pos === 'nth' || pos === 'nth_last'));
442
+ document.getElementById('recCronWrap').hidden = type !== 'cron';
443
+ document.getElementById('recCronInfo').hidden = type !== 'cron';
444
+ // Bei Cron gibt das Muster die Uhrzeit vor — ein frei editierbares Feld
445
+ // wuerde etwas anderes anzeigen als die Karte spaeter bekommt.
446
+ form.elements.dueTimeEnabled.disabled = type === 'cron';
447
+ form.elements.dueTime.readOnly = type === 'cron';
448
+ if (type === 'cron') scheduleCronCheck();
449
+ const hint = document.getElementById('recHint');
450
+ const txt = type === 'none' ? '' : t('rec.hint.' + type);
451
+ hint.hidden = !txt;
452
+ hint.textContent = txt || '';
453
+ }
454
+
455
+ on('recType', 'change', () => { renderWeekdayPick(); updateRecUI(); });
456
+ on('recWorkdayPos', 'change', updateRecUI);
457
+ on('recCron', 'input', scheduleCronCheck);
458
+
459
+ // ---- Cron: Klartext und naechste Termine -------------------------------
460
+ let cronTimer = null;
461
+ function scheduleCronCheck() {
462
+ clearTimeout(cronTimer);
463
+ cronTimer = setTimeout(runCronCheck, 300);
464
+ }
465
+
466
+ const CRON_FIELDS = ['cron.minute', 'cron.hour', 'cron.day', 'cron.month', 'cron.weekday'];
467
+ const CRON_DOW = ['cron.sun', 'cron.mon', 'cron.tue', 'cron.wed', 'cron.thu', 'cron.fri', 'cron.sat'];
468
+
469
+ /** Muster in Worte fassen. Gaengige Faelle als Satz, alles Weitere feldweise —
470
+ * eine erzwungene Satzform waere bei '1-5/2' irrefuehrender als die Aufzaehlung. */
471
+ function cronText(expr) {
472
+ const p = expr.trim().split(/\s+/);
473
+ if (p.length !== 5) return '';
474
+ const [mi, ho, dom, mon, dow] = p;
475
+ const time = /^\d+$/.test(mi) && /^\d+$/.test(ho)
476
+ ? `${String(ho).padStart(2, '0')}:${String(mi).padStart(2, '0')}` : null;
477
+ const named = n => t(CRON_DOW[Number(n) % 7]);
478
+ const simple = mon === '*' && (dom === '*' || dow === '*');
479
+ if (time && simple) {
480
+ if (dom === '*' && dow === '*') return t('cron.everyDayAt', { time });
481
+ if (dow === '*') {
482
+ if (/^\d+$/.test(dom)) return t('cron.monthlyAt', { day: dom, time });
483
+ } else if (/^\d+-\d+$/.test(dow)) {
484
+ const [a, b] = dow.split('-');
485
+ return t('cron.rangeAt', { from: named(a), to: named(b), time });
486
+ } else if (/^\d+(,\d+)*$/.test(dow)) {
487
+ return t('cron.daysAt', { days: dow.split(',').map(named).join(', '), time });
488
+ }
489
+ }
490
+ return p.map((v, i) => `${t(CRON_FIELDS[i])} ${v}`).join(' · ');
491
+ }
492
+
493
+ async function runCronCheck() {
494
+ const box = document.getElementById('recCronInfo');
495
+ const expr = form.elements.recCron.value.trim();
496
+ box.textContent = '';
497
+ if (!expr) { box.appendChild(el('span', 'hint', t('cron.empty'))); return; }
498
+ let res;
499
+ try { res = await api('api/cron/check?expr=' + encodeURIComponent(expr)); }
500
+ catch (e) { box.appendChild(el('span', 'cron-bad', e.message)); return; }
501
+ if (!res.ok) { box.appendChild(el('span', 'cron-bad', res.error)); return; }
502
+ box.appendChild(el('div', 'cron-good', cronText(expr)));
503
+ if (res.next && res.next.length) {
504
+ box.appendChild(el('div', 'cron-next', t('cron.next', { dates: res.next.map(fmtDateShort).join(' · ') })));
505
+ }
506
+ // Uhrzeit der Karte an das Muster angleichen
507
+ if (res.time) {
508
+ form.elements.dueTime.value = res.time;
509
+ form.elements.dueTimeEnabled.checked = true;
510
+ document.getElementById('dueTimeWrap').hidden = false;
511
+ }
512
+ }
513
+
514
+ function fmtDateShort(iso) {
515
+ const [y, m, d] = iso.split('-');
516
+ return `${d}.${m}.${y}`;
517
+ }
518
+
519
+ function readRecurrence() {
520
+ const t = form.elements.recType.value;
521
+ if (t === 'none') return null;
522
+ const rec = { type: t };
523
+ if (t === 'weekly') rec.dayOfWeek = [...selWeekdays].sort((a, b) => a - b);
524
+ if (t === 'monthly' || t === 'yearly') rec.dayOfMonth = Number(form.elements.recDom.value) || 1;
525
+ if (t === 'yearly') rec.month = Number(form.elements.recMonth.value) || 1;
526
+ if (t === 'monthly_weekday') {
527
+ rec.dayOfWeek = [[...selWeekdays][0] || isoToday()];
528
+ rec.ordinal = Number(form.elements.recOrdinal.value) || 1;
529
+ }
530
+ if (t === 'workday') {
531
+ rec.workdayPos = form.elements.recWorkdayPos.value || 'first';
532
+ if (rec.workdayPos === 'nth' || rec.workdayPos === 'nth_last') {
533
+ rec.n = Math.max(1, Number(form.elements.recWorkdayN.value) || 1);
534
+ }
535
+ }
536
+ if (t === 'cron') rec.cron = form.elements.recCron.value.trim();
537
+ if (t === 'every_n_days') {
538
+ rec.interval = Math.max(1, Number(form.elements.recInterval.value) || 1);
539
+ rec.startDate = form.elements.due.value || null; // Referenzpunkt = Fälligkeit
540
+ }
541
+ return rec;
542
+ }
543
+
544
+ function updateDueTimeUI() {
545
+ document.getElementById('dueTimeWrap').hidden = !form.elements.dueTimeEnabled.checked;
546
+ }
547
+ // Termindauer nur zeigen, wenn eine Kalender-Einladung angehängt wird
548
+ function updateCalDurUI() {
549
+ const wrap = document.getElementById('calDurWrap');
550
+ if (wrap) wrap.hidden = !form.elements.calendarInvite.checked;
551
+ }
552
+ on('calendarInvite', 'change', () => {
553
+ updateCalDurUI();
554
+ if (form.elements.calendarInvite.checked && !form.elements.calendarDuration.value) {
555
+ form.elements.calendarDuration.value = '01:00';
556
+ }
557
+ });
558
+ on('dueTimeEnabled', 'change', () => {
559
+ updateDueTimeUI();
560
+ if (form.elements.dueTimeEnabled.checked && !form.elements.dueTime.value) {
561
+ form.elements.dueTime.value = '09:00'; // sinnvoller Default
562
+ }
563
+ });
564
+
565
+ function updatePreview() {
566
+ const prev = document.getElementById('descPreview');
567
+ const txt = form.elements.description.value.trim();
568
+ if (!txt) { prev.hidden = true; return; }
569
+ prev.hidden = false;
570
+ renderMarkdownInto(prev, txt);
571
+ }
572
+
573
+ on('description', 'input', updatePreview);
574
+ document.getElementById('addCheckItem').addEventListener('click', () => {
575
+ addCheckRow().querySelector('input[type=text]').focus();
576
+ });
577
+ document.getElementById('cancelCardBtn').addEventListener('click', () => dlg.close());
578
+ document.getElementById('deleteCardBtn').addEventListener('click', async () => {
579
+ if (!editingCardId) return;
580
+ if (!await confirmDialog({ title: t('card.delete'), message: t('confirm.deleteCard'), danger: true, ok: t('card.delete') })) return;
581
+ await actions.deleteCard(editingCardId);
582
+ dlg.close();
583
+ });
584
+ document.getElementById('transferCardBtn').addEventListener('click', () => {
585
+ if (editingCardId) openTransfer(editingCardId);
586
+ });
587
+
588
+ // Titel-Pflichtmeldung in Board-Sprache statt Browser-Standardtext.
589
+ form.elements.title.addEventListener('invalid', () => form.elements.title.setCustomValidity(t('card.titleRequired')));
590
+ form.elements.title.addEventListener('input', () => form.elements.title.setCustomValidity(''));
591
+
592
+ form.addEventListener('submit', async ev => {
593
+ ev.preventDefault();
594
+ const data = {
595
+ title: form.elements.title.value.trim(),
596
+ description: form.elements.description.value,
597
+ due: form.elements.due.value,
598
+ dueTime: (form.elements.dueTimeEnabled.checked && form.elements.due.value)
599
+ ? form.elements.dueTime.value : '',
600
+ priority: Number(form.elements.priority.value),
601
+ link: form.elements.link.value.trim(),
602
+ location: form.elements.location.value.trim(),
603
+ calendarInvite: form.elements.calendarInvite.checked,
604
+ calendarDuration: form.elements.calendarDuration.value || '01:00',
605
+ assignees: [...selAssignees],
606
+ labels: [...selLabels],
607
+ color: selColor,
608
+ checklist: readChecklist(),
609
+ recurrence: readRecurrence(),
610
+ columnId: form.elements.columnId.value,
611
+ };
612
+ if (!data.title) return;
613
+ if (!data.assignees.length) { updateAssigneeValidity(); const p = document.getElementById('assigneeValidity'); if (p) p.reportValidity(); return; }
614
+ try {
615
+ if (editingCardId) {
616
+ await actions.updateCard(editingCardId, data);
617
+ const card = state.board.cards.find(c => c.id === editingCardId);
618
+ if (card && card.columnId !== data.columnId) {
619
+ await actions.moveCard(editingCardId, data.columnId);
620
+ }
621
+ } else {
622
+ await actions.addCard(data);
623
+ }
624
+ dlg.close();
625
+ } catch (e) {
626
+ alert(t('error.saveFailed', { msg: e.message }));
627
+ }
628
+ });
629
+
630
+ // ---------------------------------------------------------- Board-Verwaltung
631
+
632
+ const bdlg = document.getElementById('boardDialog');
633
+ let bdlgCancel = null; // Escape-Handler des Board-Dialogs (haengt an der Closure)
634
+
635
+ async function openBoardManager() {
636
+ const body = document.getElementById('boardDialogBody');
637
+ body.textContent = '';
638
+ body.appendChild(el('h3', null, t('settings.title')));
639
+
640
+ const tabbar = el('div', 'tabbar');
641
+ const panels = el('div', 'tab-panels');
642
+ body.append(tabbar, panels);
643
+ const tabs = [];
644
+ const activate = id => {
645
+ for (const tb of tabs) { const on = tb.id === id; tb.btn.classList.toggle('active', on); tb.panel.hidden = !on; }
646
+ };
647
+ const addTab = (id, label, build) => {
648
+ const btn = el('button', 'tab-btn', label); btn.type = 'button';
649
+ const panel = el('div', 'tab-panel'); panel.hidden = true;
650
+ build(panel);
651
+ btn.addEventListener('click', () => activate(id));
652
+ tabbar.appendChild(btn); panels.appendChild(panel);
653
+ tabs.push({ id, btn, panel });
654
+ };
655
+
656
+ // Bearbeitetes Board (Dropdown) - unabhaengig vom aktiven Board
657
+ let editId = (state.board && state.board.id) || (state.boards[0] && state.boards[0].id) || '';
658
+ let editBoard = null;
659
+ let dirty = false;
660
+ let titleInput = null, colBox = null, labelBox = null, linkTargetSel = null, linkUrlInput = null;
661
+ let cleanupModeSel = null, cleanupDaysInp = null, cleanupCountInp = null, memberWrap = null;
662
+ let saveBtn = null, boardPanel = null, boardSel = null;
663
+
664
+ const validateMembers = () => {
665
+ if (!saveBtn) return;
666
+ const bad = !!memberWrap && memberWrap.querySelectorAll('input:checked').length === 0;
667
+ saveBtn.disabled = bad;
668
+ saveBtn.title = bad ? t('boards.membersRequiredShort') : '';
669
+ };
670
+
671
+ async function loadEdit(id) {
672
+ // Ohne id wuerde die Anfrage auf der Board-LISTE landen: ein Array ohne
673
+ // .title, das dann als "undefined" im Titelfeld erscheint.
674
+ if (!id) { editBoard = null; return; }
675
+ try {
676
+ const b = await api(`api/boards/${encodeURIComponent(id)}`);
677
+ editBoard = b && b.id ? b : null;
678
+ } catch (e) { editBoard = null; }
679
+ }
680
+
681
+ // Aenderungen des bearbeiteten Boards einsammeln und speichern
682
+ async function saveEdit() {
683
+ if (!editBoard) return true;
684
+ const members = memberWrap ? [...memberWrap.querySelectorAll('input:checked')].map(i => i.dataset.val) : undefined;
685
+ if (memberWrap && !members.length) {
686
+ await confirmDialog({ title: t('boards.members'), message: t('boards.membersRequiredShort'), ok: t('confirm.ok') });
687
+ return false;
688
+ }
689
+ // Wer aus der Mitgliederliste fliegt, bleibt auf seinen Karten stehen —
690
+ // die tauchen dann aber nicht mehr im Personenfilter auf. Vorher fragen.
691
+ if (members) {
692
+ const dropped = (editBoard.members || []).filter(n => !members.includes(n));
693
+ if (dropped.length) {
694
+ const trashIds = (editBoard.columns || []).filter(c => c.isTrash).map(c => c.id);
695
+ const orphans = (editBoard.cards || []).filter(c =>
696
+ !trashIds.includes(c.columnId)
697
+ && (c.assignees || []).length
698
+ && (c.assignees || []).every(a => dropped.includes(a)));
699
+ if (orphans.length) {
700
+ const names = dropped.map(n => {
701
+ const u = (state.users || []).find(x => x.name === n);
702
+ return (u && u.displayName) || n;
703
+ }).join(', ');
704
+ const go = await confirmDialog({
705
+ title: t('boards.memberRemoveTitle'),
706
+ message: t('boards.memberRemoveMsg', { count: orphans.length, names }),
707
+ ok: t('confirm.ok'),
708
+ });
709
+ if (go !== true) return false;
710
+ }
711
+ }
712
+ }
713
+ const columns = [...colBox.children].map(row => ({
714
+ id: row.dataset.colId || undefined,
715
+ title: row.querySelector('input[type=text]').value.trim() || '?',
716
+ maxVisible: Number(row.querySelector('.col-max').value) || 0,
717
+ wipLimit: Number(row.querySelector('.col-wip').value) || 0,
718
+ isDone: row.querySelector('.col-done').checked,
719
+ allowAdd: row.querySelector('.col-add').checked,
720
+ }));
721
+ const labels = [...labelBox.children].map(row => {
722
+ const title = row.querySelector('input[type=text]').value.trim();
723
+ if (!title) return null;
724
+ return {
725
+ id: row.dataset.labelId || slugify(title),
726
+ title,
727
+ color: row.querySelector('.cp-trigger').dataset.color || '#4CAF50',
728
+ };
729
+ }).filter(Boolean);
730
+ await actions.patchBoardById(editBoard.id, {
731
+ title: titleInput.value.trim() || editBoard.title,
732
+ columns, labels,
733
+ linkTarget: linkTargetSel ? linkTargetSel.value : undefined,
734
+ linkUrl: linkUrlInput ? linkUrlInput.value.trim() : undefined,
735
+ cleanup: cleanupModeSel ? { mode: cleanupModeSel.value, days: Number(cleanupDaysInp.value) || 90, count: Number(cleanupCountInp.value) || 100 } : undefined,
736
+ members,
737
+ });
738
+ dirty = false;
739
+ return true;
740
+ }
741
+
742
+ // Beim Wechsel des bearbeiteten Boards nach ungespeicherten Aenderungen fragen
743
+ async function guardUnsaved() {
744
+ if (!dirty) return true;
745
+ const r = await confirmDialog({
746
+ title: t('boards.unsavedTitle'), message: t('boards.unsavedMsg'),
747
+ ok: t('boards.save'), extra: t('boards.discard'),
748
+ });
749
+ if (r === true) return await saveEdit();
750
+ if (r === 'extra') { dirty = false; return true; }
751
+ return false;
752
+ }
753
+
754
+ async function switchEdit(id) {
755
+ if (!await guardUnsaved()) { if (boardSel) boardSel.value = editId; return; }
756
+ editId = id;
757
+ await loadEdit(editId);
758
+ buildBoardPanel(boardPanel);
759
+ validateMembers();
760
+ }
761
+
762
+ function buildBoardPanel(panel) {
763
+ panel.textContent = '';
764
+ dirty = false;
765
+ titleInput = colBox = labelBox = linkTargetSel = linkUrlInput = null;
766
+ cleanupModeSel = cleanupDaysInp = cleanupCountInp = memberWrap = null;
767
+
768
+ // ---- Kopfzeile: Board-Auswahl | Anzeigen | neues Board | Anlegen ----
769
+ // Wird auch ohne Board gebaut — sonst gaebe es keinen Weg, das erste anzulegen.
770
+ const topRow = el('div', 'row board-toprow');
771
+ boardSel = document.createElement('select');
772
+ for (const b of state.boards) {
773
+ const o = document.createElement('option');
774
+ o.value = b.id; o.textContent = b.title;
775
+ boardSel.appendChild(o);
776
+ }
777
+ boardSel.value = editId;
778
+ boardSel.title = t('boards.editingBoard');
779
+ boardSel.dataset.noDirty = '1'; // Board-Wechsel ist keine Aenderung am Board
780
+ boardSel.disabled = !state.boards.length;
781
+ boardSel.addEventListener('change', () => switchEdit(boardSel.value));
782
+
783
+ const showBtn = el('button', 'icon-btn');
784
+ showBtn.type = 'button';
785
+ showBtn.appendChild(mdiIcon(MDI.arrowRightCircle));
786
+ showBtn.title = t('boards.showBoard');
787
+ showBtn.setAttribute('aria-label', showBtn.title);
788
+ showBtn.disabled = !editBoard || !!(state.board && state.board.id === editId);
789
+ showBtn.addEventListener('click', async () => {
790
+ if (state.board && state.board.id === editId) return;
791
+ const b = state.boards.find(x => x.id === editId);
792
+ if (!await confirmDialog({ title: t('boards.showBoard'), message: t('boards.showConfirm', { title: (b && b.title) || editId }), ok: t('boards.showBoard') })) return;
793
+ await actions.switchBoard(editId);
794
+ showBtn.disabled = true;
795
+ });
796
+
797
+ const newInput = document.createElement('input');
798
+ newInput.type = 'text';
799
+ newInput.placeholder = t('boards.newBoard');
800
+ newInput.className = 'board-new-name';
801
+ newInput.dataset.noDirty = '1'; // Name fuer ein NEUES Board, keine Aenderung am aktuellen
802
+ const newBtn = el('button', 'primary', t('boards.create'));
803
+ newBtn.type = 'button';
804
+ newBtn.addEventListener('click', async () => {
805
+ const title = newInput.value.trim();
806
+ if (!title) { newInput.focus(); return; }
807
+ if (!await guardUnsaved()) return;
808
+ const created = await actions.createBoardOnly(title);
809
+ newInput.value = '';
810
+ editId = created.id;
811
+ await loadEdit(editId);
812
+ buildBoardPanel(boardPanel);
813
+ validateMembers();
814
+ });
815
+ topRow.append(boardSel, showBtn, newInput, newBtn);
816
+ panel.appendChild(topRow);
817
+
818
+ // Noch kein Board: nur die Kopfzeile zum Anlegen zeigen, alles Weitere
819
+ // braucht ein Board.
820
+ if (!editBoard) { panel.appendChild(el('div', 'hint', t('boards.noneYet'))); return; }
821
+
822
+ // ---- Board-Titel ----
823
+ const titleLabel = el('label', null, t('boards.boardTitle'));
824
+ titleInput = document.createElement('input');
825
+ titleInput.type = 'text';
826
+ titleInput.value = editBoard.title;
827
+ titleLabel.appendChild(titleInput);
828
+ panel.appendChild(titleLabel);
829
+
830
+ // ---- Mitglieder (zuweisbare Benutzer) ----
831
+ if ((state.users || []).length) {
832
+ panel.appendChild(el('label', null, t('boards.members')));
833
+ panel.appendChild(el('div', 'hint', t('boards.membersHint')));
834
+ memberWrap = el('div', 'share-cols');
835
+ const cur = Array.isArray(editBoard.members) ? editBoard.members : [];
836
+ for (const u of state.users) {
837
+ const lab = el('label', 'inline');
838
+ const inp = document.createElement('input');
839
+ inp.type = 'checkbox'; inp.dataset.val = u.name; inp.checked = cur.includes(u.name);
840
+ inp.addEventListener('change', validateMembers);
841
+ lab.append(inp, document.createTextNode(' ' + (u.displayName || u.name)));
842
+ memberWrap.appendChild(lab);
843
+ }
844
+ panel.appendChild(memberWrap);
845
+ }
846
+
847
+ // ---- Spalten ----
848
+ panel.appendChild(el('label', null, t('boards.columns')));
849
+ const chead = el('div', 'col-head');
850
+ const hcell = (cls, label, tip) => { const s = el('span', cls, label); s.title = tip; return s; };
851
+ chead.append(
852
+ el('span', 'h-drag'),
853
+ hcell('h-name', t('boards.colTitle'), t('boards.legendTitle')),
854
+ hcell('h-num', t('boards.colMax'), t('boards.legendMax')),
855
+ hcell('h-num', t('boards.colWip'), t('boards.legendWip')),
856
+ hcell('h-new', t('boards.allowAdd'), t('boards.legendAdd')),
857
+ hcell('h-done', t('boards.done'), t('boards.legendDone')),
858
+ el('span', 'h-rm'),
859
+ );
860
+ panel.appendChild(chead);
861
+ colBox = el('div');
862
+ colBox.style.cssText = 'display:flex;flex-direction:column;gap:6px';
863
+ const mkColRow = (col, i) => {
864
+ const row = el('div', 'col-edit');
865
+ row.dataset.colId = col.id || '';
866
+ const drag = el('span', 'drag', '⠳');
867
+ const name = document.createElement('input');
868
+ name.type = 'text';
869
+ name.value = col.title;
870
+ name.placeholder = t('boards.columnName');
871
+ name.title = t('boards.legendTitle');
872
+ const max = document.createElement('input');
873
+ max.type = 'number'; max.min = '0';
874
+ max.value = String(col.maxVisible || 0);
875
+ max.title = t('boards.maxTitle');
876
+ max.className = 'col-max';
877
+ const wip = document.createElement('input');
878
+ wip.type = 'number'; wip.min = '0';
879
+ wip.value = String(col.wipLimit || 0);
880
+ wip.title = t('boards.wipTitle');
881
+ wip.className = 'col-wip';
882
+ const doneLbl = el('label', 'col-chk c-done');
883
+ const done = document.createElement('input');
884
+ done.type = 'checkbox'; done.className = 'col-done';
885
+ done.checked = !!col.isDone;
886
+ done.title = t('boards.legendDone'); done.setAttribute('aria-label', t('boards.done'));
887
+ doneLbl.appendChild(done);
888
+ const addLbl = el('label', 'col-chk c-new');
889
+ const addChk = document.createElement('input');
890
+ addChk.type = 'checkbox'; addChk.className = 'col-add';
891
+ addChk.checked = (typeof col.allowAdd === 'boolean') ? col.allowAdd : (i === 0);
892
+ addChk.title = t('boards.legendAdd'); addChk.setAttribute('aria-label', t('boards.allowAdd'));
893
+ addLbl.appendChild(addChk);
894
+ const rm = el('button', 'rm', '×');
895
+ rm.type = 'button';
896
+ rm.title = t('boards.deleteColumnTitle');
897
+ rm.addEventListener('click', () => { row.remove(); dirty = true; });
898
+ drag.title = t('boards.dragTitle');
899
+ row.append(drag, name, max, wip, addLbl, doneLbl, rm);
900
+ return row;
901
+ };
902
+ // Papierkorb ist eine Systemspalte und wird hier nicht bearbeitet
903
+ (editBoard.columns || []).filter(c => !c.isTrash).forEach((col, i) => colBox.appendChild(mkColRow(col, i)));
904
+ panel.appendChild(colBox);
905
+ // eslint-disable-next-line no-undef
906
+ Sortable.create(colBox, { handle: '.drag', animation: 150, onEnd: () => { dirty = true; } });
907
+ const addCol = el('button', 'linkbtn', t('boards.addColumn'));
908
+ addCol.addEventListener('click', () => { colBox.appendChild(mkColRow({ title: '', maxVisible: 0, wipLimit: 0, isDone: false, allowAdd: false })); dirty = true; });
909
+ panel.appendChild(addCol);
910
+
911
+ // ---- Labels ----
912
+ panel.appendChild(el('label', null, t('boards.labels')));
913
+ labelBox = el('div');
914
+ labelBox.style.cssText = 'display:flex;flex-direction:column;gap:6px';
915
+ const mkLabelRow = lab => {
916
+ const row = el('div', 'label-edit');
917
+ row.dataset.labelId = lab.id || '';
918
+ const color = makeColorTrigger(lab.color || '#4CAF50', () => { dirty = true; });
919
+ const name = document.createElement('input');
920
+ name.type = 'text';
921
+ name.value = lab.title || '';
922
+ name.placeholder = t('label.namePlaceholder');
923
+ const rm = el('button', 'rm', '×');
924
+ rm.type = 'button';
925
+ rm.title = t('boards.deleteLabelTitle');
926
+ rm.addEventListener('click', () => { row.remove(); dirty = true; });
927
+ row.append(color, name, rm);
928
+ return row;
929
+ };
930
+ for (const lab of editBoard.labels || []) labelBox.appendChild(mkLabelRow(lab));
931
+ panel.appendChild(labelBox);
932
+ const addLabel = el('button', 'linkbtn', t('boards.addLabel'));
933
+ addLabel.addEventListener('click', () => { labelBox.appendChild(mkLabelRow({ color: '#4CAF50' })); dirty = true; });
934
+ panel.appendChild(addLabel);
935
+
936
+ // ---- Link-Ziel fuer Benachrichtigungen ----
937
+ panel.appendChild(el('label', null, t('boards.linkTarget')));
938
+ linkTargetSel = document.createElement('select');
939
+ for (const [val, key] of [['board', 'boards.linkBoard'], ['edit', 'boards.linkEdit'], ['url', 'boards.linkUrl']]) {
940
+ const o = document.createElement('option');
941
+ o.value = val; o.textContent = t(key);
942
+ linkTargetSel.appendChild(o);
943
+ }
944
+ linkTargetSel.value = editBoard.linkTarget || 'board';
945
+ panel.appendChild(linkTargetSel);
946
+ const linkUrlWrap = el('label', null, t('boards.linkUrlField'));
947
+ linkUrlInput = document.createElement('input');
948
+ linkUrlInput.type = 'url';
949
+ linkUrlInput.placeholder = 'https://…';
950
+ linkUrlInput.value = editBoard.linkUrl || '';
951
+ linkUrlWrap.appendChild(linkUrlInput);
952
+ linkUrlWrap.hidden = linkTargetSel.value !== 'url';
953
+ panel.appendChild(linkUrlWrap);
954
+ linkTargetSel.addEventListener('change', () => { linkUrlWrap.hidden = linkTargetSel.value !== 'url'; });
955
+
956
+ // ---- Erledigte Karten aufraeumen ----
957
+ panel.appendChild(el('label', null, t('cleanup.title')));
958
+ const cl = editBoard.cleanup || { mode: 'off', days: 90, count: 100 };
959
+ const cleanupRow = el('div', 'row');
960
+ cleanupModeSel = document.createElement('select');
961
+ for (const [val, key] of [['off', 'cleanup.off'], ['age', 'cleanup.age'], ['count', 'cleanup.count']]) {
962
+ const o = document.createElement('option'); o.value = val; o.textContent = t(key); cleanupModeSel.appendChild(o);
963
+ }
964
+ cleanupModeSel.value = cl.mode || 'off';
965
+ const daysWrap = el('label', 'inline-num', t('cleanup.days'));
966
+ cleanupDaysInp = document.createElement('input');
967
+ cleanupDaysInp.type = 'number'; cleanupDaysInp.min = '1'; cleanupDaysInp.value = String(cl.days || 90);
968
+ daysWrap.appendChild(cleanupDaysInp);
969
+ const countWrap = el('label', 'inline-num', t('cleanup.countLabel'));
970
+ cleanupCountInp = document.createElement('input');
971
+ cleanupCountInp.type = 'number'; cleanupCountInp.min = '1'; cleanupCountInp.value = String(cl.count || 100);
972
+ countWrap.appendChild(cleanupCountInp);
973
+ cleanupRow.append(cleanupModeSel, daysWrap, countWrap);
974
+ panel.appendChild(cleanupRow);
975
+ panel.appendChild(el('div', 'hint', t('cleanup.hint')));
976
+ const syncCleanupUI = () => {
977
+ daysWrap.hidden = cleanupModeSel.value !== 'age';
978
+ countWrap.hidden = cleanupModeSel.value !== 'count';
979
+ };
980
+ cleanupModeSel.addEventListener('change', syncCleanupUI);
981
+ syncCleanupUI();
982
+
983
+ // ---- Papierkorb einblenden (pro Geraet, keine Board-Aenderung) ----
984
+ const trashLbl = el('label', 'inline');
985
+ const trashChk = document.createElement('input');
986
+ trashChk.type = 'checkbox'; trashChk.checked = !!state.showTrash;
987
+ trashChk.dataset.noDirty = '1'; // reine Geraete-Einstellung
988
+ trashChk.addEventListener('change', () => actions.toggleShowTrash());
989
+ trashLbl.append(trashChk, document.createTextNode(' ' + t('trash.showToggle')));
990
+ panel.appendChild(trashLbl);
991
+
992
+ // ---- Board loeschen (ganz unten) ----
993
+ const delBoard = el('button', 'danger', t('boards.deleteBoard'));
994
+ delBoard.type = 'button';
995
+ delBoard.style.marginTop = '18px';
996
+ delBoard.addEventListener('click', async () => {
997
+ if (state.boards.length < 2) {
998
+ await confirmDialog({ title: t('boards.deleteBoard'), message: t('boards.lastBoard'), ok: t('confirm.ok') });
999
+ return;
1000
+ }
1001
+ if (!await confirmDialog({ title: t('boards.deleteBoard'), message: t('confirm.deleteBoard', { title: editBoard.title }), danger: true, ok: t('boards.deleteBoard') })) return;
1002
+ await actions.deleteBoard(editBoard.id);
1003
+ dirty = false;
1004
+ editId = (state.board && state.board.id) || (state.boards[0] && state.boards[0].id) || '';
1005
+ if (!editId) { bdlg.close(); return; }
1006
+ await loadEdit(editId);
1007
+ buildBoardPanel(boardPanel);
1008
+ validateMembers();
1009
+ });
1010
+ panel.appendChild(delBoard);
1011
+
1012
+ // Aenderungen an Board-Feldern merken. Ausgenommen sind Bedienelemente,
1013
+ // die nichts am Board aendern (Board-Auswahl, Neu-Feld, Geraete-Einstellungen).
1014
+ const touch = (ev) => {
1015
+ const el2 = ev.target;
1016
+ if (el2 && el2.dataset && el2.dataset.noDirty) return;
1017
+ dirty = true;
1018
+ };
1019
+ panel.addEventListener('input', touch);
1020
+ panel.addEventListener('change', touch);
1021
+ }
1022
+
1023
+ // ---- Tab: Board ----
1024
+ await loadEdit(editId);
1025
+ addTab('board', t('settings.tabBoard'), panel => { boardPanel = panel; buildBoardPanel(panel); });
1026
+
1027
+ // ---- Tab: Benutzer (Avatare/Farben) ----
1028
+ if ((state.users || []).length) {
1029
+ addTab('users', t('settings.tabUsers'), panel => {
1030
+ panel.appendChild(el('label', null, t('boards.avatars')));
1031
+ for (const u of state.users) {
1032
+ const row = el('div', 'avatar-edit');
1033
+ const prev = el('span', 'avatar avatar-prev');
1034
+ const paint = () => {
1035
+ prev.textContent = ''; prev.style.background = '';
1036
+ if (u.avatar) {
1037
+ const im = document.createElement('img');
1038
+ im.src = `avatars/${encodeURIComponent(u.name)}?v=${state.avatarVer || 0}`;
1039
+ prev.appendChild(im);
1040
+ } else {
1041
+ prev.textContent = initials(u.displayName || u.name);
1042
+ prev.style.background = u.color || '#888';
1043
+ }
1044
+ };
1045
+ paint();
1046
+ const nm = el('span', 'avatar-name', u.displayName || u.name);
1047
+ const pick = el('button', 'linkbtn', t('avatar.choose')); pick.type = 'button';
1048
+ const file = document.createElement('input');
1049
+ file.type = 'file'; file.accept = 'image/png,image/jpeg,image/webp'; file.hidden = true;
1050
+ pick.addEventListener('click', () => file.click());
1051
+ file.addEventListener('change', async () => {
1052
+ if (!file.files || !file.files[0]) return;
1053
+ try {
1054
+ const dataUrl = await fileToSquareDataUrl(file.files[0], 128);
1055
+ await api(`api/users/${encodeURIComponent(u.name)}/avatar`, { method: 'POST', body: { image: dataUrl } });
1056
+ u.avatar = true; await actions.avatarsChanged(); paint(); rm.hidden = false;
1057
+ } catch (e) { alert(t('avatar.failed', { msg: e.message })); }
1058
+ file.value = '';
1059
+ });
1060
+ const rm = el('button', 'rm', '×'); rm.type = 'button'; rm.title = t('avatar.remove');
1061
+ rm.hidden = !u.avatar;
1062
+ rm.addEventListener('click', async () => {
1063
+ await api(`api/users/${encodeURIComponent(u.name)}/avatar`, { method: 'DELETE' });
1064
+ u.avatar = false; await actions.avatarsChanged(); paint(); rm.hidden = true;
1065
+ });
1066
+ let colorTimer = null;
1067
+ const colorTrig = makeColorTrigger(u.color || '#7E57C2', (col) => {
1068
+ u.color = col;
1069
+ clearTimeout(colorTimer);
1070
+ colorTimer = setTimeout(async () => {
1071
+ try {
1072
+ await api(`api/users/${encodeURIComponent(u.name)}`, { method: 'PATCH', body: { color: col } });
1073
+ await actions.avatarsChanged();
1074
+ } catch (e) { alert(t('avatar.failed', { msg: e.message })); }
1075
+ }, 500);
1076
+ });
1077
+ colorTrig.title = t('user.color');
1078
+ row.append(prev, nm, colorTrig, pick, file, rm);
1079
+ panel.appendChild(row);
1080
+ }
1081
+ });
1082
+ }
1083
+
1084
+ // ---- Fester Footer: Schliessen + Speichern ----
1085
+ const foot = el('footer');
1086
+ foot.appendChild(el('span', 'spacer'));
1087
+ const closeBtn = el('button', null, t('boards.close'));
1088
+ closeBtn.type = 'button';
1089
+ closeBtn.addEventListener('click', async () => { if (await guardUnsaved()) bdlg.close(); });
1090
+ foot.appendChild(closeBtn);
1091
+ saveBtn = el('button', 'primary', t('boards.save'));
1092
+ saveBtn.type = 'button';
1093
+ saveBtn.addEventListener('click', async () => { if (await saveEdit()) bdlg.close(); });
1094
+ foot.appendChild(saveBtn);
1095
+ body.appendChild(foot);
1096
+ validateMembers();
1097
+
1098
+ activate('board');
1099
+ // Escape: erst den Farbwaehler schliessen, danach nur mit Rueckfrage den
1100
+ // ganzen Dialog verwerfen — sonst sind ungespeicherte Spalten und Labels weg.
1101
+ // Der Handler haengt an der Closure dieses Aufrufs und wird darum jedes Mal
1102
+ // ausgetauscht.
1103
+ if (bdlgCancel) bdlg.removeEventListener('cancel', bdlgCancel);
1104
+ bdlgCancel = ev => {
1105
+ if (colorPickerOpen()) { ev.preventDefault(); closeColorPicker(); return; }
1106
+ if (!dirty) return;
1107
+ ev.preventDefault();
1108
+ guardUnsaved().then(ok => { if (ok) bdlg.close(); });
1109
+ };
1110
+ bdlg.addEventListener('cancel', bdlgCancel);
1111
+ bdlg.showModal();
1112
+ }
1113
+ // ---------------------------------------------------------- Ansicht teilen
1114
+ async function openShareDialog() {
1115
+ const sdlg = document.getElementById('shareDialog');
1116
+ const body = document.getElementById('shareBody');
1117
+ body.textContent = '';
1118
+ body.appendChild(el('h3', null, t('share.title')));
1119
+ body.appendChild(el('p', 'hint', t('share.hint')));
1120
+
1121
+ const opt = {
1122
+ board: (state.board && state.board.id) || (state.boards[0] && state.boards[0].id) || '',
1123
+ users: [], labels: [], labelMode: 'hide', columns: null, doneLimit: null,
1124
+ hideSettings: false, embed: false,
1125
+ };
1126
+
1127
+ const mkCheck = (text) => {
1128
+ const lab = el('label', 'inline');
1129
+ const inp = document.createElement('input'); inp.type = 'checkbox';
1130
+ lab.append(inp, document.createTextNode(' ' + text));
1131
+ return { lab, inp };
1132
+ };
1133
+
1134
+ const board = (() => {
1135
+ const lab = el('label', null, t('share.board'));
1136
+ const sel = document.createElement('select');
1137
+ for (const b of state.boards) { const o = document.createElement('option'); o.value = b.id; o.textContent = b.title; sel.appendChild(o); }
1138
+ sel.value = opt.board;
1139
+ lab.appendChild(sel);
1140
+ return { lab, sel };
1141
+ })();
1142
+
1143
+ // Benutzer (Mehrfachauswahl, board-abhängig) – keine angehakt = alle
1144
+ const usersLabel = el('label', null, t('share.users'));
1145
+ const usersWrap = el('div', 'share-cols');
1146
+ const updateUsers = () => { opt.users = [...usersWrap.querySelectorAll('input:checked')].map(i => i.dataset.val); };
1147
+ /** Beim Board-Wechsel mitziehen: Die Liste blieb früher auf den Mitgliedern
1148
+ * des angezeigten Boards stehen. Ein damit erzeugter `users=`-Link nannte
1149
+ * dann Personen, die im Zielboard niemandem gehören — und filterte beim
1150
+ * Öffnen stillschweigend gar nicht. */
1151
+ async function fillUsers(boardId) {
1152
+ usersWrap.textContent = '';
1153
+ opt.users = [];
1154
+ let b = state.board && state.board.id === boardId ? state.board : null;
1155
+ if (!b && boardId) { try { b = await api(`api/boards/${encodeURIComponent(boardId)}`); } catch (e) { b = null; } }
1156
+ const names = new Set(boardMembers(b, state.users));
1157
+ for (const u of (state.users || []).filter(x => names.has(x.name))) {
1158
+ const chk = mkCheck(u.displayName || u.name);
1159
+ chk.inp.dataset.val = u.name;
1160
+ chk.inp.addEventListener('change', () => { updateUsers(); update(); });
1161
+ usersWrap.appendChild(chk.lab);
1162
+ }
1163
+ }
1164
+
1165
+ // Labels (Mehrfachauswahl, board-abhängig) – keine angehakt = alle.
1166
+ // Modus: ausblenden (Blacklist, ?label=) oder nur diese zeigen (Whitelist, ?onlyLabel=)
1167
+ const labelsLabel = el('label', null, t('share.labels'));
1168
+ const labelModeSel = document.createElement('select');
1169
+ for (const [val, key] of [['hide', 'share.labelsHide'], ['only', 'share.labelsOnly']]) {
1170
+ const o2 = document.createElement('option');
1171
+ o2.value = val; o2.textContent = t(key);
1172
+ labelModeSel.appendChild(o2);
1173
+ }
1174
+ labelModeSel.addEventListener('change', () => { opt.labelMode = labelModeSel.value; update(); });
1175
+ labelsLabel.appendChild(labelModeSel);
1176
+ const labelsWrap = el('div', 'share-cols');
1177
+ const updateLabels = () => { opt.labels = [...labelsWrap.querySelectorAll('input:checked')].map(i => i.dataset.val); };
1178
+ async function fillLabels(boardId) {
1179
+ labelsWrap.textContent = '';
1180
+ let labels = [];
1181
+ if (state.board && state.board.id === boardId) labels = state.board.labels || [];
1182
+ else if (boardId) { try { const b = await api(`api/boards/${encodeURIComponent(boardId)}`); labels = (b && b.labels) || []; } catch (e) { /* ignore */ } }
1183
+ for (const l of labels) {
1184
+ const chk = mkCheck(l.title);
1185
+ chk.inp.dataset.val = l.id;
1186
+ chk.inp.addEventListener('change', () => { updateLabels(); update(); });
1187
+ labelsWrap.appendChild(chk.lab);
1188
+ }
1189
+ opt.labels = [];
1190
+ }
1191
+
1192
+ const cHideSettings = mkCheck(t('share.hideSettings'));
1193
+ const cEmbed = mkCheck(t('share.embed'));
1194
+
1195
+ // Sichtbare Spalten (des gewählten Boards) – alle an = kein Filter
1196
+ let curColumns = [];
1197
+ const colsLabel = el('label', null, t('share.visibleColumns'));
1198
+ const colsWrap = el('div', 'share-cols');
1199
+ const updateColumns = () => {
1200
+ const checked = [...colsWrap.querySelectorAll('input:checked')].map(i => i.dataset.colId);
1201
+ opt.columns = (checked.length === curColumns.length) ? null : checked;
1202
+ };
1203
+ async function fillColumns(boardId) {
1204
+ colsWrap.textContent = '';
1205
+ curColumns = [];
1206
+ if (state.board && state.board.id === boardId) curColumns = state.board.columns || [];
1207
+ else if (boardId) { try { const b = await api(`api/boards/${encodeURIComponent(boardId)}`); curColumns = (b && b.columns) || []; } catch (e) { /* ignore */ } }
1208
+ curColumns = curColumns.filter(c => !c.isTrash);
1209
+ for (const c of curColumns) {
1210
+ const chk = mkCheck(c.title);
1211
+ chk.inp.checked = true;
1212
+ chk.inp.dataset.colId = c.id;
1213
+ chk.inp.addEventListener('change', () => { updateColumns(); update(); });
1214
+ colsWrap.appendChild(chk.lab);
1215
+ }
1216
+ opt.columns = null;
1217
+ }
1218
+
1219
+ // Limit für erledigte Karten in Erledigt-Spalten
1220
+ const doneLimitLbl = el('label', null, t('share.doneLimit'));
1221
+ const doneLimitInp = document.createElement('input');
1222
+ doneLimitInp.type = 'number'; doneLimitInp.min = '0'; doneLimitInp.value = ''; doneLimitInp.placeholder = t('share.doneLimitAll');
1223
+ doneLimitLbl.appendChild(doneLimitInp);
1224
+
1225
+ const urlWrap = el('div', 'share-url');
1226
+ const urlField = document.createElement('input');
1227
+ urlField.type = 'text'; urlField.readOnly = true; urlField.className = 'share-url-input';
1228
+ const copyBtn = el('button', 'primary', t('share.copy')); copyBtn.type = 'button';
1229
+ urlWrap.append(urlField, copyBtn);
1230
+
1231
+ const buildUrl = () => {
1232
+ const p = new URLSearchParams();
1233
+ if (opt.board) p.set('board', opt.board);
1234
+ if (opt.users.length) p.set('users', opt.users.join(','));
1235
+ if (opt.labels.length) p.set(opt.labelMode === 'only' ? 'onlyLabel' : 'label', opt.labels.join(','));
1236
+ if (opt.columns && opt.columns.length) p.set('columns', opt.columns.join(','));
1237
+ if (opt.doneLimit != null) p.set('doneLimit', String(opt.doneLimit));
1238
+ if (opt.hideSettings) p.set('hideSettings', '1');
1239
+ if (opt.embed) p.set('embed', '1');
1240
+ const q = p.toString();
1241
+ return location.origin + location.pathname + (q ? '?' + q : '');
1242
+ };
1243
+ const update = () => { urlField.value = buildUrl(); };
1244
+
1245
+ board.sel.addEventListener('change', async () => { opt.board = board.sel.value; await Promise.all([fillUsers(opt.board), fillLabels(opt.board), fillColumns(opt.board)]); update(); });
1246
+ doneLimitInp.addEventListener('input', () => { opt.doneLimit = doneLimitInp.value === '' ? null : Math.max(0, parseInt(doneLimitInp.value, 10) || 0); update(); });
1247
+ cHideSettings.inp.addEventListener('change', () => { opt.hideSettings = cHideSettings.inp.checked; update(); });
1248
+ cEmbed.inp.addEventListener('change', () => { opt.embed = cEmbed.inp.checked; update(); });
1249
+ copyBtn.addEventListener('click', async () => {
1250
+ const done = () => { copyBtn.textContent = t('share.copied'); setTimeout(() => { copyBtn.textContent = t('share.copy'); }, 1500); };
1251
+ try { await navigator.clipboard.writeText(urlField.value); done(); }
1252
+ catch (e) { urlField.select(); try { document.execCommand('copy'); } catch (_) { /* ignore */ } done(); }
1253
+ });
1254
+
1255
+ body.append(board.lab, usersLabel, usersWrap, labelsLabel, labelsWrap, colsLabel, colsWrap, doneLimitLbl,
1256
+ cHideSettings.lab, cEmbed.lab,
1257
+ el('label', null, t('share.generatedUrl')), urlWrap);
1258
+
1259
+ const foot = el('footer');
1260
+ const close = el('button', null, t('boards.close')); close.type = 'button';
1261
+ close.addEventListener('click', () => sdlg.close());
1262
+ foot.append(el('span', 'spacer'), close);
1263
+ body.appendChild(foot);
1264
+
1265
+ await Promise.all([fillUsers(opt.board), fillLabels(opt.board), fillColumns(opt.board)]);
1266
+ update();
1267
+ sdlg.showModal();
1268
+ }
1269
+
1270
+ // ---------------------------------------------------------- In-App-Bestätigung (Feature 61)
1271
+ function confirmDialog(opts = {}) {
1272
+ const cdlg = document.getElementById('confirmDialog');
1273
+ const body = document.getElementById('confirmBody');
1274
+ body.textContent = '';
1275
+ body.appendChild(el('h3', null, opts.title || t('confirm.title')));
1276
+ body.appendChild(el('p', null, opts.message || ''));
1277
+ const foot = el('footer');
1278
+ const cancel = el('button', null, opts.cancel || t('confirm.cancel')); cancel.type = 'button';
1279
+ const ok = el('button', opts.danger ? 'danger' : 'primary', opts.ok || t('confirm.ok')); ok.type = 'button';
1280
+ // Optionaler dritter Knopf (z.B. „Verwerfen") → löst mit 'extra' auf
1281
+ let extraBtn = null;
1282
+ if (opts.extra) { extraBtn = el('button', null, opts.extra); extraBtn.type = 'button'; }
1283
+ foot.append(el('span', 'spacer'), cancel);
1284
+ if (extraBtn) foot.appendChild(extraBtn);
1285
+ foot.appendChild(ok);
1286
+ body.appendChild(foot);
1287
+ return new Promise(resolve => {
1288
+ let done = false;
1289
+ const finish = v => { if (done) return; done = true; try { cdlg.close(); } catch (e) { /* ignore */ } resolve(v); };
1290
+ cancel.addEventListener('click', () => finish(false));
1291
+ if (extraBtn) extraBtn.addEventListener('click', () => finish('extra'));
1292
+ ok.addEventListener('click', () => finish(true));
1293
+ cdlg.addEventListener('close', () => finish(false), { once: true });
1294
+ cdlg.showModal();
1295
+ });
1296
+ }
1297
+
1298
+ // ---------------------------------------------------------- Karte übertragen (Feature 6)
1299
+ async function openTransfer(cardId) {
1300
+ const card = state.board && state.board.cards.find(c => c.id === cardId);
1301
+ if (!card) return;
1302
+ const tdlg = document.getElementById('transferDialog');
1303
+ const body = document.getElementById('transferBody');
1304
+ body.textContent = '';
1305
+ body.appendChild(el('h3', null, t('transfer.title')));
1306
+ const others = state.boards.filter(b => b.id !== state.board.id);
1307
+ const foot = el('footer');
1308
+ const cancel = el('button', null, t('boards.close')); cancel.type = 'button';
1309
+ cancel.addEventListener('click', () => tdlg.close());
1310
+
1311
+ // Klonen (im selben Board), Kopieren oder Verschieben (auf ein anderes Board)
1312
+ let mode = 'clone';
1313
+ let targetBoard = null;
1314
+ const overrideSel = new Set();
1315
+ const labelName = l => String((l && (l.title || l.name)) || '').trim().toLowerCase();
1316
+
1317
+ const modeWrap = el('div', 'transfer-mode');
1318
+ const mkMode = (val, label) => {
1319
+ const b = el('button', 'transfer-mode-btn' + (val === mode ? ' active' : ''), label);
1320
+ b.type = 'button'; b.dataset.mode = val; b.title = label;
1321
+ if (val !== 'clone' && !others.length) { b.disabled = true; b.title = t('transfer.noOtherBoards'); }
1322
+ b.addEventListener('click', () => setMode(val));
1323
+ return b;
1324
+ };
1325
+ modeWrap.append(mkMode('clone', t('transfer.clone')), mkMode('copy', t('transfer.copy')), mkMode('move', t('transfer.move')));
1326
+
1327
+ const boardLbl = el('label', null, t('transfer.targetBoard'));
1328
+ const boardSel = document.createElement('select');
1329
+ for (const b of others) { const o = document.createElement('option'); o.value = b.id; o.textContent = b.title; boardSel.appendChild(o); }
1330
+ boardLbl.appendChild(boardSel);
1331
+
1332
+ const colLbl = el('label', null, t('transfer.targetColumn'));
1333
+ const colSel = document.createElement('select');
1334
+ colLbl.appendChild(colSel);
1335
+
1336
+ const note = el('div', 'transfer-note'); note.hidden = true;
1337
+ const assignLbl = el('label', null, t('transfer.pickAssignee')); assignLbl.hidden = true;
1338
+ const assignWrap = el('div', 'share-cols'); assignWrap.hidden = true;
1339
+ const ok = el('button', 'primary', t('transfer.clone')); ok.type = 'button';
1340
+
1341
+ function updateOk() { ok.disabled = !assignLbl.hidden && overrideSel.size === 0; }
1342
+ // Modus wechseln: Ziel-Board nur bei Kopieren/Verschieben, Klonen bleibt im Board
1343
+ function setMode(val) {
1344
+ mode = val;
1345
+ for (const x of modeWrap.children) x.classList.toggle('active', x.dataset.mode === mode);
1346
+ const clone = mode === 'clone';
1347
+ boardLbl.hidden = clone;
1348
+ ok.textContent = clone ? t('transfer.clone') : (mode === 'copy' ? t('transfer.copy') : t('transfer.move'));
1349
+ if (clone) { targetBoard = state.board; recompute(); } else loadTarget(boardSel.value);
1350
+ }
1351
+ function recompute() {
1352
+ if (!targetBoard) return;
1353
+ const clone = mode === 'clone';
1354
+ colSel.textContent = '';
1355
+ const cols = (targetBoard.columns || []).filter(c => !c.isTrash);
1356
+ for (const c of cols) { const o = document.createElement('option'); o.value = c.id; o.textContent = c.title; colSel.appendChild(o); }
1357
+ const def = (clone && cols.find(c => c.id === card.columnId)) || cols.find(c => c.allowAdd) || cols[0];
1358
+ if (def) colSel.value = def.id;
1359
+ if (clone) {
1360
+ note.textContent = t('transfer.cloneHint');
1361
+ note.hidden = false;
1362
+ assignLbl.hidden = true; assignWrap.hidden = true;
1363
+ overrideSel.clear();
1364
+ updateOk();
1365
+ return;
1366
+ }
1367
+ const fromById = new Map((state.board.labels || []).map(l => [l.id, l]));
1368
+ const toNames = new Set((targetBoard.labels || []).map(labelName));
1369
+ const droppedLabels = (card.labels || []).filter(id => { const n = labelName(fromById.get(id)); return !(n && toNames.has(n)); });
1370
+ // Wirksame Mitglieder wie auf dem Server: eine Liste, die auf keinen
1371
+ // bekannten Benutzer zeigt, bedeutet "alle". Die rohe Liste ergab hier
1372
+ // sonst eine Sackgasse — Zustaendiger war Pflicht, aber die Auswahl leer.
1373
+ const members = new Set(boardMembers(targetBoard, state.users));
1374
+ const keptAssignees = (card.assignees || []).filter(a => members.has(a));
1375
+ const droppedAssignees = (card.assignees || []).filter(a => !members.has(a));
1376
+ const parts = [];
1377
+ if (droppedLabels.length) parts.push(t('transfer.dropLabels', { n: droppedLabels.length }));
1378
+ if (droppedAssignees.length) parts.push(t('transfer.dropAssignees', { n: droppedAssignees.length }));
1379
+ note.textContent = parts.join(' ');
1380
+ note.hidden = !parts.length;
1381
+ const needPick = keptAssignees.length === 0;
1382
+ assignLbl.hidden = !needPick; assignWrap.hidden = !needPick;
1383
+ overrideSel.clear();
1384
+ if (needPick) {
1385
+ assignWrap.textContent = '';
1386
+ for (const u of (state.users || []).filter(x => members.has(x.name))) {
1387
+ const lab = el('label', 'inline');
1388
+ const inp = document.createElement('input'); inp.type = 'checkbox'; inp.dataset.val = u.name;
1389
+ inp.addEventListener('change', () => { if (inp.checked) overrideSel.add(u.name); else overrideSel.delete(u.name); updateOk(); });
1390
+ lab.append(inp, document.createTextNode(' ' + (u.displayName || u.name)));
1391
+ assignWrap.appendChild(lab);
1392
+ }
1393
+ }
1394
+ updateOk();
1395
+ }
1396
+ async function loadTarget(id) {
1397
+ try { targetBoard = await api(`api/boards/${encodeURIComponent(id)}`); } catch (e) { targetBoard = null; }
1398
+ recompute();
1399
+ }
1400
+ boardSel.addEventListener('change', () => loadTarget(boardSel.value));
1401
+ ok.addEventListener('click', async () => {
1402
+ try {
1403
+ const toBoard = mode === 'clone' ? state.board.id : boardSel.value;
1404
+ await actions.transferCard(cardId, toBoard, colSel.value, mode === 'move' ? 'move' : 'copy', [...overrideSel]);
1405
+ tdlg.close();
1406
+ dlg.close();
1407
+ } catch (e) { alert(t('error.saveFailed', { msg: e.message })); }
1408
+ });
1409
+
1410
+ body.append(modeWrap, boardLbl, colLbl, note, assignLbl, assignWrap);
1411
+ foot.append(el('span', 'spacer'), cancel, ok);
1412
+ body.appendChild(foot);
1413
+ if (others.length) boardSel.value = others[0].id;
1414
+ setMode('clone');
1415
+ tdlg.showModal();
1416
+ }
1417
+
1418
+ return { openCard, copyCard, openDescription, openBoardManager, openShareDialog, confirm: confirmDialog };
1419
+ }