This commit is contained in:
gsinghpal
2026-05-16 13:18:52 -04:00
parent 191a9c82be
commit 9ebf89bde2
1080 changed files with 0 additions and 1197 deletions

View File

@@ -0,0 +1,37 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class FusionApprovalCard extends Component {
static template = "fusion_accounting.ApprovalCard";
static props = ["approval", "onApprove", "onReject"];
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() {
this.props.onApprove(this.props.approval.id);
}
reject() {
this.props.onReject(this.props.approval.id);
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting.ApprovalCard">
<!-- 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">
$<t t-esc="formatAmount(props.approval.amount)"/>
</t>
</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>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,297 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting.ChatPanel">
<div class="fusion_chat_panel card h-100 d-flex flex-column">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<div class="d-flex align-items-center">
<h5 class="mb-0 d-inline"><i class="fa fa-comments-o me-2"/>Fusion AI</h5>
<small class="text-muted ms-2" t-if="state.sessionName" t-esc="state.sessionName"/>
</div>
<div class="d-flex gap-1 align-items-center">
<!-- Session history button -->
<button class="btn btn-outline-secondary btn-sm"
t-on-click="toggleSessionPicker"
title="Load previous session">
<i class="fa fa-history"/>
</button>
<button class="btn btn-outline-secondary btn-sm" t-on-click="onNewChat"
title="Start a new conversation">
<i class="fa fa-plus me-1"/>New Chat
</button>
</div>
</div>
<!-- Session Picker Dropdown -->
<t t-if="state.showSessionPicker">
<div class="fusion_session_picker border-bottom">
<div class="p-2 bg-body-tertiary">
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="fw-semibold text-muted">Recent Sessions</small>
<button class="btn-close btn-close-sm" t-on-click="toggleSessionPicker"/>
</div>
<t t-if="state.sessionList.length === 0">
<p class="text-muted small mb-0">No previous sessions found.</p>
</t>
<div class="fusion_session_list overflow-auto" style="max-height: 200px;">
<t t-foreach="state.sessionList" t-as="sess" t-key="sess.id">
<div class="fusion_session_item d-flex justify-content-between align-items-center p-2 rounded cursor-pointer"
t-att-class="sess.id === state.internalSessionId ? 'bg-primary-subtle' : ''"
t-on-click="() => this.loadSession(sess.id)">
<div>
<div class="small fw-semibold" t-esc="sess.name"/>
<div class="text-muted" style="font-size: 0.72rem;">
<t t-esc="formatSessionDate(sess.date)"/>
<span class="ms-2" t-if="sess.message_count">
<t t-esc="sess.message_count"/> msgs
</span>
<span class="ms-1 badge"
t-att-class="sess.state === 'active' ? 'bg-success-subtle text-success' : 'bg-secondary-subtle text-secondary'"
t-esc="sess.state"/>
</div>
</div>
<i class="fa fa-chevron-right text-muted" style="font-size: 0.7rem;"/>
</div>
</t>
</div>
</div>
</div>
</t>
<!-- Messages -->
<div class="fusion_chat_messages flex-grow-1 overflow-auto p-3" t-ref="messages">
<t t-if="state.loading">
<div class="text-center text-muted py-4">
<i class="fa fa-spinner fa-spin fa-2x"/>
<p class="mt-2">Loading conversation...</p>
</div>
</t>
<t t-elif="state.messages.length === 0">
<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&amp;L for this quarter')">
<i class="fa fa-line-chart me-1"/>Profit &amp; 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">
<!-- User message -->
<t t-if="msg.role === 'user'">
<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>
<!-- AI message — rich HTML rendered via onPatched -->
<t t-else="">
<div class="fusion_chat_msg fusion_ai_msg mb-3 p-3 rounded me-4">
<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 fusion_live_status">
<small class="text-muted d-block mb-1">
<i class="fa fa-robot me-1"/>Fusion AI
</small>
<!-- 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 — compact table -->
<t t-if="state.pendingApprovals.length > 0">
<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 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 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>
<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... (paste screenshot with Ctrl+V)"
rows="1"
t-model="state.inputText"
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>
</templates>

View File

@@ -0,0 +1,164 @@
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
export class FusionInteractiveTable extends Component {
static template = "fusion_accounting.InteractiveTable";
static props = ["tableData", "onTableAction"];
setup() {
const rows = (this.props.tableData.rows || []).map((row) => ({
...row,
selected: false,
userNote: "",
}));
this.state = useState({
rows,
selectAll: false,
});
}
get isInteractive() {
return this.props.tableData.mode === "interactive";
}
get columns() {
return this.props.tableData.columns || [];
}
get title() {
return this.props.tableData.title || "";
}
get actions() {
return this.props.tableData.actions || [];
}
get selectedCount() {
return this.state.rows.filter((r) => r.selected).length;
}
get hasAction() {
return (action) => this.actions.includes(action);
}
actionAvailable(action) {
return this.actions.includes(action);
}
recommendationClass(action) {
switch (action) {
case "dismiss":
return "bg-success-subtle text-success";
case "flag":
return "bg-warning-subtle text-warning";
case "create_rule":
return "bg-info-subtle text-info";
default:
return "bg-secondary-subtle text-secondary";
}
}
recommendationLabel(action) {
switch (action) {
case "dismiss":
return "Dismiss";
case "flag":
return "Flag";
case "create_rule":
return "Create Rule";
default:
return action || "Review";
}
}
onToggleSelectAll() {
const newVal = !this.state.selectAll;
this.state.selectAll = newVal;
for (const row of this.state.rows) {
row.selected = newVal;
}
}
onToggleRow(rowIndex) {
this.state.rows[rowIndex].selected = !this.state.rows[rowIndex].selected;
this.state.selectAll = this.state.rows.every((r) => r.selected);
}
onNoteInput(rowIndex, ev) {
this.state.rows[rowIndex].userNote = ev.target.value;
}
_collectSelected() {
return this.state.rows
.filter((r) => r.selected)
.map((r) => ({
id: r.id,
cells: r.cells,
recommendation: r.recommendation,
userNote: r.userNote,
}));
}
_collectAllNotes() {
return this.state.rows
.filter((r) => r.userNote.trim())
.map((r) => ({
id: r.id,
cells: r.cells,
recommendation: r.recommendation,
userNote: r.userNote,
}));
}
onApplyRecommendations() {
const selected = this._collectSelected();
if (!selected.length) return;
this.props.onTableAction({
action: "apply_recommendations",
source_tool: this.props.tableData.source_tool,
rows: selected,
});
}
onFlagSelected() {
const selected = this._collectSelected();
if (!selected.length) return;
this.props.onTableAction({
action: "flag",
source_tool: this.props.tableData.source_tool,
rows: selected,
});
}
onCreateRules() {
const selected = this._collectSelected();
if (!selected.length) return;
this.props.onTableAction({
action: "create_rule",
source_tool: this.props.tableData.source_tool,
rows: selected,
});
}
onDismissSelected() {
const selected = this._collectSelected();
if (!selected.length) return;
this.props.onTableAction({
action: "dismiss",
source_tool: this.props.tableData.source_tool,
rows: selected,
});
}
onSubmitNotes() {
const noted = this._collectAllNotes();
if (!noted.length) return;
this.props.onTableAction({
action: "submit_notes",
source_tool: this.props.tableData.source_tool,
rows: noted,
});
}
}

View File

@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting.InteractiveTable">
<div class="fusion_interactive_table my-2">
<!-- Title -->
<t t-if="title">
<div class="d-flex align-items-center mb-2">
<i class="fa fa-table me-2 text-muted"/>
<strong t-esc="title"/>
<span class="badge bg-secondary-subtle text-secondary ms-2"
t-esc="state.rows.length + ' rows'"/>
</div>
</t>
<!-- Table -->
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead>
<tr>
<!-- Checkbox column (interactive only) -->
<t t-if="isInteractive">
<th class="fit-content px-2">
<input type="checkbox"
class="form-check-input"
t-att-checked="state.selectAll"
t-on-change="onToggleSelectAll"/>
</th>
</t>
<!-- Data columns -->
<t t-foreach="columns" t-as="col" t-key="col_index">
<th class="px-2 py-1" t-esc="col"/>
</t>
<!-- AI Recommendation column (interactive only) -->
<t t-if="isInteractive">
<th class="px-2 py-1 text-info">AI Recommendation</th>
<th class="px-2 py-1 text-warning" style="min-width: 180px;">Your Input</th>
</t>
</tr>
</thead>
<tbody>
<t t-foreach="state.rows" t-as="row" t-key="row_index">
<tr t-att-class="row.selected ? 'table-active' : ''">
<!-- Checkbox -->
<t t-if="isInteractive">
<td class="fit-content px-2">
<input type="checkbox"
class="form-check-input"
t-att-checked="row.selected"
t-on-change="() => this.onToggleRow(row_index)"/>
</td>
</t>
<!-- Data cells -->
<t t-foreach="row.cells" t-as="cell" t-key="cell_index">
<td class="px-2 py-1" t-esc="cell"/>
</t>
<!-- AI Recommendation -->
<t t-if="isInteractive">
<td class="px-2 py-1">
<t t-if="row.recommendation">
<span t-att-class="'badge me-1 ' + recommendationClass(row.recommendation.action)"
t-esc="recommendationLabel(row.recommendation.action)"/>
<small class="text-muted" t-esc="row.recommendation.reason"/>
</t>
</td>
<!-- User input -->
<td class="px-2 py-1">
<input type="text"
class="form-control form-control-sm fusion_row_note"
placeholder="Add your note..."
t-att-value="row.userNote"
t-on-input="(ev) => this.onNoteInput(row_index, ev)"/>
</td>
</t>
</tr>
</t>
</tbody>
</table>
</div>
<!-- Bulk Action Bar (interactive only) -->
<t t-if="isInteractive">
<div class="fusion_table_action_bar d-flex flex-wrap align-items-center gap-2 p-2 border-top">
<small class="text-muted me-1">
<t t-esc="selectedCount"/> selected
</small>
<button class="btn btn-success btn-sm"
t-att-disabled="selectedCount === 0"
t-on-click="onApplyRecommendations">
<i class="fa fa-check me-1"/>Apply Recommendations
</button>
<t t-if="actionAvailable('flag')">
<button class="btn btn-outline-warning btn-sm"
t-att-disabled="selectedCount === 0"
t-on-click="onFlagSelected">
<i class="fa fa-flag me-1"/>Flag Selected
</button>
</t>
<t t-if="actionAvailable('create_rule')">
<button class="btn btn-outline-info btn-sm"
t-att-disabled="selectedCount === 0"
t-on-click="onCreateRules">
<i class="fa fa-plus me-1"/>Create Rules
</button>
</t>
<t t-if="actionAvailable('dismiss')">
<button class="btn btn-outline-secondary btn-sm"
t-att-disabled="selectedCount === 0"
t-on-click="onDismissSelected">
Dismiss Selected
</button>
</t>
<div class="flex-grow-1"/>
<button class="btn btn-outline-primary btn-sm"
t-on-click="onSubmitNotes">
<i class="fa fa-pencil me-1"/>Submit All Notes to AI
</button>
</div>
</t>
</div>
</t>
</templates>

View File

@@ -0,0 +1,108 @@
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { rpc } from "@web/core/network/rpc";
import { FusionHealthCard } from "./health_card";
import { FusionChatPanel } from "../chat/chat_panel";
export class FusionDashboard extends Component {
static template = "fusion_accounting.Dashboard";
static components = { FusionHealthCard, FusionChatPanel };
static props = ["*"];
setup() {
this.action = useService("action");
this.state = useState({
data: null,
loading: true,
chatSessionId: null,
});
onWillStart(async () => {
await this.loadDashboard();
});
}
async loadDashboard() {
this.state.loading = true;
try {
this.state.data = await rpc("/fusion_accounting/dashboard/data");
} catch (e) {
console.error("Dashboard load error:", e);
this.state.data = null;
}
this.state.loading = false;
}
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);
}
}
get cards() {
if (!this.state.data) return [];
const d = this.state.data;
return [
{
title: "Bank Reconciliation",
metric: `${d.bank_recon.count} unmatched`,
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: `$${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).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).toLocaleString('en-CA', {minimumFractionDigits: 0})}`,
subtext: d.hst.balance > 0 ? "Owing to CRA" : "Refund expected",
domain: "hst_management",
status: "blue",
},
{
title: "Audit Score",
metric: `${d.audit.score}/100`,
subtext: `${d.audit.flags} flags`,
domain: "audit",
status: d.audit.score >= 80 ? "green" : d.audit.score >= 60 ? "yellow" : "red",
},
{
title: "Month-End",
metric: d.month_end.status,
subtext: `${d.month_end.open_items} open items`,
domain: "month_end",
status: d.month_end.open_items === 0 ? "green" : "yellow",
},
];
}
}
registry.category("actions").add("fusion_accounting.dashboard", FusionDashboard);

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<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 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 text-muted">Loading dashboard...</p>
</div>
</t>
<t t-else="">
<div class="fusion_main_layout d-flex">
<!-- LEFT: Cards + Needs Attention -->
<div class="fusion_left_panel d-flex flex-column p-3 gap-3">
<div class="fusion_health_cards">
<t t-foreach="cards" t-as="card" t-key="card.domain">
<FusionHealthCard
title="card.title"
metric="card.metric"
subtext="card.subtext"
status="card.status"
domain="card.domain"
onCardClick.bind="onAttentionClick"/>
</t>
</div>
<!-- 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="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 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="">
<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: Chat -->
<div class="fusion_right_panel">
<FusionChatPanel sessionId="state.chatSessionId"/>
</div>
</div>
</t>
</div>
</t>
</templates>

View File

@@ -0,0 +1,24 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class FusionHealthCard extends Component {
static template = "fusion_accounting.HealthCard";
static props = ["title", "metric", "subtext", "status", "domain", "onCardClick"];
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 icons[this.props.domain] || "fa-bar-chart";
}
onClick() {
this.props.onCardClick(this.props.domain);
}
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting.HealthCard">
<div class="fusion_health_card cursor-pointer"
t-attf-class="fusion_card_{{props.status}}"
t-on-click="onClick">
<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>