generated from coulomb/repo-seed
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:
270
dashboard/src/components/action-confirm.js
Normal file
270
dashboard/src/components/action-confirm.js
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user