mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'freight/develop' of github.com:Tria-plc/edr-platform into freight_feature/priority
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -25,11 +25,6 @@ export class CreateCompanyDto {
|
||||
@MaxLength(50)
|
||||
vatNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
businessLicense?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateFFClientDto } from './create-ff-client.dto';
|
||||
|
||||
export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 { }
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
75
apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx
Normal file
75
apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
6
apps/edr-landing/next-env.d.ts
vendored
Normal 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.
|
||||
Reference in New Issue
Block a user