278 lines
11 KiB
JavaScript
278 lines
11 KiB
JavaScript
'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 });
|
||
}
|