kanbango 2.0.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.
package/index.html ADDED
@@ -0,0 +1,1095 @@
1
+ <!DOCTYPE html>
2
+ <html lang="pl">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Donna Kanban</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ :root {
11
+ --active: #e85d04;
12
+ --planned: #2563eb;
13
+ --icebox: #64748b;
14
+ --done: #16a34a;
15
+ --bg: #eef0f3;
16
+ --card: #ffffff;
17
+ --border: #dde1e9;
18
+ --text: #1e293b;
19
+ --muted: #8fa0b8;
20
+ --radius: 8px;
21
+ --col-w: repeat(4, 1fr);
22
+ }
23
+
24
+ body {
25
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
26
+ background: var(--bg);
27
+ color: var(--text);
28
+ font-size: 14px;
29
+ height: 100vh;
30
+ overflow: hidden;
31
+ display: flex;
32
+ flex-direction: column;
33
+ }
34
+
35
+ /* ── Header ── */
36
+ header {
37
+ padding: 0 16px;
38
+ height: 44px;
39
+ background: #fff;
40
+ border-bottom: 1px solid var(--border);
41
+ display: flex;
42
+ align-items: center;
43
+ gap: 12px;
44
+ flex-shrink: 0;
45
+ z-index: 30;
46
+ }
47
+ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
48
+ .hstats { font-size: 12px; color: var(--muted); }
49
+ .hspacer { flex: 1; }
50
+ .btn-new {
51
+ background: var(--planned);
52
+ color: #fff;
53
+ border: none;
54
+ border-radius: var(--radius);
55
+ padding: 5px 12px;
56
+ font-size: 12px;
57
+ font-weight: 600;
58
+ cursor: pointer;
59
+ font-family: inherit;
60
+ }
61
+ .btn-new:hover { filter: brightness(0.9); }
62
+
63
+ /* ── New epic form strip ── */
64
+ .new-epic-strip {
65
+ background: #fff;
66
+ border-bottom: 1px solid var(--border);
67
+ padding: 8px 16px;
68
+ display: none;
69
+ gap: 8px;
70
+ align-items: center;
71
+ flex-shrink: 0;
72
+ }
73
+ .new-epic-strip.open { display: flex; }
74
+ .ne-input {
75
+ border: 1px solid var(--border);
76
+ border-radius: var(--radius);
77
+ padding: 5px 9px;
78
+ font-size: 13px;
79
+ font-family: inherit;
80
+ outline: none;
81
+ color: var(--text);
82
+ }
83
+ .ne-input:focus { border-color: var(--planned); }
84
+ .ne-title { width: 260px; }
85
+ .ne-col { width: 130px; }
86
+ .ne-group { width: 200px; }
87
+ .btn-sm {
88
+ border: none; cursor: pointer; border-radius: var(--radius);
89
+ font-size: 11px; padding: 4px 10px; font-weight: 600;
90
+ font-family: inherit;
91
+ }
92
+ .btn-confirm { background: var(--planned); color: #fff; }
93
+ .btn-confirm:hover { filter: brightness(0.9); }
94
+ .btn-cancel { background: #f1f5f9; color: var(--muted); }
95
+ .btn-cancel:hover { background: #e2e8f0; }
96
+
97
+ /* ── Board wrapper ── */
98
+ #board-wrap {
99
+ flex: 1;
100
+ overflow-y: auto;
101
+ padding: 0 12px 16px;
102
+ }
103
+ #board-wrap::-webkit-scrollbar { width: 5px; }
104
+ #board-wrap::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 3px; }
105
+
106
+ /* ── Sticky column headers ── */
107
+ .col-headers {
108
+ display: grid;
109
+ grid-template-columns: var(--col-w);
110
+ position: sticky;
111
+ top: 0;
112
+ background: var(--bg);
113
+ z-index: 20;
114
+ padding: 8px 0 4px;
115
+ }
116
+ .ch {
117
+ padding: 6px 12px;
118
+ font-size: 11px;
119
+ font-weight: 700;
120
+ text-transform: uppercase;
121
+ letter-spacing: 0.7px;
122
+ display: flex;
123
+ align-items: center;
124
+ gap: 6px;
125
+ }
126
+ .ch-count {
127
+ background: rgba(0,0,0,0.07);
128
+ border-radius: 10px;
129
+ padding: 1px 6px;
130
+ font-size: 10px;
131
+ color: var(--muted);
132
+ font-weight: 600;
133
+ }
134
+ .ch.icebox { color: var(--icebox); }
135
+ .ch.planned { color: var(--planned); }
136
+ .ch.active { color: var(--active); }
137
+ .ch.done { color: var(--done); }
138
+
139
+ /* ── Swimlane ── */
140
+ .swimlane {
141
+ background: #fff;
142
+ border: 1px solid var(--border);
143
+ border-radius: var(--radius);
144
+ margin-bottom: 6px;
145
+ overflow: hidden;
146
+ }
147
+ .sl-header {
148
+ display: flex;
149
+ align-items: center;
150
+ gap: 8px;
151
+ padding: 7px 12px;
152
+ background: #f8fafc;
153
+ border-bottom: 1px solid var(--border);
154
+ cursor: pointer;
155
+ user-select: none;
156
+ }
157
+ .sl-header:hover { background: #f1f5f9; }
158
+ .sl-arrow { font-size: 10px; color: var(--muted); transition: transform 0.15s; }
159
+ .sl-header.collapsed .sl-arrow { transform: rotate(-90deg); }
160
+ .sl-name { font-size: 12px; font-weight: 600; color: var(--text); }
161
+ .sl-cnt {
162
+ font-size: 11px;
163
+ color: var(--muted);
164
+ background: #e2e8f0;
165
+ border-radius: 10px;
166
+ padding: 1px 6px;
167
+ font-weight: 600;
168
+ }
169
+ .sl-progress {
170
+ margin-left: auto;
171
+ font-size: 11px;
172
+ color: var(--muted);
173
+ }
174
+
175
+ /* ── Swimlane body grid ── */
176
+ .sl-body {
177
+ display: grid;
178
+ grid-template-columns: var(--col-w);
179
+ min-height: 60px;
180
+ }
181
+ .sl-body.hidden { display: none; }
182
+
183
+ .lane {
184
+ padding: 8px;
185
+ min-height: 56px;
186
+ border-right: 1px solid #f1f5f9;
187
+ display: flex;
188
+ flex-direction: column;
189
+ gap: 6px;
190
+ }
191
+ .lane:last-child { border-right: none; }
192
+ .lane.col-active { background: #fefcfb; }
193
+ .lane.col-done { background: #fafdfb; }
194
+
195
+ /* ── Card ── */
196
+ .card {
197
+ background: var(--card);
198
+ border-radius: var(--radius);
199
+ border-left: 4px solid transparent;
200
+ padding: 10px 12px 8px;
201
+ box-shadow: 0 1px 3px rgba(0,0,0,0.07);
202
+ cursor: pointer;
203
+ transition: box-shadow 0.15s, transform 0.1s;
204
+ position: relative;
205
+ }
206
+ .card:hover { box-shadow: 0 3px 10px rgba(0,0,0,0.11); }
207
+
208
+ .card.col-planned { border-left-color: var(--planned); }
209
+ .card.col-active {
210
+ border-left-color: var(--active);
211
+ background: #fffdf9;
212
+ box-shadow: 0 2px 6px rgba(232,93,4,0.10);
213
+ }
214
+ .card.col-done { border-left-color: var(--done); opacity: 0.6; }
215
+
216
+ /* Icebox: tile style */
217
+ .card.col-icebox {
218
+ background: #edf0f7;
219
+ border-left: none;
220
+ border-top: 3px solid #8fa0b8;
221
+ border-radius: 10px;
222
+ box-shadow: none;
223
+ padding: 8px 10px 7px;
224
+ }
225
+ .card.col-icebox:hover { box-shadow: 0 2px 8px rgba(100,116,139,0.15); }
226
+
227
+ .card-top { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
228
+ .card-id {
229
+ font-size: 10px; color: var(--muted); font-weight: 700;
230
+ background: rgba(0,0,0,0.05); border-radius: 4px;
231
+ padding: 1px 5px; white-space: nowrap;
232
+ }
233
+ .card.col-icebox .card-id { background: rgba(100,116,139,0.12); }
234
+ .card-title { font-size: 14px; font-weight: 600; line-height: 1.3; word-break: break-word; color: var(--text); }
235
+ .card.col-icebox .card-title { font-size: 13px; font-weight: 500; color: #475569; }
236
+ .card.col-done .card-title { text-decoration: line-through; color: var(--muted); }
237
+
238
+ /* ── Progress bar ── */
239
+ .prog-row { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
240
+ .prog-bar { flex: 1; height: 4px; background: #dde1e9; border-radius: 2px; overflow: hidden; }
241
+ .prog-fill { height: 100%; border-radius: 2px; transition: width 0.3s; }
242
+ .prog-fill.planned { background: var(--planned); }
243
+ .prog-fill.active { background: var(--active); }
244
+ .prog-fill.done { background: var(--done); }
245
+ .prog-fill.icebox { background: var(--icebox); }
246
+ .prog-txt { font-size: 10px; color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
247
+
248
+ /* ── Card action bar ── */
249
+ .card-actions {
250
+ display: flex; gap: 4px; margin-top: 8px;
251
+ opacity: 0; transition: opacity 0.12s;
252
+ }
253
+ .card:hover .card-actions,
254
+ .card.open .card-actions,
255
+ .card.editing .card-actions { opacity: 1; }
256
+
257
+ .cbtn {
258
+ border: none; cursor: pointer; border-radius: 5px;
259
+ font-size: 11px; padding: 3px 8px; font-weight: 600;
260
+ font-family: inherit; background: #edf0f5; color: var(--muted);
261
+ transition: background 0.1s, color 0.1s;
262
+ }
263
+ .cbtn:hover { background: #dde4f0; color: var(--text); }
264
+
265
+ /* Arrow move buttons */
266
+ .cbtn.arr {
267
+ padding: 3px 9px;
268
+ font-size: 13px;
269
+ line-height: 1;
270
+ }
271
+ .cbtn.arr:hover { background: #d0d8f0; color: var(--planned); }
272
+ .card.col-active .cbtn.arr:hover { background: #ffe4d0; color: var(--active); }
273
+
274
+ .cbtn.edit-btn { color: #5a7ab5; }
275
+ .cbtn.edit-btn:hover { background: #e8efff; color: var(--planned); }
276
+
277
+ /* ── Expanded card: subtask list (view mode) ── */
278
+ .subtask-label {
279
+ font-size: 10px; font-weight: 700; text-transform: uppercase;
280
+ letter-spacing: 0.6px; color: var(--muted);
281
+ margin: 10px 0 5px; padding-top: 8px;
282
+ border-top: 1px solid #edf0f5;
283
+ }
284
+ .tasks-list {
285
+ display: flex; flex-direction: column; gap: 4px;
286
+ }
287
+ .task-row { display: flex; align-items: flex-start; gap: 7px; }
288
+ .task-row input[type="checkbox"] {
289
+ margin-top: 2px; cursor: pointer; flex-shrink: 0;
290
+ accent-color: var(--planned);
291
+ }
292
+ .task-row label { font-size: 12px; cursor: pointer; line-height: 1.4; color: var(--text); }
293
+ .task-row.done label { text-decoration: line-through; color: var(--muted); }
294
+ .task-copy {
295
+ display: flex;
296
+ flex-direction: column;
297
+ gap: 2px;
298
+ }
299
+ .task-desc {
300
+ font-size: 11px;
301
+ color: var(--muted);
302
+ line-height: 1.4;
303
+ }
304
+
305
+ /* ── Edit mode ── */
306
+ .card.editing { box-shadow: 0 0 0 2px var(--planned); cursor: default; }
307
+
308
+ .edit-title-input {
309
+ width: 100%; border: 1px solid var(--planned); border-radius: 4px;
310
+ padding: 4px 7px; font-size: 13px; font-weight: 500; font-family: inherit;
311
+ color: var(--text); outline: none; margin-top: 2px;
312
+ }
313
+
314
+ .edit-tasks { margin-top: 9px; border-top: 1px solid #f1f5f9; padding-top: 9px; }
315
+ .edit-task-row {
316
+ display: flex; align-items: center; gap: 6px; margin-bottom: 4px;
317
+ }
318
+ .edit-task-row input[type="checkbox"] { flex-shrink: 0; accent-color: var(--planned); cursor: pointer; }
319
+ .edit-task-input {
320
+ flex: 1; border: 1px solid transparent; border-radius: 3px;
321
+ padding: 2px 5px; font-size: 12px; font-family: inherit;
322
+ color: var(--text); outline: none; background: transparent;
323
+ }
324
+ .edit-task-input:focus { border-color: var(--planned); background: #fff; }
325
+ .edit-task-input:hover { border-color: var(--border); }
326
+ .edit-task-fields {
327
+ flex: 1;
328
+ display: flex;
329
+ flex-direction: column;
330
+ gap: 4px;
331
+ }
332
+ .edit-task-desc,
333
+ .edit-block,
334
+ .lane-add-textarea {
335
+ width: 100%;
336
+ border: 1px solid #d7deea;
337
+ border-radius: 4px;
338
+ padding: 5px 7px;
339
+ font-size: 12px;
340
+ font-family: inherit;
341
+ color: var(--text);
342
+ outline: none;
343
+ resize: vertical;
344
+ min-height: 56px;
345
+ background: #fff;
346
+ }
347
+ .edit-task-desc {
348
+ min-height: 48px;
349
+ }
350
+ .edit-block:focus,
351
+ .edit-task-desc:focus,
352
+ .lane-add-textarea:focus {
353
+ border-color: var(--planned);
354
+ }
355
+ .detail-section {
356
+ margin-top: 9px;
357
+ padding-top: 8px;
358
+ border-top: 1px solid #edf0f5;
359
+ }
360
+ .detail-label {
361
+ font-size: 10px;
362
+ font-weight: 700;
363
+ text-transform: uppercase;
364
+ letter-spacing: 0.6px;
365
+ color: var(--muted);
366
+ margin-bottom: 4px;
367
+ }
368
+ .detail-text {
369
+ font-size: 12px;
370
+ line-height: 1.45;
371
+ color: var(--text);
372
+ white-space: pre-wrap;
373
+ }
374
+ .detail-list {
375
+ margin: 0;
376
+ padding-left: 18px;
377
+ font-size: 12px;
378
+ line-height: 1.45;
379
+ color: var(--text);
380
+ }
381
+ .edit-meta {
382
+ display: flex;
383
+ flex-direction: column;
384
+ gap: 8px;
385
+ margin-top: 10px;
386
+ padding-top: 8px;
387
+ border-top: 1px solid #f1f5f9;
388
+ }
389
+ .lane-addinp {
390
+ background: #fff;
391
+ border: 1px solid var(--border);
392
+ border-radius: var(--radius);
393
+ padding: 7px;
394
+ margin-top: 2px;
395
+ display: flex;
396
+ flex-direction: column;
397
+ gap: 6px;
398
+ }
399
+ .lane-add-title {
400
+ width: 100%;
401
+ border: 1px solid var(--planned);
402
+ border-radius: 4px;
403
+ padding: 4px 7px;
404
+ font-size: 12px;
405
+ font-family: inherit;
406
+ outline: none;
407
+ }
408
+ .lane-add-actions {
409
+ display: flex;
410
+ gap: 6px;
411
+ }
412
+ .del-task {
413
+ flex-shrink: 0; border: none; background: none; cursor: pointer;
414
+ color: var(--muted); font-size: 14px; line-height: 1; padding: 0 2px;
415
+ opacity: 0; transition: opacity 0.1s;
416
+ }
417
+ .edit-task-row:hover .del-task { opacity: 1; }
418
+ .del-task:hover { color: #dc2626; }
419
+
420
+ .add-task-row { margin-top: 5px; }
421
+ .add-task-inp {
422
+ width: 100%; border: 1px dashed #cbd5e1; border-radius: 3px;
423
+ padding: 3px 7px; font-size: 12px; font-family: inherit;
424
+ color: var(--text); outline: none; background: transparent;
425
+ }
426
+ .add-task-inp:focus { border-color: var(--planned); border-style: solid; background: #fff; }
427
+ .add-task-inp::placeholder { color: var(--muted); }
428
+
429
+ .edit-footer {
430
+ display: flex; gap: 5px; margin-top: 10px; padding-top: 8px;
431
+ border-top: 1px solid #f1f5f9;
432
+ }
433
+
434
+ /* ── Lane add button ── */
435
+ .lane-add {
436
+ background: none; border: 1.5px dashed #d1d5db; border-radius: var(--radius);
437
+ padding: 5px 10px; font-size: 11px; color: var(--muted); cursor: pointer;
438
+ width: 100%; text-align: left; font-family: inherit;
439
+ transition: all 0.15s; margin-top: 2px;
440
+ }
441
+ .lane-add:hover { border-color: var(--planned); color: var(--planned); background: #eff6ff; }
442
+
443
+ /* ── Toast ── */
444
+ .toast {
445
+ position: fixed; bottom: 16px; right: 16px; background: #1e293b;
446
+ color: #fff; padding: 8px 14px; border-radius: 8px;
447
+ font-size: 12px; font-weight: 500; opacity: 0;
448
+ transform: translateY(6px); transition: all 0.2s;
449
+ pointer-events: none; z-index: 999;
450
+ }
451
+ .toast.show { opacity: 1; transform: translateY(0); }
452
+ </style>
453
+ </head>
454
+ <body>
455
+
456
+ <header>
457
+ <h1>Donna Kanban</h1>
458
+ <span class="hstats" id="hstats">—</span>
459
+ <span class="hspacer"></span>
460
+ <button class="btn-new" id="btn-new" onclick="toggleNewForm()">+ Nowy task</button>
461
+ </header>
462
+
463
+ <div class="new-epic-strip" id="new-strip">
464
+ <input class="ne-input ne-title" id="ne-title" placeholder="Nazwa taska…" autocomplete="off">
465
+ <select class="ne-input ne-col" id="ne-col">
466
+ <option value="icebox">🧊 Icebox</option>
467
+ <option value="planned" selected>📦 Planned</option>
468
+ <option value="active">🔥 Active</option>
469
+ <option value="done">✅ Done</option>
470
+ </select>
471
+ <input class="ne-input ne-group" id="ne-group" placeholder="Epik / Faza (opcjonalnie)…" autocomplete="off">
472
+ <button class="btn-sm btn-confirm" onclick="submitNew()">Dodaj</button>
473
+ <button class="btn-sm btn-cancel" onclick="toggleNewForm()">Anuluj</button>
474
+ </div>
475
+
476
+ <div id="board-wrap">
477
+ <div class="col-headers" id="col-headers"></div>
478
+ <div id="swimlanes"></div>
479
+ </div>
480
+
481
+ <div class="toast" id="toast"></div>
482
+
483
+ <script>
484
+ const COL_DEFS = [
485
+ { id: "icebox", label: "🧊 Icebox", icon: "🧊" },
486
+ { id: "planned", label: "📦 Planned", icon: "📦" },
487
+ { id: "active", label: "🔥 Active", icon: "🔥" },
488
+ { id: "done", label: "✅ Done", icon: "✅" },
489
+ ];
490
+
491
+ let allEpics = [];
492
+ let colCounts = {};
493
+ let collapsed = new Set();
494
+ let expanded = new Set();
495
+ let editingId = null;
496
+ let dirtyEdits = {}; // epicId -> editable task draft
497
+
498
+ // ── Load & render ─────────────────────────────────────────────────────────────
499
+
500
+ async function load() {
501
+ const res = await fetch("/api/board");
502
+ allEpics = await res.json();
503
+ render();
504
+ }
505
+
506
+ function render() {
507
+ colCounts = {};
508
+ COL_DEFS.forEach(c => (colCounts[c.id] = 0));
509
+ allEpics.forEach(e => { if (colCounts[e.column] !== undefined) colCounts[e.column]++; });
510
+
511
+ // Header stats
512
+ const total = allEpics.length;
513
+ const done = allEpics.filter(e => e.column === "done").length;
514
+ document.getElementById("hstats").textContent = `${done}/${total} ukończone`;
515
+
516
+ // Col headers
517
+ const hdr = document.getElementById("col-headers");
518
+ hdr.innerHTML = "";
519
+ COL_DEFS.forEach(c => {
520
+ const h = document.createElement("div");
521
+ h.className = `ch ${c.id}`;
522
+ h.innerHTML = `${c.label} <span class="ch-count">${colCounts[c.id]}</span>`;
523
+ hdr.appendChild(h);
524
+ });
525
+
526
+ // Swimlanes
527
+ const sl = document.getElementById("swimlanes");
528
+ sl.innerHTML = "";
529
+ groupEpics(allEpics).forEach(g => sl.appendChild(renderSwimlane(g)));
530
+ }
531
+
532
+ function groupEpics(epics) {
533
+ const map = new Map();
534
+ epics.forEach(e => {
535
+ const g = e.epic_group || "—";
536
+ if (!map.has(g)) map.set(g, []);
537
+ map.get(g).push(e);
538
+ });
539
+ // Sort: known phases first, "—" last
540
+ return [...map.entries()]
541
+ .sort(([a], [b]) => a === "—" ? 1 : b === "—" ? -1 : a.localeCompare(b, "pl"))
542
+ .map(([name, epics]) => ({ name, epics }));
543
+ }
544
+
545
+ // ── Swimlane ──────────────────────────────────────────────────────────────────
546
+
547
+ function renderSwimlane({ name, epics }) {
548
+ const isCollapsed = collapsed.has(name);
549
+
550
+ const wrap = el("div", "swimlane");
551
+
552
+ // Header
553
+ const hdr = el("div", `sl-header${isCollapsed ? " collapsed" : ""}`);
554
+ const totalTasks = epics.reduce((s, e) => s + e.tasks.length, 0);
555
+ const doneTasks = epics.reduce((s, e) => s + e.tasks.filter(t => t.done).length, 0);
556
+ const pct = totalTasks > 0 ? Math.round(doneTasks / totalTasks * 100) : null;
557
+
558
+ hdr.innerHTML = `
559
+ <span class="sl-arrow">▼</span>
560
+ <span class="sl-name">${escHtml(name === "—" ? "Pozostałe (bez epiku)" : name)}</span>
561
+ <span class="sl-cnt">${epics.length}</span>
562
+ ${pct !== null ? `<span class="sl-progress" style="margin-left:auto">${pct}% tasków</span>` : ""}
563
+ `;
564
+ hdr.onclick = () => {
565
+ if (collapsed.has(name)) collapsed.delete(name);
566
+ else collapsed.add(name);
567
+ render();
568
+ };
569
+ wrap.appendChild(hdr);
570
+
571
+ // Body
572
+ const body = el("div", `sl-body${isCollapsed ? " hidden" : ""}`);
573
+ COL_DEFS.forEach(col => {
574
+ const lane = el("div", `lane col-${col.id}`);
575
+ epics.filter(e => e.column === col.id).forEach(e => lane.appendChild(renderCard(e, col.id)));
576
+ // Add button only in non-done columns
577
+ if (col.id !== "done") {
578
+ const addBtn = document.createElement("button");
579
+ addBtn.className = "lane-add";
580
+ addBtn.textContent = "+ Dodaj tutaj";
581
+ addBtn.onclick = e => { e.stopPropagation(); showLaneAdd(lane, col.id, name, addBtn); };
582
+ lane.appendChild(addBtn);
583
+ }
584
+ body.appendChild(lane);
585
+ });
586
+ wrap.appendChild(body);
587
+ return wrap;
588
+ }
589
+
590
+ // ── Card ──────────────────────────────────────────────────────────────────────
591
+
592
+ function getNeighborCols(colId) {
593
+ const idx = COL_DEFS.findIndex(c => c.id === colId);
594
+ return {
595
+ prev: idx > 0 ? COL_DEFS[idx - 1] : null,
596
+ next: idx < COL_DEFS.length - 1 ? COL_DEFS[idx + 1] : null,
597
+ };
598
+ }
599
+
600
+ async function moveCard(epicId, targetColId) {
601
+ await fetch(`/api/epics/${enc(epicId)}/move`, {
602
+ method: "PATCH",
603
+ headers: { "Content-Type": "application/json" },
604
+ body: JSON.stringify({ column: targetColId }),
605
+ });
606
+ expanded.delete(epicId);
607
+ await load();
608
+ }
609
+
610
+ function renderCard(epic, colId) {
611
+ const isEditing = editingId === epic.id;
612
+ const isExpanded = expanded.has(epic.id);
613
+ const card = el("div", `card col-${colId}${isExpanded ? " open" : ""}${isEditing ? " editing" : ""}`);
614
+ card.dataset.id = epic.id;
615
+
616
+ const totalT = epic.tasks.length;
617
+ const doneT = epic.tasks.filter(t => t.done).length;
618
+ const pct = totalT > 0 ? Math.round(doneT / totalT * 100) : 0;
619
+ const shortId = (epic.id.match(/^PI-\d+/) || [epic.id])[0];
620
+ const displayTitle = epic.title.replace(/^[\w.-]+:\s*/, "");
621
+
622
+ if (isEditing) {
623
+ renderEditMode(card, epic, colId, shortId);
624
+ } else {
625
+ // Top row: ID badge
626
+ const top = el("div", "card-top");
627
+ const idBadge = el("span", "card-id");
628
+ idBadge.textContent = shortId;
629
+ top.appendChild(idBadge);
630
+ card.appendChild(top);
631
+
632
+ // Title
633
+ const titleEl = el("div", "card-title");
634
+ titleEl.textContent = displayTitle;
635
+ card.appendChild(titleEl);
636
+
637
+ // Progress (skip for icebox — frozen, not in progress)
638
+ if (totalT > 0 && colId !== "icebox") {
639
+ const pr = el("div", "prog-row");
640
+ pr.innerHTML = `
641
+ <div class="prog-bar"><div class="prog-fill ${colId}" style="width:${pct}%"></div></div>
642
+ <span class="prog-txt">${doneT}/${totalT}</span>
643
+ `;
644
+ card.appendChild(pr);
645
+ } else if (totalT > 0 && colId === "icebox") {
646
+ // For icebox show just a count badge
647
+ const badge = el("span", "prog-txt");
648
+ badge.style.cssText = "display:inline-block;margin-top:5px;background:rgba(100,116,139,0.12);border-radius:4px;padding:1px 6px;";
649
+ badge.textContent = `${totalT} subtasków`;
650
+ card.appendChild(badge);
651
+ }
652
+
653
+ // Actions
654
+ const actions = el("div", "card-actions");
655
+
656
+ // ← arrow
657
+ const { prev, next } = getNeighborCols(colId);
658
+ if (prev) {
659
+ const lb = document.createElement("button");
660
+ lb.className = "cbtn arr";
661
+ lb.textContent = "←";
662
+ lb.title = `→ ${prev.label}`;
663
+ lb.onclick = async e => { e.stopPropagation(); await moveCard(epic.id, prev.id); };
664
+ actions.appendChild(lb);
665
+ }
666
+ // → arrow
667
+ if (next) {
668
+ const rb = document.createElement("button");
669
+ rb.className = "cbtn arr";
670
+ rb.textContent = "→";
671
+ rb.title = `→ ${next.label}`;
672
+ rb.onclick = async e => { e.stopPropagation(); await moveCard(epic.id, next.id); };
673
+ actions.appendChild(rb);
674
+ }
675
+
676
+ // Expand subtasks button
677
+ if (totalT > 0) {
678
+ const expBtn = document.createElement("button");
679
+ expBtn.className = "cbtn";
680
+ expBtn.textContent = isExpanded ? "▲" : "▼ Subtaski";
681
+ expBtn.onclick = e => { e.stopPropagation(); toggleExpand(epic.id); };
682
+ actions.appendChild(expBtn);
683
+ }
684
+
685
+ // Edit button
686
+ const editBtn = document.createElement("button");
687
+ editBtn.className = "cbtn edit-btn";
688
+ editBtn.textContent = "✎";
689
+ editBtn.title = "Edytuj";
690
+ editBtn.onclick = e => { e.stopPropagation(); startEdit(epic.id); };
691
+ actions.appendChild(editBtn);
692
+
693
+ card.appendChild(actions);
694
+
695
+ // Subtask list (expanded, view mode)
696
+ if (isExpanded && totalT > 0) {
697
+ const lbl = el("div", "subtask-label");
698
+ lbl.textContent = "Subtaski";
699
+ card.appendChild(lbl);
700
+
701
+ appendDetailSections(card, epic);
702
+
703
+ const list = el("div", "tasks-list");
704
+ epic.tasks.forEach((task, idx) => {
705
+ const row = el("div", `task-row${task.done ? " done" : ""}`);
706
+ const cbId = `v-${epic.id}-${idx}`;
707
+ row.innerHTML = `
708
+ <input type="checkbox" id="${cbId}" ${task.done ? "checked" : ""}>
709
+ <div class="task-copy">
710
+ <label for="${cbId}">${escHtml(task.text)}</label>
711
+ ${task.description ? `<div class="task-desc">${escHtml(task.description)}</div>` : ""}
712
+ </div>
713
+ `;
714
+ row.querySelector("input").onchange = async function() {
715
+ this.disabled = true;
716
+ await fetch(`/api/epics/${enc(epic.id)}/tasks/${idx}`, { method: "PATCH" });
717
+ await load();
718
+ };
719
+ list.appendChild(row);
720
+ });
721
+ card.appendChild(list);
722
+ }
723
+
724
+ card.onclick = e => {
725
+ if (e.target.tagName === "BUTTON" || e.target.tagName === "INPUT" || e.target.tagName === "LABEL") return;
726
+ if (totalT > 0) toggleExpand(epic.id);
727
+ };
728
+ }
729
+
730
+ return card;
731
+ }
732
+
733
+ function renderEditMode(card, epic, colId, shortId) {
734
+ const displayTitle = epic.title.replace(/^[\w.-]+:\s*/, "");
735
+ const dirty = dirtyEdits[epic.id] || {
736
+ title: displayTitle,
737
+ description: epic.description || "",
738
+ specs: epic.specs || "",
739
+ acceptance_criteria: (epic.acceptance_criteria || []).join("\n"),
740
+ notes: epic.notes || "",
741
+ tasks: epic.tasks.map(t => ({ ...t }))
742
+ };
743
+ dirtyEdits[epic.id] = dirty;
744
+
745
+ const top = el("div", "card-top");
746
+ const idBadge = el("span", "card-id");
747
+ idBadge.textContent = shortId;
748
+ top.appendChild(idBadge);
749
+ card.appendChild(top);
750
+
751
+ // Title input
752
+ const titleInput = document.createElement("input");
753
+ titleInput.className = "edit-title-input";
754
+ titleInput.value = dirty.title;
755
+ titleInput.oninput = () => { dirty.title = titleInput.value; dirtyEdits[epic.id] = dirty; };
756
+ card.appendChild(titleInput);
757
+
758
+ const meta = el("div", "edit-meta");
759
+ meta.appendChild(renderEditBlock("Opis", dirty.description, value => {
760
+ dirty.description = value;
761
+ dirtyEdits[epic.id] = dirty;
762
+ }, "Kontekst i plan implementacji..."));
763
+ meta.appendChild(renderEditBlock("Specs", dirty.specs, value => {
764
+ dirty.specs = value;
765
+ dirtyEdits[epic.id] = dirty;
766
+ }, "API, ograniczenia, edge case'y..."));
767
+ meta.appendChild(renderEditBlock("Acceptance Criteria", dirty.acceptance_criteria, value => {
768
+ dirty.acceptance_criteria = value;
769
+ dirtyEdits[epic.id] = dirty;
770
+ }, "Jeden warunek na linie"));
771
+ meta.appendChild(renderEditBlock("Notes", dirty.notes, value => {
772
+ dirty.notes = value;
773
+ dirtyEdits[epic.id] = dirty;
774
+ }, "Opcjonalne notatki"));
775
+ card.appendChild(meta);
776
+
777
+ // Subtasks section
778
+ const editTasks = el("div", "edit-tasks");
779
+ const subtaskLbl = el("div", "subtask-label");
780
+ subtaskLbl.textContent = "Subtaski";
781
+ editTasks.appendChild(subtaskLbl);
782
+
783
+ const taskRows = el("div");
784
+
785
+ const refreshRows = () => {
786
+ taskRows.innerHTML = "";
787
+ dirty.tasks.forEach((task, idx) => {
788
+ const row = el("div", "edit-task-row");
789
+ const cb = document.createElement("input");
790
+ cb.type = "checkbox";
791
+ cb.checked = task.done;
792
+ cb.onchange = () => { dirty.tasks[idx].done = cb.checked; dirtyEdits[epic.id] = dirty; };
793
+
794
+ const fields = el("div", "edit-task-fields");
795
+ const inp = document.createElement("input");
796
+ inp.className = "edit-task-input";
797
+ inp.value = task.text;
798
+ inp.oninput = () => { dirty.tasks[idx].text = inp.value; dirtyEdits[epic.id] = dirty; };
799
+ inp.onkeydown = e => {
800
+ if (e.key === "Enter") { e.preventDefault(); addSubtaskInput.focus(); }
801
+ if (e.key === "Backspace" && inp.value === "") {
802
+ e.preventDefault();
803
+ dirty.tasks.splice(idx, 1);
804
+ dirtyEdits[epic.id] = dirty;
805
+ refreshRows();
806
+ }
807
+ };
808
+
809
+ const desc = document.createElement("textarea");
810
+ desc.className = "edit-task-desc";
811
+ desc.placeholder = "Opis subtaska...";
812
+ desc.value = task.description || "";
813
+ desc.oninput = () => { dirty.tasks[idx].description = desc.value; dirtyEdits[epic.id] = dirty; };
814
+
815
+ const delBtn = document.createElement("button");
816
+ delBtn.className = "del-task";
817
+ delBtn.textContent = "×";
818
+ delBtn.title = "Usuń subtask";
819
+ delBtn.onclick = () => { dirty.tasks.splice(idx, 1); dirtyEdits[epic.id] = dirty; refreshRows(); };
820
+
821
+ fields.appendChild(inp);
822
+ fields.appendChild(desc);
823
+ row.appendChild(cb); row.appendChild(fields); row.appendChild(delBtn);
824
+ taskRows.appendChild(row);
825
+ });
826
+ };
827
+ refreshRows();
828
+ editTasks.appendChild(taskRows);
829
+
830
+ // Add subtask input
831
+ const addSubtaskInput = document.createElement("input");
832
+ addSubtaskInput.className = "add-task-inp";
833
+ addSubtaskInput.placeholder = "+ Nowy subtask… (Enter)";
834
+ addSubtaskInput.onkeydown = e => {
835
+ if (e.key === "Enter" && addSubtaskInput.value.trim()) {
836
+ dirty.tasks.push({ done: false, text: addSubtaskInput.value.trim(), description: "" });
837
+ dirtyEdits[epic.id] = dirty;
838
+ addSubtaskInput.value = "";
839
+ refreshRows();
840
+ }
841
+ };
842
+ editTasks.appendChild(addSubtaskInput);
843
+ card.appendChild(editTasks);
844
+
845
+ // Footer
846
+ const footer = el("div", "edit-footer");
847
+ const saveBtn = document.createElement("button");
848
+ saveBtn.className = "btn-sm btn-confirm";
849
+ saveBtn.textContent = "✓ Zapisz";
850
+ saveBtn.onclick = async e => { e.stopPropagation(); await saveEdit(epic.id); };
851
+
852
+ const cancelBtn = document.createElement("button");
853
+ cancelBtn.className = "btn-sm btn-cancel";
854
+ cancelBtn.textContent = "Anuluj";
855
+ cancelBtn.onclick = e => {
856
+ e.stopPropagation(); delete dirtyEdits[epic.id]; editingId = null; render();
857
+ };
858
+
859
+ footer.appendChild(saveBtn); footer.appendChild(cancelBtn);
860
+ card.appendChild(footer);
861
+ setTimeout(() => titleInput.focus(), 0);
862
+ }
863
+
864
+ // ── Edit helpers ──────────────────────────────────────────────────────────────
865
+
866
+ function startEdit(id) {
867
+ if (editingId && editingId !== id) delete dirtyEdits[editingId];
868
+ editingId = id;
869
+ expanded.add(id);
870
+ render();
871
+ }
872
+
873
+ async function saveEdit(epicId) {
874
+ const dirty = dirtyEdits[epicId];
875
+ if (!dirty) { editingId = null; render(); return; }
876
+
877
+ const body = {};
878
+ const epic = allEpics.find(e => e.id === epicId);
879
+ if (epic) {
880
+ const origTitle = epic.title.replace(/^[\w.-]+:\s*/, "");
881
+ if (dirty.title !== origTitle) body.title = dirty.title;
882
+ body.description = dirty.description;
883
+ body.specs = dirty.specs;
884
+ body.acceptance_criteria = splitLines(dirty.acceptance_criteria);
885
+ body.notes = dirty.notes;
886
+ body.tasks = dirty.tasks.filter(t => t.text.trim()).map((task, idx) => ({
887
+ id: task.id || `st-${idx + 1}`,
888
+ text: task.text.trim(),
889
+ done: Boolean(task.done),
890
+ description: (task.description || "").trim()
891
+ }));
892
+ }
893
+ try {
894
+ const res = await fetch(`/api/epics/${enc(epicId)}`, {
895
+ method: "PATCH",
896
+ headers: { "Content-Type": "application/json" },
897
+ body: JSON.stringify(body),
898
+ });
899
+ if (!res.ok) throw new Error();
900
+ toast("Zapisano ✓");
901
+ } catch { toast("Błąd zapisu!", true); }
902
+ delete dirtyEdits[epicId];
903
+ editingId = null;
904
+ await load();
905
+ }
906
+
907
+ function toggleExpand(id) {
908
+ if (expanded.has(id)) expanded.delete(id);
909
+ else expanded.add(id);
910
+ render();
911
+ }
912
+
913
+ // ── Lane "add here" ───────────────────────────────────────────────────────────
914
+
915
+ function showLaneAdd(lane, colId, groupName, addBtn) {
916
+ const existing = lane.querySelector(".lane-addinp");
917
+ if (existing) { existing.remove(); return; }
918
+
919
+ const wrap = el("div", "lane-addinp");
920
+ const inp = document.createElement("input");
921
+ inp.className = "lane-add-title";
922
+ inp.placeholder = "Nazwa nowego taska…";
923
+ inp.autocomplete = "off";
924
+
925
+ const description = document.createElement("textarea");
926
+ description.className = "lane-add-textarea";
927
+ description.placeholder = "Opis taska (opcjonalnie)...";
928
+
929
+ const actions = el("div", "lane-add-actions");
930
+ const confirmBtn = document.createElement("button");
931
+ confirmBtn.className = "btn-sm btn-confirm";
932
+ confirmBtn.textContent = "Dodaj";
933
+ const cancelBtn = document.createElement("button");
934
+ cancelBtn.className = "btn-sm btn-cancel";
935
+ cancelBtn.textContent = "Anuluj";
936
+
937
+ const doAdd = async () => {
938
+ const title = inp.value.trim();
939
+ if (!title) return;
940
+ inp.disabled = true;
941
+ description.disabled = true;
942
+ const res = await fetch("/api/epics", {
943
+ method: "POST",
944
+ headers: { "Content-Type": "application/json" },
945
+ body: JSON.stringify({
946
+ title,
947
+ column: colId,
948
+ epic_group: groupName === "—" ? "" : groupName,
949
+ description: description.value.trim()
950
+ }),
951
+ });
952
+ if (res.ok) {
953
+ toast("Dodano ✓");
954
+ wrap.remove();
955
+ await load();
956
+ } else {
957
+ toast("Błąd!", true);
958
+ inp.disabled = false;
959
+ description.disabled = false;
960
+ inp.focus();
961
+ }
962
+ };
963
+
964
+ inp.onkeydown = e => { if (e.key === "Enter") doAdd(); if (e.key === "Escape") wrap.remove(); };
965
+ description.onkeydown = e => { if (e.key === "Escape") wrap.remove(); };
966
+ confirmBtn.onclick = doAdd;
967
+ cancelBtn.onclick = () => wrap.remove();
968
+ wrap.appendChild(inp);
969
+ wrap.appendChild(description);
970
+ actions.appendChild(confirmBtn);
971
+ actions.appendChild(cancelBtn);
972
+ wrap.appendChild(actions);
973
+ lane.insertBefore(wrap, addBtn);
974
+ inp.focus();
975
+ }
976
+
977
+ // ── New epic form (header) ────────────────────────────────────────────────────
978
+
979
+ function toggleNewForm() {
980
+ const strip = document.getElementById("new-strip");
981
+ strip.classList.toggle("open");
982
+ if (strip.classList.contains("open")) {
983
+ document.getElementById("ne-title").focus();
984
+ }
985
+ }
986
+
987
+ async function submitNew() {
988
+ const title = document.getElementById("ne-title").value.trim();
989
+ const col = document.getElementById("ne-col").value;
990
+ const group = document.getElementById("ne-group").value.trim();
991
+ if (!title) { document.getElementById("ne-title").focus(); return; }
992
+
993
+ const res = await fetch("/api/epics", {
994
+ method: "POST",
995
+ headers: { "Content-Type": "application/json" },
996
+ body: JSON.stringify({ title, column: col, epic_group: group || "—" }),
997
+ });
998
+ if (res.ok) {
999
+ toast("Dodano task ✓");
1000
+ document.getElementById("ne-title").value = "";
1001
+ document.getElementById("ne-group").value = "";
1002
+ toggleNewForm();
1003
+ await load();
1004
+ } else {
1005
+ toast("Błąd!", true);
1006
+ }
1007
+ }
1008
+
1009
+ // Key handler for new form
1010
+ document.getElementById("ne-title").addEventListener("keydown", e => {
1011
+ if (e.key === "Enter") submitNew();
1012
+ if (e.key === "Escape") toggleNewForm();
1013
+ });
1014
+
1015
+ // ── Utils ─────────────────────────────────────────────────────────────────────
1016
+
1017
+ function el(tag, cls) {
1018
+ const e = document.createElement(tag);
1019
+ if (cls) e.className = cls;
1020
+ return e;
1021
+ }
1022
+ function splitLines(value) {
1023
+ return String(value || "").split("\n").map(line => line.trim()).filter(Boolean);
1024
+ }
1025
+ function appendDetailSections(card, epic) {
1026
+ if (epic.description) {
1027
+ card.appendChild(renderDetailSection("Opis", epic.description));
1028
+ }
1029
+ if (epic.specs) {
1030
+ card.appendChild(renderDetailSection("Specs", epic.specs));
1031
+ }
1032
+ if (epic.acceptance_criteria && epic.acceptance_criteria.length > 0) {
1033
+ card.appendChild(renderDetailListSection("Acceptance Criteria", epic.acceptance_criteria));
1034
+ }
1035
+ if (epic.notes) {
1036
+ card.appendChild(renderDetailSection("Notes", epic.notes));
1037
+ }
1038
+ }
1039
+ function renderDetailSection(label, text) {
1040
+ const wrap = el("div", "detail-section");
1041
+ const labelEl = el("div", "detail-label");
1042
+ labelEl.textContent = label;
1043
+ const textEl = el("div", "detail-text");
1044
+ textEl.textContent = text;
1045
+ wrap.appendChild(labelEl);
1046
+ wrap.appendChild(textEl);
1047
+ return wrap;
1048
+ }
1049
+ function renderDetailListSection(label, items) {
1050
+ const wrap = el("div", "detail-section");
1051
+ const labelEl = el("div", "detail-label");
1052
+ labelEl.textContent = label;
1053
+ const list = el("ul", "detail-list");
1054
+ items.forEach(item => {
1055
+ const li = document.createElement("li");
1056
+ li.textContent = item;
1057
+ list.appendChild(li);
1058
+ });
1059
+ wrap.appendChild(labelEl);
1060
+ wrap.appendChild(list);
1061
+ return wrap;
1062
+ }
1063
+ function renderEditBlock(label, value, onInput, placeholder) {
1064
+ const wrap = el("div");
1065
+ const labelEl = el("div", "detail-label");
1066
+ labelEl.textContent = label;
1067
+ const textarea = document.createElement("textarea");
1068
+ textarea.className = "edit-block";
1069
+ textarea.value = value;
1070
+ textarea.placeholder = placeholder;
1071
+ textarea.oninput = () => onInput(textarea.value);
1072
+ wrap.appendChild(labelEl);
1073
+ wrap.appendChild(textarea);
1074
+ return wrap;
1075
+ }
1076
+ function escHtml(s) {
1077
+ return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
1078
+ }
1079
+ function enc(s) { return encodeURIComponent(s); }
1080
+
1081
+ let toastTimer;
1082
+ function toast(msg, err = false) {
1083
+ const t = document.getElementById("toast");
1084
+ t.textContent = msg;
1085
+ t.style.background = err ? "#dc2626" : "#1e293b";
1086
+ t.classList.add("show");
1087
+ clearTimeout(toastTimer);
1088
+ toastTimer = setTimeout(() => t.classList.remove("show"), 2500);
1089
+ }
1090
+
1091
+ // ── Init ──────────────────────────────────────────────────────────────────────
1092
+ load();
1093
+ </script>
1094
+ </body>
1095
+ </html>