Merge branch 'freight/develop' of github.com:Tria-plc/edr-platform into freight_feature/priority

This commit is contained in:
marshal
2026-06-19 15:58:36 +03:00
44 changed files with 1975 additions and 1417 deletions

View File

@@ -24,14 +24,13 @@ import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module";
import { CustomersModule } from "./modules/customers/customers.module";
import { CompaniesModule } from "./modules/companies/companies.module";
import { TrackingModule } from "./modules/tracking/tracking.module";
import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { OtpModule } from './modules/otp/otp.module';
import { OtpModule } from "./modules/otp/otp.module";
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
@@ -97,7 +96,6 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module';
TrainSchedulesModule,
TrainSchedulingModule,
SchedulingRescheduleModule,
CustomersModule,
CompaniesModule,
TrackingModule,
BillingModule,

View File

@@ -114,7 +114,9 @@ export class ContractViewModelBuilder {
tinNumber: this.valueOrDash(booking.company?.tin),
vatNumber: this.valueOrDash(booking.company?.vatNumber),
fanNumber: this.valueOrDash(booking.company?.fanNumber),
businessLicense: this.valueOrDash(booking.company?.businessLicense),
businessLicense: this.valueOrDash(
booking.company?.companyProfiles?.[0]?.businessLicense,
),
},
provider: {
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',

View File

@@ -0,0 +1,103 @@
import {
MigrationInterface,
QueryRunner,
Table,
TableIndex,
TableForeignKey,
} from "typeorm";
export class CreateCompanyProfiles1752000000000 implements MigrationInterface {
name = "CreateCompanyProfiles1752000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: "freight",
name: "company_profiles",
columns: [
{
name: "id",
type: "uuid",
isPrimary: true,
generationStrategy: "uuid",
default: "gen_random_uuid()",
},
{ name: "company_id", type: "uuid" },
{ name: "type", type: "varchar", length: "32" },
{ name: "reference", type: "varchar", length: "20", isUnique: true },
{
name: "status",
type: "varchar",
length: "32",
default: "'active'",
},
{
name: "business_license",
type: "varchar",
length: "100",
isNullable: true,
},
{ name: "attributes", type: "jsonb", isNullable: true },
{ name: "created_at", type: "timestamptz", default: "now()" },
{ name: "updated_at", type: "timestamptz", default: "now()" },
{ name: "deleted_at", type: "timestamptz", isNullable: true },
],
}),
true,
);
await queryRunner.createForeignKey(
"freight.company_profiles",
new TableForeignKey({
columnNames: ["company_id"],
referencedTableName: "companies",
referencedSchema: "freight",
referencedColumnNames: ["id"],
}),
);
await queryRunner.createIndex(
"freight.company_profiles",
new TableIndex({ columnNames: ["company_id"] }),
);
await queryRunner.createIndex(
"freight.company_profiles",
new TableIndex({ columnNames: ["type"] }),
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ex START WITH 1`,
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_im START WITH 1`,
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ffe START WITH 1`,
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_fwj START WITH 1`,
);
await queryRunner.query(
`CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_tr START WITH 1`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable("freight.company_profiles");
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ex`,
);
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_im`,
);
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_ffe`,
);
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_fwj`,
);
await queryRunner.query(
`DROP SEQUENCE IF EXISTS freight.seq_company_profile_tr`,
);
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MoveBusinessLicenseToProfile1752000000001
implements MigrationInterface
{
name = 'MoveBusinessLicenseToProfile1752000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.company_profiles cp
SET business_license = c.business_license
FROM freight.companies c
WHERE cp.company_id = c.id AND c.business_license IS NOT NULL
`);
await queryRunner.query(
`ALTER TABLE freight.companies DROP COLUMN IF EXISTS business_license`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.companies ADD COLUMN business_license varchar(100) NULL`,
);
await queryRunner.query(`
UPDATE freight.companies c
SET business_license = cp.business_license
FROM (
SELECT DISTINCT ON (cp2.company_id)
cp2.company_id, cp2.business_license
FROM freight.company_profiles cp2
WHERE cp2.business_license IS NOT NULL
ORDER BY cp2.company_id, cp2.created_at
) cp
WHERE cp.company_id = c.id
`);
}
}

View File

@@ -1,22 +1,38 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { FreightAdmin } from '../../common/booking-guards';
import { FilesService } from '../files/files.service';
import { CompaniesService } from './companies.service';
import { CreateCompanyDto } from './dto/create-company.dto';
import { UpdateCompanyDto } from './dto/update-company.dto';
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
import { CreateFFClientDto } from './dto/create-ff-client.dto';
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
import { ResponseCompanyDto } from './dto/response-company.dto';
import { ResponseExternalProfileDto } from './dto/response-external-profile.dto';
import { ResponseFFClientDto } from './dto/response-ff-client.dto';
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ProfileResponseDto } from './dto/profile-response.dto';
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
ParseUUIDPipe,
HttpCode,
HttpStatus,
UseInterceptors,
UploadedFiles,
} from "@nestjs/common";
import { AnyFilesInterceptor } from "@nestjs/platform-express";
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
import { CurrentUser } from "@edr/api-common";
import { FreightAdmin } from "../../common/booking-guards";
import { FilesService } from "../files/files.service";
import { CompaniesService } from "./companies.service";
import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
import {
ResponseCompanyDto,
ResponseCompanyProfileDto,
} from "./dto/response-company.dto";
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
interface CurrentIamUser {
id: string;
@@ -25,36 +41,47 @@ interface CurrentIamUser {
phoneNumber?: string;
}
@ApiTags('Companies')
@Controller('companies')
@ApiTags("Companies")
@Controller("companies")
export class CompaniesController {
constructor(
private readonly companiesService: CompaniesService,
private readonly filesService: FilesService,
) {}
) { }
@Get('getInfo')
@ApiOperation({ summary: 'Get company info for the current user' })
async getInfo(@CurrentUser() user: CurrentIamUser): Promise<CompanyInfoResponseDto> {
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
@Get("getInfo")
@ApiOperation({ summary: "Get company info for the current user" })
async getInfo(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyInfoResponseDto> {
const { profile, company } =
await this.companiesService.getCompanyInfoByUserId(user.id);
return new CompanyInfoResponseDto(profile, company);
}
@Get('profile')
@ApiOperation({ summary: 'Get flattened profile for the settings page' })
async getProfile(@CurrentUser() user: CurrentIamUser): Promise<ProfileResponseDto> {
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
@Get("profile")
@ApiOperation({ summary: "Get flattened profile for the settings page" })
async getProfile(
@CurrentUser() user: CurrentIamUser,
): Promise<ProfileResponseDto> {
const { profile, company } =
await this.companiesService.getCompanyInfoByUserId(user.id);
return new ProfileResponseDto(profile, company);
}
@Get('dashboard')
@ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' })
async getDashboard(@CurrentUser() user: CurrentIamUser): Promise<DashboardSummaryResponseDto> {
@Get("dashboard")
@ApiOperation({
summary:
"Get portal dashboard KPIs (delivered, spend, freight volume) for the current user",
})
async getDashboard(
@CurrentUser() user: CurrentIamUser,
): Promise<DashboardSummaryResponseDto> {
return this.companiesService.getDashboardSummary(user.id);
}
@Patch('profile')
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
@Patch("profile")
@ApiOperation({ summary: "Update profile (flattened settings page)" })
async updateProfile(
@CurrentUser() user: CurrentIamUser,
@Body() dto: UpdateProfileDto,
@@ -62,145 +89,153 @@ export class CompaniesController {
return this.companiesService.updateProfile(user.id, dto);
}
@Post('create')
@ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
@Post("company-profiles")
@ApiOperation({
summary:
"Add operational profile(s) (importer/exporter/forwarder) to the current user's company",
})
async addCompanyProfiles(
@CurrentUser() user: CurrentIamUser,
@Body() dto: AddCompanyProfilesDto,
): Promise<ResponseCompanyProfileDto[]> {
const profiles = await this.companiesService.addCompanyProfilesForUser(
user.id,
dto.types,
);
return profiles.map((p) => new ResponseCompanyProfileDto(p));
}
// Used by portal
@Post("create")
@ApiOperation({
summary:
"Create a company with its associated external profile (onboarding)",
})
async createWithProfile(
@CurrentUser() user: CurrentIamUser,
@Body() dto: CreateCompanyWithProfileDto,
): Promise<CompanyInfoResponseDto> {
const nameParts = (user.name?.en ?? '').split(' ');
const { profile, company } = await this.companiesService.createCompanyWithProfile(
{
userId: user.id,
firstName: nameParts[0] || '',
lastName: nameParts.slice(-1)[0] || '',
email: user.email ?? '',
phone: user.phoneNumber ?? '',
},
dto,
);
const nameParts = (user.name?.en ?? "").split(" ");
const { profile, company } =
await this.companiesService.createCompanyWithProfile(
{
userId: user.id,
firstName: nameParts[0] || "",
lastName: nameParts.slice(-1)[0] || "",
email: user.email ?? "",
phone: user.phoneNumber ?? "",
},
dto,
);
return new CompanyInfoResponseDto(profile, company);
}
// Used by backoffice
@Post()
@FreightAdmin()
@ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
@ApiOperation({
summary:
"Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)",
})
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
const company = await this.companiesService.createCompany(dto);
return new ResponseCompanyDto(company);
}
@Get()
@ApiOperation({ summary: 'List all companies' })
@ApiOperation({ summary: "List all companies" })
async findAll(): Promise<ResponseCompanyDto[]> {
const companies = await this.companiesService.findAllCompanies();
return companies.map((c) => new ResponseCompanyDto(c));
}
@Get('type/:type')
@ApiOperation({ summary: 'Find companies by type' })
async findByType(@Param('type') type: string): Promise<ResponseCompanyDto[]> {
@Get("type/:type")
@ApiOperation({ summary: "Find companies by type" })
async findByType(@Param("type") type: string): Promise<ResponseCompanyDto[]> {
const companies = await this.companiesService.findAllCompanies();
return companies.filter((c) => c.type === type).map((c) => new ResponseCompanyDto(c));
return companies
.filter((c) => c.type === type)
.map((c) => new ResponseCompanyDto(c));
}
@Get('search')
@ApiOperation({ summary: 'Search companies by name' })
async search(@Query('name') name: string): Promise<ResponseCompanyDto[]> {
@Get("search")
@ApiOperation({ summary: "Search companies by name" })
async search(@Query("name") name: string): Promise<ResponseCompanyDto[]> {
const companies = await this.companiesService.findAllCompanies();
return companies
.filter((c) => c.name.toLowerCase().includes(name.toLowerCase()))
.map((c) => new ResponseCompanyDto(c));
}
@Get(':id')
@ApiOperation({ summary: 'Get company by ID' })
async findById(@Param('id', ParseUUIDPipe) id: string): Promise<ResponseCompanyDto> {
@Get(":id")
@ApiOperation({ summary: "Get company by ID" })
async findById(
@Param("id", ParseUUIDPipe) id: string,
): Promise<ResponseCompanyDto> {
const company = await this.companiesService.findCompanyById(id);
return new ResponseCompanyDto(company);
}
@Patch(':id')
@Patch(":id")
@FreightAdmin()
@ApiOperation({ summary: 'Update a company' })
@ApiOperation({ summary: "Update a company" })
async update(
@Param('id', ParseUUIDPipe) id: string,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateCompanyDto,
): Promise<ResponseCompanyDto> {
const company = await this.companiesService.updateCompany(id, dto);
return new ResponseCompanyDto(company);
}
@Delete(':id')
@Delete(":id")
@FreightAdmin()
@ApiOperation({ summary: 'Soft-delete a company' })
@ApiOperation({ summary: "Soft-delete a company" })
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
async remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
await this.companiesService.deleteCompany(id);
}
@Post(':companyId/documents')
@Post(":companyId/documents")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload documents for a company (onboarding)' })
@ApiConsumes("multipart/form-data")
@ApiOperation({ summary: "Upload documents for a company (onboarding)" })
async uploadDocuments(
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param("companyId", ParseUUIDPipe) companyId: string,
@UploadedFiles() files: Array<Express.Multer.File>,
) {
return this.filesService.uploadMany(companyId, 'companies', files);
return this.filesService.uploadMany(companyId, "companies", files);
}
@Post(':companyId/profiles')
@Post(":companyId/profiles")
@FreightAdmin()
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
@ApiOperation({ summary: "Add a profile (employee) to a company" })
async createProfile(
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param("companyId", ParseUUIDPipe) companyId: string,
@Body() dto: CreateExternalProfileDto,
): Promise<ResponseExternalProfileDto> {
const profile = await this.companiesService.createProfile({ ...dto, companyId });
const profile = await this.companiesService.createProfile({
...dto,
companyId,
});
return new ResponseExternalProfileDto(profile);
}
@Get(':companyId/profiles')
@ApiOperation({ summary: 'List profiles for a company' })
@Get(":companyId/profiles")
@ApiOperation({ summary: "List profiles for a company" })
async listProfiles(
@Param('companyId', ParseUUIDPipe) companyId: string,
@Param("companyId", ParseUUIDPipe) companyId: string,
): Promise<ResponseExternalProfileDto[]> {
const profiles = await this.companiesService.findProfilesByCompany(companyId);
const profiles =
await this.companiesService.findProfilesByCompany(companyId);
return profiles.map((p) => new ResponseExternalProfileDto(p));
}
@Get('profile/user/:userId')
@ApiOperation({ summary: 'Get profile by IAM user ID' })
@Get("profile/user/:userId")
@ApiOperation({ summary: "Get profile by IAM user ID" })
async findProfileByUser(
@Param('userId', ParseUUIDPipe) userId: string,
@Param("userId", ParseUUIDPipe) userId: string,
): Promise<ResponseExternalProfileDto> {
const profile = await this.companiesService.findProfileByUserId(userId);
return new ResponseExternalProfileDto(profile);
}
@Post('ff-clients')
@FreightAdmin()
@ApiOperation({ summary: 'Link a forwarder to a client company' })
async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> {
const client = await this.companiesService.createFFClient(dto);
return new ResponseFFClientDto(client);
}
@Get(':forwarderCompanyId/clients')
@ApiOperation({ summary: 'List clients of a forwarder' })
async listFFClients(
@Param('forwarderCompanyId', ParseUUIDPipe) forwarderCompanyId: string,
): Promise<ResponseFFClientDto[]> {
const clients = await this.companiesService.findForwarderClients(forwarderCompanyId);
return clients.map((c) => new ResponseFFClientDto(c));
}
@Delete('ff-clients/:id')
@FreightAdmin()
@ApiOperation({ summary: 'Remove a forwarder-client relationship' })
@HttpCode(HttpStatus.NO_CONTENT)
async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
await this.companiesService.deleteFFClient(id);
}
}

View File

@@ -1,21 +1,30 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FilesModule } from '../files/files.module';
import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service';
import { CompaniesRepository } from './companies.repository';
import { ExternalProfileRepository } from './external-profile.repository';
import { FFClientRepository } from './ff-client.repository';
import { CompanyDashboardRepository } from './company-dashboard.repository';
import { Company } from './entities/company.entity';
import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { FilesModule } from "../files/files.module";
import { CompaniesController } from "./companies.controller";
import { CompaniesService } from "./companies.service";
import { CompaniesRepository } from "./companies.repository";
import { ExternalProfileRepository } from "./external-profile.repository";
import { CompanyDashboardRepository } from "./company-dashboard.repository";
import { Company } from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import { CompanyProfile } from "./entities/company-profile.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { CompanyProfileRepository } from "./company-profile.repository";
@Module({
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule],
imports: [
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
FilesModule,
],
controllers: [CompaniesController],
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository],
providers: [
CompaniesService,
CompaniesRepository,
ExternalProfileRepository,
CompanyProfileRepository,
CompanyDashboardRepository,
],
exports: [CompaniesService],
})
export class CompaniesModule {}
export class CompaniesModule { }

View File

@@ -1,19 +1,27 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { CompaniesRepository } from './companies.repository';
import { ExternalProfileRepository } from './external-profile.repository';
import { FFClientRepository } from './ff-client.repository';
import { CompanyDashboardRepository } from './company-dashboard.repository';
import { CreateCompanyDto } from './dto/create-company.dto';
import { UpdateCompanyDto } from './dto/update-company.dto';
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
import { CreateFFClientDto } from './dto/create-ff-client.dto';
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ProfileResponseDto } from './dto/profile-response.dto';
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
import { Company } from './entities/company.entity';
import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from "@nestjs/common";
import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
import { ExternalProfileRepository } from "./external-profile.repository";
import { CompanyDashboardRepository } from "./company-dashboard.repository";
import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
import { Company } from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import {
CompanyProfile,
ProfileType,
ProfileStatus,
} from "./entities/company-profile.entity";
export interface UserIdentity {
userId: string;
@@ -27,10 +35,10 @@ export interface UserIdentity {
export class CompaniesService {
constructor(
private readonly companiesRepo: CompaniesRepository,
private readonly companyProfilesRepo: CompanyProfileRepository,
private readonly profilesRepo: ExternalProfileRepository,
private readonly ffClientsRepo: FFClientRepository,
private readonly dashboardRepo: CompanyDashboardRepository,
) {}
) { }
async createCompany(dto: CreateCompanyDto): Promise<Company> {
const exists = await this.companiesRepo.existsByTin(dto.tin);
@@ -40,27 +48,33 @@ export class CompaniesService {
return this.companiesRepo.create(dto);
}
async createCompanyWithProfile(identity: UserIdentity, dto: CreateCompanyWithProfileDto): Promise<{ company: Company; profile: ExternalProfile }> {
async createCompanyWithProfile(
identity: UserIdentity,
dto: CreateCompanyWithProfileDto,
): Promise<{ company: Company; profile: ExternalProfile }> {
if (dto.tin) {
const exists = await this.companiesRepo.existsByTin(dto.tin);
if (exists) {
throw new ConflictException(`Company with TIN ${dto.tin} already exists`);
throw new ConflictException(
`Company with TIN ${dto.tin} already exists`,
);
}
}
const existingProfile = await this.profilesRepo.findByEmail(identity.email);
if (existingProfile) {
throw new ConflictException(`Profile with email ${identity.email} already exists`);
throw new ConflictException(
`Profile with email ${identity.email} already exists`,
);
}
const company = await this.companiesRepo.create({
name: dto.companyName,
type: dto.companyType,
tin: dto.tin ?? '',
tin: dto.tin ?? "",
vatNumber: dto.vatNumber ?? null,
businessLicense: dto.fanNumber ?? null,
fanNumber: dto.fanNumber ?? null,
country: dto.companyLocation ?? 'Ethiopia',
country: dto.companyLocation ?? "Ethiopia",
address: dto.companyAddress ?? null,
phone: dto.companyPhone ?? null,
email: dto.companyEmail ?? null,
@@ -78,11 +92,39 @@ export class CompaniesService {
isPrimaryContact: dto.isPrimaryContact ?? true,
});
// Persist the operational role(s) chosen during onboarding. Types are
// already constrained to the company type on the client; any that don't
// match are skipped defensively rather than failing the whole signup.
if (dto.companyProfiles?.length) {
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
for (const input of dto.companyProfiles) {
if (!allowedTypes.includes(input.type)) continue;
const existing = await this.companyProfilesRepo.findByType(
company.id,
input.type,
);
if (existing) continue;
const reference = await this.companyProfilesRepo.generateReference(
input.type,
);
await this.companyProfilesRepo.create({
companyId: company.id,
type: input.type,
reference,
businessLicense: input.businessLicense ?? null,
status: ProfileStatus.Active,
});
}
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
company.id,
);
}
return { company, profile };
}
async findAllCompanies(): Promise<Company[]> {
return this.companiesRepo.findAll({ order: { name: 'ASC' as any } });
return this.companiesRepo.findAll({ order: { name: "ASC" } });
}
async findCompanyById(id: string): Promise<Company> {
@@ -91,12 +133,21 @@ export class CompaniesService {
return company;
}
async getCompanyInfoByUserId(userId: string): Promise<{ profile: ExternalProfile; company: Company }> {
async getCompanyInfoByUserId(
userId: string,
): Promise<{ profile: ExternalProfile; company: Company }> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const company = profile.company;
if (!company) throw new NotFoundException(`Company for profile ${profile.id} not found`);
if (!company)
throw new NotFoundException(
`Company for profile ${profile.id} not found`,
);
company.companyProfiles =
await this.companyProfilesRepo.findByCompanyId(company.id);
return { profile, company };
}
@@ -114,7 +165,9 @@ export class CompaniesService {
* column, so "delivered YTD" counts bookings created this year that reached a
* delivered/completed status.
*/
async getDashboardSummary(userId: string): Promise<DashboardSummaryResponseDto> {
async getDashboardSummary(
userId: string,
): Promise<DashboardSummaryResponseDto> {
// A user without a company profile has no bookings — return an empty summary
// rather than 404, so the portal home still renders.
const profile = await this.profilesRepo.findByUserId(userId);
@@ -125,7 +178,9 @@ export class CompaniesService {
const yearStart = new Date(now.getFullYear(), 0, 1);
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
// Same point in the previous year, so YoY compares like-for-like windows.
const prevYearToDate = new Date(prevYearStart.getTime() + (now.getTime() - yearStart.getTime()));
const prevYearToDate = new Date(
prevYearStart.getTime() + (now.getTime() - yearStart.getTime()),
);
const [
deliveredThis,
@@ -139,20 +194,36 @@ export class CompaniesService {
this.dashboardRepo.countDelivered(companyId, yearStart, now),
this.dashboardRepo.countCommitted(companyId, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(companyId, prevYearStart, prevYearToDate),
this.dashboardRepo.sumPaidSpendByCurrency(
companyId,
prevYearStart,
prevYearToDate,
),
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate),
this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now),
this.dashboardRepo.sumCommittedTonnage(
companyId,
prevYearStart,
prevYearToDate,
),
this.dashboardRepo.monthlyCommittedTonnage(
companyId,
this.monthsAgo(now, 5),
now,
),
]);
// Spend can span currencies; report the dominant one (prefer ETB on ties).
const spend = this.pickCurrencyTotal(spendThisByCcy);
const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
const spendPrev =
spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0;
return {
deliveredCount: deliveredThis,
// Share of committed bookings that reached delivered/completed.
completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0,
completionRate:
committedThis > 0
? Math.round((deliveredThis / committedThis) * 100)
: 0,
spendYtd: spend.total,
spendCurrency: spend.currency,
spendYtdChangePct: this.changePct(spend.total, spendPrev),
@@ -172,12 +243,12 @@ export class CompaniesService {
deliveredCount: 0,
completionRate: 0,
spendYtd: 0,
spendCurrency: 'ETB',
spendCurrency: "ETB",
spendYtdChangePct: 0,
freightVolume: {
totalTonnes: 0,
totalValue: 0,
currency: 'ETB',
currency: "ETB",
ytdChangePct: 0,
monthly: this.buildMonthlySeries(now, []),
},
@@ -190,8 +261,11 @@ export class CompaniesService {
}
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } {
if (totals.length === 0) return { currency: 'ETB', total: 0 };
private pickCurrencyTotal(totals: { currency: string; total: number }[]): {
currency: string;
total: number;
} {
if (totals.length === 0) return { currency: "ETB", total: 0 };
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
}
@@ -206,13 +280,29 @@ export class CompaniesService {
now: Date,
rows: { year: number; month: number; tonnes: number }[],
): { month: string; tonnes: number }[] {
const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const labels = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
const series: { month: string; tonnes: number }[] = [];
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0) });
series.push({
month: labels[d.getMonth()],
tonnes: Math.round(byKey.get(key) ?? 0),
});
}
return series;
}
@@ -224,7 +314,10 @@ export class CompaniesService {
return updated;
}
async updateProfile(userId: string, dto: UpdateProfileDto): Promise<ProfileResponseDto> {
async updateProfile(
userId: string,
dto: UpdateProfileDto,
): Promise<ProfileResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
const companyUpdates: Record<string, any> = {};
@@ -233,30 +326,38 @@ export class CompaniesService {
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress;
if (dto.companyLocation !== undefined)
companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined)
companyUpdates.address = dto.companyAddress;
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
if (dto.fanNumber !== undefined) {
companyUpdates.businessLicense = dto.fanNumber;
companyUpdates.fanNumber = dto.fanNumber;
}
if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName;
if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone;
if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail;
if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone;
if (dto.contactPersonName !== undefined)
attrUpdates.contactPersonName = dto.contactPersonName;
if (dto.contactPersonPhone !== undefined)
attrUpdates.contactPersonPhone = dto.contactPersonPhone;
if (dto.generalManagerName !== undefined)
attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined)
attrUpdates.generalManagerEmail = dto.generalManagerEmail;
if (dto.generalManagerPhone !== undefined)
attrUpdates.generalManagerPhone = dto.generalManagerPhone;
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone;
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaLocation !== undefined)
attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
companyUpdates.attributes = attrUpdates;
const updated = await this.companiesRepo.update(company.id, companyUpdates);
if (!updated) throw new NotFoundException(`Company ${company.id} not found`);
if (!updated)
throw new NotFoundException(`Company ${company.id} not found`);
return new ProfileResponseDto(profile, updated);
}
@@ -270,7 +371,9 @@ export class CompaniesService {
const existing = await this.profilesRepo.findByEmail(dto.email);
if (existing) {
throw new ConflictException(`Profile with email ${dto.email} already exists`);
throw new ConflictException(
`Profile with email ${dto.email} already exists`,
);
}
return this.profilesRepo.create(dto);
@@ -278,7 +381,8 @@ export class CompaniesService {
async findProfileByUserId(userId: string): Promise<ExternalProfile> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
return profile;
}
@@ -286,28 +390,119 @@ export class CompaniesService {
return this.profilesRepo.findByCompanyId(companyId);
}
async createFFClient(dto: CreateFFClientDto): Promise<FFClient> {
await this.findCompanyById(dto.forwarderCompanyId);
await this.findCompanyById(dto.clientCompanyId);
private getProfileTypeForCompanyType(companyType: string): ProfileType[] {
switch (companyType) {
case "customer":
return [ProfileType.importer, ProfileType.exporter];
case "freight_forwarder":
return [ProfileType.freightForwarder];
case "dj_freight_forwarder":
return [ProfileType.djFreightForwarder];
case "transporter":
return [ProfileType.transporter];
default:
return [];
}
}
const existing = await this.ffClientsRepo.findRelationship(
dto.forwarderCompanyId,
dto.clientCompanyId,
);
if (existing) {
throw new ConflictException('This forwarder-client relationship already exists');
async createCompanyProfile(
companyId: string,
profileType?: ProfileType,
): Promise<CompanyProfile> {
const company = await this.findCompanyById(companyId);
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
const type = profileType ?? allowedTypes[0];
if (!allowedTypes.includes(type)) {
throw new BadRequestException(
`Profile type "${type}" is not allowed for company type "${company.type}"`,
);
}
return this.ffClientsRepo.create(dto);
const existing = await this.companyProfilesRepo.findByType(companyId, type);
if (existing) {
throw new ConflictException(
`Company already has a ${type} profile (${existing.reference})`,
);
}
const reference = await this.companyProfilesRepo.generateReference(type);
return this.companyProfilesRepo.create({
companyId,
type,
reference,
status: ProfileStatus.Active,
});
}
async findForwarderClients(forwarderCompanyId: string): Promise<FFClient[]> {
return this.ffClientsRepo.findByForwarder(forwarderCompanyId);
async createDefaultProfilesForCompany(
companyId: string,
): Promise<CompanyProfile[]> {
const company = await this.findCompanyById(companyId);
const types = this.getProfileTypeForCompanyType(company.type);
const profiles: CompanyProfile[] = [];
for (const type of types) {
const existing = await this.companyProfilesRepo.findByType(
companyId,
type,
);
if (!existing) {
profiles.push(await this.createCompanyProfile(companyId, type));
}
}
if (profiles.length === 0) {
throw new BadRequestException(
`Company of type "${company.type}" must have at least one operational profile`,
);
}
return profiles;
}
async deleteFFClient(id: string): Promise<void> {
const client = await this.ffClientsRepo.findById(id);
if (!client) throw new NotFoundException(`FFClient ${id} not found`);
await this.ffClientsRepo.softDelete(id);
/**
* Add operational profile(s) to the current user's company (portal settings).
* Add-only and idempotent: each requested type must be allowed for the
* company's type, profiles that already exist are skipped (not re-created or
* rejected), and the full updated list is returned.
*/
async addCompanyProfilesForUser(
userId: string,
types: ProfileType[],
): Promise<CompanyProfile[]> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId;
const company = await this.findCompanyById(companyId);
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
for (const type of types) {
if (!allowedTypes.includes(type)) {
throw new BadRequestException(
`Profile type "${type}" is not allowed for company type "${company.type}"`,
);
}
const existing = await this.companyProfilesRepo.findByType(
companyId,
type,
);
if (existing) continue;
const reference = await this.companyProfilesRepo.generateReference(type);
await this.companyProfilesRepo.create({
companyId,
type,
reference,
status: ProfileStatus.Active,
});
}
return this.companyProfilesRepo.findByCompanyId(companyId);
}
}

View File

@@ -0,0 +1,61 @@
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
import { CompanyProfile, ProfileType } from "./entities/company-profile.entity";
const SEQUENCE_MAP: Record<ProfileType, string> = {
[ProfileType.exporter]: "seq_company_profile_ex",
[ProfileType.importer]: "seq_company_profile_im",
[ProfileType.freightForwarder]: "seq_company_profile_ffe",
[ProfileType.djFreightForwarder]: "seq_company_profile_fwj",
[ProfileType.transporter]: "seq_company_profile_tr",
};
const PREFIX_MAP: Record<ProfileType, string> = {
[ProfileType.exporter]: "EX",
[ProfileType.importer]: "IM",
[ProfileType.freightForwarder]: "FFE",
[ProfileType.djFreightForwarder]: "FWJ",
[ProfileType.transporter]: "TR",
};
@Injectable()
export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
constructor(
@InjectRepository(CompanyProfile)
repo: Repository<CompanyProfile>,
) {
super(repo);
}
async generateReference(type: ProfileType): Promise<string> {
const seqName = SEQUENCE_MAP[type];
const result = await this.repository.query(
`SELECT nextval('${seqName}') AS next_id`,
);
const nextId = result[0].next_id as number;
const prefix = PREFIX_MAP[type];
return `${prefix}-${String(nextId).padStart(5, "0")}`;
}
async findByCompanyId(companyId: string): Promise<CompanyProfile[]> {
return this.repository.find({
where: { companyId },
relations: ["company"],
});
}
async findByType(
companyId: string,
type: ProfileType,
): Promise<CompanyProfile | null> {
return this.repository.findOne({
where: { companyId, type },
});
}
async findByReference(reference: string): Promise<CompanyProfile | null> {
return this.repository.findOne({ where: { reference } });
}
}

View File

@@ -0,0 +1,9 @@
import { IsArray, IsEnum, ArrayMinSize } from "class-validator";
import { ProfileType } from "../entities/company-profile.entity";
export class AddCompanyProfilesDto {
@IsArray()
@ArrayMinSize(1)
@IsEnum(ProfileType, { each: true })
types!: ProfileType[];
}

View File

@@ -1,5 +1,17 @@
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum } from 'class-validator';
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum, IsArray, ValidateNested, ArrayMinSize } from 'class-validator';
import { Type } from 'class-transformer';
import { CompanyType } from '../entities/company.entity';
import { ProfileType } from '../entities/company-profile.entity';
export class CompanyProfileInputDto {
@IsEnum(ProfileType)
type!: ProfileType;
@IsOptional()
@IsString()
@MaxLength(100)
businessLicense?: string;
}
export class CreateCompanyWithProfileDto {
@IsEnum(CompanyType)
@@ -55,4 +67,11 @@ export class CreateCompanyWithProfileDto {
@IsOptional()
attributes?: Record<string, any>;
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CompanyProfileInputDto)
companyProfiles?: CompanyProfileInputDto[];
}

View File

@@ -25,11 +25,6 @@ export class CreateCompanyDto {
@MaxLength(50)
vatNumber?: string;
@IsOptional()
@IsString()
@MaxLength(100)
businessLicense?: string;
@IsOptional()
@IsString()
@MaxLength(32)

View File

@@ -1,24 +0,0 @@
import { IsUUID, IsNotEmpty, IsOptional, IsBoolean, IsEnum } from 'class-validator';
import { FFClientRelationship } from '../entities/ff-client.entity';
export class CreateFFClientDto {
@IsUUID()
@IsNotEmpty()
forwarderCompanyId!: string;
@IsUUID()
@IsNotEmpty()
clientCompanyId!: string;
@IsOptional()
@IsEnum(FFClientRelationship)
relationshipType?: FFClientRelationship;
@IsOptional()
@IsBoolean()
canBookOnBehalf?: boolean;
@IsOptional()
@IsBoolean()
canViewDocuments?: boolean;
}

View File

@@ -1,9 +1,11 @@
import { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity';
import { ResponseCompanyProfileDto } from './response-company.dto';
export class ProfileResponseDto {
companyId: string;
companyName: string;
companyType: string;
companyEmail: string | null;
companyPhone: string | null;
companyLocation: string;
@@ -12,6 +14,8 @@ export class ProfileResponseDto {
vatNumber: string | null;
fanNumber: string | null;
companyProfiles: ResponseCompanyProfileDto[];
contactPersonName: string | null;
contactPersonPhone: string | null;
generalManagerName: string | null;
@@ -29,6 +33,10 @@ export class ProfileResponseDto {
constructor(profile: ExternalProfile, company: Company) {
this.companyId = company.id;
this.companyName = company.name;
this.companyType = company.type;
this.companyProfiles =
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
[];
this.companyEmail = company.email ?? null;
this.companyPhone = company.phone ?? null;
this.companyLocation = company.country;

View File

@@ -1,6 +1,29 @@
import { Company, CompanyType, CompanyStatus } from '../entities/company.entity';
import { CompanyProfile } from '../entities/company-profile.entity';
import { ResponseExternalProfileDto } from './response-external-profile.dto';
export class ResponseCompanyProfileDto {
id: string;
type: string;
reference: string;
status: string;
businessLicense?: string | null;
attributes?: Record<string, any> | null;
createdAt: Date;
updatedAt: Date;
constructor(profile: CompanyProfile) {
this.id = profile.id;
this.type = profile.type;
this.reference = profile.reference;
this.status = profile.status;
this.businessLicense = profile.businessLicense;
this.attributes = profile.attributes;
this.createdAt = profile.createdAt;
this.updatedAt = profile.updatedAt;
}
}
export class ResponseCompanyDto {
id: string;
name: string;
@@ -8,7 +31,6 @@ export class ResponseCompanyDto {
status: CompanyStatus;
tin: string;
vatNumber?: string | null;
businessLicense?: string | null;
fanNumber?: string | null;
country: string;
address?: string | null;
@@ -17,6 +39,7 @@ export class ResponseCompanyDto {
website?: string | null;
attributes?: Record<string, any> | null;
profiles?: ResponseExternalProfileDto[];
companyProfiles?: ResponseCompanyProfileDto[];
createdAt: Date;
updatedAt: Date;
@@ -27,7 +50,6 @@ export class ResponseCompanyDto {
this.status = company.status;
this.tin = company.tin;
this.vatNumber = company.vatNumber;
this.businessLicense = company.businessLicense;
this.fanNumber = company.fanNumber;
this.country = company.country;
this.address = company.address;
@@ -36,6 +58,7 @@ export class ResponseCompanyDto {
this.website = company.website;
this.attributes = company.attributes;
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p));
this.createdAt = company.createdAt;
this.updatedAt = company.updatedAt;
}

View File

@@ -1,23 +0,0 @@
import { FFClient, FFClientRelationship } from '../entities/ff-client.entity';
export class ResponseFFClientDto {
id: string;
forwarderCompanyId: string;
clientCompanyId: string;
relationshipType: FFClientRelationship;
canBookOnBehalf: boolean;
canViewDocuments: boolean;
createdAt: Date;
updatedAt: Date;
constructor(client: FFClient) {
this.id = client.id;
this.forwarderCompanyId = client.forwarderCompanyId;
this.clientCompanyId = client.clientCompanyId;
this.relationshipType = client.relationshipType;
this.canBookOnBehalf = client.canBookOnBehalf;
this.canViewDocuments = client.canViewDocuments;
this.createdAt = client.createdAt;
this.updatedAt = client.updatedAt;
}
}

View File

@@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateFFClientDto } from './create-ff-client.dto';
export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {}

View File

@@ -0,0 +1,62 @@
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Company } from "./company.entity";
export enum ProfileType {
importer = "importer",
exporter = "exporter",
freightForwarder = "freight_forwarder",
djFreightForwarder = "dj_freight_forwarder",
transporter = "transporter",
}
export enum ProfileStatus {
Active = "active",
Pending = "pending",
Suspended = "suspended",
Blacklisted = "blacklisted",
}
@Entity({ schema: "freight", name: "company_profiles" })
@Index(["reference"], { unique: true })
@Index(["type"])
@Index(["companyId"])
export class CompanyProfile extends BaseEntity {
@Column({ name: "company_id", type: "uuid" })
companyId!: string;
@ManyToOne(() => Company, (company) => company.companyProfiles)
@JoinColumn({ name: "company_id" })
company!: Company;
@Column({ name: "type", type: "varchar", length: 32, enum: ProfileType })
type!: ProfileType;
@Column({
name: "reference",
type: "varchar",
length: 20,
nullable: false,
unique: true,
})
reference!: string;
@Column({
name: "status",
type: "varchar",
length: 32,
default: ProfileStatus.Active,
})
status!: ProfileStatus;
@Column({
name: "business_license",
type: "varchar",
length: 100,
nullable: true,
})
businessLicense?: string | null;
@Column({ name: "attributes", type: "jsonb", nullable: true })
attributes?: Record<string, any> | null;
}

View File

@@ -1,79 +1,110 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { ExternalProfile } from './external-profile.entity';
import { BaseEntity } from "@edr/api-common";
import { Column, Entity, Index, OneToMany } from "typeorm";
import { ExternalProfile } from "./external-profile.entity";
import { CompanyProfile } from "./company-profile.entity";
export enum CompanyType {
Customer = 'customer',
Forwarder = 'forwarder',
Transporter = 'transporter',
Broker = 'broker',
Customer = "customer",
FreightForwarder = "freight_forwarder",
DJFreightForwarder = "dj_freight_forwarder",
Transporter = "transporter",
}
export enum CompanyStatus {
Active = 'active',
Pending = 'pending',
Suspended = 'suspended',
Blacklisted = 'blacklisted',
Active = "active",
Pending = "pending",
Suspended = "suspended",
Blacklisted = "blacklisted",
}
@Entity({ schema: 'freight', name: 'companies' })
@Index(['tin'])
@Index(['type'])
@Entity({ schema: "freight", name: "companies" })
@Index(["tin"])
@Index(["type"])
export class Company extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 200 })
@Column({ name: "name", type: "varchar", length: 200 })
name!: string;
@Column({ name: 'type', type: 'varchar', length: 32, enum: CompanyType })
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
type!: CompanyType;
@Column({ name: 'status', type: 'varchar', length: 32, default: CompanyStatus.Pending })
@Column({
name: "status",
type: "varchar",
length: 32,
default: CompanyStatus.Pending,
})
status!: CompanyStatus;
@Column({ name: 'tin', type: 'varchar', length: 10, unique: true })
@Column({ name: "tin", type: "varchar", length: 10, unique: true })
tin!: string;
@Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
@Column({ name: "vat_number", type: "varchar", length: 50, nullable: true })
vatNumber?: string | null;
@Column({ name: 'business_license', type: 'varchar', length: 100, nullable: true })
businessLicense?: string | null;
@Column({ name: 'fan_number', type: 'varchar', length: 16, nullable: true })
@Column({ name: "fan_number", type: "varchar", length: 16, nullable: true })
fanNumber?: string | null;
@Column({ name: 'country', type: 'varchar', length: 32, default: 'Ethiopia' })
@Column({ name: "country", type: "varchar", length: 32, default: "Ethiopia" })
country!: string;
@Column({ name: 'address', type: 'text', nullable: true })
@Column({ name: "address", type: "text", nullable: true })
address?: string | null;
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
@Column({ name: "phone", type: "varchar", length: 20, nullable: true })
phone?: string | null;
@Column({ name: 'email', type: 'varchar', length: 150, nullable: true })
@Column({ name: "email", type: "varchar", length: 150, nullable: true })
email?: string | null;
@Column({ name: 'contact_person_name', type: 'varchar', length: 100, nullable: true })
@Column({
name: "contact_person_name",
type: "varchar",
length: 100,
nullable: true,
})
contactPersonName?: string | null;
@Column({ name: 'contact_person_phone', type: 'varchar', length: 20, nullable: true })
@Column({
name: "contact_person_phone",
type: "varchar",
length: 20,
nullable: true,
})
contactPersonPhone?: string | null;
@Column({ name: 'general_manager_name', type: 'varchar', length: 100, nullable: true })
@Column({
name: "general_manager_name",
type: "varchar",
length: 100,
nullable: true,
})
generalManagerName?: string | null;
@Column({ name: 'general_manager_email', type: 'varchar', length: 150, nullable: true })
@Column({
name: "general_manager_email",
type: "varchar",
length: 150,
nullable: true,
})
generalManagerEmail?: string | null;
@Column({ name: 'general_manager_phone', type: 'varchar', length: 20, nullable: true })
@Column({
name: "general_manager_phone",
type: "varchar",
length: 20,
nullable: true,
})
generalManagerPhone?: string | null;
@Column({ name: 'website', type: 'varchar', length: 200, nullable: true })
@Column({ name: "website", type: "varchar", length: 200, nullable: true })
website?: string | null;
@Column({ name: 'attributes', type: 'jsonb', nullable: true })
@Column({ name: "attributes", type: "jsonb", nullable: true })
attributes?: Record<string, any> | null;
@OneToMany(() => ExternalProfile, (profile) => profile.company)
profiles?: ExternalProfile[];
@OneToMany(() => CompanyProfile, (profile) => profile.company)
companyProfiles?: CompanyProfile[];
}

View File

@@ -1,37 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, ManyToOne, JoinColumn, Unique } from 'typeorm';
import { Company } from './company.entity';
export enum FFClientRelationship {
ManagedAccount = 'managed_account',
SubAgent = 'sub_agent',
}
@Entity({ schema: 'freight', name: 'ff_clients' })
@Unique(['forwarderCompanyId', 'clientCompanyId'])
@Index(['forwarderCompanyId'])
@Index(['clientCompanyId'])
export class FFClient extends BaseEntity {
@Column({ name: 'forwarder_company_id', type: 'uuid' })
forwarderCompanyId!: string;
@ManyToOne(() => Company)
@JoinColumn({ name: 'forwarder_company_id' })
forwarderCompany!: Company;
@Column({ name: 'client_company_id', type: 'uuid' })
clientCompanyId!: string;
@ManyToOne(() => Company)
@JoinColumn({ name: 'client_company_id' })
clientCompany!: Company;
@Column({ name: 'relationship_type', type: 'varchar', length: 32, default: FFClientRelationship.ManagedAccount })
relationshipType!: FFClientRelationship;
@Column({ name: 'can_book_on_behalf', type: 'boolean', default: true })
canBookOnBehalf!: boolean;
@Column({ name: 'can_view_documents', type: 'boolean', default: true })
canViewDocuments!: boolean;
}

View File

@@ -1,32 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { FFClient } from './entities/ff-client.entity';
@Injectable()
export class FFClientRepository extends BaseRepository<FFClient> {
constructor(
@InjectRepository(FFClient)
repo: Repository<FFClient>,
) {
super(repo);
}
async findByForwarder(forwarderCompanyId: string): Promise<FFClient[]> {
return this.repository.find({ where: { forwarderCompanyId } as any });
}
async findByClient(clientCompanyId: string): Promise<FFClient[]> {
return this.repository.find({ where: { clientCompanyId } as any });
}
async findRelationship(
forwarderCompanyId: string,
clientCompanyId: string,
): Promise<FFClient | null> {
return this.repository.findOne({
where: { forwarderCompanyId, clientCompanyId } as any,
});
}
}

View File

@@ -1,86 +0,0 @@
// src/modules/customers/customers.controller.ts
import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Body,
Query,
} from "@nestjs/common";
import { ApiOperation } from "@nestjs/swagger";
import { FreightAdmin } from "../../common/booking-guards";
import { CustomersService } from "./customers.service";
import { CreateCustomerDto } from "./dto/create-customer.dto";
import { UpdateCustomerDto } from "./dto/update-customer.dto";
import { Customer } from "./entities/customer.entity";
@Controller("customers")
@FreightAdmin()
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}
@Post()
create(@Body() createCustomerDto: CreateCustomerDto): Promise<Customer> {
return this.customersService.create(createCustomerDto);
}
@Get()
findAll(): Promise<Customer[]> {
return this.customersService.findAll();
}
@Get("stats")
@ApiOperation({ summary: "Get customer statistics" })
getStats(): Promise<{ total: number; withVatNumber: number }> {
return this.customersService.getStats();
}
@Get("search")
searchByName(@Query("name") name: string): Promise<Customer[]> {
return this.customersService.searchByName(name);
}
@Get("email/:email")
findByEmail(@Param("email") email: string): Promise<Customer> {
return this.customersService.findByEmail(email);
}
@Get("vat/:vatNumber")
findByVatNumber(@Param("vatNumber") vatNumber: string): Promise<Customer> {
return this.customersService.findByVatNumber(vatNumber);
}
@Get(":id")
findById(@Param("id", ParseUUIDPipe) id: string): Promise<Customer> {
return this.customersService.findById(id);
}
// @Get("user/:userId")
// findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise<any> {
// return this.customersService.findByUserId(userId);
// }
@Patch(":id")
@ApiOperation({ summary: "Update a customer" })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateCustomerDto,
): Promise<Customer> {
return this.customersService.update(id, dto);
}
@Delete(":id")
@ApiOperation({ summary: "Soft-delete a customer" })
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseUUIDPipe) id: string): Promise<void> {
return this.customersService.delete(id);
}
}

View File

@@ -1,15 +0,0 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { CustomersController } from "./customers.controller";
import { CustomersRepository } from "./customers.repository";
import { CustomersService } from "./customers.service";
import { Customer } from "./entities/customer.entity";
@Module({
imports: [TypeOrmModule.forFeature([Customer])],
controllers: [CustomersController],
providers: [CustomersService, CustomersRepository],
exports: [CustomersService],
})
export class CustomersModule {}

View File

@@ -1,117 +0,0 @@
// import { BaseRepository } from "@edr/api-common";
// import { EntityRepository } from "typeorm";
// src/modules/customers/customers.repository.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository, FindManyOptions, FindOptionsWhere } from "typeorm";
import { Customer } from "./entities/customer.entity";
import { CreateCustomerDto } from "./dto/create-customer.dto";
// import { UpdateCustomerDto } from "./dto/update-customer.dto";
@Injectable()
export class CustomersRepository {
constructor(
@InjectRepository(Customer)
private readonly repository: Repository<Customer>,
) { }
async create(dto: CreateCustomerDto): Promise<Customer> {
const customer = this.repository.create(dto);
return await this.repository.save(customer);
}
async findAll(options?: FindManyOptions<Customer>): Promise<Customer[]> {
return await this.repository.find(options);
}
async findById(id: string): Promise<Customer | null> {
return await this.repository.findOne({ where: { id } as FindOptionsWhere<Customer> });
}
async findByUserId(userId: string): Promise<Customer | null> {
return await this.repository.findOne({ where: { userId } as FindOptionsWhere<Customer> });
}
async findByEmail(email: string): Promise<Customer | null> {
return await this.repository.findOne({ where: { email } as FindOptionsWhere<Customer> });
}
async findByVatNumber(vatNumber: string): Promise<Customer | null> {
return await this.repository.findOne({ where: { vatNumber } as FindOptionsWhere<Customer> });
}
async findByName(name: string): Promise<Customer[]> {
return await this.repository
.createQueryBuilder("customer")
.where("customer.companyName ILIKE :name", { name: `%${name}%` })
.getMany();
}
async findOneByEmailOrVat(email?: string, vatNumber?: string): Promise<Customer | null> {
if (!email && !vatNumber) return null;
const queryBuilder = this.repository.createQueryBuilder('customer');
if (email && vatNumber) {
queryBuilder.where('customer.email = :email', { email })
.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
} else if (email) {
queryBuilder.where('customer.email = :email', { email });
} else if (vatNumber) {
queryBuilder.where('customer.vatNumber = :vatNumber', { vatNumber });
}
return await queryBuilder.getOne();
}
async update(id: string, updates: Partial<Customer>): Promise<Customer | null> {
await this.repository.update(id, updates);
return this.findById(id);
}
async delete(id: string): Promise<boolean> {
const result = await this.repository.delete(id);
return (result.affected ?? 0) > 0;
}
async count(where?: any): Promise<number> {
if (where?.createdAt) {
const result = await this.repository
.createQueryBuilder('customer')
.where('customer.createdAt >= :date', { date: where.createdAt })
.getCount();
return result;
}
return await this.repository.count();
}
async existsByUniqueFields(email: string, vatNumber?: string): Promise<boolean> {
const queryBuilder = this.repository.createQueryBuilder('customer')
.where('customer.email = :email', { email });
if (vatNumber) {
queryBuilder.orWhere('customer.vatNumber = :vatNumber', { vatNumber });
}
const count = await queryBuilder.getCount();
return count > 0;
}
async countWithVatNumber(): Promise<number> {
const count = await this.repository
.createQueryBuilder('customer')
.where('customer.vatNumber IS NOT NULL')
.andWhere("customer.vatNumber != ''")
.getCount();
return count;
}
getRepository(): Repository<Customer> {
return this.repository;
}
softDelete(id: string): any {
return id;
}
}

View File

@@ -1,140 +0,0 @@
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from "@nestjs/common";
import { CustomersRepository } from "./customers.repository";
import { CreateCustomerDto } from "./dto/create-customer.dto";
import { UpdateCustomerDto } from "./dto/update-customer.dto";
import { Customer } from "./entities/customer.entity";
@Injectable()
export class CustomersService {
constructor(private readonly customersRepository: CustomersRepository) {}
/** Create a new customer */
async create(dto: CreateCustomerDto): Promise<Customer> {
const exists = await this.customersRepository.existsByUniqueFields(
dto.email,
dto.vatNumber,
);
if (exists) {
throw new ConflictException(
"Customer with same email or VAT number already exists",
);
}
if (dto.vatNumber && dto.vatNumber.length !== 10) {
throw new BadRequestException("VAT number must be exactly 10 digits");
}
return this.customersRepository.create(dto);
}
/** Get all customers */
findAll(): Promise<Customer[]> {
return this.customersRepository.findAll({
order: { companyName: "ASC" },
});
}
/** Get customer by ID */
async findById(id: string): Promise<Customer> {
const customer = await this.customersRepository.findById(id);
if (!customer) {
throw new NotFoundException(`Customer with ID ${id} not found`);
}
return customer;
}
// async findByUserId(userId: string): Promise<Customer> {
// const customer = await this.customersRepository.findByUserId(userId);
// if (!customer) {
// throw new NotFoundException(`Customer with ID ${userId} not found`);
// }
// return customer;
//}
/** Get customer by email */
async findByEmail(email: string): Promise<Customer> {
const customer = await this.customersRepository.findByEmail(email);
if (!customer) {
throw new NotFoundException(`Customer with email ${email} not found`);
}
return customer;
}
/** Get customer by VAT number */
async findByVatNumber(vatNumber: string): Promise<Customer> {
const customer = await this.customersRepository.findByVatNumber(vatNumber);
if (!customer) {
throw new NotFoundException(
`Customer with VAT number ${vatNumber} not found`,
);
}
return customer;
}
/** Search customers by name */
searchByName(name: string): Promise<Customer[]> {
return this.customersRepository.findByName(name);
}
/** Update customer */
async update(id: string, dto: UpdateCustomerDto): Promise<Customer> {
await this.findById(id);
// Validate VAT number if provided
if (dto.vatNumber && dto.vatNumber.length !== 10) {
throw new BadRequestException("VAT number must be exactly 10 digits");
}
// // Check email conflict
// if (dto.email) {
// const existing = await this.customersRepository.findByEmail(dto.email);
// // if (existing && existing.userId !== id) {
// // throw new ConflictException(
// // `Customer with email "${dto.email}" already exists`,
// // );
// // }
// }
const updated = await this.customersRepository.update(id, dto);
if (!updated) {
throw new NotFoundException(`Customer ${id} not found`);
}
return updated;
}
/** Delete customer (soft delete) */
async remove(id: string): Promise<void> {
await this.findById(id);
await this.customersRepository.softDelete(id);
}
/** Get customer statistics */
async getStats(): Promise<{ total: number; withVatNumber: number }> {
const total = await this.customersRepository.count();
const withVatNumber = await this.customersRepository.countWithVatNumber();
return { total, withVatNumber };
}
delete(id: string): any {
return id;
}
}

View File

@@ -1,156 +0,0 @@
import {
IsEmail,
IsEnum,
IsOptional,
IsString,
MaxLength,
IsNotEmpty,
Length,
Matches,
} from "class-validator";
// Enums
export enum CustomerStatusDto {
Active = "Active",
Pending = "Pending",
Inactive = "Inactive",
}
export enum CustomerTypeDto {
Importer = "Importer",
Exporter = "Exporter",
Supplier = "Supplier",
}
// DTO
export class CreateCustomerDto {
// Basic identity
@IsString()
@IsNotEmpty()
userId!: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
firstName!: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
lastName!: string;
@IsEmail()
@IsNotEmpty()
email!: string;
@IsString()
@IsNotEmpty()
@MaxLength(20)
phone!: string;
// Company info
@IsString()
@IsNotEmpty()
@MaxLength(200)
companyName!: string;
@IsEmail()
@IsNotEmpty()
companyEmail!: string;
@IsString()
@IsNotEmpty()
@MaxLength(20)
companyPhone!: string;
@IsString()
@IsNotEmpty()
@MaxLength(100)
companyLocation!: string;
@IsString()
@IsNotEmpty()
companyAddress!: string;
// Classification
@IsOptional()
@IsEnum(CustomerTypeDto)
customerType?: CustomerTypeDto;
@IsOptional()
@IsEnum(CustomerStatusDto)
status?: CustomerStatusDto;
// Legal identifiers
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^\d+$/, { message: "TIN must contain only digits" })
tinNumber!: string;
@IsString()
@IsNotEmpty()
@Length(16, 16)
@Matches(/^\d+$/, { message: "FAN must contain only digits" })
fanNumber!: string;
@IsString()
@IsNotEmpty()
@MaxLength(50)
vatNumber!: string;
// Contact person
@IsString()
@IsNotEmpty()
@MaxLength(100)
contactPersonName!: string;
@IsString()
@IsNotEmpty()
@MaxLength(20)
contactPersonPhone!: string;
// Management
@IsString()
@IsNotEmpty()
@MaxLength(100)
generalManagerName!: string;
@IsEmail()
@IsNotEmpty()
generalManagerEmail!: string;
@IsString()
@IsNotEmpty()
@MaxLength(20)
generalManagerPhone!: string;
// POA (Power of Attorney)
@IsOptional()
@IsString()
@MaxLength(100)
poaName?: string;
@IsOptional()
@IsString()
@MaxLength(20)
poaPhone?: string;
@IsOptional()
@IsString()
poaAddress?: string;
@IsOptional()
@IsEmail()
poaEmail?: string;
@IsOptional()
@IsString()
@MaxLength(100)
poaLocation?: string;
// Extra
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -1,60 +0,0 @@
// src/modules/customers/dto/response-customer.dto.ts
import { Customer } from '../entities/customer.entity';
export class ResponseCustomerDto {
//UserId: string;
firstName: string;
lastName: string;
email: string;
phone: string;
companyName: string;
companyEmail: string;
companyPhone: string;
companyLocation: string;
companyAddress: string;
contactPersonName: string;
contactPersonPhone: string;
tinNumber: string;
vatNumber?: string;
fanNumber: string;
generalManagerName: string;
generalManagerEmail: string;
generalManagerPhone: string;
poaName?: string;
poaPhone?: string;
poaAddress?: string;
poaEmail?: string;
poaLocation?: string;
notes?: string;
createdAt: Date;
updatedAt: Date;
constructor(customer: Customer) {
//this.UserId = customer.userId;
this.firstName = customer.firstName;
this.lastName = customer.lastName;
this.email = customer.email;
this.phone = customer.phone;
this.companyName = customer.companyName;
this.companyEmail = customer.companyEmail;
this.companyPhone = customer.companyPhone;
this.companyLocation = customer.companyLocation;
this.companyAddress = customer.companyAddress;
this.contactPersonName = customer.contactPersonName;
this.contactPersonPhone = customer.contactPersonPhone;
this.tinNumber = customer.tinNumber;
this.vatNumber = customer.vatNumber ?? undefined;
this.fanNumber = customer.fanNumber;
this.generalManagerName = customer.generalManagerName;
this.generalManagerEmail = customer.generalManagerEmail;
this.generalManagerPhone = customer.generalManagerPhone;
this.poaName = customer.poaName ?? '';
this.poaPhone = customer.poaPhone ?? '';
this.poaAddress = customer.poaAddress ?? '';
this.poaEmail = customer.poaEmail ?? '';
this.poaLocation = customer.poaLocation ?? '';
this.notes = customer.notes ?? '';
this.createdAt = customer.createdAt;
this.updatedAt = customer.updatedAt;
}
}

View File

@@ -1,9 +0,0 @@
// src/modules/customers/dto/update-customer.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateCustomerDto } from './create-customer.dto';
export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {
email?: string;
vatNumber?: string;
// Add any other properties you need to access directly
}

View File

@@ -1,87 +0,0 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
@Entity({ schema: 'freight', name: 'customers' })
@Index(['email'])
//@Index(['userId'])
@Index(['tinNumber'])
@Index(['fanNumber'])
export class Customer extends BaseEntity {
//@Column({ name: 'user_id', type: 'uuid' })
//userId!: string;
@Column({ name: 'first_name', type: 'varchar', length: 100 })
firstName!: string;
@Column({ name: 'last_name', type: 'varchar', length: 100 })
lastName!: string;
@Column({ name: 'email', type: 'varchar', length: 150, unique: true })
email!: string;
@Column({ name: 'phone', type: 'varchar', length: 20 })
phone!: string;
@Column({ name: 'company_name', type: 'varchar', length: 200 })
companyName!: string;
@Column({ name: 'company_email', type: 'varchar', length: 150 })
companyEmail!: string;
@Column({ name: 'company_phone', type: 'varchar', length: 20 })
companyPhone!: string;
@Column({ name: 'company_location', type: 'varchar', length: 100 })
companyLocation!: string;
@Column({ name: 'company_address', type: 'text' })
companyAddress!: string;
@Column({ name: 'customer_type', type: 'varchar', length: 32, nullable: true })
customerType?: string | null;
@Column({ name: 'status', type: 'varchar', length: 32, nullable: true })
status?: string | null;
@Column({ name: 'contact_person_name', type: 'varchar', length: 100 })
contactPersonName!: string;
@Column({ name: 'contact_person_phone', type: 'varchar', length: 20 })
contactPersonPhone!: string;
@Column({ name: 'tin_number', type: 'varchar', length: 10, unique: true })
tinNumber!: string;
@Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
vatNumber?: string | null;
@Column({ name: 'fan_number', type: 'varchar', length: 16, unique: true })
fanNumber!: string;
@Column({ name: 'general_manager_name', type: 'varchar', length: 100 })
generalManagerName!: string;
@Column({ name: 'general_manager_email', type: 'varchar', length: 150 })
generalManagerEmail!: string;
@Column({ name: 'general_manager_phone', type: 'varchar', length: 20 })
generalManagerPhone!: string;
@Column({ name: 'poa_name', type: 'varchar', length: 100, nullable: true })
poaName?: string | null;
@Column({ name: 'poa_phone', type: 'varchar', length: 20, nullable: true })
poaPhone?: string | null;
@Column({ name: 'poa_address', type: 'text', nullable: true })
poaAddress?: string | null;
@Column({ name: 'poa_email', type: 'varchar', length: 150, nullable: true })
poaEmail?: string | null;
@Column({ name: 'poa_location', type: 'varchar', length: 100, nullable: true })
poaLocation?: string | null;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}

View File

@@ -1,25 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Employee } from '@tria-plc/iamapi-common';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Employee } from "@tria-plc/iamapi-common";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { Booking } from '../bookings/entities/booking.entity';
import { Cargo } from '../cargoes/entities/cargoes.entity';
import { Container } from '../container-management/entities/container.entity';
import { Customer } from '../customers/entities/customer.entity';
import { PaymentEntity } from '../payment/entities/payment.entity';
import { Train } from '../trains/entities/train.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { OverviewController } from './overview.controller';
import { OverviewRepository } from './overview.repository';
import { OverviewService } from './overview.service';
import { Booking } from "../bookings/entities/booking.entity";
import { Cargo } from "../cargoes/entities/cargoes.entity";
import { Container } from "../container-management/entities/container.entity";
import { Company } from "../companies/entities/company.entity";
import { PaymentEntity } from "../payment/entities/payment.entity";
import { Train } from "../trains/entities/train.entity";
import { Wagon } from "../wagons/entities/wagon.entity";
import { OverviewController } from "./overview.controller";
import { OverviewRepository } from "./overview.repository";
import { OverviewService } from "./overview.service";
@Module({
imports: [
TypeOrmModule.forFeature([
Booking,
PaymentEntity,
Customer,
Company,
Train,
Wagon,
Container,
@@ -31,4 +31,4 @@ import { OverviewService } from './overview.service';
controllers: [OverviewController],
providers: [OverviewService, OverviewRepository],
})
export class OverviewModule {}
export class OverviewModule { }

View File

@@ -1,24 +1,24 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
import { Employee } from '@tria-plc/iamapi-common';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { Freight } from '@edr/types';
import { Repository, ObjectLiteral } from 'typeorm';
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
import { Employee } from "@tria-plc/iamapi-common";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { Freight } from "@edr/types";
import { Repository, ObjectLiteral } from "typeorm";
import { Booking } from '../bookings/entities/booking.entity';
import { Cargo } from '../cargoes/entities/cargoes.entity';
import { Container } from '../container-management/entities/container.entity';
import { Customer } from '../customers/entities/customer.entity';
import { PaymentEntity } from '../payment/entities/payment.entity';
import { Train } from '../trains/entities/train.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { Booking } from "../bookings/entities/booking.entity";
import { Cargo } from "../cargoes/entities/cargoes.entity";
import { Container } from "../container-management/entities/container.entity";
import { PaymentEntity } from "../payment/entities/payment.entity";
import { Train } from "../trains/entities/train.entity";
import { Wagon } from "../wagons/entities/wagon.entity";
import {
OVERVIEW_CLOSED_STATUSES,
OVERVIEW_IN_APPROVAL_STATUSES,
OVERVIEW_NEEDS_ACTION_STATUSES,
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
} from './overview.constants';
} from "./overview.constants";
import { Company } from "../companies/entities/company.entity";
export type OverviewBookingKpisRow = {
totalActive: number;
@@ -46,8 +46,8 @@ export class OverviewRepository {
private readonly bookingRepository: Repository<Booking>,
@InjectRepository(PaymentEntity)
private readonly paymentRepository: Repository<PaymentEntity>,
@InjectRepository(Customer)
private readonly customerRepository: Repository<Customer>,
@InjectRepository(Company)
private readonly companyRepository: Repository<Company>,
@InjectRepository(Train)
private readonly trainRepository: Repository<Train>,
@InjectRepository(Wagon)
@@ -60,32 +60,32 @@ export class OverviewRepository {
private readonly employeeRepository: Repository<Employee>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
) { }
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
const row = await this.bookingRepository
.createQueryBuilder('booking')
.createQueryBuilder("booking")
.select(
`COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`,
'totalActive',
"totalActive",
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`,
'needsAction',
"needsAction",
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`,
'urgent',
"urgent",
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`,
'inApproval',
"inApproval",
)
.addSelect(
`COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`,
'submittedToday',
"submittedToday",
)
.where('booking.deleted_at IS NULL')
.where("booking.deleted_at IS NULL")
.setParameters({
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
@@ -112,9 +112,9 @@ export class OverviewRepository {
const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] =
await Promise.all([
this.trainRepository
.createQueryBuilder('train')
.where('train.deleted_at IS NULL')
.andWhere('train.status IN (:...statuses)', {
.createQueryBuilder("train")
.where("train.deleted_at IS NULL")
.andWhere("train.status IN (:...statuses)", {
statuses: [
Freight.TrainStatus.InService,
Freight.TrainStatus.Scheduled,
@@ -122,39 +122,46 @@ export class OverviewRepository {
})
.getCount(),
this.wagonRepository
.createQueryBuilder('wagon')
.where('wagon.deleted_at IS NULL')
.andWhere('wagon.status = :status', { status: Freight.WagonStatus.Available })
.createQueryBuilder("wagon")
.where("wagon.deleted_at IS NULL")
.andWhere("wagon.status = :status", {
status: Freight.WagonStatus.Available,
})
.getCount(),
this.containerRepository
.createQueryBuilder('container')
.where('container.deleted_at IS NULL')
.andWhere('container.status = :status', { status: 'IN_TRANSIT' })
.createQueryBuilder("container")
.where("container.deleted_at IS NULL")
.andWhere("container.status = :status", { status: "IN_TRANSIT" })
.getCount(),
this.cargoRepository
.createQueryBuilder('cargo')
.where('cargo.deleted_at IS NULL')
.andWhere('cargo.status IN (:...statuses)', {
statuses: ['LOADED', 'IN_TRANSIT'],
.createQueryBuilder("cargo")
.where("cargo.deleted_at IS NULL")
.andWhere("cargo.status IN (:...statuses)", {
statuses: ["LOADED", "IN_TRANSIT"],
})
.getCount(),
]);
return { trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded };
return {
trainsActive,
wagonsAvailable,
containersInTransit,
cargoesLoaded,
};
}
async getCustomerKpis(): Promise<{
totalCustomers: number;
newCustomersThisMonth: number;
}> {
const row = await this.customerRepository
.createQueryBuilder('customer')
.select('COUNT(*)::int', 'totalCustomers')
const row = await this.companyRepository
.createQueryBuilder("customer")
.select("COUNT(*)::int", "totalCustomers")
.addSelect(
`COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`,
'newCustomersThisMonth',
"newCustomersThisMonth",
)
.where('customer.deleted_at IS NULL')
.where("customer.deleted_at IS NULL")
.getRawOne<Record<string, string>>();
return {
@@ -170,26 +177,26 @@ export class OverviewRepository {
successfulPaymentsMtd: number;
}> {
const revenueRow = await this.paymentRepository
.createQueryBuilder('payment')
.createQueryBuilder("payment")
.select(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
'revenueMtdEtb',
"revenueMtdEtb",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
'revenueMtdUsd',
"revenueMtdUsd",
)
.addSelect(`COUNT(*)::int`, 'successfulPaymentsMtd')
.where('payment.status = :status', { status: 'success' })
.addSelect(`COUNT(*)::int`, "successfulPaymentsMtd")
.where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
)
.getRawOne<Record<string, string>>();
const pendingPayments = await this.paymentRepository
.createQueryBuilder('payment')
.where('payment.status IN (:...statuses)', {
statuses: ['action-required', 'processing'],
.createQueryBuilder("payment")
.where("payment.status IN (:...statuses)", {
statuses: ["action-required", "processing"],
})
.getCount();
@@ -201,7 +208,10 @@ export class OverviewRepository {
};
}
async getStaffKpis(): Promise<{ activeEmployees: number; activeUsers: number }> {
async getStaffKpis(): Promise<{
activeEmployees: number;
activeUsers: number;
}> {
const [activeEmployees, activeUsers] = await Promise.all([
this.employeeRepository.count({
where: { isCurrent: true },
@@ -217,15 +227,17 @@ export class OverviewRepository {
return { activeEmployees, activeUsers };
}
async getBookingTrend(days: number): Promise<{ date: string; count: number }[]> {
async getBookingTrend(
days: number,
): Promise<{ date: string; count: number }[]> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, 'date')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.createQueryBuilder("booking")
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy('booking.created_at::date')
.orderBy('booking.created_at::date', 'ASC')
.groupBy("booking.created_at::date")
.orderBy("booking.created_at::date", "ASC")
.getRawMany<{ date: string; count: string }>();
return rows.map((row) => ({
@@ -236,11 +248,11 @@ export class OverviewRepository {
async getStatusCounts(): Promise<Record<string, number>> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.select('booking.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.groupBy('booking.status')
.createQueryBuilder("booking")
.select("booking.status", "status")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.groupBy("booking.status")
.getRawMany<{ status: string; count: string }>();
return Object.fromEntries(
@@ -252,26 +264,26 @@ export class OverviewRepository {
days: number,
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
const rows = await this.paymentRepository
.createQueryBuilder('payment')
.createQueryBuilder("payment")
.select(
`to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`,
'date',
"date",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
'amountEtb',
"amountEtb",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
'amountUsd',
"amountUsd",
)
.where('payment.status = :status', { status: 'success' })
.where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
{ days },
)
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, 'ASC')
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC")
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>();
return rows.map((row) => ({
@@ -283,18 +295,18 @@ export class OverviewRepository {
async getRecentBookings(limit: number): Promise<OverviewRecentBookingRow[]> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.leftJoin('booking.company', 'company')
.select('booking.id', 'id')
.addSelect('booking.reference', 'reference')
.addSelect('COALESCE(company.name, \'—\')', 'customerLabel')
.addSelect('booking.status', 'status')
.addSelect('booking.priority_score', 'priorityScore')
.addSelect('booking.total_amount', 'totalAmount')
.addSelect('booking.payment_currency', 'paymentCurrency')
.addSelect('booking.created_at', 'createdAt')
.where('booking.deleted_at IS NULL')
.orderBy('booking.created_at', 'DESC')
.createQueryBuilder("booking")
.leftJoin("booking.company", "company")
.select("booking.id", "id")
.addSelect("booking.reference", "reference")
.addSelect("COALESCE(company.name, '—')", "customerLabel")
.addSelect("booking.status", "status")
.addSelect("booking.priority_score", "priorityScore")
.addSelect("booking.total_amount", "totalAmount")
.addSelect("booking.payment_currency", "paymentCurrency")
.addSelect("booking.created_at", "createdAt")
.where("booking.deleted_at IS NULL")
.orderBy("booking.created_at", "DESC")
.limit(limit)
.getRawMany<{
id: string;
@@ -319,15 +331,17 @@ export class OverviewRepository {
}));
}
async getBookingsByFreightType(): Promise<{ label: string; count: number }[]> {
async getBookingsByFreightType(): Promise<
{ label: string; count: number }[]
> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.select('booking.freight_type', 'label')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.createQueryBuilder("booking")
.select("booking.freight_type", "label")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere("booking.status != 'DRAFT'")
.groupBy('booking.freight_type')
.orderBy('count', 'DESC')
.groupBy("booking.freight_type")
.orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({
@@ -338,13 +352,13 @@ export class OverviewRepository {
async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.select('booking.payment_currency', 'label')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.createQueryBuilder("booking")
.select("booking.payment_currency", "label")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere("booking.status != 'DRAFT'")
.groupBy('booking.payment_currency')
.orderBy('count', 'DESC')
.groupBy("booking.payment_currency")
.orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({
@@ -355,11 +369,11 @@ export class OverviewRepository {
async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> {
const rows = await this.paymentRepository
.createQueryBuilder('payment')
.select('payment.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.groupBy('payment.status')
.orderBy('count', 'DESC')
.createQueryBuilder("payment")
.select("payment.status", "status")
.addSelect("COUNT(*)::int", "count")
.groupBy("payment.status")
.orderBy("count", "DESC")
.getRawMany<{ status: string; count: string }>();
return rows.map((row) => ({
@@ -372,20 +386,25 @@ export class OverviewRepository {
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
> {
const rows = await this.paymentRepository
.createQueryBuilder('payment')
.select('payment.method', 'method')
.addSelect('COUNT(*)::int', 'count')
.createQueryBuilder("payment")
.select("payment.method", "method")
.addSelect("COUNT(*)::int", "count")
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`,
'amountEtb',
"amountEtb",
)
.addSelect(
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
'amountUsd',
"amountUsd",
)
.groupBy('payment.method')
.orderBy('count', 'DESC')
.getRawMany<{ method: string; count: string; amountEtb: string; amountUsd: string }>();
.groupBy("payment.method")
.orderBy("count", "DESC")
.getRawMany<{
method: string;
count: string;
amountEtb: string;
amountUsd: string;
}>();
return rows.map((row) => ({
method: row.method,
@@ -395,16 +414,18 @@ export class OverviewRepository {
}));
}
async getRevenueByCurrency(): Promise<{ currency: string; amount: number }[]> {
async getRevenueByCurrency(): Promise<
{ currency: string; amount: number }[]
> {
const rows = await this.paymentRepository
.createQueryBuilder('payment')
.select('payment.currency', 'currency')
.addSelect('COALESCE(SUM(payment.amount), 0)', 'amount')
.where('payment.status = :status', { status: 'success' })
.createQueryBuilder("payment")
.select("payment.currency", "currency")
.addSelect("COALESCE(SUM(payment.amount), 0)", "amount")
.where("payment.status = :status", { status: "success" })
.andWhere(
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
)
.groupBy('payment.currency')
.groupBy("payment.currency")
.getRawMany<{ currency: string; amount: string }>();
return rows.map((row) => ({
@@ -413,20 +434,28 @@ export class OverviewRepository {
}));
}
async getTrainStatusBreakdown(): Promise<{ status: string; count: number }[]> {
return this.statusBreakdown(this.trainRepository, 'train');
async getTrainStatusBreakdown(): Promise<
{ status: string; count: number }[]
> {
return this.statusBreakdown(this.trainRepository, "train");
}
async getWagonStatusBreakdown(): Promise<{ status: string; count: number }[]> {
return this.statusBreakdown(this.wagonRepository, 'wagon');
async getWagonStatusBreakdown(): Promise<
{ status: string; count: number }[]
> {
return this.statusBreakdown(this.wagonRepository, "wagon");
}
async getContainerStatusBreakdown(): Promise<{ status: string; count: number }[]> {
return this.statusBreakdown(this.containerRepository, 'container');
async getContainerStatusBreakdown(): Promise<
{ status: string; count: number }[]
> {
return this.statusBreakdown(this.containerRepository, "container");
}
async getCargoStatusBreakdown(): Promise<{ status: string; count: number }[]> {
return this.statusBreakdown(this.cargoRepository, 'cargo');
async getCargoStatusBreakdown(): Promise<
{ status: string; count: number }[]
> {
return this.statusBreakdown(this.cargoRepository, "cargo");
}
private async statusBreakdown(
@@ -435,11 +464,11 @@ export class OverviewRepository {
): Promise<{ status: string; count: number }[]> {
const rows = await repository
.createQueryBuilder(alias)
.select(`${alias}.status`, 'status')
.addSelect('COUNT(*)::int', 'count')
.select(`${alias}.status`, "status")
.addSelect("COUNT(*)::int", "count")
.where(`${alias}.deleted_at IS NULL`)
.groupBy(`${alias}.status`)
.orderBy('count', 'DESC')
.orderBy("count", "DESC")
.getRawMany<{ status: string; count: string }>();
return rows.map((row) => ({
@@ -448,15 +477,19 @@ export class OverviewRepository {
}));
}
async getCustomerGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
const rows = await this.customerRepository
.createQueryBuilder('customer')
.select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, 'date')
.addSelect('COUNT(*)::int', 'count')
.where('customer.deleted_at IS NULL')
.andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy('customer.created_at::date')
.orderBy('customer.created_at::date', 'ASC')
async getCustomerGrowthTrend(
days: number,
): Promise<{ date: string; count: number }[]> {
const rows = await this.companyRepository
.createQueryBuilder("customer")
.select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, "date")
.addSelect("COUNT(*)::int", "count")
.where("customer.deleted_at IS NULL")
.andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, {
days,
})
.groupBy("customer.created_at::date")
.orderBy("customer.created_at::date", "ASC")
.getRawMany<{ date: string; count: string }>();
return rows.map((row) => ({
@@ -466,13 +499,16 @@ export class OverviewRepository {
}
async getCustomersByType(): Promise<{ label: string; count: number }[]> {
const rows = await this.customerRepository
.createQueryBuilder('customer')
.select(`COALESCE(NULLIF(customer.customer_type, ''), 'Unknown')`, 'label')
.addSelect('COUNT(*)::int', 'count')
.where('customer.deleted_at IS NULL')
.groupBy('customer.customer_type')
.orderBy('count', 'DESC')
const rows = await this.companyRepository
.createQueryBuilder("customer")
.select(
`COALESCE(NULLIF(customer.type, ''), 'Unknown')`,
"label",
)
.addSelect("COUNT(*)::int", "count")
.where("customer.deleted_at IS NULL")
.groupBy("customer.type")
.orderBy("count", "DESC")
.getRawMany<{ label: string; count: string }>();
return rows.map((row) => ({
@@ -481,16 +517,18 @@ export class OverviewRepository {
}));
}
async getTopCustomersByBookings(limit: number): Promise<{ label: string; count: number }[]> {
async getTopCustomersByBookings(
limit: number,
): Promise<{ label: string; count: number }[]> {
const rows = await this.bookingRepository
.createQueryBuilder('booking')
.leftJoin('booking.company', 'company')
.select(`COALESCE(company.name, 'Unknown')`, 'label')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.createQueryBuilder("booking")
.leftJoin("booking.company", "company")
.select(`COALESCE(company.name, 'Unknown')`, "label")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere("booking.status != 'DRAFT'")
.groupBy('company.name')
.orderBy('count', 'DESC')
.groupBy("company.name")
.orderBy("count", "DESC")
.limit(limit)
.getRawMany<{ label: string; count: string }>();
@@ -502,11 +540,11 @@ export class OverviewRepository {
async getUsersByStatus(): Promise<{ status: string; count: number }[]> {
const rows = await this.userRepository
.createQueryBuilder('user')
.select('user.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.groupBy('user.status')
.orderBy('count', 'DESC')
.createQueryBuilder("user")
.select("user.status", "status")
.addSelect("COUNT(*)::int", "count")
.groupBy("user.status")
.orderBy("count", "DESC")
.getRawMany<{ status: string; count: string }>();
return rows.map((row) => ({
@@ -515,15 +553,19 @@ export class OverviewRepository {
}));
}
async getEmployeeGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
async getEmployeeGrowthTrend(
days: number,
): Promise<{ date: string; count: number }[]> {
const rows = await this.employeeRepository
.createQueryBuilder('employee')
.select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, 'date')
.addSelect('COUNT(*)::int', 'count')
.where('employee.is_current = true')
.andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy('employee.created_at::date')
.orderBy('employee.created_at::date', 'ASC')
.createQueryBuilder("employee")
.select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, "date")
.addSelect("COUNT(*)::int", "count")
.where("employee.is_current = true")
.andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, {
days,
})
.groupBy("employee.created_at::date")
.orderBy("employee.created_at::date", "ASC")
.getRawMany<{ date: string; count: string }>();
return rows.map((row) => ({
@@ -538,16 +580,16 @@ export class OverviewRepository {
where: { isActive: true, status: EUserStatus.ACCEPTED },
}),
this.userRepository
.createQueryBuilder('user')
.where('user.is_active = false OR user.status != :status', {
.createQueryBuilder("user")
.where("user.is_active = false OR user.status != :status", {
status: EUserStatus.ACCEPTED,
})
.getCount(),
]);
return [
{ label: 'Active', count: active },
{ label: 'Inactive', count: inactive },
{ label: "Active", count: active },
{ label: "Inactive", count: inactive },
];
}
}

View File

@@ -146,10 +146,11 @@ const sidebarItems: SidebarItem[] = [
const App = () => {
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const { user, company } = useAuth();
const displayName = user?.name?.en || user?.username || user?.email || "User";
const userEmail = user?.email;
const companyProfiles = company?.company?.companyProfiles ?? [];
return (
<Routes>
@@ -190,6 +191,7 @@ const App = () => {
enableThemeToggle
userName={displayName}
userEmail={userEmail}
companyProfiles={companyProfiles}
>
<Outlet />
</AppLayout>

View File

@@ -47,9 +47,19 @@ export interface AppLayoutProps {
enableThemeToggle?: boolean;
userName?: string;
userEmail?: string;
/** Operational profiles for the company — surfaced as reference chips in the account menu. */
companyProfiles?: { type: string; reference: string; status?: string }[];
children: ReactNode;
}
const PROFILE_TYPE_LABELS: Record<string, string> = {
importer: "Importer",
exporter: "Exporter",
freight_forwarder: "Freight Forwarder",
dj_freight_forwarder: "DJ Freight Forwarder",
transporter: "Transporter",
};
function getInitials(name: string): string {
return name
.split(" ")
@@ -106,6 +116,7 @@ export function AppLayout({
enableThemeToggle = false,
userName = "User",
userEmail,
companyProfiles = [],
children,
}: AppLayoutProps) {
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
@@ -306,6 +317,34 @@ export function AppLayout({
</Text>
)}
</Box>
{companyProfiles.length > 0 && (
<>
<Divider />
<Box px="sm" py="xs">
<Stack gap={6}>
{companyProfiles.map((p) => (
<Group
key={p.reference}
justify="space-between"
gap="sm"
wrap="nowrap"
>
<Text
size="xs"
fw={600}
style={{ color: textColor }}
>
{PROFILE_TYPE_LABELS[p.type] ?? p.type}
</Text>
<Text size="xs" ff="monospace" c="dimmed">
{p.reference}
</Text>
</Group>
))}
</Stack>
</Box>
</>
)}
<Divider />
<Menu.Item
leftSection={<User size={15} />}

View File

@@ -83,6 +83,7 @@ export const URL_CONSTANTS = {
GET_INFO: "/api/companies/getInfo",
CREATE: "/api/companies/create",
PROFILE: "/api/companies/profile",
COMPANY_PROFILES: "/api/companies/company-profiles",
DASHBOARD: "/api/companies/dashboard",
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
},

View File

@@ -1,17 +1,113 @@
import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common";
import {
Badge,
Box,
Button,
Card,
Center,
Container,
Divider,
Grid,
Group,
Loader,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
BadgeCheck,
Briefcase,
Building,
Building2,
FileCheck,
Globe,
Mail,
MapPin,
Phone,
Plus,
ShieldCheck,
User,
UserCheck,
} from "lucide-react";
import { Link } from "react-router-dom";
import { rolesForCompanyType } from "./settings/companyRoles";
function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) {
function InfoItem({
icon,
label,
value,
}: {
icon?: React.ReactNode;
label: string;
value?: string | null;
}) {
return (
<div className="flex items-start gap-3">
{icon && <div className="mt-1 text-muted-foreground [&_svg]:size-4">{icon}</div>}
<div className="flex flex-col gap-0.5">
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">{label}</p>
<p className="text-sm font-bold text-foreground">{value || "—"}</p>
</div>
</div>
<Group gap="sm" align="flex-start" wrap="nowrap">
{icon && (
<ThemeIcon variant="light" color="edr-green" size="md" radius="md">
{icon}
</ThemeIcon>
)}
<Stack gap={2}>
<Text size="xs" fw={700} tt="uppercase" c="edr-muted">
{label}
</Text>
<Text size="sm" fw={600} c="edr-text">
{value || "—"}
</Text>
</Stack>
</Group>
);
}
function CardHeading({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) {
return (
<Stack gap={2} mb="md">
<Group gap="sm">
{icon}
<Title order={4} size="h5">
{title}
</Title>
</Group>
<Text size="sm" c="edr-muted">
{description}
</Text>
</Stack>
);
}
function PersonnelGroup({
color,
title,
children,
}: {
color: string;
title: string;
children: React.ReactNode;
}) {
return (
<Stack gap="sm">
<Group gap="xs">
<Box w={4} h={16} bg={color} style={{ borderRadius: 2 }} />
<Text size="sm" fw={700} tt="uppercase" c="edr-text">
{title}
</Text>
</Group>
<Stack gap="sm" pl="lg">
{children}
</Stack>
</Stack>
);
}
@@ -22,163 +118,293 @@ export default function ProfilePage() {
if (isPending) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary" />
</div>
<Center h="100%">
<Loader color="edr-green" size="lg" />
</Center>
);
}
if (!profile) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">No company profile found.</p>
</div>
<Center h="100%">
<Text c="edr-muted">No company profile found.</Text>
</Center>
);
}
// Registered operational profiles keyed by type, plus the roles this company
// type may hold (importer/exporter for a customer). Mirrors CompanyRolesCard.
const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p]));
const roleOptions = rolesForCompanyType(profile.companyType);
const activeOptions = roleOptions.filter((o) => refByType.has(o.type));
return (
<div className="px-4 py-8">
<div className="mx-auto max-w-7xl">
<div className="flex flex-col gap-8">
{/* Header */}
<div className="flex items-center gap-6">
<div className="flex size-24 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-inner">
<User className="size-12" />
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-black tracking-tight text-foreground">
{profile.companyName}
</h1>
<Badge variant="secondary" className="font-bold uppercase tracking-wider">
Verified
<Container size="xl" px="lg" py="xl">
{/* Header */}
<Group gap="lg" align="center" mb="lg">
<ThemeIcon variant="light" color="edr-green" size={88} radius="lg">
<User size={44} />
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center">
<Title order={1} size="h2">
{profile.companyName}
</Title>
<Badge color="edr-green" variant="light">
Verified
</Badge>
</Group>
{activeOptions.length > 0 ? (
<Group gap="xs">
{activeOptions.map((opt) => (
<Badge
key={opt.type}
variant="light"
color="edr-green"
size="lg"
radius="sm"
>
{opt.label} · {refByType.get(opt.type)!.reference}
</Badge>
</div>
<p className="flex items-center gap-2 font-medium text-muted-foreground">
<Building className="size-4" />
{profile.companyName}
</p>
</div>
</div>
))}
</Group>
) : (
<Group gap={6} c="edr-muted">
<Building size={16} />
<Text c="edr-muted" fw={500}>
{profile.companyType}
</Text>
</Group>
)}
</Stack>
</Group>
<Separator />
<Divider mb="lg" />
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* Left Column */}
<div className="flex flex-col gap-8 lg:col-span-2">
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
{/* Company Details */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="size-5 text-primary" />
Company Details
</CardTitle>
<CardDescription>Business registration information</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem icon={<Globe />} label="Location" value={profile.companyLocation} />
<InfoItem icon={<MapPin />} label="Address" value={profile.companyAddress} />
<InfoItem icon={<FileCheck />} label="TIN Number" value={profile.tinNumber} />
<InfoItem icon={<ShieldCheck />} label="FAN Number" value={profile.fanNumber} />
<InfoItem icon={<Mail />} label="Email" value={profile.companyEmail} />
<InfoItem icon={<Phone />} label="Phone" value={profile.companyPhone} />
</CardContent>
</Card>
<Grid gap="lg">
{/* Left Column */}
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
{/* Company Details */}
<Card>
<CardHeading
icon={
<Building2
size={20}
color="var(--mantine-color-edr-green-6)"
/>
}
title="Company Details"
description="Business registration information"
/>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<InfoItem
icon={<Globe size={16} />}
label="Location"
value={profile.companyLocation}
/>
<InfoItem
icon={<MapPin size={16} />}
label="Address"
value={profile.companyAddress}
/>
<InfoItem
icon={<FileCheck size={16} />}
label="TIN Number"
value={profile.tinNumber}
/>
<InfoItem
icon={<ShieldCheck size={16} />}
label="FAN Number"
value={profile.fanNumber}
/>
<InfoItem
icon={<Mail size={16} />}
label="Email"
value={profile.companyEmail}
/>
<InfoItem
icon={<Phone size={16} />}
label="Phone"
value={profile.companyPhone}
/>
</SimpleGrid>
</Card>
{/* Personal Details (from ExternalProfile) */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Fingerprint className="size-5 text-primary" />
Profile Details
</CardTitle>
<CardDescription>Your linked user profile</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem icon={<User />} label="Profile" value="Primary Contact" />
</CardContent>
</Card>
</div>
{/* Key Personnel */}
<Card>
<CardHeading
icon={
<Briefcase
size={20}
color="var(--mantine-color-edr-green-6)"
/>
}
title="Key Personnel"
description="Management and contact persons"
/>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
<PersonnelGroup color="edr-green" title="Contact Person">
<InfoItem label="Name" value={profile.contactPersonName} />
<InfoItem label="Phone" value={profile.contactPersonPhone} />
</PersonnelGroup>
<PersonnelGroup color="edr-accent" title="General Manager">
<InfoItem label="Name" value={profile.generalManagerName} />
<InfoItem label="Email" value={profile.generalManagerEmail} />
<InfoItem label="Phone" value={profile.generalManagerPhone} />
</PersonnelGroup>
</SimpleGrid>
</Card>
{/* Personnel Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Briefcase className="size-5 text-primary" />
Key Personnel
</CardTitle>
<CardDescription>Management and contact persons</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-8 md:grid-cols-2">
<div className="flex flex-col gap-4">
<h3 className="border-l-4 border-primary pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
Contact Person
</h3>
<div className="flex flex-col gap-3 pl-4">
<InfoItem label="Name" value={profile.contactPersonName} />
<InfoItem label="Phone" value={profile.contactPersonPhone} />
</div>
</div>
<div className="flex flex-col gap-4">
<h3 className="border-l-4 border-accent pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
General Manager
</h3>
<div className="flex flex-col gap-3 pl-4">
<InfoItem label="Name" value={profile.generalManagerName} />
<InfoItem label="Email" value={profile.generalManagerEmail} />
<InfoItem label="Phone" value={profile.generalManagerPhone} />
</div>
</div>
</CardContent>
{/* Power of Attorney */}
{profile.poaName && (
<Card style={{ borderStyle: "dashed" }}>
<CardHeading
icon={
<UserCheck
size={20}
color="var(--mantine-color-edr-accent-6)"
/>
}
title="Power of Attorney"
description="Authorized representative details"
/>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<InfoItem label="PoA Name" value={profile.poaName} />
<InfoItem label="PoA Email" value={profile.poaEmail} />
<InfoItem label="PoA Phone" value={profile.poaPhone} />
<InfoItem label="PoA Location" value={profile.poaLocation} />
</SimpleGrid>
</Card>
)}
</Stack>
</Grid.Col>
{/* Power of Attorney */}
{profile.poaName && (
<Card className="border-dashed">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserCheck className="size-5 text-accent" />
Power of Attorney
</CardTitle>
<CardDescription>Authorized representative details</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InfoItem label="PoA Name" value={profile.poaName} />
<InfoItem label="PoA Email" value={profile.poaEmail} />
<InfoItem label="PoA Phone" value={profile.poaPhone} />
<InfoItem label="PoA Location" value={profile.poaLocation} />
</CardContent>
</Card>
{/* Right Column */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Stack gap="lg">
{/* Operating Roles */}
<Card>
<CardHeading
icon={
<BadgeCheck
size={20}
color="var(--mantine-color-edr-green-6)"
/>
}
title="Operating Roles"
description="Your registered freight roles and reference numbers"
/>
{roleOptions.length === 0 ? (
<Text size="sm" c="edr-muted">
Role management for this company type is coming soon.
</Text>
) : (
<Stack gap="md">
{roleOptions.map((opt) => {
const active = refByType.get(opt.type);
return (
<Group
key={opt.type}
justify="space-between"
wrap="nowrap"
align="center"
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon
variant="light"
color="edr-green"
size="lg"
radius="md"
>
{opt.icon}
</ThemeIcon>
<Stack gap={2}>
<Text size="sm" fw={600} c="edr-text">
{opt.label}
</Text>
<Text
size="xs"
c="edr-muted"
ff={active ? "monospace" : undefined}
>
{active ? active.reference : "Not registered"}
</Text>
</Stack>
</Group>
{active ? (
<Badge
variant="light"
color={
active.status === "active"
? "edr-green"
: "edr-accent"
}
tt="capitalize"
>
{active.status}
</Badge>
) : (
<Button
component={Link}
to="/settings?tab=company"
size="xs"
variant="light"
color="edr-green"
leftSection={<Plus size={14} />}
>
Add {opt.label}
</Button>
)}
</Group>
);
})}
</Stack>
)}
</div>
</Card>
{/* Right Column */}
<div className="flex flex-col gap-8">
<Card className="relative overflow-hidden border-none bg-foreground text-background shadow-xl">
<div className="absolute right-0 top-0 p-4 opacity-10">
<ShieldCheck className="size-32" />
</div>
<CardContent className="relative z-10 flex flex-col gap-4 px-6 py-8">
<h3 className="text-xl font-black">Secure Account</h3>
<p className="text-sm leading-relaxed text-muted-foreground/80">
Your information is protected by enterprise-grade security.
Contact support for verified information updates.
</p>
<div className="pt-2">
<a
href="/settings"
className="inline-flex h-9 items-center justify-center rounded-md bg-background px-4 text-sm font-medium text-foreground hover:bg-background/90"
>
Edit Settings
</a>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
</div>
{/* Secure Account */}
<Card
padding="xl"
style={{
background: "var(--mantine-color-edr-ink-6)",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
top: 16,
right: 16,
opacity: 0.1,
}}
>
<ShieldCheck size={128} color="white" />
</Box>
<Stack gap="md" style={{ position: "relative", zIndex: 1 }}>
<Title order={3} size="h4" c="white">
Secure Account
</Title>
<Text size="sm" c="gray.4">
Your information is protected by enterprise-grade security.
Contact support for verified information updates.
</Text>
<Button
component={Link}
to="/settings"
variant="white"
color="dark"
mt="xs"
w="fit-content"
>
Edit Settings
</Button>
</Stack>
</Card>
</Stack>
</Grid.Col>
</Grid>
</Container>
);
}

View File

@@ -0,0 +1,143 @@
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
import {
Button,
Card,
Group,
SimpleGrid,
Text,
Title,
} from "@mantine/core";
import { api } from "@/services/api";
import type { ProfileResponse } from "@/types/profile";
import RoleCard from "./RoleCard";
import { rolesForCompanyType } from "./companyRoles";
interface CompanyRolesCardProps {
profile: ProfileResponse;
}
export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
const queryClient = useQueryClient();
const options = useMemo(
() => rolesForCompanyType(profile.companyType),
[profile.companyType],
);
// Roles already persisted (active + locked), keyed by type -> reference.
const activeByType = useMemo(() => {
const map = new Map<string, string>();
for (const p of profile.companyProfiles) map.set(p.type, p.reference);
return map;
}, [profile.companyProfiles]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const toggle = (type: string) => {
if (activeByType.has(type)) return; // add-only: active roles are locked
setSelected((prev) => {
const next = new Set(prev);
if (next.has(type)) next.delete(type);
else next.add(type);
return next;
});
};
const mutation = useMutation({
mutationFn: (types: string[]) =>
api.companies.addCompanyProfiles.call({ types }),
onSuccess: () => {
setSelected(new Set());
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
});
},
});
const handleSave = () => {
if (selected.size === 0) return;
mutation.mutate(Array.from(selected));
};
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Building2 size={20} />
<Title order={3}>Business Profile</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
{profile.companyType === "customer"
? "Select the role(s) your company operates as — importer, exporter, or both."
: "Your company's operational role."}
</Text>
{options.length === 0 ? (
<Text size="sm" c="edr-muted">
Role management for this company type is coming soon.
</Text>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{options.map((opt) => {
const isActive = activeByType.has(opt.type);
return (
<RoleCard
key={opt.type}
label={opt.label}
description={opt.description}
icon={opt.icon}
selected={selected.has(opt.type)}
locked={isActive}
lockedNote={
isActive ? `Active · ${activeByType.get(opt.type)}` : undefined
}
onClick={() => toggle(opt.type)}
/>
);
})}
</SimpleGrid>
)}
{options.length > 0 && (
<Group
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{mutation.isSuccess && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
Profile updated
</Text>
</Group>
)}
{mutation.isError && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
Failed to update profile
</Text>
</Group>
)}
</Group>
<Button
type="button"
leftSection={<Save size={16} />}
loading={mutation.isPending}
disabled={selected.size === 0}
onClick={handleSave}
>
{selected.size > 1 ? "Add Roles" : "Add Role"}
</Button>
</Group>
)}
</Card>
);
}

View File

@@ -0,0 +1,77 @@
import { Card, Divider, Group, SimpleGrid, Text, Title } from "@mantine/core";
import { Building2 } from "lucide-react";
import RoleCard from "./RoleCard";
import { CUSTOMER_ROLES, FREIGHT_FORWARDER } from "./companyRoles";
interface OnboardingRoleSelectProps {
/** Currently selected profile types (e.g. ["importer"], ["importer","exporter"], ["freight_forwarder"]). */
value: string[];
onChange: (next: string[]) => void;
}
/**
* First (and only) thing shown in the Company Profile tab during onboarding.
* Importer / Exporter sit side by side and can both be picked; Freight
* Forwarder is a separate, mutually-exclusive choice below them. A valid
* selection reveals the company-profile fields.
*/
export default function OnboardingRoleSelect({
value,
onChange,
}: OnboardingRoleSelectProps) {
const selected = new Set(value);
const isForwarder = selected.has(FREIGHT_FORWARDER.type);
// Toggling a customer role drops any forwarder selection (mutually exclusive).
const toggleCustomerRole = (type: string) => {
const next = new Set(value.filter((t) => t !== FREIGHT_FORWARDER.type));
if (next.has(type)) next.delete(type);
else next.add(type);
onChange([...next]);
};
const toggleForwarder = () => {
onChange(isForwarder ? [] : [FREIGHT_FORWARDER.type]);
};
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Building2 size={20} />
<Title order={3}>What does your company do?</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
Pick Importer, Exporter, or both or register as a Freight Forwarder.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{CUSTOMER_ROLES.map((role) => (
<RoleCard
key={role.type}
label={role.label}
description={role.description}
icon={role.icon}
selected={selected.has(role.type)}
onClick={() => toggleCustomerRole(role.type)}
/>
))}
</SimpleGrid>
<Divider
label="or"
labelPosition="center"
my="lg"
c="edr-muted"
styles={{ label: { textTransform: "uppercase", fontSize: 11 } }}
/>
<RoleCard
label={FREIGHT_FORWARDER.label}
description={FREIGHT_FORWARDER.description}
icon={FREIGHT_FORWARDER.icon}
selected={isForwarder}
onClick={toggleForwarder}
/>
</Card>
);
}

View File

@@ -0,0 +1,75 @@
import { Box, Group, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
import { Check } from "lucide-react";
export interface RoleCardProps {
label: string;
description: string;
icon: React.ReactNode;
/** Highlighted because the user just selected it (toggleable). */
selected?: boolean;
/** Highlighted and non-interactive because it is already persisted. */
locked?: boolean;
/** Small note under the description, e.g. "Active · IM-00001". */
lockedNote?: string;
onClick?: () => void;
}
/**
* The selectable company-role card used by both the onboarding role picker and
* the add-only roles card in settings. Visual mirror of the onboarding
* account-type cards.
*/
export default function RoleCard({
label,
description,
icon,
selected = false,
locked = false,
lockedNote,
onClick,
}: RoleCardProps) {
const highlighted = selected || locked;
return (
<UnstyledButton
type="button"
onClick={locked ? undefined : onClick}
className={`group block rounded-lg border! p-5! text-left transition-all duration-200 ${
highlighted
? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!"
: "border-edr-border! bg-edr-card! hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft"
} ${locked ? "cursor-default" : ""}`}
>
<Group gap="md" wrap="nowrap" align="start">
<ThemeIcon
size={56}
radius="lg"
variant={highlighted ? "filled" : "light"}
color="edr-green"
className="shrink-0"
>
{icon}
</ThemeIcon>
<Box className="min-w-0 flex-1">
<Text fw={700} c="edr-text" fz={15}>
{label}
</Text>
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
{description}
</Text>
{lockedNote && (
<Text size="xs" c="edr-green" mt={6} fw={600}>
{lockedNote}
</Text>
)}
</Box>
{highlighted && (
<Check
size={18}
className="shrink-0 text-[var(--mantine-color-edr-green-6)]"
/>
)}
</Group>
</UnstyledButton>
);
}

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -17,7 +17,12 @@ import {
import { api } from "@/services/api";
import PhoneInput from "@/components/auth/PhoneInput";
import type { ProfileResponse } from "@/types/profile";
import type { CreateCompanyPayload } from "@/services/companies.service";
import type {
CreateCompanyPayload,
CompanyProfileInput,
} from "@/services/companies.service";
import CompanyRolesCard from "./CompanyRolesCard";
import OnboardingRoleSelect from "./OnboardingRoleSelect";
export const COMPANY_PROFILE_SCHEMA = z.object({
companyName: z.string().min(1, "Company name is required"),
@@ -52,6 +57,7 @@ export default function TabCompanyProfile({
}: TabCompanyProfileProps) {
const queryClient = useQueryClient();
const isCreate = mode === "create";
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
const defaultValues = useMemo((): CompanyProfileFormData => {
if (profile) {
@@ -91,8 +97,7 @@ export default function TabCompanyProfile({
const mutation = useMutation({
mutationFn: async (data: CompanyProfileFormData) => {
const payload: CreateCompanyPayload = {
companyType: "customer",
const base = {
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
@@ -103,10 +108,21 @@ export default function TabCompanyProfile({
};
if (isCreate) {
// Importer/Exporter -> a "customer" company; Freight Forwarder is its
// own company type. Both drive the persisted CompanyProfile rows.
const companyType = selectedRoles.includes("freight_forwarder")
? "freight_forwarder"
: "customer";
const payload: CreateCompanyPayload = {
...base,
companyType,
companyProfiles: selectedRoles.map((type) => ({
type: type as CompanyProfileInput["type"],
})),
};
return api.companies.create.call(payload);
} else {
return api.companies.updateProfile.call(payload);
}
return api.companies.updateProfile.call(base);
},
onSuccess: () => {
queryClient.invalidateQueries({
@@ -118,14 +134,31 @@ export default function TabCompanyProfile({
},
});
const onSubmit = (data: CompanyProfileFormData) => mutation.mutate(data);
const onSubmit = (data: CompanyProfileFormData) => {
if (isCreate && selectedRoles.length === 0) return;
mutation.mutate(data);
};
// During onboarding the role selection gates the form: nothing else shows
// until the user picks Importer/Exporter or Freight Forwarder.
const showForm = !isCreate || selectedRoles.length > 0;
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Building2 size={20} />
<Title order={3}>Company Profile</Title>
</Group>
<Stack gap="lg">
{isCreate ? (
<OnboardingRoleSelect
value={selectedRoles}
onChange={setSelectedRoles}
/>
) : (
profile && <CompanyRolesCard profile={profile} />
)}
{showForm && (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Building2 size={20} />
<Title order={3}>Company Profile</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
{isCreate
? "Enter your company registration details to get started"
@@ -251,6 +284,8 @@ export default function TabCompanyProfile({
</Group>
</Group>
</form>
</Card>
</Card>
)}
</Stack>
);
}

View File

@@ -1,4 +1,16 @@
import { useState } from "react";
import { api } from "@/services/api";
import { companiesService } from "@/services/companies.service";
import { getMinFiles } from "@/types/fileUploadSettings";
import type { ProfileResponse } from "@/types/profile";
import { SmartFileInput } from "@edr/ui-common";
import {
Button,
Card,
Center,
Group,
Text,
Title,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
@@ -8,18 +20,7 @@ import {
UploadCloud,
XCircle,
} from "lucide-react";
import {
Card,
Group,
Title,
Text,
Button,
Center,
} from "@mantine/core";
import { api } from "@/services/api";
import { companiesService } from "@/services/companies.service";
import { SmartFileInput } from "@edr/ui-common";
import type { ProfileResponse } from "@/types/profile";
import { useState } from "react";
interface TabDocumentsProps {
profile: ProfileResponse;
@@ -45,6 +46,42 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
},
});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const handleFilesChange = (next: Record<string, File | File[] | null>) => {
setDocumentFiles(next);
// Clear required-field errors for any field that now has a file.
setFieldErrors((prev) => {
if (Object.keys(prev).length === 0) return prev;
const updated = { ...prev };
for (const key of Object.keys(updated)) {
const v = next[key];
const hasValue = Array.isArray(v) ? v.length > 0 : v != null;
if (hasValue) delete updated[key];
}
return updated;
});
};
// Array-aware: an emptied multi-file field is `[]`, which must not count.
const hasFiles = Object.values(documentFiles).some((f) =>
Array.isArray(f) ? f.length > 0 : f != null,
);
const validateRequired = (): Record<string, string> => {
const errs: Record<string, string> = {};
for (const field of docSettingQuery.data?.fields ?? []) {
const min = getMinFiles(field);
if (min <= 0) continue;
const v = documentFiles[field.fileKey];
const count = Array.isArray(v) ? v.length : v ? 1 : 0;
if (count < min) {
errs[field.fileKey] = `${field.fileLabel} is required`;
}
}
return errs;
};
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
@@ -67,7 +104,8 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
<SmartFileInput
file={docSettingQuery.data}
value={documentFiles}
onChange={setDocumentFiles}
onChange={handleFilesChange}
errors={fieldErrors}
/>
)}
@@ -100,13 +138,14 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
leftSection={<ArrowRight size={16} />}
loading={docUploadMutation.isPending}
onClick={() => {
const hasFiles = Object.values(documentFiles).some((f) => f !== null);
const validationErrors = validateRequired();
if (Object.keys(validationErrors).length > 0) {
setFieldErrors(validationErrors);
return;
}
if (hasFiles) {
docUploadMutation.mutate(documentFiles, {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
onContinue?.();
},
onSuccess: () => onContinue?.(),
});
} else {
onContinue?.();
@@ -120,7 +159,11 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
type="button"
leftSection={<UploadCloud size={16} />}
loading={docUploadMutation.isPending}
onClick={() => docUploadMutation.mutate(documentFiles)}
disabled={!hasFiles}
onClick={() => {
if (!hasFiles) return;
docUploadMutation.mutate(documentFiles);
}}
>
Upload Documents
</Button>

View File

@@ -0,0 +1,39 @@
import { ArrowDownToLine, ArrowUpFromLine, Building2 } from "lucide-react";
export interface RoleMeta {
type: string;
label: string;
description: string;
icon: React.ReactNode;
}
export const IMPORTER: RoleMeta = {
type: "importer",
label: "Importer",
description: "Import goods into Ethiopia via the railway corridor.",
icon: <ArrowDownToLine size={22} />,
};
export const EXPORTER: RoleMeta = {
type: "exporter",
label: "Exporter",
description: "Export goods from Ethiopia via rail.",
icon: <ArrowUpFromLine size={22} />,
};
export const FREIGHT_FORWARDER: RoleMeta = {
type: "freight_forwarder",
label: "Freight Forwarder",
description: "Handle cargo on behalf of importers and exporters.",
icon: <Building2 size={22} />,
};
/** Importer / Exporter — the two roles a "customer" company can hold. */
export const CUSTOMER_ROLES: RoleMeta[] = [IMPORTER, EXPORTER];
// dj_freight_forwarder and transporter are intentionally not exposed yet.
export function rolesForCompanyType(companyType: string): RoleMeta[] {
if (companyType === "customer") return CUSTOMER_ROLES;
if (companyType === "freight_forwarder") return [FREIGHT_FORWARDER];
return [];
}

View File

@@ -38,6 +38,7 @@ import {
} from "@/types/dropdownSettings";
import type {
CompanyInfoResponse,
CompanyProfileResponse,
CreateCompanyPayload,
DashboardSummary,
} from "./companies.service";
@@ -128,14 +129,19 @@ export const api = {
"getDashboard",
companiesService.getDashboard,
),
addCompanyProfiles: endpoint<{ types: string[] }, CompanyProfileResponse[]>(
"companies",
"addCompanyProfiles",
companiesService.addCompanyProfiles,
),
},
bookings: {
list: endpoint<BookingListFilter | void, PaginatedResponse<Freight.IBooking>>(
"bookings",
"list",
bookingsService.list,
),
list: endpoint<
BookingListFilter | void,
PaginatedResponse<Freight.IBooking>
>("bookings", "list", bookingsService.list),
get: endpoint<{ id: string }, Freight.IBooking>(
"bookings",
@@ -217,8 +223,14 @@ export const api = {
getBookableSchedules: endpoint<
{ originYardId?: string; destinationYardId?: string },
Freight.BookableScheduleItem[]
>("train-scheduling", "bookableSchedules", ({ originYardId, destinationYardId }) =>
bookingsService.getBookableSchedules({ originYardId, destinationYardId }),
>(
"train-scheduling",
"bookableSchedules",
({ originYardId, destinationYardId }) =>
bookingsService.getBookableSchedules({
originYardId,
destinationYardId,
}),
),
getAvailableDays: endpoint<
@@ -291,19 +303,6 @@ export const api = {
({ entity }) => fileUploadSettingsService.getByEntity(entity),
),
create: endpoint<CreateFileUploadSettingDto, FileUploadSetting>(
"file-upload-settings",
"create",
(payload) => fileUploadSettingsService.create(payload),
),
update: endpoint<
{ id: string; dto: UpdateFileUploadSettingDto },
FileUploadSetting
>("file-upload-settings", "update", ({ id, dto }) =>
fileUploadSettingsService.update(id, dto),
),
remove: endpoint<{ id: string }, void>(
"file-upload-settings",
"remove",

View File

@@ -35,6 +35,18 @@ export interface CompanyResponse {
email: string | null;
website: string | null;
attributes: Record<string, any> | null;
companyProfiles?: CompanyProfileResponse[];
createdAt: string;
updatedAt: string;
}
export interface CompanyProfileResponse {
id: string;
type: string;
reference: string;
status: string;
businessLicense: string | null;
attributes: Record<string, any> | null;
createdAt: string;
updatedAt: string;
}
@@ -44,6 +56,11 @@ export interface CompanyInfoResponse {
company: CompanyResponse;
}
export interface CompanyProfileInput {
type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter";
businessLicense?: string;
}
export interface CreateCompanyPayload {
companyType?: string;
companyName: string;
@@ -57,6 +74,7 @@ export interface CreateCompanyPayload {
jobTitle?: string;
isPrimaryContact?: boolean;
attributes?: Record<string, any>;
companyProfiles?: CompanyProfileInput[];
}
export interface FreightVolumePoint {
@@ -124,6 +142,16 @@ export const companiesService = {
return unwrap(response.data);
},
addCompanyProfiles: async (payload: {
types: string[];
}): Promise<CompanyProfileResponse[]> => {
const response = await client.post<ApiResponse<CompanyProfileResponse[]>>(
URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILES,
payload,
);
return unwrap(response.data);
},
uploadDocuments: async (
companyId: string,
files: Record<string, File | File[] | null>,

View File

@@ -1,6 +1,10 @@
import type { CompanyProfileResponse } from "@/services/companies.service";
export interface ProfileResponse {
companyId: string;
companyName: string;
companyType: string;
companyProfiles: CompanyProfileResponse[];
companyEmail: string | null;
companyPhone: string | null;
companyLocation: string;

6
apps/edr-landing/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.