1
0
This commit is contained in:
Ahmed Bouhuolia
2026-04-07 20:43:46 +02:00
parent ceef73ba0a
commit a306c62710
35 changed files with 1663 additions and 155 deletions
@@ -45,6 +45,15 @@ export const events = {
baseCurrencyUpdated: 'onOrganizationBaseCurrencyUpdated',
},
/**
* Workspace service.
*/
workspace: {
created: 'onWorkspaceCreated',
deleting: 'onWorkspaceDeleting',
deleted: 'onWorkspaceDeleted',
},
/**
* Organization subscription.
*/
@@ -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');
});
};
@@ -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;
}
@@ -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>
);
}
+3 -1
View File
@@ -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);
@@ -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(
@@ -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);
+75 -3
View File
@@ -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,
}
);
}
+27
View File
@@ -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;
}
}
}
}