changes
This commit is contained in:
@@ -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>
|
||||
@@ -0,0 +1,25 @@
|
||||
// Fusion assets design tokens.
|
||||
// COLOR uses BS5 CSS custom properties (--bs-*) declared inline in
|
||||
// assets.scss so dark mode flips automatically. SCSS vars below are
|
||||
// for spacing/typography only.
|
||||
|
||||
// Spacing
|
||||
$asset-space-1: 0.25rem;
|
||||
$asset-space-2: 0.5rem;
|
||||
$asset-space-3: 0.75rem;
|
||||
$asset-space-4: 1rem;
|
||||
$asset-space-5: 1.25rem;
|
||||
$asset-space-6: 1.5rem;
|
||||
$asset-space-8: 2rem;
|
||||
|
||||
// Typography
|
||||
$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;
|
||||
|
||||
// Borders + radii
|
||||
$asset-border-radius: 0.375rem;
|
||||
$asset-border-radius-md: 0.5rem;
|
||||
$asset-border-radius-lg: 0.75rem;
|
||||
@@ -0,0 +1,177 @@
|
||||
// Variables (spacing/typography) come from _variables.scss via manifest order.
|
||||
// COLOR uses BS5 CSS custom properties so dark mode flips automatically.
|
||||
|
||||
:root {
|
||||
--fusion-asset-accent: #3b82f6;
|
||||
--fusion-asset-accent-bg: rgba(59, 130, 246, 0.10);
|
||||
--fusion-asset-state-draft: #6b7280;
|
||||
--fusion-asset-state-draft-bg: rgba(107, 114, 128, 0.12);
|
||||
--fusion-asset-state-running: #10b981;
|
||||
--fusion-asset-state-running-bg: rgba(16, 185, 129, 0.12);
|
||||
--fusion-asset-state-paused: #f59e0b;
|
||||
--fusion-asset-state-paused-bg: rgba(245, 158, 11, 0.12);
|
||||
--fusion-asset-state-disposed: #ef4444;
|
||||
--fusion-asset-state-disposed-bg:rgba(239, 68, 68, 0.12);
|
||||
--fusion-asset-severity-high: #ef4444;
|
||||
--fusion-asset-severity-high-bg: rgba(239, 68, 68, 0.12);
|
||||
--fusion-asset-severity-medium: #f59e0b;
|
||||
--fusion-asset-severity-medium-bg:rgba(245, 158, 11, 0.12);
|
||||
--fusion-asset-severity-low: #10b981;
|
||||
--fusion-asset-severity-low-bg: rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.o_fusion_assets {
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
min-height: 100vh;
|
||||
|
||||
&_header {
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-emphasis-color);
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
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; color: inherit; }
|
||||
|
||||
.o_fusion_assets_summary {
|
||||
display: flex;
|
||||
gap: $asset-space-6;
|
||||
font-size: $asset-font-size-sm;
|
||||
color: var(--bs-secondary-color);
|
||||
|
||||
.summary-value {
|
||||
font-weight: 600;
|
||||
color: var(--bs-emphasis-color);
|
||||
margin-left: $asset-space-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&_card {
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
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: var(--fusion-asset-accent);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--fusion-asset-accent);
|
||||
background: var(--fusion-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;
|
||||
color: var(--bs-emphasis-color);
|
||||
}
|
||||
|
||||
.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: var(--fusion-asset-state-draft-bg); color: var(--fusion-asset-state-draft); }
|
||||
&[data-state="running"] { background: var(--fusion-asset-state-running-bg); color: var(--fusion-asset-state-running); }
|
||||
&[data-state="paused"] { background: var(--fusion-asset-state-paused-bg); color: var(--fusion-asset-state-paused); }
|
||||
&[data-state="disposed"] { background: var(--fusion-asset-state-disposed-bg); color: var(--fusion-asset-state-disposed); }
|
||||
}
|
||||
|
||||
.asset-numbers {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $asset-space-2;
|
||||
font-size: $asset-font-size-sm;
|
||||
color: var(--bs-secondary-color);
|
||||
|
||||
.label { font-weight: 500; margin-right: $asset-space-2; }
|
||||
.value { color: var(--bs-emphasis-color); font-weight: 500; }
|
||||
}
|
||||
}
|
||||
|
||||
&_table {
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
border-radius: $asset-border-radius-md;
|
||||
overflow: hidden;
|
||||
font-size: $asset-font-size-sm;
|
||||
|
||||
table { width: 100%; border-collapse: collapse; color: inherit; }
|
||||
|
||||
th {
|
||||
background: var(--bs-tertiary-bg);
|
||||
padding: $asset-space-3;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--bs-secondary-color);
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: $asset-space-2 $asset-space-3;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
tr.posted { background: var(--bs-secondary-bg); }
|
||||
tr.due-now { background: var(--fusion-asset-severity-medium-bg); }
|
||||
.text-end { text-align: right; }
|
||||
}
|
||||
|
||||
.btn_asset {
|
||||
padding: $asset-space-2 $asset-space-4;
|
||||
border-radius: $asset-border-radius;
|
||||
background: var(--bs-body-bg);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
color: var(--bs-body-color);
|
||||
font-size: $asset-font-size-sm;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background: var(--bs-tertiary-bg); }
|
||||
|
||||
&.primary {
|
||||
background: var(--fusion-asset-accent);
|
||||
border-color: var(--fusion-asset-accent);
|
||||
color: #ffffff;
|
||||
&:hover { filter: brightness(0.92); }
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: var(--fusion-asset-severity-high);
|
||||
border-color: var(--fusion-asset-severity-high);
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
color: var(--bs-body-color);
|
||||
|
||||
&[data-severity="high"] { background: var(--fusion-asset-severity-high-bg); border-color: var(--fusion-asset-severity-high); }
|
||||
&[data-severity="medium"] { background: var(--fusion-asset-severity-medium-bg); border-color: var(--fusion-asset-severity-medium); }
|
||||
&[data-severity="low"] { background: var(--fusion-asset-severity-low-bg); border-color: var(--fusion-asset-severity-low); }
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { reactive } from "@odoo/owl";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
const ENDPOINT_BASE = "/fusion/assets";
|
||||
|
||||
export class AssetsService {
|
||||
constructor(env, services) {
|
||||
this.env = env;
|
||||
// V19: rpc is a standalone import, not a service.
|
||||
this.rpc = 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: ["notification"],
|
||||
start(env, services) { return new AssetsService(env, services); },
|
||||
};
|
||||
|
||||
registry.category("services").add("fusion_assets", assetsService);
|
||||
@@ -0,0 +1,80 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
/**
|
||||
* 5 OWL tours for fusion_accounting_assets smoke testing.
|
||||
*
|
||||
* Each tour scripts a user interaction and is invoked from Python via
|
||||
* HttpCase.start_tour(). Useful for catching UI regressions that asset-bundle
|
||||
* compilation alone won't catch.
|
||||
*/
|
||||
|
||||
// Tour 1: smoke
|
||||
registry.category("web_tour.tours").add("fusion_assets_smoke", {
|
||||
test: true,
|
||||
url: "/odoo",
|
||||
steps: () => [
|
||||
{
|
||||
content: "Wait for app",
|
||||
trigger: ".o_navbar",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Tour 2: open asset list
|
||||
registry.category("web_tour.tours").add("fusion_assets_list", {
|
||||
test: true,
|
||||
url: "/odoo/action-fusion_accounting_assets.action_fusion_asset_list",
|
||||
steps: () => [
|
||||
{
|
||||
content: "List view loads",
|
||||
trigger: ".o_list_view, .o_view_nocontent",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Tour 3: open categories
|
||||
registry.category("web_tour.tours").add("fusion_assets_categories", {
|
||||
test: true,
|
||||
url: "/odoo/action-fusion_accounting_assets.action_fusion_asset_category_list",
|
||||
steps: () => [
|
||||
{
|
||||
content: "Categories view loads",
|
||||
trigger: ".o_list_view, .o_view_nocontent",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Tour 4: anomalies
|
||||
registry.category("web_tour.tours").add("fusion_assets_anomalies", {
|
||||
test: true,
|
||||
url: "/odoo/action-fusion_accounting_assets.action_fusion_asset_anomaly_list",
|
||||
steps: () => [
|
||||
{
|
||||
content: "Anomalies view loads",
|
||||
trigger: ".o_list_view, .o_view_nocontent",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Tour 5: depreciation run wizard
|
||||
registry.category("web_tour.tours").add("fusion_assets_depreciation_wizard", {
|
||||
test: true,
|
||||
url: "/odoo/action-fusion_accounting_assets.action_fusion_depreciation_run_wizard",
|
||||
steps: () => [
|
||||
{
|
||||
content: "Wizard form opens",
|
||||
trigger: ".modal-dialog .o_form_view",
|
||||
},
|
||||
{
|
||||
content: "Period date field exists",
|
||||
trigger: ".modal-dialog [name='period_date']",
|
||||
},
|
||||
{
|
||||
content: "Close wizard",
|
||||
trigger: ".modal-dialog .btn-secondary",
|
||||
run: "click",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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