Redmine へチケットを起票するボタンを追加
chktime のタスクと git 履歴から下書きを作り、まとめて Redmine へ登録できる ようにした。外部通信はこの機能を使ったときだけで、使わなければ従来どおり ローカルの jsonl を読むだけ。 - Redmine の REST API を叩く最小クライアント (標準ライブラリのみ) - 下書きは「タスクから」(1タスク=1チケット) と「git 履歴から」 (日ごと / 日 x 種別 / 期間一括) の 2 系統 - 起票は件数 x 間隔ぶん時間がかかるのでジョブにし、進捗だけ見に行く - 作成時の status_id は Redmine 側で無視されるため、作成後に付け直す - 連続アクセスに 403 を返すサーバがあるので 1 件ごとに間隔をあける (既定 7 秒) - 自己署名証明書が普通なので、既定は証明書の検証なし - API キーは redmine.json に置き、git 管理外にする Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+391
@@ -1080,6 +1080,388 @@ function stopFollow() {
|
||||
followStatus('off', '');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Redmine 起票
|
||||
|
||||
// 下書きを作る → 中身を確かめる → まとめて起票する、の 3 段。
|
||||
// サーバ側が 1 件ずつ間隔をあけて投げるので、起票はジョブにして進捗だけ見に行く。
|
||||
const rm = {
|
||||
cfg: null, // /api/redmine/config (キーは含まれない)
|
||||
meta: null, // プロジェクト / トラッカー / ステータス
|
||||
settings: false, // 接続設定を開いているか
|
||||
source: 'tasks', // tasks=chktime のタスク / git=git 履歴
|
||||
mode: 'day', // git のまとめ方: day / type / all
|
||||
repo: '',
|
||||
from: '',
|
||||
to: '',
|
||||
tasks: new Map(), // 起票対象のタスク: キー -> {project, id}
|
||||
drafts: [],
|
||||
off: new Set(), // 下書きのうち外したもの (index)
|
||||
job: null,
|
||||
timer: null,
|
||||
busy: false,
|
||||
};
|
||||
|
||||
async function post(path, payload) {
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** タスクの識別子。プロジェクトとタスク ID の組で 1 件を指す。 */
|
||||
const taskOf = (s) => ({ project: s.project || state.project || '', id: s.id });
|
||||
const taskKey = (t) => `${t.project}/${t.id}`;
|
||||
|
||||
function rmMsg(text, kind) {
|
||||
const el = $('#rm-msg');
|
||||
el.className = kind === 'err' ? 'err-inline' : 'note';
|
||||
el.textContent = text || '';
|
||||
}
|
||||
|
||||
function rmConn() {
|
||||
const el = $('#rm-conn');
|
||||
if (!rm.cfg || !rm.cfg.url) {
|
||||
el.className = 'fstate wait';
|
||||
el.textContent = '未設定';
|
||||
} else if (!rm.cfg.hasKey) {
|
||||
el.className = 'fstate wait';
|
||||
el.textContent = 'キー未設定';
|
||||
} else {
|
||||
el.className = 'fstate live';
|
||||
el.textContent = rm.cfg.url;
|
||||
}
|
||||
}
|
||||
|
||||
async function openRedmine() {
|
||||
$('#rm-back').classList.remove('hidden');
|
||||
rmMsg('');
|
||||
try {
|
||||
rm.cfg = await api('/api/redmine/config');
|
||||
rm.settings = !rm.cfg.url || !rm.cfg.hasKey;
|
||||
if (!rm.repo) rm.repo = (state.detail && state.detail.cwd) || (state.project ? projectPathOf(state.project) : '');
|
||||
if (!rm.tasks.size && state.session) {
|
||||
const t = { project: state.sessionProject, id: state.session };
|
||||
rm.tasks.set(taskKey(t), t);
|
||||
}
|
||||
renderRedmine();
|
||||
if (rm.cfg.hasKey && !rm.meta) loadRedmineMeta();
|
||||
} catch (e) {
|
||||
$('#rm-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function closeRedmine() {
|
||||
$('#rm-back').classList.add('hidden');
|
||||
clearInterval(rm.timer);
|
||||
rm.timer = null;
|
||||
}
|
||||
|
||||
async function loadRedmineMeta() {
|
||||
try {
|
||||
rmMsg('Redmine に接続しています…');
|
||||
rm.meta = await api('/api/redmine/meta');
|
||||
rmMsg('');
|
||||
} catch (e) {
|
||||
rm.meta = null;
|
||||
rmMsg(e.message, 'err');
|
||||
}
|
||||
renderRedmine();
|
||||
}
|
||||
|
||||
function optionList(items, selected, label) {
|
||||
return items
|
||||
.map((it) => `<option value="${it.id}"${String(it.id) === String(selected ?? '') ? ' selected' : ''}>${esc(label(it))}</option>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderRedmine() {
|
||||
rmConn();
|
||||
const cfg = rm.cfg || {};
|
||||
const meta = rm.meta;
|
||||
|
||||
const settings = `
|
||||
<div class="rm-sec">
|
||||
<h4>接続 <button class="rng" id="rm-toggle">${rm.settings ? '隠す' : '設定'}</button></h4>
|
||||
${rm.settings
|
||||
? `<div class="rm-row"><label>URL</label>
|
||||
<input type="text" id="rm-url" value="${esc(cfg.url || '')}" placeholder="https://192.168.3.150"></div>
|
||||
<div class="rm-row"><label>APIキー</label>
|
||||
<input type="password" id="rm-key" placeholder="${cfg.hasKey ? '保存済み(変更するときだけ入力)' : '個人設定 → APIアクセスキー'}"></div>
|
||||
<div class="rm-row">
|
||||
<label class="chk"><input type="checkbox" id="rm-verify"${cfg.verifySsl ? ' checked' : ''}> 証明書を検証する</label>
|
||||
<label>間隔(秒)</label><input type="text" id="rm-int" value="${esc(cfg.interval ?? 7)}" style="flex:0 0 60px">
|
||||
<button id="rm-save">保存</button>
|
||||
</div>
|
||||
<div class="note">設定は ${esc(cfg.configPath || '')} に保存されます(キーを含むので git 管理外)。
|
||||
自己署名証明書のサーバでは「証明書を検証する」を外してください。</div>`
|
||||
: ''}
|
||||
</div>`;
|
||||
|
||||
const target = meta
|
||||
? `<div class="rm-sec">
|
||||
<h4>起票先</h4>
|
||||
<div class="rm-row">
|
||||
<label>プロジェクト</label>
|
||||
<select id="rm-proj"><option value="">選んでください</option>${optionList(meta.projects, cfg.projectId, (p) => p.name)}</select>
|
||||
<label>トラッカー</label>
|
||||
<select id="rm-tracker">${optionList(meta.trackers, cfg.trackerId, (t) => t.name)}</select>
|
||||
<label>ステータス</label>
|
||||
<select id="rm-status">${optionList(meta.statuses, cfg.statusId, (s) => s.name)}</select>
|
||||
</div>
|
||||
<div class="rm-row">
|
||||
<label class="chk"><input type="checkbox" id="rm-assign"${cfg.assignedToId ? ' checked' : ''}> 自分(${esc(meta.user.name || meta.user.login || '')})を担当にする</label>
|
||||
<label class="chk"><input type="checkbox" id="rm-done"> 進捗を 100% にする</label>
|
||||
</div>
|
||||
</div>`
|
||||
: cfg.hasKey
|
||||
? '<div class="rm-sec"><p class="note">Redmine の情報を取得できていません。接続設定を確認してください。</p></div>'
|
||||
: '';
|
||||
|
||||
const tab = (v, label) => `<button class="rng${rm.source === v ? ' sel' : ''}" data-src="${v}">${label}</button>`;
|
||||
const picker =
|
||||
rm.source === 'tasks'
|
||||
? `<div class="rm-row">
|
||||
<span class="note">一覧に出ている ${state.sessions.length} 件から選びます</span>
|
||||
<button class="rng" id="rm-all">全選択</button><button class="rng" id="rm-none">全解除</button>
|
||||
</div>
|
||||
<div class="rm-list">${
|
||||
state.sessions.length
|
||||
? state.sessions
|
||||
.map((s) => {
|
||||
const t = taskOf(s);
|
||||
return `<div class="rm-item"><label class="h">
|
||||
<input type="checkbox" class="rm-task" data-project="${esc(t.project)}" data-id="${esc(t.id)}"${rm.tasks.has(taskKey(t)) ? ' checked' : ''}>
|
||||
<span class="t"><span class="subject">${esc(s.title)}</span>
|
||||
<span class="meta">${fmtDateTime(s.start)} · ${s.turnCount} 往復 · 作業 ${fmtDur(s.activeMs)}</span></span>
|
||||
</label></div>`;
|
||||
})
|
||||
.join('')
|
||||
: '<div class="rm-item"><div class="h"><span class="note">タスクがありません</span></div></div>'
|
||||
}</div>`
|
||||
: `<div class="rm-row">
|
||||
<label>リポジトリ</label><input type="text" id="rm-repo" value="${esc(rm.repo)}" placeholder="C:\\debug\\chktime">
|
||||
</div>
|
||||
<div class="rm-row">
|
||||
<label>期間</label><input type="date" id="rm-from" value="${rm.from}"><span class="note">〜</span><input type="date" id="rm-to" value="${rm.to}">
|
||||
<label>まとめ方</label>
|
||||
<select id="rm-mode">
|
||||
<option value="day"${rm.mode === 'day' ? ' selected' : ''}>日ごと</option>
|
||||
<option value="type"${rm.mode === 'type' ? ' selected' : ''}>日 × 種別 (feat/fix/docs…)</option>
|
||||
<option value="all"${rm.mode === 'all' ? ' selected' : ''}>期間まとめて 1 件</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="note">分類は件名の prefix と日付だけを見た機械的なものです。中身の判断はしません。</div>`;
|
||||
|
||||
const source = `
|
||||
<div class="rm-sec">
|
||||
<h4>取得元 <span class="rngs" style="display:inline-flex;margin-left:6px">${tab('tasks', 'タスクから')}${tab('git', 'git 履歴から')}</span></h4>
|
||||
${picker}
|
||||
<div class="rm-row" style="margin-top:8px"><button id="rm-draft">下書きを作る</button></div>
|
||||
</div>`;
|
||||
|
||||
const drafts = rm.drafts.length
|
||||
? `<div class="rm-sec">
|
||||
<h4>下書き ${rm.drafts.length - rm.off.size} / ${rm.drafts.length} 件(クリックで本文を確認)</h4>
|
||||
<div class="rm-list">${rm.drafts
|
||||
.map(
|
||||
(d, i) => `<div class="rm-item${rm.off.has(i) ? ' off' : ''}" data-i="${i}">
|
||||
<div class="h">
|
||||
<input type="checkbox" class="rm-pick" data-i="${i}"${rm.off.has(i) ? '' : ' checked'}>
|
||||
<span class="t">
|
||||
<span class="subject">${esc(d.subject)}</span>
|
||||
<span class="meta">${esc(d.start_date || '')}${d.due_date && d.due_date !== d.start_date ? ' → ' + esc(d.due_date) : ''}
|
||||
${d.source && d.source.count ? ` · ${d.source.count} コミット` : ''}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="body">${esc(d.description)}</div>
|
||||
</div>`
|
||||
)
|
||||
.join('')}</div>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
$('#rm-body').innerHTML = settings + target + source + drafts + (rm.job ? renderJob() : '');
|
||||
wireRedmine();
|
||||
syncRun();
|
||||
}
|
||||
|
||||
function renderJob() {
|
||||
const j = rm.job;
|
||||
const pct = j.total ? Math.round((j.done / j.total) * 100) : 0;
|
||||
const ok = j.results.filter((r) => r.ok).length;
|
||||
const ng = j.results.length - ok;
|
||||
return `<div class="rm-sec">
|
||||
<h4>起票 ${j.done} / ${j.total}${j.state === 'done' ? '(完了)' : ''}</h4>
|
||||
<div class="rm-prog"><i style="width:${pct}%"></i></div>
|
||||
<div class="note">成功 ${ok} 件${ng ? ` · 失敗 ${ng} 件` : ''}</div>
|
||||
<div class="rm-list" style="margin-top:6px">${j.results
|
||||
.map(
|
||||
(r) => `<div class="rm-res">
|
||||
<span class="id${r.ok ? '' : ' ng'}">${r.ok ? '#' + r.issueId : '失敗'}</span>
|
||||
<span class="s">${esc(r.subject)}${r.ok ? '' : ` — ${esc(r.error || '')}`}</span>
|
||||
</div>`
|
||||
)
|
||||
.join('')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** ダイアログ内のイベントを配線する (再描画のたびに張り直す)。 */
|
||||
function wireRedmine() {
|
||||
const on = (sel, ev, fn) => {
|
||||
const el = $(sel);
|
||||
if (el) el[ev] = fn;
|
||||
};
|
||||
on('#rm-toggle', 'onclick', () => {
|
||||
rm.settings = !rm.settings;
|
||||
renderRedmine();
|
||||
});
|
||||
on('#rm-save', 'onclick', async () => {
|
||||
try {
|
||||
rmMsg('保存しています…');
|
||||
rm.cfg = await post('/api/redmine/config', {
|
||||
url: $('#rm-url').value.trim(),
|
||||
key: $('#rm-key').value.trim(),
|
||||
verifySsl: $('#rm-verify').checked,
|
||||
interval: Number($('#rm-int').value) || 7,
|
||||
});
|
||||
rm.settings = false;
|
||||
rm.meta = null;
|
||||
rmMsg('保存しました');
|
||||
await loadRedmineMeta();
|
||||
} catch (e) {
|
||||
rmMsg(e.message, 'err');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('#rm-body .rng[data-src]').forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
rm.source = btn.dataset.src;
|
||||
renderRedmine();
|
||||
};
|
||||
});
|
||||
document.querySelectorAll('#rm-body .rm-task').forEach((box) => {
|
||||
box.onchange = () => {
|
||||
const t = { project: box.dataset.project, id: box.dataset.id };
|
||||
if (box.checked) rm.tasks.set(taskKey(t), t);
|
||||
else rm.tasks.delete(taskKey(t));
|
||||
};
|
||||
});
|
||||
const pickAll = (want) => {
|
||||
state.sessions.forEach((s) => {
|
||||
const t = taskOf(s);
|
||||
if (want) rm.tasks.set(taskKey(t), t);
|
||||
else rm.tasks.delete(taskKey(t));
|
||||
});
|
||||
renderRedmine();
|
||||
};
|
||||
on('#rm-all', 'onclick', () => pickAll(true));
|
||||
on('#rm-none', 'onclick', () => pickAll(false));
|
||||
on('#rm-repo', 'onchange', (e) => (rm.repo = e.target.value.trim()));
|
||||
on('#rm-from', 'onchange', (e) => (rm.from = e.target.value));
|
||||
on('#rm-to', 'onchange', (e) => (rm.to = e.target.value));
|
||||
on('#rm-mode', 'onchange', (e) => (rm.mode = e.target.value));
|
||||
on('#rm-draft', 'onclick', makeDrafts);
|
||||
|
||||
document.querySelectorAll('#rm-body .rm-pick').forEach((box) => {
|
||||
box.onclick = (e) => e.stopPropagation(); // 本文の開閉と混ざらないように
|
||||
box.onchange = () => {
|
||||
const i = Number(box.dataset.i);
|
||||
if (box.checked) rm.off.delete(i);
|
||||
else rm.off.add(i);
|
||||
renderRedmine();
|
||||
};
|
||||
});
|
||||
document.querySelectorAll('#rm-body .rm-item[data-i] .h').forEach((head) => {
|
||||
head.onclick = (e) => {
|
||||
if (e.target.classList.contains('rm-pick')) return;
|
||||
head.parentElement.classList.toggle('open');
|
||||
};
|
||||
});
|
||||
|
||||
['#rm-proj', '#rm-tracker', '#rm-status'].forEach((sel) => on(sel, 'onchange', syncRun));
|
||||
}
|
||||
|
||||
/** 起票ボタンの有効・無効。下書きと起票先が揃うまでは押せない。 */
|
||||
function syncRun() {
|
||||
const proj = $('#rm-proj');
|
||||
$('#rm-run').disabled =
|
||||
rm.busy || !rm.meta || !rm.drafts.length || rm.drafts.length === rm.off.size || !(proj && proj.value);
|
||||
}
|
||||
|
||||
async function makeDrafts() {
|
||||
const payload =
|
||||
rm.source === 'tasks'
|
||||
? {
|
||||
source: 'tasks',
|
||||
ids: [...rm.tasks.values()],
|
||||
}
|
||||
: { source: 'git', repo: rm.repo, from: rm.from, to: rm.to, mode: rm.mode };
|
||||
try {
|
||||
rmMsg('下書きを作っています…');
|
||||
const data = await post('/api/redmine/draft', payload);
|
||||
rm.drafts = data.drafts;
|
||||
rm.off = new Set();
|
||||
rm.job = null;
|
||||
rmMsg(`${rm.drafts.length} 件の下書きを作りました`);
|
||||
} catch (e) {
|
||||
rm.drafts = [];
|
||||
rmMsg(e.message, 'err');
|
||||
}
|
||||
renderRedmine();
|
||||
}
|
||||
|
||||
async function runRedmine() {
|
||||
const drafts = rm.drafts.filter((_, i) => !rm.off.has(i));
|
||||
if (!drafts.length) return;
|
||||
const defaults = {
|
||||
projectId: Number($('#rm-proj').value) || null,
|
||||
trackerId: Number($('#rm-tracker').value) || null,
|
||||
statusId: Number($('#rm-status').value) || null,
|
||||
assignedToId: $('#rm-assign').checked && rm.meta ? rm.meta.user.id : null,
|
||||
doneRatio: $('#rm-done').checked ? 100 : null,
|
||||
};
|
||||
const secs = Math.round(drafts.length * (rm.cfg.interval || 7) * (defaults.statusId ? 2 : 1));
|
||||
if (!confirm(`${drafts.length} 件を Redmine に起票します。\n約 ${Math.ceil(secs / 60)} 分かかります。よろしいですか?`)) return;
|
||||
try {
|
||||
rm.busy = true;
|
||||
rmMsg('起票しています…');
|
||||
// 次に開いたときも同じ設定で始められるよう、選んだ既定値を残す
|
||||
post('/api/redmine/config', {
|
||||
projectId: defaults.projectId, trackerId: defaults.trackerId,
|
||||
statusId: defaults.statusId, assignedToId: defaults.assignedToId,
|
||||
}).catch(() => {});
|
||||
rm.job = await post('/api/redmine/issues', { drafts, defaults });
|
||||
renderRedmine();
|
||||
clearInterval(rm.timer);
|
||||
rm.timer = setInterval(pollJob, 1000);
|
||||
} catch (e) {
|
||||
rm.busy = false;
|
||||
rmMsg(e.message, 'err');
|
||||
renderRedmine();
|
||||
}
|
||||
}
|
||||
|
||||
async function pollJob() {
|
||||
if (!rm.job) return;
|
||||
try {
|
||||
rm.job = await api(`/api/redmine/job?id=${encodeURIComponent(rm.job.id)}`);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (rm.job.state === 'done') {
|
||||
clearInterval(rm.timer);
|
||||
rm.timer = null;
|
||||
rm.busy = false;
|
||||
const ng = rm.job.results.filter((r) => !r.ok).length;
|
||||
rmMsg(ng ? `完了(失敗 ${ng} 件)` : '完了しました', ng ? 'err' : '');
|
||||
}
|
||||
renderRedmine();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 起動
|
||||
|
||||
$('#filter').oninput = renderSessions;
|
||||
@@ -1090,6 +1472,15 @@ $('#relative').onchange = (e) => {
|
||||
$('#tab-turns').onclick = () => setView('turns');
|
||||
$('#tab-daily').onclick = () => setView('daily');
|
||||
$('#tab-gantt').onclick = () => setView('gantt');
|
||||
$('#redmine').onclick = openRedmine;
|
||||
$('#rm-close').onclick = closeRedmine;
|
||||
$('#rm-run').onclick = runRedmine;
|
||||
$('#rm-back').onclick = (e) => {
|
||||
if (e.target.id === 'rm-back') closeRedmine();
|
||||
};
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !$('#rm-back').classList.contains('hidden')) closeRedmine();
|
||||
});
|
||||
$('#reload').onclick = async () => {
|
||||
await fetch('/api/reload');
|
||||
const keep = { project: state.sessionProject, session: state.session, view: state.view };
|
||||
|
||||
@@ -14,9 +14,27 @@
|
||||
<label class="chk"><input type="checkbox" id="relative"> 経過時間で表示</label>
|
||||
<label class="chk"><input type="checkbox" id="follow"> 自動追従</label>
|
||||
<span id="follow-state" class="fstate"></span>
|
||||
<button id="redmine" title="Redmine にチケットを起票する">Redmine へ起票</button>
|
||||
<button id="reload" title="キャッシュを破棄して再読込">再読込</button>
|
||||
</header>
|
||||
|
||||
<div id="rm-back" class="modal-back hidden">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<span>Redmine へ起票</span>
|
||||
<span class="spacer"></span>
|
||||
<span id="rm-conn" class="fstate"></span>
|
||||
<button id="rm-close" title="閉じる">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="rm-body"></div>
|
||||
<div class="modal-foot">
|
||||
<span id="rm-msg" class="note"></span>
|
||||
<span class="spacer"></span>
|
||||
<button id="rm-run" class="go">起票する</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<nav id="projects" class="pane">
|
||||
<div class="pane-head">プロジェクト</div>
|
||||
|
||||
@@ -297,6 +297,98 @@ table { width: 100%; border-collapse: collapse; }
|
||||
.gantt svg rect.g-hit { fill: transparent; cursor: pointer; }
|
||||
.gantt svg rect.g-hit:hover { fill: #ffffff; opacity: .07; }
|
||||
|
||||
/* Redmine 起票ダイアログ */
|
||||
.hidden { display: none !important; }
|
||||
.modal-back {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, .55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(880px, 94vw);
|
||||
height: min(760px, 92vh);
|
||||
background: var(--pane);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-head, .modal-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
background: var(--pane2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.modal-foot { border-bottom: none; border-top: 1px solid var(--line); }
|
||||
.modal-head .spacer, .modal-foot .spacer { flex: 1; }
|
||||
.modal-body { flex: 1; overflow: auto; padding: 12px 14px 20px; }
|
||||
.modal .note { color: var(--dim); font-size: 11px; }
|
||||
.modal .err-inline { color: #f7768e; font-size: 11px; }
|
||||
.modal .go { border-color: var(--work); color: var(--work); }
|
||||
.modal .go:disabled { border-color: var(--line); color: var(--dim); cursor: default; }
|
||||
|
||||
.rm-sec { margin-bottom: 14px; }
|
||||
.rm-sec > h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: .08em;
|
||||
color: var(--dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.rm-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
|
||||
.rm-row label { color: var(--dim); font-size: 11px; }
|
||||
.modal input[type="text"], .modal input[type="password"], .modal input[type="date"], .modal select {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
color: var(--fg);
|
||||
padding: 3px 8px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
.modal input[type="text"], .modal input[type="password"] { flex: 1 1 220px; min-width: 140px; }
|
||||
.modal input:focus, .modal select:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
.rm-list { border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
|
||||
.rm-item { border-bottom: 1px solid var(--line); }
|
||||
.rm-item:last-child { border-bottom: none; }
|
||||
.rm-item > .h { display: flex; align-items: flex-start; gap: 8px; padding: 6px 10px; cursor: pointer; }
|
||||
.rm-item > .h:hover { background: var(--pane2); }
|
||||
.rm-item .t { flex: 1; min-width: 0; }
|
||||
.rm-item .subject { font-size: 12.5px; word-break: break-word; }
|
||||
.rm-item .meta { color: var(--dim); font-size: 11px; margin-top: 2px; }
|
||||
.rm-item .body {
|
||||
display: none;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 11px;
|
||||
color: var(--dim);
|
||||
background: var(--bg);
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 8px 10px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
.rm-item.open .body { display: block; }
|
||||
.rm-item.off .subject, .rm-item.off .meta { opacity: .45; text-decoration: line-through; }
|
||||
|
||||
.rm-res { display: flex; gap: 8px; padding: 4px 10px; border-bottom: 1px solid var(--line); font-size: 12px; }
|
||||
.rm-res:last-child { border-bottom: none; }
|
||||
.rm-res .id { color: var(--work); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.rm-res .id.ng { color: #f7768e; }
|
||||
.rm-res .s { flex: 1; min-width: 0; word-break: break-word; }
|
||||
.rm-prog { height: 6px; background: var(--bg); border: 1px solid var(--line); border-radius: 4px; overflow: hidden; margin: 8px 0; }
|
||||
.rm-prog i { display: block; height: 100%; background: var(--work); transition: width .2s; }
|
||||
|
||||
/* ガント (ターン単位) */
|
||||
.gantt .g-no { color: var(--dim); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.gantt svg rect.t-wait { fill: var(--user); }
|
||||
|
||||
Reference in New Issue
Block a user