n8n 連携: 作業区間を選んで送る「n8n へ送る」タブを追加
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+10
-1
@@ -144,6 +144,7 @@ async function selectProject(id) {
|
||||
$('#session-table tbody').innerHTML = '<tr><td class="loading">解析中…</td></tr>';
|
||||
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();
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
<button class="tab sel" id="tab-turns">やりとり</button>
|
||||
<button class="tab" id="tab-daily">日別集計</button>
|
||||
<button class="tab" id="tab-gantt">ガント</button>
|
||||
<button class="tab" id="tab-n8n" title="Claude の作業区間を n8n の work_log に送る">n8n へ送る</button>
|
||||
</span>
|
||||
<a id="csv-turns" class="btn" href="#" download>CSV</a>
|
||||
</div>
|
||||
@@ -67,5 +68,6 @@
|
||||
</main>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="n8n.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+277
@@ -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 = '<p class="loading">区間を計算中…</p>';
|
||||
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 = `<p class="err">${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function n8Status(b) {
|
||||
const marks = [];
|
||||
if (b.sent) marks.push('<span class="n8-mark sent">送信済み</span>');
|
||||
if (b.deleted) marks.push('<span class="n8-mark warn" title="一度送ってから work_log で削除された区間">削除済み</span>');
|
||||
if (b.inProgress) marks.push('<span class="n8-mark warn">進行中</span>');
|
||||
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(`<span class="n8-mark warn" title="${esc(tip)}">重なり ${b.overlaps.length}</span>`);
|
||||
}
|
||||
return marks.join(' ');
|
||||
}
|
||||
|
||||
function n8Settings() {
|
||||
const c = n8.cfg || {};
|
||||
return `<div class="n8-settings">
|
||||
<h4>接続設定 <span class="note">${esc(c.configPath || '')}</span></h4>
|
||||
<label>URL <input id="n8-url" value="${esc(c.url || '')}" placeholder="https://n8n.yfox.jp"></label>
|
||||
<label>合言葉 <input id="n8-token" type="password" placeholder="${c.hasToken ? '設定済み (変えるときだけ入力)' : '未設定'}"></label>
|
||||
<label class="chk"><input type="checkbox" id="n8-verify" ${c.verifySsl ? 'checked' : ''}> 証明書を検証する</label>
|
||||
<label>区切る空き (分) <input id="n8-gap" type="number" min="1" max="240" value="${c.gapMinutes ?? 30}"></label>
|
||||
<label>最短 (分) <input id="n8-min" type="number" min="0" max="240" value="${c.minMinutes ?? 5}"></label>
|
||||
<button id="n8-save">保存</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function n8ResultHtml() {
|
||||
const r = n8.result;
|
||||
if (!r) return '';
|
||||
const errs = (r.errors || []).map((e) => `<li>${esc(e.source)}: ${esc(e.error)}</li>`).join('');
|
||||
const skips = (r.skipped || []).map((s) => `<li>${esc(s.source)}: ${esc(s.reason)}</li>`).join('');
|
||||
return `<div class="n8-result ${r.ok ? 'ok' : 'ng'}">
|
||||
追加 ${(r.inserted || []).length} 件 / スキップ ${(r.skipped || []).length} 件 / エラー ${(r.errors || []).length} 件
|
||||
${skips ? `<ul>${skips}</ul>` : ''}${errs ? `<ul class="err-inline">${errs}</ul>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function n8PickHtml() {
|
||||
if (!n8.pick) return '';
|
||||
const rows = n8.issues
|
||||
.map((i) => `<li><button class="n8-issue" data-id="${i.id}">#${i.id}</button> <span class="note">[${esc(i.project)}]</span> ${esc(i.subject)}</li>`)
|
||||
.join('');
|
||||
return `<div class="n8-pick">
|
||||
<h4>Redmine のチケットを選ぶ</h4>
|
||||
<input id="n8-q" placeholder="件名の一部、または番号"> <button id="n8-find">検索</button> <button id="n8-pick-close">閉じる</button>
|
||||
<span class="note">${esc(n8.pickMsg)}</span>
|
||||
<ul>${rows}</ul>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 `<tr data-src="${esc(b.source)}" class="${b.sent ? 'n8-sent' : ''}">
|
||||
<td><input type="checkbox" class="n8-on" ${n8.checked.has(b.source) && n8CanSend(b) ? 'checked' : ''} ${n8CanSend(b) ? '' : 'disabled'}></td>
|
||||
<td class="time">${esc(b.date)}</td>
|
||||
<td class="time">${fmtTime(b.start)}–${fmtTime(b.end)}</td>
|
||||
<td class="num">${b.minutes} 分</td>
|
||||
<td class="n8-pj" title="${esc(b.projectPath)}">${esc(shortPath(b.projectPath))}</td>
|
||||
<td><input class="n8-task" value="${esc(e.task)}"> <input class="n8-memo" value="${esc(e.memo)}" placeholder="メモ"></td>
|
||||
<td><button class="n8-choose">選ぶ</button></td>
|
||||
<td>${n8Status(b)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join('');
|
||||
$('#detail-body').innerHTML = `<div class="n8">
|
||||
<div class="n8-bar">
|
||||
<label>期間 <input type="date" id="n8-from" value="${esc(n8.from)}"> – <input type="date" id="n8-to" value="${esc(n8.to)}"></label>
|
||||
<button id="n8-reload">読み直す</button>
|
||||
<label class="chk"><input type="checkbox" id="n8-same" ${n8.sameSession ? 'checked' : ''}> 同じセッションの区間にも入れる</label>
|
||||
<span class="spacer"></span>
|
||||
<button id="n8-toggle">${n8.settings ? '設定を閉じる' : '接続設定'}</button>
|
||||
<button id="n8-send" class="go" ${d.n8nError || n8.busy || !on.length ? 'disabled' : ''}>選んだ ${on.length} 件を送る</button>
|
||||
</div>
|
||||
${d.n8nError ? `<p class="err-inline">n8n に接続できません: ${esc(d.n8nError)} (送信済みが分からないので送れません)</p>` : ''}
|
||||
${n8.settings ? n8Settings() : ''}
|
||||
${n8ResultHtml()}
|
||||
${n8PickHtml()}
|
||||
<p class="note">区間: 空き ${d.gapMinutes} 分以上で区切り、${d.minMinutes} 分未満は除外。${blocks.length} 件</p>
|
||||
<table id="n8n-table"><thead><tr><th>送る</th><th>日付</th><th>時刻</th><th>時間</th><th>プロジェクト</th><th>タスク名 / メモ</th><th>チケット</th><th>状態</th></tr></thead>
|
||||
<tbody>${rows || '<tr><td colspan="8" class="empty">この期間の区間はありません</td></tr>'}</tbody></table>
|
||||
</div>`;
|
||||
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 });
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user