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',
|
||||
},
|
||||
|
||||
/**
|
||||
* 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: {
|
||||
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:
|
||||
'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
|
||||
{
|
||||
|
||||
@@ -98,6 +98,7 @@ import { MiscellaneousModule } from '../Miscellaneous/Miscellaneous.module';
|
||||
import { UsersModule } from '../UsersModule/Users.module';
|
||||
import { ContactsModule } from '../Contacts/Contacts.module';
|
||||
import { BankingPlaidModule } from '../BankingPlaid/BankingPlaid.module';
|
||||
import { AssetsModule } from '../Assets/Assets.module';
|
||||
import { BankingCategorizeModule } from '../BankingCategorize/BankingCategorize.module';
|
||||
import { ExchangeRatesModule } from '../ExchangeRates/ExchangeRates.module';
|
||||
import { TenantModelsInitializeModule } from '../Tenancy/TenantModelsInitialize.module';
|
||||
@@ -258,6 +259,7 @@ import { AppThrottleModule } from './AppThrottle.module';
|
||||
ContactsModule,
|
||||
SocketModule,
|
||||
ExchangeRatesModule,
|
||||
AssetsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
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 { AssetAction } from "../Assets/Assets.types";
|
||||
import { ReportsAction } from "../FinancialStatements/types/Report.types";
|
||||
import { InventoryAdjustmentAction } from "../InventoryAdjutments/types/InventoryAdjustments.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',
|
||||
VendorCredit = 'VendorCredit',
|
||||
Project = 'Project',
|
||||
TaxRate = 'TaxRate'
|
||||
TaxRate = 'TaxRate',
|
||||
Asset = 'Asset',
|
||||
}
|
||||
|
||||
export interface IRoleCreatedPayload {
|
||||
|
||||
@@ -23,6 +23,7 @@ export const AbilitySubject = {
|
||||
Project: 'Project',
|
||||
TaxRate: 'TaxRate',
|
||||
BankRule: 'BankRule',
|
||||
Asset: 'Asset',
|
||||
};
|
||||
|
||||
export const ItemAction = {
|
||||
@@ -202,3 +203,10 @@ export const BankRuleAction = {
|
||||
Edit: 'Edit',
|
||||
Delete: 'Delete',
|
||||
};
|
||||
|
||||
export const AssetAction = {
|
||||
View: 'View',
|
||||
Create: 'Create',
|
||||
Edit: 'Edit',
|
||||
Delete: 'Delete',
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
CashflowAction,
|
||||
PreferencesAbility,
|
||||
TaxRateAction,
|
||||
AssetAction,
|
||||
} from '@/constants/abilityOption';
|
||||
import { DialogsName } from './dialogs';
|
||||
|
||||
@@ -416,6 +417,15 @@ export const SidebarMenu = [
|
||||
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],
|
||||
},
|
||||
|
||||
// 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.
|
||||
{
|
||||
path: `/inventory-adjustments`,
|
||||
|
||||
Reference in New Issue
Block a user