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:
@@ -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');
|
||||||
|
};
|
||||||
+33
@@ -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';
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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`,
|
||||||
|
|||||||
Reference in New Issue
Block a user