Changeset 0.30.2 (#201)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
@@ -296,6 +296,66 @@ const Switchboard = {
|
||||
};
|
||||
};
|
||||
|
||||
// ── Workflow ─────────────────────────────────────────
|
||||
// v0.30.2: Workflow stage surface management.
|
||||
|
||||
/**
|
||||
* Mount a workflow stage surface into a container.
|
||||
* Delegates to WorkflowSurfaces registry.
|
||||
*
|
||||
* @param {HTMLElement} container — mount target
|
||||
* @param {object} opts — { channelId, stageMode, surfacePkgId?, formTemplate?, ... }
|
||||
* @returns {object|null} — mounted surface instance
|
||||
*/
|
||||
sw.workflow = function (container, opts) {
|
||||
if (typeof WorkflowSurfaces === 'undefined') {
|
||||
console.error('[Switchboard] WorkflowSurfaces not available');
|
||||
return null;
|
||||
}
|
||||
const _opts = opts || {};
|
||||
return WorkflowSurfaces.mount(container, _opts.stageMode || 'chat_only', _opts);
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a custom stage surface.
|
||||
* @param {string} name — surface identifier
|
||||
* @param {Function} factory — (container, ctx) => { mount(), unmount() }
|
||||
*/
|
||||
sw.workflow.registerSurface = function (name, factory) {
|
||||
if (typeof WorkflowSurfaces !== 'undefined') {
|
||||
WorkflowSurfaces.register(name, factory);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get workflow instance context (status, stage data, etc.).
|
||||
* @param {string} channelId
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
sw.workflow.getContext = async function (channelId) {
|
||||
return sw.api.get('/api/v1/channels/' + channelId + '/workflow/status');
|
||||
};
|
||||
|
||||
/**
|
||||
* Advance a workflow stage.
|
||||
* @param {string} channelId
|
||||
* @param {object} [data] — stage data to merge
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
sw.workflow.advance = async function (channelId, data) {
|
||||
return sw.api.post('/api/v1/channels/' + channelId + '/workflow/advance', { data: data || {} });
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject a workflow stage (go back).
|
||||
* @param {string} channelId
|
||||
* @param {string} reason
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
sw.workflow.reject = async function (channelId, reason) {
|
||||
return sw.api.post('/api/v1/channels/' + channelId + '/workflow/reject', { reason: reason });
|
||||
};
|
||||
|
||||
// ── Pipe/Filter Pipeline ─────────────────────────────
|
||||
// CS1: registration + introspection. CS2: execution engine
|
||||
// wired into chat.js, ui-core.js, ui-format.js.
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
'<label class="toggle-label"><input type="checkbox" id="wfEditActive"><span class="toggle-track"></span><span>Active</span></label>' +
|
||||
'<button class="btn-small btn-primary" id="wfSaveBtn">Save Changes</button>' +
|
||||
'<button class="btn-small" id="wfPublishBtn">Publish</button>' +
|
||||
'<button class="btn-small" id="wfExportBtn">Export .pkg</button>' +
|
||||
'<button class="btn-small btn-danger" id="wfDeleteBtn" style="margin-left:auto">Delete</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
@@ -74,6 +75,7 @@
|
||||
'<option value="chat_only">Chat Only</option>' +
|
||||
'<option value="form_only">Form Only (no AI)</option>' +
|
||||
'<option value="form_chat">Form + Chat</option>' +
|
||||
'<option value="review">Review (Human)</option>' +
|
||||
'</select></div>' +
|
||||
'</div>' +
|
||||
|
||||
@@ -167,7 +169,10 @@
|
||||
|
||||
let html = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">' +
|
||||
'<span class="text-muted">' + wfs.length + ' workflow(s)</span>' +
|
||||
'<button class="btn-small btn-primary" data-action="_wfCreate">+ New</button></div>';
|
||||
'<div style="display:flex;gap:6px">' +
|
||||
'<button class="btn-small" data-action="_wfImportPkg">Import .pkg</button>' +
|
||||
'<button class="btn-small btn-primary" data-action="_wfCreate">+ New</button>' +
|
||||
'</div></div>';
|
||||
html += '<table class="admin-table"><thead><tr>' +
|
||||
'<th>Name</th><th>Slug</th><th>Entry</th><th>Active</th><th>Version</th>' +
|
||||
'</tr></thead><tbody>';
|
||||
@@ -262,8 +267,12 @@
|
||||
personaLabel = p ? '<span class="badge">' + esc((p.icon ? p.icon + ' ' : '') + p.name) + '</span>' :
|
||||
'<span class="badge">persona</span>';
|
||||
}
|
||||
var modeLabel = s.stage_mode && s.stage_mode !== 'chat_only'
|
||||
? '<span class="badge badge-accent">' + esc(s.stage_mode.replace('_', ' ')) + '</span>' : '';
|
||||
var modeLabel = '';
|
||||
if (s.stage_mode === 'review') {
|
||||
modeLabel = '<span class="badge badge-warn">review</span>';
|
||||
} else if (s.stage_mode && s.stage_mode !== 'chat_only') {
|
||||
modeLabel = '<span class="badge badge-accent">' + esc(s.stage_mode.replace('_', ' ')) + '</span>';
|
||||
}
|
||||
html += '<div class="wf-stage-row" draggable="true" data-id="' + s.id + '" data-idx="' + i + '">' +
|
||||
'<span class="wf-stage-grip">⠿</span>' +
|
||||
'<span class="wf-stage-ord">' + i + '</span>' +
|
||||
@@ -380,6 +389,11 @@
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('wfExportBtn').onclick = function() {
|
||||
if (!_currentWf) return;
|
||||
API.exportWorkflowPkg(_currentWf.id);
|
||||
};
|
||||
|
||||
document.getElementById('wfDeleteBtn').onclick = async function() {
|
||||
if (!_currentWf) return;
|
||||
if (!confirm('Delete workflow "' + _currentWf.name + '"? This cannot be undone.')) return;
|
||||
@@ -504,6 +518,36 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── Import .pkg ──────────────────────────
|
||||
|
||||
sb.register('_wfImportPkg', function() {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.pkg,.zip';
|
||||
input.onchange = async function() {
|
||||
if (!input.files || !input.files[0]) return;
|
||||
var formData = new FormData();
|
||||
formData.append('file', input.files[0]);
|
||||
try {
|
||||
var resp = await fetch((window.__BASE__ || '') + '/api/v1/admin/packages/install', {
|
||||
method: 'POST',
|
||||
headers: API._authHeaders ? { 'Authorization': API._authHeaders()['Authorization'] } : {},
|
||||
body: formData,
|
||||
});
|
||||
var result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
UI.toast('Import failed: ' + (result.error || 'unknown'), 'error');
|
||||
return;
|
||||
}
|
||||
UI.toast('Workflow imported: ' + (result.title || result.id), 'success');
|
||||
_loadAdminWorkflows();
|
||||
} catch (e) {
|
||||
UI.toast('Import error: ' + e.message, 'error');
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
|
||||
sb.register('_wfDeleteStage', async function(stageId) {
|
||||
if (!_currentWf) return;
|
||||
if (!confirm('Delete this stage?')) return;
|
||||
|
||||
@@ -88,6 +88,18 @@
|
||||
return this._post(`/api/v1/w/${channelId}/form-submit`, data);
|
||||
};
|
||||
|
||||
// ── Workflow Packages (v0.30.2) ─────────
|
||||
|
||||
API.exportWorkflowPkg = function(workflowId) {
|
||||
// Trigger download via hidden link (binary response)
|
||||
const a = document.createElement('a');
|
||||
a.href = (window.__BASE__ || '') + `/api/v1/admin/workflows/${workflowId}/export`;
|
||||
a.download = '';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
// ── Assignments ─────────────────────────
|
||||
|
||||
API.listMyAssignments = function() {
|
||||
|
||||
481
src/js/workflow-surfaces.js
Normal file
481
src/js/workflow-surfaces.js
Normal file
@@ -0,0 +1,481 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — Workflow Surfaces
|
||||
// ==========================================
|
||||
// v0.30.2: Stage surface registry + built-in surface implementations.
|
||||
// Surfaces are composable UI components that render workflow stages.
|
||||
//
|
||||
// Built-in surfaces: form, chat, review
|
||||
// Custom surfaces can be registered via WorkflowSurfaces.register().
|
||||
//
|
||||
// Uses createComponentRegistry + componentMixin pattern from ui-primitives.js.
|
||||
// ==========================================
|
||||
|
||||
'use strict';
|
||||
|
||||
const WorkflowSurfaces = {
|
||||
|
||||
...createComponentRegistry('WorkflowSurface'),
|
||||
|
||||
_types: new Map(),
|
||||
|
||||
/**
|
||||
* Register a stage surface type.
|
||||
* @param {string} name — surface identifier (e.g. 'form', 'chat', 'review')
|
||||
* @param {Function} factory — (container, ctx) => surface instance with { mount(), unmount() }
|
||||
*/
|
||||
register(name, factory) {
|
||||
this._types.set(name, factory);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a registered surface factory.
|
||||
* @param {string} name
|
||||
* @returns {Function|null}
|
||||
*/
|
||||
get(name) {
|
||||
return this._types.get(name) || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Mount the appropriate surface for a stage mode.
|
||||
* @param {HTMLElement} container — mount target
|
||||
* @param {string} stageMode — chat_only | form_only | form_chat | review
|
||||
* @param {object} ctx — { channelId, formTemplate, sessionId, basePath, sessionName, ... }
|
||||
* @returns {object|null} — mounted surface instance
|
||||
*/
|
||||
mount(container, stageMode, ctx) {
|
||||
if (!container) return null;
|
||||
|
||||
// Map stage_mode to surface type(s)
|
||||
switch (stageMode) {
|
||||
case 'form_only': {
|
||||
const factory = this._types.get('form');
|
||||
if (!factory) return null;
|
||||
const surface = factory(container, ctx);
|
||||
surface.mount();
|
||||
return surface;
|
||||
}
|
||||
case 'chat_only': {
|
||||
const factory = this._types.get('chat');
|
||||
if (!factory) return null;
|
||||
const surface = factory(container, ctx);
|
||||
surface.mount();
|
||||
return surface;
|
||||
}
|
||||
case 'form_chat': {
|
||||
const formFactory = this._types.get('form');
|
||||
const chatFactory = this._types.get('chat');
|
||||
if (!formFactory || !chatFactory) return null;
|
||||
|
||||
// Build split layout
|
||||
container.innerHTML = '';
|
||||
const splitWrap = document.createElement('div');
|
||||
splitWrap.className = 'wf-body-split';
|
||||
splitWrap.style.cssText = 'display:flex;flex:1;overflow:hidden;height:100%';
|
||||
|
||||
const formCol = document.createElement('div');
|
||||
formCol.className = 'wf-form';
|
||||
formCol.style.cssText = 'flex:1;border-right:1px solid var(--border);overflow-y:auto;padding:24px';
|
||||
|
||||
const chatCol = document.createElement('div');
|
||||
chatCol.className = 'wf-chat-col';
|
||||
chatCol.style.cssText = 'flex:1;display:flex;flex-direction:column';
|
||||
|
||||
splitWrap.appendChild(formCol);
|
||||
splitWrap.appendChild(chatCol);
|
||||
container.appendChild(splitWrap);
|
||||
|
||||
const formSurface = formFactory(formCol, ctx);
|
||||
const chatSurface = chatFactory(chatCol, ctx);
|
||||
formSurface.mount();
|
||||
chatSurface.mount();
|
||||
|
||||
return {
|
||||
mount() {},
|
||||
unmount() {
|
||||
formSurface.unmount();
|
||||
chatSurface.unmount();
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'review': {
|
||||
const factory = this._types.get('review');
|
||||
if (!factory) return null;
|
||||
const surface = factory(container, ctx);
|
||||
surface.mount();
|
||||
return surface;
|
||||
}
|
||||
default: {
|
||||
// Try custom surface
|
||||
const factory = this._types.get(stageMode);
|
||||
if (factory) {
|
||||
const surface = factory(container, ctx);
|
||||
surface.mount();
|
||||
return surface;
|
||||
}
|
||||
console.warn('[WorkflowSurfaces] Unknown stage mode:', stageMode);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// ── Built-in: Form Surface ──────────────────────
|
||||
|
||||
WorkflowSurfaces.register('form', function FormSurface(container, ctx) {
|
||||
const tpl = ctx.formTemplate;
|
||||
|
||||
function renderField(f) {
|
||||
var req = f.required ? '<span class="required">*</span>' : '';
|
||||
var ph = f.placeholder ? ' placeholder="' + _escAttr(f.placeholder) + '"' : '';
|
||||
var inner = '';
|
||||
|
||||
switch (f.type) {
|
||||
case 'text': case 'email': case 'date':
|
||||
inner = '<input type="' + f.type + '" id="ff_' + f.key + '"' + ph + '>';
|
||||
break;
|
||||
case 'number':
|
||||
var attrs = ph;
|
||||
if (f.validation) {
|
||||
if (f.validation.min != null) attrs += ' min="' + f.validation.min + '"';
|
||||
if (f.validation.max != null) attrs += ' max="' + f.validation.max + '"';
|
||||
if (f.validation.step != null) attrs += ' step="' + f.validation.step + '"';
|
||||
}
|
||||
inner = '<input type="number" id="ff_' + f.key + '"' + attrs + '>';
|
||||
break;
|
||||
case 'textarea':
|
||||
inner = '<textarea id="ff_' + f.key + '"' + ph + ' rows="4"></textarea>';
|
||||
break;
|
||||
case 'select':
|
||||
var opts = '<option value="">— Select —</option>';
|
||||
if (f.options) {
|
||||
for (var j = 0; j < f.options.length; j++) {
|
||||
opts += '<option value="' + _escAttr(f.options[j].value) + '">' + _escHtml(f.options[j].label) + '</option>';
|
||||
}
|
||||
}
|
||||
inner = '<select id="ff_' + f.key + '">' + opts + '</select>';
|
||||
break;
|
||||
case 'checkbox':
|
||||
return '<div class="wf-form-field" data-key="' + f.key + '">' +
|
||||
'<div class="wf-form-check">' +
|
||||
'<input type="checkbox" id="ff_' + f.key + '">' +
|
||||
'<label for="ff_' + f.key + '">' + _escHtml(f.label) + req + '</label>' +
|
||||
'</div>' +
|
||||
'<div class="field-error" id="err_' + f.key + '"></div>' +
|
||||
'</div>';
|
||||
case 'file':
|
||||
var accept = (f.validation && f.validation.accept) ? ' accept="' + _escAttr(f.validation.accept) + '"' : '';
|
||||
inner = '<input type="file" id="ff_' + f.key + '"' + accept + '>';
|
||||
break;
|
||||
default:
|
||||
inner = '<input type="text" id="ff_' + f.key + '"' + ph + '>';
|
||||
}
|
||||
|
||||
return '<div class="wf-form-field" data-key="' + f.key + '">' +
|
||||
'<label for="ff_' + f.key + '">' + _escHtml(f.label) + req + '</label>' +
|
||||
inner +
|
||||
'<div class="field-error" id="err_' + f.key + '"></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (!tpl || !tpl.fields) return;
|
||||
var btn = container.querySelector('#formSubmitBtn');
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
container.querySelectorAll('.wf-form-field').forEach(el => el.classList.remove('has-error'));
|
||||
container.querySelectorAll('.field-error').forEach(el => { el.textContent = ''; });
|
||||
|
||||
var data = {};
|
||||
for (var i = 0; i < tpl.fields.length; i++) {
|
||||
var f = tpl.fields[i];
|
||||
var el = container.querySelector('#ff_' + f.key);
|
||||
if (!el) continue;
|
||||
if (f.type === 'checkbox') data[f.key] = el.checked;
|
||||
else if (f.type === 'number') data[f.key] = el.value !== '' ? parseFloat(el.value) : null;
|
||||
else data[f.key] = el.value;
|
||||
}
|
||||
|
||||
try {
|
||||
var basePath = ctx.basePath || '';
|
||||
var resp = await fetch(basePath + '/api/v1/w/' + ctx.channelId + '/form-submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: data }),
|
||||
});
|
||||
var result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
if (result.errors) {
|
||||
for (var i = 0; i < result.errors.length; i++) {
|
||||
var e = result.errors[i];
|
||||
var errEl = container.querySelector('#err_' + e.key);
|
||||
if (errEl) errEl.textContent = e.message;
|
||||
var fieldEl = container.querySelector('.wf-form-field[data-key="' + e.key + '"]');
|
||||
if (fieldEl) fieldEl.classList.add('has-error');
|
||||
}
|
||||
} else if (result.error) {
|
||||
alert(result.error);
|
||||
}
|
||||
if (btn) btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'advanced' || result.status === 'completed') {
|
||||
container.innerHTML = '<div class="wf-form-success"><h3>Submitted</h3><p>' +
|
||||
_escHtml(result.status === 'completed' ? 'Thank you! Your submission is complete.' : 'Submitted! Moving to the next step...') +
|
||||
'</p></div>';
|
||||
if (result.status === 'advanced') {
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
}
|
||||
} else {
|
||||
container.innerHTML = '<div class="wf-form-success"><h3>Submitted</h3><p>Your response has been submitted.</p></div>';
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Network error: ' + e.message);
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mount() {
|
||||
if (!tpl || !tpl.fields || tpl.fields.length === 0) {
|
||||
container.innerHTML = '<div class="wf-form-success"><p>No form fields configured for this stage.</p></div>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
for (var i = 0; i < tpl.fields.length; i++) {
|
||||
html += renderField(tpl.fields[i]);
|
||||
}
|
||||
html += '<div class="wf-form-submit"><button id="formSubmitBtn">Submit</button></div>';
|
||||
container.innerHTML = html;
|
||||
container.querySelector('#formSubmitBtn').addEventListener('click', submitForm);
|
||||
},
|
||||
unmount() {
|
||||
container.innerHTML = '';
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// ── Built-in: Chat Surface ──────────────────────
|
||||
|
||||
WorkflowSurfaces.register('chat', function ChatSurface(container, ctx) {
|
||||
var chatEl, inputEl, sendBtn, sending = false;
|
||||
|
||||
function addMessage(role, content, name) {
|
||||
if (!chatEl) return;
|
||||
var div = document.createElement('div');
|
||||
div.className = 'message ' + role;
|
||||
var meta = name ? '<div class="meta">' + _escHtml(name) + '</div>' : '';
|
||||
div.innerHTML = meta + '<div class="content">' + _escHtml(content) + '</div>';
|
||||
chatEl.appendChild(div);
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!inputEl || !sendBtn) return;
|
||||
var text = inputEl.value.trim();
|
||||
if (!text || sending) return;
|
||||
sending = true;
|
||||
sendBtn.disabled = true;
|
||||
inputEl.value = '';
|
||||
|
||||
addMessage('user', text, ctx.sessionName || 'You');
|
||||
|
||||
try {
|
||||
var basePath = ctx.basePath || '';
|
||||
var resp = await fetch(basePath + '/api/v1/w/' + ctx.channelId + '/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channel_id: ctx.channelId, content: text, stream: false }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
var data = await resp.json();
|
||||
if (data.content) {
|
||||
addMessage('assistant', data.content, data.persona_name || 'Assistant');
|
||||
}
|
||||
} else {
|
||||
var err = await resp.json().catch(() => ({}));
|
||||
addMessage('assistant', 'Error: ' + (err.error || resp.statusText));
|
||||
}
|
||||
} catch(e) {
|
||||
addMessage('assistant', 'Network error: ' + e.message);
|
||||
}
|
||||
|
||||
sending = false;
|
||||
sendBtn.disabled = false;
|
||||
inputEl.focus();
|
||||
}
|
||||
|
||||
return {
|
||||
mount() {
|
||||
chatEl = document.createElement('div');
|
||||
chatEl.className = 'wf-chat';
|
||||
chatEl.style.cssText = 'flex:1;overflow-y:auto;padding:16px 24px';
|
||||
|
||||
var inputWrap = document.createElement('div');
|
||||
inputWrap.className = 'wf-input';
|
||||
|
||||
inputEl = document.createElement('textarea');
|
||||
inputEl.placeholder = 'Type a message\u2026';
|
||||
inputEl.rows = 1;
|
||||
inputEl.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||||
});
|
||||
inputEl.addEventListener('input', function() {
|
||||
this.style.height = 'auto';
|
||||
this.style.height = Math.min(this.scrollHeight, 160) + 'px';
|
||||
});
|
||||
|
||||
sendBtn = document.createElement('button');
|
||||
sendBtn.textContent = 'Send';
|
||||
sendBtn.addEventListener('click', sendMessage);
|
||||
|
||||
inputWrap.appendChild(inputEl);
|
||||
inputWrap.appendChild(sendBtn);
|
||||
container.appendChild(chatEl);
|
||||
container.appendChild(inputWrap);
|
||||
},
|
||||
unmount() {
|
||||
container.innerHTML = '';
|
||||
chatEl = inputEl = sendBtn = null;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// ── Built-in: Review Surface ────────────────────
|
||||
|
||||
WorkflowSurfaces.register('review', function ReviewSurface(container, ctx) {
|
||||
var mounted = false;
|
||||
|
||||
async function loadReviewData() {
|
||||
var basePath = ctx.basePath || '';
|
||||
var html = '<div style="padding:24px;max-width:640px;margin:0 auto">';
|
||||
html += '<h3 style="margin-bottom:16px">Review Stage</h3>';
|
||||
|
||||
try {
|
||||
// Load workflow status to get stage data
|
||||
var statusResp = await fetch(basePath + '/api/v1/channels/' + ctx.channelId + '/workflow/status', {
|
||||
headers: ctx.authHeaders ? ctx.authHeaders() : {},
|
||||
});
|
||||
|
||||
if (statusResp.ok) {
|
||||
var status = await statusResp.json();
|
||||
|
||||
html += '<div style="margin-bottom:16px">';
|
||||
html += '<h4 style="margin-bottom:8px;font-size:14px;color:var(--text-2)">Collected Data</h4>';
|
||||
|
||||
if (status.stage_data && typeof status.stage_data === 'object') {
|
||||
html += '<table style="width:100%;border-collapse:collapse">';
|
||||
for (var key in status.stage_data) {
|
||||
if (!status.stage_data.hasOwnProperty(key)) continue;
|
||||
html += '<tr style="border-bottom:1px solid var(--border)">';
|
||||
html += '<td style="padding:8px;font-weight:600;font-size:13px;width:30%">' + _escHtml(key) + '</td>';
|
||||
html += '<td style="padding:8px;font-size:13px">' + _escHtml(String(status.stage_data[key])) + '</td>';
|
||||
html += '</tr>';
|
||||
}
|
||||
html += '</table>';
|
||||
} else {
|
||||
html += '<p style="color:var(--text-3)">No data collected yet.</p>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
html += '<div style="margin-bottom:16px">';
|
||||
html += '<span style="font-size:12px;color:var(--text-3)">Status: ' + _escHtml(status.status) + '</span>';
|
||||
html += '</div>';
|
||||
}
|
||||
} catch(e) {
|
||||
html += '<p style="color:var(--danger)">Failed to load review data.</p>';
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
html += '<div style="display:flex;gap:8px;margin-top:24px">';
|
||||
html += '<button id="reviewAdvanceBtn" style="background:var(--accent);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer">Approve & Advance</button>';
|
||||
html += '<button id="reviewRejectBtn" style="background:var(--danger,#e74c3c);color:#fff;border:none;border-radius:8px;padding:10px 24px;font-weight:600;cursor:pointer">Reject</button>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
var advBtn = container.querySelector('#reviewAdvanceBtn');
|
||||
var rejBtn = container.querySelector('#reviewRejectBtn');
|
||||
|
||||
if (advBtn) {
|
||||
advBtn.addEventListener('click', async function() {
|
||||
advBtn.disabled = true;
|
||||
try {
|
||||
var resp = await fetch(basePath + '/api/v1/channels/' + ctx.channelId + '/workflow/advance', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, ctx.authHeaders ? ctx.authHeaders() : {}),
|
||||
body: JSON.stringify({ data: {} }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center"><h3>Approved</h3><p style="color:var(--text-2)">Stage advanced successfully.</p></div>';
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
} else {
|
||||
var err = await resp.json().catch(() => ({}));
|
||||
alert('Failed: ' + (err.error || 'unknown error'));
|
||||
advBtn.disabled = false;
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Network error: ' + e.message);
|
||||
advBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (rejBtn) {
|
||||
rejBtn.addEventListener('click', async function() {
|
||||
var reason = prompt('Rejection reason:');
|
||||
if (!reason) return;
|
||||
rejBtn.disabled = true;
|
||||
try {
|
||||
var resp = await fetch(basePath + '/api/v1/channels/' + ctx.channelId + '/workflow/reject', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, ctx.authHeaders ? ctx.authHeaders() : {}),
|
||||
body: JSON.stringify({ reason: reason }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center"><h3>Rejected</h3><p style="color:var(--text-2)">Stage sent back for revision.</p></div>';
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
} else {
|
||||
var err = await resp.json().catch(() => ({}));
|
||||
alert('Failed: ' + (err.error || 'unknown error'));
|
||||
rejBtn.disabled = false;
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Network error: ' + e.message);
|
||||
rejBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mount() {
|
||||
if (mounted) return;
|
||||
mounted = true;
|
||||
container.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text-3)">Loading review data\u2026</div>';
|
||||
loadReviewData();
|
||||
},
|
||||
unmount() {
|
||||
container.innerHTML = '';
|
||||
mounted = false;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// ── Helpers ─────────────────────────────────────
|
||||
|
||||
function _escHtml(s) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function _escAttr(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
Reference in New Issue
Block a user