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',
|
||||
'version': '19.0.1.0.18',
|
||||
'version': '19.0.1.0.26',
|
||||
'category': 'Accounting/Accounting',
|
||||
'summary': 'AI-augmented asset management with depreciation schedules.',
|
||||
'description': """
|
||||
@@ -37,6 +37,25 @@ menu hides; the engine + AI tools remain available for the chat.
|
||||
],
|
||||
'assets': {
|
||||
'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,
|
||||
|
||||
@@ -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