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

Upload 3 files

Browse files
v7_軽量kanbanBoard_impexp機能追加/app.js ADDED
@@ -0,0 +1,1545 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
v7_軽量kanbanBoard_impexp機能追加/index.html ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>
v7_軽量kanbanBoard_impexp機能追加/tailwindcdn.js ADDED
The diff for this file is too large to render. See raw diff