This commit is contained in:
gsinghpal
2026-05-16 13:18:52 -04:00
parent 191a9c82be
commit 9ebf89bde2
1080 changed files with 0 additions and 1197 deletions

View File

@@ -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);
}
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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 },
};
}

View File

@@ -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>

View File

@@ -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 });
}
}

View File

@@ -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>

View File

@@ -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 "";
}
}

View File

@@ -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>

View File

@@ -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
}
}
}

View File

@@ -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">&#215;</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>

View File

@@ -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;

View File

@@ -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); }
}

View File

@@ -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);

View File

@@ -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",
},
],
});

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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);