- POST /companies/{slug}/work-items/{id}/answer: shareholder answers a
blocked item; answer is appended to the description and the existing
kanban task is rerun (status=ready + updated body), or the item goes
back to ready for fresh dispatch if it was never dispatched
- Dashboard: answer button on blocked work item rows (native prompt)
- /office: pixel-art 2D office view — one desk row per employee, avatar
color by role, LED/typing animation driven only by real engine status,
live event ticker (PRD 12: no fake activity)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
149 lines
7.4 KiB
HTML
149 lines
7.4 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>FarmHouse Holdings</title>
|
|
<style>
|
|
:root { --bg:#101418; --card:#1a2027; --text:#d8dee6; --dim:#8a94a0; --line:#2a323c;
|
|
--ok:#4caf7d; --warn:#e0b354; --bad:#e06c5c; --run:#5c9de0; }
|
|
* { box-sizing:border-box; margin:0; }
|
|
body { background:var(--bg); color:var(--text); font:14px/1.5 system-ui, sans-serif; padding:1.5rem; }
|
|
h1 { font-size:1.2rem; margin-bottom:1rem; display:flex; align-items:center; gap:.75rem; }
|
|
h2 { font-size:.95rem; color:var(--dim); margin:1.2rem 0 .5rem; text-transform:uppercase; letter-spacing:.05em; }
|
|
.grid { display:grid; grid-template-columns:1fr 1fr; gap:1.5rem; align-items:start; }
|
|
@media (max-width:900px){ .grid{grid-template-columns:1fr} }
|
|
.card { background:var(--card); border:1px solid var(--line); border-radius:8px; padding:1rem; margin-bottom:1rem; }
|
|
table { width:100%; border-collapse:collapse; }
|
|
td,th { padding:.35rem .5rem; text-align:left; border-bottom:1px solid var(--line); vertical-align:top; }
|
|
th { color:var(--dim); font-weight:500; font-size:.8rem; }
|
|
.pill { display:inline-block; padding:.05rem .5rem; border-radius:99px; font-size:.75rem; border:1px solid var(--line); }
|
|
.pill.done,.pill.idle,.pill.active { color:var(--ok); border-color:var(--ok); }
|
|
.pill.in_progress,.pill.working { color:var(--run); border-color:var(--run); }
|
|
.pill.blocked { color:var(--bad); border-color:var(--bad); }
|
|
.pill.ready,.pill.backlog { color:var(--warn); border-color:var(--warn); }
|
|
.feed { max-height:32rem; overflow-y:auto; font-size:.8rem; }
|
|
.feed .ev { padding:.3rem 0; border-bottom:1px solid var(--line); }
|
|
.feed .t { color:var(--dim); margin-right:.5rem; }
|
|
.feed .type { color:var(--run); margin-right:.5rem; }
|
|
button { background:var(--card); color:var(--text); border:1px solid var(--line); border-radius:6px;
|
|
padding:.4rem .9rem; cursor:pointer; font-size:.85rem; }
|
|
button:hover { border-color:var(--dim); }
|
|
textarea { width:100%; background:var(--bg); color:var(--text); border:1px solid var(--line);
|
|
border-radius:6px; padding:.5rem; font:inherit; resize:vertical; margin-bottom:.4rem; }
|
|
#pausebtn.paused { color:var(--bad); border-color:var(--bad); }
|
|
.muted { color:var(--dim); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>🏠 FarmHouse Holdings
|
|
<a href="/office" style="color:var(--run); font-size:.8rem; text-decoration:none;">🕹 office view</a>
|
|
<span id="health" class="pill">…</span>
|
|
<button id="pausebtn" onclick="togglePause()">…</button>
|
|
</h1>
|
|
<div id="companies"></div>
|
|
<script>
|
|
let paused = false;
|
|
|
|
async function j(url, opts) { const r = await fetch(url, opts); return r.json(); }
|
|
|
|
function pill(s) { return `<span class="pill ${s}">${s}</span>`; }
|
|
|
|
async function togglePause() {
|
|
await j(paused ? '/resume' : '/pause', {method:'POST'});
|
|
refresh();
|
|
}
|
|
|
|
async function answerItem(slug, id) {
|
|
const answer = window.prompt('Answer / unblock instruction for this task:');
|
|
if (!answer || !answer.trim()) return;
|
|
const r = await fetch(`/companies/${slug}/work-items/${id}/answer`, {method:'POST',
|
|
headers:{'Content-Type':'application/json'}, body: JSON.stringify({answer: answer.trim()})});
|
|
if (!r.ok) alert(`answer failed: ${(await r.json()).detail ?? r.status}`);
|
|
refresh();
|
|
}
|
|
|
|
async function sendDirective(ev, slug) {
|
|
ev.preventDefault();
|
|
const el = document.getElementById('dir-' + slug);
|
|
const message = el.value.trim();
|
|
if (!message) return false;
|
|
await j(`/companies/${slug}/directives`, {method:'POST',
|
|
headers:{'Content-Type':'application/json'}, body: JSON.stringify({message})});
|
|
el.value = '';
|
|
refresh();
|
|
return false;
|
|
}
|
|
|
|
async function refresh() {
|
|
// don't clobber a directive mid-typing
|
|
if (document.activeElement && document.activeElement.tagName === 'TEXTAREA') return;
|
|
try {
|
|
const h = await j('/health');
|
|
paused = h.paused;
|
|
document.getElementById('health').textContent = h.status;
|
|
document.getElementById('health').className = 'pill ' + (h.status === 'ok' ? 'done' : 'blocked');
|
|
const b = document.getElementById('pausebtn');
|
|
b.textContent = paused ? '▶ RESUME (paused)' : '⏸ PAUSE ALL';
|
|
b.className = paused ? 'paused' : '';
|
|
|
|
const companies = await j('/companies');
|
|
let html = '';
|
|
for (const c of companies) {
|
|
if (c.lifecycle_state === 'archived') continue;
|
|
const [detail, items, events] = await Promise.all([
|
|
j(`/companies/${c.slug}`), j(`/companies/${c.slug}/work-items`),
|
|
j(`/companies/${c.slug}/events?limit=40`)]);
|
|
const emps = detail.employees.map(e =>
|
|
`<tr><td>${e.name}</td><td class="muted">${e.title}</td><td>${pill(e.status)}</td></tr>`).join('');
|
|
const rows = items.filter(w => w.type !== 'objective').map(w => {
|
|
const answer = w.status === 'blocked'
|
|
? ` <button onclick="answerItem('${c.slug}','${w.id}')">answer</button>` : '';
|
|
return `<tr><td>${w.title}</td><td class="muted">${w.owner_name ?? ''}</td><td>${pill(w.status)}${answer}</td></tr>`;
|
|
}).join('');
|
|
const objs = items.filter(w => w.type === 'objective').map(w =>
|
|
`<tr><td>${w.title}</td><td>${pill(w.status)}</td></tr>`).join('');
|
|
const feed = events.map(e => {
|
|
const t = new Date(e.created_at).toLocaleTimeString();
|
|
const p = e.payload ?? {};
|
|
const txt = p.summary ?? p.reason ?? p.title ?? p.question ?? p.name ?? '';
|
|
return `<div class="ev"><span class="t">${t}</span><span class="type">${e.event_type}</span>${String(txt).slice(0,120)}</div>`;
|
|
}).join('');
|
|
const CEO_EVENTS = ['milestone.update', 'directive.received', 'clarification.requested'];
|
|
const ceoFeed = events.filter(e => CEO_EVENTS.includes(e.event_type)).map(e => {
|
|
const t = new Date(e.created_at).toLocaleTimeString();
|
|
const p = e.payload ?? {};
|
|
const txt = p.summary ?? p.message ?? p.question ?? '';
|
|
const who = e.event_type === 'directive.received' ? '🧑💼 you' : '👔 CEO';
|
|
return `<div class="ev"><span class="t">${t}</span><span class="type">${who}</span>${String(txt).slice(0,300)}</div>`;
|
|
}).join('');
|
|
html += `<div class="card"><h1>${c.name} <span class="pill ${c.lifecycle_state}">${c.lifecycle_state}</span></h1>
|
|
<div class="grid">
|
|
<div>
|
|
<h2>Employees</h2><table>${emps}</table>
|
|
<h2>Objectives</h2><table>${objs || '<tr><td class="muted">none</td></tr>'}</table>
|
|
<h2>Work items</h2><table>${rows || '<tr><td class="muted">none</td></tr>'}</table>
|
|
</div>
|
|
<div>
|
|
<h2>Shareholder line</h2>
|
|
<form onsubmit="return sendDirective(event, '${c.slug}')">
|
|
<textarea id="dir-${c.slug}" rows="2" placeholder="Directive to ${c.name} leadership…"></textarea>
|
|
<button type="submit">Send directive</button>
|
|
</form>
|
|
<h2>CEO updates</h2><div class="feed">${ceoFeed || '<div class="muted">no updates yet</div>'}</div>
|
|
<h2>Event feed</h2><div class="feed">${feed}</div>
|
|
</div>
|
|
</div></div>`;
|
|
}
|
|
document.getElementById('companies').innerHTML = html || '<p class="muted">No companies yet.</p>';
|
|
} catch (e) {
|
|
document.getElementById('health').textContent = 'engine unreachable';
|
|
document.getElementById('health').className = 'pill blocked';
|
|
}
|
|
}
|
|
refresh();
|
|
setInterval(refresh, 5000);
|
|
</script>
|
|
</body>
|
|
</html>
|