changes
This commit is contained in:
@@ -4,6 +4,48 @@ import { Component, useState, useRef, onWillStart, onMounted, onPatched } from "
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { FusionApprovalCard } from "./approval_card";
|
||||
|
||||
/**
|
||||
* Parse a fusion-table JSON block from AI response.
|
||||
* Returns {json, placeholder} or null if not a fusion-table block.
|
||||
*/
|
||||
function parseFusionTableBlock(text) {
|
||||
// Match ```fusion-table ... ``` blocks
|
||||
const regex = /```fusion-table\s*\n([\s\S]*?)```/g;
|
||||
const tables = [];
|
||||
let lastIndex = 0;
|
||||
const parts = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// Add text before the block
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: "md", content: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
// Parse the JSON
|
||||
try {
|
||||
const data = JSON.parse(match[1].trim());
|
||||
const tableIdx = tables.length;
|
||||
tables.push(data);
|
||||
parts.push({ type: "table", idx: tableIdx });
|
||||
} catch (e) {
|
||||
// If JSON parse fails, treat as regular code block
|
||||
parts.push({ type: "md", content: match[0] });
|
||||
}
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Remaining text after last block
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ type: "md", content: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
if (tables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return { parts, tables };
|
||||
}
|
||||
|
||||
|
||||
function mdToHtml(text) {
|
||||
if (!text) return "";
|
||||
|
||||
@@ -150,6 +192,8 @@ export class FusionChatPanel extends Component {
|
||||
setup() {
|
||||
this.inputRef = useRef("chatInput");
|
||||
this.messagesRef = useRef("messages");
|
||||
// Track parsed table data per message index for interactive tables
|
||||
this._parsedTables = {};
|
||||
this.state = useState({
|
||||
messages: [],
|
||||
pendingApprovals: [],
|
||||
@@ -158,6 +202,11 @@ export class FusionChatPanel extends Component {
|
||||
loading: true,
|
||||
internalSessionId: null,
|
||||
sessionName: null,
|
||||
// Interactive tables extracted from AI messages, keyed by msg index
|
||||
interactiveTables: {},
|
||||
// Session history picker
|
||||
showSessionPicker: false,
|
||||
sessionList: [],
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
@@ -181,14 +230,240 @@ export class FusionChatPanel extends Component {
|
||||
const idx = parseInt(div.dataset.idx);
|
||||
const msg = this.state.messages[idx];
|
||||
if (msg && msg.role === "assistant" && msg.content) {
|
||||
const html = mdToHtml(msg.content);
|
||||
if (div.innerHTML !== html) {
|
||||
div.innerHTML = html;
|
||||
// Check for fusion-table blocks
|
||||
const parsed = parseFusionTableBlock(msg.content);
|
||||
if (parsed) {
|
||||
// Build HTML with placeholders for interactive tables
|
||||
let html = "";
|
||||
for (const part of parsed.parts) {
|
||||
if (part.type === "md") {
|
||||
html += mdToHtml(part.content);
|
||||
} else if (part.type === "table") {
|
||||
const tableKey = `${idx}_${part.idx}`;
|
||||
html += `<div class="fusion_table_mount" data-table-key="${tableKey}"></div>`;
|
||||
// Store table data for OWL mounting
|
||||
this._parsedTables[tableKey] = parsed.tables[part.idx];
|
||||
}
|
||||
}
|
||||
if (div.innerHTML !== html) {
|
||||
div.innerHTML = html;
|
||||
}
|
||||
// Mount OWL interactive table components into placeholders
|
||||
this._mountInteractiveTables(div);
|
||||
} else {
|
||||
const html = mdToHtml(msg.content);
|
||||
if (div.innerHTML !== html) {
|
||||
div.innerHTML = html;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_mountInteractiveTables(container) {
|
||||
const mounts = container.querySelectorAll(".fusion_table_mount[data-table-key]");
|
||||
for (const el of mounts) {
|
||||
const key = el.dataset.tableKey;
|
||||
if (el.dataset.mounted === "true") continue;
|
||||
const tableData = this._parsedTables[key];
|
||||
if (!tableData) continue;
|
||||
|
||||
el.dataset.mounted = "true";
|
||||
el.innerHTML = this._buildInteractiveTableHtml(tableData, key);
|
||||
this._wireTableEvents(el, tableData, key);
|
||||
}
|
||||
}
|
||||
|
||||
_badgeClass(action) {
|
||||
switch (action) {
|
||||
case "dismiss": return "bg-success-subtle text-success";
|
||||
case "flag": return "bg-warning-subtle text-warning";
|
||||
case "create_rule": return "bg-info-subtle text-info";
|
||||
default: return "bg-secondary-subtle text-secondary";
|
||||
}
|
||||
}
|
||||
|
||||
_badgeLabel(action) {
|
||||
switch (action) {
|
||||
case "dismiss": return "Dismiss";
|
||||
case "flag": return "Flag";
|
||||
case "create_rule": return "Create Rule";
|
||||
default: return action || "Review";
|
||||
}
|
||||
}
|
||||
|
||||
_esc(text) {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = text;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
_buildInteractiveTableHtml(tableData, key) {
|
||||
const cols = tableData.columns || [];
|
||||
const rows = tableData.rows || [];
|
||||
const isInteractive = tableData.mode === "interactive";
|
||||
const actions = tableData.actions || [];
|
||||
const title = tableData.title || "";
|
||||
|
||||
let h = '<div class="fusion_interactive_table my-2">';
|
||||
|
||||
// Title
|
||||
if (title) {
|
||||
h += `<div class="d-flex align-items-center mb-2">`;
|
||||
h += `<i class="fa fa-table me-2 text-muted"></i>`;
|
||||
h += `<strong>${this._esc(title)}</strong>`;
|
||||
h += `<span class="badge bg-secondary-subtle text-secondary ms-2">${rows.length} rows</span>`;
|
||||
h += `</div>`;
|
||||
}
|
||||
|
||||
// Table
|
||||
h += '<div class="table-responsive"><table class="table table-sm table-hover align-middle mb-0"><thead><tr>';
|
||||
if (isInteractive) {
|
||||
h += `<th class="fit-content px-2"><input type="checkbox" class="form-check-input" data-action="select-all"/></th>`;
|
||||
}
|
||||
for (const col of cols) {
|
||||
h += `<th class="px-2 py-1">${this._esc(col)}</th>`;
|
||||
}
|
||||
if (isInteractive) {
|
||||
h += `<th class="px-2 py-1 text-info">AI Recommendation</th>`;
|
||||
h += `<th class="px-2 py-1 text-warning" style="min-width:180px;">Your Input</th>`;
|
||||
}
|
||||
h += '</tr></thead><tbody>';
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
h += `<tr data-row-idx="${i}">`;
|
||||
if (isInteractive) {
|
||||
h += `<td class="fit-content px-2"><input type="checkbox" class="form-check-input" data-action="select-row" data-idx="${i}"/></td>`;
|
||||
}
|
||||
for (const cell of (row.cells || [])) {
|
||||
h += `<td class="px-2 py-1">${this._esc(String(cell))}</td>`;
|
||||
}
|
||||
if (isInteractive) {
|
||||
// Recommendation
|
||||
h += `<td class="px-2 py-1">`;
|
||||
if (row.recommendation) {
|
||||
const rc = row.recommendation;
|
||||
h += `<span class="badge me-1 ${this._badgeClass(rc.action)}">${this._badgeLabel(rc.action)}</span>`;
|
||||
h += `<small class="text-muted">${this._esc(rc.reason || "")}</small>`;
|
||||
}
|
||||
h += `</td>`;
|
||||
// User input
|
||||
h += `<td class="px-2 py-1"><input type="text" class="form-control form-control-sm fusion_row_note" data-idx="${i}" placeholder="Add your note..."/></td>`;
|
||||
}
|
||||
h += '</tr>';
|
||||
}
|
||||
h += '</tbody></table></div>';
|
||||
|
||||
// Action bar
|
||||
if (isInteractive) {
|
||||
h += '<div class="fusion_table_action_bar d-flex flex-wrap align-items-center gap-2 p-2 border-top">';
|
||||
h += '<small class="text-muted me-1 fusion_selected_count">0 selected</small>';
|
||||
h += `<button class="btn btn-success btn-sm" data-action="apply_recommendations" disabled><i class="fa fa-check me-1"></i>Apply Recommendations</button>`;
|
||||
if (actions.includes("flag")) {
|
||||
h += `<button class="btn btn-outline-warning btn-sm" data-action="flag" disabled><i class="fa fa-flag me-1"></i>Flag Selected</button>`;
|
||||
}
|
||||
if (actions.includes("create_rule")) {
|
||||
h += `<button class="btn btn-outline-info btn-sm" data-action="create_rule" disabled><i class="fa fa-plus me-1"></i>Create Rules</button>`;
|
||||
}
|
||||
if (actions.includes("dismiss")) {
|
||||
h += `<button class="btn btn-outline-secondary btn-sm" data-action="dismiss" disabled>Dismiss Selected</button>`;
|
||||
}
|
||||
h += '<div class="flex-grow-1"></div>';
|
||||
h += `<button class="btn btn-outline-primary btn-sm" data-action="submit_notes"><i class="fa fa-pencil me-1"></i>Submit All Notes to AI</button>`;
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
h += '</div>';
|
||||
return h;
|
||||
}
|
||||
|
||||
_wireTableEvents(container, tableData, key) {
|
||||
const rows = tableData.rows || [];
|
||||
|
||||
// Select all checkbox
|
||||
const selectAll = container.querySelector('[data-action="select-all"]');
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener("change", () => {
|
||||
const cbs = container.querySelectorAll('[data-action="select-row"]');
|
||||
for (const cb of cbs) cb.checked = selectAll.checked;
|
||||
this._updateTableActionBar(container);
|
||||
});
|
||||
}
|
||||
|
||||
// Individual row checkboxes
|
||||
const rowCbs = container.querySelectorAll('[data-action="select-row"]');
|
||||
for (const cb of rowCbs) {
|
||||
cb.addEventListener("change", () => this._updateTableActionBar(container));
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
const actionBtns = container.querySelectorAll('.fusion_table_action_bar button[data-action]');
|
||||
for (const btn of actionBtns) {
|
||||
btn.addEventListener("click", () => {
|
||||
const action = btn.dataset.action;
|
||||
const selectedRows = this._collectTableRows(container, tableData, action === "submit_notes");
|
||||
if (selectedRows.length === 0) return;
|
||||
this.onTableAction({
|
||||
action,
|
||||
source_tool: tableData.source_tool,
|
||||
rows: selectedRows,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_updateTableActionBar(container) {
|
||||
const cbs = container.querySelectorAll('[data-action="select-row"]:checked');
|
||||
const count = cbs.length;
|
||||
const countEl = container.querySelector('.fusion_selected_count');
|
||||
if (countEl) countEl.textContent = `${count} selected`;
|
||||
// Enable/disable action buttons
|
||||
const btns = container.querySelectorAll('.fusion_table_action_bar button[data-action]');
|
||||
for (const btn of btns) {
|
||||
if (btn.dataset.action === "submit_notes") continue; // always enabled
|
||||
btn.disabled = (count === 0);
|
||||
}
|
||||
}
|
||||
|
||||
_collectTableRows(container, tableData, allNotes) {
|
||||
const rows = tableData.rows || [];
|
||||
const result = [];
|
||||
|
||||
if (allNotes) {
|
||||
// Collect all rows that have a note
|
||||
const inputs = container.querySelectorAll('.fusion_row_note');
|
||||
for (const inp of inputs) {
|
||||
const idx = parseInt(inp.dataset.idx);
|
||||
const note = inp.value.trim();
|
||||
if (note && rows[idx]) {
|
||||
result.push({
|
||||
id: rows[idx].id,
|
||||
cells: rows[idx].cells,
|
||||
recommendation: rows[idx].recommendation,
|
||||
userNote: note,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Collect checked rows
|
||||
const cbs = container.querySelectorAll('[data-action="select-row"]:checked');
|
||||
for (const cb of cbs) {
|
||||
const idx = parseInt(cb.dataset.idx);
|
||||
if (rows[idx]) {
|
||||
const noteInput = container.querySelector(`.fusion_row_note[data-idx="${idx}"]`);
|
||||
result.push({
|
||||
id: rows[idx].id,
|
||||
cells: rows[idx].cells,
|
||||
recommendation: rows[idx].recommendation,
|
||||
userNote: noteInput ? noteInput.value.trim() : "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
get sessionId() {
|
||||
return this.state.internalSessionId || this.props.sessionId;
|
||||
}
|
||||
@@ -209,17 +484,87 @@ export class FusionChatPanel extends Component {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
async toggleSessionPicker() {
|
||||
if (this.state.showSessionPicker) {
|
||||
this.state.showSessionPicker = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await rpc("/fusion_accounting/session/list", { limit: 20 });
|
||||
this.state.sessionList = data.sessions || [];
|
||||
} catch (e) {
|
||||
console.error("Failed to load session list:", e);
|
||||
this.state.sessionList = [];
|
||||
}
|
||||
this.state.showSessionPicker = true;
|
||||
}
|
||||
|
||||
async loadSession(sessionId) {
|
||||
this.state.showSessionPicker = false;
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const data = await rpc("/fusion_accounting/session/history", { session_id: sessionId });
|
||||
if (data.messages) {
|
||||
this.state.internalSessionId = data.session_id;
|
||||
// Filter display messages same as session/latest
|
||||
const display = [];
|
||||
for (const msg of data.messages) {
|
||||
if (typeof msg.content === "string" && msg.content.trim()) {
|
||||
display.push(msg);
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const block of msg.content) {
|
||||
if (block && block.type === "text" && block.text && block.text.trim()) {
|
||||
display.push({ role: msg.role, content: block.text });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.state.messages = display;
|
||||
// Find session name from the list
|
||||
const found = this.state.sessionList.find(s => s.id === sessionId);
|
||||
this.state.sessionName = found ? found.name : `Session #${sessionId}`;
|
||||
this.state.pendingApprovals = [];
|
||||
this._parsedTables = {};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load session:", e);
|
||||
}
|
||||
this.state.loading = false;
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
formatSessionDate(isoDate) {
|
||||
if (!isoDate) return "";
|
||||
try {
|
||||
const d = new Date(isoDate);
|
||||
return d.toLocaleDateString("en-CA", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
} catch (e) {
|
||||
return isoDate;
|
||||
}
|
||||
}
|
||||
|
||||
async onNewChat() {
|
||||
// Close current session first — must succeed before creating new one
|
||||
if (this.sessionId) {
|
||||
try {
|
||||
await rpc("/fusion_accounting/session/close", { session_id: this.sessionId });
|
||||
} catch (e) { /* not critical */ }
|
||||
const closeResult = await rpc("/fusion_accounting/session/close", { session_id: this.sessionId });
|
||||
if (closeResult.error) {
|
||||
console.warn("Failed to close session:", closeResult.error);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Error closing session:", e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const session = await rpc("/fusion_accounting/session/create", {});
|
||||
this.state.internalSessionId = session.session_id;
|
||||
this.state.sessionName = session.name;
|
||||
this.state.messages = [];
|
||||
this.state.pendingApprovals = [];
|
||||
this._parsedTables = {};
|
||||
} catch (e) {
|
||||
console.error("Failed to create new session:", e);
|
||||
}
|
||||
const session = await rpc("/fusion_accounting/session/create", {});
|
||||
this.state.internalSessionId = session.session_id;
|
||||
this.state.sessionName = session.name;
|
||||
this.state.messages = [];
|
||||
this.state.pendingApprovals = [];
|
||||
}
|
||||
|
||||
async sendMessage() {
|
||||
@@ -258,6 +603,66 @@ export class FusionChatPanel extends Component {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle actions from interactive tables (Apply, Flag, Create Rule, Dismiss, Submit Notes).
|
||||
* Formats a structured message and sends it back through the chat.
|
||||
*/
|
||||
async onTableAction(payload) {
|
||||
const { action, source_tool, rows } = payload;
|
||||
const actionLabels = {
|
||||
apply_recommendations: "Apply Recommendations",
|
||||
flag: "Flag",
|
||||
create_rule: "Create Rules",
|
||||
dismiss: "Dismiss",
|
||||
submit_notes: "Submit Notes",
|
||||
};
|
||||
const label = actionLabels[action] || action;
|
||||
|
||||
// Build a structured message for the AI
|
||||
let parts = [`[TABLE_ACTION] source=${source_tool} action=${action}`];
|
||||
for (const row of rows) {
|
||||
const cellSummary = (row.cells || []).join(" | ");
|
||||
let line = `- Row #${row.id}: ${cellSummary}`;
|
||||
if (row.recommendation) {
|
||||
line += ` (AI suggested: ${row.recommendation.action} - ${row.recommendation.reason})`;
|
||||
}
|
||||
if (row.userNote) {
|
||||
line += ` [User note: ${row.userNote}]`;
|
||||
}
|
||||
parts.push(line);
|
||||
}
|
||||
|
||||
const message = parts.join("\n");
|
||||
|
||||
// Show user what we're sending
|
||||
this.state.messages.push({
|
||||
role: "user",
|
||||
content: `**${label}** on ${rows.length} row(s) from ${source_tool}`,
|
||||
});
|
||||
this.state.sending = true;
|
||||
this.scrollToBottom();
|
||||
|
||||
try {
|
||||
const result = await rpc("/fusion_accounting/chat", {
|
||||
session_id: this.sessionId,
|
||||
message: message,
|
||||
});
|
||||
if (result.text) {
|
||||
this.state.messages.push({ role: "assistant", content: result.text });
|
||||
}
|
||||
if (result.pending_approvals) {
|
||||
this.state.pendingApprovals = result.pending_approvals;
|
||||
}
|
||||
} catch (e) {
|
||||
this.state.messages.push({
|
||||
role: "assistant",
|
||||
content: `Error processing table action: ${e.message || "Something went wrong."}`,
|
||||
});
|
||||
}
|
||||
this.state.sending = false;
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
onKeyDown(ev) {
|
||||
if (ev.key === "Enter" && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
|
||||
@@ -3,16 +3,60 @@
|
||||
<t t-name="fusion_accounting.ChatPanel">
|
||||
<div class="fusion_chat_panel card h-100 d-flex flex-column">
|
||||
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
||||
<div>
|
||||
<div class="d-flex align-items-center">
|
||||
<h5 class="mb-0 d-inline"><i class="fa fa-comments-o me-2"/>Fusion AI</h5>
|
||||
<small class="text-muted ms-2" t-if="state.sessionName" t-esc="state.sessionName"/>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary btn-sm" t-on-click="onNewChat"
|
||||
title="Start a new conversation">
|
||||
<i class="fa fa-plus me-1"/>New Chat
|
||||
</button>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<!-- Session history button -->
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
t-on-click="toggleSessionPicker"
|
||||
title="Load previous session">
|
||||
<i class="fa fa-history"/>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" t-on-click="onNewChat"
|
||||
title="Start a new conversation">
|
||||
<i class="fa fa-plus me-1"/>New Chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Picker Dropdown -->
|
||||
<t t-if="state.showSessionPicker">
|
||||
<div class="fusion_session_picker border-bottom">
|
||||
<div class="p-2 bg-body-tertiary">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<small class="fw-semibold text-muted">Recent Sessions</small>
|
||||
<button class="btn-close btn-close-sm" t-on-click="toggleSessionPicker"/>
|
||||
</div>
|
||||
<t t-if="state.sessionList.length === 0">
|
||||
<p class="text-muted small mb-0">No previous sessions found.</p>
|
||||
</t>
|
||||
<div class="fusion_session_list overflow-auto" style="max-height: 200px;">
|
||||
<t t-foreach="state.sessionList" t-as="sess" t-key="sess.id">
|
||||
<div class="fusion_session_item d-flex justify-content-between align-items-center p-2 rounded cursor-pointer"
|
||||
t-att-class="sess.id === state.internalSessionId ? 'bg-primary-subtle' : ''"
|
||||
t-on-click="() => this.loadSession(sess.id)">
|
||||
<div>
|
||||
<div class="small fw-semibold" t-esc="sess.name"/>
|
||||
<div class="text-muted" style="font-size: 0.72rem;">
|
||||
<t t-esc="formatSessionDate(sess.date)"/>
|
||||
<span class="ms-2" t-if="sess.message_count">
|
||||
<t t-esc="sess.message_count"/> msgs
|
||||
</span>
|
||||
<span class="ms-1 badge"
|
||||
t-att-class="sess.state === 'active' ? 'bg-success-subtle text-success' : 'bg-secondary-subtle text-secondary'"
|
||||
t-esc="sess.state"/>
|
||||
</div>
|
||||
</div>
|
||||
<i class="fa fa-chevron-right text-muted" style="font-size: 0.7rem;"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Messages -->
|
||||
<div class="fusion_chat_messages flex-grow-1 overflow-auto p-3" t-ref="messages">
|
||||
<t t-if="state.loading">
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
|
||||
|
||||
export class FusionInteractiveTable extends Component {
|
||||
static template = "fusion_accounting.InteractiveTable";
|
||||
static props = ["tableData", "onTableAction"];
|
||||
|
||||
setup() {
|
||||
const rows = (this.props.tableData.rows || []).map((row) => ({
|
||||
...row,
|
||||
selected: false,
|
||||
userNote: "",
|
||||
}));
|
||||
this.state = useState({
|
||||
rows,
|
||||
selectAll: false,
|
||||
});
|
||||
}
|
||||
|
||||
get isInteractive() {
|
||||
return this.props.tableData.mode === "interactive";
|
||||
}
|
||||
|
||||
get columns() {
|
||||
return this.props.tableData.columns || [];
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.props.tableData.title || "";
|
||||
}
|
||||
|
||||
get actions() {
|
||||
return this.props.tableData.actions || [];
|
||||
}
|
||||
|
||||
get selectedCount() {
|
||||
return this.state.rows.filter((r) => r.selected).length;
|
||||
}
|
||||
|
||||
get hasAction() {
|
||||
return (action) => this.actions.includes(action);
|
||||
}
|
||||
|
||||
actionAvailable(action) {
|
||||
return this.actions.includes(action);
|
||||
}
|
||||
|
||||
recommendationClass(action) {
|
||||
switch (action) {
|
||||
case "dismiss":
|
||||
return "bg-success-subtle text-success";
|
||||
case "flag":
|
||||
return "bg-warning-subtle text-warning";
|
||||
case "create_rule":
|
||||
return "bg-info-subtle text-info";
|
||||
default:
|
||||
return "bg-secondary-subtle text-secondary";
|
||||
}
|
||||
}
|
||||
|
||||
recommendationLabel(action) {
|
||||
switch (action) {
|
||||
case "dismiss":
|
||||
return "Dismiss";
|
||||
case "flag":
|
||||
return "Flag";
|
||||
case "create_rule":
|
||||
return "Create Rule";
|
||||
default:
|
||||
return action || "Review";
|
||||
}
|
||||
}
|
||||
|
||||
onToggleSelectAll() {
|
||||
const newVal = !this.state.selectAll;
|
||||
this.state.selectAll = newVal;
|
||||
for (const row of this.state.rows) {
|
||||
row.selected = newVal;
|
||||
}
|
||||
}
|
||||
|
||||
onToggleRow(rowIndex) {
|
||||
this.state.rows[rowIndex].selected = !this.state.rows[rowIndex].selected;
|
||||
this.state.selectAll = this.state.rows.every((r) => r.selected);
|
||||
}
|
||||
|
||||
onNoteInput(rowIndex, ev) {
|
||||
this.state.rows[rowIndex].userNote = ev.target.value;
|
||||
}
|
||||
|
||||
_collectSelected() {
|
||||
return this.state.rows
|
||||
.filter((r) => r.selected)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
cells: r.cells,
|
||||
recommendation: r.recommendation,
|
||||
userNote: r.userNote,
|
||||
}));
|
||||
}
|
||||
|
||||
_collectAllNotes() {
|
||||
return this.state.rows
|
||||
.filter((r) => r.userNote.trim())
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
cells: r.cells,
|
||||
recommendation: r.recommendation,
|
||||
userNote: r.userNote,
|
||||
}));
|
||||
}
|
||||
|
||||
onApplyRecommendations() {
|
||||
const selected = this._collectSelected();
|
||||
if (!selected.length) return;
|
||||
this.props.onTableAction({
|
||||
action: "apply_recommendations",
|
||||
source_tool: this.props.tableData.source_tool,
|
||||
rows: selected,
|
||||
});
|
||||
}
|
||||
|
||||
onFlagSelected() {
|
||||
const selected = this._collectSelected();
|
||||
if (!selected.length) return;
|
||||
this.props.onTableAction({
|
||||
action: "flag",
|
||||
source_tool: this.props.tableData.source_tool,
|
||||
rows: selected,
|
||||
});
|
||||
}
|
||||
|
||||
onCreateRules() {
|
||||
const selected = this._collectSelected();
|
||||
if (!selected.length) return;
|
||||
this.props.onTableAction({
|
||||
action: "create_rule",
|
||||
source_tool: this.props.tableData.source_tool,
|
||||
rows: selected,
|
||||
});
|
||||
}
|
||||
|
||||
onDismissSelected() {
|
||||
const selected = this._collectSelected();
|
||||
if (!selected.length) return;
|
||||
this.props.onTableAction({
|
||||
action: "dismiss",
|
||||
source_tool: this.props.tableData.source_tool,
|
||||
rows: selected,
|
||||
});
|
||||
}
|
||||
|
||||
onSubmitNotes() {
|
||||
const noted = this._collectAllNotes();
|
||||
if (!noted.length) return;
|
||||
this.props.onTableAction({
|
||||
action: "submit_notes",
|
||||
source_tool: this.props.tableData.source_tool,
|
||||
rows: noted,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting.InteractiveTable">
|
||||
<div class="fusion_interactive_table my-2">
|
||||
<!-- Title -->
|
||||
<t t-if="title">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i class="fa fa-table me-2 text-muted"/>
|
||||
<strong t-esc="title"/>
|
||||
<span class="badge bg-secondary-subtle text-secondary ms-2"
|
||||
t-esc="state.rows.length + ' rows'"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<!-- Checkbox column (interactive only) -->
|
||||
<t t-if="isInteractive">
|
||||
<th class="fit-content px-2">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
t-att-checked="state.selectAll"
|
||||
t-on-change="onToggleSelectAll"/>
|
||||
</th>
|
||||
</t>
|
||||
<!-- Data columns -->
|
||||
<t t-foreach="columns" t-as="col" t-key="col_index">
|
||||
<th class="px-2 py-1" t-esc="col"/>
|
||||
</t>
|
||||
<!-- AI Recommendation column (interactive only) -->
|
||||
<t t-if="isInteractive">
|
||||
<th class="px-2 py-1 text-info">AI Recommendation</th>
|
||||
<th class="px-2 py-1 text-warning" style="min-width: 180px;">Your Input</th>
|
||||
</t>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="state.rows" t-as="row" t-key="row_index">
|
||||
<tr t-att-class="row.selected ? 'table-active' : ''">
|
||||
<!-- Checkbox -->
|
||||
<t t-if="isInteractive">
|
||||
<td class="fit-content px-2">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
t-att-checked="row.selected"
|
||||
t-on-change="() => this.onToggleRow(row_index)"/>
|
||||
</td>
|
||||
</t>
|
||||
<!-- Data cells -->
|
||||
<t t-foreach="row.cells" t-as="cell" t-key="cell_index">
|
||||
<td class="px-2 py-1" t-esc="cell"/>
|
||||
</t>
|
||||
<!-- AI Recommendation -->
|
||||
<t t-if="isInteractive">
|
||||
<td class="px-2 py-1">
|
||||
<t t-if="row.recommendation">
|
||||
<span t-att-class="'badge me-1 ' + recommendationClass(row.recommendation.action)"
|
||||
t-esc="recommendationLabel(row.recommendation.action)"/>
|
||||
<small class="text-muted" t-esc="row.recommendation.reason"/>
|
||||
</t>
|
||||
</td>
|
||||
<!-- User input -->
|
||||
<td class="px-2 py-1">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm fusion_row_note"
|
||||
placeholder="Add your note..."
|
||||
t-att-value="row.userNote"
|
||||
t-on-input="(ev) => this.onNoteInput(row_index, ev)"/>
|
||||
</td>
|
||||
</t>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Action Bar (interactive only) -->
|
||||
<t t-if="isInteractive">
|
||||
<div class="fusion_table_action_bar d-flex flex-wrap align-items-center gap-2 p-2 border-top">
|
||||
<small class="text-muted me-1">
|
||||
<t t-esc="selectedCount"/> selected
|
||||
</small>
|
||||
<button class="btn btn-success btn-sm"
|
||||
t-att-disabled="selectedCount === 0"
|
||||
t-on-click="onApplyRecommendations">
|
||||
<i class="fa fa-check me-1"/>Apply Recommendations
|
||||
</button>
|
||||
<t t-if="actionAvailable('flag')">
|
||||
<button class="btn btn-outline-warning btn-sm"
|
||||
t-att-disabled="selectedCount === 0"
|
||||
t-on-click="onFlagSelected">
|
||||
<i class="fa fa-flag me-1"/>Flag Selected
|
||||
</button>
|
||||
</t>
|
||||
<t t-if="actionAvailable('create_rule')">
|
||||
<button class="btn btn-outline-info btn-sm"
|
||||
t-att-disabled="selectedCount === 0"
|
||||
t-on-click="onCreateRules">
|
||||
<i class="fa fa-plus me-1"/>Create Rules
|
||||
</button>
|
||||
</t>
|
||||
<t t-if="actionAvailable('dismiss')">
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
t-att-disabled="selectedCount === 0"
|
||||
t-on-click="onDismissSelected">
|
||||
Dismiss Selected
|
||||
</button>
|
||||
</t>
|
||||
<div class="flex-grow-1"/>
|
||||
<button class="btn btn-outline-primary btn-sm"
|
||||
t-on-click="onSubmitNotes">
|
||||
<i class="fa fa-pencil me-1"/>Submit All Notes to AI
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -17,35 +17,52 @@
|
||||
</t>
|
||||
|
||||
<t t-else="">
|
||||
<!-- Health Cards -->
|
||||
<div class="fusion_health_cards d-flex flex-wrap gap-3 p-3">
|
||||
<t t-foreach="cards" t-as="card" t-key="card.domain">
|
||||
<FusionHealthCard
|
||||
title="card.title"
|
||||
metric="card.metric"
|
||||
subtext="card.subtext"
|
||||
status="card.status"
|
||||
domain="card.domain"
|
||||
onCardClick.bind="onCardClick"/>
|
||||
</t>
|
||||
</div>
|
||||
<!-- Main layout: Left panel (cards + needs attention) | Right panel (chat) -->
|
||||
<div class="fusion_main_layout d-flex">
|
||||
|
||||
<!-- Action Centre + Chat -->
|
||||
<div class="d-flex gap-3 p-3" style="min-height: 500px;">
|
||||
<!-- Action Centre -->
|
||||
<div class="flex-grow-1">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Needs Attention</h5>
|
||||
<!-- LEFT SIDE: Cards (2 rows of 3) + Needs Attention -->
|
||||
<div class="fusion_left_panel d-flex flex-column p-3 gap-3">
|
||||
|
||||
<!-- Health Cards: 2 rows x 3 cards -->
|
||||
<div class="fusion_health_cards d-flex flex-wrap gap-2">
|
||||
<t t-foreach="cards" t-as="card" t-key="card.domain">
|
||||
<FusionHealthCard
|
||||
title="card.title"
|
||||
metric="card.metric"
|
||||
subtext="card.subtext"
|
||||
status="card.status"
|
||||
domain="card.domain"
|
||||
onCardClick.bind="onCardClick"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Needs Attention Panel -->
|
||||
<div class="card fusion_attention_card">
|
||||
<div class="card-header py-2">
|
||||
<h5 class="mb-0"><i class="fa fa-exclamation-triangle me-2 text-warning"/>Needs Attention</h5>
|
||||
</div>
|
||||
<div class="card-body overflow-auto">
|
||||
<p class="text-muted">AI-prioritised items will appear here after the first audit scan.</p>
|
||||
<div class="card-body overflow-auto p-2">
|
||||
<t t-if="state.data and state.data.needs_attention and state.data.needs_attention.length">
|
||||
<t t-foreach="state.data.needs_attention" t-as="item" t-key="item_index">
|
||||
<div class="fusion_attention_item d-flex align-items-start gap-2 p-2 rounded mb-1 cursor-pointer"
|
||||
t-on-click="() => this.onCardClick(item.domain)">
|
||||
<i class="fa fa-circle-o text-warning mt-1" style="font-size: 0.6rem;"/>
|
||||
<div>
|
||||
<div class="fw-semibold small" t-esc="item.title"/>
|
||||
<div class="text-muted" style="font-size: 0.78rem;" t-esc="item.action"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p class="text-muted small mb-0">AI-prioritised items will appear here after the first audit scan.</p>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Panel (720px = original 400 + 80%) -->
|
||||
<div style="width: 720px; min-width: 600px;">
|
||||
<!-- RIGHT SIDE: Chat Panel (full height, input pinned to bottom) -->
|
||||
<div class="fusion_right_panel border-start">
|
||||
<FusionChatPanel sessionId="state.chatSessionId"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user