From e75b3ead106c9014bf639dd291e7fd2dc6e27030 Mon Sep 17 00:00:00 2001 From: yoshikazu Date: Tue, 13 Oct 2026 03:18:40 +0900 Subject: [PATCH] =?UTF-8?q?n8n=20=E9=80=A3=E6=90=BA:=20=E4=BD=9C=E6=A5=AD?= =?UTF-8?q?=E5=8C=BA=E9=96=93=E3=82=92=E9=81=B8=E3=82=93=E3=81=A7=E9=80=81?= =?UTF-8?q?=E3=82=8B=E3=80=8Cn8n=20=E3=81=B8=E9=80=81=E3=82=8B=E3=80=8D?= =?UTF-8?q?=E3=82=BF=E3=83=96=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/app.js | 11 +- web/index.html | 2 + web/n8n.js | 277 +++++++++++++++++++++++++++++++++++++++++++++++++ web/style.css | 24 +++++ 4 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 web/n8n.js diff --git a/web/app.js b/web/app.js index 71c9ba8..d74773c 100644 --- a/web/app.js +++ b/web/app.js @@ -144,6 +144,7 @@ async function selectProject(id) { $('#session-table tbody').innerHTML = '解析中…'; if (state.view === 'daily') loadDaily(); else if (state.view === 'gantt') loadGantt(); + else if (state.view === 'n8n') loadN8n(); await reloadSessions(); } @@ -317,6 +318,7 @@ function syncTabs() { $('#tab-turns').classList.toggle('sel', state.view === 'turns'); $('#tab-daily').classList.toggle('sel', state.view === 'daily'); $('#tab-gantt').classList.toggle('sel', state.view === 'gantt'); + $('#tab-n8n').classList.toggle('sel', state.view === 'n8n'); } function ganttQuery(extra) { @@ -343,6 +345,10 @@ function dailyQuery(extra) { function updateCsv() { const link = $('#csv-turns'); + if (state.view === 'n8n') { + link.removeAttribute('href'); + return; + } if (state.view === 'daily') { link.href = '/api/daily?' + dailyQuery({ format: 'csv' }); link.title = '日別集計を CSV 出力'; @@ -363,6 +369,7 @@ function setView(view) { updateCsv(); if (view === 'daily') loadDaily(); else if (view === 'gantt') loadGantt(); + else if (view === 'n8n') loadN8n(); else renderDetail(); } @@ -1021,6 +1028,7 @@ async function refreshFollow() { await reloadSessions(); if (state.view === 'daily') await loadDaily({ silent: true }); else if (state.view === 'gantt') await loadGantt({ silent: true }); + else if (state.view === 'n8n') { /* 編集中の入力を消さないよう、自動追従では読み直さない */ } else await reloadDetail(); list.scrollTop = listTop; // やりとりを末尾まで見ていたら、追記された行に追従する (tail -f と同じ感覚) @@ -1472,6 +1480,7 @@ $('#relative').onchange = (e) => { $('#tab-turns').onclick = () => setView('turns'); $('#tab-daily').onclick = () => setView('daily'); $('#tab-gantt').onclick = () => setView('gantt'); +$('#tab-n8n').onclick = () => setView('n8n'); $('#redmine').onclick = openRedmine; $('#rm-close').onclick = closeRedmine; $('#rm-run').onclick = runRedmine; @@ -1490,7 +1499,7 @@ $('#reload').onclick = async () => { let resizeTimer = null; window.addEventListener('resize', () => { - if (state.view === 'turns') return; + if (state.view === 'turns' || state.view === 'n8n') return; clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { if (state.view === 'daily' && state.daily) renderDaily(); diff --git a/web/index.html b/web/index.html index da6b98e..421f78f 100644 --- a/web/index.html +++ b/web/index.html @@ -57,6 +57,7 @@ + CSV @@ -67,5 +68,6 @@ + diff --git a/web/n8n.js b/web/n8n.js new file mode 100644 index 0000000..6bb5036 --- /dev/null +++ b/web/n8n.js @@ -0,0 +1,277 @@ +'use strict'; +// 「n8n へ送る」タブ: Claude のセッションから作った作業区間を選び、n8n の work_log に送る。 +// app.js の後に読み込む ($ / state / api / post / esc / fmtTime / shortPath を使う)。 + +const N8N_NAMES_KEY = 'chktime.n8n.names'; // セッション ID -> {task, memo} (ブラウザに覚える) + +const n8 = { + cfg: null, // /api/n8n/config (合言葉は含まれない) + from: '', + to: '', + data: null, // /api/n8n/blocks + checked: new Set(), // 送る区間の source + edits: new Map(), // source -> {task, memo} + sameSession: true, // 同じセッションの他の区間にも入れる + settings: false, + busy: false, + result: null, // 直前の送信結果 + pick: null, // チケットを選んでいる区間の source + issues: [], + pickMsg: '', +}; + +function n8LoadNames() { + try { + return JSON.parse(localStorage.getItem(N8N_NAMES_KEY) || '{}') || {}; + } catch (e) { + return {}; + } +} + +function n8SaveName(sessionId, value) { + try { + const all = n8LoadNames(); + all[sessionId] = value; + localStorage.setItem(N8N_NAMES_KEY, JSON.stringify(all)); + } catch (e) { + /* 保存できなくても動く */ + } +} + +const n8CanSend = (b) => !b.sent && !b.inProgress; +const n8DefaultOn = (b) => n8CanSend(b) && !b.deleted && !(b.overlaps || []).length; + +function n8Query() { + const q = new URLSearchParams(); + if (state.project) q.set('project', state.project); + if (n8.from) q.set('from', n8.from); + if (n8.to) q.set('to', n8.to); + return q.toString(); +} + +async function loadN8n({ silent = false } = {}) { + $('#detail-title').textContent = 'n8n へ送る'; + if (!silent) $('#detail-body').innerHTML = '

区間を計算中…

'; + try { + n8.cfg = await api('/api/n8n/config'); + if (!n8.cfg.url || !n8.cfg.hasToken) n8.settings = true; + n8.data = await api('/api/n8n/blocks?' + n8Query()); + n8.from = n8.data.from; + n8.to = n8.data.to; + const names = n8LoadNames(); + n8.checked = new Set(n8.data.blocks.filter(n8DefaultOn).map((b) => b.source)); + for (const b of n8.data.blocks) { + if (n8.edits.has(b.source)) continue; + const saved = names[b.sessionId] || {}; + n8.edits.set(b.source, { task: saved.task || b.taskName, memo: saved.memo || '' }); + } + renderN8n(); + } catch (e) { + $('#detail-body').innerHTML = `

${esc(e.message)}

`; + } +} + +function n8Status(b) { + const marks = []; + if (b.sent) marks.push('送信済み'); + if (b.deleted) marks.push('削除済み'); + if (b.inProgress) marks.push('進行中'); + if ((b.overlaps || []).length) { + const tip = b.overlaps.map((o) => `${o.kind === 'row' ? 'work_log' : '区間'}: ${fmtTime(o.start)}–${fmtTime(o.end)} ${o.task}`).join('\n'); + marks.push(`重なり ${b.overlaps.length}`); + } + return marks.join(' '); +} + +function n8Settings() { + const c = n8.cfg || {}; + return `
+

接続設定 ${esc(c.configPath || '')}

+ + + + + + +
`; +} + +function n8ResultHtml() { + const r = n8.result; + if (!r) return ''; + const errs = (r.errors || []).map((e) => `
  • ${esc(e.source)}: ${esc(e.error)}
  • `).join(''); + const skips = (r.skipped || []).map((s) => `
  • ${esc(s.source)}: ${esc(s.reason)}
  • `).join(''); + return `
    + 追加 ${(r.inserted || []).length} 件 / スキップ ${(r.skipped || []).length} 件 / エラー ${(r.errors || []).length} 件 + ${skips ? `
      ${skips}
    ` : ''}${errs ? `
      ${errs}
    ` : ''} +
    `; +} + +function n8PickHtml() { + if (!n8.pick) return ''; + const rows = n8.issues + .map((i) => `
  • [${esc(i.project)}] ${esc(i.subject)}
  • `) + .join(''); + return `
    +

    Redmine のチケットを選ぶ

    + + ${esc(n8.pickMsg)} +
      ${rows}
    +
    `; +} + +function renderN8n() { + const d = n8.data; + if (!d) return; + const blocks = d.blocks; + const on = blocks.filter((b) => n8.checked.has(b.source) && n8CanSend(b)); + const rows = blocks + .map((b) => { + const e = n8.edits.get(b.source) || { task: b.taskName, memo: '' }; + return ` + + ${esc(b.date)} + ${fmtTime(b.start)}–${fmtTime(b.end)} + ${b.minutes} 分 + ${esc(shortPath(b.projectPath))} + + + ${n8Status(b)} + `; + }) + .join(''); + $('#detail-body').innerHTML = `
    +
    + + + + + + +
    + ${d.n8nError ? `

    n8n に接続できません: ${esc(d.n8nError)} (送信済みが分からないので送れません)

    ` : ''} + ${n8.settings ? n8Settings() : ''} + ${n8ResultHtml()} + ${n8PickHtml()} +

    区間: 空き ${d.gapMinutes} 分以上で区切り、${d.minMinutes} 分未満は除外。${blocks.length} 件

    + + ${rows || ''}
    送る日付時刻時間プロジェクトタスク名 / メモチケット状態
    この期間の区間はありません
    +
    `; + n8Wire(); +} + +function n8Block(source) { + return n8.data.blocks.find((b) => b.source === source); +} + +/** 区間の編集値を変え、必要なら同じセッションの他の区間にも入れて、ブラウザに覚える。 */ +function n8SetEdit(source, patch) { + const b = n8Block(source); + const targets = n8.sameSession ? n8.data.blocks.filter((x) => x.sessionId === b.sessionId && !x.sent) : [b]; + for (const t of targets) n8.edits.set(t.source, { ...(n8.edits.get(t.source) || {}), ...patch }); + n8SaveName(b.sessionId, n8.edits.get(source)); +} + +function n8Wire() { + $('#n8-reload').onclick = () => { + n8.from = $('#n8-from').value; + n8.to = $('#n8-to').value; + loadN8n(); + }; + $('#n8-same').onchange = (e) => { + n8.sameSession = e.target.checked; + }; + $('#n8-toggle').onclick = () => { + n8.settings = !n8.settings; + renderN8n(); + }; + $('#n8-send').onclick = n8Send; + if (n8.settings) $('#n8-save').onclick = n8SaveSettings; + if (n8.pick) { + $('#n8-find').onclick = n8Find; + $('#n8-q').onkeydown = (e) => { + if (e.key === 'Enter') n8Find(); + }; + $('#n8-pick-close').onclick = () => { + n8.pick = null; + renderN8n(); + }; + document.querySelectorAll('.n8-issue').forEach((btn) => { + btn.onclick = () => { + const issue = n8.issues.find((i) => String(i.id) === btn.dataset.id); + n8SetEdit(n8.pick, { task: `[${issue.project}] #${issue.id} ${issue.subject}` }); + n8.pick = null; + renderN8n(); + }; + }); + } + document.querySelectorAll('#n8n-table tbody tr[data-src]').forEach((tr) => { + const source = tr.dataset.src; + tr.querySelector('.n8-on').onchange = (e) => { + if (e.target.checked) n8.checked.add(source); + else n8.checked.delete(source); + renderN8n(); + }; + tr.querySelector('.n8-task').onchange = (e) => { + n8SetEdit(source, { task: e.target.value }); + renderN8n(); + }; + tr.querySelector('.n8-memo').onchange = (e) => { + n8SetEdit(source, { memo: e.target.value }); + }; + tr.querySelector('.n8-choose').onclick = () => { + n8.pick = source; + n8.issues = []; + n8.pickMsg = ''; + renderN8n(); + }; + }); +} + +async function n8Find() { + n8.pickMsg = '検索中…'; + const q = $('#n8-q').value; + renderN8n(); + try { + n8.issues = (await api('/api/redmine/search?q=' + encodeURIComponent(q))).issues; + n8.pickMsg = n8.issues.length ? '' : '見つかりません'; + } catch (e) { + n8.pickMsg = e.message; + } + renderN8n(); + $('#n8-q').value = q; +} + +async function n8SaveSettings() { + const patch = { + url: $('#n8-url').value, + verifySsl: $('#n8-verify').checked, + gapMinutes: Number($('#n8-gap').value), + minMinutes: Number($('#n8-min').value), + }; + const token = $('#n8-token').value; + if (token) patch.token = token; + try { + n8.cfg = await post('/api/n8n/config', patch); + n8.settings = false; + loadN8n(); + } catch (e) { + alert('保存できません: ' + e.message); + } +} + +async function n8Send() { + const items = n8.data.blocks + .filter((b) => n8.checked.has(b.source) && n8CanSend(b)) + .map((b) => ({ project: b.project, source: b.source, ...(n8.edits.get(b.source) || { task: b.taskName, memo: '' }) })); + if (!items.length) return; + n8.busy = true; + renderN8n(); + try { + n8.result = await post('/api/n8n/send', { items }); + } catch (e) { + n8.result = { ok: false, inserted: [], skipped: [], errors: [{ source: '', error: e.message }] }; + } + n8.busy = false; + await loadN8n({ silent: true }); +} diff --git a/web/style.css b/web/style.css index 09ee935..33bd461 100644 --- a/web/style.css +++ b/web/style.css @@ -395,3 +395,27 @@ table { width: 100%; border-collapse: collapse; } .gantt svg rect.t-work { fill: var(--work); } .gantt svg rect.t-none { fill: var(--idle); } #turns tr.hi td { background: rgba(122, 162, 247, .18); } + +/* n8n へ送る */ +.n8 { padding: 8px 12px; } +.n8-bar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; } +.n8-bar .spacer { flex: 1; } +.n8 .go { border-color: var(--work); color: var(--work); } +.n8 .go:disabled { border-color: var(--line); color: var(--dim); cursor: default; } +.n8 .note { color: var(--dim); font-size: 11px; } +.n8 .err-inline { color: #f7768e; font-size: 11px; } +.n8-settings, .n8-pick, .n8-result { border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; margin: 8px 0; } +.n8-settings label { display: inline-flex; gap: 4px; align-items: center; margin-right: 12px; } +.n8-result.ok { border-color: var(--work); } +.n8-result.ng { border-color: #f7768e; } +.n8-pick ul { list-style: none; padding: 0; margin: 6px 0 0; max-height: 200px; overflow: auto; } +#n8n-table th { text-align: left; font-size: 11px; color: var(--dim); padding: 4px 6px; border-bottom: 1px solid var(--line); } +#n8n-table td { padding: 4px 6px; border-bottom: 1px solid var(--line); vertical-align: middle; } +#n8n-table .time, #n8n-table .num { white-space: nowrap; font-variant-numeric: tabular-nums; } +#n8n-table .num { text-align: right; } +#n8n-table .n8-task { width: 22em; } +#n8n-table .n8-memo { width: 10em; } +#n8n-table tr.n8-sent td { color: var(--dim); } +.n8-mark { display: inline-block; padding: 0 6px; border-radius: 9px; font-size: 10.5px; border: 1px solid var(--line); } +.n8-mark.sent { color: var(--work); border-color: var(--work); } +.n8-mark.warn { color: var(--warn); border-color: var(--warn); }