changes
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
approve() {
|
||||
this.props.onApprove(this.props.approval.id);
|
||||
}
|
||||
|
||||
reject() {
|
||||
this.props.onReject(this.props.approval.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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"/>
|
||||
<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>
|
||||
<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>
|
||||
</t>
|
||||
</templates>
|
||||
304
fusion_accounting/static/src/components/chat/chat_panel.js
Normal file
304
fusion_accounting/static/src/components/chat/chat_panel.js
Normal file
@@ -0,0 +1,304 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState, useRef, onWillStart, onMounted, onPatched } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { FusionApprovalCard } from "./approval_card";
|
||||
|
||||
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.state = useState({
|
||||
messages: [],
|
||||
pendingApprovals: [],
|
||||
inputText: "",
|
||||
sending: false,
|
||||
loading: true,
|
||||
internalSessionId: null,
|
||||
sessionName: 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" && msg.content) {
|
||||
const html = mdToHtml(msg.content);
|
||||
if (div.innerHTML !== html) {
|
||||
div.innerHTML = html;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load session:", e);
|
||||
}
|
||||
this.state.loading = false;
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
async onNewChat() {
|
||||
if (this.sessionId) {
|
||||
try {
|
||||
await rpc("/fusion_accounting/session/close", { session_id: this.sessionId });
|
||||
} catch (e) { /* not critical */ }
|
||||
}
|
||||
const session = await rpc("/fusion_accounting/session/create", {});
|
||||
this.state.internalSessionId = session.session_id;
|
||||
this.state.sessionName = session.name;
|
||||
this.state.messages = [];
|
||||
this.state.pendingApprovals = [];
|
||||
}
|
||||
|
||||
async sendMessage() {
|
||||
const text = this.state.inputText.trim();
|
||||
if (!text || 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 });
|
||||
this.state.inputText = "";
|
||||
this.state.sending = true;
|
||||
this.scrollToBottom();
|
||||
|
||||
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 });
|
||||
}
|
||||
if (result.pending_approvals) {
|
||||
this.state.pendingApprovals = result.pending_approvals;
|
||||
}
|
||||
} catch (e) {
|
||||
this.state.messages.push({
|
||||
role: "assistant",
|
||||
content: `Error: ${e.message || "Something went wrong."}`,
|
||||
});
|
||||
}
|
||||
this.state.sending = false;
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
onKeyDown(ev) {
|
||||
if (ev.key === "Enter" && !ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
const el = this.messagesRef.el;
|
||||
if (el) {
|
||||
setTimeout(() => { el.scrollTop = el.scrollHeight; }, 100);
|
||||
}
|
||||
}
|
||||
|
||||
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.` });
|
||||
}
|
||||
}
|
||||
103
fusion_accounting/static/src/components/chat/chat_panel.xml
Normal file
103
fusion_accounting/static/src/components/chat/chat_panel.xml
Normal file
@@ -0,0 +1,103 @@
|
||||
<?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>
|
||||
<h5 class="mb-0 d-inline"><i class="fa fa-comments-o me-2"/>Fusion AI</h5>
|
||||
<small class="text-muted ms-2" t-if="state.sessionName" t-esc="state.sessionName"/>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary btn-sm" t-on-click="onNewChat"
|
||||
title="Start a new conversation">
|
||||
<i class="fa fa-plus me-1"/>New Chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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-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>
|
||||
</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
|
||||
</small>
|
||||
<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>
|
||||
<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">
|
||||
<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...
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Pending Approvals -->
|
||||
<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="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>
|
||||
<button class="btn btn-outline-danger btn-sm" t-on-click="onRejectAll">
|
||||
Reject 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>
|
||||
</t>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="fusion_chat_input border-top p-2">
|
||||
<div class="input-group">
|
||||
<textarea
|
||||
t-ref="chatInput"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="Ask Fusion AI..."
|
||||
rows="2"
|
||||
t-model="state.inputText"
|
||||
t-on-keydown="onKeyDown"/>
|
||||
<button class="btn btn-primary btn-sm" t-on-click="sendMessage"
|
||||
t-att-disabled="state.sending">
|
||||
<i class="fa fa-paper-plane"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,98 @@
|
||||
/** @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 onCardClick(domain) {
|
||||
if (!this.state.chatSessionId) {
|
||||
const session = await rpc("/fusion_accounting/session/create", {
|
||||
context_domain: domain,
|
||||
});
|
||||
this.state.chatSessionId = session.session_id;
|
||||
}
|
||||
}
|
||||
|
||||
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).toFixed(2)} 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)}`,
|
||||
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)}`,
|
||||
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)}`,
|
||||
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);
|
||||
@@ -0,0 +1,55 @@
|
||||
<?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 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
|
||||
</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>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-else="">
|
||||
<!-- Health Cards -->
|
||||
<div class="fusion_health_cards d-flex flex-wrap gap-3 p-3">
|
||||
<t t-foreach="cards" t-as="card" t-key="card.domain">
|
||||
<FusionHealthCard
|
||||
title="card.title"
|
||||
metric="card.metric"
|
||||
subtext="card.subtext"
|
||||
status="card.status"
|
||||
domain="card.domain"
|
||||
onCardClick.bind="onCardClick"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Action Centre + Chat -->
|
||||
<div class="d-flex gap-3 p-3" style="min-height: 500px;">
|
||||
<!-- Action Centre -->
|
||||
<div class="flex-grow-1">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Needs Attention</h5>
|
||||
</div>
|
||||
<div class="card-body overflow-auto">
|
||||
<p class="text-muted">AI-prioritised items will appear here after the first audit scan.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Panel (720px = original 400 + 80%) -->
|
||||
<div style="width: 720px; min-width: 600px;">
|
||||
<FusionChatPanel sessionId="state.chatSessionId"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,22 @@
|
||||
/** @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 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",
|
||||
};
|
||||
return map[this.props.status] || "bg-light";
|
||||
}
|
||||
|
||||
onClick() {
|
||||
this.props.onCardClick(this.props.domain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?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;"
|
||||
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>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
Reference in New Issue
Block a user