Compare commits
8 Commits
475d17c1aa
...
9794970429
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9794970429 | ||
|
|
c0b8cc4159 | ||
|
|
51bff01f13 | ||
|
|
7ba15c65aa | ||
|
|
bf8689716c | ||
|
|
bddd22cabd | ||
|
|
6051ef22a0 | ||
|
|
24f8a5857e |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
'name': 'Fusion Accounting Assets',
|
'name': 'Fusion Accounting Assets',
|
||||||
'version': '19.0.1.0.18',
|
'version': '19.0.1.0.26',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'summary': 'AI-augmented asset management with depreciation schedules.',
|
'summary': 'AI-augmented asset management with depreciation schedules.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -37,6 +37,25 @@ menu hides; the engine + AI tools remain available for the chat.
|
|||||||
],
|
],
|
||||||
'assets': {
|
'assets': {
|
||||||
'web.assets_backend': [
|
'web.assets_backend': [
|
||||||
|
'fusion_accounting_assets/static/src/scss/_variables.scss',
|
||||||
|
'fusion_accounting_assets/static/src/scss/assets.scss',
|
||||||
|
'fusion_accounting_assets/static/src/scss/dark_mode.scss',
|
||||||
|
'fusion_accounting_assets/static/src/services/assets_service.js',
|
||||||
|
'fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard.js',
|
||||||
|
'fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard.xml',
|
||||||
|
'fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard_view.js',
|
||||||
|
'fusion_accounting_assets/static/src/components/asset_card/asset_card.js',
|
||||||
|
'fusion_accounting_assets/static/src/components/asset_card/asset_card.xml',
|
||||||
|
'fusion_accounting_assets/static/src/components/asset_detail_panel/asset_detail_panel.js',
|
||||||
|
'fusion_accounting_assets/static/src/components/asset_detail_panel/asset_detail_panel.xml',
|
||||||
|
'fusion_accounting_assets/static/src/components/depreciation_board/depreciation_board.js',
|
||||||
|
'fusion_accounting_assets/static/src/components/depreciation_board/depreciation_board.xml',
|
||||||
|
'fusion_accounting_assets/static/src/components/disposal_dialog/disposal_dialog.js',
|
||||||
|
'fusion_accounting_assets/static/src/components/disposal_dialog/disposal_dialog.xml',
|
||||||
|
'fusion_accounting_assets/static/src/components/ai_useful_life_panel/ai_useful_life_panel.js',
|
||||||
|
'fusion_accounting_assets/static/src/components/ai_useful_life_panel/ai_useful_life_panel.xml',
|
||||||
|
'fusion_accounting_assets/static/src/components/anomaly_strip/anomaly_strip.js',
|
||||||
|
'fusion_accounting_assets/static/src/components/anomaly_strip/anomaly_strip.xml',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'installable': True,
|
'installable': True,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component, useState } from "@odoo/owl";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
export class AiUsefulLifePanel extends Component {
|
||||||
|
static template = "fusion_accounting_assets.AiUsefulLifePanel";
|
||||||
|
static props = {
|
||||||
|
description: { type: String, optional: true },
|
||||||
|
amount: { type: Number, optional: true },
|
||||||
|
onSelect: { type: Function, optional: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.assets = useService("fusion_assets");
|
||||||
|
this.state = useState({
|
||||||
|
suggestion: null,
|
||||||
|
isLoading: false,
|
||||||
|
descInput: this.props.description || '',
|
||||||
|
amountInput: this.props.amount || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSuggest() {
|
||||||
|
this.state.isLoading = true;
|
||||||
|
try {
|
||||||
|
this.state.suggestion = await this.assets.suggestUsefulLife(
|
||||||
|
this.state.descInput,
|
||||||
|
parseFloat(this.state.amountInput) || null,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.state.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUseSuggestion() {
|
||||||
|
if (this.state.suggestion && this.props.onSelect) {
|
||||||
|
this.props.onSelect(this.state.suggestion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_accounting_assets.AiUsefulLifePanel">
|
||||||
|
<div style="background: white; padding: 1rem; border: 1px solid #e5e7eb; border-radius: 0.5rem;">
|
||||||
|
<h5>AI Suggest Useful Life</h5>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label>Description</label>
|
||||||
|
<input class="form-control" t-att-value="state.descInput"
|
||||||
|
t-on-input="(ev) => state.descInput = ev.target.value"/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label>Amount</label>
|
||||||
|
<input type="number" class="form-control" t-att-value="state.amountInput"
|
||||||
|
t-on-input="(ev) => state.amountInput = ev.target.value"/>
|
||||||
|
</div>
|
||||||
|
<button class="btn_asset primary" t-on-click="onSuggest"
|
||||||
|
t-att-disabled="state.isLoading">
|
||||||
|
<t t-if="state.isLoading">Asking AI...</t>
|
||||||
|
<t t-else="">Suggest</t>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div t-if="state.suggestion" class="mt-3 p-2"
|
||||||
|
style="background: #eff6ff; border-radius: 0.25rem;">
|
||||||
|
<div><strong>Suggested life:</strong> <t t-esc="state.suggestion.useful_life_years"/> years</div>
|
||||||
|
<div><strong>Method:</strong> <t t-esc="state.suggestion.depreciation_method"/></div>
|
||||||
|
<div class="text-muted small">
|
||||||
|
<em><t t-esc="state.suggestion.rationale"/></em>
|
||||||
|
(confidence: <t t-esc="(state.suggestion.confidence * 100).toFixed(0)"/>%)
|
||||||
|
</div>
|
||||||
|
<button class="btn_asset mt-2" t-if="props.onSelect" t-on-click="onUseSuggestion">
|
||||||
|
Use This
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
|
||||||
|
export class AnomalyStrip extends Component {
|
||||||
|
static template = "fusion_accounting_assets.AnomalyStrip";
|
||||||
|
static props = {
|
||||||
|
anomaly: { type: Object },
|
||||||
|
};
|
||||||
|
|
||||||
|
formatNumber(n) {
|
||||||
|
if (n === null || n === undefined) return "";
|
||||||
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
minimumFractionDigits: 0, maximumFractionDigits: 1,
|
||||||
|
}).format(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_accounting_assets.AnomalyStrip">
|
||||||
|
<div class="o_fusion_anomaly_strip" t-att-data-severity="props.anomaly.severity">
|
||||||
|
<strong>
|
||||||
|
<t t-esc="props.anomaly.asset_name || 'Asset'"/>
|
||||||
|
</strong>
|
||||||
|
<span class="ms-2">
|
||||||
|
<t t-esc="props.anomaly.anomaly_type.replace('_', ' ')"/>:
|
||||||
|
<t t-esc="formatNumber(props.anomaly.variance_pct)"/>%
|
||||||
|
</span>
|
||||||
|
<span class="ms-3 text-muted">
|
||||||
|
<t t-esc="props.anomaly.detail"/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
|
||||||
|
export class AssetCard extends Component {
|
||||||
|
static template = "fusion_accounting_assets.AssetCard";
|
||||||
|
static props = {
|
||||||
|
asset: { type: Object },
|
||||||
|
selected: { type: Boolean, optional: true },
|
||||||
|
onSelect: { type: Function },
|
||||||
|
formatCurrency: { type: Function },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_accounting_assets.AssetCard">
|
||||||
|
<div class="o_fusion_assets_card"
|
||||||
|
t-att-class="props.selected ? 'selected' : ''"
|
||||||
|
t-on-click="props.onSelect">
|
||||||
|
<div class="o_fusion_assets_card_header">
|
||||||
|
<div class="asset-name">
|
||||||
|
<t t-esc="props.asset.name"/>
|
||||||
|
<span t-if="props.asset.code" class="text-muted ms-2">
|
||||||
|
[<t t-esc="props.asset.code"/>]
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="asset-state-badge" t-att-data-state="props.asset.state">
|
||||||
|
<t t-esc="props.asset.state"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="asset-numbers">
|
||||||
|
<div>
|
||||||
|
<span class="label">Cost:</span>
|
||||||
|
<span class="value">$<t t-esc="props.formatCurrency(props.asset.cost)"/></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">Book Value:</span>
|
||||||
|
<span class="value">$<t t-esc="props.formatCurrency(props.asset.book_value)"/></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">Method:</span>
|
||||||
|
<span class="value"><t t-esc="props.asset.method"/></span>
|
||||||
|
</div>
|
||||||
|
<div t-if="props.asset.category_name">
|
||||||
|
<span class="label">Category:</span>
|
||||||
|
<span class="value"><t t-esc="props.asset.category_name"/></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { DepreciationBoard } from "../depreciation_board/depreciation_board";
|
||||||
|
|
||||||
|
export class AssetDetailPanel extends Component {
|
||||||
|
static template = "fusion_accounting_assets.AssetDetailPanel";
|
||||||
|
static props = {
|
||||||
|
detail: { type: Object },
|
||||||
|
formatCurrency: { type: Function },
|
||||||
|
};
|
||||||
|
static components = { DepreciationBoard };
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.assets = useService("fusion_assets");
|
||||||
|
}
|
||||||
|
|
||||||
|
async onComputeSchedule() {
|
||||||
|
await this.assets.computeSchedule(this.props.detail.asset.id, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onRecomputeSchedule() {
|
||||||
|
await this.assets.computeSchedule(this.props.detail.asset.id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onPostDepreciation() {
|
||||||
|
await this.assets.postDepreciation(this.props.detail.asset.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onDispose() {
|
||||||
|
const saleAmount = parseFloat(prompt("Sale amount (0 for scrap)?", "0"));
|
||||||
|
if (isNaN(saleAmount)) return;
|
||||||
|
await this.assets.disposeAsset(this.props.detail.asset.id, { saleAmount });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_accounting_assets.AssetDetailPanel">
|
||||||
|
<div style="background: white; padding: 1rem; border-radius: 0.5rem; border: 1px solid #e5e7eb;">
|
||||||
|
<h3><t t-esc="props.detail.asset.name"/></h3>
|
||||||
|
<div class="text-muted" t-if="props.detail.asset.code">
|
||||||
|
[<t t-esc="props.detail.asset.code"/>]
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<div><strong>State:</strong> <t t-esc="props.detail.asset.state"/></div>
|
||||||
|
<div><strong>Cost:</strong> $<t t-esc="props.formatCurrency(props.detail.asset.cost)"/></div>
|
||||||
|
<div><strong>Salvage:</strong> $<t t-esc="props.formatCurrency(props.detail.asset.salvage_value)"/></div>
|
||||||
|
<div><strong>Book Value:</strong> $<t t-esc="props.formatCurrency(props.detail.asset.book_value)"/></div>
|
||||||
|
<div><strong>Total Depreciated:</strong> $<t t-esc="props.formatCurrency(props.detail.asset.total_depreciated)"/></div>
|
||||||
|
<div><strong>Method:</strong> <t t-esc="props.detail.asset.method"/></div>
|
||||||
|
<div><strong>Useful Life:</strong> <t t-esc="props.detail.asset.useful_life_years"/> years</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex mt-3" style="gap: 0.5rem; flex-wrap: wrap;">
|
||||||
|
<button class="btn_asset" t-on-click="onComputeSchedule">Compute Schedule</button>
|
||||||
|
<button class="btn_asset" t-on-click="onRecomputeSchedule">Recompute</button>
|
||||||
|
<button class="btn_asset primary"
|
||||||
|
t-if="props.detail.asset.state === 'running'"
|
||||||
|
t-on-click="onPostDepreciation">Post Next</button>
|
||||||
|
<button class="btn_asset danger"
|
||||||
|
t-if="props.detail.asset.state !== 'disposed'"
|
||||||
|
t-on-click="onDispose">Dispose</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="mt-4">Depreciation Schedule</h4>
|
||||||
|
<DepreciationBoard t-if="props.detail.depreciation_lines"
|
||||||
|
lines="props.detail.depreciation_lines"
|
||||||
|
formatCurrency="props.formatCurrency"/>
|
||||||
|
|
||||||
|
<div t-if="props.detail.anomalies and props.detail.anomalies.length" class="mt-3">
|
||||||
|
<h4>Active Anomalies</h4>
|
||||||
|
<div t-foreach="props.detail.anomalies" t-as="a" t-key="a.id"
|
||||||
|
class="o_fusion_anomaly_strip" t-att-data-severity="a.severity">
|
||||||
|
<strong><t t-esc="a.anomaly_type"/></strong>: <t t-esc="a.detail"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
|
||||||
|
export class DepreciationBoard extends Component {
|
||||||
|
static template = "fusion_accounting_assets.DepreciationBoard";
|
||||||
|
static props = {
|
||||||
|
lines: { type: Array },
|
||||||
|
formatCurrency: { type: Function },
|
||||||
|
};
|
||||||
|
|
||||||
|
rowClass(line) {
|
||||||
|
if (line.is_posted) return "posted";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_accounting_assets.DepreciationBoard">
|
||||||
|
<div class="o_fusion_assets_table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th class="text-end">Amount</th>
|
||||||
|
<th class="text-end">Accumulated</th>
|
||||||
|
<th class="text-end">Book Value</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr t-foreach="props.lines" t-as="line" t-key="line.id"
|
||||||
|
t-att-class="rowClass(line)">
|
||||||
|
<td><t t-esc="line.period_index + 1"/></td>
|
||||||
|
<td><t t-esc="line.scheduled_date"/></td>
|
||||||
|
<td class="text-end">$<t t-esc="props.formatCurrency(line.amount)"/></td>
|
||||||
|
<td class="text-end">$<t t-esc="props.formatCurrency(line.accumulated)"/></td>
|
||||||
|
<td class="text-end">$<t t-esc="props.formatCurrency(line.book_value_at_end)"/></td>
|
||||||
|
<td>
|
||||||
|
<t t-if="line.is_posted">Posted</t>
|
||||||
|
<t t-else="">Pending</t>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component, useState } from "@odoo/owl";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
|
||||||
|
export class DisposalDialog extends Component {
|
||||||
|
static template = "fusion_accounting_assets.DisposalDialog";
|
||||||
|
static props = {
|
||||||
|
assetId: { type: Number },
|
||||||
|
onClose: { type: Function },
|
||||||
|
};
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.assets = useService("fusion_assets");
|
||||||
|
this.state = useState({
|
||||||
|
disposalType: 'sale',
|
||||||
|
saleAmount: 0,
|
||||||
|
saleDate: new Date().toISOString().slice(0, 10),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onConfirm() {
|
||||||
|
try {
|
||||||
|
await this.assets.disposeAsset(this.props.assetId, {
|
||||||
|
disposalType: this.state.disposalType,
|
||||||
|
saleAmount: parseFloat(this.state.saleAmount) || 0,
|
||||||
|
saleDate: this.state.saleDate,
|
||||||
|
});
|
||||||
|
this.props.onClose();
|
||||||
|
} catch (e) {
|
||||||
|
// Error already shown by service
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_accounting_assets.DisposalDialog">
|
||||||
|
<div class="modal" style="display: block; background: rgba(0,0,0,0.5); position: fixed; top:0; left:0; right:0; bottom:0; z-index: 1050;">
|
||||||
|
<div class="modal-dialog" style="margin: 5vh auto; max-width: 500px;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5>Dispose Asset</h5>
|
||||||
|
<button class="btn-close" t-on-click="props.onClose">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label>Disposal Type</label>
|
||||||
|
<select class="form-select"
|
||||||
|
t-on-change="(ev) => state.disposalType = ev.target.value">
|
||||||
|
<option value="sale" selected="state.disposalType === 'sale'">Sale</option>
|
||||||
|
<option value="scrap" selected="state.disposalType === 'scrap'">Scrap</option>
|
||||||
|
<option value="donation" selected="state.disposalType === 'donation'">Donation</option>
|
||||||
|
<option value="lost" selected="state.disposalType === 'lost'">Lost</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3" t-if="state.disposalType === 'sale'">
|
||||||
|
<label>Sale Amount ($)</label>
|
||||||
|
<input type="number" class="form-control"
|
||||||
|
t-att-value="state.saleAmount"
|
||||||
|
t-on-change="(ev) => state.saleAmount = ev.target.value"/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label>Date</label>
|
||||||
|
<input type="date" class="form-control"
|
||||||
|
t-att-value="state.saleDate"
|
||||||
|
t-on-change="(ev) => state.saleDate = ev.target.value"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn_asset" t-on-click="props.onClose">Cancel</button>
|
||||||
|
<button class="btn_asset primary" t-on-click="onConfirm">Confirm Disposal</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
40
fusion_accounting_assets/static/src/scss/_variables.scss
Normal file
40
fusion_accounting_assets/static/src/scss/_variables.scss
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Fusion assets design tokens (extends Phase 1+2's tokens for consistency).
|
||||||
|
|
||||||
|
$asset-bg-primary: #ffffff;
|
||||||
|
$asset-bg-secondary: #f9fafb;
|
||||||
|
$asset-bg-tertiary: #f3f4f6;
|
||||||
|
$asset-border: #e5e7eb;
|
||||||
|
$asset-text-primary: #111827;
|
||||||
|
$asset-text-secondary: #6b7280;
|
||||||
|
$asset-text-muted: #9ca3af;
|
||||||
|
$asset-accent: #3b82f6;
|
||||||
|
$asset-accent-bg: #eff6ff;
|
||||||
|
|
||||||
|
// State colors
|
||||||
|
$asset-state-draft: #6b7280;
|
||||||
|
$asset-state-running: #10b981;
|
||||||
|
$asset-state-paused: #f59e0b;
|
||||||
|
$asset-state-disposed: #ef4444;
|
||||||
|
|
||||||
|
// Severity colors (mirrors phase 2)
|
||||||
|
$asset-severity-high: #ef4444;
|
||||||
|
$asset-severity-high-bg: #fef2f2;
|
||||||
|
$asset-severity-medium: #f59e0b;
|
||||||
|
$asset-severity-medium-bg: #fffbeb;
|
||||||
|
$asset-severity-low: #10b981;
|
||||||
|
$asset-severity-low-bg: #ecfdf5;
|
||||||
|
|
||||||
|
$asset-space-1: 0.25rem;
|
||||||
|
$asset-space-2: 0.5rem;
|
||||||
|
$asset-space-3: 0.75rem;
|
||||||
|
$asset-space-4: 1rem;
|
||||||
|
$asset-space-6: 1.5rem;
|
||||||
|
|
||||||
|
$asset-font-size-xs: 0.75rem;
|
||||||
|
$asset-font-size-sm: 0.875rem;
|
||||||
|
$asset-font-size-base: 1rem;
|
||||||
|
$asset-font-size-lg: 1.125rem;
|
||||||
|
$asset-font-size-xl: 1.25rem;
|
||||||
|
|
||||||
|
$asset-border-radius: 0.375rem;
|
||||||
|
$asset-border-radius-md: 0.5rem;
|
||||||
157
fusion_accounting_assets/static/src/scss/assets.scss
Normal file
157
fusion_accounting_assets/static/src/scss/assets.scss
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
@import "variables";
|
||||||
|
|
||||||
|
.o_fusion_assets {
|
||||||
|
background: $asset-bg-secondary;
|
||||||
|
min-height: 100vh;
|
||||||
|
|
||||||
|
&_header {
|
||||||
|
background: $asset-bg-primary;
|
||||||
|
border-bottom: 1px solid $asset-border;
|
||||||
|
padding: $asset-space-4 $asset-space-6;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
h1 { font-size: $asset-font-size-xl; margin: 0; }
|
||||||
|
|
||||||
|
.o_fusion_assets_summary {
|
||||||
|
display: flex;
|
||||||
|
gap: $asset-space-6;
|
||||||
|
font-size: $asset-font-size-sm;
|
||||||
|
color: $asset-text-secondary;
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $asset-text-primary;
|
||||||
|
margin-left: $asset-space-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&_card {
|
||||||
|
background: $asset-bg-primary;
|
||||||
|
border: 1px solid $asset-border;
|
||||||
|
border-radius: $asset-border-radius-md;
|
||||||
|
padding: $asset-space-4;
|
||||||
|
margin-bottom: $asset-space-3;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 200ms ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $asset-accent;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
border-color: $asset-accent;
|
||||||
|
background: $asset-accent-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
&_header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: $asset-space-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: $asset-font-size-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-state-badge {
|
||||||
|
padding: $asset-space-1 $asset-space-2;
|
||||||
|
border-radius: $asset-border-radius;
|
||||||
|
font-size: $asset-font-size-xs;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
&[data-state="draft"] { background: lighten($asset-state-draft, 40%); color: $asset-state-draft; }
|
||||||
|
&[data-state="running"] { background: lighten($asset-state-running, 45%); color: $asset-state-running; }
|
||||||
|
&[data-state="paused"] { background: lighten($asset-state-paused, 35%); color: $asset-state-paused; }
|
||||||
|
&[data-state="disposed"] { background: lighten($asset-state-disposed, 35%); color: $asset-state-disposed; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-numbers {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: $asset-space-2;
|
||||||
|
font-size: $asset-font-size-sm;
|
||||||
|
color: $asset-text-secondary;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: $asset-space-2;
|
||||||
|
}
|
||||||
|
.value {
|
||||||
|
color: $asset-text-primary;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&_table {
|
||||||
|
background: $asset-bg-primary;
|
||||||
|
border-radius: $asset-border-radius-md;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: $asset-font-size-sm;
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: $asset-bg-tertiary;
|
||||||
|
padding: $asset-space-3;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $asset-text-secondary;
|
||||||
|
border-bottom: 1px solid $asset-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: $asset-space-2 $asset-space-3;
|
||||||
|
border-bottom: 1px solid lighten($asset-border, 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.posted { background: $asset-bg-secondary; }
|
||||||
|
tr.due-now { background: $asset-severity-medium-bg; }
|
||||||
|
.text-end { text-align: right; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn_asset {
|
||||||
|
padding: $asset-space-2 $asset-space-4;
|
||||||
|
border-radius: $asset-border-radius;
|
||||||
|
background: $asset-bg-primary;
|
||||||
|
border: 1px solid $asset-border;
|
||||||
|
color: $asset-text-primary;
|
||||||
|
font-size: $asset-font-size-sm;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover { background: $asset-bg-tertiary; }
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
background: $asset-accent;
|
||||||
|
border-color: $asset-accent;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover { background: darken($asset-accent, 8%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
background: $asset-severity-high;
|
||||||
|
border-color: $asset-severity-high;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fusion_anomaly_strip {
|
||||||
|
margin: $asset-space-3 0;
|
||||||
|
padding: $asset-space-3;
|
||||||
|
border-radius: $asset-border-radius;
|
||||||
|
border: 1px solid;
|
||||||
|
font-size: $asset-font-size-sm;
|
||||||
|
|
||||||
|
&[data-severity="high"] { background: $asset-severity-high-bg; border-color: $asset-severity-high; }
|
||||||
|
&[data-severity="medium"] { background: $asset-severity-medium-bg; border-color: $asset-severity-medium; }
|
||||||
|
&[data-severity="low"] { background: $asset-severity-low-bg; border-color: $asset-severity-low; }
|
||||||
|
}
|
||||||
32
fusion_accounting_assets/static/src/scss/dark_mode.scss
Normal file
32
fusion_accounting_assets/static/src/scss/dark_mode.scss
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
@import "variables";
|
||||||
|
|
||||||
|
[data-color-scheme="dark"] .o_fusion_assets {
|
||||||
|
background: #1f2937; color: #f9fafb;
|
||||||
|
|
||||||
|
&_header, &_card, &_table { background: #111827; border-color: #374151; }
|
||||||
|
|
||||||
|
&_card {
|
||||||
|
&:hover { border-color: #60a5fa; }
|
||||||
|
&.selected { background: #1e3a8a; border-color: #60a5fa; }
|
||||||
|
.asset-numbers .label { color: #9ca3af; }
|
||||||
|
.asset-numbers .value { color: #f9fafb; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&_table {
|
||||||
|
th { background: #1f2937; color: #d1d5db; }
|
||||||
|
td { border-color: #374151; }
|
||||||
|
tr.posted { background: #1f2937; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn_asset {
|
||||||
|
background: #374151; border-color: #4b5563; color: #f9fafb;
|
||||||
|
&:hover { background: #4b5563; }
|
||||||
|
&.primary { background: #3b82f6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.o_fusion_anomaly_strip {
|
||||||
|
&[data-severity="high"] { background: rgba(239, 68, 68, 0.15); }
|
||||||
|
&[data-severity="medium"] { background: rgba(245, 158, 11, 0.15); }
|
||||||
|
&[data-severity="low"] { background: rgba(16, 185, 129, 0.15); }
|
||||||
|
}
|
||||||
|
}
|
||||||
149
fusion_accounting_assets/static/src/services/assets_service.js
Normal file
149
fusion_accounting_assets/static/src/services/assets_service.js
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { reactive } from "@odoo/owl";
|
||||||
|
|
||||||
|
const ENDPOINT_BASE = "/fusion/assets";
|
||||||
|
|
||||||
|
export class AssetsService {
|
||||||
|
constructor(env, services) {
|
||||||
|
this.env = env;
|
||||||
|
this.rpc = services.rpc;
|
||||||
|
this.notification = services.notification;
|
||||||
|
|
||||||
|
this.state = reactive({
|
||||||
|
assets: [],
|
||||||
|
count: 0,
|
||||||
|
total: 0,
|
||||||
|
stateFilter: null,
|
||||||
|
categoryFilter: null,
|
||||||
|
isLoading: false,
|
||||||
|
isProcessing: false,
|
||||||
|
selectedAssetId: null,
|
||||||
|
selectedDetail: null,
|
||||||
|
companyId: null,
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
anomalies: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAssets(companyId = null) {
|
||||||
|
this.state.companyId = companyId;
|
||||||
|
this.state.isLoading = true;
|
||||||
|
try {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/list`, {
|
||||||
|
state: this.state.stateFilter,
|
||||||
|
category_id: this.state.categoryFilter,
|
||||||
|
limit: this.state.limit,
|
||||||
|
offset: this.state.offset,
|
||||||
|
company_id: companyId,
|
||||||
|
});
|
||||||
|
this.state.assets = result.assets;
|
||||||
|
this.state.count = result.count;
|
||||||
|
this.state.total = result.total;
|
||||||
|
} finally {
|
||||||
|
this.state.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectAsset(assetId) {
|
||||||
|
this.state.selectedAssetId = assetId;
|
||||||
|
this.state.selectedDetail = null;
|
||||||
|
try {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/get_detail`, {
|
||||||
|
asset_id: assetId,
|
||||||
|
});
|
||||||
|
this.state.selectedDetail = result;
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(`Failed to load asset detail: ${err.message || err}`, { type: "danger" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async computeSchedule(assetId, recompute = false) {
|
||||||
|
this.state.isProcessing = true;
|
||||||
|
try {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/compute_schedule`, {
|
||||||
|
asset_id: assetId, recompute: recompute,
|
||||||
|
});
|
||||||
|
this.notification.add(`Schedule computed (${result.lines_created} lines)`, { type: "success" });
|
||||||
|
if (this.state.selectedAssetId === assetId) {
|
||||||
|
await this.selectAsset(assetId);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(`Compute failed: ${err.message || err}`, { type: "danger" });
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.state.isProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async postDepreciation(assetId) {
|
||||||
|
this.state.isProcessing = true;
|
||||||
|
try {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/post_depreciation`, {
|
||||||
|
asset_id: assetId,
|
||||||
|
});
|
||||||
|
this.notification.add(`Posted ${result.posted_count} period(s)`, { type: "success" });
|
||||||
|
if (this.state.selectedAssetId === assetId) {
|
||||||
|
await this.selectAsset(assetId);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(`Post failed: ${err.message || err}`, { type: "danger" });
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.state.isProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async disposeAsset(assetId, { saleAmount = 0, saleDate = null, salePartnerId = null, disposalType = "sale" } = {}) {
|
||||||
|
this.state.isProcessing = true;
|
||||||
|
try {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/dispose`, {
|
||||||
|
asset_id: assetId, sale_amount: saleAmount,
|
||||||
|
sale_date: saleDate, sale_partner_id: salePartnerId,
|
||||||
|
disposal_type: disposalType,
|
||||||
|
});
|
||||||
|
this.notification.add(`Asset disposed: gain/loss $${result.gain_loss_amount.toFixed(2)}`, { type: "success" });
|
||||||
|
await this.loadAssets(this.state.companyId);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
this.notification.add(`Dispose failed: ${err.message || err}`, { type: "danger" });
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.state.isProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAnomalies(severity = null) {
|
||||||
|
try {
|
||||||
|
const result = await this.rpc(`${ENDPOINT_BASE}/get_anomalies`, {
|
||||||
|
severity: severity, company_id: this.state.companyId,
|
||||||
|
});
|
||||||
|
this.state.anomalies = result.anomalies || [];
|
||||||
|
} catch (err) {
|
||||||
|
this.state.anomalies = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async suggestUsefulLife(description, amount = null, partnerName = null) {
|
||||||
|
return await this.rpc(`${ENDPOINT_BASE}/suggest_useful_life`, {
|
||||||
|
description: description, amount: amount, partner_name: partnerName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setStateFilter(state) {
|
||||||
|
this.state.stateFilter = state;
|
||||||
|
this.state.offset = 0;
|
||||||
|
this.loadAssets(this.state.companyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const assetsService = {
|
||||||
|
dependencies: ["rpc", "notification"],
|
||||||
|
start(env, services) { return new AssetsService(env, services); },
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("services").add("fusion_assets", assetsService);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { Component, useState, onWillStart } from "@odoo/owl";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { AssetCard } from "../../components/asset_card/asset_card";
|
||||||
|
import { AssetDetailPanel } from "../../components/asset_detail_panel/asset_detail_panel";
|
||||||
|
import { AnomalyStrip } from "../../components/anomaly_strip/anomaly_strip";
|
||||||
|
|
||||||
|
export class AssetDashboard extends Component {
|
||||||
|
static template = "fusion_accounting_assets.AssetDashboard";
|
||||||
|
static props = { "*": true };
|
||||||
|
static components = { AssetCard, AssetDetailPanel, AnomalyStrip };
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.assets = useService("fusion_assets");
|
||||||
|
this.state = useState(this.assets.state);
|
||||||
|
|
||||||
|
const companyId = this.env.services.user?.context?.allowed_company_ids?.[0];
|
||||||
|
|
||||||
|
onWillStart(async () => {
|
||||||
|
await this.assets.loadAssets(companyId);
|
||||||
|
await this.assets.fetchAnomalies();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectAsset(id) {
|
||||||
|
this.assets.selectAsset(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
onStateFilter(state) {
|
||||||
|
this.assets.setStateFilter(state || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatCurrency(amount) {
|
||||||
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
minimumFractionDigits: 2, maximumFractionDigits: 2,
|
||||||
|
}).format(amount || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalCost() {
|
||||||
|
return this.state.assets.reduce((sum, a) => sum + a.cost, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalBookValue() {
|
||||||
|
return this.state.assets.reduce((sum, a) => sum + a.book_value, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="fusion_accounting_assets.AssetDashboard">
|
||||||
|
<div class="o_fusion_assets">
|
||||||
|
<div class="o_fusion_assets_header">
|
||||||
|
<div>
|
||||||
|
<h1>Asset Management</h1>
|
||||||
|
<div class="text-muted">
|
||||||
|
<t t-esc="state.count"/> of <t t-esc="state.total"/> assets
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="o_fusion_assets_summary">
|
||||||
|
<div>Cost: <span class="summary-value">$<t t-esc="formatCurrency(totalCost)"/></span></div>
|
||||||
|
<div>Book Value: <span class="summary-value">$<t t-esc="formatCurrency(totalBookValue)"/></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex" style="gap: 0.5rem; padding: 0.75rem;">
|
||||||
|
<button class="btn_asset" t-on-click="() => onStateFilter(null)"
|
||||||
|
t-att-class="state.stateFilter === null ? 'primary' : ''">All</button>
|
||||||
|
<button class="btn_asset" t-on-click="() => onStateFilter('draft')"
|
||||||
|
t-att-class="state.stateFilter === 'draft' ? 'primary' : ''">Draft</button>
|
||||||
|
<button class="btn_asset" t-on-click="() => onStateFilter('running')"
|
||||||
|
t-att-class="state.stateFilter === 'running' ? 'primary' : ''">Running</button>
|
||||||
|
<button class="btn_asset" t-on-click="() => onStateFilter('paused')"
|
||||||
|
t-att-class="state.stateFilter === 'paused' ? 'primary' : ''">Paused</button>
|
||||||
|
<button class="btn_asset" t-on-click="() => onStateFilter('disposed')"
|
||||||
|
t-att-class="state.stateFilter === 'disposed' ? 'primary' : ''">Disposed</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnomalyStrip t-foreach="state.anomalies" t-as="anomaly"
|
||||||
|
t-key="anomaly.id" anomaly="anomaly"/>
|
||||||
|
|
||||||
|
<div class="d-flex" style="gap: 1rem; padding: 1rem;">
|
||||||
|
<div style="flex: 1 1 60%;">
|
||||||
|
<div t-if="state.isLoading" class="text-center p-4 text-muted">Loading...</div>
|
||||||
|
<div t-elif="state.assets.length === 0" class="text-center p-4 text-muted">No assets found.</div>
|
||||||
|
<div t-else="">
|
||||||
|
<AssetCard t-foreach="state.assets" t-as="asset" t-key="asset.id"
|
||||||
|
asset="asset" selected="state.selectedAssetId === asset.id"
|
||||||
|
onSelect="() => onSelectAsset(asset.id)"
|
||||||
|
formatCurrency="formatCurrency.bind(this)"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1 1 40%;">
|
||||||
|
<AssetDetailPanel t-if="state.selectedDetail"
|
||||||
|
detail="state.selectedDetail"
|
||||||
|
formatCurrency="formatCurrency.bind(this)"/>
|
||||||
|
<div t-else="" class="p-4 text-muted">Select an asset to see details.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { AssetDashboard } from "./asset_dashboard";
|
||||||
|
|
||||||
|
export const fusionAssetDashboardView = {
|
||||||
|
type: "fusion_assets",
|
||||||
|
Controller: AssetDashboard,
|
||||||
|
display_name: "Fusion Asset Management",
|
||||||
|
icon: "fa-cubes",
|
||||||
|
multiRecord: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("views").add("fusion_assets", fusionAssetDashboardView);
|
||||||
Reference in New Issue
Block a user