diff --git a/packages/server/src/common/events/events.ts b/packages/server/src/common/events/events.ts index 22c770b71..192b784b9 100644 --- a/packages/server/src/common/events/events.ts +++ b/packages/server/src/common/events/events.ts @@ -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', diff --git a/packages/server/src/database/tenant/migrations/20250409100000_create_assets_table.ts b/packages/server/src/database/tenant/migrations/20250409100000_create_assets_table.ts new file mode 100644 index 000000000..a11e33915 --- /dev/null +++ b/packages/server/src/database/tenant/migrations/20250409100000_create_assets_table.ts @@ -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'); +}; diff --git a/packages/server/src/database/tenant/migrations/20250409100100_create_asset_depreciation_entries_table.ts b/packages/server/src/database/tenant/migrations/20250409100100_create_asset_depreciation_entries_table.ts new file mode 100644 index 000000000..9c7fddb66 --- /dev/null +++ b/packages/server/src/database/tenant/migrations/20250409100100_create_asset_depreciation_entries_table.ts @@ -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'); +}; diff --git a/packages/server/src/database/tenant/seeds/data/accounts.ts b/packages/server/src/database/tenant/seeds/data/accounts.ts index 50a46ee85..8e0dbbbc8 100644 --- a/packages/server/src/database/tenant/seeds/data/accounts.ts +++ b/packages/server/src/database/tenant/seeds/data/accounts.ts @@ -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 { diff --git a/packages/server/src/modules/App/App.module.ts b/packages/server/src/modules/App/App.module.ts index 8ae530926..e21ea4762 100644 --- a/packages/server/src/modules/App/App.module.ts +++ b/packages/server/src/modules/App/App.module.ts @@ -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: [ diff --git a/packages/server/src/modules/Assets/Assets.constants.ts b/packages/server/src/modules/Assets/Assets.constants.ts new file mode 100644 index 000000000..b5b2b9663 --- /dev/null +++ b/packages/server/src/modules/Assets/Assets.constants.ts @@ -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 = []; diff --git a/packages/server/src/modules/Assets/Assets.controller.ts b/packages/server/src/modules/Assets/Assets.controller.ts new file mode 100644 index 000000000..c5048e312 --- /dev/null +++ b/packages/server/src/modules/Assets/Assets.controller.ts @@ -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 }; + } +} diff --git a/packages/server/src/modules/Assets/Assets.module.ts b/packages/server/src/modules/Assets/Assets.module.ts new file mode 100644 index 000000000..1c1dd8051 --- /dev/null +++ b/packages/server/src/modules/Assets/Assets.module.ts @@ -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 {} diff --git a/packages/server/src/modules/Assets/Assets.types.ts b/packages/server/src/modules/Assets/Assets.types.ts new file mode 100644 index 000000000..06b86c1e7 --- /dev/null +++ b/packages/server/src/modules/Assets/Assets.types.ts @@ -0,0 +1,6 @@ +export enum AssetAction { + VIEW = 'view', + CREATE = 'create', + EDIT = 'edit', + DELETE = 'delete', +} diff --git a/packages/server/src/modules/Assets/AssetsApplication.service.ts b/packages/server/src/modules/Assets/AssetsApplication.service.ts new file mode 100644 index 000000000..38dcdc573 --- /dev/null +++ b/packages/server/src/modules/Assets/AssetsApplication.service.ts @@ -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 { + return this.createAssetService.createAsset(dto); + } + + /** + * Edits an existing asset. + */ + public editAsset(assetId: number, dto: EditAssetDto): Promise { + return this.editAssetService.editAsset(assetId, dto); + } + + /** + * Deletes an asset. + */ + public deleteAsset(assetId: number): Promise { + return this.deleteAssetService.deleteAsset(assetId); + } + + /** + * Bulk deletes assets. + */ + public bulkDeleteAssets(assetIds: number[]): Promise { + return this.deleteAssetService.bulkDeleteAssets(assetIds); + } + + /** + * Disposes an asset. + */ + public disposeAsset(assetId: number, dto: DisposeAssetDto): Promise { + return this.disposeAssetService.disposeAsset(assetId, dto); + } + + /** + * Gets a single asset. + */ + public getAsset(assetId: number): Promise { + 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 { + return this.calculateDepreciationService.calculateDepreciationSchedule(assetId); + } + + /** + * Gets depreciation schedule for an asset. + */ + public getDepreciationSchedule(assetId: number): Promise { + return this.getDepreciationScheduleService.getDepreciationSchedule(assetId); + } +} diff --git a/packages/server/src/modules/Assets/commands/CalculateAssetDepreciation.service.ts b/packages/server/src/modules/Assets/commands/CalculateAssetDepreciation.service.ts new file mode 100644 index 000000000..88e0195f1 --- /dev/null +++ b/packages/server/src/modules/Assets/commands/CalculateAssetDepreciation.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/packages/server/src/modules/Assets/commands/CreateAsset.service.ts b/packages/server/src/modules/Assets/commands/CreateAsset.service.ts new file mode 100644 index 000000000..8c2f83676 --- /dev/null +++ b/packages/server/src/modules/Assets/commands/CreateAsset.service.ts @@ -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 { + 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; + } +} diff --git a/packages/server/src/modules/Assets/commands/DeleteAsset.service.ts b/packages/server/src/modules/Assets/commands/DeleteAsset.service.ts new file mode 100644 index 000000000..0f662b0af --- /dev/null +++ b/packages/server/src/modules/Assets/commands/DeleteAsset.service.ts @@ -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 { + 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 { + for (const assetId of assetIds) { + await this.deleteAsset(assetId, trx); + } + } +} diff --git a/packages/server/src/modules/Assets/commands/DisposeAsset.service.ts b/packages/server/src/modules/Assets/commands/DisposeAsset.service.ts new file mode 100644 index 000000000..11c837b26 --- /dev/null +++ b/packages/server/src/modules/Assets/commands/DisposeAsset.service.ts @@ -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 { + 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; + } +} diff --git a/packages/server/src/modules/Assets/commands/EditAsset.service.ts b/packages/server/src/modules/Assets/commands/EditAsset.service.ts new file mode 100644 index 000000000..b1e8e4beb --- /dev/null +++ b/packages/server/src/modules/Assets/commands/EditAsset.service.ts @@ -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 { + 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; + } +} diff --git a/packages/server/src/modules/Assets/dtos/CreateAsset.dto.ts b/packages/server/src/modules/Assets/dtos/CreateAsset.dto.ts new file mode 100644 index 000000000..f9b52e973 --- /dev/null +++ b/packages/server/src/modules/Assets/dtos/CreateAsset.dto.ts @@ -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; +} diff --git a/packages/server/src/modules/Assets/dtos/DisposeAsset.dto.ts b/packages/server/src/modules/Assets/dtos/DisposeAsset.dto.ts new file mode 100644 index 000000000..48bf66968 --- /dev/null +++ b/packages/server/src/modules/Assets/dtos/DisposeAsset.dto.ts @@ -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; +} diff --git a/packages/server/src/modules/Assets/dtos/EditAsset.dto.ts b/packages/server/src/modules/Assets/dtos/EditAsset.dto.ts new file mode 100644 index 000000000..d40e51d81 --- /dev/null +++ b/packages/server/src/modules/Assets/dtos/EditAsset.dto.ts @@ -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; +} diff --git a/packages/server/src/modules/Assets/dtos/GetAssetsQuery.dto.ts b/packages/server/src/modules/Assets/dtos/GetAssetsQuery.dto.ts new file mode 100644 index 000000000..159dcc016 --- /dev/null +++ b/packages/server/src/modules/Assets/dtos/GetAssetsQuery.dto.ts @@ -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; +} diff --git a/packages/server/src/modules/Assets/models/Asset.meta.ts b/packages/server/src/modules/Assets/models/Asset.meta.ts new file mode 100644 index 000000000..f448392a9 --- /dev/null +++ b/packages/server/src/modules/Assets/models/Asset.meta.ts @@ -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' }, + ], +}; diff --git a/packages/server/src/modules/Assets/models/Asset.model.ts b/packages/server/src/modules/Assets/models/Asset.model.ts new file mode 100644 index 000000000..5e3329845 --- /dev/null +++ b/packages/server/src/modules/Assets/models/Asset.model.ts @@ -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' }, + ]; + } +} diff --git a/packages/server/src/modules/Assets/models/AssetDepreciationEntry.model.ts b/packages/server/src/modules/Assets/models/AssetDepreciationEntry.model.ts new file mode 100644 index 000000000..453795f46 --- /dev/null +++ b/packages/server/src/modules/Assets/models/AssetDepreciationEntry.model.ts @@ -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' }, + }, + }; + } +} diff --git a/packages/server/src/modules/Assets/queries/GetAsset.service.ts b/packages/server/src/modules/Assets/queries/GetAsset.service.ts new file mode 100644 index 000000000..fdd49e8a2 --- /dev/null +++ b/packages/server/src/modules/Assets/queries/GetAsset.service.ts @@ -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 { + const asset = await this.assetRepository.findById(assetId); + if (!asset) { + throw new NotFoundException(`Asset with id ${assetId} not found`); + } + return asset; + } +} diff --git a/packages/server/src/modules/Assets/queries/GetAssetDepreciationSchedule.service.ts b/packages/server/src/modules/Assets/queries/GetAssetDepreciationSchedule.service.ts new file mode 100644 index 000000000..bd9cc8061 --- /dev/null +++ b/packages/server/src/modules/Assets/queries/GetAssetDepreciationSchedule.service.ts @@ -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 { + 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; + } +} diff --git a/packages/server/src/modules/Assets/queries/GetAssets.service.ts b/packages/server/src/modules/Assets/queries/GetAssets.service.ts new file mode 100644 index 000000000..0067313c7 --- /dev/null +++ b/packages/server/src/modules/Assets/queries/GetAssets.service.ts @@ -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, + }, + }; + } +} diff --git a/packages/server/src/modules/Assets/repositories/Asset.repository.ts b/packages/server/src/modules/Assets/repositories/Asset.repository.ts new file mode 100644 index 000000000..579855714 --- /dev/null +++ b/packages/server/src/modules/Assets/repositories/Asset.repository.ts @@ -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 { + return this.model + .query() + .findById(id) + .withGraphFetched('[assetAccount, depreciationExpenseAccount, accumulatedDepreciationAccount, category]'); + } + + /** + * Find asset by code. + */ + async findByCode(code: string): Promise { + return this.model + .query() + .where('code', code) + .first(); + } + + /** + * Find one or fail. + */ + async findOneOrFail(id: number): Promise { + 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 }; + } +} diff --git a/packages/server/src/modules/Assets/repositories/AssetDepreciationEntry.repository.ts b/packages/server/src/modules/Assets/repositories/AssetDepreciationEntry.repository.ts new file mode 100644 index 000000000..bd7265594 --- /dev/null +++ b/packages/server/src/modules/Assets/repositories/AssetDepreciationEntry.repository.ts @@ -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 { + return this.model + .query() + .where('assetId', assetId) + .orderBy(['periodYear', 'periodMonth']); + } + + /** + * Find unposted entries by period. + */ + async findUnpostedByPeriod(year: number, month: number): Promise { + 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 { + return this.model + .query() + .where('assetId', assetId) + .where('periodYear', year) + .where('periodMonth', month); + } + + /** + * Delete unposted entries for an asset. + */ + async deleteUnpostedEntries(assetId: number): Promise { + await this.model + .query() + .where('assetId', assetId) + .where('isPosted', false) + .delete(); + } + + /** + * Create a depreciation entry. + */ + async create(data: Partial): Promise { + return this.model.query().insert(data); + } + + /** + * Update a depreciation entry. + */ + async update(id: number, data: Partial): Promise { + return this.model.query().patchAndFetchById(id, data); + } + + /** + * Get the last posted entry for an asset. + */ + async getLastPostedEntry(assetId: number): Promise { + return this.model + .query() + .where('assetId', assetId) + .where('isPosted', true) + .orderBy(['periodYear', 'periodMonth'], 'desc') + .first(); + } +} diff --git a/packages/server/src/modules/Roles/AbilitySchema.ts b/packages/server/src/modules/Roles/AbilitySchema.ts index 36c35cf71..3d1b25d19 100644 --- a/packages/server/src/modules/Roles/AbilitySchema.ts +++ b/packages/server/src/modules/Roles/AbilitySchema.ts @@ -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 }, + ], + }, ]; /** diff --git a/packages/server/src/modules/Roles/Roles.types.ts b/packages/server/src/modules/Roles/Roles.types.ts index 258b76509..261ab8825 100644 --- a/packages/server/src/modules/Roles/Roles.types.ts +++ b/packages/server/src/modules/Roles/Roles.types.ts @@ -60,7 +60,8 @@ export enum AbilitySubject { CreditNote = 'CreditNode', VendorCredit = 'VendorCredit', Project = 'Project', - TaxRate = 'TaxRate' + TaxRate = 'TaxRate', + Asset = 'Asset', } export interface IRoleCreatedPayload { diff --git a/packages/webapp/src/constants/abilityOption.tsx b/packages/webapp/src/constants/abilityOption.tsx index 70b73a99d..6c9d9ec18 100644 --- a/packages/webapp/src/constants/abilityOption.tsx +++ b/packages/webapp/src/constants/abilityOption.tsx @@ -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', +}; diff --git a/packages/webapp/src/constants/sidebarMenu.tsx b/packages/webapp/src/constants/sidebarMenu.tsx index de14f26ac..bf5e16090 100644 --- a/packages/webapp/src/constants/sidebarMenu.tsx +++ b/packages/webapp/src/constants/sidebarMenu.tsx @@ -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: , + href: '/assets', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Asset, + ability: AssetAction.View, + }, + }, ], }, { diff --git a/packages/webapp/src/containers/Assets/AssetForm.tsx b/packages/webapp/src/containers/Assets/AssetForm.tsx new file mode 100644 index 000000000..b92722a59 --- /dev/null +++ b/packages/webapp/src/containers/Assets/AssetForm.tsx @@ -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 ( + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/Assets/AssetFormFields.tsx b/packages/webapp/src/containers/Assets/AssetFormFields.tsx new file mode 100644 index 000000000..a5c66337d --- /dev/null +++ b/packages/webapp/src/containers/Assets/AssetFormFields.tsx @@ -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 ( + + {children} + + ); +} + +/** + * 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 ( +
+
+
+

+ + }> + + + + }> + + + + }> + + + + }> + ({ + label: acc.name, + value: acc.id, + })), + ]} + /> + + +

+ + }> + + + + }> + + + +

+ + }> + + + + {values.depreciationMethod === 'straight_line' && ( + }> + + + )} + + {values.depreciationMethod === 'declining_balance' && ( + }> + + + )} + + }> + + + + }> + + + + }> + ({ + label: acc.name, + value: acc.id, + })), + ]} + /> + + + }> + ({ + label: acc.name, + value: acc.id, + })), + ]} + /> + +
+ +
+ + +
+
+
+ ); +} diff --git a/packages/webapp/src/containers/Assets/AssetFormPage.tsx b/packages/webapp/src/containers/Assets/AssetFormPage.tsx new file mode 100644 index 000000000..4762a37b9 --- /dev/null +++ b/packages/webapp/src/containers/Assets/AssetFormPage.tsx @@ -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 ( + + + + ); +} diff --git a/packages/webapp/src/containers/Assets/AssetFormProvider.tsx b/packages/webapp/src/containers/Assets/AssetFormProvider.tsx new file mode 100644 index 000000000..1b9aa89d6 --- /dev/null +++ b/packages/webapp/src/containers/Assets/AssetFormProvider.tsx @@ -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 ( + + {children} + + ); +} + +/** + * Hook to use asset form context. + */ +export function useAssetFormContext() { + return useContext(AssetFormContext); +} diff --git a/packages/webapp/src/containers/Assets/AssetsActionsBar.tsx b/packages/webapp/src/containers/Assets/AssetsActionsBar.tsx new file mode 100644 index 000000000..1c0fffa29 --- /dev/null +++ b/packages/webapp/src/containers/Assets/AssetsActionsBar.tsx @@ -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 ( + + + + ); +} diff --git a/packages/webapp/src/containers/Assets/AssetsDataTable.tsx b/packages/webapp/src/containers/Assets/AssetsDataTable.tsx new file mode 100644 index 000000000..a1b51fae6 --- /dev/null +++ b/packages/webapp/src/containers/Assets/AssetsDataTable.tsx @@ -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 ( + + + + ); +} diff --git a/packages/webapp/src/containers/Assets/AssetsEmptyStatus.tsx b/packages/webapp/src/containers/Assets/AssetsEmptyStatus.tsx new file mode 100644 index 000000000..8d930b2b8 --- /dev/null +++ b/packages/webapp/src/containers/Assets/AssetsEmptyStatus.tsx @@ -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 ( + } + description={} + action={ + + } + /> + ); +} diff --git a/packages/webapp/src/containers/Assets/AssetsList.tsx b/packages/webapp/src/containers/Assets/AssetsList.tsx new file mode 100644 index 000000000..3c0a7db47 --- /dev/null +++ b/packages/webapp/src/containers/Assets/AssetsList.tsx @@ -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 ( + + + + ); + } + + return ( + + + + + ); +} + +/** + * Assets list page. + */ +export default function AssetsList() { + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Assets/AssetsListProvider.tsx b/packages/webapp/src/containers/Assets/AssetsListProvider.tsx new file mode 100644 index 000000000..0534b4fd8 --- /dev/null +++ b/packages/webapp/src/containers/Assets/AssetsListProvider.tsx @@ -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 ( + + {children} + + ); +} + +/** + * Hook to use assets list context. + */ +export function useAssetsListContext() { + return useContext(AssetsListContext); +} diff --git a/packages/webapp/src/containers/Assets/index.tsx b/packages/webapp/src/containers/Assets/index.tsx new file mode 100644 index 000000000..6ba3b9548 --- /dev/null +++ b/packages/webapp/src/containers/Assets/index.tsx @@ -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'; diff --git a/packages/webapp/src/hooks/query/assets.tsx b/packages/webapp/src/hooks/query/assets.tsx new file mode 100644 index 000000000..7d27851e6 --- /dev/null +++ b/packages/webapp/src/hooks/query/assets.tsx @@ -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, + }); +} diff --git a/packages/webapp/src/routes/dashboard.tsx b/packages/webapp/src/routes/dashboard.tsx index c3c16d97d..9523556f1 100644 --- a/packages/webapp/src/routes/dashboard.tsx +++ b/packages/webapp/src/routes/dashboard.tsx @@ -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`,