wip
This commit is contained in:
@@ -45,6 +45,15 @@ export const events = {
|
||||
baseCurrencyUpdated: 'onOrganizationBaseCurrencyUpdated',
|
||||
},
|
||||
|
||||
/**
|
||||
* Workspace service.
|
||||
*/
|
||||
workspace: {
|
||||
created: 'onWorkspaceCreated',
|
||||
deleting: 'onWorkspaceDeleting',
|
||||
deleted: 'onWorkspaceDeleted',
|
||||
},
|
||||
|
||||
/**
|
||||
* Organization subscription.
|
||||
*/
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
|
||||
exports.up = function(knex) {
|
||||
return knex.schema.table('tenants', (table) => {
|
||||
table.boolean('is_deleting').defaultTo(false).nullable();
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema.table('tenants', (table) => {
|
||||
table.dropColumn('is_deleting');
|
||||
});
|
||||
};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
exports.up = function(knex) {
|
||||
return knex.schema.table('tenants', (table) => {
|
||||
table.boolean('is_inactive').defaultTo(false).nullable();
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function(knex) {
|
||||
return knex.schema.table('tenants', (table) => {
|
||||
table.dropColumn('is_inactive');
|
||||
});
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Request,
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
@@ -62,6 +63,13 @@ export class AuthController {
|
||||
const { user } = req;
|
||||
const tenant = await this.tenantModel.query().findById(user.tenantId);
|
||||
|
||||
if (tenant.isInactive) {
|
||||
throw new UnauthorizedException({
|
||||
message: 'Organization is inactive. Please contact the administrator.',
|
||||
errors: [{ type: 'ORGANIZATION.INACTIVE' }],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: this.authSignin.signToken(user),
|
||||
organizationId: tenant.organizationId,
|
||||
|
||||
@@ -42,5 +42,6 @@ import { TransformerModule } from '../Transformer/Transformer.module';
|
||||
TransformerModule,
|
||||
],
|
||||
controllers: [OrganizationController],
|
||||
exports: [BuildOrganizationService],
|
||||
})
|
||||
export class OrganizationModule {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Queue } from 'bullmq';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import {
|
||||
BuildOrganizationResult,
|
||||
IOrganizationBuildEventPayload,
|
||||
@@ -20,6 +21,8 @@ import { events } from '@/common/events/events';
|
||||
import { transformBuildDto } from '../Organization.utils';
|
||||
import { BuildOrganizationDto } from '../dtos/Organization.dto';
|
||||
import { TenantRepository } from '@/modules/System/repositories/Tenant.repository';
|
||||
import { TenantModel } from '@/modules/System/models/TenantModel';
|
||||
import { SystemUser } from '@/modules/System/models/SystemUser';
|
||||
|
||||
@Injectable()
|
||||
export class BuildOrganizationService {
|
||||
@@ -28,9 +31,16 @@ export class BuildOrganizationService {
|
||||
private readonly tenantsManager: TenantsManagerService,
|
||||
private readonly tenancyContext: TenancyContext,
|
||||
private readonly tenantRepository: TenantRepository,
|
||||
private readonly clsService: ClsService,
|
||||
|
||||
@InjectQueue(OrganizationBuildQueue)
|
||||
private readonly organizationBuildQueue: Queue,
|
||||
|
||||
@Inject(TenantModel.name)
|
||||
private readonly tenantModel: typeof TenantModel,
|
||||
|
||||
@Inject(SystemUser.name)
|
||||
private readonly systemUserModel: typeof SystemUser,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -128,4 +138,61 @@ export class BuildOrganizationService {
|
||||
|
||||
await this.tenantRepository.markAsBuildCompleted().findById(tenant.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the database schema and seed data for a specific tenant and user.
|
||||
* This is used for workspace creation where the tenant is not the current context tenant.
|
||||
* @param {number} tenantId - The tenant id to build.
|
||||
* @param {number} userId - The user id to use for the build.
|
||||
* @param {BuildOrganizationDto} buildDTO - Organization build dto.
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
public async buildForTenant(
|
||||
tenantId: number,
|
||||
userId: number,
|
||||
buildDTO: BuildOrganizationDto,
|
||||
): Promise<void> {
|
||||
const tenant = await this.tenantModel.query().findById(tenantId);
|
||||
const systemUser = await this.systemUserModel.query().findById(userId);
|
||||
|
||||
if (!tenant) {
|
||||
throw new Error('Tenant not found');
|
||||
}
|
||||
if (!systemUser) {
|
||||
throw new Error('System user not found');
|
||||
}
|
||||
|
||||
// Throw error if the tenant is already initialized.
|
||||
throwIfTenantInitizalized(tenant);
|
||||
|
||||
// Set the tenant context for this build operation.
|
||||
this.clsService.set('tenantId', tenantId);
|
||||
this.clsService.set('organizationId', tenant.organizationId);
|
||||
this.clsService.set('userId', userId);
|
||||
|
||||
await this.tenantsManager.dropDatabaseIfExists();
|
||||
await this.tenantsManager.createDatabase();
|
||||
|
||||
await this.tenantsManager.migrateTenant();
|
||||
await this.tenantsManager.seedTenant();
|
||||
|
||||
// Throws `onOrganizationBuild` event.
|
||||
await this.eventPublisher.emitAsync(events.organization.build, {
|
||||
tenantId: tenant.id,
|
||||
buildDTO,
|
||||
systemUser,
|
||||
} as IOrganizationBuildEventPayload);
|
||||
|
||||
// Marks the tenant as completed building.
|
||||
await this.tenantRepository.markAsBuilt().findById(tenant.id);
|
||||
await this.tenantRepository.markAsBuildCompleted().findById(tenant.id);
|
||||
|
||||
// Flags the tenant database batch.
|
||||
await this.tenantRepository.flagTenantDBBatch().findById(tenant.id);
|
||||
|
||||
// Triggers the organization built event.
|
||||
await this.eventPublisher.emitAsync(events.organization.built, {
|
||||
tenantId: tenant.id,
|
||||
} as IOrganizationBuiltEventPayload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ export class TenantModel extends BaseModel {
|
||||
public readonly buildJobId: string;
|
||||
public readonly upgradeJobId: string;
|
||||
public readonly databaseBatch: string;
|
||||
public readonly isDeleting: boolean;
|
||||
public readonly isInactive: boolean;
|
||||
public readonly subscriptions: Array<PlanSubscription>;
|
||||
|
||||
/**
|
||||
@@ -24,7 +26,7 @@ export class TenantModel extends BaseModel {
|
||||
* @returns {string[]}
|
||||
*/
|
||||
static get virtualAttributes() {
|
||||
return ['isReady', 'isBuildRunning', 'isUpgradeRunning'];
|
||||
return ['isReady', 'isBuildRunning', 'isUpgradeRunning', 'isActive'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,6 +53,14 @@ export class TenantModel extends BaseModel {
|
||||
return !!this.upgradeJobId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the tenant is active (not inactive and not deleting).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
get isActive() {
|
||||
return !this.isInactive && !this.isDeleting;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relations mappings.
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiExtraModels, ApiOperation, ApiResponse, ApiTags, getSchemaPath } from '@nestjs/swagger';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
@@ -17,6 +19,8 @@ import { IgnoreTenantSeededRoute } from '@/modules/Tenancy/EnsureTenantIsSeeded.
|
||||
import { IgnoreTenantModelsInitialize } from '@/modules/Tenancy/TenancyInitializeModels.guard';
|
||||
import { CreateWorkspaceService } from './commands/CreateWorkspace.service';
|
||||
import { DeleteWorkspaceService } from './commands/DeleteWorkspace.service';
|
||||
import { DeleteWorkspaceJobService } from './commands/DeleteWorkspaceJob.service';
|
||||
import { InactivateWorkspaceService } from './commands/InactivateWorkspace.service';
|
||||
import { SetDefaultWorkspaceService } from './commands/SetDefaultWorkspace.service';
|
||||
import { GetWorkspacesService } from './queries/GetWorkspaces.service';
|
||||
import { GetWorkspaceBuildJobService } from './queries/GetWorkspaceBuildJob.service';
|
||||
@@ -35,6 +39,8 @@ export class WorkspacesController {
|
||||
constructor(
|
||||
private readonly createWorkspaceService: CreateWorkspaceService,
|
||||
private readonly deleteWorkspaceService: DeleteWorkspaceService,
|
||||
private readonly deleteWorkspaceJobService: DeleteWorkspaceJobService,
|
||||
private readonly inactivateWorkspaceService: InactivateWorkspaceService,
|
||||
private readonly setDefaultWorkspaceService: SetDefaultWorkspaceService,
|
||||
private readonly getWorkspacesService: GetWorkspacesService,
|
||||
private readonly getWorkspaceBuildJobService: GetWorkspaceBuildJobService,
|
||||
@@ -57,9 +63,16 @@ export class WorkspacesController {
|
||||
items: { $ref: getSchemaPath(WorkspaceDto) },
|
||||
},
|
||||
})
|
||||
async listWorkspaces(): Promise<WorkspaceDto[]> {
|
||||
async listWorkspaces(
|
||||
@Query('includeInactive') includeInactive?: string,
|
||||
@Query('currentOrganizationId') currentOrganizationId?: string,
|
||||
): Promise<WorkspaceDto[]> {
|
||||
const userId = this.cls.get<number>('userId');
|
||||
return this.getWorkspacesService.getWorkspaces(userId);
|
||||
return this.getWorkspacesService.getWorkspaces(
|
||||
userId,
|
||||
includeInactive === 'true',
|
||||
currentOrganizationId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +101,7 @@ export class WorkspacesController {
|
||||
|
||||
/**
|
||||
* Deletes a workspace. Only the workspace owner is permitted to delete it.
|
||||
* The deletion runs asynchronously via a background job.
|
||||
* Requires `organization-id` header (must match the path param).
|
||||
*/
|
||||
@Delete(':organizationId')
|
||||
@@ -98,13 +112,69 @@ export class WorkspacesController {
|
||||
@ApiOperation({ summary: 'Delete a workspace (owner only)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Workspace deleted successfully',
|
||||
description: 'Workspace deletion initiated successfully',
|
||||
schema: {
|
||||
properties: {
|
||||
jobId: { type: 'string' },
|
||||
organizationId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async deleteWorkspace(
|
||||
@Param('organizationId') organizationId: string,
|
||||
): Promise<{ jobId: string | number; organizationId: string }> {
|
||||
const userId = this.cls.get<number>('userId');
|
||||
const currentOrganizationId = this.cls.get<string>('organizationId');
|
||||
|
||||
if (organizationId === currentOrganizationId) {
|
||||
throw new BadRequestException('Cannot delete the current organization');
|
||||
}
|
||||
|
||||
return this.deleteWorkspaceJobService.initiateDelete(userId, organizationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inactivates a workspace. Only the workspace owner is permitted to inactivate it.
|
||||
* When inactivated, no one can sign in to the workspace until it's reactivated.
|
||||
* Requires `organization-id` header (must match the path param).
|
||||
*/
|
||||
@Put(':organizationId/inactivate')
|
||||
@IgnoreTenantInitializedRoute()
|
||||
@IgnoreTenantSeededRoute()
|
||||
@IgnoreTenantModelsInitialize()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Inactivate a workspace (owner only)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Workspace inactivated successfully',
|
||||
})
|
||||
async inactivateWorkspace(
|
||||
@Param('organizationId') organizationId: string,
|
||||
): Promise<void> {
|
||||
const userId = this.cls.get<number>('userId');
|
||||
await this.deleteWorkspaceService.deleteWorkspace(userId, organizationId);
|
||||
return this.inactivateWorkspaceService.inactivateWorkspace(userId, organizationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivates a workspace. Only the workspace owner is permitted to reactivate it.
|
||||
* Once reactivated, users can sign in again.
|
||||
* Requires `organization-id` header (must match the path param).
|
||||
*/
|
||||
@Put(':organizationId/activate')
|
||||
@IgnoreTenantInitializedRoute()
|
||||
@IgnoreTenantSeededRoute()
|
||||
@IgnoreTenantModelsInitialize()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Reactivate a workspace (owner only)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Workspace reactivated successfully',
|
||||
})
|
||||
async activateWorkspace(
|
||||
@Param('organizationId') organizationId: string,
|
||||
): Promise<void> {
|
||||
const userId = this.cls.get<number>('userId');
|
||||
return this.inactivateWorkspaceService.activateWorkspace(userId, organizationId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,11 +3,18 @@ import { BullModule } from '@nestjs/bullmq';
|
||||
import { WorkspacesController } from './Workspaces.controller';
|
||||
import { CreateWorkspaceService } from './commands/CreateWorkspace.service';
|
||||
import { DeleteWorkspaceService } from './commands/DeleteWorkspace.service';
|
||||
import { DeleteWorkspaceJobService } from './commands/DeleteWorkspaceJob.service';
|
||||
import { InactivateWorkspaceService } from './commands/InactivateWorkspace.service';
|
||||
import { SetDefaultWorkspaceService } from './commands/SetDefaultWorkspace.service';
|
||||
import { GetWorkspacesService } from './queries/GetWorkspaces.service';
|
||||
import { GetWorkspacesFinancialService } from './queries/GetWorkspacesFinancial.service';
|
||||
import { GetWorkspaceBuildJobService } from './queries/GetWorkspaceBuildJob.service';
|
||||
import { CreateUserTenantOnSignupSubscriber } from './subscribers/CreateUserTenantOnSignup.subscriber';
|
||||
import { WorkspaceCreatedSubscriber } from './subscribers/WorkspaceCreated.subscriber';
|
||||
import { DeleteWorkspaceProcessor } from './processors/DeleteWorkspace.processor';
|
||||
import { OrganizationBuildQueue } from '@/modules/Organization/Organization.types';
|
||||
import { DeleteWorkspaceQueue } from './Workspaces.types';
|
||||
import { OrganizationModule } from '@/modules/Organization/Organization.module';
|
||||
import { InjectSystemModel } from '@/modules/System/SystemModels/SystemModels.module';
|
||||
import { UserTenant } from '@/modules/System/models/UserTenant.model';
|
||||
import { SystemUser } from '@/modules/System/models/SystemUser';
|
||||
@@ -18,8 +25,12 @@ import { TenantRepository } from '@/modules/System/repositories/Tenant.repositor
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BullModule.registerQueue({ name: OrganizationBuildQueue }),
|
||||
BullModule.registerQueue(
|
||||
{ name: OrganizationBuildQueue },
|
||||
{ name: DeleteWorkspaceQueue },
|
||||
),
|
||||
TenantDBManagerModule,
|
||||
OrganizationModule,
|
||||
],
|
||||
controllers: [WorkspacesController],
|
||||
providers: [
|
||||
@@ -29,11 +40,16 @@ import { TenantRepository } from '@/modules/System/repositories/Tenant.repositor
|
||||
TenantRepository,
|
||||
CreateWorkspaceService,
|
||||
DeleteWorkspaceService,
|
||||
DeleteWorkspaceJobService,
|
||||
InactivateWorkspaceService,
|
||||
SetDefaultWorkspaceService,
|
||||
GetWorkspacesService,
|
||||
GetWorkspacesFinancialService,
|
||||
GetWorkspaceBuildJobService,
|
||||
CreateUserTenantOnSignupSubscriber,
|
||||
WorkspaceCreatedSubscriber,
|
||||
GetBuildOrganizationBuildJob,
|
||||
DeleteWorkspaceProcessor,
|
||||
],
|
||||
})
|
||||
export class WorkspacesModule {}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IOrganizationBuildDTO } from '@/modules/Organization/Organization.types';
|
||||
|
||||
export interface IWorkspaceCreatedEventPayload {
|
||||
tenantId: number;
|
||||
organizationId: string;
|
||||
userId: number;
|
||||
buildDTO: IOrganizationBuildDTO;
|
||||
}
|
||||
|
||||
export const DeleteWorkspaceQueue = 'delete-workspace';
|
||||
|
||||
export interface DeleteWorkspaceQueueJobPayload {
|
||||
organizationId: string;
|
||||
userId: number;
|
||||
}
|
||||
+95
-47
@@ -1,11 +1,11 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getQueueToken } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { CreateWorkspaceService } from './CreateWorkspace.service';
|
||||
import { UserTenant } from '@/modules/System/models/UserTenant.model';
|
||||
import { TenantRepository } from '@/modules/System/repositories/Tenant.repository';
|
||||
import { OrganizationBuildQueue, OrganizationBuildQueueJob } from '@/modules/Organization/Organization.types';
|
||||
import { BuildOrganizationService } from '@/modules/Organization/commands/BuildOrganization.service';
|
||||
import { SystemKnexConnection } from '@/modules/System/SystemDB/SystemDB.constants';
|
||||
import { events } from '@/common/events/events';
|
||||
|
||||
// Mock the Organization.utils module
|
||||
jest.mock('@/modules/Organization/Organization.utils', () => ({
|
||||
@@ -19,7 +19,8 @@ describe('CreateWorkspaceService', () => {
|
||||
let service: CreateWorkspaceService;
|
||||
let tenantRepository: jest.Mocked<any>;
|
||||
let userTenantModel: jest.Mocked<any>;
|
||||
let organizationBuildQueue: jest.Mocked<Queue>;
|
||||
let mockBuildOrganizationService: jest.Mocked<BuildOrganizationService>;
|
||||
let mockEventEmitter: jest.Mocked<EventEmitter2>;
|
||||
let mockKnexTransaction: jest.Mock;
|
||||
|
||||
const mockTenant = {
|
||||
@@ -31,13 +32,6 @@ describe('CreateWorkspaceService', () => {
|
||||
buildJobId: null,
|
||||
};
|
||||
|
||||
const mockJob = {
|
||||
id: 'job_123',
|
||||
name: 'organization-build',
|
||||
data: {},
|
||||
opts: {},
|
||||
};
|
||||
|
||||
const createMockQuery = () => ({
|
||||
insert: jest.fn().mockResolvedValue({ id: 1, userId: 1, tenantId: 1, role: 'owner' }),
|
||||
findById: jest.fn().mockResolvedValue(mockTenant),
|
||||
@@ -59,9 +53,15 @@ describe('CreateWorkspaceService', () => {
|
||||
findById: jest.fn().mockResolvedValue(mockTenant),
|
||||
};
|
||||
|
||||
const mockQueue = {
|
||||
add: jest.fn().mockResolvedValue(mockJob),
|
||||
};
|
||||
// Mock build organization service
|
||||
mockBuildOrganizationService = {
|
||||
buildForTenant: jest.fn().mockResolvedValue(undefined),
|
||||
} as any;
|
||||
|
||||
// Mock event emitter
|
||||
mockEventEmitter = {
|
||||
emitAsync: jest.fn().mockResolvedValue([]),
|
||||
} as any;
|
||||
|
||||
// Mock knex transaction
|
||||
mockKnexTransaction = jest.fn(async (callback) => {
|
||||
@@ -85,20 +85,23 @@ describe('CreateWorkspaceService', () => {
|
||||
useValue: mockTenantRepository,
|
||||
},
|
||||
{
|
||||
provide: getQueueToken(OrganizationBuildQueue),
|
||||
useValue: mockQueue,
|
||||
provide: BuildOrganizationService,
|
||||
useValue: mockBuildOrganizationService,
|
||||
},
|
||||
{
|
||||
provide: SystemKnexConnection,
|
||||
useValue: mockSystemKnex,
|
||||
},
|
||||
{
|
||||
provide: EventEmitter2,
|
||||
useValue: mockEventEmitter,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CreateWorkspaceService>(CreateWorkspaceService);
|
||||
tenantRepository = module.get(TenantRepository);
|
||||
userTenantModel = module.get(UserTenant.name);
|
||||
organizationBuildQueue = module.get(getQueueToken(OrganizationBuildQueue));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -122,7 +125,7 @@ describe('CreateWorkspaceService', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
organizationId: mockTenant.organizationId,
|
||||
jobId: mockJob.id,
|
||||
jobId: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,33 +169,32 @@ describe('CreateWorkspaceService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should enqueue the organization build job outside the transaction', async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
mockKnexTransaction.mockImplementationOnce(async (callback) => {
|
||||
const trx = {};
|
||||
const result = await callback(trx);
|
||||
callOrder.push('transactionCommitted');
|
||||
return result;
|
||||
});
|
||||
|
||||
(organizationBuildQueue.add as jest.Mock).mockImplementationOnce(async () => {
|
||||
callOrder.push('enqueueJob');
|
||||
return mockJob;
|
||||
});
|
||||
|
||||
it('should call buildForTenant after transaction commit', async () => {
|
||||
await service.createWorkspace(userId, dto);
|
||||
|
||||
expect(callOrder).toEqual(['transactionCommitted', 'enqueueJob']);
|
||||
expect(mockBuildOrganizationService.buildForTenant).toHaveBeenCalledWith(
|
||||
mockTenant.id,
|
||||
userId,
|
||||
expect.objectContaining({
|
||||
name: dto.name,
|
||||
baseCurrency: dto.baseCurrency,
|
||||
location: dto.location,
|
||||
timezone: dto.timezone,
|
||||
fiscalYear: dto.fiscalYear,
|
||||
language: dto.language,
|
||||
industry: dto.industry,
|
||||
dateFormat: 'DD MMM YYYY',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return organization id and job id', async () => {
|
||||
it('should return organization id with null job id', async () => {
|
||||
const result = await service.createWorkspace(userId, dto);
|
||||
|
||||
expect(result).toHaveProperty('organizationId');
|
||||
expect(result).toHaveProperty('jobId');
|
||||
expect(result.organizationId).toBe(mockTenant.organizationId);
|
||||
expect(result.jobId).toBe(mockJob.id);
|
||||
expect(result.jobId).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle tenant creation failure and rollback transaction', async () => {
|
||||
@@ -225,7 +227,7 @@ describe('CreateWorkspaceService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not enqueue job if transaction fails', async () => {
|
||||
it('should not call buildForTenant if transaction fails', async () => {
|
||||
tenantRepository.createWithUniqueOrgId.mockRejectedValueOnce(
|
||||
new Error('Database error'),
|
||||
);
|
||||
@@ -234,17 +236,17 @@ describe('CreateWorkspaceService', () => {
|
||||
'Database error',
|
||||
);
|
||||
|
||||
expect(organizationBuildQueue.add).not.toHaveBeenCalled();
|
||||
expect(mockBuildOrganizationService.buildForTenant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle queue add failure after successful transaction', async () => {
|
||||
organizationBuildQueue.add.mockRejectedValueOnce(
|
||||
new Error('Queue error'),
|
||||
it('should handle buildForTenant failure after successful transaction', async () => {
|
||||
mockBuildOrganizationService.buildForTenant.mockRejectedValueOnce(
|
||||
new Error('Build error'),
|
||||
);
|
||||
|
||||
// Transaction should succeed but then queue add should fail
|
||||
// Transaction should succeed but then build should fail
|
||||
await expect(service.createWorkspace(userId, dto)).rejects.toThrow(
|
||||
'Queue error',
|
||||
'Build error',
|
||||
);
|
||||
|
||||
// Transaction should have completed
|
||||
@@ -321,14 +323,60 @@ describe('CreateWorkspaceService', () => {
|
||||
return mockTenant;
|
||||
});
|
||||
|
||||
(organizationBuildQueue.add as jest.Mock).mockImplementationOnce(async () => {
|
||||
callOrder.push('enqueueJob');
|
||||
return mockJob;
|
||||
(mockEventEmitter.emitAsync as jest.Mock).mockImplementationOnce(async () => {
|
||||
callOrder.push('emitEvent');
|
||||
return [];
|
||||
});
|
||||
|
||||
(mockBuildOrganizationService.buildForTenant as jest.Mock).mockImplementationOnce(async () => {
|
||||
callOrder.push('buildForTenant');
|
||||
});
|
||||
|
||||
await service.createWorkspace(userId, dto);
|
||||
|
||||
expect(callOrder).toEqual(['createTenant', 'linkUser', 'saveMetadata', 'transactionCommitted', 'enqueueJob']);
|
||||
expect(callOrder).toEqual(['createTenant', 'linkUser', 'saveMetadata', 'transactionCommitted', 'emitEvent', 'buildForTenant']);
|
||||
});
|
||||
|
||||
it('should emit workspace created event after transaction commit', async () => {
|
||||
await service.createWorkspace(userId, dto);
|
||||
|
||||
expect(mockEventEmitter.emitAsync).toHaveBeenCalledWith(
|
||||
events.workspace.created,
|
||||
expect.objectContaining({
|
||||
tenantId: mockTenant.id,
|
||||
organizationId: mockTenant.organizationId,
|
||||
userId,
|
||||
buildDTO: expect.objectContaining({
|
||||
name: dto.name,
|
||||
baseCurrency: dto.baseCurrency,
|
||||
location: dto.location,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit event after transaction commit and before build', async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
mockKnexTransaction.mockImplementationOnce(async (callback) => {
|
||||
const trx = {};
|
||||
const result = await callback(trx);
|
||||
callOrder.push('transactionCommitted');
|
||||
return result;
|
||||
});
|
||||
|
||||
(mockEventEmitter.emitAsync as jest.Mock).mockImplementationOnce(async () => {
|
||||
callOrder.push('emitEvent');
|
||||
return [];
|
||||
});
|
||||
|
||||
(mockBuildOrganizationService.buildForTenant as jest.Mock).mockImplementationOnce(async () => {
|
||||
callOrder.push('buildForTenant');
|
||||
});
|
||||
|
||||
await service.createWorkspace(userId, dto);
|
||||
|
||||
expect(callOrder).toEqual(['transactionCommitted', 'emitEvent', 'buildForTenant']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { Queue } from 'bullmq';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Knex } from 'knex';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { UserTenant } from '@/modules/System/models/UserTenant.model';
|
||||
import { TenantRepository } from '@/modules/System/repositories/Tenant.repository';
|
||||
import {
|
||||
OrganizationBuildQueue,
|
||||
OrganizationBuildQueueJob,
|
||||
OrganizationBuildQueueJobPayload,
|
||||
} from '@/modules/Organization/Organization.types';
|
||||
import { BuildOrganizationService } from '@/modules/Organization/commands/BuildOrganization.service';
|
||||
import { transformBuildDto } from '@/modules/Organization/Organization.utils';
|
||||
import { SystemKnexConnection } from '@/modules/System/SystemDB/SystemDB.constants';
|
||||
import { events } from '@/common/events/events';
|
||||
import { CreateWorkspaceDto } from '../dtos/CreateWorkspace.dto';
|
||||
import { CreateWorkspaceResponseDto } from '../dtos/WorkspaceResponse.dto';
|
||||
import { IWorkspaceCreatedEventPayload } from '../Workspaces.types';
|
||||
|
||||
@Injectable()
|
||||
export class CreateWorkspaceService {
|
||||
@@ -22,8 +19,9 @@ export class CreateWorkspaceService {
|
||||
|
||||
private readonly tenantRepository: TenantRepository,
|
||||
|
||||
@InjectQueue(OrganizationBuildQueue)
|
||||
private readonly organizationBuildQueue: Queue,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
|
||||
private readonly buildOrganizationService: BuildOrganizationService,
|
||||
|
||||
@Inject(SystemKnexConnection)
|
||||
private readonly systemKnex: Knex,
|
||||
@@ -60,20 +58,25 @@ export class CreateWorkspaceService {
|
||||
return tenant;
|
||||
});
|
||||
|
||||
// Enqueue the build job outside the transaction.
|
||||
// This ensures the DB changes are committed before the job starts processing.
|
||||
const jobMeta = await this.organizationBuildQueue.add(
|
||||
OrganizationBuildQueueJob,
|
||||
{
|
||||
organizationId: tenant.organizationId,
|
||||
userId,
|
||||
buildDto: transformedDto,
|
||||
} as OrganizationBuildQueueJobPayload,
|
||||
// Emit workspace created event for subscribers to handle any post-creation setup.
|
||||
await this.eventEmitter.emitAsync(events.workspace.created, {
|
||||
tenantId: tenant.id,
|
||||
organizationId: tenant.organizationId,
|
||||
userId,
|
||||
buildDTO: transformedDto,
|
||||
} as IWorkspaceCreatedEventPayload);
|
||||
|
||||
// Build the organization for the newly created tenant.
|
||||
// This creates the tenant database and seeds initial data.
|
||||
await this.buildOrganizationService.buildForTenant(
|
||||
tenant.id,
|
||||
userId,
|
||||
transformedDto,
|
||||
);
|
||||
|
||||
return {
|
||||
organizationId: tenant.organizationId,
|
||||
jobId: jobMeta.id,
|
||||
jobId: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||
import { UserTenant } from '@/modules/System/models/UserTenant.model';
|
||||
import { TenantModel } from '@/modules/System/models/TenantModel';
|
||||
import { TenantRepository } from '@/modules/System/repositories/Tenant.repository';
|
||||
import { TenantDBManager } from '@/modules/TenantDBManager/TenantDBManager';
|
||||
import { events } from '@/common/events/events';
|
||||
|
||||
const ERRORS = {
|
||||
WORKSPACE_NOT_FOUND: 'WORKSPACE.NOT_FOUND',
|
||||
@@ -18,9 +19,8 @@ export class DeleteWorkspaceService {
|
||||
|
||||
@Inject(TenantModel.name)
|
||||
private readonly tenantModel: typeof TenantModel,
|
||||
|
||||
private readonly tenantRepository: TenantRepository,
|
||||
private readonly tenantDBManager: TenantDBManager,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -47,5 +47,12 @@ export class DeleteWorkspaceService {
|
||||
|
||||
// Delete the tenant row — cascades to user_tenants via FK.
|
||||
await this.tenantModel.query().deleteById(tenant.id);
|
||||
|
||||
// Emit workspace deleted event.
|
||||
await this.eventEmitter.emitAsync(events.workspace.deleted, {
|
||||
organizationId,
|
||||
userId,
|
||||
tenantId: tenant.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Queue } from 'bullmq';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||
import { UserTenant } from '@/modules/System/models/UserTenant.model';
|
||||
import { TenantModel } from '@/modules/System/models/TenantModel';
|
||||
import {
|
||||
DeleteWorkspaceQueue,
|
||||
DeleteWorkspaceQueueJobPayload,
|
||||
} from '../Workspaces.types';
|
||||
import { events } from '@/common/events/events';
|
||||
|
||||
const ERRORS = {
|
||||
WORKSPACE_NOT_FOUND: 'WORKSPACE.NOT_FOUND',
|
||||
NOT_WORKSPACE_OWNER: 'NOT.WORKSPACE.OWNER',
|
||||
WORKSPACE_DELETING: 'WORKSPACE.DELETING',
|
||||
};
|
||||
|
||||
interface DeleteWorkspaceResult {
|
||||
jobId: string | number;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DeleteWorkspaceJobService {
|
||||
constructor(
|
||||
@Inject(UserTenant.name)
|
||||
private readonly userTenantModel: typeof UserTenant,
|
||||
|
||||
@Inject(TenantModel.name)
|
||||
private readonly tenantModel: typeof TenantModel,
|
||||
|
||||
@InjectQueue(DeleteWorkspaceQueue)
|
||||
private readonly deleteWorkspaceQueue: Queue,
|
||||
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Initiates a workspace deletion by enqueueing a job.
|
||||
* Only the owner can delete a workspace.
|
||||
* @param {number} userId - The user id requesting deletion.
|
||||
* @param {string} organizationId - The organization id to delete.
|
||||
* @returns {Promise<DeleteWorkspaceResult>} - Returns the job id and organization id.
|
||||
*/
|
||||
async initiateDelete(
|
||||
userId: number,
|
||||
organizationId: string,
|
||||
): Promise<DeleteWorkspaceResult> {
|
||||
const tenant = await this.tenantModel.query().findOne({ organizationId });
|
||||
|
||||
if (!tenant) {
|
||||
throw new ServiceError(ERRORS.WORKSPACE_NOT_FOUND);
|
||||
}
|
||||
|
||||
const membership = await this.userTenantModel
|
||||
.query()
|
||||
.findOne({ userId, tenantId: tenant.id });
|
||||
|
||||
if (!membership || membership.role !== 'owner') {
|
||||
throw new ServiceError(ERRORS.NOT_WORKSPACE_OWNER);
|
||||
}
|
||||
|
||||
// Check if workspace is already being deleted.
|
||||
if (tenant.isDeleting) {
|
||||
throw new ServiceError(ERRORS.WORKSPACE_DELETING);
|
||||
}
|
||||
|
||||
// Emit workspace deleting event.
|
||||
await this.eventEmitter.emitAsync(events.workspace.deleting, {
|
||||
organizationId,
|
||||
userId,
|
||||
tenantId: tenant.id,
|
||||
});
|
||||
|
||||
// Mark the tenant as being deleted.
|
||||
await this.tenantModel.query().findById(tenant.id).patch({
|
||||
isDeleting: true,
|
||||
});
|
||||
|
||||
// Enqueue the deletion job.
|
||||
const job = await this.deleteWorkspaceQueue.add('delete-workspace', {
|
||||
organizationId,
|
||||
userId,
|
||||
} as DeleteWorkspaceQueueJobPayload);
|
||||
|
||||
return {
|
||||
jobId: job.id!,
|
||||
organizationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ServiceError } from '@/modules/Items/ServiceError';
|
||||
import { UserTenant } from '@/modules/System/models/UserTenant.model';
|
||||
import { TenantModel } from '@/modules/System/models/TenantModel';
|
||||
|
||||
@Injectable()
|
||||
export class InactivateWorkspaceService {
|
||||
constructor(
|
||||
@Inject(UserTenant.name)
|
||||
private readonly userTenantModel: typeof UserTenant,
|
||||
|
||||
@Inject(TenantModel.name)
|
||||
private readonly tenantModel: typeof TenantModel,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Inactivates a workspace. Only the owner can inactivate.
|
||||
* @param {number} userId
|
||||
* @param {string} organizationId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async inactivateWorkspace(userId: number, organizationId: string): Promise<void> {
|
||||
const tenant = await this.tenantModel.query().findOne({ organizationId });
|
||||
|
||||
if (!tenant) {
|
||||
throw new ServiceError('WORKSPACE_NOT_FOUND', 'Workspace not found');
|
||||
}
|
||||
|
||||
const membership = await this.userTenantModel
|
||||
.query()
|
||||
.findOne({ userId, tenantId: tenant.id })
|
||||
.withGraphFetched('tenant');
|
||||
|
||||
if (!membership) {
|
||||
throw new ServiceError('WORKSPACE_NOT_FOUND', 'Workspace not found');
|
||||
}
|
||||
|
||||
if (membership.role !== 'owner') {
|
||||
throw new ServiceError(
|
||||
'NOT_OWNER',
|
||||
'Only the workspace owner can inactivate the workspace',
|
||||
);
|
||||
}
|
||||
|
||||
await this.tenantModel
|
||||
.query()
|
||||
.findById(tenant.id)
|
||||
.patch({
|
||||
isInactive: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactivates a workspace. Only the owner can reactivate.
|
||||
* @param {number} userId
|
||||
* @param {string} organizationId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async activateWorkspace(userId: number, organizationId: string): Promise<void> {
|
||||
const tenant = await this.tenantModel.query().findOne({ organizationId });
|
||||
|
||||
if (!tenant) {
|
||||
throw new ServiceError('WORKSPACE_NOT_FOUND', 'Workspace not found');
|
||||
}
|
||||
|
||||
const membership = await this.userTenantModel
|
||||
.query()
|
||||
.findOne({ userId, tenantId: tenant.id })
|
||||
.withGraphFetched('tenant');
|
||||
|
||||
if (!membership) {
|
||||
throw new ServiceError('WORKSPACE_NOT_FOUND', 'Workspace not found');
|
||||
}
|
||||
|
||||
if (membership.role !== 'owner') {
|
||||
throw new ServiceError(
|
||||
'NOT_OWNER',
|
||||
'Only the workspace owner can reactivate the workspace',
|
||||
);
|
||||
}
|
||||
|
||||
await this.tenantModel
|
||||
.query()
|
||||
.findById(tenant.id)
|
||||
.patch({
|
||||
isInactive: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,15 @@ export class WorkspaceDto {
|
||||
@ApiProperty() organizationId: string;
|
||||
@ApiProperty() isReady: boolean;
|
||||
@ApiProperty() isBuildRunning: boolean;
|
||||
@ApiProperty() isDeleting: boolean;
|
||||
@ApiProperty() isActive: boolean;
|
||||
@ApiPropertyOptional() buildJobId?: string;
|
||||
@ApiProperty() role: 'owner' | 'member';
|
||||
@ApiPropertyOptional() isDefault?: boolean;
|
||||
@ApiPropertyOptional({ type: WorkspaceMetadataDto })
|
||||
metadata?: WorkspaceMetadataDto;
|
||||
@ApiPropertyOptional() totalIncome?: number;
|
||||
@ApiPropertyOptional() totalExpenses?: number;
|
||||
}
|
||||
|
||||
export class CreateWorkspaceResponseDto {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import { Scope } from '@nestjs/common';
|
||||
import { ClsService, UseCls } from 'nestjs-cls';
|
||||
import {
|
||||
DeleteWorkspaceQueue,
|
||||
DeleteWorkspaceQueueJobPayload,
|
||||
} from '../Workspaces.types';
|
||||
import { DeleteWorkspaceService } from '../commands/DeleteWorkspace.service';
|
||||
|
||||
@Processor({
|
||||
name: DeleteWorkspaceQueue,
|
||||
scope: Scope.REQUEST,
|
||||
})
|
||||
export class DeleteWorkspaceProcessor extends WorkerHost {
|
||||
constructor(
|
||||
private readonly deleteWorkspaceService: DeleteWorkspaceService,
|
||||
private readonly clsService: ClsService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@UseCls()
|
||||
async process(job: Job<DeleteWorkspaceQueueJobPayload>) {
|
||||
console.log('Processing workspace deletion job:', job.id);
|
||||
|
||||
this.clsService.set('organizationId', job.data.organizationId);
|
||||
this.clsService.set('userId', job.data.userId);
|
||||
|
||||
try {
|
||||
await this.deleteWorkspaceService.deleteWorkspace(
|
||||
job.data.userId,
|
||||
job.data.organizationId,
|
||||
);
|
||||
console.log('Workspace deletion completed successfully:', job.id);
|
||||
} catch (error) {
|
||||
console.error('Error processing workspace deletion job:', error);
|
||||
throw error; // Re-throw to mark job as failed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { UserTenant } from '@/modules/System/models/UserTenant.model';
|
||||
import { SystemUser } from '@/modules/System/models/SystemUser';
|
||||
import { WorkspaceDto } from '../dtos/WorkspaceResponse.dto';
|
||||
import { WorkspaceTransformer } from '../transformers/WorkspaceTransformer';
|
||||
import { GetWorkspacesFinancialService } from './GetWorkspacesFinancial.service';
|
||||
|
||||
@Injectable()
|
||||
export class GetWorkspacesService {
|
||||
@@ -11,13 +12,20 @@ export class GetWorkspacesService {
|
||||
private readonly userTenantModel: typeof UserTenant,
|
||||
@Inject(SystemUser.name)
|
||||
private readonly systemUserModel: typeof SystemUser,
|
||||
private readonly financialService: GetWorkspacesFinancialService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns all workspaces (organizations) the given user belongs to,
|
||||
* including their metadata and build status.
|
||||
* including their metadata, build status, and financial data.
|
||||
* @param includeInactive - Whether to include inactive workspaces (default: false)
|
||||
* @param currentOrganizationId - Current org ID to sort first (only when includeInactive is false)
|
||||
*/
|
||||
async getWorkspaces(userId: number): Promise<WorkspaceDto[]> {
|
||||
async getWorkspaces(
|
||||
userId: number,
|
||||
includeInactive: boolean = false,
|
||||
currentOrganizationId?: string,
|
||||
): Promise<WorkspaceDto[]> {
|
||||
const memberships = await this.userTenantModel
|
||||
.query()
|
||||
.where('userId', userId)
|
||||
@@ -32,9 +40,40 @@ export class GetWorkspacesService {
|
||||
|
||||
const defaultTenantId = user?.defaultTenantId;
|
||||
|
||||
// Fetch financial data for all workspaces
|
||||
const workspaceInfos = memberships.map((m) => ({
|
||||
tenantId: m.tenantId,
|
||||
organizationId: m.tenant?.organizationId,
|
||||
isReady: m.tenant?.isReady ?? false,
|
||||
}));
|
||||
|
||||
const financialDataMap =
|
||||
await this.financialService.getWorkspacesFinancial(workspaceInfos);
|
||||
|
||||
const transformer = new WorkspaceTransformer();
|
||||
return memberships.map((membership) =>
|
||||
transformer.transform(membership, defaultTenantId),
|
||||
);
|
||||
let workspaces = memberships.map((membership) => {
|
||||
const financialData = financialDataMap.get(membership.tenantId);
|
||||
return transformer.transform(
|
||||
membership,
|
||||
defaultTenantId,
|
||||
financialData,
|
||||
);
|
||||
});
|
||||
|
||||
// Filter out inactive workspaces unless includeInactive is true
|
||||
if (!includeInactive) {
|
||||
workspaces = workspaces.filter((w) => w.isActive);
|
||||
}
|
||||
|
||||
// Sort: current organization first, then by name
|
||||
return workspaces.sort((a, b) => {
|
||||
if (currentOrganizationId) {
|
||||
if (a.organizationId === currentOrganizationId) return -1;
|
||||
if (b.organizationId === currentOrganizationId) return 1;
|
||||
}
|
||||
return (a.metadata?.name || a.organizationId).localeCompare(
|
||||
b.metadata?.name || b.organizationId,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as moment from 'moment';
|
||||
import { Knex } from 'knex';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { knexSnakeCaseMappers } from 'objection';
|
||||
import { ACCOUNT_TYPE } from '@/constants/accounts';
|
||||
import { ACCOUNT_ROOT_TYPE } from '@/modules/Accounts/Accounts.constants';
|
||||
|
||||
interface WorkspaceFinancialData {
|
||||
tenantId: number;
|
||||
totalIncome: number;
|
||||
totalExpenses: number;
|
||||
}
|
||||
|
||||
interface AccountTransaction {
|
||||
credit: number;
|
||||
debit: number;
|
||||
accountNormal: string;
|
||||
accountRootType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service to retrieve financial data (income and expenses) for multiple workspaces.
|
||||
* This service creates dynamic connections to tenant databases to fetch aggregated data.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GetWorkspacesFinancialService {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
/**
|
||||
* Get a knex instance for a specific tenant database.
|
||||
*/
|
||||
private getTenantKnex(organizationId: string): Knex {
|
||||
const database = `bigcapital_tenant_${organizationId}`;
|
||||
|
||||
return require('knex')({
|
||||
client: this.configService.get('tenantDatabase.client'),
|
||||
connection: {
|
||||
host: this.configService.get('tenantDatabase.host'),
|
||||
user: this.configService.get('tenantDatabase.user'),
|
||||
password: this.configService.get('tenantDatabase.password'),
|
||||
database,
|
||||
charset: 'utf8',
|
||||
},
|
||||
pool: { min: 0, max: 2, acquireTimeoutMillis: 5000, idleTimeoutMillis: 10000 },
|
||||
...knexSnakeCaseMappers({ upperCase: true }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total income from account transactions.
|
||||
* Income accounts have credit normal, so income = credit - debit.
|
||||
*/
|
||||
private calculateIncome(transactions: AccountTransaction[]): number {
|
||||
return transactions
|
||||
.filter((t) => t.accountRootType === ACCOUNT_ROOT_TYPE.INCOME)
|
||||
.reduce((sum, t) => sum + (t.credit - t.debit), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total expenses from account transactions.
|
||||
* Expense accounts have debit normal, so expenses = debit - credit.
|
||||
*/
|
||||
private calculateExpenses(transactions: AccountTransaction[]): number {
|
||||
return transactions
|
||||
.filter((t) => t.accountRootType === ACCOUNT_ROOT_TYPE.EXPENSE)
|
||||
.reduce((sum, t) => sum + (t.debit - t.credit), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch financial data for a single tenant.
|
||||
*/
|
||||
private async fetchTenantFinancialData(
|
||||
tenantId: number,
|
||||
organizationId: string,
|
||||
): Promise<WorkspaceFinancialData> {
|
||||
const knex = this.getTenantKnex(organizationId);
|
||||
|
||||
try {
|
||||
// Get the current year date range
|
||||
const fromDate = moment().startOf('year').format('YYYY-MM-DD');
|
||||
const toDate = moment().endOf('year').format('YYYY-MM-DD');
|
||||
|
||||
// Query to get aggregated transactions by account with account type info
|
||||
const transactions = await knex('accounts_transactions as at')
|
||||
.join('accounts as a', 'at.account_id', 'a.id')
|
||||
.whereBetween('at.date', [fromDate, toDate])
|
||||
.select(
|
||||
knex.raw('SUM(at.credit) as credit'),
|
||||
knex.raw('SUM(at.debit) as debit'),
|
||||
'a.account_normal as accountNormal',
|
||||
'a.root_type as accountRootType',
|
||||
)
|
||||
.groupBy('at.account_id', 'a.account_normal', 'a.root_type');
|
||||
|
||||
const totalIncome = this.calculateIncome(transactions);
|
||||
const totalExpenses = this.calculateExpenses(transactions);
|
||||
|
||||
return {
|
||||
tenantId,
|
||||
totalIncome: Math.max(0, totalIncome),
|
||||
totalExpenses: Math.max(0, totalExpenses),
|
||||
};
|
||||
} catch (error) {
|
||||
// If tenant database doesn't exist or other error, return zeros
|
||||
return {
|
||||
tenantId,
|
||||
totalIncome: 0,
|
||||
totalExpenses: 0,
|
||||
};
|
||||
} finally {
|
||||
await knex.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get financial data (total income and expenses) for a list of workspaces.
|
||||
* @param workspaces - Array of workspace info with tenantId and organizationId
|
||||
* @returns Map of tenantId to financial data
|
||||
*/
|
||||
async getWorkspacesFinancial(
|
||||
workspaces: Array<{ tenantId: number; organizationId: string; isReady: boolean }>,
|
||||
): Promise<Map<number, WorkspaceFinancialData>> {
|
||||
const results = new Map<number, WorkspaceFinancialData>();
|
||||
|
||||
// Filter only ready workspaces (have initialized databases)
|
||||
const readyWorkspaces = workspaces.filter((w) => w.isReady);
|
||||
|
||||
// Fetch financial data for each workspace in parallel
|
||||
const promises = readyWorkspaces.map(async (workspace) => {
|
||||
const data = await this.fetchTenantFinancialData(
|
||||
workspace.tenantId,
|
||||
workspace.organizationId,
|
||||
);
|
||||
return data;
|
||||
});
|
||||
|
||||
const financialDataList = await Promise.all(promises);
|
||||
|
||||
// Build the map
|
||||
financialDataList.forEach((data) => {
|
||||
results.set(data.tenantId, data);
|
||||
});
|
||||
|
||||
// Add zero values for non-ready workspaces
|
||||
workspaces
|
||||
.filter((w) => !w.isReady)
|
||||
.forEach((w) => {
|
||||
results.set(w.tenantId, {
|
||||
tenantId: w.tenantId,
|
||||
totalIncome: 0,
|
||||
totalExpenses: 0,
|
||||
});
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { events } from '@/common/events/events';
|
||||
import { IWorkspaceCreatedEventPayload } from '../Workspaces.types';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceCreatedSubscriber {
|
||||
constructor(
|
||||
// Inject services needed for workspace setup
|
||||
// e.g., private readonly someSetupService: SomeSetupService,
|
||||
) {}
|
||||
|
||||
@OnEvent(events.workspace.created)
|
||||
async handleWorkspaceCreated({
|
||||
tenantId,
|
||||
organizationId,
|
||||
userId,
|
||||
buildDTO,
|
||||
}: IWorkspaceCreatedEventPayload) {
|
||||
// Handle any setup that needs to happen after workspace creation.
|
||||
// This runs after system-level metadata is saved in tenants_metadata.
|
||||
// Note: The tenant database is not ready yet - the build job will handle that later.
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,36 @@ import { Transformer } from '@/modules/Transformer/Transformer';
|
||||
import { UserTenant } from '@/modules/System/models/UserTenant.model';
|
||||
import { WorkspaceDto } from '../dtos/WorkspaceResponse.dto';
|
||||
|
||||
interface FinancialData {
|
||||
tenantId: number;
|
||||
totalIncome: number;
|
||||
totalExpenses: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms UserTenant (workspace membership) to WorkspaceDto.
|
||||
*/
|
||||
export class WorkspaceTransformer extends Transformer<UserTenant> {
|
||||
private defaultTenantId?: number;
|
||||
private financialData?: FinancialData;
|
||||
|
||||
/**
|
||||
* Include these attributes in the transformed output.
|
||||
*/
|
||||
public includeAttributes = (): string[] => {
|
||||
return ['organizationId', 'isReady', 'isBuildRunning', 'buildJobId', 'role', 'metadata', 'isDefault'];
|
||||
return [
|
||||
'organizationId',
|
||||
'isReady',
|
||||
'isBuildRunning',
|
||||
'isDeleting',
|
||||
'isActive',
|
||||
'buildJobId',
|
||||
'role',
|
||||
'metadata',
|
||||
'isDefault',
|
||||
'totalIncome',
|
||||
'totalExpenses',
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -36,6 +55,20 @@ export class WorkspaceTransformer extends Transformer<UserTenant> {
|
||||
return membership.tenant?.isBuildRunning ?? false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract isDeleting from tenant relation.
|
||||
*/
|
||||
protected isDeleting = (membership: UserTenant): boolean => {
|
||||
return membership.tenant?.isDeleting ?? false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract isActive from tenant relation.
|
||||
*/
|
||||
protected isActive = (membership: UserTenant): boolean => {
|
||||
return membership.tenant?.isActive ?? false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract buildJobId from tenant relation.
|
||||
*/
|
||||
@@ -68,19 +101,42 @@ export class WorkspaceTransformer extends Transformer<UserTenant> {
|
||||
return membership.tenantId === this.defaultTenantId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get total income from financial data.
|
||||
*/
|
||||
protected totalIncome = (): number => {
|
||||
return this.financialData?.totalIncome ?? 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get total expenses from financial data.
|
||||
*/
|
||||
protected totalExpenses = (): number => {
|
||||
return this.financialData?.totalExpenses ?? 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform single membership to WorkspaceDto.
|
||||
*/
|
||||
transform = (membership: UserTenant, defaultTenantId?: number): WorkspaceDto => {
|
||||
transform = (
|
||||
membership: UserTenant,
|
||||
defaultTenantId?: number,
|
||||
financialData?: FinancialData,
|
||||
): WorkspaceDto => {
|
||||
this.defaultTenantId = defaultTenantId;
|
||||
this.financialData = financialData;
|
||||
return {
|
||||
organizationId: this.organizationId(membership),
|
||||
isReady: this.isReady(membership),
|
||||
isBuildRunning: this.isBuildRunning(membership),
|
||||
isDeleting: this.isDeleting(membership),
|
||||
isActive: this.isActive(membership),
|
||||
buildJobId: this.buildJobId(membership),
|
||||
role: membership.role,
|
||||
isDefault: this.isDefault(membership),
|
||||
metadata: this.metadata(membership),
|
||||
totalIncome: this.totalIncome(),
|
||||
totalExpenses: this.totalExpenses(),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Switch, Route } from 'react-router';
|
||||
import '@/style/pages/Dashboard/Dashboard.scss';
|
||||
|
||||
import { Sidebar } from '@/containers/Dashboard/Sidebar/Sidebar';
|
||||
import { WorkspacesSidebar } from '@/containers/Dashboard/WorkspacesSidebar/WorkspacesSidebar';
|
||||
import DashboardContent from '@/components/Dashboard/DashboardContent';
|
||||
import DialogsContainer from '@/components/DialogsContainer';
|
||||
import PreferencesPage from '@/components/Preferences/PreferencesPage';
|
||||
@@ -23,7 +22,6 @@ import { DashboardSockets } from './DashboardSockets';
|
||||
function DashboardPreferences() {
|
||||
return (
|
||||
<div className="dashboard-layout">
|
||||
<WorkspacesSidebar />
|
||||
<div className="dashboard-layout__main">
|
||||
<DashboardSplitPane>
|
||||
<Sidebar />
|
||||
@@ -40,7 +38,6 @@ function DashboardPreferences() {
|
||||
function DashboardAnyPage() {
|
||||
return (
|
||||
<div className="dashboard-layout">
|
||||
<WorkspacesSidebar />
|
||||
<div className="dashboard-layout__main">
|
||||
<DashboardSplitPane>
|
||||
<Sidebar />
|
||||
|
||||
@@ -50,6 +50,8 @@ import { DisconnectBankAccountDialog } from '@/containers/CashFlow/AccountTransa
|
||||
import { SharePaymentLinkDialog } from '@/containers/PaymentLink/dialogs/SharePaymentLinkDialog/SharePaymentLinkDialog';
|
||||
import { SelectPaymentMethodsDialog } from '@/containers/PaymentLink/dialogs/SelectPaymentMethodsDialog/SelectPaymentMethodsDialog';
|
||||
import ApiKeysGenerateDialog from '@/containers/Dialogs/ApiKeysGenerateDialog';
|
||||
import WorkspaceDeleteDialog from '@/containers/Dialogs/Workspaces/WorkspaceDeleteDialog';
|
||||
import WorkspaceInactivateDialog from '@/containers/Dialogs/Workspaces/WorkspaceInactivateDialog';
|
||||
import InvoiceBulkDeleteDialog from '@/containers/Dialogs/Invoices/InvoiceBulkDeleteDialog';
|
||||
import EstimateBulkDeleteDialog from '@/containers/Dialogs/Estimates/EstimateBulkDeleteDialog';
|
||||
import ReceiptBulkDeleteDialog from '@/containers/Dialogs/Receipts/ReceiptBulkDeleteDialog';
|
||||
@@ -185,6 +187,8 @@ export default function DialogsContainer() {
|
||||
<ApiKeysGenerateDialog
|
||||
dialogName={DialogsName.ApiKeysGenerate}
|
||||
/>
|
||||
<WorkspaceDeleteDialog dialogName={DialogsName.WorkspaceDelete} />
|
||||
<WorkspaceInactivateDialog dialogName={DialogsName.WorkspaceInactivate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,5 +94,7 @@ export enum DialogsName {
|
||||
SelectPaymentMethod = 'SelectPaymentMethodsDialog',
|
||||
|
||||
StripeSetup = 'StripeSetup',
|
||||
ApiKeysGenerate = 'api-keys-generate'
|
||||
ApiKeysGenerate = 'api-keys-generate',
|
||||
WorkspaceDelete = 'workspace-delete',
|
||||
WorkspaceInactivate = 'workspace-inactivate',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// @ts-nocheck
|
||||
// No workspace alerts - using dialogs instead
|
||||
export default [];
|
||||
@@ -25,6 +25,7 @@ import WarehousesTransfersAlerts from '@/containers/WarehouseTransfers/Warehouse
|
||||
import BranchesAlerts from '@/containers/Preferences/Branches/BranchesAlerts';
|
||||
import ProjectAlerts from '@/containers/Projects/containers/ProjectAlerts';
|
||||
import TaxRatesAlerts from '@/containers/TaxRates/alerts';
|
||||
import WorkspacesAlerts from '@/containers/Alerts/Workspaces/WorkspacesAlerts';
|
||||
import { CashflowAlerts } from '../CashFlow/CashflowAlerts';
|
||||
import { BankRulesAlerts } from '../Banking/Rules/RulesList/BankRulesAlerts';
|
||||
import { SubscriptionAlerts } from '../Subscriptions/alerts/alerts';
|
||||
@@ -65,4 +66,5 @@ export default [
|
||||
...BankAccountAlerts,
|
||||
...BrandingTemplatesAlerts,
|
||||
...PaymentMethodsAlerts,
|
||||
...WorkspacesAlerts,
|
||||
];
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
// @ts-nocheck
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuDivider,
|
||||
Position,
|
||||
} from '@blueprintjs/core';
|
||||
|
||||
import { Icon, FormattedMessage as T } from '@/components';
|
||||
|
||||
import { withCurrentOrganization } from '@/containers/Organization/withCurrentOrganization';
|
||||
import { useAuthenticatedAccount } from '@/hooks/query';
|
||||
import { compose } from '@/utils';
|
||||
import { useAuthenticatedAccount, useWorkspaces } from '@/hooks/query';
|
||||
import { useAuthOrganizationId, useAuthActions } from '@/hooks/state';
|
||||
import { useSwitchOrganization } from '@/hooks/useSwitchOrganization';
|
||||
import { DRAWERS } from '@/constants/drawers';
|
||||
import { withDrawerActions } from '@/containers/Drawer/withDrawerActions';
|
||||
import { compose, firstLettersArgs } from '@/utils';
|
||||
|
||||
// Popover modifiers.
|
||||
const POPOVER_MODIFIERS = {
|
||||
offset: { offset: '28, 8' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Sidebar head.
|
||||
@@ -9,19 +29,127 @@ import { compose } from '@/utils';
|
||||
function SidebarHeadJSX({
|
||||
// #withCurrentOrganization
|
||||
organization,
|
||||
// #withDrawerActions
|
||||
openDrawer,
|
||||
}) {
|
||||
// Retrieve authenticated user information.
|
||||
const { data: user } = useAuthenticatedAccount();
|
||||
const { data: workspaces } = useWorkspaces();
|
||||
const currentOrganizationId = useAuthOrganizationId();
|
||||
const switchOrganization = useSwitchOrganization();
|
||||
const { setLogout } = useAuthActions();
|
||||
|
||||
const handleSwitchWorkspace = (organizationId) => {
|
||||
if (organizationId === currentOrganizationId) {
|
||||
return;
|
||||
}
|
||||
switchOrganization(organizationId);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setLogout();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sidebar__head">
|
||||
<div className="sidebar__head-organization">
|
||||
<div className="title">{organization.name}</div>
|
||||
<Popover
|
||||
modifiers={POPOVER_MODIFIERS}
|
||||
boundary={'window'}
|
||||
content={
|
||||
<Menu className={'menu--dashboard-organization'}>
|
||||
{/* Current Organization Header */}
|
||||
<div className="org-item org-item--current">
|
||||
<div className="org-item__logo">
|
||||
{firstLettersArgs(...(organization.name || '').split(' '))}
|
||||
</div>
|
||||
<div className="org-item__name">{organization.name}</div>
|
||||
</div>
|
||||
|
||||
<MenuDivider />
|
||||
|
||||
{/* View All Workspaces */}
|
||||
<MenuItem
|
||||
icon={<Icon icon={'list'} size={16} />}
|
||||
text={
|
||||
<T id={'workspaces.view_all_workspaces'} />
|
||||
}
|
||||
onClick={() => openDrawer(DRAWERS.ORGANIZATIONS_LIST)}
|
||||
/>
|
||||
|
||||
<MenuDivider />
|
||||
|
||||
{/* Workspaces List */}
|
||||
<div className="org-workspaces-list">
|
||||
{workspaces?.map((workspace) => {
|
||||
const name = workspace.metadata?.name || workspace.organizationId;
|
||||
const initials = firstLettersArgs(...(name || '').split(' '));
|
||||
const isActive = workspace.organizationId === currentOrganizationId;
|
||||
const isDisabled = !workspace.isReady || workspace.isBuildRunning;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={workspace.organizationId}
|
||||
className={`org-workspace-item ${isActive ? 'is-active' : ''}`}
|
||||
disabled={isDisabled}
|
||||
onClick={() => handleSwitchWorkspace(workspace.organizationId)}
|
||||
text={
|
||||
<div className="org-workspace-item__content">
|
||||
<div className="org-workspace-item__avatar">
|
||||
{initials}
|
||||
</div>
|
||||
<span className="org-workspace-item__name">{name}</span>
|
||||
{isActive && (
|
||||
<Icon
|
||||
icon={'tick'}
|
||||
size={14}
|
||||
className="org-workspace-item__check"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<MenuDivider />
|
||||
|
||||
{/* Create Workspace */}
|
||||
<MenuItem
|
||||
icon={<Icon icon={'plus'} size={16} />}
|
||||
text={<T id={'workspaces.create_workspace'} />}
|
||||
onClick={() => openDrawer(DRAWERS.CREATE_WORKSPACE)}
|
||||
/>
|
||||
|
||||
{/* Log out */}
|
||||
<MenuItem
|
||||
icon={<Icon icon={'log-out'} size={16} />}
|
||||
text={<T id={'logout'} />}
|
||||
onClick={handleLogout}
|
||||
/>
|
||||
</Menu>
|
||||
}
|
||||
position={Position.BOTTOM}
|
||||
minimal={true}
|
||||
>
|
||||
<Button
|
||||
className="title"
|
||||
rightIcon={<Icon icon={'caret-down-16'} size={16} />}
|
||||
>
|
||||
{organization.name}
|
||||
</Button>
|
||||
</Popover>
|
||||
<span class="subtitle">{user.full_name}</span>
|
||||
</div>
|
||||
|
||||
<div className="sidebar__head-logo">
|
||||
<span className="bigcapital--alt">BC</span>
|
||||
<Icon
|
||||
icon={'mini-bigcapital'}
|
||||
width={28}
|
||||
height={28}
|
||||
className="bigcapital--alt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -29,4 +157,5 @@ function SidebarHeadJSX({
|
||||
|
||||
export const SidebarHead = compose(
|
||||
withCurrentOrganization(({ organization }) => ({ organization })),
|
||||
withDrawerActions,
|
||||
)(SidebarHeadJSX);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { Button, Classes, Dialog, Intent, Callout } from '@blueprintjs/core';
|
||||
import { FormattedMessage as T, AppToaster } from '@/components';
|
||||
import intl from 'react-intl-universal';
|
||||
|
||||
import { useDeleteWorkspace } from '@/hooks/query';
|
||||
import withDialogRedux from '@/components/DialogReduxConnect';
|
||||
import { withDialogActions } from '@/containers/Dialog/withDialogActions';
|
||||
import { compose } from '@/utils';
|
||||
|
||||
function WorkspaceDeleteDialog({
|
||||
dialogName,
|
||||
isOpen,
|
||||
payload: { organizationId, workspaceName } = {},
|
||||
|
||||
// #withDialogActions
|
||||
closeDialog,
|
||||
}) {
|
||||
const { mutateAsync: deleteWorkspace, isLoading } = useDeleteWorkspace();
|
||||
|
||||
const handleCancel = () => {
|
||||
closeDialog(dialogName);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
deleteWorkspace(organizationId)
|
||||
.then(() => {
|
||||
AppToaster.show({
|
||||
message: intl.get('workspaces.workspace_deleted_successfully', {
|
||||
fallback: 'Workspace has been deleted successfully',
|
||||
}),
|
||||
intent: Intent.SUCCESS,
|
||||
});
|
||||
closeDialog(dialogName);
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error?.response?.data?.errors?.[0]?.message ||
|
||||
intl.get('workspaces.cannot_delete_workspace', {
|
||||
fallback: 'Cannot delete workspace',
|
||||
});
|
||||
AppToaster.show({
|
||||
message: errorMessage,
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
closeDialog(dialogName);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={intl.get('workspaces.delete_workspace', { fallback: 'Delete Workspace' })}
|
||||
isOpen={isOpen}
|
||||
onClose={handleCancel}
|
||||
canEscapeKeyClose={!isLoading}
|
||||
canOutsideClickClose={!isLoading}
|
||||
className="workspace-delete-dialog"
|
||||
>
|
||||
<div className={Classes.DIALOG_BODY}>
|
||||
<Callout intent={Intent.DANGER} icon="warning-sign">
|
||||
<p
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: intl.get('workspaces.delete_workspace_confirmation', {
|
||||
name: workspaceName || organizationId,
|
||||
fallback: `Are you sure you want to delete <b>${workspaceName || organizationId}</b>? This action cannot be undone and all data will be permanently lost.`,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</Callout>
|
||||
|
||||
<div className="workspace-delete-dialog__details">
|
||||
<p>{intl.get('workspaces.delete_workspace_details', {
|
||||
fallback: 'Deleting this workspace will permanently remove:',
|
||||
})}</p>
|
||||
<ul>
|
||||
<li>{intl.get('workspaces.delete_workspace_all_data', { fallback: 'All organization data including transactions, accounts, and contacts' })}</li>
|
||||
<li>{intl.get('workspaces.delete_workspace_all_users', { fallback: 'All user associations and permissions' })}</li>
|
||||
<li>{intl.get('workspaces.delete_workspace_database', { fallback: 'The entire database for this workspace' })}</li>
|
||||
</ul>
|
||||
<p className="workspace-delete-dialog__warning">
|
||||
{intl.get('workspaces.delete_workspace_irreversible', {
|
||||
fallback: 'This action is irreversible. Please make sure you have exported any important data before proceeding.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Button onClick={handleCancel} disabled={isLoading}>
|
||||
<T id={'cancel'} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
intent={Intent.DANGER}
|
||||
onClick={handleConfirmDelete}
|
||||
loading={isLoading}
|
||||
icon="trash"
|
||||
>
|
||||
<T id={'delete'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default compose(
|
||||
withDialogRedux(),
|
||||
withDialogActions,
|
||||
)(WorkspaceDeleteDialog);
|
||||
@@ -0,0 +1,141 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { Button, Classes, Dialog, Intent, Callout } from '@blueprintjs/core';
|
||||
import { FormattedMessage as T, AppToaster } from '@/components';
|
||||
import intl from 'react-intl-universal';
|
||||
|
||||
import { useInactivateWorkspace, useActivateWorkspace } from '@/hooks/query';
|
||||
import withDialogRedux from '@/components/DialogReduxConnect';
|
||||
import { withDialogActions } from '@/containers/Dialog/withDialogActions';
|
||||
import { compose } from '@/utils';
|
||||
|
||||
function WorkspaceInactivateDialog({
|
||||
dialogName,
|
||||
isOpen,
|
||||
payload: { organizationId, workspaceName, isActive } = {},
|
||||
|
||||
// #withDialogActions
|
||||
closeDialog,
|
||||
}) {
|
||||
const { mutateAsync: inactivateWorkspace, isLoading: isInactivating } = useInactivateWorkspace();
|
||||
const { mutateAsync: activateWorkspace, isLoading: isActivating } = useActivateWorkspace();
|
||||
|
||||
const isLoading = isInactivating || isActivating;
|
||||
const isInactivateAction = isActive !== false;
|
||||
|
||||
const handleCancel = () => {
|
||||
closeDialog(dialogName);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
const action = isInactivateAction ? inactivateWorkspace : activateWorkspace;
|
||||
const successMessage = isInactivateAction
|
||||
? intl.get('workspaces.workspace_inactivated_successfully', {
|
||||
fallback: 'Workspace has been inactivated',
|
||||
})
|
||||
: intl.get('workspaces.workspace_activated_successfully', {
|
||||
fallback: 'Workspace has been reactivated',
|
||||
});
|
||||
const errorMessage = isInactivateAction
|
||||
? intl.get('workspaces.cannot_inactivate_workspace', {
|
||||
fallback: 'Cannot inactivate workspace',
|
||||
})
|
||||
: intl.get('workspaces.cannot_activate_workspace', {
|
||||
fallback: 'Cannot activate workspace',
|
||||
});
|
||||
|
||||
action(organizationId)
|
||||
.then(() => {
|
||||
AppToaster.show({
|
||||
message: successMessage,
|
||||
intent: Intent.SUCCESS,
|
||||
});
|
||||
closeDialog(dialogName);
|
||||
})
|
||||
.catch((error) => {
|
||||
const message =
|
||||
error?.response?.data?.errors?.[0]?.message || errorMessage;
|
||||
AppToaster.show({
|
||||
message,
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
closeDialog(dialogName);
|
||||
});
|
||||
};
|
||||
|
||||
const title = isInactivateAction
|
||||
? intl.get('workspaces.inactivate_workspace', { fallback: 'Inactivate Workspace' })
|
||||
: intl.get('workspaces.activate_workspace', { fallback: 'Activate Workspace' });
|
||||
|
||||
const confirmationKey = isInactivateAction
|
||||
? 'workspaces.inactivate_workspace_confirmation'
|
||||
: 'workspaces.activate_workspace_confirmation';
|
||||
|
||||
const confirmationFallback = isInactivateAction
|
||||
? `Are you sure you want to inactivate <b>${workspaceName || organizationId}</b>? No one will be able to sign in until it's reactivated.`
|
||||
: `Reactivate <b>${workspaceName || organizationId}</b>? Users will be able to sign in again.`;
|
||||
|
||||
const intent = isInactivateAction ? Intent.WARNING : Intent.SUCCESS;
|
||||
const icon = isInactivateAction ? 'warning-sign' : 'refresh';
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={title}
|
||||
isOpen={isOpen}
|
||||
onClose={handleCancel}
|
||||
canEscapeKeyClose={!isLoading}
|
||||
canOutsideClickClose={!isLoading}
|
||||
className="workspace-inactivate-dialog"
|
||||
>
|
||||
<div className={Classes.DIALOG_BODY}>
|
||||
<Callout intent={intent} icon={icon}>
|
||||
<p
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: intl.get(confirmationKey, {
|
||||
name: workspaceName || organizationId,
|
||||
fallback: confirmationFallback,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</Callout>
|
||||
|
||||
{isInactivateAction && (
|
||||
<div className="workspace-inactivate-dialog__details">
|
||||
<p>{intl.get('workspaces.inactivate_workspace_details', {
|
||||
fallback: 'Inactivating this workspace will:',
|
||||
})}</p>
|
||||
<ul>
|
||||
<li>{intl.get('workspaces.inactivate_workspace_effect_1', { fallback: 'Prevent all users from signing in' })}</li>
|
||||
<li>{intl.get('workspaces.inactivate_workspace_effect_2', { fallback: 'Preserve all data and settings' })}</li>
|
||||
<li>{intl.get('workspaces.inactivate_workspace_effect_3', { fallback: 'Allow reactivation at any time' })}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Button onClick={handleCancel} disabled={isLoading}>
|
||||
<T id={'cancel'} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
intent={intent}
|
||||
onClick={handleConfirm}
|
||||
loading={isLoading}
|
||||
icon={isInactivateAction ? 'pause' : 'play'}
|
||||
>
|
||||
{isInactivateAction
|
||||
? intl.get('workspaces.inactivate_workspace', { fallback: 'Inactivate' })
|
||||
: intl.get('workspaces.activate_workspace', { fallback: 'Activate' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default compose(
|
||||
withDialogRedux(),
|
||||
withDialogActions,
|
||||
)(WorkspaceInactivateDialog);
|
||||
+2
-2
@@ -6,7 +6,7 @@ import { FormGroup, InputGroup, Button, Intent } from '@blueprintjs/core';
|
||||
import { withDrawerActions } from '@/containers/Drawer/withDrawerActions';
|
||||
import { DRAWERS } from '@/constants/drawers';
|
||||
import { useWorkspaces } from '@/hooks/query';
|
||||
import { OrganizationsListTable } from './OrganizationsListTable';
|
||||
import OrganizationsListTable from './OrganizationsListTable';
|
||||
import intl from 'react-intl-universal';
|
||||
|
||||
import '@/style/containers/Workspaces/OrganizationsListDrawer.scss';
|
||||
@@ -17,7 +17,7 @@ import '@/style/containers/Workspaces/OrganizationsListDrawer.scss';
|
||||
function OrganizationsListDrawerContentRoot({ closeDrawer, openDrawer }) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const { data: workspaces, isLoading } = useWorkspaces();
|
||||
const { data: workspaces, isLoading } = useWorkspaces({ includeInactive: true });
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedSetSearch = useCallback(
|
||||
|
||||
+104
-33
@@ -14,8 +14,10 @@ import { DataTable, TableSkeletonRows } from '@/components';
|
||||
import { useWorkspaces, useSetDefaultWorkspace } from '@/hooks/query';
|
||||
import { useAuthOrganizationId } from '@/hooks/state';
|
||||
import { useSwitchOrganization } from '@/hooks/useSwitchOrganization';
|
||||
import { firstLettersArgs } from '@/utils';
|
||||
import { firstLettersArgs, compose } from '@/utils';
|
||||
import { WorkspaceSwitchingOverlay } from '@/components';
|
||||
import { withDialogActions } from '@/containers/Dialog/withDialogActions';
|
||||
import { DialogsName } from '@/constants/dialogs';
|
||||
import classNames from 'classnames';
|
||||
import intl from 'react-intl-universal';
|
||||
|
||||
@@ -64,7 +66,7 @@ function formatCurrency(amount: number): string {
|
||||
/**
|
||||
* Organizations list table component.
|
||||
*/
|
||||
export function OrganizationsListTable({ workspaces, isLoading, onClose }) {
|
||||
function OrganizationsListTable({ workspaces, isLoading, onClose, openDialog }) {
|
||||
const activeOrganizationId = useAuthOrganizationId();
|
||||
const switchOrganization = useSwitchOrganization();
|
||||
const setDefaultWorkspace = useSetDefaultWorkspace();
|
||||
@@ -91,6 +93,27 @@ export function OrganizationsListTable({ workspaces, isLoading, onClose }) {
|
||||
[setDefaultWorkspace],
|
||||
);
|
||||
|
||||
const handleDeleteWorkspace = useCallback(
|
||||
(workspace) => {
|
||||
openDialog(DialogsName.WorkspaceDelete, {
|
||||
organizationId: workspace.organizationId,
|
||||
workspaceName: workspace.metadata?.name || workspace.organizationId,
|
||||
});
|
||||
},
|
||||
[openDialog],
|
||||
);
|
||||
|
||||
const handleInactivateWorkspace = useCallback(
|
||||
(workspace) => {
|
||||
openDialog(DialogsName.WorkspaceInactivate, {
|
||||
organizationId: workspace.organizationId,
|
||||
workspaceName: workspace.metadata?.name || workspace.organizationId,
|
||||
isActive: workspace.isActive,
|
||||
});
|
||||
},
|
||||
[openDialog],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -151,28 +174,34 @@ export function OrganizationsListTable({ workspaces, isLoading, onClose }) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: intl.get('money_movement', { fallback: 'Money Movement' }),
|
||||
accessor: 'moneyMovement',
|
||||
width: 200,
|
||||
Header: intl.get('total_income', { fallback: 'Total Income' }),
|
||||
accessor: 'totalIncome',
|
||||
width: 150,
|
||||
Cell: ({ row }) => {
|
||||
const workspace = row.original;
|
||||
// Mock money movement data
|
||||
const mockIn = workspace.metadata?.name
|
||||
? workspace.organizationId.charCodeAt(0) * 5000 + Math.random() * 20000
|
||||
: 0;
|
||||
const mockOut = workspace.metadata?.name
|
||||
? workspace.organizationId.charCodeAt(0) * 3000 + Math.random() * 15000
|
||||
: 0;
|
||||
const totalIncome = workspace.totalIncome ?? 0;
|
||||
|
||||
return (
|
||||
<div className="organizations-list-table__movement">
|
||||
<span className="organizations-list-table__movement-in">
|
||||
<Icon icon="arrow-top-right" iconSize={12} />
|
||||
{formatCurrency(mockIn)}
|
||||
<div className="organizations-list-table__income">
|
||||
<span className="organizations-list-table__income-amount">
|
||||
{formatCurrency(totalIncome)}
|
||||
</span>
|
||||
<span className="organizations-list-table__movement-out">
|
||||
<Icon icon="arrow-bottom-right" iconSize={12} />
|
||||
{formatCurrency(mockOut)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: intl.get('total_expenses', { fallback: 'Total Expenses' }),
|
||||
accessor: 'totalExpenses',
|
||||
width: 150,
|
||||
Cell: ({ row }) => {
|
||||
const workspace = row.original;
|
||||
const totalExpenses = workspace.totalExpenses ?? 0;
|
||||
|
||||
return (
|
||||
<div className="organizations-list-table__expenses">
|
||||
<span className="organizations-list-table__expenses-amount">
|
||||
{formatCurrency(totalExpenses)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -184,7 +213,7 @@ export function OrganizationsListTable({ workspaces, isLoading, onClose }) {
|
||||
width: 80,
|
||||
Cell: ({ row }) => {
|
||||
const workspace = row.original;
|
||||
const isDisabled = !workspace.isReady || workspace.isBuildRunning;
|
||||
const isDisabled = !workspace.isReady || workspace.isBuildRunning || !workspace.isActive;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -205,28 +234,68 @@ export function OrganizationsListTable({ workspaces, isLoading, onClose }) {
|
||||
{
|
||||
Header: '',
|
||||
accessor: 'actions',
|
||||
width: 60,
|
||||
width: 140,
|
||||
disableSortBy: true,
|
||||
Cell: ({ row }) => {
|
||||
const workspace = row.original;
|
||||
const isActive = workspace.organizationId === activeOrganizationId;
|
||||
const isDisabled = !workspace.isReady || workspace.isBuildRunning;
|
||||
const isActiveOrg = workspace.organizationId === activeOrganizationId;
|
||||
const isDisabled = !workspace.isReady || workspace.isBuildRunning || workspace.isDeleting;
|
||||
const isOwner = workspace.role === 'owner';
|
||||
const canSwitch = !isDisabled && workspace.isActive;
|
||||
|
||||
return (
|
||||
<Button
|
||||
minimal
|
||||
disabled={isDisabled}
|
||||
onClick={() =>
|
||||
handleSwitchWorkspace(workspace.organizationId, workspace.metadata?.name)
|
||||
}
|
||||
className="organizations-list-table__switch-btn"
|
||||
icon={<Icon icon="arrow-right" iconSize={20} />}
|
||||
/>
|
||||
<div className="organizations-list-table__actions">
|
||||
{isOwner && (
|
||||
<>
|
||||
<Tooltip
|
||||
content={
|
||||
workspace.isActive
|
||||
? intl.get('workspaces.inactivate_workspace', { fallback: 'Inactivate Workspace' })
|
||||
: intl.get('workspaces.activate_workspace', { fallback: 'Activate Workspace' })
|
||||
}
|
||||
position={Position.TOP}
|
||||
>
|
||||
<Button
|
||||
minimal
|
||||
disabled={isDisabled}
|
||||
onClick={() => handleInactivateWorkspace(workspace)}
|
||||
className="organizations-list-table__inactivate-btn"
|
||||
icon={<Icon
|
||||
icon={workspace.isActive ? 'pause' : 'play'}
|
||||
iconSize={16}
|
||||
intent={workspace.isActive ? Intent.WARNING : Intent.SUCCESS}
|
||||
/>}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={intl.get('workspaces.delete_workspace', { fallback: 'Delete Workspace' })}
|
||||
position={Position.TOP}
|
||||
>
|
||||
<Button
|
||||
minimal
|
||||
disabled={isDisabled}
|
||||
onClick={() => handleDeleteWorkspace(workspace)}
|
||||
className="organizations-list-table__delete-btn"
|
||||
icon={<Icon icon="trash" iconSize={16} intent={Intent.DANGER} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
minimal
|
||||
disabled={!canSwitch}
|
||||
onClick={() =>
|
||||
handleSwitchWorkspace(workspace.organizationId, workspace.metadata?.name)
|
||||
}
|
||||
className="organizations-list-table__switch-btn"
|
||||
icon={<Icon icon="arrow-right" iconSize={20} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[activeOrganizationId, handleSwitchWorkspace, handleSetDefault],
|
||||
[activeOrganizationId, handleSwitchWorkspace, handleSetDefault, handleDeleteWorkspace, handleInactivateWorkspace],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -250,3 +319,5 @@ export function OrganizationsListTable({ workspaces, isLoading, onClose }) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default compose(withDialogActions)(OrganizationsListTable);
|
||||
|
||||
@@ -2,15 +2,20 @@
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { useRequestQuery } from '../useQueryRequest';
|
||||
import useApiRequest from '../useRequest';
|
||||
import { useAuthOrganizationId } from '../state';
|
||||
import { transformToCamelCase } from '@/utils';
|
||||
|
||||
/**
|
||||
* Retrieve workspaces of the authenticated user.
|
||||
* @param options.includeInactive - Whether to include inactive workspaces (default: false)
|
||||
*/
|
||||
export function useWorkspaces(props) {
|
||||
export function useWorkspaces(options = {}) {
|
||||
const { includeInactive = false, ...props } = options;
|
||||
const currentOrganizationId = useAuthOrganizationId();
|
||||
|
||||
return useRequestQuery(
|
||||
['workspaces'],
|
||||
{ method: 'get', url: 'workspaces' },
|
||||
['workspaces', { includeInactive }],
|
||||
{ method: 'get', url: 'workspaces', params: { includeInactive, currentOrganizationId } },
|
||||
{
|
||||
select: (res) => transformToCamelCase(res.data),
|
||||
initialDataUpdatedAt: 0,
|
||||
@@ -59,3 +64,70 @@ export function useSetDefaultWorkspace() {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a workspace (owner only).
|
||||
*/
|
||||
export function useDeleteWorkspace(props?: any) {
|
||||
const apiRequest = useApiRequest();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
async (organizationId: string) => {
|
||||
const response = await apiRequest.delete(`workspaces/${organizationId}`);
|
||||
return transformToCamelCase(response.data);
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['workspaces']);
|
||||
},
|
||||
...props,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inactivates a workspace (owner only).
|
||||
*/
|
||||
export function useInactivateWorkspace(props?: any) {
|
||||
const apiRequest = useApiRequest();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
async (organizationId: string) => {
|
||||
const response = await apiRequest.put(
|
||||
`workspaces/${organizationId}/inactivate`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['workspaces']);
|
||||
},
|
||||
...props,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates (reactivates) a workspace (owner only).
|
||||
*/
|
||||
export function useActivateWorkspace(props?: any) {
|
||||
const apiRequest = useApiRequest();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
async (organizationId: string) => {
|
||||
const response = await apiRequest.put(
|
||||
`workspaces/${organizationId}/activate`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['workspaces']);
|
||||
},
|
||||
...props,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,33 @@
|
||||
"workspaces.current_organization": "Current",
|
||||
"workspaces.set_as_default": "Set as default",
|
||||
"workspaces.default_workspace": "Default",
|
||||
"workspaces.view_all_workspaces": "View All Workspaces",
|
||||
"workspaces.create_workspace": "Create Workspace",
|
||||
"workspaces.delete_workspace": "Delete Workspace",
|
||||
"workspaces.delete_workspace_confirmation": "Are you sure you want to delete <b>{name}</b>? This action cannot be undone and all data will be permanently lost.",
|
||||
"workspaces.workspace_deleted_successfully": "Workspace has been deleted successfully",
|
||||
"workspaces.cannot_delete_workspace": "Cannot delete workspace",
|
||||
"workspaces.not_workspace_owner": "You must be the workspace owner to delete it",
|
||||
"workspaces.deleting": "Deleting...",
|
||||
"workspaces.delete_workspace_details": "Deleting this workspace will permanently remove:",
|
||||
"workspaces.delete_workspace_all_data": "All organization data including transactions, accounts, and contacts",
|
||||
"workspaces.delete_workspace_all_users": "All user associations and permissions",
|
||||
"workspaces.delete_workspace_database": "The entire database for this workspace",
|
||||
"workspaces.delete_workspace_irreversible": "This action is irreversible. Please make sure you have exported any important data before proceeding.",
|
||||
"workspaces.inactivate_workspace": "Inactivate Workspace",
|
||||
"workspaces.activate_workspace": "Activate Workspace",
|
||||
"workspaces.inactivate_workspace_confirmation": "Are you sure you want to inactivate <b>{name}</b>? No one will be able to sign in until it's reactivated.",
|
||||
"workspaces.activate_workspace_confirmation": "Reactivate <b>{name}</b>? Users will be able to sign in again.",
|
||||
"workspaces.inactivate_workspace_details": "Inactivating this workspace will:",
|
||||
"workspaces.inactivate_workspace_effect_1": "Prevent all users from signing in",
|
||||
"workspaces.inactivate_workspace_effect_2": "Preserve all data and settings",
|
||||
"workspaces.inactivate_workspace_effect_3": "Allow reactivation at any time",
|
||||
"workspaces.workspace_inactivated_successfully": "Workspace has been inactivated",
|
||||
"workspaces.workspace_activated_successfully": "Workspace has been reactivated",
|
||||
"workspaces.cannot_inactivate_workspace": "Cannot inactivate workspace",
|
||||
"workspaces.cannot_activate_workspace": "Cannot activate workspace",
|
||||
"total_income": "Total Income",
|
||||
"total_expenses": "Total Expenses",
|
||||
"building": "Building",
|
||||
"ready": "Ready",
|
||||
"pending": "Pending",
|
||||
|
||||
@@ -340,10 +340,19 @@
|
||||
|
||||
.menu--dashboard-organization {
|
||||
padding: 10px;
|
||||
min-width: 280px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
|
||||
.org-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
|
||||
&--current {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
height: 40px;
|
||||
@@ -360,6 +369,7 @@
|
||||
&__name {
|
||||
margin-left: 12px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&__divider {
|
||||
@@ -368,4 +378,83 @@
|
||||
background: #ebebeb;
|
||||
}
|
||||
}
|
||||
|
||||
.org-workspaces-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.org-workspace-item {
|
||||
padding: 8px 10px;
|
||||
|
||||
&.is-active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
background-color: #4A90E2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__check {
|
||||
color: #48aff0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.is-active .org-workspace-item__name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&:hover:not(.is-active):not(.bp4-disabled) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
&.bp4-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.bp4-menu-divider {
|
||||
margin: 8px 0;
|
||||
border-top-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.bp4-menu-item {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 4px;
|
||||
line-height: 20px;
|
||||
|
||||
&:hover:not(.bp4-disabled) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bp4-icon {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,33 +173,16 @@
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&__movement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
&__income {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $green4;
|
||||
}
|
||||
|
||||
&-in {
|
||||
color: $green4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.bp4-icon {
|
||||
color: $green4;
|
||||
}
|
||||
}
|
||||
|
||||
&-out {
|
||||
color: $red4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.bp4-icon {
|
||||
color: $red4;
|
||||
}
|
||||
}
|
||||
&__expenses {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $red4;
|
||||
}
|
||||
|
||||
&__default-checkbox {
|
||||
@@ -222,4 +205,99 @@
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__delete-btn {
|
||||
color: $red4 !important;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: $red3 !important;
|
||||
background: rgba($red4, 0.1) !important;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
&__inactivate-btn {
|
||||
color: $orange4 !important;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: $orange3 !important;
|
||||
background: rgba($orange4, 0.1) !important;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace Delete Dialog Styles
|
||||
.workspace-delete-dialog {
|
||||
.bp4-dialog-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
&__details {
|
||||
margin-top: 16px;
|
||||
|
||||
p {
|
||||
margin: 0 0 8px 0;
|
||||
color: $gray1;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
color: $gray1;
|
||||
|
||||
li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__warning {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: rgba($red4, 0.1);
|
||||
border-left: 3px solid $red4;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
color: $red3 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace Inactivate Dialog Styles
|
||||
.workspace-inactivate-dialog {
|
||||
.bp4-dialog-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
&__details {
|
||||
margin-top: 16px;
|
||||
|
||||
p {
|
||||
margin: 0 0 8px 0;
|
||||
color: $gray1;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
color: $gray1;
|
||||
|
||||
li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user