Implement dashboard UI improvements

This commit is contained in:
2026-05-19 02:16:24 +02:00
parent bf09782f68
commit cc21c5869e
9 changed files with 342 additions and 25 deletions

View File

@@ -401,12 +401,18 @@ export function buildEntityTable(rows, columns, onRowClick) {
for (const col of columns) {
const td = document.createElement("td");
const raw = col.key ? row[col.key] : null;
const text = col.render ? col.render(row) : (raw ?? "—");
const textStr = String(text ?? "—");
td.textContent = textStr;
const rendered = col.render ? col.render(row) : (raw ?? "—");
let textStr = "";
if (rendered instanceof Node) {
td.append(rendered);
textStr = td.textContent ?? "";
} else {
textStr = String(rendered ?? "—");
td.textContent = textStr;
}
if (col.cls) td.className = col.cls;
// Native tooltip so full value shows on hover (skip placeholder "—")
if (textStr && textStr !== "—") td.title = textStr;
if (!(rendered instanceof Node) && textStr && textStr !== "—") td.title = textStr;
tr.append(td);
}
tbody.append(tr);

View File

@@ -0,0 +1,160 @@
import {apiFetch} from "./config.js";
import {WORKSTREAM_STATUSES} from "./workplan-status.js";
const STYLE_ID = "status-control-styles";
export const TASK_STATUSES = ["todo", "in_progress", "blocked", "done", "cancelled"];
export {WORKSTREAM_STATUSES};
function ensureStyles() {
if (typeof document === "undefined" || document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.status-control {
display: inline-flex;
align-items: center;
gap: 0.4rem;
max-width: 100%;
}
.status-control-select {
min-width: 8.5rem;
max-width: 100%;
height: 1.85rem;
border: 1px solid var(--theme-foreground-faint, #d1d5db);
border-radius: 6px;
background: var(--theme-background, #fff);
color: var(--theme-foreground, #111);
font: inherit;
font-size: 0.82rem;
padding: 0.18rem 0.45rem;
}
.status-control-select:disabled {
opacity: 0.65;
}
.status-control-message {
min-width: 3.5rem;
font-size: 0.72rem;
color: var(--theme-foreground-muted, #6b7280);
white-space: nowrap;
}
.status-control-message.status-control-error {
color: #dc2626;
}
.status-control-message.status-control-ok {
color: #16a34a;
}
`;
document.head.append(style);
}
function labelForStatus(status) {
return String(status ?? "").replace(/_/g, " ");
}
function endpointFor(type, id) {
if (type === "task") return `/tasks/${id}`;
if (type === "workstream") return `/workstreams/${id}`;
throw new Error(`Unsupported status-control type: ${type}`);
}
async function readError(response) {
try {
const body = await response.json();
if (Array.isArray(body?.detail)) return body.detail.map(d => d.msg ?? String(d)).join("; ");
return body?.detail ?? `HTTP ${response.status}`;
} catch {
return `HTTP ${response.status}`;
}
}
export function statusControl({
entity,
type,
statuses = type === "task" ? TASK_STATUSES : WORKSTREAM_STATUSES,
onSaved = null,
} = {}) {
ensureStyles();
let currentStatus = entity?.status ?? "";
const root = document.createElement("span");
root.className = "status-control";
root.addEventListener("click", event => event.stopPropagation());
root.addEventListener("mousedown", event => event.stopPropagation());
const select = document.createElement("select");
select.className = "status-control-select";
select.setAttribute("aria-label", "Status");
for (const status of statuses) {
const option = document.createElement("option");
option.value = status;
option.textContent = labelForStatus(status);
select.append(option);
}
select.value = currentStatus;
const message = document.createElement("span");
message.className = "status-control-message";
function setMessage(text, kind = "") {
message.textContent = text;
message.classList.toggle("status-control-error", kind === "error");
message.classList.toggle("status-control-ok", kind === "ok");
}
select.addEventListener("change", async () => {
const nextStatus = select.value;
if (nextStatus === currentStatus) return;
const payload = {status: nextStatus};
if (type === "task" && nextStatus === "blocked") {
const existingReason = entity?.blocking_reason ?? "";
const reason = existingReason || window.prompt("Blocking reason required for blocked tasks:");
if (!reason) {
select.value = currentStatus;
setMessage("unchanged");
return;
}
payload.blocking_reason = reason;
}
if (type === "task" && nextStatus === "done" && currentStatus !== "done") {
const ok = window.confirm(
"Mark this task done? Without token fields, State Hub records a heuristic token event."
);
if (!ok) {
select.value = currentStatus;
setMessage("unchanged");
return;
}
}
select.disabled = true;
setMessage("saving");
try {
const response = await apiFetch(endpointFor(type, entity.id), {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(await readError(response));
const updated = await response.json();
Object.assign(entity, updated);
currentStatus = updated.status;
select.value = currentStatus;
setMessage("saved", "ok");
if (typeof onSaved === "function") onSaved(updated);
setTimeout(() => {
if (message.textContent === "saved") setMessage("");
}, 2200);
} catch (error) {
select.value = currentStatus;
setMessage(error?.message ?? "save failed", "error");
} finally {
select.disabled = false;
}
});
root.append(select, message);
return root;
}