1
0

feat(assets): implement asset management with depreciation

Add comprehensive asset management system with depreciation tracking similar to Xero.

Features:
- Create and manage fixed assets with purchase details
- Calculate depreciation using straight-line and declining balance methods
- Track asset book value and accumulated depreciation
- Dispose/sell assets with gain/loss calculations
- Automatic depreciation schedule generation
- Integration with chart of accounts for depreciation entries

Database:
- Add assets table with purchase and depreciation fields
- Add asset_depreciation_entries table for schedule tracking
- Add accumulated depreciation account to seed data

Server-side:
- Assets module with CRUD operations
- Depreciation calculation service
- Asset disposal service with gain/loss tracking
- REST API endpoints with permission guards

Frontend:
- Assets list page with data table
- Asset form with purchase and depreciation sections
- React Query hooks for API integration
- Sidebar navigation under Accounting

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ahmed Bouhuolia
2026-04-09 20:09:14 +02:00
parent 5944aa3972
commit b6a2c97f9f
43 changed files with 2615 additions and 1 deletions
@@ -756,6 +756,27 @@ export const events = {
onAccountUpdated: 'onStripeAccountUpdated', onAccountUpdated: 'onStripeAccountUpdated',
}, },
/**
* Assets service.
*/
assets: {
onCreated: 'onAssetCreated',
onCreating: 'onAssetCreating',
onEdited: 'onAssetEdited',
onEditing: 'onAssetEditing',
onDeleted: 'onAssetDeleted',
onDeleting: 'onAssetDeleting',
onDisposed: 'onAssetDisposed',
onDisposing: 'onAssetDisposing',
onDepreciationCalculated: 'onAssetDepreciationCalculated',
onDepreciationPost: 'onAssetDepreciationPost',
onDepreciationPosted: 'onAssetDepreciationPosted',
},
// Reports // Reports
reports: { reports: {
onBalanceSheetViewed: 'onBalanceSheetViewed', onBalanceSheetViewed: 'onBalanceSheetViewed',
@@ -0,0 +1,66 @@
exports.up = function(knex) {
return knex.schema.createTable('assets', (table) => {
table.increments('id').primary();
table.string('name').notNullable();
table.string('code').nullable().unique();
table.text('description').nullable();
// Asset classification
table.integer('asset_account_id').unsigned().references('id').inTable('accounts').notNullable();
table.integer('category_id').unsigned().references('id').inTable('items_categories').nullable();
// Purchase details
table.decimal('purchase_price', 15, 2).notNullable().defaultTo(0);
table.date('purchase_date').notNullable();
table.string('purchase_reference').nullable();
table.integer('purchase_transaction_id').unsigned().nullable();
table.string('purchase_transaction_type').nullable();
// Depreciation settings
table.enum('depreciation_method', [
'straight_line',
'declining_balance',
'sum_of_years_digits',
'units_of_production'
]).notNullable().defaultTo('straight_line');
table.decimal('depreciation_rate', 5, 2).nullable();
table.integer('useful_life_years').unsigned().nullable();
table.decimal('residual_value', 15, 2).notNullable().defaultTo(0);
table.date('depreciation_start_date').notNullable();
table.enum('depreciation_frequency', ['daily', 'monthly', 'yearly']).defaultTo('monthly');
// Accounts for depreciation
table.integer('depreciation_expense_account_id').unsigned().references('id').inTable('accounts').notNullable();
table.integer('accumulated_depreciation_account_id').unsigned().references('id').inTable('accounts').notNullable();
// Current values (calculated)
table.decimal('opening_depreciation', 15, 2).notNullable().defaultTo(0);
table.decimal('current_depreciation', 15, 2).notNullable().defaultTo(0);
table.decimal('total_depreciation', 15, 2).notNullable().defaultTo(0);
table.decimal('book_value', 15, 2).notNullable();
// Disposal
table.enum('status', ['active', 'fully_depreciated', 'disposed', 'sold']).defaultTo('active');
table.date('disposal_date').nullable();
table.decimal('disposal_proceeds', 15, 2).nullable();
table.decimal('disposal_gain_loss', 15, 2).nullable();
table.text('disposal_notes').nullable();
// Metadata
table.string('serial_number').nullable();
table.string('location').nullable();
table.integer('user_id').unsigned().references('id').inTable('users').notNullable();
table.boolean('active').defaultTo(true);
table.timestamps(true, true);
// Indexes
table.index('asset_account_id');
table.index('status');
table.index('purchase_date');
table.index('depreciation_start_date');
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('assets');
};
@@ -0,0 +1,33 @@
exports.up = function(knex) {
return knex.schema.createTable('asset_depreciation_entries', (table) => {
table.increments('id').primary();
table.integer('asset_id').unsigned().references('id').inTable('assets').notNullable().onDelete('CASCADE');
// Depreciation period
table.date('depreciation_date').notNullable();
table.integer('period_year').unsigned().notNullable();
table.integer('period_month').unsigned().notNullable();
// Calculated amounts
table.decimal('depreciation_amount', 15, 2).notNullable();
table.decimal('accumulated_depreciation', 15, 2).notNullable();
table.decimal('book_value', 15, 2).notNullable();
// Journal entry reference
table.integer('journal_id').unsigned().references('id').inTable('manual_journals').nullable();
table.boolean('is_posted').defaultTo(false);
table.datetime('posted_at').nullable();
table.timestamps(true, true);
// Indexes
table.index('asset_id');
table.index(['period_year', 'period_month']);
table.index('depreciation_date');
table.unique(['asset_id', 'period_year', 'period_month']);
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('asset_depreciation_entries');
};
@@ -174,6 +174,17 @@ export const AccountsData = [
description: description:
'An account that holds valuation of products or goods that available for sale.', 'An account that holds valuation of products or goods that available for sale.',
}, },
{
name: 'Accumulated Depreciation',
slug: 'accumulated-depreciation',
code: '10009',
account_type: 'fixed-asset',
predefined: 1,
parent_account_id: null,
index: 1,
active: 1,
description: 'Accumulated depreciation for fixed assets (contra-asset account).',
},
// Libilities // Libilities
{ {
@@ -98,6 +98,7 @@ import { MiscellaneousModule } from '../Miscellaneous/Miscellaneous.module';
import { UsersModule } from '../UsersModule/Users.module'; import { UsersModule } from '../UsersModule/Users.module';
import { ContactsModule } from '../Contacts/Contacts.module'; import { ContactsModule } from '../Contacts/Contacts.module';
import { BankingPlaidModule } from '../BankingPlaid/BankingPlaid.module'; import { BankingPlaidModule } from '../BankingPlaid/BankingPlaid.module';
import { AssetsModule } from '../Assets/Assets.module';
import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.module'; import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.module';
import { ExchangeRatesModule } from '../ExchangeRates/ExchangeRates.module'; import { ExchangeRatesModule } from '../ExchangeRates/ExchangeRates.module';
import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module'; import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module';
@@ -258,6 +259,7 @@ import { AppThrottleModule } from './AppThrottle.module';
ContactsModule, ContactsModule,
SocketModule, SocketModule,
ExchangeRatesModule, ExchangeRatesModule,
AssetsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [
@@ -0,0 +1,24 @@
export const ASSETS_TABLE_NAME = 'assets';
export const ASSET_DEPRECIATION_ENTRIES_TABLE_NAME = 'asset_depreciation_entries';
export const DepreciationMethods = {
STRAIGHT_LINE: 'straight_line',
DECLINING_BALANCE: 'declining_balance',
SUM_OF_YEARS_DIGITS: 'sum_of_years_digits',
UNITS_OF_PRODUCTION: 'units_of_production',
} as const;
export const DepreciationFrequencies = {
DAILY: 'daily',
MONTHLY: 'monthly',
YEARLY: 'yearly',
} as const;
export const AssetStatuses = {
ACTIVE: 'active',
FULLY_DEPRECIATED: 'fully_depreciated',
DISPOSED: 'disposed',
SOLD: 'sold',
} as const;
export const AssetDefaultViews = [];
@@ -0,0 +1,117 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseIntPipe,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { AssetsApplicationService } from './AssetsApplication.service';
import { CreateAssetDto } from './dtos/CreateAsset.dto';
import { EditAssetDto } from './dtos/EditAsset.dto';
import { DisposeAssetDto } from './dtos/DisposeAsset.dto';
import { GetAssetsQueryDto } from './dtos/GetAssetsQuery.dto';
import { BulkDeleteDto } from '@/common/dtos/BulkDelete.dto';
import { ApiCommonHeaders } from '@/decorators/ApiCommonHeaders';
import { AuthorizationGuard } from '@/modules/Auth/Guards/AuthorizationGuard';
import { PermissionGuard } from '@/modules/Auth/Guards/PermissionGuard';
import { RequirePermission } from '@/modules/Auth/Guards/RequirePermission';
import { AbilitySubject, AssetAction } from '@/constants/abilities';
@Controller('assets')
@ApiTags('Assets')
@ApiCommonHeaders()
@UseGuards(AuthorizationGuard, PermissionGuard)
export class AssetsController {
constructor(
private readonly assetsApplication: AssetsApplicationService,
) {}
@Post()
@RequirePermission(AssetAction.CREATE, AbilitySubject.Asset)
@ApiOperation({ summary: 'Create a new asset' })
@ApiResponse({ status: 201, description: 'Asset created successfully' })
async createAsset(@Body() createAssetDto: CreateAssetDto) {
const asset = await this.assetsApplication.createAsset(createAssetDto);
return { asset };
}
@Put(':id')
@RequirePermission(AssetAction.EDIT, AbilitySubject.Asset)
@ApiOperation({ summary: 'Edit an existing asset' })
@ApiResponse({ status: 200, description: 'Asset updated successfully' })
async editAsset(
@Param('id', ParseIntPipe) id: number,
@Body() editAssetDto: EditAssetDto,
) {
const asset = await this.assetsApplication.editAsset(id, editAssetDto);
return { asset };
}
@Delete(':id')
@RequirePermission(AssetAction.DELETE, AbilitySubject.Asset)
@ApiOperation({ summary: 'Delete an asset' })
@ApiResponse({ status: 200, description: 'Asset deleted successfully' })
async deleteAsset(@Param('id', ParseIntPipe) id: number) {
await this.assetsApplication.deleteAsset(id);
return { message: 'Asset deleted successfully' };
}
@Post('bulk-delete')
@HttpCode(200)
@RequirePermission(AssetAction.DELETE, AbilitySubject.Asset)
@ApiOperation({ summary: 'Bulk delete assets' })
async bulkDeleteAssets(@Body() bulkDeleteDto: BulkDeleteDto) {
await this.assetsApplication.bulkDeleteAssets(bulkDeleteDto.ids);
return { message: 'Assets deleted successfully' };
}
@Get()
@RequirePermission(AssetAction.VIEW, AbilitySubject.Asset)
@ApiOperation({ summary: 'Get list of assets' })
async getAssets(@Query() filterDto: GetAssetsQueryDto) {
const { assets, filterMeta } = await this.assetsApplication.getAssets(filterDto);
return { assets, filterMeta };
}
@Get(':id')
@RequirePermission(AssetAction.VIEW, AbilitySubject.Asset)
@ApiOperation({ summary: 'Get a single asset' })
async getAsset(@Param('id', ParseIntPipe) id: number) {
const asset = await this.assetsApplication.getAsset(id);
return { asset };
}
@Post(':id/calculate-depreciation')
@RequirePermission(AssetAction.EDIT, AbilitySubject.Asset)
@ApiOperation({ summary: 'Calculate depreciation schedule for an asset' })
async calculateDepreciation(@Param('id', ParseIntPipe) id: number) {
await this.assetsApplication.calculateDepreciation(id);
return { message: 'Depreciation calculated successfully' };
}
@Get(':id/depreciation-schedule')
@RequirePermission(AssetAction.VIEW, AbilitySubject.Asset)
@ApiOperation({ summary: 'Get depreciation schedule for an asset' })
async getDepreciationSchedule(@Param('id', ParseIntPipe) id: number) {
const schedule = await this.assetsApplication.getDepreciationSchedule(id);
return { schedule };
}
@Post(':id/dispose')
@RequirePermission(AssetAction.EDIT, AbilitySubject.Asset)
@ApiOperation({ summary: 'Dispose or sell an asset' })
async disposeAsset(
@Param('id', ParseIntPipe) id: number,
@Body() disposeDto: DisposeAssetDto,
) {
const asset = await this.assetsApplication.disposeAsset(id, disposeDto);
return { asset };
}
}
@@ -0,0 +1,43 @@
import { Module } from '@nestjs/common';
import { TenancyDatabaseModule } from '@/modules/Tenancy/TenancyDB/TenancyDB.module';
import { AssetsController } from './Assets.controller';
import { AssetsApplicationService } from './AssetsApplication.service';
import { AssetRepository } from './repositories/Asset.repository';
import { AssetDepreciationEntryRepository } from './repositories/AssetDepreciationEntry.repository';
import { CreateAssetService } from './commands/CreateAsset.service';
import { EditAssetService } from './commands/EditAsset.service';
import { DeleteAssetService } from './commands/DeleteAsset.service';
import { DisposeAssetService } from './commands/DisposeAsset.service';
import { CalculateAssetDepreciationService } from './commands/CalculateAssetDepreciation.service';
import { GetAssetService } from './queries/GetAsset.service';
import { GetAssetsService } from './queries/GetAssets.service';
import { GetAssetDepreciationScheduleService } from './queries/GetAssetDepreciationSchedule.service';
import { Asset } from './models/Asset.model';
import { AssetDepreciationEntry } from './models/AssetDepreciationEntry.model';
import { RegisterTenancyModel } from '@/modules/Tenancy/TenancyModels/TenancyModels.registry';
const models = RegisterTenancyModel([Asset, AssetDepreciationEntry]);
@Module({
imports: [TenancyDatabaseModule, ...models],
controllers: [AssetsController],
providers: [
AssetsApplicationService,
AssetRepository,
AssetDepreciationEntryRepository,
CreateAssetService,
EditAssetService,
DeleteAssetService,
DisposeAssetService,
CalculateAssetDepreciationService,
GetAssetService,
GetAssetsService,
GetAssetDepreciationScheduleService,
],
exports: [
AssetsApplicationService,
AssetRepository,
AssetDepreciationEntryRepository,
],
})
export class AssetsModule {}
@@ -0,0 +1,6 @@
export enum AssetAction {
VIEW = 'view',
CREATE = 'create',
EDIT = 'edit',
DELETE = 'delete',
}
@@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import { Asset } from './models/Asset.model';
import { AssetDepreciationEntry } from './models/AssetDepreciationEntry.model';
import { CreateAssetDto } from './dtos/CreateAsset.dto';
import { EditAssetDto } from './dtos/EditAsset.dto';
import { DisposeAssetDto } from './dtos/DisposeAsset.dto';
import { GetAssetsQueryDto } from './dtos/GetAssetsQuery.dto';
import { CreateAssetService } from './commands/CreateAsset.service';
import { EditAssetService } from './commands/EditAsset.service';
import { DeleteAssetService } from './commands/DeleteAsset.service';
import { DisposeAssetService } from './commands/DisposeAsset.service';
import { CalculateAssetDepreciationService } from './commands/CalculateAssetDepreciation.service';
import { GetAssetService } from './queries/GetAsset.service';
import { GetAssetsService } from './queries/GetAssets.service';
import { GetAssetDepreciationScheduleService } from './queries/GetAssetDepreciationSchedule.service';
@Injectable()
export class AssetsApplicationService {
constructor(
private readonly createAssetService: CreateAssetService,
private readonly editAssetService: EditAssetService,
private readonly deleteAssetService: DeleteAssetService,
private readonly disposeAssetService: DisposeAssetService,
private readonly calculateDepreciationService: CalculateAssetDepreciationService,
private readonly getAssetService: GetAssetService,
private readonly getAssetsService: GetAssetsService,
private readonly getDepreciationScheduleService: GetAssetDepreciationScheduleService,
) {}
/**
* Creates a new asset.
*/
public createAsset(dto: CreateAssetDto): Promise<Asset> {
return this.createAssetService.createAsset(dto);
}
/**
* Edits an existing asset.
*/
public editAsset(assetId: number, dto: EditAssetDto): Promise<Asset> {
return this.editAssetService.editAsset(assetId, dto);
}
/**
* Deletes an asset.
*/
public deleteAsset(assetId: number): Promise<void> {
return this.deleteAssetService.deleteAsset(assetId);
}
/**
* Bulk deletes assets.
*/
public bulkDeleteAssets(assetIds: number[]): Promise<void> {
return this.deleteAssetService.bulkDeleteAssets(assetIds);
}
/**
* Disposes an asset.
*/
public disposeAsset(assetId: number, dto: DisposeAssetDto): Promise<Asset> {
return this.disposeAssetService.disposeAsset(assetId, dto);
}
/**
* Gets a single asset.
*/
public getAsset(assetId: number): Promise<Asset> {
return this.getAssetService.getAsset(assetId);
}
/**
* Gets paginated list of assets.
*/
public getAssets(query: GetAssetsQueryDto): Promise<{ assets: Asset[]; filterMeta: any }> {
return this.getAssetsService.getAssetsList(query);
}
/**
* Calculates depreciation schedule for an asset.
*/
public calculateDepreciation(assetId: number): Promise<void> {
return this.calculateDepreciationService.calculateDepreciationSchedule(assetId);
}
/**
* Gets depreciation schedule for an asset.
*/
public getDepreciationSchedule(assetId: number): Promise<AssetDepreciationEntry[]> {
return this.getDepreciationScheduleService.getDepreciationSchedule(assetId);
}
}
@@ -0,0 +1,188 @@
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Asset } from '../models/Asset.model';
import { AssetRepository } from '../repositories/Asset.repository';
import { AssetDepreciationEntryRepository } from '../repositories/AssetDepreciationEntry.repository';
import { events } from '@/common/events/events';
interface DepreciationEntryData {
assetId: number;
depreciationDate: string;
periodYear: number;
periodMonth: number;
depreciationAmount: number;
accumulatedDepreciation: number;
bookValue: number;
isPosted: boolean;
}
@Injectable()
export class CalculateAssetDepreciationService {
constructor(
private readonly assetRepository: AssetRepository,
private readonly depreciationEntryRepository: AssetDepreciationEntryRepository,
private readonly eventEmitter: EventEmitter2,
) {}
/**
* Calculate depreciation schedule for an asset.
*/
public async calculateDepreciationSchedule(assetId: number): Promise<void> {
const asset = await this.assetRepository.findById(assetId);
if (!asset) {
throw new Error(`Asset with id ${assetId} not found`);
}
// Check if asset can be depreciated
if (asset.status !== 'active' && asset.status !== 'fully_depreciated') {
throw new Error('Cannot calculate depreciation for disposed or inactive assets');
}
// Clear existing unposted entries
await this.depreciationEntryRepository.deleteUnpostedEntries(assetId);
// Calculate based on method
switch (asset.depreciationMethod) {
case 'straight_line':
await this.calculateStraightLineDepreciation(asset);
break;
case 'declining_balance':
await this.calculateDecliningBalanceDepreciation(asset);
break;
default:
throw new Error(`Depreciation method ${asset.depreciationMethod} not implemented`);
}
// Emit event
this.eventEmitter.emit(events.assets.onDepreciationCalculated, {
assetId,
});
}
/**
* Calculate straight-line depreciation.
*/
private async calculateStraightLineDepreciation(asset: Asset): Promise<void> {
const usefulLife = asset.usefulLifeYears || 5;
const depreciableAmount = asset.purchasePrice - asset.residualValue - asset.openingDepreciation;
if (depreciableAmount <= 0) return;
const annualDepreciation = depreciableAmount / usefulLife;
const monthlyDepreciation = annualDepreciation / 12;
let accumulatedDepreciation = asset.openingDepreciation;
let bookValue = asset.purchasePrice - accumulatedDepreciation;
const startDate = new Date(asset.depreciationStartDate);
const endDate = new Date(startDate);
endDate.setFullYear(endDate.getFullYear() + usefulLife);
let currentDate = new Date(startDate);
const entries: DepreciationEntryData[] = [];
while (currentDate < endDate && bookValue > asset.residualValue) {
const year = currentDate.getFullYear();
const month = currentDate.getMonth() + 1;
// Adjust final period if needed
let periodDepreciation = monthlyDepreciation;
if (bookValue - periodDepreciation < asset.residualValue) {
periodDepreciation = bookValue - asset.residualValue;
}
accumulatedDepreciation += periodDepreciation;
bookValue -= periodDepreciation;
entries.push({
assetId: asset.id,
depreciationDate: currentDate.toISOString().split('T')[0],
periodYear: year,
periodMonth: month,
depreciationAmount: Math.round(periodDepreciation * 100) / 100,
accumulatedDepreciation: Math.round(accumulatedDepreciation * 100) / 100,
bookValue: Math.round(bookValue * 100) / 100,
isPosted: false,
});
currentDate.setMonth(currentDate.getMonth() + 1);
}
// Bulk insert entries
for (const entry of entries) {
await this.depreciationEntryRepository.create(entry);
}
}
/**
* Calculate declining balance depreciation.
*/
private async calculateDecliningBalanceDepreciation(asset: Asset): Promise<void> {
const rate = asset.depreciationRate ? asset.depreciationRate / 100 : 0.2;
const maxYears = asset.usefulLifeYears || 10;
let bookValue = asset.purchasePrice - asset.openingDepreciation;
let accumulatedDepreciation = asset.openingDepreciation;
const startDate = new Date(asset.depreciationStartDate);
let currentDate = new Date(startDate);
const endDate = new Date(startDate);
endDate.setFullYear(endDate.getFullYear() + maxYears);
const entries: DepreciationEntryData[] = [];
while (currentDate < endDate && bookValue > asset.residualValue) {
const year = currentDate.getFullYear();
const month = currentDate.getMonth() + 1;
let periodDepreciation = (bookValue * rate) / 12;
// Adjust for final period
if (bookValue - periodDepreciation < asset.residualValue) {
periodDepreciation = bookValue - asset.residualValue;
}
accumulatedDepreciation += periodDepreciation;
bookValue -= periodDepreciation;
entries.push({
assetId: asset.id,
depreciationDate: currentDate.toISOString().split('T')[0],
periodYear: year,
periodMonth: month,
depreciationAmount: Math.round(periodDepreciation * 100) / 100,
accumulatedDepreciation: Math.round(accumulatedDepreciation * 100) / 100,
bookValue: Math.round(bookValue * 100) / 100,
isPosted: false,
});
currentDate.setMonth(currentDate.getMonth() + 1);
}
// Bulk insert entries
for (const entry of entries) {
await this.depreciationEntryRepository.create(entry);
}
}
/**
* Post depreciation for a specific period.
*/
public async postPeriodDepreciation(year: number, month: number): Promise<number> {
const entries = await this.depreciationEntryRepository.findUnpostedByPeriod(year, month);
let postedCount = 0;
for (const entry of entries) {
// Emit event for journal entry creation
this.eventEmitter.emit(events.assets.onDepreciationPost, {
entryId: entry.id,
assetId: entry.assetId,
year,
month,
});
postedCount++;
}
return postedCount;
}
}
@@ -0,0 +1,54 @@
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Knex } from 'knex';
import { Asset } from '../models/Asset.model';
import { AssetRepository } from '../repositories/Asset.repository';
import { CreateAssetDto } from '../dtos/CreateAsset.dto';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { events } from '@/common/events/events';
@Injectable()
export class CreateAssetService {
constructor(
private readonly assetRepository: AssetRepository,
private readonly tenancyContext: TenancyContext,
private readonly eventEmitter: EventEmitter2,
) {}
/**
* Creates a new asset.
*/
public async createAsset(
dto: CreateAssetDto,
trx?: Knex.Transaction,
): Promise<Asset> {
const user = await this.tenancyContext.getSystemUser();
// Calculate initial book value
const bookValue = dto.purchasePrice - (dto.openingDepreciation || 0);
const assetData = {
...dto,
userId: user.id,
bookValue,
currentDepreciation: 0,
totalDepreciation: dto.openingDepreciation || 0,
status: 'active' as const,
};
// Create the asset within transaction if provided
const createQuery = trx
? this.assetRepository.model.query(trx).insert(assetData)
: this.assetRepository.model.query().insert(assetData);
const asset = await createQuery;
// Emit event
this.eventEmitter.emit(events.assets.onCreated, {
asset,
trx,
});
return asset;
}
}
@@ -0,0 +1,68 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Knex } from 'knex';
import { Asset } from '../models/Asset.model';
import { AssetRepository } from '../repositories/Asset.repository';
import { AssetDepreciationEntryRepository } from '../repositories/AssetDepreciationEntry.repository';
import { events } from '@/common/events/events';
@Injectable()
export class DeleteAssetService {
constructor(
private readonly assetRepository: AssetRepository,
private readonly depreciationEntryRepository: AssetDepreciationEntryRepository,
private readonly eventEmitter: EventEmitter2,
) {}
/**
* Deletes an asset.
*/
public async deleteAsset(
assetId: number,
trx?: Knex.Transaction,
): Promise<void> {
const asset = await this.assetRepository.findById(assetId);
if (!asset) {
throw new NotFoundException(`Asset with id ${assetId} not found`);
}
// Check if asset has posted depreciation entries
const postedEntries = await this.depreciationEntryRepository.model
.query()
.where('assetId', assetId)
.where('isPosted', true)
.first();
if (postedEntries) {
throw new Error('Cannot delete asset with posted depreciation entries');
}
// Delete unposted depreciation entries first
await this.depreciationEntryRepository.deleteUnpostedEntries(assetId);
// Delete the asset within transaction if provided
const deleteQuery = trx
? this.assetRepository.model.query(trx).deleteById(assetId)
: this.assetRepository.model.query().deleteById(assetId);
await deleteQuery;
// Emit event
this.eventEmitter.emit(events.assets.onDeleted, {
assetId,
trx,
});
}
/**
* Bulk delete assets.
*/
public async bulkDeleteAssets(
assetIds: number[],
trx?: Knex.Transaction,
): Promise<void> {
for (const assetId of assetIds) {
await this.deleteAsset(assetId, trx);
}
}
}
@@ -0,0 +1,64 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Knex } from 'knex';
import { Asset } from '../models/Asset.model';
import { AssetRepository } from '../repositories/Asset.repository';
import { DisposeAssetDto } from '../dtos/DisposeAsset.dto';
import { events } from '@/common/events/events';
@Injectable()
export class DisposeAssetService {
constructor(
private readonly assetRepository: AssetRepository,
private readonly eventEmitter: EventEmitter2,
) {}
/**
* Disposes an asset (sale or disposal).
*/
public async disposeAsset(
assetId: number,
dto: DisposeAssetDto,
trx?: Knex.Transaction,
): Promise<Asset> {
const asset = await this.assetRepository.findById(assetId);
if (!asset) {
throw new NotFoundException(`Asset with id ${assetId} not found`);
}
// Check if already disposed
if (asset.status === 'disposed' || asset.status === 'sold') {
throw new BadRequestException('Asset is already disposed or sold');
}
// Calculate gain/loss
const netBookValue = asset.netBookValue;
const disposalGainLoss = dto.disposalProceeds - netBookValue;
const updateData = {
status: dto.status,
disposalDate: dto.disposalDate,
disposalProceeds: dto.disposalProceeds,
disposalGainLoss,
disposalNotes: dto.disposalNotes,
active: false,
};
// Update the asset within transaction if provided
const updateQuery = trx
? this.assetRepository.model.query(trx).patchAndFetchById(assetId, updateData)
: this.assetRepository.model.query().patchAndFetchById(assetId, updateData);
const updatedAsset = await updateQuery;
// Emit event
this.eventEmitter.emit(events.assets.onDisposed, {
asset: updatedAsset,
netBookValue,
disposalGainLoss,
trx,
});
return updatedAsset;
}
}
@@ -0,0 +1,49 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Knex } from 'knex';
import { Asset } from '../models/Asset.model';
import { AssetRepository } from '../repositories/Asset.repository';
import { EditAssetDto } from '../dtos/EditAsset.dto';
import { events } from '@/common/events/events';
@Injectable()
export class EditAssetService {
constructor(
private readonly assetRepository: AssetRepository,
private readonly eventEmitter: EventEmitter2,
) {}
/**
* Edits an existing asset.
*/
public async editAsset(
assetId: number,
dto: EditAssetDto,
trx?: Knex.Transaction,
): Promise<Asset> {
const asset = await this.assetRepository.findById(assetId);
if (!asset) {
throw new NotFoundException(`Asset with id ${assetId} not found`);
}
// Prevent editing disposed assets
if (asset.status === 'disposed' || asset.status === 'sold') {
throw new Error('Cannot edit a disposed or sold asset');
}
// Update the asset within transaction if provided
const updateQuery = trx
? this.assetRepository.model.query(trx).patchAndFetchById(assetId, dto)
: this.assetRepository.model.query().patchAndFetchById(assetId, dto);
const updatedAsset = await updateQuery;
// Emit event
this.eventEmitter.emit(events.assets.onEdited, {
asset: updatedAsset,
trx,
});
return updatedAsset;
}
}
@@ -0,0 +1,202 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsBoolean,
IsDateString,
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
export class CreateAssetDto {
@IsString()
@MinLength(1)
@MaxLength(255)
@ApiProperty({
description: 'Asset name',
example: 'Office Laptop',
})
name: string;
@IsOptional()
@IsString()
@MaxLength(50)
@ApiProperty({
description: 'Asset code',
example: 'LAPTOP-001',
required: false,
})
code?: string;
@IsOptional()
@IsString()
@ApiProperty({
description: 'Asset description',
example: 'MacBook Pro 16-inch',
required: false,
})
description?: string;
@IsNumber()
@IsInt()
@Min(1)
@ApiProperty({
description: 'Asset account ID',
example: 1,
})
assetAccountId: number;
@IsOptional()
@IsNumber()
@IsInt()
@Min(1)
@ApiProperty({
description: 'Category ID',
example: 1,
required: false,
})
categoryId?: number;
@IsNumber()
@Min(0)
@ApiProperty({
description: 'Purchase price',
example: 2000.00,
})
purchasePrice: number;
@IsDateString()
@ApiProperty({
description: 'Purchase date',
example: '2024-01-15',
})
purchaseDate: string;
@IsOptional()
@IsString()
@MaxLength(255)
@ApiProperty({
description: 'Purchase reference (bill/invoice number)',
example: 'INV-001',
required: false,
})
purchaseReference?: string;
@IsEnum(['straight_line', 'declining_balance', 'sum_of_years_digits', 'units_of_production'])
@ApiProperty({
description: 'Depreciation method',
example: 'straight_line',
enum: ['straight_line', 'declining_balance', 'sum_of_years_digits', 'units_of_production'],
})
depreciationMethod: string;
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
@ApiProperty({
description: 'Depreciation rate (percentage, required for declining balance)',
example: 20,
required: false,
})
depreciationRate?: number;
@IsOptional()
@IsInt()
@Min(1)
@ApiProperty({
description: 'Useful life in years (required for straight-line)',
example: 5,
required: false,
})
usefulLifeYears?: number;
@IsNumber()
@Min(0)
@ApiProperty({
description: 'Residual value',
example: 200.00,
default: 0,
})
residualValue: number = 0;
@IsDateString()
@ApiProperty({
description: 'Depreciation start date',
example: '2024-02-01',
})
depreciationStartDate: string;
@IsEnum(['daily', 'monthly', 'yearly'])
@ApiProperty({
description: 'Depreciation frequency',
example: 'monthly',
enum: ['daily', 'monthly', 'yearly'],
default: 'monthly',
})
depreciationFrequency: string = 'monthly';
@IsNumber()
@IsInt()
@Min(1)
@ApiProperty({
description: 'Depreciation expense account ID',
example: 45,
})
depreciationExpenseAccountId: number;
@IsNumber()
@IsInt()
@Min(1)
@ApiProperty({
description: 'Accumulated depreciation account ID',
example: 9,
})
accumulatedDepreciationAccountId: number;
@IsOptional()
@IsNumber()
@Min(0)
@ApiProperty({
description: 'Opening depreciation (for existing assets)',
example: 0,
default: 0,
required: false,
})
openingDepreciation?: number;
@IsOptional()
@IsString()
@MaxLength(255)
@ApiProperty({
description: 'Serial number',
example: 'SN123456',
required: false,
})
serialNumber?: string;
@IsOptional()
@IsString()
@MaxLength(255)
@ApiProperty({
description: 'Asset location',
example: 'Main Office',
required: false,
})
location?: string;
@IsOptional()
@IsBoolean()
@ApiProperty({
description: 'Whether the asset is active',
example: true,
default: true,
required: false,
})
active?: boolean = true;
}
@@ -0,0 +1,45 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsDateString,
IsEnum,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
} from 'class-validator';
export class DisposeAssetDto {
@IsDateString()
@ApiProperty({
description: 'Disposal date',
example: '2024-12-31',
})
disposalDate: string;
@IsNumber()
@Min(0)
@ApiProperty({
description: 'Disposal proceeds (sale amount)',
example: 500.00,
})
disposalProceeds: number;
@IsEnum(['disposed', 'sold'])
@ApiProperty({
description: 'Disposal status',
example: 'sold',
enum: ['disposed', 'sold'],
})
status: 'disposed' | 'sold';
@IsOptional()
@IsString()
@MaxLength(1000)
@ApiProperty({
description: 'Disposal notes',
example: 'Sold to third party',
required: false,
})
disposalNotes?: string;
}
@@ -0,0 +1,86 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsBoolean,
IsDateString,
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
export class EditAssetDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(255)
@ApiProperty({
description: 'Asset name',
example: 'Office Laptop',
required: false,
})
name?: string;
@IsOptional()
@IsString()
@MaxLength(50)
@ApiProperty({
description: 'Asset code',
example: 'LAPTOP-001',
required: false,
})
code?: string;
@IsOptional()
@IsString()
@ApiProperty({
description: 'Asset description',
example: 'MacBook Pro 16-inch',
required: false,
})
description?: string;
@IsOptional()
@IsNumber()
@IsInt()
@Min(1)
@ApiProperty({
description: 'Category ID',
example: 1,
required: false,
})
categoryId?: number;
@IsOptional()
@IsString()
@MaxLength(255)
@ApiProperty({
description: 'Serial number',
example: 'SN123456',
required: false,
})
serialNumber?: string;
@IsOptional()
@IsString()
@MaxLength(255)
@ApiProperty({
description: 'Asset location',
example: 'Main Office',
required: false,
})
location?: string;
@IsOptional()
@IsBoolean()
@ApiProperty({
description: 'Whether the asset is active',
example: true,
required: false,
})
active?: boolean;
}
@@ -0,0 +1,49 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator';
import { Transform } from 'class-transformer';
export class GetAssetsQueryDto {
@IsOptional()
@IsString()
@ApiPropertyOptional({
description: 'Search query for name or code',
example: 'laptop',
})
q?: string;
@IsOptional()
@IsEnum(['active', 'fully_depreciated', 'disposed', 'sold'])
@ApiPropertyOptional({
description: 'Filter by status',
example: 'active',
enum: ['active', 'fully_depreciated', 'disposed', 'sold'],
})
status?: string;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsNumber()
@ApiPropertyOptional({
description: 'Filter by asset account ID',
example: 1,
})
assetAccountId?: number;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsNumber()
@ApiPropertyOptional({
description: 'Page number',
example: 1,
})
page?: number = 1;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
@IsNumber()
@ApiPropertyOptional({
description: 'Page size',
example: 20,
})
pageSize?: number = 20;
}
@@ -0,0 +1,29 @@
export const AssetMeta = {
tableName: 'assets',
fields: [
{ name: 'name', type: 'string' },
{ name: 'code', type: 'string' },
{ name: 'description', type: 'text' },
{ name: 'assetAccountId', type: 'number', relation: 'accounts' },
{ name: 'categoryId', type: 'number', relation: 'items_categories' },
{ name: 'purchasePrice', type: 'number' },
{ name: 'purchaseDate', type: 'date' },
{ name: 'purchaseReference', type: 'string' },
{ name: 'depreciationMethod', type: 'string' },
{ name: 'depreciationRate', type: 'number' },
{ name: 'usefulLifeYears', type: 'number' },
{ name: 'residualValue', type: 'number' },
{ name: 'depreciationStartDate', type: 'date' },
{ name: 'depreciationFrequency', type: 'string' },
{ name: 'depreciationExpenseAccountId', type: 'number', relation: 'accounts' },
{ name: 'accumulatedDepreciationAccountId', type: 'number', relation: 'accounts' },
{ name: 'openingDepreciation', type: 'number' },
{ name: 'currentDepreciation', type: 'number' },
{ name: 'totalDepreciation', type: 'number' },
{ name: 'bookValue', type: 'number' },
{ name: 'status', type: 'string' },
{ name: 'serialNumber', type: 'string' },
{ name: 'location', type: 'string' },
{ name: 'active', type: 'boolean' },
],
};
@@ -0,0 +1,121 @@
import { Model } from 'objection';
import { ExportableModel } from '@/modules/Export/decorators/ExportableModel.decorator';
import { ImportableModel } from '@/modules/Import/decorators/Import.decorator';
import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
import { AssetMeta } from './Asset.meta';
@ExportableModel()
@ImportableModel()
@InjectModelMeta(AssetMeta)
export class Asset extends TenantBaseModel {
public id!: number;
public name!: string;
public code!: string | null;
public description!: string | null;
public assetAccountId!: number;
public categoryId!: number | null;
public purchasePrice!: number;
public purchaseDate!: string;
public purchaseReference!: string | null;
public purchaseTransactionId!: number | null;
public purchaseTransactionType!: string | null;
public depreciationMethod!: 'straight_line' | 'declining_balance' | 'sum_of_years_digits' | 'units_of_production';
public depreciationRate!: number | null;
public usefulLifeYears!: number | null;
public residualValue!: number;
public depreciationStartDate!: string;
public depreciationFrequency!: 'daily' | 'monthly' | 'yearly';
public depreciationExpenseAccountId!: number;
public accumulatedDepreciationAccountId!: number;
public openingDepreciation!: number;
public currentDepreciation!: number;
public totalDepreciation!: number;
public bookValue!: number;
public status!: 'active' | 'fully_depreciated' | 'disposed' | 'sold';
public disposalDate!: string | null;
public disposalProceeds!: number | null;
public disposalGainLoss!: number | null;
public disposalNotes!: string | null;
public serialNumber!: string | null;
public location!: string | null;
public userId!: number;
public active!: boolean;
public createdAt!: string;
public updatedAt!: string;
static get tableName() {
return 'assets';
}
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
static get virtualAttributes() {
return ['netBookValue', 'isDepreciable'];
}
get netBookValue(): number {
return this.purchasePrice - this.totalDepreciation;
}
get isDepreciable(): boolean {
return this.status === 'active' && this.netBookValue > this.residualValue;
}
static get relationMappings() {
const { Account } = require('../../Accounts/models/Account.model');
const { AssetDepreciationEntry } = require('./AssetDepreciationEntry.model');
const { ItemCategory } = require('../../ItemCategories/models/ItemCategory.model');
return {
assetAccount: {
relation: Model.BelongsToOneRelation,
modelClass: Account,
join: { from: 'assets.assetAccountId', to: 'accounts.id' },
},
depreciationExpenseAccount: {
relation: Model.BelongsToOneRelation,
modelClass: Account,
join: { from: 'assets.depreciationExpenseAccountId', to: 'accounts.id' },
},
accumulatedDepreciationAccount: {
relation: Model.BelongsToOneRelation,
modelClass: Account,
join: { from: 'assets.accumulatedDepreciationAccountId', to: 'accounts.id' },
},
category: {
relation: Model.BelongsToOneRelation,
modelClass: ItemCategory,
join: { from: 'assets.categoryId', to: 'items_categories.id' },
},
depreciationEntries: {
relation: Model.HasManyRelation,
modelClass: AssetDepreciationEntry,
join: { from: 'assets.id', to: 'asset_depreciation_entries.assetId' },
},
};
}
static get modifiers() {
return {
active(query, active = true) {
query.where('assets.active', active);
},
byStatus(query, status: string) {
query.where('assets.status', status);
},
depreciable(query) {
query.whereIn('assets.status', ['active', 'fully_depreciated']);
},
};
}
static get searchRoles() {
return [
{ condition: 'or', fieldKey: 'name', comparator: 'contains' },
{ condition: 'or', fieldKey: 'code', comparator: 'like' },
];
}
}
@@ -0,0 +1,44 @@
import { Model } from 'objection';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
export class AssetDepreciationEntry extends TenantBaseModel {
public id!: number;
public assetId!: number;
public depreciationDate!: string;
public periodYear!: number;
public periodMonth!: number;
public depreciationAmount!: number;
public accumulatedDepreciation!: number;
public bookValue!: number;
public journalId!: number | null;
public isPosted!: boolean;
public postedAt!: string | null;
public createdAt!: string;
public updatedAt!: string;
static get tableName() {
return 'asset_depreciation_entries';
}
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
static get relationMappings() {
const { Asset } = require('./Asset.model');
const { ManualJournal } = require('../../ManualJournals/models/ManualJournal');
return {
asset: {
relation: Model.BelongsToOneRelation,
modelClass: Asset,
join: { from: 'asset_depreciation_entries.assetId', to: 'assets.id' },
},
journal: {
relation: Model.BelongsToOneRelation,
modelClass: ManualJournal,
join: { from: 'asset_depreciation_entries.journalId', to: 'manual_journals.id' },
},
};
}
}
@@ -0,0 +1,21 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Asset } from '../models/Asset.model';
import { AssetRepository } from '../repositories/Asset.repository';
@Injectable()
export class GetAssetService {
constructor(
private readonly assetRepository: AssetRepository,
) {}
/**
* Get a single asset by ID.
*/
public async getAsset(assetId: number): Promise<Asset> {
const asset = await this.assetRepository.findById(assetId);
if (!asset) {
throw new NotFoundException(`Asset with id ${assetId} not found`);
}
return asset;
}
}
@@ -0,0 +1,25 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { AssetDepreciationEntry } from '../models/AssetDepreciationEntry.model';
import { AssetRepository } from '../repositories/Asset.repository';
import { AssetDepreciationEntryRepository } from '../repositories/AssetDepreciationEntry.repository';
@Injectable()
export class GetAssetDepreciationScheduleService {
constructor(
private readonly assetRepository: AssetRepository,
private readonly depreciationEntryRepository: AssetDepreciationEntryRepository,
) {}
/**
* Get depreciation schedule for an asset.
*/
public async getDepreciationSchedule(assetId: number): Promise<AssetDepreciationEntry[]> {
const asset = await this.assetRepository.findById(assetId);
if (!asset) {
throw new NotFoundException(`Asset with id ${assetId} not found`);
}
const entries = await this.depreciationEntryRepository.findByAssetId(assetId);
return entries;
}
}
@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { Asset } from '../models/Asset.model';
import { AssetRepository } from '../repositories/Asset.repository';
import { GetAssetsQueryDto } from '../dtos/GetAssetsQuery.dto';
interface IFilterMeta {
total: number;
pagesCount: number;
}
@Injectable()
export class GetAssetsService {
constructor(
private readonly assetRepository: AssetRepository,
) {}
/**
* Get paginated list of assets.
*/
public async getAssetsList(
query: GetAssetsQueryDto,
): Promise<{ assets: Asset[]; filterMeta: IFilterMeta }> {
const { assets, total } = await this.assetRepository.getAssets({
q: query.q,
status: query.status,
assetAccountId: query.assetAccountId,
page: query.page,
pageSize: query.pageSize,
});
const pagesCount = Math.ceil(total / (query.pageSize || 20));
return {
assets,
filterMeta: {
total,
pagesCount,
},
};
}
}
@@ -0,0 +1,89 @@
import { Injectable, Scope } from '@nestjs/common';
import { Knex } from 'knex';
import { Inject } from '@nestjs/common';
import { TenantRepository } from '@/modules/Tenancy/TenancyDB/TenantRepository';
import { TENANCY_DB_CONNECTION } from '@/modules/Tenancy/TenancyDB/TenancyDB.constants';
import { Asset } from '../models/Asset.model';
@Injectable({ scope: Scope.REQUEST })
export class AssetRepository extends TenantRepository {
constructor(
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantDBKnex: () => Knex,
) {
super();
}
get model(): typeof Asset {
return Asset.bindKnex(this.tenantDBKnex());
}
/**
* Find asset by ID with relations.
*/
async findById(id: number): Promise<Asset | undefined> {
return this.model
.query()
.findById(id)
.withGraphFetched('[assetAccount, depreciationExpenseAccount, accumulatedDepreciationAccount, category]');
}
/**
* Find asset by code.
*/
async findByCode(code: string): Promise<Asset | undefined> {
return this.model
.query()
.where('code', code)
.first();
}
/**
* Find one or fail.
*/
async findOneOrFail(id: number): Promise<Asset> {
const asset = await this.findById(id);
if (!asset) {
throw new Error(`Asset with id ${id} not found`);
}
return asset;
}
/**
* Paginated list of assets.
*/
async getAssets(filters: {
q?: string;
status?: string;
assetAccountId?: number;
page?: number;
pageSize?: number;
}): Promise<{ assets: Asset[]; total: number }> {
const { q, status, assetAccountId, page = 1, pageSize = 20 } = filters;
let query = this.model
.query()
.withGraphFetched('[assetAccount, category]');
if (q) {
query = query.where((builder) => {
builder.where('name', 'like', `%${q}%`).orWhere('code', 'like', `%${q}%`);
});
}
if (status) {
query = query.where('status', status);
}
if (assetAccountId) {
query = query.where('assetAccountId', assetAccountId);
}
const total = await query.clone().resultSize();
const assets = await query
.orderBy('createdAt', 'desc')
.page(page - 1, pageSize);
return { assets: assets.results, total };
}
}
@@ -0,0 +1,89 @@
import { Injectable, Scope } from '@nestjs/common';
import { Knex } from 'knex';
import { Inject } from '@nestjs/common';
import { TenantRepository } from '@/modules/Tenancy/TenancyDB/TenantRepository';
import { TENANCY_DB_CONNECTION } from '@/modules/Tenancy/TenancyDB/TenancyDB.constants';
import { AssetDepreciationEntry } from '../models/AssetDepreciationEntry.model';
@Injectable({ scope: Scope.REQUEST })
export class AssetDepreciationEntryRepository extends TenantRepository {
constructor(
@Inject(TENANCY_DB_CONNECTION)
private readonly tenantDBKnex: () => Knex,
) {
super();
}
get model(): typeof AssetDepreciationEntry {
return AssetDepreciationEntry.bindKnex(this.tenantDBKnex());
}
/**
* Find entries by asset ID.
*/
async findByAssetId(assetId: number): Promise<AssetDepreciationEntry[]> {
return this.model
.query()
.where('assetId', assetId)
.orderBy(['periodYear', 'periodMonth']);
}
/**
* Find unposted entries by period.
*/
async findUnpostedByPeriod(year: number, month: number): Promise<AssetDepreciationEntry[]> {
return this.model
.query()
.where('periodYear', year)
.where('periodMonth', month)
.where('isPosted', false);
}
/**
* Find entries by asset and period.
*/
async findByPeriod(assetId: number, year: number, month: number): Promise<AssetDepreciationEntry[]> {
return this.model
.query()
.where('assetId', assetId)
.where('periodYear', year)
.where('periodMonth', month);
}
/**
* Delete unposted entries for an asset.
*/
async deleteUnpostedEntries(assetId: number): Promise<void> {
await this.model
.query()
.where('assetId', assetId)
.where('isPosted', false)
.delete();
}
/**
* Create a depreciation entry.
*/
async create(data: Partial<AssetDepreciationEntry>): Promise<AssetDepreciationEntry> {
return this.model.query().insert(data);
}
/**
* Update a depreciation entry.
*/
async update(id: number, data: Partial<AssetDepreciationEntry>): Promise<AssetDepreciationEntry> {
return this.model.query().patchAndFetchById(id, data);
}
/**
* Get the last posted entry for an asset.
*/
async getLastPostedEntry(assetId: number): Promise<AssetDepreciationEntry | undefined> {
return this.model
.query()
.where('assetId', assetId)
.where('isPosted', true)
.orderBy(['periodYear', 'periodMonth'], 'desc')
.first();
}
}
@@ -1,4 +1,5 @@
import { ItemAction } from "@/interfaces/Item"; import { ItemAction } from "@/interfaces/Item";
import { AssetAction } from "../Assets/Assets.types";
import { ReportsAction } from "../FinancialStatements/types/Report.types"; import { ReportsAction } from "../FinancialStatements/types/Report.types";
import { InventoryAdjustmentAction } from "../InventoryAdjutments/types/InventoryAdjustments.types"; import { InventoryAdjustmentAction } from "../InventoryAdjutments/types/InventoryAdjustments.types";
import { CashflowAction } from "../BankingTransactions/types/BankingTransactions.types"; import { CashflowAction } from "../BankingTransactions/types/BankingTransactions.types";
@@ -305,6 +306,16 @@ export const AbilitySchema: ISubjectAbilitiesSchema[] = [
}, },
], ],
}, },
{
subject: AbilitySubject.Asset,
subjectLabel: 'ability.assets',
abilities: [
{ key: AssetAction.VIEW, label: 'ability.view', default: true },
{ key: AssetAction.CREATE, label: 'ability.create', default: true },
{ key: AssetAction.EDIT, label: 'ability.edit', default: true },
{ key: AssetAction.DELETE, label: 'ability.delete', default: true },
],
},
]; ];
/** /**
@@ -60,7 +60,8 @@ export enum AbilitySubject {
CreditNote = 'CreditNode', CreditNote = 'CreditNode',
VendorCredit = 'VendorCredit', VendorCredit = 'VendorCredit',
Project = 'Project', Project = 'Project',
TaxRate = 'TaxRate' TaxRate = 'TaxRate',
Asset = 'Asset',
} }
export interface IRoleCreatedPayload { export interface IRoleCreatedPayload {
@@ -23,6 +23,7 @@ export const AbilitySubject = {
Project: 'Project', Project: 'Project',
TaxRate: 'TaxRate', TaxRate: 'TaxRate',
BankRule: 'BankRule', BankRule: 'BankRule',
Asset: 'Asset',
}; };
export const ItemAction = { export const ItemAction = {
@@ -202,3 +203,10 @@ export const BankRuleAction = {
Edit: 'Edit', Edit: 'Edit',
Delete: 'Delete', Delete: 'Delete',
}; };
export const AssetAction = {
View: 'View',
Create: 'Create',
Edit: 'Edit',
Delete: 'Delete',
};
@@ -25,6 +25,7 @@ import {
CashflowAction, CashflowAction,
PreferencesAbility, PreferencesAbility,
TaxRateAction, TaxRateAction,
AssetAction,
} from '@/constants/abilityOption'; } from '@/constants/abilityOption';
import { DialogsName } from './dialogs'; import { DialogsName } from './dialogs';
@@ -416,6 +417,15 @@ export const SidebarMenu = [
ability: TaxRateAction.View, ability: TaxRateAction.View,
}, },
}, },
{
text: <T id={'sidebar.assets'} />,
href: '/assets',
type: ISidebarMenuItemType.Link,
permission: {
subject: AbilitySubject.Asset,
ability: AssetAction.View,
},
},
], ],
}, },
{ {
@@ -0,0 +1,139 @@
// @ts-nocheck
import React from 'react';
import { Formik } from 'formik';
import * as Yup from 'yup';
import { DashboardCard, DashboardInsider } from '@/components';
import { useAssetFormContext } from './AssetFormProvider';
import { AssetFormFields } from './AssetFormFields';
import { AppToaster } from '@/components';
import { Intent } from '@blueprintjs/core';
import intl from 'react-intl-universal';
/**
* Asset form schema.
*/
const AssetFormSchema = Yup.object().shape({
name: Yup.string().required('Asset name is required'),
code: Yup.string().nullable(),
assetAccountId: Yup.number().required('Asset account is required'),
purchasePrice: Yup.number().min(0).required('Purchase price is required'),
purchaseDate: Yup.date().required('Purchase date is required'),
depreciationMethod: Yup.string().oneOf(['straight_line', 'declining_balance']).required(),
depreciationRate: Yup.number().when('depreciationMethod', {
is: 'declining_balance',
then: Yup.number().required('Depreciation rate is required'),
}),
usefulLifeYears: Yup.number().when('depreciationMethod', {
is: 'straight_line',
then: Yup.number().required('Useful life is required'),
}),
residualValue: Yup.number().min(0).default(0),
depreciationStartDate: Yup.date().required('Depreciation start date is required'),
depreciationExpenseAccountId: Yup.number().required('Depreciation expense account is required'),
accumulatedDepreciationAccountId: Yup.number().required('Accumulated depreciation account is required'),
});
/**
* Asset form initial values.
*/
function useInitialValues(asset) {
return React.useMemo(() => {
if (asset) {
return {
name: asset.name || '',
code: asset.code || '',
description: asset.description || '',
assetAccountId: asset.assetAccountId || '',
categoryId: asset.categoryId || '',
purchasePrice: asset.purchasePrice || 0,
purchaseDate: asset.purchaseDate || '',
purchaseReference: asset.purchaseReference || '',
depreciationMethod: asset.depreciationMethod || 'straight_line',
depreciationRate: asset.depreciationRate || '',
usefulLifeYears: asset.usefulLifeYears || '',
residualValue: asset.residualValue || 0,
depreciationStartDate: asset.depreciationStartDate || '',
depreciationFrequency: asset.depreciationFrequency || 'monthly',
depreciationExpenseAccountId: asset.depreciationExpenseAccountId || '',
accumulatedDepreciationAccountId: asset.accumulatedDepreciationAccountId || '',
openingDepreciation: asset.openingDepreciation || 0,
serialNumber: asset.serialNumber || '',
location: asset.location || '',
};
}
return {
name: '',
code: '',
description: '',
assetAccountId: '',
categoryId: '',
purchasePrice: 0,
purchaseDate: '',
purchaseReference: '',
depreciationMethod: 'straight_line',
depreciationRate: '',
usefulLifeYears: '',
residualValue: 0,
depreciationStartDate: '',
depreciationFrequency: 'monthly',
depreciationExpenseAccountId: '',
accumulatedDepreciationAccountId: '',
openingDepreciation: 0,
serialNumber: '',
location: '',
};
}, [asset]);
}
/**
* Asset form.
*/
export function AssetForm({ assetId, onSubmitSuccess, onCancel }) {
const { isNewMode, asset, isAssetLoading, isSubmitting, createAssetMutate, editAssetMutate } = useAssetFormContext();
const initialValues = useInitialValues(asset);
const handleSubmit = async (values, { setSubmitting, setErrors }) => {
try {
if (isNewMode) {
await createAssetMutate(values);
AppToaster.show({
message: intl.get('asset_created_successfully'),
intent: Intent.SUCCESS,
});
} else {
await editAssetMutate([assetId, values]);
AppToaster.show({
message: intl.get('asset_updated_successfully'),
intent: Intent.SUCCESS,
});
}
onSubmitSuccess?.();
} catch (error) {
if (error.response?.data?.errors) {
setErrors(error.response.data.errors);
} else {
AppToaster.show({
message: error.message || intl.get('error_saving_asset'),
intent: Intent.DANGER,
});
}
} finally {
setSubmitting(false);
}
};
return (
<DashboardInsider loading={isAssetLoading} name="asset-form">
<DashboardCard page>
<Formik
enableReinitialize={true}
initialValues={initialValues}
validationSchema={AssetFormSchema}
onSubmit={handleSubmit}
>
<AssetFormFields onCancel={onCancel} isSubmitting={isSubmitting} />
</Formik>
</DashboardCard>
</DashboardInsider>
);
}
@@ -0,0 +1,225 @@
// @ts-nocheck
import React from 'react';
import { Form, useFormikContext } from 'formik';
import {
FormGroup,
InputGroup,
HTMLSelect,
Button,
Intent,
} from '@blueprintjs/core';
import { FormattedMessage as T } from '@/components';
import { useAccounts } from '@/hooks/query';
/**
* Form field component.
*/
function Field({ name, label, children, helperText }) {
const { errors, touched } = useFormikContext();
const hasError = touched[name] && errors[name];
return (
<FormGroup
label={label}
helperText={hasError ? errors[name] : helperText}
intent={hasError ? Intent.DANGER : Intent.NONE}
>
{children}
</FormGroup>
);
}
/**
* Asset form fields.
*/
export function AssetFormFields({ onCancel, isSubmitting }) {
const { values, handleChange, handleBlur, setFieldValue } = useFormikContext();
const { data: accountsData } = useAccounts({}, { keepPreviousData: true });
const fixedAssetAccounts = React.useMemo(() => {
return accountsData?.accounts?.filter(
(account) => account.accountType === 'fixed-asset'
) || [];
}, [accountsData]);
const expenseAccounts = React.useMemo(() => {
return accountsData?.accounts?.filter(
(account) => account.accountType === 'expense'
) || [];
}, [accountsData]);
return (
<Form>
<div className="page-form">
<div className="page-form__body">
<h3><T id="asset_details" /></h3>
<Field name="name" label={<T id="asset_name" />}>
<InputGroup
name="name"
value={values.name}
onChange={handleChange}
onBlur={handleBlur}
/>
</Field>
<Field name="code" label={<T id="asset_code" />}>
<InputGroup
name="code"
value={values.code}
onChange={handleChange}
onBlur={handleBlur}
/>
</Field>
<Field name="description" label={<T id="description" />}>
<InputGroup
name="description"
value={values.description}
onChange={handleChange}
onBlur={handleBlur}
/>
</Field>
<Field name="assetAccountId" label={<T id="asset_account" />}>
<HTMLSelect
name="assetAccountId"
value={values.assetAccountId}
onChange={handleChange}
options={[
{ label: 'Select Account', value: '' },
...fixedAssetAccounts.map((acc) => ({
label: acc.name,
value: acc.id,
})),
]}
/>
</Field>
<h3><T id="purchase_details" /></h3>
<Field name="purchasePrice" label={<T id="purchase_price" />}>
<InputGroup
name="purchasePrice"
type="number"
value={values.purchasePrice}
onChange={handleChange}
onBlur={handleBlur}
/>
</Field>
<Field name="purchaseDate" label={<T id="purchase_date" />}>
<InputGroup
name="purchaseDate"
type="date"
value={values.purchaseDate}
onChange={handleChange}
onBlur={handleBlur}
/>
</Field>
<h3><T id="depreciation_settings" /></h3>
<Field name="depreciationMethod" label={<T id="depreciation_method" />}>
<HTMLSelect
name="depreciationMethod"
value={values.depreciationMethod}
onChange={handleChange}
options={[
{ label: 'Straight Line', value: 'straight_line' },
{ label: 'Declining Balance', value: 'declining_balance' },
]}
/>
</Field>
{values.depreciationMethod === 'straight_line' && (
<Field name="usefulLifeYears" label={<T id="useful_life_years" />}>
<InputGroup
name="usefulLifeYears"
type="number"
value={values.usefulLifeYears}
onChange={handleChange}
onBlur={handleBlur}
/>
</Field>
)}
{values.depreciationMethod === 'declining_balance' && (
<Field name="depreciationRate" label={<T id="depreciation_rate_percent" />}>
<InputGroup
name="depreciationRate"
type="number"
value={values.depreciationRate}
onChange={handleChange}
onBlur={handleBlur}
/>
</Field>
)}
<Field name="residualValue" label={<T id="residual_value" />}>
<InputGroup
name="residualValue"
type="number"
value={values.residualValue}
onChange={handleChange}
onBlur={handleBlur}
/>
</Field>
<Field name="depreciationStartDate" label={<T id="depreciation_start_date" />}>
<InputGroup
name="depreciationStartDate"
type="date"
value={values.depreciationStartDate}
onChange={handleChange}
onBlur={handleBlur}
/>
</Field>
<Field name="depreciationExpenseAccountId" label={<T id="depreciation_expense_account" />}>
<HTMLSelect
name="depreciationExpenseAccountId"
value={values.depreciationExpenseAccountId}
onChange={handleChange}
options={[
{ label: 'Select Account', value: '' },
...expenseAccounts.map((acc) => ({
label: acc.name,
value: acc.id,
})),
]}
/>
</Field>
<Field name="accumulatedDepreciationAccountId" label={<T id="accumulated_depreciation_account" />}>
<HTMLSelect
name="accumulatedDepreciationAccountId"
value={values.accumulatedDepreciationAccountId}
onChange={handleChange}
options={[
{ label: 'Select Account', value: '' },
...fixedAssetAccounts.map((acc) => ({
label: acc.name,
value: acc.id,
})),
]}
/>
</Field>
</div>
<div className="page-form__floating-actions">
<Button onClick={onCancel}>
<T id="cancel" />
</Button>
<Button
type="submit"
intent={Intent.PRIMARY}
loading={isSubmitting}
>
<T id="save" />
</Button>
</div>
</div>
</Form>
);
}
@@ -0,0 +1,31 @@
// @ts-nocheck
import React from 'react';
import { useParams, useHistory } from 'react-router-dom';
import { AssetForm } from './AssetForm';
import { AssetFormProvider } from './AssetFormProvider';
/**
* Asset form page.
*/
export default function AssetFormPage() {
const { id } = useParams();
const history = useHistory();
const handleSubmitSuccess = () => {
history.push('/assets');
};
const handleCancel = () => {
history.goBack();
};
return (
<AssetFormProvider assetId={id}>
<AssetForm
assetId={id}
onSubmitSuccess={handleSubmitSuccess}
onCancel={handleCancel}
/>
</AssetFormProvider>
);
}
@@ -0,0 +1,44 @@
// @ts-nocheck
import React, { createContext, useContext, useMemo } from 'react';
import { useAsset, useCreateAsset, useEditAsset } from '@/hooks/query/assets';
const AssetFormContext = createContext();
/**
* Asset form provider.
*/
export function AssetFormProvider({ assetId, children }) {
const isNewMode = !assetId;
const { data: asset, isLoading: isAssetLoading } = useAsset(assetId, {
enabled: !!assetId,
});
const createAssetMutation = useCreateAsset();
const editAssetMutation = useEditAsset();
const isSubmitting = createAssetMutation.isLoading || editAssetMutation.isLoading;
const value = {
assetId,
asset,
isNewMode,
isAssetLoading,
isSubmitting,
createAssetMutate: createAssetMutation.mutateAsync,
editAssetMutate: editAssetMutation.mutateAsync,
};
return (
<AssetFormContext.Provider value={value}>
{children}
</AssetFormContext.Provider>
);
}
/**
* Hook to use asset form context.
*/
export function useAssetFormContext() {
return useContext(AssetFormContext);
}
@@ -0,0 +1,34 @@
// @ts-nocheck
import React from 'react';
import { useHistory } from 'react-router-dom';
import {
DashboardActionsBar,
Button,
FormattedMessage as T,
} from '@/components';
import { Icon } from '@blueprintjs/core';
import { useAssetsListContext } from './AssetsListProvider';
/**
* Assets actions bar.
*/
export function AssetsActionsBar() {
const history = useHistory();
const { selectedRows } = useAssetsListContext();
const handleAddAsset = () => {
history.push('/assets/new');
};
return (
<DashboardActionsBar>
<Button
intent="primary"
onClick={handleAddAsset}
icon={<Icon icon="plus" iconSize={16} />}
>
<T id={'new_asset'} />
</Button>
</DashboardActionsBar>
);
}
@@ -0,0 +1,105 @@
// @ts-nocheck
import React from 'react';
import { useHistory } from 'react-router-dom';
import {
DashboardContentTable,
DataTable,
TableSkeletonRows,
TableSkeletonHeader,
} from '@/components';
import { useAssetsListContext } from './AssetsListProvider';
import { useMemorizedColumnsWidths } from '@/hooks';
import { TABLES } from '@/constants/tables';
/**
* Assets data table columns.
*/
function useAssetsTableColumns() {
return React.useMemo(
() => [
{
Header: 'Name',
accessor: 'name',
width: 200,
},
{
Header: 'Code',
accessor: 'code',
width: 100,
},
{
Header: 'Purchase Price',
accessor: 'purchasePrice',
width: 120,
Cell: ({ value }) => value?.toLocaleString(),
},
{
Header: 'Book Value',
accessor: 'bookValue',
width: 120,
Cell: ({ value }) => value?.toLocaleString(),
},
{
Header: 'Status',
accessor: 'status',
width: 120,
},
{
Header: 'Purchase Date',
accessor: 'purchaseDate',
width: 120,
},
],
[],
);
}
/**
* Assets data table.
*/
export function AssetsDataTable() {
const history = useHistory();
const { assets, pagination, isAssetsLoading, isAssetsFetching, setTableState } = useAssetsListContext();
const columns = useAssetsTableColumns();
const [initialColumnsWidths, , handleColumnResizing] = useMemorizedColumnsWidths(TABLES.ITEMS);
const handleFetchData = React.useCallback(
({ pageSize, pageIndex, sortBy }) => {
setTableState({ pageIndex, pageSize, sortBy });
},
[setTableState],
);
const handleCellClick = (cell) => {
const assetId = cell.row.original.id;
history.push(`/assets/${assetId}/edit`);
};
return (
<DashboardContentTable>
<DataTable
columns={columns}
data={assets}
loading={isAssetsLoading}
headerLoading={isAssetsLoading}
progressBarLoading={isAssetsFetching}
noInitialFetch={true}
selectionColumn={true}
manualSortBy={true}
manualPagination={true}
pagesCount={pagination?.pagesCount}
pagination={true}
autoResetSortBy={false}
autoResetPage={true}
TableLoadingRenderer={TableSkeletonRows}
TableHeaderSkeletonRenderer={TableSkeletonHeader}
onFetchData={handleFetchData}
onCellClick={handleCellClick}
initialColumnsWidths={initialColumnsWidths}
onColumnResizing={handleColumnResizing}
sticky={true}
/>
</DashboardContentTable>
);
}
@@ -0,0 +1,28 @@
// @ts-nocheck
import React from 'react';
import { useHistory } from 'react-router-dom';
import { EmptyStatus, Button } from '@/components';
import { FormattedMessage as T } from '@/components';
/**
* Assets empty status.
*/
export default function AssetsEmptyStatus() {
const history = useHistory();
const handleAddAsset = () => {
history.push('/assets/new');
};
return (
<EmptyStatus
title={<T id={'assets_empty_status_title'} />}
description={<T id={'assets_empty_status_description'} />}
action={
<Button intent="primary" onClick={handleAddAsset}>
<T id={'new_asset'} />
</Button>
}
/>
);
}
@@ -0,0 +1,40 @@
// @ts-nocheck
import React from 'react';
import { DashboardPageContent, DashboardContentTable } from '@/components';
import { AssetsListProvider, useAssetsListContext } from './AssetsListProvider';
import { AssetsActionsBar } from './AssetsActionsBar';
import { AssetsDataTable } from './AssetsDataTable';
import { AssetsEmptyStatus } from './AssetsEmptyStatus';
/**
* Assets list page content.
*/
function AssetsListContent() {
const { isEmptyStatus } = useAssetsListContext();
if (isEmptyStatus) {
return (
<DashboardPageContent>
<AssetsEmptyStatus />
</DashboardPageContent>
);
}
return (
<DashboardPageContent>
<AssetsActionsBar />
<AssetsDataTable />
</DashboardPageContent>
);
}
/**
* Assets list page.
*/
export default function AssetsList() {
return (
<AssetsListProvider>
<AssetsListContent />
</AssetsListProvider>
);
}
@@ -0,0 +1,53 @@
// @ts-nocheck
import React, { createContext, useContext, useState, useMemo } from 'react';
import { useAssets } from '@/hooks/query/assets';
import { transformTableStateToQuery } from '@/utils';
const AssetsListContext = createContext();
/**
* Assets list provider.
*/
export function AssetsListProvider({ children }) {
const [tableState, setTableState] = useState({
pageIndex: 0,
pageSize: 20,
sortBy: [],
});
const [selectedRows, setSelectedRows] = useState([]);
const query = useMemo(() => transformTableStateToQuery(tableState), [tableState]);
const {
data: assetsData,
isLoading: isAssetsLoading,
isFetching: isAssetsFetching,
} = useAssets(query);
const isEmptyStatus = assetsData?.assets?.length === 0 && !isAssetsLoading;
const value = {
assets: assetsData?.assets || [],
pagination: assetsData?.pagination,
isAssetsLoading,
isAssetsFetching,
isEmptyStatus,
tableState,
setTableState,
selectedRows,
setSelectedRows,
};
return (
<AssetsListContext.Provider value={value}>
{children}
</AssetsListContext.Provider>
);
}
/**
* Hook to use assets list context.
*/
export function useAssetsListContext() {
return useContext(AssetsListContext);
}
@@ -0,0 +1,10 @@
// @ts-nocheck
export { default as AssetsList } from './AssetsList';
export { default as AssetFormPage } from './AssetFormPage';
export * from './AssetsListProvider';
export * from './AssetsActionsBar';
export * from './AssetsDataTable';
export { default as AssetsEmptyStatus } from './AssetsEmptyStatus';
export * from './AssetFormProvider';
export * from './AssetForm';
export * from './AssetFormFields';
+169
View File
@@ -0,0 +1,169 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRequestQuery } from '../useQueryRequest';
import { useApiRequest } from '../useApiRequest';
import { transformPagination, transformTableStateToQuery } from '@/utils';
import { DEFAULT_PAGINATION } from '@/constants';
// Query keys
export const ASSETS = 'assets';
export const ASSET = 'asset';
export const ASSET_DEPRECIATION_SCHEDULE = 'asset_depreciation_schedule';
const transformAssetsResponse = (response) => {
return {
assets: response.data.assets,
pagination: transformPagination(response.data.filterMeta),
filterMeta: response.data.filterMeta,
};
};
/**
* Retrieves the assets list.
*/
export function useAssets(query, props) {
return useRequestQuery(
[ASSETS, query],
{
method: 'get',
url: 'assets',
params: { ...query },
},
{
select: transformAssetsResponse,
defaultData: {
assets: [],
pagination: DEFAULT_PAGINATION,
filterMeta: {},
},
...props,
},
);
}
/**
* Retrieves a single asset.
*/
export function useAsset(id, props) {
return useRequestQuery(
[ASSET, id],
{
method: 'get',
url: `assets/${id}`,
},
{
select: (res) => res.data.asset,
...props,
},
);
}
/**
* Creates a new asset.
*/
export function useCreateAsset(props) {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation((values) => apiRequest.post('assets', values), {
onSuccess: () => {
queryClient.invalidateQueries(ASSETS);
},
...props,
});
}
/**
* Edits an asset.
*/
export function useEditAsset(props) {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation(([id, values]) => apiRequest.put(`assets/${id}`, values), {
onSuccess: (res, [id]) => {
queryClient.invalidateQueries([ASSET, id]);
queryClient.invalidateQueries(ASSETS);
},
...props,
});
}
/**
* Deletes an asset.
*/
export function useDeleteAsset(props) {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation((id) => apiRequest.delete(`assets/${id}`), {
onSuccess: () => {
queryClient.invalidateQueries(ASSETS);
},
...props,
});
}
/**
* Bulk deletes assets.
*/
export function useBulkDeleteAssets(props) {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation((ids) => apiRequest.post('assets/bulk-delete', { ids }), {
onSuccess: () => {
queryClient.invalidateQueries(ASSETS);
},
...props,
});
}
/**
* Calculates depreciation schedule for an asset.
*/
export function useCalculateDepreciation(props) {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation((id) => apiRequest.post(`assets/${id}/calculate-depreciation`), {
onSuccess: (res, id) => {
queryClient.invalidateQueries([ASSET, id]);
queryClient.invalidateQueries([ASSET_DEPRECIATION_SCHEDULE, id]);
},
...props,
});
}
/**
* Retrieves the depreciation schedule for an asset.
*/
export function useAssetDepreciationSchedule(id, props) {
return useRequestQuery(
[ASSET_DEPRECIATION_SCHEDULE, id],
{
method: 'get',
url: `assets/${id}/depreciation-schedule`,
},
{
select: (res) => res.data.schedule,
defaultData: [],
...props,
},
);
}
/**
* Disposes an asset.
*/
export function useDisposeAsset(props) {
const queryClient = useQueryClient();
const apiRequest = useApiRequest();
return useMutation(([id, values]) => apiRequest.post(`assets/${id}/dispose`, values), {
onSuccess: (res, [id]) => {
queryClient.invalidateQueries([ASSET, id]);
queryClient.invalidateQueries(ASSETS);
},
...props,
});
}
+27
View File
@@ -144,6 +144,33 @@ export const getDashboardRoutes = () => [
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN], subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
}, },
// Assets.
{
path: `/assets/:id/edit`,
component: lazy(() => import('@/containers/Assets/AssetFormPage')),
name: 'asset-edit',
breadcrumb: intl.get('edit_asset'),
pageTitle: intl.get('edit_asset'),
backLink: true,
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
},
{
path: `/assets/new`,
component: lazy(() => import('@/containers/Assets/AssetFormPage')),
name: 'asset-new',
breadcrumb: intl.get('new_asset'),
pageTitle: intl.get('new_asset'),
backLink: true,
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
},
{
path: `/assets`,
component: lazy(() => import('@/containers/Assets/AssetsList')),
breadcrumb: intl.get('assets'),
pageTitle: intl.get('assets_list'),
subscriptionActive: [SUBSCRIPTION_TYPE.MAIN],
},
// Inventory adjustments. // Inventory adjustments.
{ {
path: `/inventory-adjustments`, path: `/inventory-adjustments`,