Files
Odoo-Modules/fusion_accounting_ai/static/src/components/chat/chat_panel.js
gsinghpal 6c72f2ab49 refactor(fusion_accounting): move AI module code into fusion_accounting_ai sub-module
git mv preserves history. fusion_accounting/ retains only __manifest__.py,
__init__.py, CLAUDE.md, and docs/ — the meta-module shell. All Python,
data, views, security, services, static, tests, wizards, report move to
fusion_accounting_ai/. Manifest data list updated; security.xml move to
_core deferred to Task 12.

Made-with: Cursor
2026-04-18 21:45:06 -04:00

1217 lines
50 KiB
JavaScript

/** @odoo-module **/
import { Component, useState, useRef, onWillStart, onMounted, onPatched } from "@odoo/owl";
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 "";
// Split into lines for block-level processing
const lines = text.split("\n");
const output = [];
let inTable = false;
let tableHeader = false;
let inList = false;
let listType = null;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
const trimmed = line.trim();
// Close list if we're not in a list item anymore
if (inList && !trimmed.match(/^[-*]\s/) && !trimmed.match(/^\d+\.\s/) && trimmed !== "") {
output.push(listType === "ul" ? "</ul>" : "</ol>");
inList = false;
listType = null;
}
// Table row detection (line has at least 2 pipes)
const pipeCount = (trimmed.match(/\|/g) || []).length;
if (pipeCount >= 2 && trimmed.includes("|")) {
// Separator row (|---|---|)
if (/^[\s|:\-]+$/.test(trimmed)) {
tableHeader = true;
continue;
}
// Split cells
let cells = trimmed.split("|").map(c => c.trim());
// Remove empty first/last from leading/trailing pipes
if (cells[0] === "") cells.shift();
if (cells.length > 0 && cells[cells.length - 1] === "") cells.pop();
if (!inTable) {
output.push('<div class="table-responsive my-2"><table class="table table-sm table-bordered align-middle">');
inTable = true;
// First row is header
output.push("<thead><tr>");
cells.forEach(c => output.push(`<th class="px-2 py-1 fw-semibold">${inlineFormat(c)}</th>`));
output.push("</tr></thead><tbody>");
continue;
}
// Body row
output.push("<tr>");
cells.forEach(c => output.push(`<td class="px-2 py-1">${inlineFormat(c)}</td>`));
output.push("</tr>");
continue;
}
// Close table if we were in one
if (inTable) {
output.push("</tbody></table></div>");
inTable = false;
tableHeader = false;
}
// Empty line
if (trimmed === "") {
output.push("");
continue;
}
// Headers
const headerMatch = trimmed.match(/^(#{1,5})\s+(.+)$/);
if (headerMatch) {
const level = Math.min(headerMatch[1].length + 2, 6); // ## -> h4, ### -> h5
output.push(`<h${level} class="mt-3 mb-1">${inlineFormat(headerMatch[2])}</h${level}>`);
continue;
}
// Horizontal rule
if (/^[-*_]{3,}$/.test(trimmed)) {
output.push('<hr class="my-2"/>');
continue;
}
// Unordered list
const ulMatch = trimmed.match(/^[-*]\s+(.+)$/);
if (ulMatch) {
if (!inList || listType !== "ul") {
if (inList) output.push(listType === "ul" ? "</ul>" : "</ol>");
output.push('<ul class="mb-1">');
inList = true;
listType = "ul";
}
output.push(`<li>${inlineFormat(ulMatch[1])}</li>`);
continue;
}
// Ordered list
const olMatch = trimmed.match(/^\d+\.\s+(.+)$/);
if (olMatch) {
if (!inList || listType !== "ol") {
if (inList) output.push(listType === "ul" ? "</ul>" : "</ol>");
output.push('<ol class="mb-1">');
inList = true;
listType = "ol";
}
output.push(`<li>${inlineFormat(olMatch[1])}</li>`);
continue;
}
// Regular paragraph
output.push(`<p class="mb-1">${inlineFormat(trimmed)}</p>`);
}
// Close open elements
if (inTable) output.push("</tbody></table></div>");
if (inList) output.push(listType === "ul" ? "</ul>" : "</ol>");
return output.join("\n");
}
function inlineFormat(text) {
if (!text) return "";
return text
// Escape HTML entities
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
// Bold + italic
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Inline code
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Links [text](url)
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
// Odoo record links #model,id
.replace(/#([\w.]+),(\d+)/g, '<a href="/odoo/$1/$2" class="badge text-bg-primary text-decoration-none">$1#$2</a>');
}
export class FusionChatPanel extends Component {
static template = "fusion_accounting.ChatPanel";
static components = { FusionApprovalCard };
static props = ["sessionId?"];
setup() {
this.inputRef = useRef("chatInput");
this.messagesRef = useRef("messages");
this.fileRef = useRef("fileInput");
// Track parsed table data per message index for interactive tables
this._parsedTables = {};
this.state = useState({
messages: [],
pendingApprovals: [],
inputText: "",
sending: false,
loading: true,
internalSessionId: null,
sessionName: null,
// Interactive tables extracted from AI messages, keyed by msg index
interactiveTables: {},
// Session history picker
showSessionPicker: false,
sessionList: [],
// Image attachment
pendingImage: null, // { name, dataUrl, base64, mediaType }
// Live execution status (from polling)
liveThinking: '',
liveToolCalls: [],
});
this._statusPollInterval = null;
onWillStart(async () => {
await this.loadLatestSession();
});
onMounted(() => {
this._renderRichMessages();
});
onPatched(() => {
this._renderRichMessages();
});
}
_renderRichMessages() {
const container = this.messagesRef.el;
if (!container) return;
const richDivs = container.querySelectorAll(".fusion_rich_slot[data-idx]");
for (const div of richDivs) {
const idx = parseInt(div.dataset.idx);
const msg = this.state.messages[idx];
if (msg && msg.role === "assistant") {
let html = "";
// Priority 1: Server-side reconciliation table (direct from tool result)
if (msg.reconciliationTable && !div.dataset.reconMounted) {
const reconKey = `recon_${idx}`;
// Strip any fusion-table blocks from AI text to avoid double rendering
const cleanText = (msg.content || "").replace(/```fusion-table[\s\S]*?```/g, '').trim();
html = mdToHtml(cleanText);
html += `<div class="fusion_table_mount" data-table-key="${reconKey}"></div>`;
this._parsedTables[reconKey] = {
mode: "reconciliation",
source_tool: "suggest_bank_line_matches",
bank_line: msg.reconciliationTable.bank_line,
candidates: msg.reconciliationTable.candidates,
best_combination: msg.reconciliationTable.best_combination,
};
div.innerHTML = html;
div.dataset.reconMounted = "true";
this._mountInteractiveTables(div);
}
// Priority 2: AI-generated fusion-table blocks
else if (msg.content && !div.dataset.reconMounted) {
const parsed = parseFusionTableBlock(msg.content);
if (parsed) {
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>`;
this._parsedTables[tableKey] = parsed.tables[part.idx];
}
}
if (div.innerHTML !== html) {
div.innerHTML = html;
}
this._mountInteractiveTables(div);
} else {
const mdHtml = mdToHtml(msg.content);
if (div.innerHTML !== mdHtml) {
div.innerHTML = mdHtml;
}
}
}
}
}
}
_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";
if (tableData.mode === "reconciliation") {
el.innerHTML = this._buildReconciliationTableHtml(tableData, key);
this._wireReconciliationEvents(el, tableData, key);
} else {
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;
}
// ================================================================
// Image Upload
// ================================================================
triggerFileUpload() {
const input = this.fileRef.el;
if (input) input.click();
}
onFileSelected(ev) {
const file = ev.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
console.warn("Only image files are supported");
return;
}
if (file.size > 10 * 1024 * 1024) {
console.warn("Image too large (max 10MB)");
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result;
// Extract base64 and media type
const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/);
if (match) {
this.state.pendingImage = {
name: file.name,
dataUrl,
base64: match[2],
mediaType: match[1],
};
}
};
reader.readAsDataURL(file);
// Reset input so same file can be selected again
ev.target.value = '';
}
clearImage() {
this.state.pendingImage = null;
}
_startStatusPolling() {
this._stopStatusPolling();
this.state.liveThinking = '';
this.state.liveToolCalls = [];
this._statusPollInterval = setInterval(async () => {
if (!this.state.sending || !this.sessionId) {
this._stopStatusPolling();
return;
}
try {
const status = await rpc('/fusion_accounting/chat/status', {
session_id: this.sessionId,
});
if (status.thinking) {
this.state.liveThinking = status.thinking;
}
if (status.tool_calls && status.tool_calls.length) {
this.state.liveToolCalls = status.tool_calls;
}
} catch (e) {
// Polling failure is not critical — just skip
}
}, 600);
}
_stopStatusPolling() {
if (this._statusPollInterval) {
clearInterval(this._statusPollInterval);
this._statusPollInterval = null;
}
this.state.liveThinking = '';
this.state.liveToolCalls = [];
}
// ================================================================
// Reconciliation Table Mode
// ================================================================
_buildReconciliationTableHtml(tableData, key) {
const bankLine = tableData.bank_line || {};
// Accept candidates from either 'candidates' (tool output) or 'rows' (AI formatted)
let rawCandidates = tableData.candidates || tableData.rows || [];
// Normalize: if AI sent rows with 'id' instead of 'aml_id', or 'cells' arrays, fix them
const candidates = rawCandidates.map(c => {
if (c.aml_id) return c; // Already in correct format
// Try to extract from cells array if AI used interactive table format
const norm = { ...c };
if (!norm.aml_id && norm.id) norm.aml_id = norm.id;
if (!norm.amount_residual && typeof norm.amount_residual !== 'number') norm.amount_residual = norm.amount_total || norm.amount || 0;
return norm;
});
// Store normalized rows back for collect function
tableData.rows = candidates;
const bestCombo = new Set((tableData.best_combination || []).map(String));
const bankAmount = Math.abs(bankLine.abs_amount || bankLine.amount || 0);
const title = tableData.title || `Match: ${bankLine.ref || ''} $${bankAmount.toFixed(2)}`;
let h = `<div class="fusion_recon_table my-2" data-key="${key}" data-bank-line-id="${bankLine.id || ''}" data-bank-amount="${bankAmount}">`;
// Header
h += `<div class="d-flex align-items-center justify-content-between mb-2">`;
h += `<div><i class="fa fa-exchange me-2 text-primary"></i><strong>${this._esc(title)}</strong>`;
h += `<span class="badge bg-secondary-subtle text-secondary ms-2">${bankLine.direction || ''}</span></div>`;
h += `<span class="badge bg-primary">${bankLine.journal || ''}</span></div>`;
// Table
h += '<div class="table-responsive"><table class="table table-sm table-hover align-middle mb-0"><thead><tr>';
h += '<th class="fit-content px-2"><input type="checkbox" class="form-check-input" data-action="recon-select-all"/></th>';
h += '<th class="px-2 py-1">Entry</th>';
h += '<th class="px-2 py-1">Type</th>';
h += '<th class="px-2 py-1">Partner</th>';
h += '<th class="px-2 py-1">Date</th>';
h += '<th class="px-2 py-1 text-end">Residual</th>';
h += '<th class="px-2 py-1 text-end" style="min-width:110px;">Apply</th>';
h += '<th class="px-2 py-1">Score</th>';
h += '</tr></thead><tbody>';
for (let i = 0; i < candidates.length; i++) {
const c = candidates[i];
const inCombo = bestCombo.has(String(c.aml_id));
const checked = inCombo ? 'checked' : '';
const residual = c.amount_residual || c.amount_total || 0;
const score = c.score || 0;
const scoreClass = score >= 60 ? 'text-success' : score >= 30 ? 'text-warning' : 'text-muted';
h += `<tr data-row-idx="${i}" data-aml-id="${c.aml_id}">`;
h += `<td class="fit-content px-2"><input type="checkbox" class="form-check-input recon-row-check" data-idx="${i}" ${checked}/></td>`;
h += `<td class="px-2 py-1 small"><strong>${this._esc(c.name)}</strong>`;
if (c.ref) h += `<br/><span class="text-muted">${this._esc(c.ref)}</span>`;
h += `</td>`;
const typeClass = c.type === 'payment' ? 'bg-success-subtle text-success' : c.type === 'bill' ? 'bg-info-subtle text-info' : 'bg-primary-subtle text-primary';
h += `<td class="px-2 py-1 small"><span class="badge ${typeClass}">${this._esc(c.type || 'entry')}</span></td>`;
h += `<td class="px-2 py-1 small">${this._esc(c.partner)}</td>`;
h += `<td class="px-2 py-1 small text-nowrap">${this._esc(c.date)}</td>`;
h += `<td class="px-2 py-1 small text-end text-nowrap">$${residual.toFixed(2)}</td>`;
h += `<td class="px-2 py-1 text-end">`;
h += `<input type="number" class="form-control form-control-sm fusion_apply_amount text-end" `;
h += `data-idx="${i}" data-max="${residual}" step="0.01" min="0" max="${residual}" `;
h += `value="${residual.toFixed(2)}" style="width:110px; display:inline-block;"/>`;
h += `</td>`;
h += `<td class="px-2 py-1 small"><span class="${scoreClass}">${score}</span>`;
if (c.reasons) h += ` <span class="text-muted">— ${this._esc(c.reasons)}</span>`;
h += `</td></tr>`;
}
h += '</tbody></table></div>';
// Search bar
h += '<div class="fusion_recon_search p-2 border-top position-relative">';
h += '<div class="input-group input-group-sm">';
h += '<span class="input-group-text"><i class="fa fa-search"></i></span>';
h += `<input type="text" class="form-control fusion_match_search" placeholder="Search by invoice #, amount, or partner..." data-key="${key}" data-line-id="${bankLine.id || ''}"/>`;
h += '</div>';
h += `<div class="fusion_search_results d-none" data-key="${key}"></div>`;
h += '</div>';
// Running total footer
h += '<div class="fusion_match_total d-flex align-items-center justify-content-between p-2 border-top">';
h += '<div class="small">';
h += '<span class="fusion_selected_total fw-semibold">$0.00</span>';
h += ` <span class="text-muted">/ Bank: $${bankAmount.toFixed(2)}</span>`;
h += ' <span class="fusion_remaining_badge badge ms-1"></span>';
h += '</div>';
h += `<button class="btn btn-primary btn-sm fusion_apply_match_btn" data-action="apply_match" disabled>`;
h += '<i class="fa fa-check me-1"></i>Apply Match</button>';
h += '</div>';
h += '</div>';
return h;
}
_wireReconciliationEvents(container, tableData, key) {
const bankAmount = parseFloat(container.querySelector('.fusion_recon_table')?.dataset.bankAmount || '0');
const recalcTotal = () => {
let total = 0;
const checks = container.querySelectorAll('.recon-row-check:checked');
for (const cb of checks) {
const idx = parseInt(cb.dataset.idx);
const amtInput = container.querySelector(`.fusion_apply_amount[data-idx="${idx}"]`);
if (amtInput) total += parseFloat(amtInput.value) || 0;
}
total = Math.round(total * 100) / 100;
const totalEl = container.querySelector('.fusion_selected_total');
const badgeEl = container.querySelector('.fusion_remaining_badge');
const applyBtn = container.querySelector('.fusion_apply_match_btn');
if (totalEl) totalEl.textContent = `$${total.toFixed(2)}`;
const remaining = Math.round((bankAmount - total) * 100) / 100;
if (badgeEl) {
if (Math.abs(remaining) < 0.01) {
badgeEl.textContent = 'Balanced ✓';
badgeEl.className = 'fusion_remaining_badge badge ms-1 bg-success';
} else if (remaining > 0) {
badgeEl.textContent = `Remaining: $${remaining.toFixed(2)}`;
badgeEl.className = 'fusion_remaining_badge badge ms-1 bg-warning text-dark';
} else {
badgeEl.textContent = `Over: $${Math.abs(remaining).toFixed(2)}`;
badgeEl.className = 'fusion_remaining_badge badge ms-1 bg-danger';
}
}
if (applyBtn) applyBtn.disabled = total === 0;
};
// Select-all
const selectAll = container.querySelector('[data-action="recon-select-all"]');
if (selectAll) {
selectAll.addEventListener('change', () => {
for (const cb of container.querySelectorAll('.recon-row-check')) cb.checked = selectAll.checked;
recalcTotal();
});
}
// Row checkboxes
for (const cb of container.querySelectorAll('.recon-row-check')) {
cb.addEventListener('change', recalcTotal);
}
// Amount inputs
for (const inp of container.querySelectorAll('.fusion_apply_amount')) {
inp.addEventListener('input', () => {
const max = parseFloat(inp.dataset.max) || 0;
let val = parseFloat(inp.value) || 0;
if (val > max) { inp.value = max.toFixed(2); }
if (val < 0) { inp.value = '0.00'; }
recalcTotal();
});
}
// Apply Match button
const applyBtn = container.querySelector('.fusion_apply_match_btn');
if (applyBtn) {
applyBtn.addEventListener('click', () => {
const rows = this._collectReconciliationRows(container, tableData);
if (!rows.length) return;
const bankLineId = container.querySelector('.fusion_recon_table')?.dataset.bankLineId;
this.onTableAction({
action: 'apply_match',
source_tool: tableData.source_tool || 'suggest_bank_line_matches',
bank_line_id: bankLineId,
bank_amount: bankAmount,
rows,
});
});
}
// Search bar with debounce
const searchInput = container.querySelector('.fusion_match_search');
const resultsDiv = container.querySelector('.fusion_search_results');
let searchTimeout = null;
if (searchInput && resultsDiv) {
searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
const q = searchInput.value.trim();
if (q.length < 2) { resultsDiv.classList.add('d-none'); return; }
searchTimeout = setTimeout(async () => {
try {
const data = await rpc('/fusion_accounting/search_matches', {
statement_line_id: parseInt(searchInput.dataset.lineId) || 0,
query: q,
});
this._renderSearchResults(resultsDiv, container, data.candidates || [], tableData, key);
} catch (e) {
resultsDiv.innerHTML = '<div class="p-2 text-danger small">Search failed</div>';
resultsDiv.classList.remove('d-none');
}
}, 300);
});
}
// Initial total calculation
recalcTotal();
}
_renderSearchResults(resultsDiv, tableContainer, candidates, tableData, key) {
if (!candidates.length) {
resultsDiv.innerHTML = '<div class="p-2 text-muted small">No matching entries found</div>';
resultsDiv.classList.remove('d-none');
return;
}
let h = '<div class="list-group list-group-flush">';
for (const c of candidates.slice(0, 8)) {
// Skip if already in the table
if (tableContainer.querySelector(`tr[data-aml-id="${c.aml_id}"]`)) continue;
h += `<button class="list-group-item list-group-item-action p-2 small fusion_search_result" `;
h += `data-aml='${JSON.stringify(c).replace(/'/g, "&#39;")}'>`;
h += `<div class="d-flex justify-content-between">`;
h += `<span><strong>${this._esc(c.name)}</strong> — ${this._esc(c.partner)}</span>`;
h += `<span class="fw-semibold">$${(c.amount_residual || 0).toFixed(2)}</span>`;
h += `</div>`;
h += `<small class="text-muted">${this._esc(c.date)} | ${this._esc(c.ref || '')}</small>`;
h += `</button>`;
}
h += '</div>';
resultsDiv.innerHTML = h;
resultsDiv.classList.remove('d-none');
// Wire add-on-click
for (const btn of resultsDiv.querySelectorAll('.fusion_search_result')) {
btn.addEventListener('click', () => {
const candidate = JSON.parse(btn.dataset.aml);
this._addRowToReconciliationTable(tableContainer, candidate, tableData);
resultsDiv.classList.add('d-none');
const searchInput = tableContainer.querySelector('.fusion_match_search');
if (searchInput) searchInput.value = '';
});
}
}
_addRowToReconciliationTable(container, candidate, tableData) {
const tbody = container.querySelector('tbody');
if (!tbody) return;
const rows = tableData.rows || [];
const idx = rows.length;
rows.push(candidate);
const residual = candidate.amount_residual || 0;
const tr = document.createElement('tr');
tr.dataset.rowIdx = idx;
tr.dataset.amlId = candidate.aml_id;
const cType = candidate.type || 'entry';
const cTypeClass = cType === 'payment' ? 'bg-success-subtle text-success' : cType === 'bill' ? 'bg-info-subtle text-info' : 'bg-primary-subtle text-primary';
tr.innerHTML = `
<td class="fit-content px-2"><input type="checkbox" class="form-check-input recon-row-check" data-idx="${idx}" checked/></td>
<td class="px-2 py-1 small"><strong>${this._esc(candidate.name)}</strong>${candidate.ref ? '<br/><span class="text-muted">' + this._esc(candidate.ref) + '</span>' : ''}</td>
<td class="px-2 py-1 small"><span class="badge ${cTypeClass}">${this._esc(cType)}</span></td>
<td class="px-2 py-1 small">${this._esc(candidate.partner)}</td>
<td class="px-2 py-1 small text-nowrap">${this._esc(candidate.date)}</td>
<td class="px-2 py-1 small text-end text-nowrap">$${residual.toFixed(2)}</td>
<td class="px-2 py-1 text-end">
<input type="number" class="form-control form-control-sm fusion_apply_amount text-end"
data-idx="${idx}" data-max="${residual}" step="0.01" min="0" max="${residual}"
value="${residual.toFixed(2)}" style="width:110px; display:inline-block;"/>
</td>
<td class="px-2 py-1 small text-muted">added</td>
`;
tbody.appendChild(tr);
// Wire new row's checkbox and amount input
const cb = tr.querySelector('.recon-row-check');
const amt = tr.querySelector('.fusion_apply_amount');
const recalc = () => {
// Trigger recalc by dispatching change on first checkbox
const first = container.querySelector('.recon-row-check');
if (first) first.dispatchEvent(new Event('change'));
};
if (cb) cb.addEventListener('change', recalc);
if (amt) amt.addEventListener('input', recalc);
recalc();
}
_collectReconciliationRows(container, tableData) {
const result = [];
const checks = container.querySelectorAll('.recon-row-check:checked');
const rows = tableData.rows || [];
for (const cb of checks) {
const idx = parseInt(cb.dataset.idx);
const tr = cb.closest('tr');
const amlId = tr?.dataset.amlId;
const amtInput = container.querySelector(`.fusion_apply_amount[data-idx="${idx}"]`);
const applyAmount = parseFloat(amtInput?.value) || 0;
const maxAmount = parseFloat(amtInput?.dataset.max) || 0;
const c = rows[idx] || {};
result.push({
aml_id: parseInt(amlId) || c.aml_id,
name: c.name || '',
apply_amount: applyAmount,
amount_residual: maxAmount,
is_partial: applyAmount < maxAmount - 0.01,
});
}
// Sort: full matches first, partial last (Odoo applies partial on last AML)
result.sort((a, b) => (a.is_partial ? 1 : 0) - (b.is_partial ? 1 : 0));
return result;
}
get sessionId() {
return this.state.internalSessionId || this.props.sessionId;
}
async loadLatestSession() {
this.state.loading = true;
try {
const data = await rpc("/fusion_accounting/session/latest", {});
if (data.session_id) {
this.state.internalSessionId = data.session_id;
this.state.messages = data.messages || [];
this.state.sessionName = data.name;
if (data.pending_approvals && data.pending_approvals.length) {
this.state.pendingApprovals = data.pending_approvals;
}
}
} catch (e) {
console.error("Failed to load session:", e);
}
this.state.loading = false;
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 {
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);
}
}
async sendMessage() {
const text = this.state.inputText.trim();
const image = this.state.pendingImage;
if ((!text && !image) || this.state.sending) return;
if (!this.sessionId) {
const session = await rpc("/fusion_accounting/session/create", {});
this.state.internalSessionId = session.session_id;
this.state.sessionName = session.name;
}
this.state.messages.push({
role: "user",
content: text || (image ? `[Attached: ${image.name}]` : ''),
hasImage: !!image,
imageUrl: image?.dataUrl || null,
});
this.state.inputText = "";
this.state.pendingImage = null;
this.state.sending = true;
this.scrollToBottom();
this._startStatusPolling();
const chatPayload = {
session_id: this.sessionId,
message: text || "Please analyze the attached image.",
};
if (image) {
chatPayload.image = {
base64: image.base64,
media_type: image.mediaType,
name: image.name,
};
}
try {
const result = await rpc("/fusion_accounting/chat", chatPayload);
this._stopStatusPolling();
if (result.text || (result.tool_calls_log && result.tool_calls_log.length)) {
this.state.messages.push({
role: "assistant",
content: result.text || "",
toolCalls: result.tool_calls_log || [],
// Attach server-side reconciliation data for direct rendering
reconciliationTable: result.reconciliation_table || null,
});
}
if (result.pending_approvals) {
this.state.pendingApprovals = result.pending_approvals;
}
} catch (e) {
this._stopStatusPolling();
this.state.messages.push({
role: "assistant",
content: `Error: ${e.message || "Something went wrong."}`,
});
}
this.state.sending = false;
this.scrollToNewReply();
}
/**
* 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",
apply_match: "Apply Match",
};
const label = actionLabels[action] || action;
let message;
if (action === 'apply_match') {
// Reconciliation-specific format with AML IDs and custom amounts
const bankLineId = payload.bank_line_id || '';
const bankAmount = payload.bank_amount || 0;
const total = rows.reduce((s, r) => s + (r.apply_amount || 0), 0);
let parts = [`[TABLE_ACTION] source=${source_tool} action=apply_match`];
parts.push(`bank_line_id=${bankLineId} bank_amount=${bankAmount}`);
for (const row of rows) {
const tag = row.is_partial ? 'partial' : 'full';
parts.push(`- AML #${row.aml_id}: ${row.name} | apply=$${(row.apply_amount || 0).toFixed(2)} residual=$${(row.amount_residual || 0).toFixed(2)} (${tag})`);
}
parts.push(`Total: $${total.toFixed(2)} / Bank: $${bankAmount.toFixed(2)}`);
message = parts.join('\n');
} else {
// Standard interactive table format
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);
}
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();
this._startStatusPolling();
try {
const result = await rpc("/fusion_accounting/chat", {
session_id: this.sessionId,
message: message,
});
this._stopStatusPolling();
if (result.text || (result.tool_calls_log && result.tool_calls_log.length)) {
this.state.messages.push({
role: "assistant",
content: result.text || "",
toolCalls: result.tool_calls_log || [],
});
}
if (result.pending_approvals) {
this.state.pendingApprovals = result.pending_approvals;
}
} catch (e) {
this._stopStatusPolling();
this.state.messages.push({
role: "assistant",
content: `Error processing table action: ${e.message || "Something went wrong."}`,
});
}
this.state.sending = false;
this.scrollToNewReply();
}
sendStarter(text) {
this.state.inputText = text;
this.sendMessage();
}
onKeyDown(ev) {
if (ev.key === "Enter" && !ev.shiftKey) {
ev.preventDefault();
this.sendMessage();
}
}
onPaste(ev) {
const items = ev.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
ev.preventDefault();
const file = item.getAsFile();
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result;
const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/);
if (match) {
this.state.pendingImage = {
name: `screenshot-${Date.now()}.png`,
dataUrl,
base64: match[2],
mediaType: match[1],
};
}
};
reader.readAsDataURL(file);
return;
}
}
}
scrollToBottom() {
const el = this.messagesRef.el;
if (el) {
setTimeout(() => { el.scrollTop = el.scrollHeight; }, 100);
}
}
scrollToNewReply() {
// Scroll so the TOP of the latest AI message is visible
const el = this.messagesRef.el;
if (!el) return;
setTimeout(() => {
const aiMsgs = el.querySelectorAll(".fusion_ai_msg");
if (aiMsgs.length) {
const last = aiMsgs[aiMsgs.length - 1];
// Scroll so the message top aligns near the top of the container
const offset = last.offsetTop - el.offsetTop - 8;
el.scrollTop = offset;
} else {
el.scrollTop = el.scrollHeight;
}
}, 120);
}
async onApprove(matchHistoryId) {
await rpc("/fusion_accounting/approve", { match_history_id: matchHistoryId });
this.state.pendingApprovals = this.state.pendingApprovals.filter(a => a.id !== matchHistoryId);
this.state.messages.push({ role: "assistant", content: "Action approved and executed." });
}
async onReject(matchHistoryId) {
await rpc("/fusion_accounting/reject", { match_history_id: matchHistoryId, reason: "User rejected" });
this.state.pendingApprovals = this.state.pendingApprovals.filter(a => a.id !== matchHistoryId);
this.state.messages.push({ role: "assistant", content: "Action rejected." });
}
async onApproveAll() {
const ids = this.state.pendingApprovals.map(a => a.id);
if (!ids.length) return;
await rpc("/fusion_accounting/approve_all", { match_history_ids: ids });
const count = this.state.pendingApprovals.length;
this.state.pendingApprovals = [];
this.state.messages.push({ role: "assistant", content: `${count} actions approved and executed.` });
}
async onRejectAll() {
const ids = this.state.pendingApprovals.map(a => a.id);
if (!ids.length) return;
await rpc("/fusion_accounting/reject_all", { match_history_ids: ids, reason: "Batch rejected" });
const count = this.state.pendingApprovals.length;
this.state.pendingApprovals = [];
this.state.messages.push({ role: "assistant", content: `${count} actions rejected.` });
}
}