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
1217 lines
50 KiB
JavaScript
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
// 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, "'")}'>`;
|
|
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.` });
|
|
}
|
|
}
|