Okoge-keys commited on
Commit
690cb78
·
verified ·
1 Parent(s): 769932b

Delete v6_軽量KanbanBoard

Browse files
v6_軽量KanbanBoard/app.js DELETED
@@ -1,1545 +0,0 @@
1
- 
2
- (() => {
3
- "use strict";
4
-
5
- const STORAGE_KEY = "toyota-kanban-tasks-v1";
6
- const SETTINGS_KEY = "toyota-kanban-settings-v1";
7
- const DEFAULT_TITLE = "トヨタ式カンバンボード";
8
- const PANEL_KEYS = ["analytics", "filters", "form"];
9
-
10
- const columnSettings = {
11
- backlog: { wip: Number.POSITIVE_INFINITY, label: "バックログ" },
12
- ready: { wip: 5, label: "準備中" },
13
- inProgress: { wip: 3, label: "仕掛中" },
14
- waiting: { wip: 2, label: "停止中" },
15
- done: { wip: Number.POSITIVE_INFINITY, label: "完了" }
16
- };
17
-
18
- const form = document.getElementById("task-form");
19
- const fields = {
20
- title: document.getElementById("task-title"),
21
- owner: document.getElementById("task-owner"),
22
- due: document.getElementById("task-due"),
23
- status: document.getElementById("task-status"),
24
- notes: document.getElementById("task-notes"),
25
- tags: document.getElementById("task-tags"),
26
- link: document.getElementById("task-link")
27
- };
28
- const messageArea = document.getElementById("message-area");
29
- const seedButton = document.getElementById("seed-demo");
30
- const template = document.getElementById("task-template");
31
- const dropzones = Array.from(document.querySelectorAll("[data-dropzone]"));
32
- const counters = Array.from(document.querySelectorAll("[data-counter]"));
33
- const metricElements = {
34
- total: document.querySelector("[data-metric=\"total\"]"),
35
- active: document.querySelector("[data-metric=\"active\"]"),
36
- throughput7: document.querySelector("[data-metric=\"throughput7\"]"),
37
- leadtime: document.querySelector("[data-metric=\"leadtime\"]")
38
- };
39
- const editDialog = document.getElementById("edit-dialog");
40
- const editForm = document.getElementById("edit-form");
41
- const editFields = editForm
42
- ? {
43
- title: document.getElementById("edit-title"),
44
- owner: document.getElementById("edit-owner"),
45
- due: document.getElementById("edit-due"),
46
- notes: document.getElementById("edit-notes"),
47
- tags: document.getElementById("edit-tags"),
48
- link: document.getElementById("edit-link")
49
- }
50
- : null;
51
-
52
- const boardTitleHeading = document.getElementById("board-title-heading");
53
- const titleEditButton = document.getElementById("title-edit");
54
- const panelSections = Array.from(document.querySelectorAll("[data-panel]"));
55
- const panels = new Map(panelSections.map((section) => [section.dataset.panel, section]));
56
- const panelToggleButtons = Array.from(document.querySelectorAll("[data-action=\"toggle-panel\"]"));
57
-
58
- const filterElements = {
59
- keyword: document.getElementById("filter-keyword"),
60
- dueStart: document.getElementById("filter-due-start"),
61
- dueEnd: document.getElementById("filter-due-end"),
62
- clear: document.getElementById("filter-clear"),
63
- tagList: document.getElementById("filter-tag-list")
64
- };
65
- const selectionElements = {
66
- counter: document.getElementById("selection-counter"),
67
- hint: document.getElementById("selection-hint"),
68
- selectAll: document.getElementById("selection-selectall"),
69
- clear: document.getElementById("selection-clear"),
70
- delete: document.getElementById("selection-delete")
71
- };
72
- const dataTransferElements = {
73
- exportButton: document.getElementById("data-export"),
74
- importButton: document.getElementById("data-import"),
75
- importInput: document.getElementById("data-import-input")
76
- };
77
-
78
- const uiElements = {
79
- viewToggle: document.getElementById("view-toggle")
80
- };
81
-
82
- const filterState = {
83
- keyword: "",
84
- dueStart: "",
85
- dueEnd: "",
86
- tags: new Set()
87
- };
88
-
89
- const selectionState = {
90
- ids: new Set(),
91
- lastVisibleIds: []
92
- };
93
-
94
- const uiState = {
95
- compact: false
96
- };
97
-
98
- let settings = createDefaultSettings();
99
- let tasks = [];
100
- let messageTimer = null;
101
- let draggingTaskId = null;
102
- let editingTaskId = null;
103
-
104
- init();
105
-
106
- function createDefaultSettings() {
107
- return {
108
- title: DEFAULT_TITLE,
109
- panels: PANEL_KEYS.reduce((acc, key) => {
110
- acc[key] = false;
111
- return acc;
112
- }, {})
113
- };
114
- }
115
-
116
- function init() {
117
- loadSettings();
118
- applyTitle();
119
- applyPanelStates({ initial: true });
120
- loadFromStorage();
121
- attachFormEvents();
122
- attachDropzoneEvents();
123
- attachSeedButton();
124
- attachEditForm();
125
- attachFilterHandlers();
126
- attachSelectionHandlers();
127
- attachDataTransferHandlers();
128
- attachViewHandlers();
129
- attachPanelHandlers();
130
- attachTitleEditor();
131
- form?.addEventListener("reset", () => hideMessage());
132
- updateViewToggleLabel();
133
- updateSelectionUI();
134
- render();
135
- }
136
-
137
- function loadSettings() {
138
- try {
139
- const raw = window.localStorage.getItem(SETTINGS_KEY);
140
- if (raw) {
141
- const parsed = JSON.parse(raw);
142
- if (parsed && typeof parsed === "object") {
143
- settings = {
144
- title: typeof parsed.title === "string" && parsed.title.trim() ? parsed.title.trim() : DEFAULT_TITLE,
145
- panels: { ...createDefaultSettings().panels, ...(parsed.panels || {}) }
146
- };
147
- return;
148
- }
149
- }
150
- } catch (error) {
151
- console.warn("Failed to load settings:", error);
152
- }
153
- settings = createDefaultSettings();
154
- }
155
-
156
- function saveSettings() {
157
- try {
158
- window.localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
159
- } catch (error) {
160
- console.warn("Failed to save settings:", error);
161
- }
162
- }
163
-
164
- function applyTitle() {
165
- if (boardTitleHeading) {
166
- boardTitleHeading.textContent = settings.title;
167
- }
168
- document.title = settings.title;
169
- }
170
-
171
- function attachTitleEditor() {
172
- titleEditButton?.addEventListener("click", handleTitleEdit);
173
- }
174
-
175
- function handleTitleEdit() {
176
- const current = settings.title || DEFAULT_TITLE;
177
- const next = window.prompt("ボードタイトルを入力してください。", current);
178
- if (next === null) return;
179
- const trimmed = next.trim();
180
- settings.title = trimmed || DEFAULT_TITLE;
181
- applyTitle();
182
- saveSettings();
183
- showMessage("タイトルを更新しました。", "info");
184
- }
185
-
186
- function attachPanelHandlers() {
187
- panelToggleButtons.forEach((button) => {
188
- button.addEventListener("click", () => {
189
- const key = button.dataset.target;
190
- const section = panels.get(key);
191
- if (!key || !section) return;
192
- const willCollapse = !section.classList.contains("panel-collapsed");
193
- setPanelCollapsed(key, willCollapse);
194
- });
195
- });
196
- }
197
-
198
- function applyPanelStates({ initial = false } = {}) {
199
- PANEL_KEYS.forEach((key) => {
200
- const collapsed = Boolean(settings.panels[key]);
201
- setPanelCollapsed(key, collapsed, { save: !initial });
202
- });
203
- }
204
- function setPanelCollapsed(panelKey, collapsed, { save = true } = {}) {
205
- const section = panels.get(panelKey);
206
- if (!section) return;
207
- section.classList.toggle("panel-collapsed", collapsed);
208
- const body = section.querySelector(`[data-panel-body="${panelKey}"]`);
209
- if (body) {
210
- body.hidden = collapsed;
211
- }
212
- updatePanelButton(panelKey, collapsed);
213
- settings.panels[panelKey] = collapsed;
214
- if (save) {
215
- saveSettings();
216
- }
217
- }
218
-
219
- function updatePanelButton(panelKey, collapsed) {
220
- const button = panelToggleButtons.find((item) => item.dataset.target === panelKey);
221
- if (!button) return;
222
- button.textContent = collapsed ? "展開する" : "折りたたむ";
223
- button.setAttribute("aria-expanded", collapsed ? "false" : "true");
224
- }
225
-
226
- function attachFormEvents() {
227
- form?.addEventListener("submit", handleSubmit);
228
- }
229
-
230
- function handleSubmit(event) {
231
- event.preventDefault();
232
- const rawTitle = fields.title.value.trim();
233
- const rawOwner = fields.owner.value.trim();
234
- const rawNotes = fields.notes.value.trim();
235
- const status = fields.status.value;
236
- const due = fields.due.value;
237
- const tags = parseTags(fields.tags?.value ?? "");
238
- const link = normalizeLink(fields.link?.value ?? "");
239
-
240
- if (!rawTitle) {
241
- showMessage("タイトルを入力してください。", "warn");
242
- fields.title.focus();
243
- return;
244
- }
245
-
246
- if (!canEnterColumn(status)) {
247
- const limit = columnSettings[status]?.wip;
248
- const label = columnSettings[status]?.label ?? status;
249
- showMessage(`「${label}」はWIP上限(${limit})を超えられません。`, "warn");
250
- return;
251
- }
252
-
253
- const now = new Date();
254
- const newTask = {
255
- id: createId(),
256
- title: rawTitle,
257
- owner: rawOwner,
258
- due,
259
- notes: rawNotes,
260
- status,
261
- link,
262
- createdAt: now.toISOString(),
263
- tags
264
- };
265
-
266
- if (status === "done") {
267
- newTask.completedAt = now.toISOString();
268
- }
269
-
270
- tasks.push(newTask);
271
- persist();
272
- render();
273
- form.reset();
274
- fields.title.focus();
275
- showMessage("タスクを登録しました。", "info");
276
- }
277
-
278
- function attachDropzoneEvents() {
279
- dropzones.forEach((zone) => {
280
- zone.addEventListener("dragover", handleDragOver);
281
- zone.addEventListener("dragleave", handleDragLeave);
282
- zone.addEventListener("drop", handleDrop);
283
- });
284
- }
285
-
286
- function attachSeedButton() {
287
- seedButton?.addEventListener("click", seedDemoData);
288
- }
289
-
290
- function attachEditForm() {
291
- if (!editForm || !editDialog || !editFields) return;
292
-
293
- editForm.addEventListener("submit", handleEditSubmit);
294
- const cancelButton = editForm.querySelector("[data-action=\"cancel-edit\"]");
295
- cancelButton?.addEventListener("click", () => closeEditDialog());
296
-
297
- editDialog.addEventListener("cancel", () => closeEditDialog(true));
298
- editDialog.addEventListener("close", () => resetEditingState());
299
- }
300
-
301
- function attachFilterHandlers() {
302
- filterElements.keyword?.addEventListener("input", handleKeywordFilter);
303
- filterElements.dueStart?.addEventListener("change", handleDueFilter);
304
- filterElements.dueEnd?.addEventListener("change", handleDueFilter);
305
- filterElements.clear?.addEventListener("click", clearFilters);
306
- filterElements.tagList?.addEventListener("click", handleTagFilterClick);
307
- }
308
-
309
- function attachSelectionHandlers() {
310
- selectionElements.selectAll?.addEventListener("click", selectAllVisible);
311
- selectionElements.clear?.addEventListener("click", () => {
312
- clearSelection();
313
- syncSelectionCheckboxes();
314
- });
315
- selectionElements.delete?.addEventListener("click", deleteSelectedTasks);
316
- }
317
-
318
- function attachDataTransferHandlers() {
319
- const { exportButton, importButton, importInput } = dataTransferElements;
320
- exportButton?.addEventListener("click", handleExportTasks);
321
- importButton?.addEventListener("click", () => importInput?.click());
322
- importInput?.addEventListener("change", handleImportFileSelection);
323
- }
324
-
325
- function handleExportTasks() {
326
- if (!tasks.length) {
327
- showMessage("エクスポートできるカードがありません。", "warn");
328
- return;
329
- }
330
-
331
- try {
332
- const now = new Date();
333
- const payload = {
334
- version: 1,
335
- exportedAt: now.toISOString(),
336
- boardTitle: settings.title,
337
- tasks: tasks.map((task) => ({ ...task }))
338
- };
339
- const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
340
- const filename = `kanban-export-${now.toISOString().replace(/[:.]/g, "-")}.json`;
341
- const url = URL.createObjectURL(blob);
342
- const link = document.createElement("a");
343
- link.href = url;
344
- link.download = filename;
345
- document.body.appendChild(link);
346
- link.click();
347
- window.setTimeout(() => {
348
- document.body.removeChild(link);
349
- URL.revokeObjectURL(url);
350
- }, 0);
351
- showMessage("カードデータをエクスポートしました。", "info");
352
- } catch (error) {
353
- console.warn("Failed to export tasks:", error);
354
- showMessage("エクスポートに失敗しました。", "warn");
355
- }
356
- }
357
-
358
- function handleImportFileSelection(event) {
359
- const input = event.target;
360
- if (!input?.files?.length) return;
361
-
362
- const [file] = input.files;
363
- const reader = new FileReader();
364
-
365
- reader.addEventListener("load", () => {
366
- try {
367
- const imported = parseImportedTasks(reader.result);
368
- if (!imported.length) {
369
- showMessage("取り込めるカードが見つかりませんでした。", "warn");
370
- } else {
371
- tasks = imported;
372
- selectionState.ids.clear();
373
- persist();
374
- render();
375
- showMessage(`${imported.length}件のカードをインポートしました。`, "info");
376
- }
377
- } catch (error) {
378
- console.warn("Failed to import tasks:", error);
379
- showMessage("インポートに失敗しました。", "warn");
380
- } finally {
381
- input.value = "";
382
- }
383
- });
384
-
385
- reader.addEventListener("error", () => {
386
- console.warn("Failed to read import file:", reader.error);
387
- showMessage("ファイルを読み取れませんでした。", "warn");
388
- input.value = "";
389
- });
390
-
391
- reader.readAsText(file, "utf-8");
392
- }
393
-
394
- function parseImportedTasks(raw) {
395
- if (raw == null) return [];
396
-
397
- let data;
398
- try {
399
- data = JSON.parse(String(raw));
400
- } catch (error) {
401
- throw new Error("Invalid JSON");
402
- }
403
-
404
- const entries = Array.isArray(data) ? data : Array.isArray(data?.tasks) ? data.tasks : [];
405
- if (!entries.length) {
406
- return [];
407
- }
408
-
409
- const seenIds = new Set();
410
- const imported = [];
411
-
412
- entries.forEach((item) => {
413
- if (!item || typeof item !== "object") return;
414
- const candidate = { ...item };
415
- let id = normalizeImportedId(candidate.id);
416
- if (!id) {
417
- id = createId();
418
- }
419
- while (seenIds.has(id)) {
420
- id = createId();
421
- }
422
- candidate.id = id;
423
- const normalized = normalizeTask(candidate);
424
- if (!normalized.title) return;
425
- seenIds.add(normalized.id);
426
- imported.push(normalized);
427
- });
428
-
429
- return imported;
430
- }
431
-
432
- function normalizeImportedId(value) {
433
- if (typeof value === "string") {
434
- const trimmed = value.trim();
435
- return trimmed ? trimmed : "";
436
- }
437
- if (typeof value === "number" && Number.isFinite(value)) {
438
- return String(value);
439
- }
440
- return "";
441
- }
442
-
443
- function attachViewHandlers() {
444
- uiElements.viewToggle?.addEventListener("click", toggleViewMode);
445
- }
446
-
447
- function handleKeywordFilter(event) {
448
- filterState.keyword = event.target.value.trim();
449
- render();
450
- }
451
-
452
- function handleDueFilter() {
453
- filterState.dueStart = filterElements.dueStart?.value ?? "";
454
- filterState.dueEnd = filterElements.dueEnd?.value ?? "";
455
- render();
456
- }
457
-
458
- function handleTagFilterClick(event) {
459
- const button = event.target.closest("[data-tag-value]");
460
- if (!button) return;
461
- const tag = button.dataset.tagValue;
462
- if (!tag) return;
463
-
464
- if (filterState.tags.has(tag)) {
465
- filterState.tags.delete(tag);
466
- } else {
467
- filterState.tags.add(tag);
468
- }
469
- render();
470
- }
471
-
472
- function clearFilters() {
473
- filterState.keyword = "";
474
- filterState.dueStart = "";
475
- filterState.dueEnd = "";
476
- filterState.tags.clear();
477
- if (filterElements.keyword) {
478
- filterElements.keyword.value = "";
479
- }
480
- if (filterElements.dueStart) {
481
- filterElements.dueStart.value = "";
482
- }
483
- if (filterElements.dueEnd) {
484
- filterElements.dueEnd.value = "";
485
- }
486
- render();
487
- }
488
-
489
- function handleDragStart(event) {
490
- const card = event.currentTarget;
491
- const taskId = card.dataset.taskId;
492
- if (!taskId) return;
493
- draggingTaskId = taskId;
494
- event.dataTransfer.effectAllowed = "move";
495
- event.dataTransfer.setData("text/plain", taskId);
496
- card.classList.add("opacity-40");
497
- }
498
-
499
- function handleDragEnd(event) {
500
- event.currentTarget.classList.remove("opacity-40");
501
- draggingTaskId = null;
502
- dropzones.forEach((zone) => setDropzoneHighlight(zone, "none"));
503
- }
504
-
505
- function handleDragOver(event) {
506
- event.preventDefault();
507
- const zone = event.currentTarget;
508
- const targetColumn = zone.dataset.dropzone;
509
- const allowed = canEnterColumn(targetColumn, draggingTaskId);
510
- event.dataTransfer.dropEffect = allowed ? "move" : "none";
511
- setDropzoneHighlight(zone, allowed ? "allowed" : "denied");
512
- }
513
-
514
- function handleDragLeave(event) {
515
- setDropzoneHighlight(event.currentTarget, "none");
516
- }
517
-
518
- function handleDrop(event) {
519
- event.preventDefault();
520
- const zone = event.currentTarget;
521
- const targetColumn = zone.dataset.dropzone;
522
- const taskId = draggingTaskId || event.dataTransfer.getData("text/plain");
523
-
524
- setDropzoneHighlight(zone, "none");
525
-
526
- if (!taskId) return;
527
-
528
- if (!canEnterColumn(targetColumn, taskId)) {
529
- const limit = columnSettings[targetColumn]?.wip;
530
- const label = columnSettings[targetColumn]?.label ?? targetColumn;
531
- showMessage(`「${label}」はWIP上限(${limit})を超えられません。`, "warn");
532
- return;
533
- }
534
-
535
- moveTask(taskId, targetColumn);
536
- }
537
-
538
- function moveTask(taskId, targetColumn) {
539
- const task = tasks.find((item) => item.id === taskId);
540
- if (!task || task.status === targetColumn) return;
541
-
542
- task.status = targetColumn;
543
- if (targetColumn === "done") {
544
- task.completedAt = new Date().toISOString();
545
- }
546
-
547
- persist();
548
- render();
549
- showMessage(`タスクを「${columnSettings[targetColumn]?.label ?? targetColumn}」へ移動しました。`, "info");
550
- }
551
-
552
- function render() {
553
- cleanupSelection();
554
- renderTagFilters();
555
-
556
- const filterActive = isFilterActive();
557
- const filteredTasks = getFilteredTasks();
558
- const tasksToRender = filterActive ? filteredTasks : tasks;
559
-
560
- selectionState.lastVisibleIds = tasksToRender.map((task) => task.id);
561
-
562
- dropzones.forEach((zone) => zone.replaceChildren());
563
-
564
- const byColumn = new Map();
565
- tasksToRender
566
- .slice()
567
- .sort((a, b) => new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime())
568
- .forEach((task) => {
569
- const list = byColumn.get(task.status) ?? [];
570
- list.push(task);
571
- byColumn.set(task.status, list);
572
- });
573
-
574
- dropzones.forEach((zone) => {
575
- const columnKey = zone.dataset.dropzone;
576
- const columnTasks = byColumn.get(columnKey) ?? [];
577
- columnTasks.forEach((task) => zone.appendChild(buildTaskCard(task)));
578
- if (!zone.childElementCount) {
579
- zone.appendChild(createEmptyState(filterActive));
580
- }
581
- });
582
-
583
- updateCounters(filterActive, filteredTasks);
584
- renderMetrics(filteredTasks, filterActive);
585
- syncSelectionCheckboxes();
586
- updateSelectionUI();
587
- }
588
- function buildTaskCard(task) {
589
- const fragment = template.content.cloneNode(true);
590
- const card = fragment.querySelector("[data-task-id]");
591
- card.dataset.taskId = task.id;
592
-
593
- const isSelected = selectionState.ids.has(task.id);
594
- card.classList.toggle("kanban-card-selected", isSelected);
595
-
596
-
597
-
598
- const titleEl = card.querySelector("[data-field=\"title\"]");
599
- const ownerEl = card.querySelector("[data-field=\"owner\"]");
600
- const dueEl = card.querySelector("[data-field=\"due\"]");
601
- const notesEl = card.querySelector("[data-field=\"notes\"]");
602
- const tagsEl = card.querySelector("[data-field=\"tags\"]");
603
- const tagsSection = card.querySelector("[data-section=\"tags\"]");
604
- const linkSection = card.querySelector("[data-section=\"link\"]");
605
- const linkEl = card.querySelector("[data-field=\"link\"]");
606
- const doneButton = card.querySelector("[data-action=\"mark-done\"]");
607
- const editButton = card.querySelector("[data-action=\"edit-task\"]");
608
- const deleteButton = card.querySelector("[data-action=\"delete-task\"]");
609
- const checkbox = card.querySelector("[data-action=\"select-task\"]");
610
- const toggleDetailsButton = card.querySelector("[data-action=\"toggle-details\"]");
611
-
612
- titleEl.textContent = task.title;
613
- ownerEl.textContent = task.owner || "未設定";
614
- dueEl.textContent = formatDue(task.due);
615
- notesEl.textContent = task.notes || "メモなし";
616
- notesEl.classList.toggle("text-slate-500", !task.notes);
617
-
618
- if (tagsEl) {
619
- tagsEl.replaceChildren();
620
- const hasTags = Array.isArray(task.tags) && task.tags.length > 0;
621
- tagsSection?.classList.toggle("hidden", !hasTags);
622
- tagsSection?.setAttribute("aria-hidden", hasTags ? "false" : "true");
623
- tagsSection?.setAttribute("data-has-tags", hasTags ? "true" : "false");
624
- if (hasTags) {
625
- task.tags.forEach((tag) => {
626
- const pill = document.createElement("span");
627
- pill.textContent = `#${tag}`;
628
- pill.className = buildTagPillClass(filterState.tags.has(tag));
629
- tagsEl.appendChild(pill);
630
- });
631
- }
632
- }
633
-
634
- if (linkSection && linkEl) {
635
- if (task.link) {
636
- linkSection.classList.remove("hidden");
637
- linkEl.href = task.link;
638
- linkEl.textContent = formatLinkLabel(task.link);
639
- linkEl.title = task.link;
640
- } else {
641
- linkSection.classList.add("hidden");
642
- linkEl.removeAttribute("href");
643
- linkEl.removeAttribute("title");
644
- linkEl.textContent = "";
645
- }
646
- }
647
-
648
- if (checkbox) {
649
- checkbox.checked = isSelected;
650
- checkbox.addEventListener("click", (event) => event.stopPropagation());
651
- checkbox.addEventListener("mousedown", (event) => event.stopPropagation());
652
- checkbox.addEventListener("change", (event) => toggleSelection(task.id, event.target.checked));
653
- }
654
-
655
- if (doneButton) {
656
- const isDone = task.status === "done";
657
- doneButton.classList.toggle("hidden", isDone);
658
- doneButton.addEventListener("click", (event) => {
659
- event.preventDefault();
660
- event.stopPropagation();
661
- moveTask(task.id, "done");
662
- });
663
- }
664
-
665
- editButton?.addEventListener("click", (event) => {
666
- event.preventDefault();
667
- event.stopPropagation();
668
- openEditDialog(task.id);
669
- });
670
- deleteButton?.addEventListener("click", (event) => {
671
- event.preventDefault();
672
- event.stopPropagation();
673
- deleteTask(task.id);
674
- });
675
-
676
- if (toggleDetailsButton) {
677
- toggleDetailsButton.addEventListener("click", (event) => {
678
- event.preventDefault();
679
- event.stopPropagation();
680
- card.classList.toggle("kanban-card-expanded");
681
- updateCardDetailVisibility(card);
682
- });
683
- }
684
-
685
- card.addEventListener("dragstart", handleDragStart);
686
- card.addEventListener("dragend", handleDragEnd);
687
-
688
- applyCardLayout(card);
689
-
690
- return card;
691
- }
692
-
693
- function applyCardLayout(card) {
694
- if (!card) return;
695
- card.classList.toggle("kanban-card-compact", uiState.compact);
696
- if (!uiState.compact) {
697
- card.classList.remove("kanban-card-expanded");
698
- }
699
- updateCardDetailVisibility(card);
700
- }
701
-
702
- function updateCardDetailVisibility(card) {
703
- const isExpanded = card.classList.contains("kanban-card-expanded");
704
- const showDetails = !uiState.compact || isExpanded;
705
-
706
- const meta = card.querySelector("[data-section=\"meta\"]");
707
- const notes = card.querySelector("[data-section=\"notes\"]");
708
- const tags = card.querySelector("[data-section=\"tags\"]");
709
- const toggleDetailsButton = card.querySelector("[data-action=\"toggle-details\"]");
710
-
711
- if (meta) {
712
- meta.classList.toggle("hidden", !showDetails);
713
- }
714
- if (notes) {
715
- notes.classList.toggle("hidden", !showDetails);
716
- notes.classList.toggle("kanban-note-clamped", uiState.compact && !isExpanded);
717
- }
718
-
719
- if (tags) {
720
- const hasTags = tags && tags.getAttribute("data-has-tags") === "true";
721
- if (hasTags) {
722
- tags.classList.toggle("hidden", !showDetails);
723
- }
724
- }
725
- if (toggleDetailsButton) {
726
- const hasDetails = Boolean((meta && meta.textContent.trim()) || (notes && notes.textContent.trim() && notes.textContent.trim() !== "メモなし"));
727
- const shouldShowToggle = uiState.compact && hasDetails;
728
- toggleDetailsButton.classList.toggle("invisible", !shouldShowToggle);
729
- toggleDetailsButton.classList.toggle("pointer-events-none", !shouldShowToggle);
730
- toggleDetailsButton.textContent = isExpanded ? "折りたたむ" : "詳細";
731
- toggleDetailsButton.setAttribute("aria-expanded", isExpanded ? "true" : "false");
732
- }
733
- }
734
-
735
- function createEmptyState(filterActive) {
736
- const empty = document.createElement("p");
737
- empty.className = "rounded-lg border border-dashed border-slate-600/60 px-3 py-6 text-center text-xs text-slate-500";
738
- empty.textContent = filterActive ? "該当するカードがありません" : "カードなし";
739
- empty.setAttribute("aria-hidden", "true");
740
- return empty;
741
- }
742
-
743
- function updateCounters(filterActive, filteredTasks) {
744
- counters.forEach((counter) => {
745
- const columnKey = counter.dataset.counter;
746
- const totalCount = tasks.filter((task) => task.status === columnKey).length;
747
- const visibleCount = filterActive
748
- ? filteredTasks.filter((task) => task.status === columnKey).length
749
- : totalCount;
750
-
751
- counter.textContent = filterActive ? `${visibleCount}/${totalCount}` : String(totalCount);
752
-
753
- const parentArticle = counter.closest("article");
754
- if (!parentArticle) return;
755
-
756
- const limit = columnSettings[columnKey]?.wip;
757
- const isLimited = Number.isFinite(limit);
758
- parentArticle.classList.remove("border", "border-kanban-caution");
759
- if (isLimited && totalCount >= limit) {
760
- parentArticle.classList.add("border", "border-kanban-caution");
761
- }
762
- });
763
- }
764
-
765
- function renderMetrics(filteredTasks, filterActive) {
766
- const activeStatuses = new Set(["ready", "inProgress", "waiting"]);
767
-
768
- const visibleTasks = filterActive ? filteredTasks : tasks;
769
- const totalTasks = tasks.length;
770
- const visibleTotal = visibleTasks.length;
771
-
772
- const totalDisplay = filterActive
773
- ? formatFilteredCount(visibleTotal, totalTasks)
774
- : formatCount(totalTasks);
775
-
776
- const totalActive = tasks.filter((task) => activeStatuses.has(task.status)).length;
777
- const visibleActive = visibleTasks.filter((task) => activeStatuses.has(task.status)).length;
778
- const activeDisplay = filterActive
779
- ? formatFilteredCount(visibleActive, totalActive)
780
- : formatCount(totalActive);
781
-
782
- const now = new Date();
783
- const sevenDaysAgo = new Date(now);
784
- sevenDaysAgo.setDate(now.getDate() - 7);
785
-
786
- const totalDone = tasks.filter((task) => task.status === "done");
787
- const visibleDone = visibleTasks.filter((task) => task.status === "done");
788
-
789
- const totalThroughput7 = totalDone.filter((task) => isCompletedSince(task, sevenDaysAgo)).length;
790
- const visibleThroughput7 = visibleDone.filter((task) => isCompletedSince(task, sevenDaysAgo)).length;
791
- const throughputDisplay = filterActive
792
- ? formatFilteredCount(visibleThroughput7, totalThroughput7)
793
- : formatCount(totalThroughput7);
794
-
795
- const totalLeadAvg = computeAverageLead(totalDone);
796
- const visibleLeadAvg = computeAverageLead(visibleDone);
797
- const leadDisplay = filterActive
798
- ? formatLeadFiltered(visibleLeadAvg, totalLeadAvg)
799
- : formatLeadFiltered(totalLeadAvg, null);
800
-
801
- setMetric("total", totalDisplay);
802
- setMetric("active", activeDisplay);
803
- setMetric("throughput7", throughputDisplay);
804
- setMetric("leadtime", leadDisplay);
805
- }
806
-
807
- function computeAverageLead(doneTasks) {
808
- const leadTimes = doneTasks
809
- .map((task) => computeLeadTimeDays(task))
810
- .filter((value) => Number.isFinite(value));
811
- if (!leadTimes.length) return null;
812
- const total = leadTimes.reduce((sum, value) => sum + value, 0);
813
- return total / leadTimes.length;
814
- }
815
-
816
- function formatLeadFiltered(primary, overall) {
817
- const base = Number.isFinite(primary) ? `${primary.toFixed(1)}日` : "--";
818
- if (overall === null || overall === undefined) {
819
- return base;
820
- }
821
- const overallText = Number.isFinite(overall) ? `${overall.toFixed(1)}日` : "--";
822
- return `${base} / 全${overallText}`;
823
- }
824
-
825
- function setMetric(key, text) {
826
- const target = metricElements[key];
827
- if (!target) return;
828
- target.textContent = text;
829
- }
830
-
831
- function formatCount(count) {
832
- return `${count}枚`;
833
- }
834
-
835
- function formatFilteredCount(visible, total) {
836
- return `${formatCount(visible)} / 全${formatCount(total)}`;
837
- }
838
-
839
- function isCompletedSince(task, threshold) {
840
- if (!task.completedAt) return false;
841
- const completed = new Date(task.completedAt);
842
- return !Number.isNaN(completed.getTime()) && completed >= threshold;
843
- }
844
-
845
- function computeLeadTimeDays(task) {
846
- if (!task?.createdAt || !task?.completedAt) return Number.NaN;
847
- const start = new Date(task.createdAt);
848
- const end = new Date(task.completedAt);
849
- if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return Number.NaN;
850
- const diffMs = end.getTime() - start.getTime();
851
- if (diffMs < 0) return Number.NaN;
852
- return diffMs / (1000 * 60 * 60 * 24);
853
- }
854
-
855
- function setDropzoneHighlight(zone, state) {
856
- zone.classList.remove("ring-2", "ring-offset-2", "ring-offset-slate-900", "ring-kanban-accent", "ring-kanban-caution", "bg-slate-800/40", "bg-slate-900/40");
857
- if (state === "allowed") {
858
- zone.classList.add("ring-2", "ring-offset-2", "ring-offset-slate-900", "ring-kanban-accent", "bg-slate-800/40");
859
- } else if (state === "denied") {
860
- zone.classList.add("ring-2", "ring-offset-2", "ring-offset-slate-900", "ring-kanban-caution", "bg-slate-900/40");
861
- }
862
- }
863
-
864
- function canEnterColumn(columnKey, movingTaskId) {
865
- const settingsColumn = columnSettings[columnKey];
866
- if (!settingsColumn) return true;
867
- if (!Number.isFinite(settingsColumn.wip)) return true;
868
- const current = tasks.filter((task) => task.status === columnKey && task.id !== movingTaskId).length;
869
- return current < settingsColumn.wip;
870
- }
871
-
872
- function isFilterActive() {
873
- return (
874
- Boolean(filterState.keyword) ||
875
- Boolean(filterState.dueStart) ||
876
- Boolean(filterState.dueEnd) ||
877
- filterState.tags.size > 0
878
- );
879
- }
880
-
881
- function getFilteredTasks() {
882
- if (!isFilterActive()) return tasks;
883
- const keyword = filterState.keyword.toLowerCase();
884
- const startDate = parseISODate(filterState.dueStart);
885
- const endDate = parseISODate(filterState.dueEnd);
886
-
887
- return tasks.filter((task) => {
888
- const keywordMatch = keyword
889
- ? [task.title, task.owner, task.notes, task.due]
890
- .filter(Boolean)
891
- .some((value) => String(value).toLowerCase().includes(keyword))
892
- : true;
893
-
894
- const tagsMatch = filterState.tags.size
895
- ? [...filterState.tags].every((tag) => Array.isArray(task.tags) && task.tags.includes(tag))
896
- : true;
897
-
898
- const dueMatch = (() => {
899
- if (!startDate && !endDate) return true;
900
- if (!task.due) return false;
901
- const dueDate = parseISODate(task.due);
902
- if (!dueDate) return false;
903
- if (startDate && dueDate < startDate) return false;
904
- if (endDate && dueDate > endDate) return false;
905
- return true;
906
- })();
907
-
908
- return keywordMatch && tagsMatch && dueMatch;
909
- });
910
- }
911
-
912
- function renderTagFilters() {
913
- if (!filterElements.tagList) return;
914
-
915
- const keyword = filterState.keyword.trim().toLowerCase();
916
- const startDate = parseISODate(filterState.dueStart);
917
- const endDate = parseISODate(filterState.dueEnd);
918
-
919
- const sourceTasks = tasks.filter((task) => {
920
- const keywordMatch = keyword
921
- ? [task.title, task.owner, task.notes, task.due]
922
- .filter(Boolean)
923
- .some((value) => String(value).toLowerCase().includes(keyword))
924
- : true;
925
-
926
- const dueMatch = (() => {
927
- if (!startDate && !endDate) return true;
928
- if (!task.due) return false;
929
- const dueDate = parseISODate(task.due);
930
- if (!dueDate) return false;
931
- if (startDate && dueDate < startDate) return false;
932
- if (endDate && dueDate > endDate) return false;
933
- return true;
934
- })();
935
-
936
- return keywordMatch && dueMatch;
937
- });
938
-
939
- const allTags = Array.from(
940
- new Set(
941
- sourceTasks
942
- .flatMap((task) => (Array.isArray(task.tags) ? task.tags : []))
943
- .map((tag) => tag.trim())
944
- .filter(Boolean)
945
- )
946
- ).sort((a, b) => a.localeCompare(b, 'ja'));
947
-
948
- const validTags = new Set(allTags);
949
- let removed = false;
950
- [...filterState.tags].forEach((tag) => {
951
- if (!validTags.has(tag)) {
952
- filterState.tags.delete(tag);
953
- removed = true;
954
- }
955
- });
956
- if (removed && filterElements.keyword && !isFilterActive()) {
957
- filterElements.keyword.value = filterState.keyword;
958
- }
959
-
960
- filterElements.tagList.replaceChildren();
961
-
962
- if (!allTags.length) {
963
- const empty = document.createElement('p');
964
- empty.className = 'text-xs text-slate-500';
965
- empty.textContent = 'タグはまだ登録されていません。';
966
- filterElements.tagList.appendChild(empty);
967
- return;
968
- }
969
-
970
- allTags.forEach((tag) => {
971
- const button = document.createElement('button');
972
- button.type = 'button';
973
- button.dataset.tagValue = tag;
974
- button.textContent = `#${tag}`;
975
- button.className = buildTagFilterClass(filterState.tags.has(tag));
976
- filterElements.tagList.appendChild(button);
977
- });
978
- }
979
-
980
- function buildTagFilterClass(isActive) {
981
- const base = "rounded-full border px-3 py-1 text-xs font-medium transition focus:outline-none focus-visible:ring focus-visible:ring-kanban-accent focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900";
982
- return isActive
983
- ? `${base} border-kanban-accent bg-kanban-accent/20 text-kanban-accent`
984
- : `${base} border-slate-600/70 bg-slate-800/80 text-slate-300 hover:border-slate-400 hover:text-white`;
985
- }
986
-
987
- function buildTagPillClass(isActive) {
988
- const base = "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium";
989
- return isActive
990
- ? `${base} border-kanban-accent bg-kanban-accent/20 text-kanban-accent`
991
- : `${base} border-slate-600/70 bg-slate-800/80 text-slate-300`;
992
- }
993
-
994
- function cleanupSelection() {
995
- const validIds = new Set(tasks.map((task) => task.id));
996
- let changed = false;
997
- selectionState.ids.forEach((id) => {
998
- if (!validIds.has(id)) {
999
- selectionState.ids.delete(id);
1000
- changed = true;
1001
- }
1002
- });
1003
- if (changed) {
1004
- updateSelectionUI();
1005
- syncSelectionCheckboxes();
1006
- }
1007
- }
1008
-
1009
- function toggleSelection(taskId, isSelected) {
1010
- if (isSelected) {
1011
- selectionState.ids.add(taskId);
1012
- } else {
1013
- selectionState.ids.delete(taskId);
1014
- }
1015
- updateSelectionUI();
1016
- syncSelectionCheckboxes();
1017
- }
1018
-
1019
- function selectAllVisible() {
1020
- if (!selectionState.lastVisibleIds.length) return;
1021
- selectionState.lastVisibleIds.forEach((id) => selectionState.ids.add(id));
1022
- updateSelectionUI();
1023
- syncSelectionCheckboxes();
1024
- }
1025
-
1026
- function clearSelection() {
1027
- selectionState.ids.clear();
1028
- updateSelectionUI();
1029
- syncSelectionCheckboxes();
1030
- }
1031
-
1032
- function syncSelectionCheckboxes() {
1033
- const checkboxes = document.querySelectorAll("[data-action=\"select-task\"]");
1034
- checkboxes.forEach((checkbox) => {
1035
- const card = checkbox.closest("[data-task-id]");
1036
- if (!card) return;
1037
- const isSelected = selectionState.ids.has(card.dataset.taskId);
1038
- checkbox.checked = isSelected;
1039
- card.classList.toggle("kanban-card-selected", isSelected);
1040
- });
1041
- }
1042
-
1043
- function updateSelectionUI() {
1044
- const count = selectionState.ids.size;
1045
- if (selectionElements.counter) {
1046
- selectionElements.counter.textContent = `選択中: ${count}枚`;
1047
- }
1048
- if (selectionElements.delete) {
1049
- selectionElements.delete.disabled = count === 0;
1050
- selectionElements.delete.setAttribute("aria-disabled", count === 0 ? "true" : "false");
1051
- }
1052
- if (selectionElements.clear) {
1053
- selectionElements.clear.disabled = count === 0;
1054
- }
1055
- if (selectionElements.selectAll) {
1056
- selectionElements.selectAll.disabled = selectionState.lastVisibleIds.length === 0;
1057
- }
1058
- }
1059
-
1060
- function deleteTask(taskId) {
1061
- const task = tasks.find((item) => item.id === taskId);
1062
- if (!task) return;
1063
- if (!window.confirm("このタスクを削除しますか?")) return;
1064
- selectionState.ids.delete(taskId);
1065
- tasks = tasks.filter((item) => item.id !== taskId);
1066
- persist();
1067
- render();
1068
- showMessage("タスクを削除しました。", "info");
1069
- }
1070
-
1071
- function deleteSelectedTasks() {
1072
- const ids = [...selectionState.ids];
1073
- if (!ids.length) return;
1074
- if (!window.confirm(`選択中の${ids.length}件を削除しますか?`)) return;
1075
- const idSet = new Set(ids);
1076
- tasks = tasks.filter((task) => !idSet.has(task.id));
1077
- selectionState.ids.clear();
1078
- persist();
1079
- render();
1080
- showMessage("選択したタスクを削除しました。", "info");
1081
- }
1082
- function loadFromStorage() {
1083
- try {
1084
- const raw = window.localStorage.getItem(STORAGE_KEY);
1085
- if (!raw) return;
1086
- const parsed = JSON.parse(raw);
1087
- if (!Array.isArray(parsed)) return;
1088
-
1089
- tasks = parsed
1090
- .filter((item) => typeof item === "object" && item !== null && item.id && item.title)
1091
- .map((item) => normalizeTask(item));
1092
- } catch (error) {
1093
- console.warn("Failed to load tasks from localStorage:", error);
1094
- tasks = [];
1095
- }
1096
- }
1097
-
1098
- function normalizeTask(item) {
1099
- const status = columnSettings[item.status] ? item.status : "backlog";
1100
- const createdAt = item.createdAt ?? new Date().toISOString();
1101
- const normalized = {
1102
- id: item.id,
1103
- title: item.title,
1104
- owner: item.owner ?? "",
1105
- notes: item.notes ?? "",
1106
- status,
1107
- due: item.due ?? "",
1108
- createdAt,
1109
- tags: normalizeTags(item.tags),
1110
- link: normalizeLink(item.link ?? item.url ?? "")
1111
- };
1112
-
1113
- if (status === "done") {
1114
- normalized.completedAt = item.completedAt ?? item.updatedAt ?? createdAt;
1115
- } else if (item.completedAt) {
1116
- normalized.completedAt = item.completedAt;
1117
- }
1118
-
1119
- return normalized;
1120
- }
1121
-
1122
- function normalizeTags(value) {
1123
- if (Array.isArray(value)) {
1124
- return parseTags(value.join(","));
1125
- }
1126
- if (typeof value === "string") {
1127
- return parseTags(value);
1128
- }
1129
- return [];
1130
- }
1131
-
1132
- function persist() {
1133
- try {
1134
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
1135
- } catch (error) {
1136
- console.warn("Failed to write tasks to localStorage:", error);
1137
- showMessage("ローカルストレージに保存できませんでした。", "warn");
1138
- }
1139
- }
1140
-
1141
- function showMessage(message, tone = "info") {
1142
- if (!messageArea) return;
1143
- clearTimeout(messageTimer);
1144
- messageArea.textContent = message;
1145
- messageArea.classList.remove("opacity-0", "text-kanban-caution", "text-sky-300");
1146
- messageArea.classList.add(tone === "warn" ? "text-kanban-caution" : "text-sky-300", "opacity-100");
1147
- messageTimer = window.setTimeout(() => hideMessage(), 4200);
1148
- }
1149
-
1150
- function hideMessage() {
1151
- if (!messageArea) return;
1152
- messageArea.classList.add("opacity-0");
1153
- }
1154
-
1155
- function seedDemoData() {
1156
- const now = new Date();
1157
- const demoCards = [
1158
- {
1159
- title: "前日の不具合整理",
1160
- owner: "田中",
1161
- notes: "Eラインの停止要因を5Whyで再整理",
1162
- status: "backlog",
1163
- due: isoDateOffset(now, 1),
1164
- tags: ["品質", "改善"],
1165
- link: "https://example.com/quality-review"
1166
- },
1167
- {
1168
- title: "朝会ファシリテーション",
1169
- owner: "佐藤",
1170
- notes: "指標ボード更新と本日の重点テーマ共有",
1171
- status: "ready",
1172
- due: isoDateOffset(now, 0),
1173
- tags: ["コミュニケーション"],
1174
- link: "https://example.com/daily-meeting"
1175
- },
1176
- {
1177
- title: "標準作業票の更新",
1178
- owner: "鈴木",
1179
- notes: "工程C-12の新治具対応版。現場レビュー待ち。",
1180
- status: "waiting",
1181
- due: isoDateOffset(now, 3),
1182
- tags: ["標準化", "改善"],
1183
- link: "https://example.com/standard-work"
1184
- },
1185
- {
1186
- title: "トレーニング計画ドラフト",
1187
- owner: "山本",
1188
- notes: "新入社員向け。ヒアリング結果を反映中。",
1189
- status: "inProgress",
1190
- due: isoDateOffset(now, 5),
1191
- tags: ["人材育成"],
1192
- link: "https://example.com/training-plan"
1193
- },
1194
- {
1195
- title: "品質ロス分析",
1196
- owner: "石井",
1197
- notes: "先週分のデータを取り込み済み。残りは原因記入。",
1198
- status: "ready",
1199
- due: isoDateOffset(now, 2),
1200
- tags: ["品質", "分析"],
1201
- link: "https://example.com/quality-loss"
1202
- },
1203
- {
1204
- title: "安全KY実施",
1205
- owner: "加藤",
1206
- notes: "夜勤チームへ展開済み。記録は箱に保管。",
1207
- status: "done",
1208
- due: isoDateOffset(now, -1),
1209
- tags: ["安全", "教育"],
1210
- link: "https://example.com/safety-ky"
1211
- }
1212
- ];
1213
-
1214
- demoCards.forEach((card, index) => {
1215
- const desiredStatus = card.status;
1216
- const status = canEnterColumn(desiredStatus) ? desiredStatus : "backlog";
1217
- const createdAt = new Date(now.getTime() + index * 1000);
1218
- const entry = {
1219
- id: createId(),
1220
- title: card.title,
1221
- owner: card.owner,
1222
- notes: card.notes,
1223
- status,
1224
- due: card.due,
1225
- createdAt: createdAt.toISOString(),
1226
- tags: parseTags(card.tags?.join(",") ?? ""),
1227
- link: normalizeLink(card.link ?? "")
1228
- };
1229
-
1230
- if (status === "done") {
1231
- entry.completedAt = new Date(createdAt.getTime() + 60 * 60 * 1000).toISOString();
1232
- }
1233
-
1234
- tasks.push(entry);
1235
- });
1236
-
1237
- persist();
1238
- render();
1239
- showMessage("デモデータを追加しました。", "info");
1240
- }
1241
-
1242
- function isoDateOffset(date, offsetDays) {
1243
- const clone = new Date(date);
1244
- clone.setDate(clone.getDate() + offsetDays);
1245
- return clone.toISOString().slice(0, 10);
1246
- }
1247
-
1248
- function formatDue(value) {
1249
- if (!value) return "未設定";
1250
- try {
1251
- const date = new Date(value);
1252
- if (Number.isNaN(date.getTime())) return "未設定";
1253
- return `${date.getFullYear()}年${padZero(date.getMonth() + 1)}月${padZero(date.getDate())}日`;
1254
- } catch (_error) {
1255
- return "未設定";
1256
- }
1257
- }
1258
-
1259
- function padZero(num) {
1260
- return String(num).padStart(2, "0");
1261
- }
1262
-
1263
- function createId() {
1264
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
1265
- return crypto.randomUUID();
1266
- }
1267
- return `task-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
1268
- }
1269
-
1270
- function openEditDialog(taskId) {
1271
- const task = tasks.find((item) => item.id === taskId);
1272
- if (!task) return;
1273
-
1274
- if (!editDialog || !editForm || !editFields) {
1275
- fallbackEdit(task);
1276
- return;
1277
- }
1278
-
1279
- editingTaskId = taskId;
1280
- editFields.title.value = task.title;
1281
- editFields.owner.value = task.owner ?? "";
1282
- editFields.due.value = task.due ?? "";
1283
- editFields.notes.value = task.notes ?? "";
1284
- if (editFields.tags) {
1285
- editFields.tags.value = Array.isArray(task.tags) ? task.tags.join(", ") : "";
1286
- }
1287
- editFields.link.value = task.link ?? "";
1288
-
1289
- try {
1290
- if (typeof editDialog.showModal === "function") {
1291
- editDialog.showModal();
1292
- } else {
1293
- editDialog.setAttribute("open", "true");
1294
- }
1295
- window.setTimeout(() => editFields.title.focus(), 0);
1296
- } catch (error) {
1297
- console.warn("Failed to open dialog, using fallback.", error);
1298
- fallbackEdit(task);
1299
- }
1300
- }
1301
-
1302
- function handleEditSubmit(event) {
1303
- event.preventDefault();
1304
- if (!editFields || !editingTaskId) return;
1305
-
1306
- const title = editFields.title.value.trim();
1307
- if (!title) {
1308
- showMessage("タイトルを入力してください。", "warn");
1309
- editFields.title.focus();
1310
- return;
1311
- }
1312
-
1313
- const updates = {
1314
- title,
1315
- owner: editFields.owner.value.trim(),
1316
- due: editFields.due.value,
1317
- notes: editFields.notes.value.trim(),
1318
- tags: parseTags(editFields.tags ? editFields.tags.value : ""),
1319
- link: normalizeLink(editFields.link?.value ?? "")
1320
- };
1321
-
1322
- applyTaskUpdates(editingTaskId, updates);
1323
- closeEditDialog();
1324
- }
1325
-
1326
- function applyTaskUpdates(taskId, updates) {
1327
- const task = tasks.find((item) => item.id === taskId);
1328
- if (!task) return false;
1329
-
1330
- task.title = updates.title;
1331
- task.owner = updates.owner;
1332
- task.due = updates.due;
1333
- task.notes = updates.notes;
1334
- task.tags = updates.tags;
1335
- task.link = updates.link;
1336
-
1337
- persist();
1338
- render();
1339
- showMessage("タスクを更新しました。", "info");
1340
- return true;
1341
- }
1342
-
1343
- function closeEditDialog(fromEvent = false) {
1344
- if (!editDialog) return;
1345
-
1346
- if (!fromEvent) {
1347
- if (typeof editDialog.close === "function" && editDialog.open) {
1348
- editDialog.close();
1349
- } else {
1350
- editDialog.removeAttribute("open");
1351
- }
1352
- } else {
1353
- editDialog.removeAttribute("open");
1354
- }
1355
-
1356
- resetEditingState();
1357
- }
1358
-
1359
- function resetEditingState() {
1360
- editingTaskId = null;
1361
- editForm?.reset();
1362
- }
1363
-
1364
- function fallbackEdit(task) {
1365
- const title = window.prompt("タイトルを編集", task.title);
1366
- if (title === null) return;
1367
- const trimmedTitle = title.trim();
1368
- if (!trimmedTitle) {
1369
- showMessage("タイトルを入力してください。", "warn");
1370
- return;
1371
- }
1372
-
1373
- const owner = window.prompt("担当を編集", task.owner ?? "") ?? task.owner ?? "";
1374
- const due = window.prompt("期限 (YYYY-MM-DD)", task.due ?? "") ?? task.due ?? "";
1375
- const notes = window.prompt("メモ", task.notes ?? "") ?? task.notes ?? "";
1376
- const tagsInput = window.prompt("タグ(カンマ区切り)", Array.isArray(task.tags) ? task.tags.join(", ") : "") ?? (Array.isArray(task.tags) ? task.tags.join(", ") : "");
1377
- const linkInput = window.prompt("参考リンク(URL)", task.link ?? "") ?? (task.link ?? "");
1378
-
1379
- applyTaskUpdates(task.id, {
1380
- title: trimmedTitle,
1381
- owner: owner.trim(),
1382
- due,
1383
- notes: notes.trim(),
1384
- tags: parseTags(tagsInput),
1385
- link: normalizeLink(linkInput)
1386
- });
1387
- }
1388
-
1389
- function toggleViewMode() {
1390
- uiState.compact = !uiState.compact;
1391
- updateViewToggleLabel();
1392
- render();
1393
- showMessage(uiState.compact ? "コンパクト表示に切り替えました。" : "標準表示に戻しました。", "info");
1394
- }
1395
-
1396
- function updateViewToggleLabel() {
1397
- if (!uiElements.viewToggle) return;
1398
- uiElements.viewToggle.textContent = uiState.compact ? "標準表示" : "コンパクト表示";
1399
- }
1400
-
1401
- function parseISODate(value) {
1402
- if (!value) return null;
1403
- try {
1404
- const date = new Date(value);
1405
- return Number.isNaN(date.getTime()) ? null : new Date(date.getFullYear(), date.getMonth(), date.getDate());
1406
- } catch (_error) {
1407
- return null;
1408
- }
1409
- }
1410
-
1411
- function formatLinkLabel(url) {
1412
- try {
1413
- const parsed = new URL(url);
1414
- const path = parsed.pathname === "/" ? "" : parsed.pathname;
1415
- return `${parsed.hostname}${path}`;
1416
- } catch (_error) {
1417
- return url;
1418
- }
1419
- }
1420
-
1421
- function normalizeLink(input) {
1422
- if (!input) return "";
1423
- let raw0 = String(input).trim();
1424
- if (!raw0) return "";
1425
-
1426
- // ✅ 追加: 先頭・末尾のダブルクォーテーションを削除
1427
- raw0 = raw0.replace(/^"(.*)"$/, "$1");
1428
-
1429
- // 全角の「¥」をバックスラッシュに誤入力しているケースを補正
1430
- const raw = raw0.replace(/¥/g, "\\");
1431
-
1432
- const ensureDirSlash = (u) => {
1433
- try {
1434
- const url = new URL(u);
1435
- if (url.protocol !== "file:") return u;
1436
- if (url.pathname.endsWith("/")) return u;
1437
-
1438
- const last = decodeURI(url.pathname).split("/").pop() || "";
1439
- const looksLikeFile = /\.[A-Za-z0-9]{1,6}$/.test(last);
1440
- return looksLikeFile ? u : (u + "/");
1441
- } catch {
1442
- return u;
1443
- }
1444
- };
1445
-
1446
- // 1) Windows ドライブパス: C:\... → file:///C:/...
1447
- if (/^[a-zA-Z]:\\/.test(raw)) {
1448
- const path = raw.replace(/\\/g, "/");
1449
- return ensureDirSlash(`file:///${encodeURI(path)}`);
1450
- }
1451
-
1452
- // 2) UNC: \\server\share\path → file://server/share/path
1453
- if (/^\\\\[^\\]/.test(raw)) {
1454
- const path = raw.replace(/^\\\\/, "").replace(/\\/g, "/");
1455
- return ensureDirSlash(`file://${encodeURI(path)}`);
1456
- }
1457
-
1458
- // 3) 明示スキームあり(http/https/file)
1459
- const hasScheme = /^[a-zA-Z][\\w+.-]*:/.test(raw);
1460
- if (hasScheme) {
1461
- try {
1462
- const url = new URL(raw);
1463
- if (/^https?:$/i.test(url.protocol)) return url.href;
1464
- if (/^file:$/i.test(url.protocol)) return ensureDirSlash(url.href);
1465
- return "";
1466
- } catch {
1467
- return "";
1468
- }
1469
- }
1470
-
1471
- // 4) スキーム無し → Webドメインらしければ https:// を補完
1472
- try {
1473
- const candidate = `https://${raw}`;
1474
- const url = new URL(candidate);
1475
- return url.href;
1476
- } catch {
1477
- return "";
1478
- }
1479
- }
1480
-
1481
-
1482
- function parseTags(input) {
1483
- if (!input) return [];
1484
- const parts = Array.isArray(input) ? input : String(input).split(/[,、\s]+/);
1485
- const seen = new Set();
1486
- const result = [];
1487
- parts
1488
- .map((item) => String(item).trim())
1489
- .filter(Boolean)
1490
- .forEach((tag) => {
1491
- const cleaned = tag.replace(/^#/, "").trim();
1492
- if (!cleaned) return;
1493
- const key = cleaned.toLowerCase();
1494
- if (seen.has(key)) return;
1495
- seen.add(key);
1496
- result.push(cleaned);
1497
- });
1498
- return result;
1499
- }
1500
- })();
1501
-
1502
-
1503
-
1504
-
1505
-
1506
-
1507
-
1508
-
1509
-
1510
-
1511
-
1512
-
1513
-
1514
-
1515
-
1516
-
1517
-
1518
-
1519
-
1520
-
1521
-
1522
-
1523
-
1524
-
1525
-
1526
-
1527
-
1528
-
1529
-
1530
-
1531
-
1532
-
1533
-
1534
-
1535
-
1536
-
1537
-
1538
-
1539
-
1540
-
1541
-
1542
-
1543
-
1544
-
1545
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
v6_軽量KanbanBoard/index.html DELETED
@@ -1,509 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="ja">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>トヨタ式カンバンボード</title>
7
- <!-- ✅ ローカルのTailwind CDNを先に読み込む -->
8
- <script src="./tailwindcdn.js"></script>
9
-
10
- <style>
11
- :root {
12
- color-scheme: dark;
13
- }
14
-
15
- .kanban-column-body {
16
- max-height: calc(100vh - 320px);
17
- overflow-y: auto;
18
- }
19
-
20
- @media (min-width: 768px) {
21
- .kanban-column-body {
22
- max-height: calc(100vh - 280px);
23
- }
24
- }
25
-
26
- @media (min-width: 1280px) {
27
- .kanban-column-body {
28
- max-height: calc(100vh - 260px);
29
- }
30
- }
31
-
32
- .kanban-scrollbar {
33
- scrollbar-width: thin;
34
- scrollbar-color: #334155 transparent;
35
- }
36
-
37
- .kanban-scrollbar::-webkit-scrollbar {
38
- width: 8px;
39
- }
40
-
41
- .kanban-scrollbar::-webkit-scrollbar-track {
42
- background: transparent;
43
- }
44
-
45
- .kanban-scrollbar::-webkit-scrollbar-thumb {
46
- background-color: #334155;
47
- border-radius: 9999px;
48
- }
49
-
50
- .kanban-note-clamped {
51
- display: -webkit-box;
52
- -webkit-line-clamp: 3;
53
- -webkit-box-orient: vertical;
54
- overflow: hidden;
55
- }
56
-
57
- [data-panel-body][hidden] {
58
- display: none;
59
- }
60
-
61
- [data-panel].panel-collapsed {
62
- border-color: rgba(148, 163, 184, 0.25);
63
- }
64
-
65
- .panel-toggle {
66
- display: inline-flex;
67
- align-items: center;
68
- gap: 0.5rem;
69
- border-radius: 0.5rem;
70
- border: 1px solid rgba(100, 116, 139, 0.6);
71
- padding: 0.35rem 0.75rem;
72
- font-size: 0.75rem;
73
- font-weight: 600;
74
- color: rgba(226, 232, 240, 0.9);
75
- background-color: transparent;
76
- transition: border-color 0.2s ease, color 0.2s ease;
77
- }
78
-
79
- .panel-toggle:hover {
80
- border-color: rgba(226, 232, 240, 0.85);
81
- color: #f8fafc;
82
- }
83
-
84
- .panel-toggle:focus-visible {
85
- outline: 2px solid rgba(56, 189, 248, 0.6);
86
- outline-offset: 2px;
87
- }
88
-
89
- .kanban-card {
90
- display: flex;
91
- flex-direction: column;
92
- gap: 0.75rem;
93
- transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
94
- }
95
-
96
- .kanban-card:hover {
97
- transform: translateY(-2px);
98
- }
99
-
100
- .kanban-card-actions {
101
- transition: opacity 0.2s ease;
102
- }
103
-
104
- .kanban-card-actions:focus-within {
105
- opacity: 1;
106
- pointer-events: auto;
107
- }
108
-
109
- .kanban-card-title {
110
- display: block;
111
- overflow-wrap: anywhere;
112
- word-break: break-word;
113
- line-height: 1.4;
114
- }
115
-
116
- .kanban-card-compact {
117
- padding-top: 1rem;
118
- padding-bottom: 1rem;
119
- }
120
-
121
- .kanban-card-compact .kanban-card-title {
122
- font-size: 0.95rem;
123
- display: -webkit-box;
124
- -webkit-line-clamp: 2;
125
- -webkit-box-orient: vertical;
126
- overflow: hidden;
127
- }
128
-
129
- .kanban-card-compact .kanban-card-actions {
130
- opacity: 0;
131
- pointer-events: none;
132
- }
133
-
134
- .kanban-card-notes {
135
- overflow-wrap: anywhere;
136
- line-height: 1.5;
137
- }
138
-
139
- .kanban-card-compact.kanban-card-expanded .kanban-card-actions,
140
- .kanban-card:hover .kanban-card-actions {
141
- opacity: 1;
142
- pointer-events: auto;
143
- }
144
-
145
- .kanban-card-selected {
146
- border-color: rgba(56, 189, 248, 0.7) !important;
147
- box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.35);
148
- }
149
-
150
- .kanban-card-main {
151
- flex: 1 1 260px;
152
- min-width: 220px;
153
- }
154
-
155
- .kanban-card-aside {
156
- flex: 0 0 180px;
157
- min-width: 160px;
158
- align-items: flex-end;
159
- text-align: right;
160
- gap: 0.5rem;
161
- }
162
-
163
- @media (max-width: 768px) {
164
- .kanban-card-aside {
165
- flex: 1 1 200px;
166
- align-items: flex-start;
167
- text-align: left;
168
- }
169
- }
170
-
171
- .kanban-card-meta {
172
- display: flex;
173
- flex-wrap: wrap;
174
- gap: 0.75rem;
175
- }
176
-
177
- .kanban-card-meta-item {
178
- flex: 1 1 200px;
179
- min-width: 180px;
180
- }
181
- </style>
182
- </head>
183
- <body class="min-h-screen bg-slate-950 text-slate-100">
184
- <div class="mx-auto flex max-w-7xl flex-col gap-10 px-6 py-10">
185
- <header class="space-y-4">
186
- <div class="flex flex-wrap items-center justify-between gap-3">
187
- <span class="inline-flex items-center gap-2 rounded-full bg-slate-800/70 px-4 py-1 text-xs uppercase tracking-wide text-slate-300">Made by T</span>
188
- <button id="title-edit" type="button" class="rounded-lg border border-slate-600 px-3 py-1 text-xs font-semibold text-slate-200 transition hover:border-slate-400 hover:text-white">タイトル編集</button>
189
- </div>
190
- <h1 id="board-title-heading" class="text-3xl font-bold text-white md:text-4xl">トヨタ式カンバンボード</h1>
191
- <p class="max-w-2xl text-sm text-slate-300 md:text-base">
192
- 現場の「見���る化」を支援する軽量なカンバンボード。<br>WIP制限を守りつつ、タスクの流れとボトルネックを素早く把握!
193
- </p>
194
- </header>
195
-
196
- <section aria-labelledby="analytics-title" class="rounded-2xl bg-slate-800/70 p-6 shadow-lg shadow-black/20" data-panel="analytics">
197
- <div class="flex flex-wrap items-center justify-between gap-4">
198
- <div>
199
- <h2 id="analytics-title" class="text-lg font-semibold text-white">改善指標</h2>
200
- <p class="text-sm text-slate-400">カードの滞留や流量を把握し、改善サイクルを素早く回すためのヒントとなる指標!</p>
201
- </div>
202
- <button type="button" class="panel-toggle" data-action="toggle-panel" data-target="analytics" aria-expanded="true">折りたたむ</button>
203
- </div>
204
- <div class="mt-6" data-panel-body="analytics">
205
- <div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
206
- <article class="rounded-xl border border-slate-700/60 bg-slate-900/40 p-4 shadow-inner shadow-black/10">
207
- <span class="text-xs uppercase tracking-wide text-slate-400">総カード数</span>
208
- <p class="mt-2 text-3xl font-semibold text-white" data-metric="total">0枚</p>
209
- <p class="mt-2 text-xs text-slate-400">登録済みカードの全体数。</p>
210
- </article>
211
- <article class="rounded-xl border border-slate-700/60 bg-slate-900/40 p-4 shadow-inner shadow-black/10">
212
- <span class="text-xs uppercase tracking-wide text-slate-400">仕掛り中(準備中・仕掛中・停止中)</span>
213
- <p class="mt-2 text-3xl font-semibold text-white" data-metric="active">0枚</p>
214
- <p class="mt-2 text-xs text-slate-400">ボトルネック発見のために観察したい枚数。</p>
215
- </article>
216
- <article class="rounded-xl border border-slate-700/60 bg-slate-900/40 p-4 shadow-inner shadow-black/10">
217
- <span class="text-xs uppercase tracking-wide text-slate-400">直近7日間の完了</span>
218
- <p class="mt-2 text-3xl font-semibold text-white" data-metric="throughput7">0枚</p>
219
- <p class="mt-2 text-xs text-slate-400">完了ペース(スループット)の目安。</p>
220
- </article>
221
- <article class="rounded-xl border border-slate-700/60 bg-slate-900/40 p-4 shadow-inner shadow-black/10">
222
- <span class="text-xs uppercase tracking-wide text-slate-400">平均リードタイム</span>
223
- <p class="mt-2 text-3xl font-semibold text-white" data-metric="leadtime">--</p>
224
- <p class="mt-2 text-xs text-slate-400">完了までの日数。平準化の指標として活用可能。</p>
225
- </article>
226
- </div>
227
- </div>
228
- </section>
229
-
230
- <section aria-labelledby="filter-title" class="rounded-2xl bg-slate-800/70 p-6 shadow-lg shadow-black/20" data-panel="filters">
231
- <div class="flex flex-wrap items-center justify-between gap-4">
232
- <h2 id="filter-title" class="text-lg font-semibold text-white">フィルタと検索</h2>
233
- <button type="button" class="panel-toggle" data-action="toggle-panel" data-target="filters" aria-expanded="true">折りたたむ</button>
234
- </div>
235
- <div class="mt-6 space-y-6" data-panel-body="filters">
236
- <div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
237
- <label class="space-y-2 text-sm">
238
- <span class="text-slate-200">キーワード検索</span>
239
- <input id="filter-keyword" type="search" placeholder="例: 標準作業票" class="w-full rounded-lg border border-transparent bg-slate-800/80 px-3 py-2 text-slate-100 placeholder:text-slate-500 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-400/40" />
240
- </label>
241
- <label class="space-y-2 text-sm">
242
- <span class="text-slate-200">期限(開始)</span>
243
- <input id="filter-due-start" type="date" class="w-full rounded-lg border border-transparent bg-slate-800/80 px-3 py-2 text-slate-100 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-400/40" />
244
- </label>
245
- <label class="space-y-2 text-sm">
246
- <span class="text-slate-200">期限(終了)</span>
247
- <input id="filter-due-end" type="date" class="w-full rounded-lg border border-transparent bg-slate-800/80 px-3 py-2 text-slate-100 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-400/40" />
248
- </label>
249
- </div>
250
- <div class="flex flex-wrap gap-2">
251
- <button id="filter-clear" type="button" class="rounded-lg border border-slate-600 px-4 py-2 text-sm font-medium text-slate-200 transition hover:border-slate-400 hover:text-white">
252
- 条件クリア
253
- </button>
254
- </div>
255
- <div class="space-y-3">
256
- <div class="flex items-center justify-between">
257
- <span class="text-sm font-semibold text-white">タグで絞り込み</span>
258
- </div>
259
- <div id="filter-tag-list" class="flex flex-wrap gap-2"></div>
260
- <p class="text-xs text-slate-500">タグをクリックしてON/OFFを切り替え。複数選択でAND条件。</p>
261
- </div>
262
- </div>
263
- </section>
264
-
265
- <section aria-labelledby="form-title" class="rounded-2xl bg-slate-800/70 p-6 shadow-lg shadow-black/20" data-panel="form">
266
- <div class="flex flex-wrap items-center justify-between gap-4">
267
- <div>
268
- <h2 id="form-title" class="text-lg font-semibold text-white">タスク登録</h2>
269
- <p class="text-sm text-slate-400">ポイントは小さく、進捗はこまめに。日々の改善に役立てよう!</p>
270
- </div>
271
- <div class="flex flex-wrap gap-2">
272
- <button type="button" class="panel-toggle" data-action="toggle-panel" data-target="form" aria-expanded="true">折りたたむ</button>
273
- </div>
274
- </div>
275
- <div class="mt-6" data-panel-body="form">
276
- <form id="task-form" class="grid gap-4 md:grid-cols-12 md:items-end">
277
- <label class="space-y-2 text-sm md:col-span-4">
278
- <span class="text-slate-200">タイトル</span>
279
- <input id="task-title" name="title" type="text" required placeholder="例: 毎朝の現場ミーティング準備" class="w-full rounded-lg border border-transparent bg-slate-800/80 px-3 py-2 text-slate-100 placeholder:text-slate-500 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-400/40" />
280
- </label>
281
- <label class="space-y-2 text-sm md:col-span-3">
282
- <span class="text-slate-200">担当</span>
283
- <input id="task-owner" name="owner" type="text" placeholder="例: 佐藤" class="w-full rounded-lg border border-transparent bg-slate-800/80 px-3 py-2 text-slate-100 placeholder:text-slate-500 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-400/40" />
284
- </label>
285
- <label class="space-y-2 text-sm md:col-span-3">
286
- <span class="text-slate-200">期限</span>
287
- <input id="task-due" name="due" type="date" class="w-full rounded-lg border border-transparent bg-slate-800/80 px-3 py-2 text-slate-100 placeholder:text-slate-500 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-400/40" />
288
- </label>
289
- <label class="space-y-2 text-sm md:col-span-2">
290
- <span class="text-slate-200">工程</span>
291
- <select id="task-status" name="status" class="w-full rounded-lg border border-transparent bg-slate-800/80 px-3 py-2 text-slate-100 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-400/40">
292
- <option value="backlog">バックログ</option>
293
- <option value="ready">準備中</option>
294
- <option value="inProgress">仕掛中</option>
295
- <option value="waiting">停止中</option>
296
- <option value="done">完了</option>
297
- </select>
298
- </label>
299
- <label class="space-y-2 text-sm md:col-span-4 md:col-start-1 md:row-start-2">
300
- <span class="text-slate-200">タグ(カンマ区切り)</span>
301
- <input id="task-tags" name="tags" type="text" placeholder="例: 改善,品質" class="w-full rounded-lg border border-transparent bg-slate-800/80 px-3 py-2 text-slate-100 placeholder:text-slate-500 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-400/40" />
302
- </label>
303
- <label class="space-y-2 text-sm md:col-span-4 md:row-start-2">
304
- <span class="text-slate-200">参考リンク(URL)</span>
305
- <input id="task-link" name="link" type="text" placeholder="例: https://example.com" class="w-full rounded-lg border border-transparent bg-slate-800/80 px-3 py-2 text-slate-100 placeholder:text-slate-500 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-400/40" />
306
- </label>
307
- <label class="space-y-2 text-sm md:col-span-12 md:row-start-3">
308
- <span class="text-slate-200">メモ</span>
309
- <textarea id="task-notes" name="notes" rows="2" placeholder="作業手順や注意点などを記入" class="w-full rounded-lg border border-transparent bg-slate-800/80 px-3 py-2 text-slate-100 placeholder:text-slate-500 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-400/40"></textarea>
310
- </label>
311
- <div class="md:col-span-4 md:col-start-9 md:row-start-4 md:flex md:justify-end md:space-x-3">
312
- <button type="reset" class="mt-2 w-full rounded-lg border border-slate-600 px-4 py-2 text-sm font-medium text-slate-200 transition hover:border-slate-400 hover:text-white md:mt-0 md:w-auto">
313
- クリア
314
- </button>
315
- <button type="submit" class="mt-2 w-full rounded-lg bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-900 transition hover:bg-sky-300 md:mt-0 md:w-auto">
316
- 登録
317
- </button>
318
- </div>
319
- </form>
320
- <p id="message-area" class="mt-4 text-sm font-medium text-orange-500 opacity-0 transition-opacity duration-300" aria-live="assertive"></p>
321
- </div>
322
- </section>
323
-
324
- <section aria-labelledby="board-title" class="space-y-4">
325
- <div class="flex flex-wrap items-center justify-between gap-3">
326
- <div>
327
- <h2 id="board-title" class="text-lg font-semibold text-white">フローの見える化</h2>
328
- <p class="text-sm text-slate-400">カードはドラッグ&ドロップで工程移動が可能。仕掛り中の制限数を超えないように管理。</p>
329
- </div>
330
- <div class="flex gap-2 text-xs text-slate-300">
331
- <span class="rounded-full bg-slate-800/60 px-3 py-1">準備中 WIP 5</span>
332
- <span class="rounded-full bg-slate-800/60 px-3 py-1">仕掛中 WIP 3</span>
333
- <span class="rounded-full bg-slate-800/60 px-3 py-1">停止中 WIP 2</span>
334
- </div>
335
- </div>
336
-
337
- <div class="flex flex-wrap items-center justify-between gap-4 rounded-2xl bg-slate-800/70 px-4 py-3 text-sm text-slate-300">
338
- <div class="flex flex-wrap items-center gap-3">
339
- <span id="selection-counter">選択中: 0枚</span>
340
- <span class="text-xs text-slate-500" id="selection-hint">カード左端のチェックで選択、ツールで一括操作可能。</span>
341
- </div>
342
- <div class="flex flex-wrap gap-2">
343
- <button id="view-toggle" type="button" class="rounded-lg border border-slate-600 px-3 py-1 text-xs font-medium text-slate-200 transition hover:border-slate-400 hover:text-white">
344
- コンパクト表示
345
- </button>
346
- <button id="selection-selectall" type="button" class="rounded-lg border border-slate-600 px-3 py-1 text-xs font-medium text-slate-200 transition hover:border-slate-400 hover:text-white">
347
- 全選択
348
- </button>
349
- <button id="selection-clear" type="button" class="rounded-lg border border-slate-600 px-3 py-1 text-xs font-medium text-slate-200 transition hover:border-slate-400 hover:text-white" disabled>
350
- 選択解除
351
- </button>
352
- <button id="selection-delete" type="button" class="rounded-lg bg-rose-500/80 px-3 py-1 text-xs font-semibold text-rose-50 transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:bg-rose-900/40" disabled>
353
- 選択削除
354
- </button>
355
-
356
- <button id="data-export" type="button" class="rounded-lg border border-slate-600 px-3 py-1 text-xs font-medium text-slate-200 transition hover:border-slate-400 hover:text-white">
357
- エクスポート
358
- </button>
359
- <button id="data-import" type="button" class="rounded-lg border border-slate-600 px-3 py-1 text-xs font-medium text-slate-200 transition hover:border-slate-400 hover:text-white">
360
- インポート
361
- </button>
362
- <input id="data-import-input" type="file" accept="application/json" class="hidden" />
363
- </div>
364
- </div>
365
-
366
- <div class="grid gap-6 md:grid-cols-2 xl:grid-cols-5">
367
- <article class="flex min-h-[240px] flex-col rounded-2xl bg-slate-800/70 p-4 shadow-lg shadow-black/10" data-column="backlog">
368
- <header class="mb-3 flex items-center justify-between text-sm text-slate-300">
369
- <h3 class="text-base font-semibold text-white">バックログ</h3>
370
- <span class="rounded-full bg-slate-800/70 px-2 py-0.5 text-xs font-medium" data-counter="backlog">0</span>
371
- </header>
372
- <div class="kanban-column-body kanban-scrollbar flex flex-1 flex-col gap-3" data-dropzone="backlog"></div>
373
- </article>
374
-
375
- <article class="flex min-h-[240px] flex-col rounded-2xl bg-slate-800/70 p-4 shadow-lg shadow-black/10" data-column="ready" data-wip="5">
376
- <header class="mb-3 flex items-center justify-between text-sm text-slate-300">
377
- <h3 class="text-base font-semibold text-white">準備中</h3>
378
- <span class="rounded-full bg-slate-800/70 px-2 py-0.5 text-xs font-medium" data-counter="ready">0</span>
379
- </header>
380
- <div class="kanban-column-body kanban-scrollbar flex flex-1 flex-col gap-3" data-dropzone="ready"></div>
381
- </article>
382
-
383
- <article class="flex min-h-[240px] flex-col rounded-2xl bg-slate-800/70 p-4 shadow-lg shadow-black/10" data-column="inProgress" data-wip="3">
384
- <header class="mb-3 flex items-center justify-between text-sm text-slate-300">
385
- <h3 class="text-base font-semibold text-white">仕掛中</h3>
386
- <span class="rounded-full bg-slate-800/70 px-2 py-0.5 text-xs font-medium" data-counter="inProgress">0</span>
387
- </header>
388
- <div class="kanban-column-body kanban-scrollbar flex flex-1 flex-col gap-3" data-dropzone="inProgress"></div>
389
- </article>
390
-
391
- <article class="flex min-h-[240px] flex-col rounded-2xl bg-slate-800/70 p-4 shadow-lg shadow-black/10" data-column="waiting" data-wip="2">
392
- <header class="mb-3 flex items-center justify-between text-sm text-slate-300">
393
- <h3 class="text-base font-semibold text-white">停止中</h3>
394
- <span class="rounded-full bg-slate-800/70 px-2 py-0.5 text-xs font-medium" data-counter="waiting">0</span>
395
- </header>
396
- <div class="kanban-column-body kanban-scrollbar flex flex-1 flex-col gap-3" data-dropzone="waiting"></div>
397
- </article>
398
-
399
- <article class="flex min-h-[240px] flex-col rounded-2xl bg-slate-800/70 p-4 shadow-lg shadow-black/10" data-column="done">
400
- <header class="mb-3 flex items-center justify-between text-sm text-slate-300">
401
- <h3 class="text-base font-semibold text-white">完了</h3>
402
- <span class="rounded-full bg-slate-800/70 px-2 py-0.5 text-xs font-medium" data-counter="done">0</span>
403
- </header>
404
- <div class="kanban-column-body kanban-scrollbar flex flex-1 flex-col gap-3" data-dropzone="done"></div>
405
- </article>
406
- </div>
407
- </section>
408
- </div>
409
-
410
- <template id="task-template">
411
- <div class="kanban-card group rounded-xl border border-slate-700 bg-slate-800/80 p-4 shadow-sm shadow-black/20 transition hover:border-sky-400/60" draggable="true" data-task-id="">
412
- <div class="kanban-card-header flex flex-wrap items-start justify-between gap-3">
413
- <div class="kanban-card-main flex min-w-0 flex-1 flex-col gap-2">
414
- <div class="flex flex-wrap items-start gap-3">
415
- <label class="mt-1 inline-flex shrink-0 items-center">
416
- <input type="checkbox" data-action="select-task" class="h-4 w-4 rounded border-slate-600 bg-slate-900 text-sky-400 focus:ring-2 focus:ring-sky-400/40" />
417
- <span class="sr-only">カードを選択</span>
418
- </label>
419
- <div class="flex-1 min-w-0 space-y-1">
420
- <h3 data-field="title" class="kanban-card-title text-base font-semibold text-white leading-tight"></h3>
421
- <div class="hidden flex flex-wrap gap-2 text-xs text-slate-300" data-section="tags" aria-hidden="true">
422
- <span class="sr-only">タグ</span>
423
- <div data-field="tags" class="flex flex-wrap gap-2"></div>
424
- </div>
425
- </div>
426
- </div>
427
- </div>
428
- <div class="kanban-card-aside flex flex-col items-end gap-2 text-xs text-slate-300">
429
- <div class="hidden flex flex-wrap items-center gap-2" data-section="link">
430
- <svg aria-hidden="true" class="h-3.5 w-3.5 text-sky-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
431
- <path d="M9.91 13.91a3 3 0 0 1 0-4.24l2.83-2.83a3 3 0 0 1 4.24 4.24l-.7.7"/>
432
- <path d="M14.09 10.09a3 3 0 0 1 0 4.24l-2.83 2.83a3 3 0 0 1-4.24-4.24l.7-.7"/>
433
- </svg>
434
- <a data-field="link" class="block break-words text-sky-300 underline-offset-2 hover:underline" href="#" target="_blank" rel="noreferrer noopener">リンクを開く</a>
435
- </div>
436
- </div>
437
- </div>
438
- <div class="kanban-card-actions mt-3 flex flex-wrap justify-end gap-2">
439
- <button class="rounded-md border border-slate-500 px-2 py-1 text-xs font-semibold text-slate-200 transition hover:border-slate-300 hover:text-white" type="button" data-action="toggle-details" aria-expanded="false">
440
- 詳細
441
- </button>
442
- <button class="hidden rounded-md bg-sky-400 px-2 py-1 text-xs font-semibold text-slate-900 transition hover:bg-sky-300 group-hover:inline-flex" type="button" data-action="mark-done">
443
- 完了へ
444
- </button>
445
- <button class="hidden rounded-md border border-slate-500 px-2 py-1 text-xs font-semibold text-slate-200 transition hover:border-slate-300 hover:text-white group-hover:inline-flex" type="button" data-action="edit-task">
446
- 編集
447
- </button>
448
- <button class="hidden rounded-md border border-rose-600 px-2 py-1 text-xs font-semibold text-rose-200 transition hover:border-rose-400 hover:text-rose-50 group-hover:inline-flex" type="button" data-action="delete-task">
449
- 削除
450
- </button>
451
- </div>
452
- <dl class="kanban-card-meta mt-3 text-xs text-slate-300" data-section="meta">
453
- <div class="kanban-card-meta-item space-y-1">
454
- <dt class="uppercase tracking-wide text-slate-500">担当</dt>
455
- <dd class="font-medium text-white" data-field="owner"></dd>
456
- </div>
457
- <div class="kanban-card-meta-item space-y-1">
458
- <dt class="uppercase tracking-wide text-slate-500">期限</dt>
459
- <dd class="font-medium text-white" data-field="due"></dd>
460
- </div>
461
- </dl>
462
- <p class="kanban-card-notes mt-3 whitespace-pre-line text-sm text-slate-200" data-section="notes" data-field="notes"></p>
463
- </div>
464
- </template>
465
-
466
- <dialog id="edit-dialog" class="w-full max-w-xl rounded-2xl border border-slate-600/60 bg-slate-900/95 p-0 text-slate-100 backdrop:bg-slate-900/60">
467
- <form id="edit-form" class="grid gap-4 p-6" method="dialog">
468
- <header class="space-y-1">
469
- <h2 class="text-lg font-semibold text-white">タスクの編集</h2>
470
- <p class="text-sm text-slate-400">内容の更新や振り返りを反映できます。</p>
471
- </header>
472
- <label class="space-y-2 text-sm">
473
- <span class="text-slate-200">タイトル</span>
474
- <input id="edit-title" name="title" type="text" required class="w-full rounded-lg border border-transparent bg-slate-800/80 px-3 py-2 text-slate-100 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-400/40" />
475
- </label>
476
- <div class="grid gap-4 md:grid-cols-2">
477
- <label class="space-y-2 text-sm">
478
- <span class="text-slate-200">担当</span>
479
- <input id="edit-owner" name="owner" type="text" class="w-full rounded-lg border border-transparent bg-slate-800/80 px-3 py-2 text-slate-100 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-400/40" />
480
- </label>
481
- <label class="space-y-2 text-sm">
482
- <span class="text-slate-200">期限</span>
483
- <input id="edit-due" name="due" type="date" class="w-full rounded-lg border border-transparent bg-slate-800/80 px-3 py-2 text-slate-100 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-400/40" />
484
- </label>
485
- </div>
486
- <label class="space-y-2 text-sm">
487
- <span class="text-slate-200">メモ</span>
488
- <textarea id="edit-notes" name="notes" rows="3" class="w-full rounded-lg border border-transparent bg-slate-800/80 px-3 py-2 text-slate-100 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-400/40"></textarea>
489
- </label>
490
-
491
- <label class="space-y-2 text-sm">
492
- <span class="text-slate-200">タグ(カンマ区切り)</span>
493
- <input id="edit-tags" name="tags" type="text" placeholder="例: デザイン, レビュー" class="w-full rounded-lg border border-transparent bg-slate-800/80 px-3 py-2 text-slate-100 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-400/40" />
494
- </label>
495
-
496
- <label class="space-y-2 text-sm">
497
- <span class="text-slate-200">参考リンク(URL)</span>
498
- <input id="edit-link" name="link" type="text" class="w-full rounded-lg border border-transparent bg-slate-800/80 px-3 py-2 text-slate-100 focus:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-400/40" />
499
- </label>
500
- <div class="flex justify-end gap-3 pt-2">
501
- <button type="button" data-action="cancel-edit" class="rounded-lg border border-slate-600 px-4 py-2 text-sm font-medium text-slate-200 transition hover:border-slate-400 hover:text-white">キャンセル</button>
502
- <button type="submit" class="rounded-lg bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-900 transition hover:bg-sky-300">保存</button>
503
- </div>
504
- </form>
505
- </dialog>
506
-
507
- <script src="./app.js"></script>
508
- </body>
509
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
v6_軽量KanbanBoard/tailwindcdn.js DELETED
The diff for this file is too large to render. See raw diff