feat(dashboard): Interventions page improvements and action-confirm modal

- Move Interventions under Workstreams in the navigator
- Add action-confirm.js: shared modal component for actions requiring a
  mandatory comment (survives live-poll re-renders, unlike inline DOM mutation)
- Wire action-confirm into Interventions (Mark done) and Decisions (Resolve)
- Fix Interventions completed section: fetch all tasks and filter client-side
  so resolved interventions (needs_human=false) still appear under Completed
- Add docs/interventions.md help page with ? button on the h1
- Replace all hardcoded "Bernd" with "human" across dashboard src and docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 23:15:06 +01:00
parent c792ab0bc0
commit 0bdf4929fc
9 changed files with 426 additions and 31 deletions

View File

@@ -0,0 +1,270 @@
/**
* action-confirm — modal dialog that requires a non-empty comment before
* confirming an action (e.g. resolving a decision, marking an intervention done).
*
* Lives on document.body so it survives live-poll re-renders of the page content.
*
* Usage:
* import {openActionConfirm} from "./components/action-confirm.js";
*
* openActionConfirm({
* title: "Mark as Done", // modal header
* entityTitle: task.title, // shown as context below the header
* label: "Resolution comment", // textarea label
* placeholder: "What was done?", // textarea placeholder
* confirmLabel: "Mark Done", // confirm button text
* onConfirm: async (comment) => { ... }, // called with trimmed comment
* // onConfirm should throw (or return a rejected promise) on API error
* });
*/
const _STYLE_ID = "action-confirm-styles";
function _ensureStyles() {
if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return;
const s = document.createElement("style");
s.id = _STYLE_ID;
s.textContent = `
/* ── Backdrop ────────────────────────────────────────────────────────────── */
.ac-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
z-index: 9200; display: flex; align-items: center; justify-content: center;
animation: _ac-fade 0.15s ease;
}
@keyframes _ac-fade { from { opacity: 0 } to { opacity: 1 } }
/* ── Box ──────────────────────────────────────────────────────────────────── */
.ac-box {
width: min(480px, 92vw);
background: var(--theme-background, #fff);
border-radius: 12px;
box-shadow: 0 16px 56px rgba(0,0,0,0.28);
display: flex; flex-direction: column; overflow: hidden;
animation: _ac-rise 0.15s ease;
}
@keyframes _ac-rise {
from { transform: translateY(12px); opacity: 0 }
to { transform: translateY(0); opacity: 1 }
}
/* ── Header ───────────────────────────────────────────────────────────────── */
.ac-header {
display: flex; align-items: flex-start; gap: 0.75rem;
padding: 0.85rem 1rem 0.75rem;
border-bottom: 1px solid var(--theme-foreground-faint, #e8e8e8);
background: var(--theme-background-alt, #f7f7f7);
}
.ac-title {
flex: 1; font-size: 0.95rem; font-weight: 700; line-height: 1.3;
color: var(--theme-foreground, #111);
}
.ac-close {
background: none; border: 1px solid transparent; cursor: pointer;
font-size: 0.9rem; color: var(--theme-foreground-muted, #888);
padding: 0.15rem 0.45rem; border-radius: 6px; flex-shrink: 0;
font-family: inherit; line-height: 1.3;
}
.ac-close:hover {
border-color: var(--theme-foreground-faint, #ccc);
background: var(--theme-background, #fff); color: var(--theme-foreground, #111);
}
/* ── Body ─────────────────────────────────────────────────────────────────── */
.ac-body { padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
.ac-entity-title {
font-size: 0.85rem; color: var(--theme-foreground-muted, #555);
background: var(--theme-background-alt, #f9f9f9);
border: 1px solid var(--theme-foreground-faint, #eee);
border-radius: 6px; padding: 0.45rem 0.65rem; line-height: 1.45;
word-break: break-word;
}
.ac-label {
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--theme-foreground-muted, #666);
margin-bottom: 0.25rem; display: block;
}
.ac-textarea {
width: 100%; box-sizing: border-box;
min-height: 80px; resize: vertical;
font-size: 0.87rem; line-height: 1.5; font-family: inherit;
padding: 0.5rem 0.65rem;
border: 1px solid var(--theme-foreground-faint, #d1d5db);
border-radius: 6px;
background: var(--theme-background, #fff);
color: var(--theme-foreground, #111);
transition: border-color 0.1s, box-shadow 0.1s;
}
.ac-textarea:focus { outline: none; border-color: steelblue; box-shadow: 0 0 0 2px #bfdbfe; }
.ac-textarea.ac-invalid { border-color: #dc2626; box-shadow: 0 0 0 2px #fecaca; }
.ac-error {
font-size: 0.8rem; color: #dc2626;
background: #fef2f2; border: 1px solid #fecaca;
border-radius: 6px; padding: 0.35rem 0.6rem;
}
/* ── Footer ───────────────────────────────────────────────────────────────── */
.ac-footer {
display: flex; justify-content: flex-end; gap: 0.5rem;
padding: 0.65rem 1rem 0.9rem;
border-top: 1px solid var(--theme-foreground-faint, #e8e8e8);
}
.ac-btn-cancel {
padding: 0.3rem 0.85rem; border-radius: 6px;
border: 1px solid var(--theme-foreground-faint, #d1d5db);
background: var(--theme-background, #fff);
color: var(--theme-foreground-muted, #555);
font-size: 0.85rem; font-family: inherit; cursor: pointer;
}
.ac-btn-cancel:hover { border-color: #9ca3af; color: var(--theme-foreground, #111); }
.ac-btn-confirm {
padding: 0.3rem 0.85rem; border-radius: 6px;
border: 1px solid #22c55e; background: #f0fdf4; color: #166534;
font-size: 0.85rem; font-weight: 600; font-family: inherit; cursor: pointer;
transition: background 0.1s;
}
.ac-btn-confirm:hover:not(:disabled) { background: #dcfce7; }
.ac-btn-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
`;
document.head.append(s);
}
/**
* @param {object} opts
* @param {string} opts.title Modal header text
* @param {string} [opts.entityTitle] Optional context shown below the header
* @param {string} opts.label Textarea label
* @param {string} [opts.placeholder] Textarea placeholder
* @param {string} [opts.confirmLabel] Confirm button label (default: "Confirm")
* @param {Function} opts.onConfirm async (comment: string) => void — throw to show error
*/
export function openActionConfirm({
title,
entityTitle,
label,
placeholder = "Add a comment…",
confirmLabel = "Confirm",
onConfirm,
}) {
_ensureStyles();
document.getElementById("_ac-root")?.remove();
const root = document.createElement("div");
root.id = "_ac-root";
root.className = "ac-backdrop";
root.setAttribute("role", "dialog");
root.setAttribute("aria-modal", "true");
root.setAttribute("aria-label", title);
// ── Header ────────────────────────────────────────────────────────────────
const header = document.createElement("div");
header.className = "ac-header";
const titleEl = document.createElement("div");
titleEl.className = "ac-title";
titleEl.textContent = title;
const closeBtn = document.createElement("button");
closeBtn.className = "ac-close";
closeBtn.textContent = "✕";
closeBtn.setAttribute("aria-label", "Cancel");
header.append(titleEl, closeBtn);
// ── Body ──────────────────────────────────────────────────────────────────
const body = document.createElement("div");
body.className = "ac-body";
if (entityTitle) {
const ctx = document.createElement("div");
ctx.className = "ac-entity-title";
ctx.textContent = entityTitle;
body.append(ctx);
}
const labelEl = document.createElement("label");
labelEl.className = "ac-label";
labelEl.textContent = label;
const textarea = document.createElement("textarea");
textarea.className = "ac-textarea";
textarea.placeholder = placeholder;
textarea.rows = 3;
labelEl.append(textarea); // make label clickable
const fieldWrap = document.createElement("div");
fieldWrap.append(labelEl);
body.append(fieldWrap);
const errorEl = document.createElement("div");
errorEl.className = "ac-error";
errorEl.style.display = "none";
body.append(errorEl);
// ── Footer ────────────────────────────────────────────────────────────────
const footer = document.createElement("div");
footer.className = "ac-footer";
const cancelBtn = document.createElement("button");
cancelBtn.className = "ac-btn-cancel";
cancelBtn.textContent = "Cancel";
const confirmBtn = document.createElement("button");
confirmBtn.className = "ac-btn-confirm";
confirmBtn.textContent = confirmLabel;
footer.append(cancelBtn, confirmBtn);
// ── Assemble ──────────────────────────────────────────────────────────────
const box = document.createElement("div");
box.className = "ac-box";
box.append(header, body, footer);
root.append(box);
document.body.append(root);
// Focus the textarea after animation
setTimeout(() => textarea.focus(), 50);
// ── Behaviour ─────────────────────────────────────────────────────────────
const close = () => root.remove();
cancelBtn.addEventListener("click", close);
closeBtn.addEventListener("click", close);
root.addEventListener("click", e => { if (e.target === root) close(); });
const onKey = e => {
if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); }
};
document.addEventListener("keydown", onKey);
textarea.addEventListener("input", () => {
textarea.classList.remove("ac-invalid");
errorEl.style.display = "none";
});
confirmBtn.addEventListener("click", async () => {
const comment = textarea.value.trim();
if (!comment) {
textarea.classList.add("ac-invalid");
textarea.focus();
return;
}
confirmBtn.disabled = true;
cancelBtn.disabled = true;
confirmBtn.textContent = "…";
errorEl.style.display = "none";
try {
await onConfirm(comment);
close();
} catch (err) {
errorEl.textContent = err?.message ?? "Request failed — check that the API is running.";
errorEl.style.display = "";
confirmBtn.disabled = false;
cancelBtn.disabled = false;
confirmBtn.textContent = confirmLabel;
}
});
}