This commit is contained in:
gsinghpal
2026-04-03 15:45:18 -04:00
parent 4cd7357aa0
commit c66bdf5089
71 changed files with 6721 additions and 118 deletions

View File

@@ -4,6 +4,48 @@ import { Component, useState, useRef, onWillStart, onMounted, onPatched } from "
import { rpc } from "@web/core/network/rpc";
import { FusionApprovalCard } from "./approval_card";
/**
* Parse a fusion-table JSON block from AI response.
* Returns {json, placeholder} or null if not a fusion-table block.
*/
function parseFusionTableBlock(text) {
// Match ```fusion-table ... ``` blocks
const regex = /```fusion-table\s*\n([\s\S]*?)```/g;
const tables = [];
let lastIndex = 0;
const parts = [];
let match;
while ((match = regex.exec(text)) !== null) {
// Add text before the block
if (match.index > lastIndex) {
parts.push({ type: "md", content: text.slice(lastIndex, match.index) });
}
// Parse the JSON
try {
const data = JSON.parse(match[1].trim());
const tableIdx = tables.length;
tables.push(data);
parts.push({ type: "table", idx: tableIdx });
} catch (e) {
// If JSON parse fails, treat as regular code block
parts.push({ type: "md", content: match[0] });
}
lastIndex = match.index + match[0].length;
}
// Remaining text after last block
if (lastIndex < text.length) {
parts.push({ type: "md", content: text.slice(lastIndex) });
}
if (tables.length === 0) {
return null;
}
return { parts, tables };
}
function mdToHtml(text) {
if (!text) return "";
@@ -150,6 +192,8 @@ export class FusionChatPanel extends Component {
setup() {
this.inputRef = useRef("chatInput");
this.messagesRef = useRef("messages");
// Track parsed table data per message index for interactive tables
this._parsedTables = {};
this.state = useState({
messages: [],
pendingApprovals: [],
@@ -158,6 +202,11 @@ export class FusionChatPanel extends Component {
loading: true,
internalSessionId: null,
sessionName: null,
// Interactive tables extracted from AI messages, keyed by msg index
interactiveTables: {},
// Session history picker
showSessionPicker: false,
sessionList: [],
});
onWillStart(async () => {
@@ -181,14 +230,240 @@ export class FusionChatPanel extends Component {
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;
// Check for fusion-table blocks
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;
}
}
}
}
}
_mountInteractiveTables(container) {
const mounts = container.querySelectorAll(".fusion_table_mount[data-table-key]");
for (const el of mounts) {
const key = el.dataset.tableKey;
if (el.dataset.mounted === "true") continue;
const tableData = this._parsedTables[key];
if (!tableData) continue;
el.dataset.mounted = "true";
el.innerHTML = this._buildInteractiveTableHtml(tableData, key);
this._wireTableEvents(el, tableData, key);
}
}
_badgeClass(action) {
switch (action) {
case "dismiss": return "bg-success-subtle text-success";
case "flag": return "bg-warning-subtle text-warning";
case "create_rule": return "bg-info-subtle text-info";
default: return "bg-secondary-subtle text-secondary";
}
}
_badgeLabel(action) {
switch (action) {
case "dismiss": return "Dismiss";
case "flag": return "Flag";
case "create_rule": return "Create Rule";
default: return action || "Review";
}
}
_esc(text) {
const d = document.createElement("div");
d.textContent = text;
return d.innerHTML;
}
_buildInteractiveTableHtml(tableData, key) {
const cols = tableData.columns || [];
const rows = tableData.rows || [];
const isInteractive = tableData.mode === "interactive";
const actions = tableData.actions || [];
const title = tableData.title || "";
let h = '<div class="fusion_interactive_table my-2">';
// Title
if (title) {
h += `<div class="d-flex align-items-center mb-2">`;
h += `<i class="fa fa-table me-2 text-muted"></i>`;
h += `<strong>${this._esc(title)}</strong>`;
h += `<span class="badge bg-secondary-subtle text-secondary ms-2">${rows.length} rows</span>`;
h += `</div>`;
}
// Table
h += '<div class="table-responsive"><table class="table table-sm table-hover align-middle mb-0"><thead><tr>';
if (isInteractive) {
h += `<th class="fit-content px-2"><input type="checkbox" class="form-check-input" data-action="select-all"/></th>`;
}
for (const col of cols) {
h += `<th class="px-2 py-1">${this._esc(col)}</th>`;
}
if (isInteractive) {
h += `<th class="px-2 py-1 text-info">AI Recommendation</th>`;
h += `<th class="px-2 py-1 text-warning" style="min-width:180px;">Your Input</th>`;
}
h += '</tr></thead><tbody>';
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
h += `<tr data-row-idx="${i}">`;
if (isInteractive) {
h += `<td class="fit-content px-2"><input type="checkbox" class="form-check-input" data-action="select-row" data-idx="${i}"/></td>`;
}
for (const cell of (row.cells || [])) {
h += `<td class="px-2 py-1">${this._esc(String(cell))}</td>`;
}
if (isInteractive) {
// Recommendation
h += `<td class="px-2 py-1">`;
if (row.recommendation) {
const rc = row.recommendation;
h += `<span class="badge me-1 ${this._badgeClass(rc.action)}">${this._badgeLabel(rc.action)}</span>`;
h += `<small class="text-muted">${this._esc(rc.reason || "")}</small>`;
}
h += `</td>`;
// User input
h += `<td class="px-2 py-1"><input type="text" class="form-control form-control-sm fusion_row_note" data-idx="${i}" placeholder="Add your note..."/></td>`;
}
h += '</tr>';
}
h += '</tbody></table></div>';
// Action bar
if (isInteractive) {
h += '<div class="fusion_table_action_bar d-flex flex-wrap align-items-center gap-2 p-2 border-top">';
h += '<small class="text-muted me-1 fusion_selected_count">0 selected</small>';
h += `<button class="btn btn-success btn-sm" data-action="apply_recommendations" disabled><i class="fa fa-check me-1"></i>Apply Recommendations</button>`;
if (actions.includes("flag")) {
h += `<button class="btn btn-outline-warning btn-sm" data-action="flag" disabled><i class="fa fa-flag me-1"></i>Flag Selected</button>`;
}
if (actions.includes("create_rule")) {
h += `<button class="btn btn-outline-info btn-sm" data-action="create_rule" disabled><i class="fa fa-plus me-1"></i>Create Rules</button>`;
}
if (actions.includes("dismiss")) {
h += `<button class="btn btn-outline-secondary btn-sm" data-action="dismiss" disabled>Dismiss Selected</button>`;
}
h += '<div class="flex-grow-1"></div>';
h += `<button class="btn btn-outline-primary btn-sm" data-action="submit_notes"><i class="fa fa-pencil me-1"></i>Submit All Notes to AI</button>`;
h += '</div>';
}
h += '</div>';
return h;
}
_wireTableEvents(container, tableData, key) {
const rows = tableData.rows || [];
// Select all checkbox
const selectAll = container.querySelector('[data-action="select-all"]');
if (selectAll) {
selectAll.addEventListener("change", () => {
const cbs = container.querySelectorAll('[data-action="select-row"]');
for (const cb of cbs) cb.checked = selectAll.checked;
this._updateTableActionBar(container);
});
}
// Individual row checkboxes
const rowCbs = container.querySelectorAll('[data-action="select-row"]');
for (const cb of rowCbs) {
cb.addEventListener("change", () => this._updateTableActionBar(container));
}
// Action buttons
const actionBtns = container.querySelectorAll('.fusion_table_action_bar button[data-action]');
for (const btn of actionBtns) {
btn.addEventListener("click", () => {
const action = btn.dataset.action;
const selectedRows = this._collectTableRows(container, tableData, action === "submit_notes");
if (selectedRows.length === 0) return;
this.onTableAction({
action,
source_tool: tableData.source_tool,
rows: selectedRows,
});
});
}
}
_updateTableActionBar(container) {
const cbs = container.querySelectorAll('[data-action="select-row"]:checked');
const count = cbs.length;
const countEl = container.querySelector('.fusion_selected_count');
if (countEl) countEl.textContent = `${count} selected`;
// Enable/disable action buttons
const btns = container.querySelectorAll('.fusion_table_action_bar button[data-action]');
for (const btn of btns) {
if (btn.dataset.action === "submit_notes") continue; // always enabled
btn.disabled = (count === 0);
}
}
_collectTableRows(container, tableData, allNotes) {
const rows = tableData.rows || [];
const result = [];
if (allNotes) {
// Collect all rows that have a note
const inputs = container.querySelectorAll('.fusion_row_note');
for (const inp of inputs) {
const idx = parseInt(inp.dataset.idx);
const note = inp.value.trim();
if (note && rows[idx]) {
result.push({
id: rows[idx].id,
cells: rows[idx].cells,
recommendation: rows[idx].recommendation,
userNote: note,
});
}
}
} else {
// Collect checked rows
const cbs = container.querySelectorAll('[data-action="select-row"]:checked');
for (const cb of cbs) {
const idx = parseInt(cb.dataset.idx);
if (rows[idx]) {
const noteInput = container.querySelector(`.fusion_row_note[data-idx="${idx}"]`);
result.push({
id: rows[idx].id,
cells: rows[idx].cells,
recommendation: rows[idx].recommendation,
userNote: noteInput ? noteInput.value.trim() : "",
});
}
}
}
return result;
}
get sessionId() {
return this.state.internalSessionId || this.props.sessionId;
}
@@ -209,17 +484,87 @@ export class FusionChatPanel extends Component {
this.scrollToBottom();
}
async toggleSessionPicker() {
if (this.state.showSessionPicker) {
this.state.showSessionPicker = false;
return;
}
try {
const data = await rpc("/fusion_accounting/session/list", { limit: 20 });
this.state.sessionList = data.sessions || [];
} catch (e) {
console.error("Failed to load session list:", e);
this.state.sessionList = [];
}
this.state.showSessionPicker = true;
}
async loadSession(sessionId) {
this.state.showSessionPicker = false;
this.state.loading = true;
try {
const data = await rpc("/fusion_accounting/session/history", { session_id: sessionId });
if (data.messages) {
this.state.internalSessionId = data.session_id;
// Filter display messages same as session/latest
const display = [];
for (const msg of data.messages) {
if (typeof msg.content === "string" && msg.content.trim()) {
display.push(msg);
} else if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block && block.type === "text" && block.text && block.text.trim()) {
display.push({ role: msg.role, content: block.text });
}
}
}
}
this.state.messages = display;
// Find session name from the list
const found = this.state.sessionList.find(s => s.id === sessionId);
this.state.sessionName = found ? found.name : `Session #${sessionId}`;
this.state.pendingApprovals = [];
this._parsedTables = {};
}
} catch (e) {
console.error("Failed to load session:", e);
}
this.state.loading = false;
this.scrollToBottom();
}
formatSessionDate(isoDate) {
if (!isoDate) return "";
try {
const d = new Date(isoDate);
return d.toLocaleDateString("en-CA", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
} catch (e) {
return isoDate;
}
}
async onNewChat() {
// Close current session first — must succeed before creating new one
if (this.sessionId) {
try {
await rpc("/fusion_accounting/session/close", { session_id: this.sessionId });
} catch (e) { /* not critical */ }
const closeResult = await rpc("/fusion_accounting/session/close", { session_id: this.sessionId });
if (closeResult.error) {
console.warn("Failed to close session:", closeResult.error);
}
} catch (e) {
console.warn("Error closing session:", e);
}
}
try {
const session = await rpc("/fusion_accounting/session/create", {});
this.state.internalSessionId = session.session_id;
this.state.sessionName = session.name;
this.state.messages = [];
this.state.pendingApprovals = [];
this._parsedTables = {};
} catch (e) {
console.error("Failed to create new session:", e);
}
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() {
@@ -258,6 +603,66 @@ export class FusionChatPanel extends Component {
this.scrollToBottom();
}
/**
* Handle actions from interactive tables (Apply, Flag, Create Rule, Dismiss, Submit Notes).
* Formats a structured message and sends it back through the chat.
*/
async onTableAction(payload) {
const { action, source_tool, rows } = payload;
const actionLabels = {
apply_recommendations: "Apply Recommendations",
flag: "Flag",
create_rule: "Create Rules",
dismiss: "Dismiss",
submit_notes: "Submit Notes",
};
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})`;
}
if (row.userNote) {
line += ` [User note: ${row.userNote}]`;
}
parts.push(line);
}
const message = parts.join("\n");
// Show user what we're sending
this.state.messages.push({
role: "user",
content: `**${label}** on ${rows.length} row(s) from ${source_tool}`,
});
this.state.sending = true;
this.scrollToBottom();
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 });
}
if (result.pending_approvals) {
this.state.pendingApprovals = result.pending_approvals;
}
} catch (e) {
this.state.messages.push({
role: "assistant",
content: `Error processing table action: ${e.message || "Something went wrong."}`,
});
}
this.state.sending = false;
this.scrollToBottom();
}
onKeyDown(ev) {
if (ev.key === "Enter" && !ev.shiftKey) {
ev.preventDefault();

View File

@@ -3,16 +3,60 @@
<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>
<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>
<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 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">

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

@@ -17,35 +17,52 @@
</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>
<!-- Main layout: Left panel (cards + needs attention) | Right panel (chat) -->
<div class="fusion_main_layout d-flex">
<!-- 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>
<!-- LEFT SIDE: Cards (2 rows of 3) + 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">
<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>
<!-- 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>
</div>
<div class="card-body overflow-auto">
<p class="text-muted">AI-prioritised items will appear here after the first audit scan.</p>
<div class="card-body overflow-auto p-2">
<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>
</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>
</t>
</div>
</div>
</div>
<!-- Chat Panel (720px = original 400 + 80%) -->
<div style="width: 720px; min-width: 600px;">
<!-- RIGHT SIDE: Chat Panel (full height, input pinned to bottom) -->
<div class="fusion_right_panel border-start">
<FusionChatPanel sessionId="state.chatSessionId"/>
</div>
</div>