Okoge-keys commited on
Commit
f3c968c
·
verified ·
1 Parent(s): 0fee24a

Upload 3 files

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