changes
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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&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">
|
||||
<!-- 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>
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user