changes
This commit is contained in:
@@ -6,8 +6,25 @@ export class FusionApprovalCard extends Component {
|
||||
static template = "fusion_accounting.ApprovalCard";
|
||||
static props = ["approval", "onApprove", "onReject"];
|
||||
|
||||
get confidencePercent() {
|
||||
return Math.round((this.props.approval.confidence || 0) * 100);
|
||||
get toolLabel() {
|
||||
const name = this.props.approval.tool_name || "";
|
||||
// Short labels for common tools
|
||||
const labels = {
|
||||
create_payroll_journal_entry: "Payroll JE",
|
||||
create_vendor_bill: "Vendor Bill",
|
||||
register_bill_payment: "Bill Payment",
|
||||
create_expense_entry: "Expense",
|
||||
register_hst_payment: "HST Payment",
|
||||
apply_payment: "Payment",
|
||||
send_followup: "Follow-up",
|
||||
flag_entry: "Flag",
|
||||
};
|
||||
return labels[name] || name.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
formatAmount(val) {
|
||||
if (!val) return "";
|
||||
return Number(val).toLocaleString("en-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
approve() {
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting.ApprovalCard">
|
||||
<div class="fusion_approval_card card border-warning mb-2">
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<strong t-esc="props.approval.tool_name"/>
|
||||
<span class="badge bg-warning text-dark">
|
||||
<t t-esc="confidencePercent"/>% conf
|
||||
</span>
|
||||
</div>
|
||||
<p class="small mb-1 text-muted" t-esc="props.approval.reasoning"/>
|
||||
<!-- Single row in the approval table — rendered inside <tbody> by chat_panel -->
|
||||
<tr class="fusion_approval_row">
|
||||
<td class="px-2 py-1 small text-nowrap" t-esc="toolLabel"/>
|
||||
<td class="px-2 py-1 small" style="white-space: pre-line; max-width: 320px;">
|
||||
<t t-esc="props.approval.summary || ''"/>
|
||||
</td>
|
||||
<td class="px-2 py-1 small text-end text-nowrap fw-semibold">
|
||||
<t t-if="props.approval.amount">
|
||||
<p class="small mb-1">
|
||||
Amount: <strong>$<t t-esc="(props.approval.amount || 0).toFixed(2)"/></strong>
|
||||
</p>
|
||||
$<t t-esc="formatAmount(props.approval.amount)"/>
|
||||
</t>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-success btn-sm flex-grow-1" t-on-click="approve">
|
||||
<i class="fa fa-check"/> Approve
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm flex-grow-1" t-on-click="reject">
|
||||
<i class="fa fa-times"/> Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-1 py-1 text-end text-nowrap">
|
||||
<button class="btn btn-success btn-xs px-2 py-0 me-1" t-on-click="approve"
|
||||
style="font-size: 0.75rem; line-height: 1.5;"
|
||||
title="Approve">
|
||||
<i class="fa fa-check"/>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-xs px-2 py-0" t-on-click="reject"
|
||||
style="font-size: 0.75rem; line-height: 1.5;"
|
||||
title="Reject">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -192,6 +192,7 @@ export class FusionChatPanel extends Component {
|
||||
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({
|
||||
@@ -207,7 +208,13 @@ export class FusionChatPanel extends Component {
|
||||
// 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();
|
||||
@@ -229,33 +236,51 @@ export class FusionChatPanel extends Component {
|
||||
for (const div of richDivs) {
|
||||
const idx = parseInt(div.dataset.idx);
|
||||
const msg = this.state.messages[idx];
|
||||
if (msg && msg.role === "assistant" && msg.content) {
|
||||
// Check for fusion-table blocks
|
||||
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) {
|
||||
// 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;
|
||||
const mdHtml = mdToHtml(msg.content);
|
||||
if (div.innerHTML !== mdHtml) {
|
||||
div.innerHTML = mdHtml;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,8 +294,13 @@ export class FusionChatPanel extends Component {
|
||||
if (!tableData) continue;
|
||||
|
||||
el.dataset.mounted = "true";
|
||||
el.innerHTML = this._buildInteractiveTableHtml(tableData, key);
|
||||
this._wireTableEvents(el, tableData, key);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,6 +494,383 @@ export class FusionChatPanel extends Component {
|
||||
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;
|
||||
}
|
||||
@@ -476,6 +883,9 @@ export class FusionChatPanel extends Component {
|
||||
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);
|
||||
@@ -569,7 +979,8 @@ export class FusionChatPanel extends Component {
|
||||
|
||||
async sendMessage() {
|
||||
const text = this.state.inputText.trim();
|
||||
if (!text || this.state.sending) return;
|
||||
const image = this.state.pendingImage;
|
||||
if ((!text && !image) || this.state.sending) return;
|
||||
|
||||
if (!this.sessionId) {
|
||||
const session = await rpc("/fusion_accounting/session/create", {});
|
||||
@@ -577,30 +988,54 @@ export class FusionChatPanel extends Component {
|
||||
this.state.sessionName = session.name;
|
||||
}
|
||||
|
||||
this.state.messages.push({ role: "user", content: text });
|
||||
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", {
|
||||
session_id: this.sessionId,
|
||||
message: text,
|
||||
});
|
||||
if (result.text) {
|
||||
this.state.messages.push({ role: "assistant", content: result.text });
|
||||
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.scrollToBottom();
|
||||
this.scrollToNewReply();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -615,25 +1050,41 @@ export class FusionChatPanel extends Component {
|
||||
create_rule: "Create Rules",
|
||||
dismiss: "Dismiss",
|
||||
submit_notes: "Submit Notes",
|
||||
apply_match: "Apply Match",
|
||||
};
|
||||
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})`;
|
||||
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})`);
|
||||
}
|
||||
if (row.userNote) {
|
||||
line += ` [User note: ${row.userNote}]`;
|
||||
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);
|
||||
}
|
||||
parts.push(line);
|
||||
message = parts.join('\n');
|
||||
}
|
||||
|
||||
const message = parts.join("\n");
|
||||
|
||||
// Show user what we're sending
|
||||
this.state.messages.push({
|
||||
role: "user",
|
||||
@@ -641,26 +1092,38 @@ export class FusionChatPanel extends Component {
|
||||
});
|
||||
this.state.sending = true;
|
||||
this.scrollToBottom();
|
||||
this._startStatusPolling();
|
||||
|
||||
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 });
|
||||
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.scrollToBottom();
|
||||
this.scrollToNewReply();
|
||||
}
|
||||
|
||||
sendStarter(text) {
|
||||
this.state.inputText = text;
|
||||
this.sendMessage();
|
||||
}
|
||||
|
||||
onKeyDown(ev) {
|
||||
@@ -670,6 +1133,33 @@ export class FusionChatPanel extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -677,6 +1167,23 @@ export class FusionChatPanel extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -66,10 +66,51 @@
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="state.messages.length === 0">
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="fa fa-robot fa-3x mb-3 d-block"/>
|
||||
<p>Ask me about your accounting data.<br/>
|
||||
I can help with bank reconciliation, tax analysis, AR/AP, auditing, and more.</p>
|
||||
<div class="text-center text-muted py-3">
|
||||
<i class="fa fa-robot fa-3x mb-2 d-block"/>
|
||||
<p class="mb-3">What would you like to work on?</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-center px-3 pb-3">
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('Reconcile the latest ADP payment on Scotia Current')">
|
||||
<i class="fa fa-exchange me-1"/>Match ADP Payment
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('Show me unreconciled bank lines on all journals')">
|
||||
<i class="fa fa-bank me-1"/>Unreconciled Lines
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('How much do we owe to Pride Mobility?')">
|
||||
<i class="fa fa-credit-card me-1"/>Vendor Balance
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('Show me invoicing by month for this year')">
|
||||
<i class="fa fa-bar-chart me-1"/>Invoicing by Month
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('How much are we collecting this month?')">
|
||||
<i class="fa fa-money me-1"/>Collections
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('What is our current HST balance?')">
|
||||
<i class="fa fa-percent me-1"/>HST Balance
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('Show me overdue invoices')">
|
||||
<i class="fa fa-exclamation-circle me-1"/>Overdue Invoices
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('Run month-end close checklist')">
|
||||
<i class="fa fa-check-square-o me-1"/>Month-End Close
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('Show me the P&L for this quarter')">
|
||||
<i class="fa fa-line-chart me-1"/>Profit & Loss
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm fusion_starter"
|
||||
t-on-click="() => this.sendStarter('Find duplicate bills')">
|
||||
<i class="fa fa-copy me-1"/>Duplicate Bills
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-foreach="state.messages" t-as="msg" t-key="msg_index">
|
||||
@@ -78,7 +119,14 @@
|
||||
<div class="fusion_chat_msg mb-2 p-2 rounded bg-primary-subtle ms-4">
|
||||
<small class="text-muted d-block mb-1">
|
||||
<i class="fa fa-user me-1"/>You
|
||||
<t t-if="msg.hasImage">
|
||||
<i class="fa fa-image ms-1 text-info" title="Image attached"/>
|
||||
</t>
|
||||
</small>
|
||||
<t t-if="msg.imageUrl">
|
||||
<img t-att-src="msg.imageUrl" class="rounded mb-1 d-block" style="max-height: 120px; max-width: 200px; cursor: pointer; object-fit: cover;"
|
||||
t-on-click="() => window.open(msg.imageUrl, '_blank')"/>
|
||||
</t>
|
||||
<span style="white-space: pre-wrap;" t-esc="msg.content"/>
|
||||
</div>
|
||||
</t>
|
||||
@@ -88,59 +136,161 @@
|
||||
<small class="text-muted d-block mb-2">
|
||||
<i class="fa fa-robot me-1"/>Fusion AI
|
||||
</small>
|
||||
<!-- Collapsible tool calls log (like Claude Code) -->
|
||||
<t t-if="msg.toolCalls and msg.toolCalls.length">
|
||||
<details class="fusion_tool_calls mb-2">
|
||||
<summary class="small text-muted cursor-pointer d-inline-flex align-items-center gap-1 user-select-none">
|
||||
<i class="fa fa-wrench" style="font-size: 0.7rem;"/>
|
||||
<span><t t-esc="msg.toolCalls.length"/> tool call<t t-if="msg.toolCalls.length > 1">s</t></span>
|
||||
</summary>
|
||||
<div class="mt-1 ms-2 border-start ps-2" style="border-color: var(--o-border-color) !important;">
|
||||
<t t-foreach="msg.toolCalls" t-as="tc" t-key="tc_index">
|
||||
<div class="d-flex align-items-start gap-1 py-1 small"
|
||||
style="line-height: 1.3;">
|
||||
<i t-att-class="'fa fa-fw ' + (tc.status === 'error' ? 'fa-times-circle text-danger' : tc.status === 'pending_approval' ? 'fa-clock-o text-warning' : 'fa-check-circle text-success')"
|
||||
style="font-size: 0.7rem; margin-top: 3px;"/>
|
||||
<span>
|
||||
<code class="small" style="font-size: 0.78rem;" t-esc="tc.name"/>
|
||||
<t t-if="tc.summary">
|
||||
<span class="text-muted ms-1">— <t t-esc="tc.summary"/></span>
|
||||
</t>
|
||||
<t t-if="tc.duration_ms">
|
||||
<span class="text-muted ms-1" style="font-size: 0.7rem;">(<t t-esc="tc.duration_ms"/>ms)</span>
|
||||
</t>
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</details>
|
||||
</t>
|
||||
<div class="fusion_rich_content fusion_rich_slot"
|
||||
t-att-data-idx="msg_index"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-if="state.sending">
|
||||
<div class="fusion_ai_msg rounded p-3 me-4 mb-2">
|
||||
<div class="fusion_ai_msg rounded p-3 me-4 mb-2 fusion_live_status">
|
||||
<small class="text-muted d-block mb-1">
|
||||
<i class="fa fa-robot me-1"/>Fusion AI
|
||||
</small>
|
||||
<i class="fa fa-spinner fa-spin me-1"/> Thinking...
|
||||
<!-- Live thinking text -->
|
||||
<t t-if="state.liveThinking">
|
||||
<div class="fusion_thinking_block mb-2 p-2 rounded small fst-italic"
|
||||
style="background: rgba(var(--bs-body-color-rgb), 0.03); border-left: 3px solid var(--bs-purple, #6f42c1); max-height: 120px; overflow-y: auto;">
|
||||
<i class="fa fa-brain me-1 text-purple" style="color: var(--bs-purple, #6f42c1);"/>
|
||||
<span t-esc="state.liveThinking"/>
|
||||
</div>
|
||||
</t>
|
||||
<!-- Live tool calls -->
|
||||
<t t-if="state.liveToolCalls.length > 0">
|
||||
<div class="mb-1">
|
||||
<t t-foreach="state.liveToolCalls" t-as="tc" t-key="tc_index">
|
||||
<div class="d-flex align-items-center gap-1 small py-1" style="line-height: 1.3;">
|
||||
<t t-if="tc.status === 'running'">
|
||||
<i class="fa fa-spinner fa-spin text-primary" style="font-size: 0.7rem;"/>
|
||||
</t>
|
||||
<t t-elif="tc.status === 'ok'">
|
||||
<i class="fa fa-check-circle text-success" style="font-size: 0.7rem;"/>
|
||||
</t>
|
||||
<t t-elif="tc.status === 'error'">
|
||||
<i class="fa fa-times-circle text-danger" style="font-size: 0.7rem;"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa fa-clock-o text-warning" style="font-size: 0.7rem;"/>
|
||||
</t>
|
||||
<code class="small" style="font-size: 0.75rem;" t-esc="tc.name"/>
|
||||
<t t-if="tc.summary">
|
||||
<span class="text-muted">— <t t-esc="tc.summary"/></span>
|
||||
</t>
|
||||
<t t-if="tc.duration_ms">
|
||||
<span class="text-muted" style="font-size: 0.68rem;">(<t t-esc="tc.duration_ms"/>ms)</span>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<!-- Default thinking indicator if no live data yet -->
|
||||
<t t-if="!state.liveThinking and state.liveToolCalls.length === 0">
|
||||
<i class="fa fa-spinner fa-spin me-1"/> Thinking...
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Pending Approvals -->
|
||||
<!-- Pending Approvals — compact table -->
|
||||
<t t-if="state.pendingApprovals.length > 0">
|
||||
<div class="border-top p-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<small class="text-muted">Pending Approvals (<t t-esc="state.pendingApprovals.length"/>):</small>
|
||||
<div class="border-top">
|
||||
<div class="d-flex justify-content-between align-items-center px-2 py-1 bg-warning-subtle">
|
||||
<small class="fw-semibold">
|
||||
<i class="fa fa-exclamation-triangle me-1 text-warning"/>
|
||||
<t t-esc="state.pendingApprovals.length"/> Pending Approval<t t-if="state.pendingApprovals.length > 1">s</t>
|
||||
</small>
|
||||
<div class="d-flex gap-1" t-if="state.pendingApprovals.length > 1">
|
||||
<button class="btn btn-success btn-sm" t-on-click="onApproveAll">
|
||||
<i class="fa fa-check-double"/> Approve All
|
||||
<button class="btn btn-success px-2 py-0" style="font-size: 0.75rem;"
|
||||
t-on-click="onApproveAll" title="Approve all">
|
||||
<i class="fa fa-check me-1"/>All
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm" t-on-click="onRejectAll">
|
||||
Reject All
|
||||
<button class="btn btn-outline-danger px-2 py-0" style="font-size: 0.75rem;"
|
||||
t-on-click="onRejectAll" title="Reject all">
|
||||
<i class="fa fa-times me-1"/>All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<t t-foreach="state.pendingApprovals" t-as="approval" t-key="approval.id">
|
||||
<FusionApprovalCard
|
||||
approval="approval"
|
||||
onApprove.bind="onApprove"
|
||||
onReject.bind="onReject"/>
|
||||
</t>
|
||||
<div class="overflow-auto" style="max-height: 280px;">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr class="small text-muted">
|
||||
<th class="px-2 py-1 fw-semibold">Type</th>
|
||||
<th class="px-2 py-1 fw-semibold">Details</th>
|
||||
<th class="px-2 py-1 fw-semibold text-end">Amount</th>
|
||||
<th class="px-1 py-1 fw-semibold text-end" style="width: 80px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="state.pendingApprovals" t-as="approval" t-key="approval.id">
|
||||
<FusionApprovalCard
|
||||
approval="approval"
|
||||
onApprove.bind="onApprove"
|
||||
onReject.bind="onReject"/>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="fusion_chat_input border-top p-2">
|
||||
<!-- Image preview -->
|
||||
<t t-if="state.pendingImage">
|
||||
<div class="fusion_image_preview d-flex align-items-center gap-2 mb-1 p-1 rounded bg-body-tertiary">
|
||||
<img t-att-src="state.pendingImage.dataUrl" class="rounded" style="max-height: 48px; max-width: 80px; object-fit: cover;"/>
|
||||
<small class="text-muted flex-grow-1 text-truncate" t-esc="state.pendingImage.name"/>
|
||||
<button class="btn btn-sm p-0 text-danger" t-on-click="clearImage" title="Remove">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<div class="input-group">
|
||||
<button class="btn btn-outline-secondary btn-sm" t-on-click="triggerFileUpload"
|
||||
title="Attach image (screenshot, remittance advice, etc.)">
|
||||
<i class="fa fa-paperclip"/>
|
||||
</button>
|
||||
<textarea
|
||||
t-ref="chatInput"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="Ask Fusion AI..."
|
||||
rows="2"
|
||||
placeholder="Ask Fusion AI... (paste screenshot with Ctrl+V)"
|
||||
rows="1"
|
||||
t-model="state.inputText"
|
||||
t-on-keydown="onKeyDown"/>
|
||||
t-on-keydown="onKeyDown"
|
||||
t-on-paste="onPaste"/>
|
||||
<button class="btn btn-primary btn-sm" t-on-click="sendMessage"
|
||||
t-att-disabled="state.sending">
|
||||
<i class="fa fa-paper-plane"/>
|
||||
</button>
|
||||
</div>
|
||||
<input type="file" t-ref="fileInput" class="d-none" accept="image/*"
|
||||
t-on-change="onFileSelected"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
@@ -36,12 +36,22 @@ export class FusionDashboard extends Component {
|
||||
this.state.loading = false;
|
||||
}
|
||||
|
||||
async onCardClick(domain) {
|
||||
if (!this.state.chatSessionId) {
|
||||
const session = await rpc("/fusion_accounting/session/create", {
|
||||
context_domain: domain,
|
||||
});
|
||||
this.state.chatSessionId = session.session_id;
|
||||
async onAttentionClick(domain, prompt) {
|
||||
// Type the prompt into the chat input and send
|
||||
if (!prompt) return;
|
||||
const textarea = this.el?.querySelector('.fusion_chat_input textarea');
|
||||
if (textarea) {
|
||||
// Set value and trigger OWL's model update via input event
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype, 'value'
|
||||
).set;
|
||||
nativeInputValueSetter.call(textarea, prompt);
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
// Click send button
|
||||
setTimeout(() => {
|
||||
const sendBtn = this.el?.querySelector('.fusion_chat_input .btn-primary');
|
||||
if (sendBtn) sendBtn.click();
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,27 +62,27 @@ export class FusionDashboard extends Component {
|
||||
{
|
||||
title: "Bank Reconciliation",
|
||||
metric: `${d.bank_recon.count} unmatched`,
|
||||
subtext: `$${(d.bank_recon.amount || 0).toFixed(2)} total`,
|
||||
subtext: `$${(d.bank_recon.amount || 0).toLocaleString('en-CA', {minimumFractionDigits: 0})} total`,
|
||||
domain: "bank_reconciliation",
|
||||
status: d.bank_recon.count === 0 ? "green" : d.bank_recon.count < 10 ? "yellow" : "red",
|
||||
},
|
||||
{
|
||||
title: "AR Outstanding",
|
||||
metric: `$${(d.ar.total || 0).toFixed(2)}`,
|
||||
metric: `$${Math.abs(d.ar.total || 0).toLocaleString('en-CA', {minimumFractionDigits: 0})}`,
|
||||
subtext: `${d.ar.overdue_count} overdue`,
|
||||
domain: "accounts_receivable",
|
||||
status: d.ar.overdue_count === 0 ? "green" : d.ar.overdue_count < 5 ? "yellow" : "red",
|
||||
},
|
||||
{
|
||||
title: "AP Due",
|
||||
metric: `$${(d.ap.total || 0).toFixed(2)}`,
|
||||
metric: `$${(d.ap.total || 0).toLocaleString('en-CA', {minimumFractionDigits: 0})}`,
|
||||
subtext: `${d.ap.due_this_week} due this week`,
|
||||
domain: "accounts_payable",
|
||||
status: d.ap.due_this_week === 0 ? "green" : "yellow",
|
||||
},
|
||||
{
|
||||
title: "HST Balance",
|
||||
metric: `$${(d.hst.balance || 0).toFixed(2)}`,
|
||||
metric: `$${(d.hst.balance || 0).toLocaleString('en-CA', {minimumFractionDigits: 0})}`,
|
||||
subtext: d.hst.balance > 0 ? "Owing to CRA" : "Refund expected",
|
||||
domain: "hst_management",
|
||||
status: "blue",
|
||||
|
||||
@@ -2,29 +2,27 @@
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting.Dashboard">
|
||||
<div class="o_action fusion_accounting_dashboard">
|
||||
<div class="fusion_dashboard_header d-flex justify-content-between align-items-center p-3">
|
||||
<h2 class="mb-0">Fusion AI Dashboard</h2>
|
||||
<button class="btn btn-outline-primary btn-sm" t-on-click="loadDashboard">
|
||||
<i class="fa fa-refresh"/> Refresh
|
||||
<div class="fusion_dashboard_header d-flex justify-content-between align-items-center px-3 py-2">
|
||||
<h4 class="mb-0"><i class="fa fa-bolt me-2"/>Fusion AI</h4>
|
||||
<button class="btn btn-outline-secondary btn-sm" t-on-click="loadDashboard">
|
||||
<i class="fa fa-refresh me-1"/>Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<t t-if="state.loading">
|
||||
<div class="text-center p-5">
|
||||
<i class="fa fa-spinner fa-spin fa-2x"/>
|
||||
<p class="mt-2">Loading dashboard...</p>
|
||||
<p class="mt-2 text-muted">Loading dashboard...</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-else="">
|
||||
<!-- Main layout: Left panel (cards + needs attention) | Right panel (chat) -->
|
||||
<div class="fusion_main_layout d-flex">
|
||||
|
||||
<!-- LEFT SIDE: Cards (2 rows of 3) + Needs Attention -->
|
||||
<!-- LEFT: Cards + 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">
|
||||
<div class="fusion_health_cards">
|
||||
<t t-foreach="cards" t-as="card" t-key="card.domain">
|
||||
<FusionHealthCard
|
||||
title="card.title"
|
||||
@@ -32,37 +30,45 @@
|
||||
subtext="card.subtext"
|
||||
status="card.status"
|
||||
domain="card.domain"
|
||||
onCardClick.bind="onCardClick"/>
|
||||
onCardClick.bind="onAttentionClick"/>
|
||||
</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>
|
||||
<!-- Needs Attention -->
|
||||
<div class="fusion_attention_panel flex-grow-1 d-flex flex-column">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<i class="fa fa-bell text-warning"/>
|
||||
<span class="fw-semibold small">Needs Attention</span>
|
||||
<t t-if="state.data and state.data.needs_attention">
|
||||
<span class="badge bg-warning text-dark" t-esc="state.data.needs_attention.length"/>
|
||||
</t>
|
||||
</div>
|
||||
<div class="card-body overflow-auto p-2">
|
||||
<div class="fusion_attention_list flex-grow-1 overflow-auto">
|
||||
<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 class="fusion_attention_item d-flex align-items-start gap-2 p-2 rounded cursor-pointer"
|
||||
t-on-click="() => this.onAttentionClick(item.domain, item.prompt)">
|
||||
<div t-attf-class="fusion_attn_dot fusion_attn_{{item.severity || 'warning'}}"/>
|
||||
<div class="flex-grow-1 small">
|
||||
<div class="fw-semibold" t-esc="item.title"/>
|
||||
<div class="text-muted" style="font-size: 0.75rem;" t-esc="item.action"/>
|
||||
</div>
|
||||
<i class="fa fa-chevron-right text-muted mt-1" style="font-size: 0.6rem;"/>
|
||||
</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>
|
||||
<div class="text-center text-muted small py-3">
|
||||
<i class="fa fa-check-circle fa-2x mb-2 d-block text-success"/>
|
||||
All clear! No items need attention.
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT SIDE: Chat Panel (full height, input pinned to bottom) -->
|
||||
<div class="fusion_right_panel border-start">
|
||||
<!-- RIGHT: Chat -->
|
||||
<div class="fusion_right_panel">
|
||||
<FusionChatPanel sessionId="state.chatSessionId"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,14 +6,16 @@ export class FusionHealthCard extends Component {
|
||||
static template = "fusion_accounting.HealthCard";
|
||||
static props = ["title", "metric", "subtext", "status", "domain", "onCardClick"];
|
||||
|
||||
get statusClass() {
|
||||
const map = {
|
||||
green: "bg-success-subtle border-success",
|
||||
yellow: "bg-warning-subtle border-warning",
|
||||
red: "bg-danger-subtle border-danger",
|
||||
blue: "bg-info-subtle border-info",
|
||||
get icon() {
|
||||
const icons = {
|
||||
bank_reconciliation: "fa-bank",
|
||||
accounts_receivable: "fa-file-text-o",
|
||||
accounts_payable: "fa-credit-card",
|
||||
hst_management: "fa-percent",
|
||||
audit: "fa-shield",
|
||||
month_end: "fa-calendar-check-o",
|
||||
};
|
||||
return map[this.props.status] || "bg-light";
|
||||
return icons[this.props.domain] || "fa-bar-chart";
|
||||
}
|
||||
|
||||
onClick() {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_accounting.HealthCard">
|
||||
<div class="fusion_health_card card border-2 cursor-pointer"
|
||||
t-attf-class="{{statusClass}}"
|
||||
style="min-width: 180px; flex: 1;"
|
||||
<div class="fusion_health_card cursor-pointer"
|
||||
t-attf-class="fusion_card_{{props.status}}"
|
||||
t-on-click="onClick">
|
||||
<div class="card-body text-center p-3">
|
||||
<h6 class="card-title text-muted mb-1" t-esc="props.title"/>
|
||||
<h3 class="mb-1" t-esc="props.metric"/>
|
||||
<small class="text-muted" t-esc="props.subtext"/>
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<div class="fusion_card_icon">
|
||||
<i t-attf-class="fa {{icon}}"/>
|
||||
</div>
|
||||
<span class="fusion_card_title" t-esc="props.title"/>
|
||||
</div>
|
||||
<div class="fusion_card_metric" t-esc="props.metric"/>
|
||||
<div class="fusion_card_sub" t-esc="props.subtext"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
Reference in New Issue
Block a user