mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +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 { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module";
|
||||||
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
|
import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module";
|
||||||
import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.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 { CompaniesModule } from "./modules/companies/companies.module";
|
||||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||||
import { BillingModule } from "./modules/billing/billing.module";
|
import { BillingModule } from "./modules/billing/billing.module";
|
||||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-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 { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||||
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
import { BackofficeModule } from "./modules/backoffice/backoffice.module";
|
||||||
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
|
import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module";
|
||||||
@@ -97,7 +96,6 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
|||||||
TrainSchedulesModule,
|
TrainSchedulesModule,
|
||||||
TrainSchedulingModule,
|
TrainSchedulingModule,
|
||||||
SchedulingRescheduleModule,
|
SchedulingRescheduleModule,
|
||||||
CustomersModule,
|
|
||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
TrackingModule,
|
TrackingModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
|
|||||||
@@ -114,7 +114,9 @@ export class ContractViewModelBuilder {
|
|||||||
tinNumber: this.valueOrDash(booking.company?.tin),
|
tinNumber: this.valueOrDash(booking.company?.tin),
|
||||||
vatNumber: this.valueOrDash(booking.company?.vatNumber),
|
vatNumber: this.valueOrDash(booking.company?.vatNumber),
|
||||||
fanNumber: this.valueOrDash(booking.company?.fanNumber),
|
fanNumber: this.valueOrDash(booking.company?.fanNumber),
|
||||||
businessLicense: this.valueOrDash(booking.company?.businessLicense),
|
businessLicense: this.valueOrDash(
|
||||||
|
booking.company?.companyProfiles?.[0]?.businessLicense,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
provider: {
|
provider: {
|
||||||
name: 'Ethio-Djibouti Standard Gauge Railway Share Company',
|
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 {
|
||||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
Controller,
|
||||||
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
Get,
|
||||||
import { CurrentUser } from '@edr/api-common';
|
Post,
|
||||||
import { FreightAdmin } from '../../common/booking-guards';
|
Patch,
|
||||||
import { FilesService } from '../files/files.service';
|
Delete,
|
||||||
import { CompaniesService } from './companies.service';
|
Body,
|
||||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
Param,
|
||||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
Query,
|
||||||
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
ParseUUIDPipe,
|
||||||
import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
HttpCode,
|
||||||
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
HttpStatus,
|
||||||
import { ResponseCompanyDto } from './dto/response-company.dto';
|
UseInterceptors,
|
||||||
import { ResponseExternalProfileDto } from './dto/response-external-profile.dto';
|
UploadedFiles,
|
||||||
import { ResponseFFClientDto } from './dto/response-ff-client.dto';
|
} from "@nestjs/common";
|
||||||
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
|
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger";
|
||||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
import { CurrentUser } from "@edr/api-common";
|
||||||
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
|
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 {
|
interface CurrentIamUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,36 +41,47 @@ interface CurrentIamUser {
|
|||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiTags('Companies')
|
@ApiTags("Companies")
|
||||||
@Controller('companies')
|
@Controller("companies")
|
||||||
export class CompaniesController {
|
export class CompaniesController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly companiesService: CompaniesService,
|
private readonly companiesService: CompaniesService,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
@Get('getInfo')
|
@Get("getInfo")
|
||||||
@ApiOperation({ summary: 'Get company info for the current user' })
|
@ApiOperation({ summary: "Get company info for the current user" })
|
||||||
async getInfo(@CurrentUser() user: CurrentIamUser): Promise<CompanyInfoResponseDto> {
|
async getInfo(
|
||||||
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
): Promise<CompanyInfoResponseDto> {
|
||||||
|
const { profile, company } =
|
||||||
|
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||||
return new CompanyInfoResponseDto(profile, company);
|
return new CompanyInfoResponseDto(profile, company);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('profile')
|
@Get("profile")
|
||||||
@ApiOperation({ summary: 'Get flattened profile for the settings page' })
|
@ApiOperation({ summary: "Get flattened profile for the settings page" })
|
||||||
async getProfile(@CurrentUser() user: CurrentIamUser): Promise<ProfileResponseDto> {
|
async getProfile(
|
||||||
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
): Promise<ProfileResponseDto> {
|
||||||
|
const { profile, company } =
|
||||||
|
await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||||
return new ProfileResponseDto(profile, company);
|
return new ProfileResponseDto(profile, company);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('dashboard')
|
@Get("dashboard")
|
||||||
@ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' })
|
@ApiOperation({
|
||||||
async getDashboard(@CurrentUser() user: CurrentIamUser): Promise<DashboardSummaryResponseDto> {
|
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);
|
return this.companiesService.getDashboardSummary(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('profile')
|
@Patch("profile")
|
||||||
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
|
@ApiOperation({ summary: "Update profile (flattened settings page)" })
|
||||||
async updateProfile(
|
async updateProfile(
|
||||||
@CurrentUser() user: CurrentIamUser,
|
@CurrentUser() user: CurrentIamUser,
|
||||||
@Body() dto: UpdateProfileDto,
|
@Body() dto: UpdateProfileDto,
|
||||||
@@ -62,145 +89,153 @@ export class CompaniesController {
|
|||||||
return this.companiesService.updateProfile(user.id, dto);
|
return this.companiesService.updateProfile(user.id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('create')
|
@Post("company-profiles")
|
||||||
@ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
|
@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(
|
async createWithProfile(
|
||||||
@CurrentUser() user: CurrentIamUser,
|
@CurrentUser() user: CurrentIamUser,
|
||||||
@Body() dto: CreateCompanyWithProfileDto,
|
@Body() dto: CreateCompanyWithProfileDto,
|
||||||
): Promise<CompanyInfoResponseDto> {
|
): Promise<CompanyInfoResponseDto> {
|
||||||
const nameParts = (user.name?.en ?? '').split(' ');
|
const nameParts = (user.name?.en ?? "").split(" ");
|
||||||
const { profile, company } = await this.companiesService.createCompanyWithProfile(
|
const { profile, company } =
|
||||||
{
|
await this.companiesService.createCompanyWithProfile(
|
||||||
userId: user.id,
|
{
|
||||||
firstName: nameParts[0] || '',
|
userId: user.id,
|
||||||
lastName: nameParts.slice(-1)[0] || '',
|
firstName: nameParts[0] || "",
|
||||||
email: user.email ?? '',
|
lastName: nameParts.slice(-1)[0] || "",
|
||||||
phone: user.phoneNumber ?? '',
|
email: user.email ?? "",
|
||||||
},
|
phone: user.phoneNumber ?? "",
|
||||||
dto,
|
},
|
||||||
);
|
dto,
|
||||||
|
);
|
||||||
return new CompanyInfoResponseDto(profile, company);
|
return new CompanyInfoResponseDto(profile, company);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Used by backoffice
|
||||||
@Post()
|
@Post()
|
||||||
@FreightAdmin()
|
@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> {
|
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
|
||||||
const company = await this.companiesService.createCompany(dto);
|
const company = await this.companiesService.createCompany(dto);
|
||||||
return new ResponseCompanyDto(company);
|
return new ResponseCompanyDto(company);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List all companies' })
|
@ApiOperation({ summary: "List all companies" })
|
||||||
async findAll(): Promise<ResponseCompanyDto[]> {
|
async findAll(): Promise<ResponseCompanyDto[]> {
|
||||||
const companies = await this.companiesService.findAllCompanies();
|
const companies = await this.companiesService.findAllCompanies();
|
||||||
return companies.map((c) => new ResponseCompanyDto(c));
|
return companies.map((c) => new ResponseCompanyDto(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('type/:type')
|
@Get("type/:type")
|
||||||
@ApiOperation({ summary: 'Find companies by type' })
|
@ApiOperation({ summary: "Find companies by type" })
|
||||||
async findByType(@Param('type') type: string): Promise<ResponseCompanyDto[]> {
|
async findByType(@Param("type") type: string): Promise<ResponseCompanyDto[]> {
|
||||||
const companies = await this.companiesService.findAllCompanies();
|
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')
|
@Get("search")
|
||||||
@ApiOperation({ summary: 'Search companies by name' })
|
@ApiOperation({ summary: "Search companies by name" })
|
||||||
async search(@Query('name') name: string): Promise<ResponseCompanyDto[]> {
|
async search(@Query("name") name: string): Promise<ResponseCompanyDto[]> {
|
||||||
const companies = await this.companiesService.findAllCompanies();
|
const companies = await this.companiesService.findAllCompanies();
|
||||||
return companies
|
return companies
|
||||||
.filter((c) => c.name.toLowerCase().includes(name.toLowerCase()))
|
.filter((c) => c.name.toLowerCase().includes(name.toLowerCase()))
|
||||||
.map((c) => new ResponseCompanyDto(c));
|
.map((c) => new ResponseCompanyDto(c));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(":id")
|
||||||
@ApiOperation({ summary: 'Get company by ID' })
|
@ApiOperation({ summary: "Get company by ID" })
|
||||||
async findById(@Param('id', ParseUUIDPipe) id: string): Promise<ResponseCompanyDto> {
|
async findById(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
): Promise<ResponseCompanyDto> {
|
||||||
const company = await this.companiesService.findCompanyById(id);
|
const company = await this.companiesService.findCompanyById(id);
|
||||||
return new ResponseCompanyDto(company);
|
return new ResponseCompanyDto(company);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(":id")
|
||||||
@FreightAdmin()
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: 'Update a company' })
|
@ApiOperation({ summary: "Update a company" })
|
||||||
async update(
|
async update(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: UpdateCompanyDto,
|
@Body() dto: UpdateCompanyDto,
|
||||||
): Promise<ResponseCompanyDto> {
|
): Promise<ResponseCompanyDto> {
|
||||||
const company = await this.companiesService.updateCompany(id, dto);
|
const company = await this.companiesService.updateCompany(id, dto);
|
||||||
return new ResponseCompanyDto(company);
|
return new ResponseCompanyDto(company);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(":id")
|
||||||
@FreightAdmin()
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: 'Soft-delete a company' })
|
@ApiOperation({ summary: "Soft-delete a company" })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@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);
|
await this.companiesService.deleteCompany(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':companyId/documents')
|
@Post(":companyId/documents")
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiOperation({ summary: 'Upload documents for a company (onboarding)' })
|
@ApiOperation({ summary: "Upload documents for a company (onboarding)" })
|
||||||
async uploadDocuments(
|
async uploadDocuments(
|
||||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
@UploadedFiles() files: Array<Express.Multer.File>,
|
@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()
|
@FreightAdmin()
|
||||||
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
|
@ApiOperation({ summary: "Add a profile (employee) to a company" })
|
||||||
async createProfile(
|
async createProfile(
|
||||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
@Body() dto: CreateExternalProfileDto,
|
@Body() dto: CreateExternalProfileDto,
|
||||||
): Promise<ResponseExternalProfileDto> {
|
): Promise<ResponseExternalProfileDto> {
|
||||||
const profile = await this.companiesService.createProfile({ ...dto, companyId });
|
const profile = await this.companiesService.createProfile({
|
||||||
|
...dto,
|
||||||
|
companyId,
|
||||||
|
});
|
||||||
return new ResponseExternalProfileDto(profile);
|
return new ResponseExternalProfileDto(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':companyId/profiles')
|
@Get(":companyId/profiles")
|
||||||
@ApiOperation({ summary: 'List profiles for a company' })
|
@ApiOperation({ summary: "List profiles for a company" })
|
||||||
async listProfiles(
|
async listProfiles(
|
||||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
): Promise<ResponseExternalProfileDto[]> {
|
): Promise<ResponseExternalProfileDto[]> {
|
||||||
const profiles = await this.companiesService.findProfilesByCompany(companyId);
|
const profiles =
|
||||||
|
await this.companiesService.findProfilesByCompany(companyId);
|
||||||
return profiles.map((p) => new ResponseExternalProfileDto(p));
|
return profiles.map((p) => new ResponseExternalProfileDto(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('profile/user/:userId')
|
@Get("profile/user/:userId")
|
||||||
@ApiOperation({ summary: 'Get profile by IAM user ID' })
|
@ApiOperation({ summary: "Get profile by IAM user ID" })
|
||||||
async findProfileByUser(
|
async findProfileByUser(
|
||||||
@Param('userId', ParseUUIDPipe) userId: string,
|
@Param("userId", ParseUUIDPipe) userId: string,
|
||||||
): Promise<ResponseExternalProfileDto> {
|
): Promise<ResponseExternalProfileDto> {
|
||||||
const profile = await this.companiesService.findProfileByUserId(userId);
|
const profile = await this.companiesService.findProfileByUserId(userId);
|
||||||
return new ResponseExternalProfileDto(profile);
|
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 { Module } from "@nestjs/common";
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from "../files/files.module";
|
||||||
import { CompaniesController } from './companies.controller';
|
import { CompaniesController } from "./companies.controller";
|
||||||
import { CompaniesService } from './companies.service';
|
import { CompaniesService } from "./companies.service";
|
||||||
import { CompaniesRepository } from './companies.repository';
|
import { CompaniesRepository } from "./companies.repository";
|
||||||
import { ExternalProfileRepository } from './external-profile.repository';
|
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||||
import { FFClientRepository } from './ff-client.repository';
|
import { CompanyDashboardRepository } from "./company-dashboard.repository";
|
||||||
import { CompanyDashboardRepository } from './company-dashboard.repository';
|
import { Company } from "./entities/company.entity";
|
||||||
import { Company } from './entities/company.entity';
|
import { ExternalProfile } from "./entities/external-profile.entity";
|
||||||
import { ExternalProfile } from './entities/external-profile.entity';
|
import { CompanyProfile } from "./entities/company-profile.entity";
|
||||||
import { FFClient } from './entities/ff-client.entity';
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
|
||||||
|
FilesModule,
|
||||||
|
],
|
||||||
controllers: [CompaniesController],
|
controllers: [CompaniesController],
|
||||||
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository],
|
providers: [
|
||||||
|
CompaniesService,
|
||||||
|
CompaniesRepository,
|
||||||
|
ExternalProfileRepository,
|
||||||
|
CompanyProfileRepository,
|
||||||
|
CompanyDashboardRepository,
|
||||||
|
],
|
||||||
exports: [CompaniesService],
|
exports: [CompaniesService],
|
||||||
})
|
})
|
||||||
export class CompaniesModule {}
|
export class CompaniesModule { }
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
import {
|
||||||
import { CompaniesRepository } from './companies.repository';
|
Injectable,
|
||||||
import { ExternalProfileRepository } from './external-profile.repository';
|
NotFoundException,
|
||||||
import { FFClientRepository } from './ff-client.repository';
|
ConflictException,
|
||||||
import { CompanyDashboardRepository } from './company-dashboard.repository';
|
BadRequestException,
|
||||||
import { CreateCompanyDto } from './dto/create-company.dto';
|
} from "@nestjs/common";
|
||||||
import { UpdateCompanyDto } from './dto/update-company.dto';
|
import { CompaniesRepository } from "./companies.repository";
|
||||||
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||||
import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||||
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
import { CompanyDashboardRepository } from "./company-dashboard.repository";
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||||
import { ProfileResponseDto } from './dto/profile-response.dto';
|
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||||
import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto';
|
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||||
import { Company } from './entities/company.entity';
|
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||||
import { ExternalProfile } from './entities/external-profile.entity';
|
import { UpdateProfileDto } from "./dto/update-profile.dto";
|
||||||
import { FFClient } from './entities/ff-client.entity';
|
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 {
|
export interface UserIdentity {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -27,10 +35,10 @@ export interface UserIdentity {
|
|||||||
export class CompaniesService {
|
export class CompaniesService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly companiesRepo: CompaniesRepository,
|
private readonly companiesRepo: CompaniesRepository,
|
||||||
|
private readonly companyProfilesRepo: CompanyProfileRepository,
|
||||||
private readonly profilesRepo: ExternalProfileRepository,
|
private readonly profilesRepo: ExternalProfileRepository,
|
||||||
private readonly ffClientsRepo: FFClientRepository,
|
|
||||||
private readonly dashboardRepo: CompanyDashboardRepository,
|
private readonly dashboardRepo: CompanyDashboardRepository,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||||
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||||
@@ -40,27 +48,33 @@ export class CompaniesService {
|
|||||||
return this.companiesRepo.create(dto);
|
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) {
|
if (dto.tin) {
|
||||||
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||||
if (exists) {
|
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);
|
const existingProfile = await this.profilesRepo.findByEmail(identity.email);
|
||||||
if (existingProfile) {
|
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({
|
const company = await this.companiesRepo.create({
|
||||||
name: dto.companyName,
|
name: dto.companyName,
|
||||||
type: dto.companyType,
|
type: dto.companyType,
|
||||||
tin: dto.tin ?? '',
|
tin: dto.tin ?? "",
|
||||||
vatNumber: dto.vatNumber ?? null,
|
vatNumber: dto.vatNumber ?? null,
|
||||||
businessLicense: dto.fanNumber ?? null,
|
|
||||||
fanNumber: dto.fanNumber ?? null,
|
fanNumber: dto.fanNumber ?? null,
|
||||||
country: dto.companyLocation ?? 'Ethiopia',
|
country: dto.companyLocation ?? "Ethiopia",
|
||||||
address: dto.companyAddress ?? null,
|
address: dto.companyAddress ?? null,
|
||||||
phone: dto.companyPhone ?? null,
|
phone: dto.companyPhone ?? null,
|
||||||
email: dto.companyEmail ?? null,
|
email: dto.companyEmail ?? null,
|
||||||
@@ -78,11 +92,39 @@ export class CompaniesService {
|
|||||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
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 };
|
return { company, profile };
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAllCompanies(): Promise<Company[]> {
|
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> {
|
async findCompanyById(id: string): Promise<Company> {
|
||||||
@@ -91,12 +133,21 @@ export class CompaniesService {
|
|||||||
return company;
|
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);
|
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;
|
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 };
|
return { profile, company };
|
||||||
}
|
}
|
||||||
@@ -114,7 +165,9 @@ export class CompaniesService {
|
|||||||
* column, so "delivered YTD" counts bookings created this year that reached a
|
* column, so "delivered YTD" counts bookings created this year that reached a
|
||||||
* delivered/completed status.
|
* 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
|
// A user without a company profile has no bookings — return an empty summary
|
||||||
// rather than 404, so the portal home still renders.
|
// rather than 404, so the portal home still renders.
|
||||||
const profile = await this.profilesRepo.findByUserId(userId);
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
@@ -125,7 +178,9 @@ export class CompaniesService {
|
|||||||
const yearStart = new Date(now.getFullYear(), 0, 1);
|
const yearStart = new Date(now.getFullYear(), 0, 1);
|
||||||
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
|
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
|
||||||
// Same point in the previous year, so YoY compares like-for-like windows.
|
// 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 [
|
const [
|
||||||
deliveredThis,
|
deliveredThis,
|
||||||
@@ -139,20 +194,36 @@ export class CompaniesService {
|
|||||||
this.dashboardRepo.countDelivered(companyId, yearStart, now),
|
this.dashboardRepo.countDelivered(companyId, yearStart, now),
|
||||||
this.dashboardRepo.countCommitted(companyId, yearStart, now),
|
this.dashboardRepo.countCommitted(companyId, yearStart, now),
|
||||||
this.dashboardRepo.sumPaidSpendByCurrency(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, yearStart, now),
|
||||||
this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate),
|
this.dashboardRepo.sumCommittedTonnage(
|
||||||
this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now),
|
companyId,
|
||||||
|
prevYearStart,
|
||||||
|
prevYearToDate,
|
||||||
|
),
|
||||||
|
this.dashboardRepo.monthlyCommittedTonnage(
|
||||||
|
companyId,
|
||||||
|
this.monthsAgo(now, 5),
|
||||||
|
now,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Spend can span currencies; report the dominant one (prefer ETB on ties).
|
// Spend can span currencies; report the dominant one (prefer ETB on ties).
|
||||||
const spend = this.pickCurrencyTotal(spendThisByCcy);
|
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 {
|
return {
|
||||||
deliveredCount: deliveredThis,
|
deliveredCount: deliveredThis,
|
||||||
// Share of committed bookings that reached delivered/completed.
|
// 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,
|
spendYtd: spend.total,
|
||||||
spendCurrency: spend.currency,
|
spendCurrency: spend.currency,
|
||||||
spendYtdChangePct: this.changePct(spend.total, spendPrev),
|
spendYtdChangePct: this.changePct(spend.total, spendPrev),
|
||||||
@@ -172,12 +243,12 @@ export class CompaniesService {
|
|||||||
deliveredCount: 0,
|
deliveredCount: 0,
|
||||||
completionRate: 0,
|
completionRate: 0,
|
||||||
spendYtd: 0,
|
spendYtd: 0,
|
||||||
spendCurrency: 'ETB',
|
spendCurrency: "ETB",
|
||||||
spendYtdChangePct: 0,
|
spendYtdChangePct: 0,
|
||||||
freightVolume: {
|
freightVolume: {
|
||||||
totalTonnes: 0,
|
totalTonnes: 0,
|
||||||
totalValue: 0,
|
totalValue: 0,
|
||||||
currency: 'ETB',
|
currency: "ETB",
|
||||||
ytdChangePct: 0,
|
ytdChangePct: 0,
|
||||||
monthly: this.buildMonthlySeries(now, []),
|
monthly: this.buildMonthlySeries(now, []),
|
||||||
},
|
},
|
||||||
@@ -190,8 +261,11 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
|
/** Pick the currency with the largest total, preferring ETB on ties / when empty. */
|
||||||
private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } {
|
private pickCurrencyTotal(totals: { currency: string; total: number }[]): {
|
||||||
if (totals.length === 0) return { currency: 'ETB', total: 0 };
|
currency: string;
|
||||||
|
total: number;
|
||||||
|
} {
|
||||||
|
if (totals.length === 0) return { currency: "ETB", total: 0 };
|
||||||
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
|
return totals.reduce((best, cur) => (cur.total > best.total ? cur : best));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,13 +280,29 @@ export class CompaniesService {
|
|||||||
now: Date,
|
now: Date,
|
||||||
rows: { year: number; month: number; tonnes: number }[],
|
rows: { year: number; month: number; tonnes: number }[],
|
||||||
): { month: string; 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 byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes]));
|
||||||
const series: { month: string; tonnes: number }[] = [];
|
const series: { month: string; tonnes: number }[] = [];
|
||||||
for (let i = 5; i >= 0; i--) {
|
for (let i = 5; i >= 0; i--) {
|
||||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
const key = `${d.getFullYear()}-${d.getMonth() + 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;
|
return series;
|
||||||
}
|
}
|
||||||
@@ -224,7 +314,10 @@ export class CompaniesService {
|
|||||||
return updated;
|
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 { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
|
||||||
const companyUpdates: Record<string, any> = {};
|
const companyUpdates: Record<string, any> = {};
|
||||||
@@ -233,30 +326,38 @@ export class CompaniesService {
|
|||||||
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
|
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
|
||||||
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
|
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
|
||||||
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
|
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
|
||||||
if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation;
|
if (dto.companyLocation !== undefined)
|
||||||
if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress;
|
companyUpdates.country = dto.companyLocation;
|
||||||
|
if (dto.companyAddress !== undefined)
|
||||||
|
companyUpdates.address = dto.companyAddress;
|
||||||
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
|
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
|
||||||
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
||||||
if (dto.fanNumber !== undefined) {
|
if (dto.fanNumber !== undefined) {
|
||||||
companyUpdates.businessLicense = dto.fanNumber;
|
|
||||||
companyUpdates.fanNumber = dto.fanNumber;
|
companyUpdates.fanNumber = dto.fanNumber;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName;
|
if (dto.contactPersonName !== undefined)
|
||||||
if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone;
|
attrUpdates.contactPersonName = dto.contactPersonName;
|
||||||
if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName;
|
if (dto.contactPersonPhone !== undefined)
|
||||||
if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail;
|
attrUpdates.contactPersonPhone = dto.contactPersonPhone;
|
||||||
if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone;
|
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.poaName !== undefined) attrUpdates.poaName = dto.poaName;
|
||||||
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone;
|
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone;
|
||||||
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
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;
|
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
||||||
|
|
||||||
companyUpdates.attributes = attrUpdates;
|
companyUpdates.attributes = attrUpdates;
|
||||||
|
|
||||||
const updated = await this.companiesRepo.update(company.id, companyUpdates);
|
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);
|
return new ProfileResponseDto(profile, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,7 +371,9 @@ export class CompaniesService {
|
|||||||
|
|
||||||
const existing = await this.profilesRepo.findByEmail(dto.email);
|
const existing = await this.profilesRepo.findByEmail(dto.email);
|
||||||
if (existing) {
|
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);
|
return this.profilesRepo.create(dto);
|
||||||
@@ -278,7 +381,8 @@ export class CompaniesService {
|
|||||||
|
|
||||||
async findProfileByUserId(userId: string): Promise<ExternalProfile> {
|
async findProfileByUserId(userId: string): Promise<ExternalProfile> {
|
||||||
const profile = await this.profilesRepo.findByUserId(userId);
|
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;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,28 +390,119 @@ export class CompaniesService {
|
|||||||
return this.profilesRepo.findByCompanyId(companyId);
|
return this.profilesRepo.findByCompanyId(companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createFFClient(dto: CreateFFClientDto): Promise<FFClient> {
|
private getProfileTypeForCompanyType(companyType: string): ProfileType[] {
|
||||||
await this.findCompanyById(dto.forwarderCompanyId);
|
switch (companyType) {
|
||||||
await this.findCompanyById(dto.clientCompanyId);
|
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(
|
async createCompanyProfile(
|
||||||
dto.forwarderCompanyId,
|
companyId: string,
|
||||||
dto.clientCompanyId,
|
profileType?: ProfileType,
|
||||||
);
|
): Promise<CompanyProfile> {
|
||||||
if (existing) {
|
const company = await this.findCompanyById(companyId);
|
||||||
throw new ConflictException('This forwarder-client relationship already exists');
|
|
||||||
|
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[]> {
|
async createDefaultProfilesForCompany(
|
||||||
return this.ffClientsRepo.findByForwarder(forwarderCompanyId);
|
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);
|
* Add operational profile(s) to the current user's company (portal settings).
|
||||||
if (!client) throw new NotFoundException(`FFClient ${id} not found`);
|
* Add-only and idempotent: each requested type must be allowed for the
|
||||||
await this.ffClientsRepo.softDelete(id);
|
* 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 { 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 {
|
export class CreateCompanyWithProfileDto {
|
||||||
@IsEnum(CompanyType)
|
@IsEnum(CompanyType)
|
||||||
@@ -55,4 +67,11 @@ export class CreateCompanyWithProfileDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
attributes?: Record<string, any>;
|
attributes?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CompanyProfileInputDto)
|
||||||
|
companyProfiles?: CompanyProfileInputDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,6 @@ export class CreateCompanyDto {
|
|||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
vatNumber?: string;
|
vatNumber?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(100)
|
|
||||||
businessLicense?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(32)
|
@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 { Company } from '../entities/company.entity';
|
||||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||||
|
import { ResponseCompanyProfileDto } from './response-company.dto';
|
||||||
|
|
||||||
export class ProfileResponseDto {
|
export class ProfileResponseDto {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
|
companyType: string;
|
||||||
companyEmail: string | null;
|
companyEmail: string | null;
|
||||||
companyPhone: string | null;
|
companyPhone: string | null;
|
||||||
companyLocation: string;
|
companyLocation: string;
|
||||||
@@ -12,6 +14,8 @@ export class ProfileResponseDto {
|
|||||||
vatNumber: string | null;
|
vatNumber: string | null;
|
||||||
fanNumber: string | null;
|
fanNumber: string | null;
|
||||||
|
|
||||||
|
companyProfiles: ResponseCompanyProfileDto[];
|
||||||
|
|
||||||
contactPersonName: string | null;
|
contactPersonName: string | null;
|
||||||
contactPersonPhone: string | null;
|
contactPersonPhone: string | null;
|
||||||
generalManagerName: string | null;
|
generalManagerName: string | null;
|
||||||
@@ -29,6 +33,10 @@ export class ProfileResponseDto {
|
|||||||
constructor(profile: ExternalProfile, company: Company) {
|
constructor(profile: ExternalProfile, company: Company) {
|
||||||
this.companyId = company.id;
|
this.companyId = company.id;
|
||||||
this.companyName = company.name;
|
this.companyName = company.name;
|
||||||
|
this.companyType = company.type;
|
||||||
|
this.companyProfiles =
|
||||||
|
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
||||||
|
[];
|
||||||
this.companyEmail = company.email ?? null;
|
this.companyEmail = company.email ?? null;
|
||||||
this.companyPhone = company.phone ?? null;
|
this.companyPhone = company.phone ?? null;
|
||||||
this.companyLocation = company.country;
|
this.companyLocation = company.country;
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
import { Company, CompanyType, CompanyStatus } from '../entities/company.entity';
|
import { Company, CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||||
|
import { CompanyProfile } from '../entities/company-profile.entity';
|
||||||
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
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 {
|
export class ResponseCompanyDto {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -8,7 +31,6 @@ export class ResponseCompanyDto {
|
|||||||
status: CompanyStatus;
|
status: CompanyStatus;
|
||||||
tin: string;
|
tin: string;
|
||||||
vatNumber?: string | null;
|
vatNumber?: string | null;
|
||||||
businessLicense?: string | null;
|
|
||||||
fanNumber?: string | null;
|
fanNumber?: string | null;
|
||||||
country: string;
|
country: string;
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
@@ -17,6 +39,7 @@ export class ResponseCompanyDto {
|
|||||||
website?: string | null;
|
website?: string | null;
|
||||||
attributes?: Record<string, any> | null;
|
attributes?: Record<string, any> | null;
|
||||||
profiles?: ResponseExternalProfileDto[];
|
profiles?: ResponseExternalProfileDto[];
|
||||||
|
companyProfiles?: ResponseCompanyProfileDto[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
@@ -27,7 +50,6 @@ export class ResponseCompanyDto {
|
|||||||
this.status = company.status;
|
this.status = company.status;
|
||||||
this.tin = company.tin;
|
this.tin = company.tin;
|
||||||
this.vatNumber = company.vatNumber;
|
this.vatNumber = company.vatNumber;
|
||||||
this.businessLicense = company.businessLicense;
|
|
||||||
this.fanNumber = company.fanNumber;
|
this.fanNumber = company.fanNumber;
|
||||||
this.country = company.country;
|
this.country = company.country;
|
||||||
this.address = company.address;
|
this.address = company.address;
|
||||||
@@ -36,6 +58,7 @@ export class ResponseCompanyDto {
|
|||||||
this.website = company.website;
|
this.website = company.website;
|
||||||
this.attributes = company.attributes;
|
this.attributes = company.attributes;
|
||||||
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
|
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
|
||||||
|
this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p));
|
||||||
this.createdAt = company.createdAt;
|
this.createdAt = company.createdAt;
|
||||||
this.updatedAt = company.updatedAt;
|
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 { BaseEntity } from "@edr/api-common";
|
||||||
import { Column, Entity, Index, OneToMany } from 'typeorm';
|
import { Column, Entity, Index, OneToMany } from "typeorm";
|
||||||
import { ExternalProfile } from './external-profile.entity';
|
import { ExternalProfile } from "./external-profile.entity";
|
||||||
|
import { CompanyProfile } from "./company-profile.entity";
|
||||||
|
|
||||||
export enum CompanyType {
|
export enum CompanyType {
|
||||||
Customer = 'customer',
|
Customer = "customer",
|
||||||
Forwarder = 'forwarder',
|
FreightForwarder = "freight_forwarder",
|
||||||
Transporter = 'transporter',
|
DJFreightForwarder = "dj_freight_forwarder",
|
||||||
Broker = 'broker',
|
Transporter = "transporter",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum CompanyStatus {
|
export enum CompanyStatus {
|
||||||
Active = 'active',
|
Active = "active",
|
||||||
Pending = 'pending',
|
Pending = "pending",
|
||||||
Suspended = 'suspended',
|
Suspended = "suspended",
|
||||||
Blacklisted = 'blacklisted',
|
Blacklisted = "blacklisted",
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'companies' })
|
@Entity({ schema: "freight", name: "companies" })
|
||||||
@Index(['tin'])
|
@Index(["tin"])
|
||||||
@Index(['type'])
|
@Index(["type"])
|
||||||
export class Company extends BaseEntity {
|
export class Company extends BaseEntity {
|
||||||
@Column({ name: 'name', type: 'varchar', length: 200 })
|
@Column({ name: "name", type: "varchar", length: 200 })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@Column({ name: 'type', type: 'varchar', length: 32, enum: CompanyType })
|
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
|
||||||
type!: 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;
|
status!: CompanyStatus;
|
||||||
|
|
||||||
@Column({ name: 'tin', type: 'varchar', length: 10, unique: true })
|
@Column({ name: "tin", type: "varchar", length: 10, unique: true })
|
||||||
tin!: string;
|
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;
|
vatNumber?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'business_license', type: 'varchar', length: 100, nullable: true })
|
@Column({ name: "fan_number", type: "varchar", length: 16, nullable: true })
|
||||||
businessLicense?: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'fan_number', type: 'varchar', length: 16, nullable: true })
|
|
||||||
fanNumber?: string | null;
|
fanNumber?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'country', type: 'varchar', length: 32, default: 'Ethiopia' })
|
@Column({ name: "country", type: "varchar", length: 32, default: "Ethiopia" })
|
||||||
country!: string;
|
country!: string;
|
||||||
|
|
||||||
@Column({ name: 'address', type: 'text', nullable: true })
|
@Column({ name: "address", type: "text", nullable: true })
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
|
@Column({ name: "phone", type: "varchar", length: 20, nullable: true })
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'email', type: 'varchar', length: 150, nullable: true })
|
@Column({ name: "email", type: "varchar", length: 150, nullable: true })
|
||||||
email?: string | null;
|
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;
|
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;
|
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;
|
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;
|
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;
|
generalManagerPhone?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'website', type: 'varchar', length: 200, nullable: true })
|
@Column({ name: "website", type: "varchar", length: 200, nullable: true })
|
||||||
website?: string | null;
|
website?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'attributes', type: 'jsonb', nullable: true })
|
@Column({ name: "attributes", type: "jsonb", nullable: true })
|
||||||
attributes?: Record<string, any> | null;
|
attributes?: Record<string, any> | null;
|
||||||
|
|
||||||
@OneToMany(() => ExternalProfile, (profile) => profile.company)
|
@OneToMany(() => ExternalProfile, (profile) => profile.company)
|
||||||
profiles?: ExternalProfile[];
|
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 { Module } from "@nestjs/common";
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
import { Employee } from '@tria-plc/iamapi-common';
|
import { Employee } from "@tria-plc/iamapi-common";
|
||||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
import { Cargo } from "../cargoes/entities/cargoes.entity";
|
||||||
import { Container } from '../container-management/entities/container.entity';
|
import { Container } from "../container-management/entities/container.entity";
|
||||||
import { Customer } from '../customers/entities/customer.entity';
|
import { Company } from "../companies/entities/company.entity";
|
||||||
import { PaymentEntity } from '../payment/entities/payment.entity';
|
import { PaymentEntity } from "../payment/entities/payment.entity";
|
||||||
import { Train } from '../trains/entities/train.entity';
|
import { Train } from "../trains/entities/train.entity";
|
||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
import { Wagon } from "../wagons/entities/wagon.entity";
|
||||||
import { OverviewController } from './overview.controller';
|
import { OverviewController } from "./overview.controller";
|
||||||
import { OverviewRepository } from './overview.repository';
|
import { OverviewRepository } from "./overview.repository";
|
||||||
import { OverviewService } from './overview.service';
|
import { OverviewService } from "./overview.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
Booking,
|
Booking,
|
||||||
PaymentEntity,
|
PaymentEntity,
|
||||||
Customer,
|
Company,
|
||||||
Train,
|
Train,
|
||||||
Wagon,
|
Wagon,
|
||||||
Container,
|
Container,
|
||||||
@@ -31,4 +31,4 @@ import { OverviewService } from './overview.service';
|
|||||||
controllers: [OverviewController],
|
controllers: [OverviewController],
|
||||||
providers: [OverviewService, OverviewRepository],
|
providers: [OverviewService, OverviewRepository],
|
||||||
})
|
})
|
||||||
export class OverviewModule {}
|
export class OverviewModule { }
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from "@nestjs/common";
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum';
|
import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum";
|
||||||
import { Employee } from '@tria-plc/iamapi-common';
|
import { Employee } from "@tria-plc/iamapi-common";
|
||||||
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
|
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
|
||||||
import { Freight } from '@edr/types';
|
import { Freight } from "@edr/types";
|
||||||
import { Repository, ObjectLiteral } from 'typeorm';
|
import { Repository, ObjectLiteral } from "typeorm";
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
import { Cargo } from "../cargoes/entities/cargoes.entity";
|
||||||
import { Container } from '../container-management/entities/container.entity';
|
import { Container } from "../container-management/entities/container.entity";
|
||||||
import { Customer } from '../customers/entities/customer.entity';
|
import { PaymentEntity } from "../payment/entities/payment.entity";
|
||||||
import { PaymentEntity } from '../payment/entities/payment.entity';
|
import { Train } from "../trains/entities/train.entity";
|
||||||
import { Train } from '../trains/entities/train.entity';
|
import { Wagon } from "../wagons/entities/wagon.entity";
|
||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
|
||||||
import {
|
import {
|
||||||
OVERVIEW_CLOSED_STATUSES,
|
OVERVIEW_CLOSED_STATUSES,
|
||||||
OVERVIEW_IN_APPROVAL_STATUSES,
|
OVERVIEW_IN_APPROVAL_STATUSES,
|
||||||
OVERVIEW_NEEDS_ACTION_STATUSES,
|
OVERVIEW_NEEDS_ACTION_STATUSES,
|
||||||
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
|
OVERVIEW_URGENT_PRIORITY_THRESHOLD,
|
||||||
} from './overview.constants';
|
} from "./overview.constants";
|
||||||
|
import { Company } from "../companies/entities/company.entity";
|
||||||
|
|
||||||
export type OverviewBookingKpisRow = {
|
export type OverviewBookingKpisRow = {
|
||||||
totalActive: number;
|
totalActive: number;
|
||||||
@@ -46,8 +46,8 @@ export class OverviewRepository {
|
|||||||
private readonly bookingRepository: Repository<Booking>,
|
private readonly bookingRepository: Repository<Booking>,
|
||||||
@InjectRepository(PaymentEntity)
|
@InjectRepository(PaymentEntity)
|
||||||
private readonly paymentRepository: Repository<PaymentEntity>,
|
private readonly paymentRepository: Repository<PaymentEntity>,
|
||||||
@InjectRepository(Customer)
|
@InjectRepository(Company)
|
||||||
private readonly customerRepository: Repository<Customer>,
|
private readonly companyRepository: Repository<Company>,
|
||||||
@InjectRepository(Train)
|
@InjectRepository(Train)
|
||||||
private readonly trainRepository: Repository<Train>,
|
private readonly trainRepository: Repository<Train>,
|
||||||
@InjectRepository(Wagon)
|
@InjectRepository(Wagon)
|
||||||
@@ -60,32 +60,32 @@ export class OverviewRepository {
|
|||||||
private readonly employeeRepository: Repository<Employee>,
|
private readonly employeeRepository: Repository<Employee>,
|
||||||
@InjectRepository(User)
|
@InjectRepository(User)
|
||||||
private readonly userRepository: Repository<User>,
|
private readonly userRepository: Repository<User>,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
|
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
|
||||||
const row = await this.bookingRepository
|
const row = await this.bookingRepository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder("booking")
|
||||||
.select(
|
.select(
|
||||||
`COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`,
|
`COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`,
|
||||||
'totalActive',
|
"totalActive",
|
||||||
)
|
)
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`,
|
`COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`,
|
||||||
'needsAction',
|
"needsAction",
|
||||||
)
|
)
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`,
|
`COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`,
|
||||||
'urgent',
|
"urgent",
|
||||||
)
|
)
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`,
|
`COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`,
|
||||||
'inApproval',
|
"inApproval",
|
||||||
)
|
)
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`,
|
`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({
|
.setParameters({
|
||||||
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
|
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
|
||||||
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
|
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
|
||||||
@@ -112,9 +112,9 @@ export class OverviewRepository {
|
|||||||
const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] =
|
const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.trainRepository
|
this.trainRepository
|
||||||
.createQueryBuilder('train')
|
.createQueryBuilder("train")
|
||||||
.where('train.deleted_at IS NULL')
|
.where("train.deleted_at IS NULL")
|
||||||
.andWhere('train.status IN (:...statuses)', {
|
.andWhere("train.status IN (:...statuses)", {
|
||||||
statuses: [
|
statuses: [
|
||||||
Freight.TrainStatus.InService,
|
Freight.TrainStatus.InService,
|
||||||
Freight.TrainStatus.Scheduled,
|
Freight.TrainStatus.Scheduled,
|
||||||
@@ -122,39 +122,46 @@ export class OverviewRepository {
|
|||||||
})
|
})
|
||||||
.getCount(),
|
.getCount(),
|
||||||
this.wagonRepository
|
this.wagonRepository
|
||||||
.createQueryBuilder('wagon')
|
.createQueryBuilder("wagon")
|
||||||
.where('wagon.deleted_at IS NULL')
|
.where("wagon.deleted_at IS NULL")
|
||||||
.andWhere('wagon.status = :status', { status: Freight.WagonStatus.Available })
|
.andWhere("wagon.status = :status", {
|
||||||
|
status: Freight.WagonStatus.Available,
|
||||||
|
})
|
||||||
.getCount(),
|
.getCount(),
|
||||||
this.containerRepository
|
this.containerRepository
|
||||||
.createQueryBuilder('container')
|
.createQueryBuilder("container")
|
||||||
.where('container.deleted_at IS NULL')
|
.where("container.deleted_at IS NULL")
|
||||||
.andWhere('container.status = :status', { status: 'IN_TRANSIT' })
|
.andWhere("container.status = :status", { status: "IN_TRANSIT" })
|
||||||
.getCount(),
|
.getCount(),
|
||||||
this.cargoRepository
|
this.cargoRepository
|
||||||
.createQueryBuilder('cargo')
|
.createQueryBuilder("cargo")
|
||||||
.where('cargo.deleted_at IS NULL')
|
.where("cargo.deleted_at IS NULL")
|
||||||
.andWhere('cargo.status IN (:...statuses)', {
|
.andWhere("cargo.status IN (:...statuses)", {
|
||||||
statuses: ['LOADED', 'IN_TRANSIT'],
|
statuses: ["LOADED", "IN_TRANSIT"],
|
||||||
})
|
})
|
||||||
.getCount(),
|
.getCount(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded };
|
return {
|
||||||
|
trainsActive,
|
||||||
|
wagonsAvailable,
|
||||||
|
containersInTransit,
|
||||||
|
cargoesLoaded,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCustomerKpis(): Promise<{
|
async getCustomerKpis(): Promise<{
|
||||||
totalCustomers: number;
|
totalCustomers: number;
|
||||||
newCustomersThisMonth: number;
|
newCustomersThisMonth: number;
|
||||||
}> {
|
}> {
|
||||||
const row = await this.customerRepository
|
const row = await this.companyRepository
|
||||||
.createQueryBuilder('customer')
|
.createQueryBuilder("customer")
|
||||||
.select('COUNT(*)::int', 'totalCustomers')
|
.select("COUNT(*)::int", "totalCustomers")
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`,
|
`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>>();
|
.getRawOne<Record<string, string>>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -170,26 +177,26 @@ export class OverviewRepository {
|
|||||||
successfulPaymentsMtd: number;
|
successfulPaymentsMtd: number;
|
||||||
}> {
|
}> {
|
||||||
const revenueRow = await this.paymentRepository
|
const revenueRow = await this.paymentRepository
|
||||||
.createQueryBuilder('payment')
|
.createQueryBuilder("payment")
|
||||||
.select(
|
.select(
|
||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||||||
'revenueMtdEtb',
|
"revenueMtdEtb",
|
||||||
)
|
)
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||||
'revenueMtdUsd',
|
"revenueMtdUsd",
|
||||||
)
|
)
|
||||||
.addSelect(`COUNT(*)::int`, 'successfulPaymentsMtd')
|
.addSelect(`COUNT(*)::int`, "successfulPaymentsMtd")
|
||||||
.where('payment.status = :status', { status: 'success' })
|
.where("payment.status = :status", { status: "success" })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
||||||
)
|
)
|
||||||
.getRawOne<Record<string, string>>();
|
.getRawOne<Record<string, string>>();
|
||||||
|
|
||||||
const pendingPayments = await this.paymentRepository
|
const pendingPayments = await this.paymentRepository
|
||||||
.createQueryBuilder('payment')
|
.createQueryBuilder("payment")
|
||||||
.where('payment.status IN (:...statuses)', {
|
.where("payment.status IN (:...statuses)", {
|
||||||
statuses: ['action-required', 'processing'],
|
statuses: ["action-required", "processing"],
|
||||||
})
|
})
|
||||||
.getCount();
|
.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([
|
const [activeEmployees, activeUsers] = await Promise.all([
|
||||||
this.employeeRepository.count({
|
this.employeeRepository.count({
|
||||||
where: { isCurrent: true },
|
where: { isCurrent: true },
|
||||||
@@ -217,15 +227,17 @@ export class OverviewRepository {
|
|||||||
return { activeEmployees, activeUsers };
|
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
|
const rows = await this.bookingRepository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder("booking")
|
||||||
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, 'date')
|
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where('booking.deleted_at IS NULL')
|
.where("booking.deleted_at IS NULL")
|
||||||
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
||||||
.groupBy('booking.created_at::date')
|
.groupBy("booking.created_at::date")
|
||||||
.orderBy('booking.created_at::date', 'ASC')
|
.orderBy("booking.created_at::date", "ASC")
|
||||||
.getRawMany<{ date: string; count: string }>();
|
.getRawMany<{ date: string; count: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -236,11 +248,11 @@ export class OverviewRepository {
|
|||||||
|
|
||||||
async getStatusCounts(): Promise<Record<string, number>> {
|
async getStatusCounts(): Promise<Record<string, number>> {
|
||||||
const rows = await this.bookingRepository
|
const rows = await this.bookingRepository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder("booking")
|
||||||
.select('booking.status', 'status')
|
.select("booking.status", "status")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where('booking.deleted_at IS NULL')
|
.where("booking.deleted_at IS NULL")
|
||||||
.groupBy('booking.status')
|
.groupBy("booking.status")
|
||||||
.getRawMany<{ status: string; count: string }>();
|
.getRawMany<{ status: string; count: string }>();
|
||||||
|
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
@@ -252,26 +264,26 @@ export class OverviewRepository {
|
|||||||
days: number,
|
days: number,
|
||||||
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
|
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
|
||||||
const rows = await this.paymentRepository
|
const rows = await this.paymentRepository
|
||||||
.createQueryBuilder('payment')
|
.createQueryBuilder("payment")
|
||||||
.select(
|
.select(
|
||||||
`to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`,
|
`to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`,
|
||||||
'date',
|
"date",
|
||||||
)
|
)
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`,
|
||||||
'amountEtb',
|
"amountEtb",
|
||||||
)
|
)
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
`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(
|
.andWhere(
|
||||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||||
{ days },
|
{ days },
|
||||||
)
|
)
|
||||||
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
|
.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 }>();
|
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -283,18 +295,18 @@ export class OverviewRepository {
|
|||||||
|
|
||||||
async getRecentBookings(limit: number): Promise<OverviewRecentBookingRow[]> {
|
async getRecentBookings(limit: number): Promise<OverviewRecentBookingRow[]> {
|
||||||
const rows = await this.bookingRepository
|
const rows = await this.bookingRepository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder("booking")
|
||||||
.leftJoin('booking.company', 'company')
|
.leftJoin("booking.company", "company")
|
||||||
.select('booking.id', 'id')
|
.select("booking.id", "id")
|
||||||
.addSelect('booking.reference', 'reference')
|
.addSelect("booking.reference", "reference")
|
||||||
.addSelect('COALESCE(company.name, \'—\')', 'customerLabel')
|
.addSelect("COALESCE(company.name, '—')", "customerLabel")
|
||||||
.addSelect('booking.status', 'status')
|
.addSelect("booking.status", "status")
|
||||||
.addSelect('booking.priority_score', 'priorityScore')
|
.addSelect("booking.priority_score", "priorityScore")
|
||||||
.addSelect('booking.total_amount', 'totalAmount')
|
.addSelect("booking.total_amount", "totalAmount")
|
||||||
.addSelect('booking.payment_currency', 'paymentCurrency')
|
.addSelect("booking.payment_currency", "paymentCurrency")
|
||||||
.addSelect('booking.created_at', 'createdAt')
|
.addSelect("booking.created_at", "createdAt")
|
||||||
.where('booking.deleted_at IS NULL')
|
.where("booking.deleted_at IS NULL")
|
||||||
.orderBy('booking.created_at', 'DESC')
|
.orderBy("booking.created_at", "DESC")
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.getRawMany<{
|
.getRawMany<{
|
||||||
id: string;
|
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
|
const rows = await this.bookingRepository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder("booking")
|
||||||
.select('booking.freight_type', 'label')
|
.select("booking.freight_type", "label")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where('booking.deleted_at IS NULL')
|
.where("booking.deleted_at IS NULL")
|
||||||
.andWhere("booking.status != 'DRAFT'")
|
.andWhere("booking.status != 'DRAFT'")
|
||||||
.groupBy('booking.freight_type')
|
.groupBy("booking.freight_type")
|
||||||
.orderBy('count', 'DESC')
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ label: string; count: string }>();
|
.getRawMany<{ label: string; count: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -338,13 +352,13 @@ export class OverviewRepository {
|
|||||||
|
|
||||||
async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> {
|
async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> {
|
||||||
const rows = await this.bookingRepository
|
const rows = await this.bookingRepository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder("booking")
|
||||||
.select('booking.payment_currency', 'label')
|
.select("booking.payment_currency", "label")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where('booking.deleted_at IS NULL')
|
.where("booking.deleted_at IS NULL")
|
||||||
.andWhere("booking.status != 'DRAFT'")
|
.andWhere("booking.status != 'DRAFT'")
|
||||||
.groupBy('booking.payment_currency')
|
.groupBy("booking.payment_currency")
|
||||||
.orderBy('count', 'DESC')
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ label: string; count: string }>();
|
.getRawMany<{ label: string; count: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -355,11 +369,11 @@ export class OverviewRepository {
|
|||||||
|
|
||||||
async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> {
|
async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> {
|
||||||
const rows = await this.paymentRepository
|
const rows = await this.paymentRepository
|
||||||
.createQueryBuilder('payment')
|
.createQueryBuilder("payment")
|
||||||
.select('payment.status', 'status')
|
.select("payment.status", "status")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.groupBy('payment.status')
|
.groupBy("payment.status")
|
||||||
.orderBy('count', 'DESC')
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ status: string; count: string }>();
|
.getRawMany<{ status: string; count: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -372,20 +386,25 @@ export class OverviewRepository {
|
|||||||
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
|
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
|
||||||
> {
|
> {
|
||||||
const rows = await this.paymentRepository
|
const rows = await this.paymentRepository
|
||||||
.createQueryBuilder('payment')
|
.createQueryBuilder("payment")
|
||||||
.select('payment.method', 'method')
|
.select("payment.method", "method")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`,
|
||||||
'amountEtb',
|
"amountEtb",
|
||||||
)
|
)
|
||||||
.addSelect(
|
.addSelect(
|
||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
|
||||||
'amountUsd',
|
"amountUsd",
|
||||||
)
|
)
|
||||||
.groupBy('payment.method')
|
.groupBy("payment.method")
|
||||||
.orderBy('count', 'DESC')
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ method: string; count: string; amountEtb: string; amountUsd: string }>();
|
.getRawMany<{
|
||||||
|
method: string;
|
||||||
|
count: string;
|
||||||
|
amountEtb: string;
|
||||||
|
amountUsd: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
method: row.method,
|
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
|
const rows = await this.paymentRepository
|
||||||
.createQueryBuilder('payment')
|
.createQueryBuilder("payment")
|
||||||
.select('payment.currency', 'currency')
|
.select("payment.currency", "currency")
|
||||||
.addSelect('COALESCE(SUM(payment.amount), 0)', 'amount')
|
.addSelect("COALESCE(SUM(payment.amount), 0)", "amount")
|
||||||
.where('payment.status = :status', { status: 'success' })
|
.where("payment.status = :status", { status: "success" })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
`COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`,
|
||||||
)
|
)
|
||||||
.groupBy('payment.currency')
|
.groupBy("payment.currency")
|
||||||
.getRawMany<{ currency: string; amount: string }>();
|
.getRawMany<{ currency: string; amount: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -413,20 +434,28 @@ export class OverviewRepository {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTrainStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
async getTrainStatusBreakdown(): Promise<
|
||||||
return this.statusBreakdown(this.trainRepository, 'train');
|
{ status: string; count: number }[]
|
||||||
|
> {
|
||||||
|
return this.statusBreakdown(this.trainRepository, "train");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getWagonStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
async getWagonStatusBreakdown(): Promise<
|
||||||
return this.statusBreakdown(this.wagonRepository, 'wagon');
|
{ status: string; count: number }[]
|
||||||
|
> {
|
||||||
|
return this.statusBreakdown(this.wagonRepository, "wagon");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContainerStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
async getContainerStatusBreakdown(): Promise<
|
||||||
return this.statusBreakdown(this.containerRepository, 'container');
|
{ status: string; count: number }[]
|
||||||
|
> {
|
||||||
|
return this.statusBreakdown(this.containerRepository, "container");
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCargoStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
async getCargoStatusBreakdown(): Promise<
|
||||||
return this.statusBreakdown(this.cargoRepository, 'cargo');
|
{ status: string; count: number }[]
|
||||||
|
> {
|
||||||
|
return this.statusBreakdown(this.cargoRepository, "cargo");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async statusBreakdown(
|
private async statusBreakdown(
|
||||||
@@ -435,11 +464,11 @@ export class OverviewRepository {
|
|||||||
): Promise<{ status: string; count: number }[]> {
|
): Promise<{ status: string; count: number }[]> {
|
||||||
const rows = await repository
|
const rows = await repository
|
||||||
.createQueryBuilder(alias)
|
.createQueryBuilder(alias)
|
||||||
.select(`${alias}.status`, 'status')
|
.select(`${alias}.status`, "status")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where(`${alias}.deleted_at IS NULL`)
|
.where(`${alias}.deleted_at IS NULL`)
|
||||||
.groupBy(`${alias}.status`)
|
.groupBy(`${alias}.status`)
|
||||||
.orderBy('count', 'DESC')
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ status: string; count: string }>();
|
.getRawMany<{ status: string; count: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -448,15 +477,19 @@ export class OverviewRepository {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCustomerGrowthTrend(days: number): Promise<{ date: string; count: number }[]> {
|
async getCustomerGrowthTrend(
|
||||||
const rows = await this.customerRepository
|
days: number,
|
||||||
.createQueryBuilder('customer')
|
): Promise<{ date: string; count: number }[]> {
|
||||||
.select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, 'date')
|
const rows = await this.companyRepository
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.createQueryBuilder("customer")
|
||||||
.where('customer.deleted_at IS NULL')
|
.select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||||
.andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.groupBy('customer.created_at::date')
|
.where("customer.deleted_at IS NULL")
|
||||||
.orderBy('customer.created_at::date', 'ASC')
|
.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 }>();
|
.getRawMany<{ date: string; count: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -466,13 +499,16 @@ export class OverviewRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getCustomersByType(): Promise<{ label: string; count: number }[]> {
|
async getCustomersByType(): Promise<{ label: string; count: number }[]> {
|
||||||
const rows = await this.customerRepository
|
const rows = await this.companyRepository
|
||||||
.createQueryBuilder('customer')
|
.createQueryBuilder("customer")
|
||||||
.select(`COALESCE(NULLIF(customer.customer_type, ''), 'Unknown')`, 'label')
|
.select(
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
`COALESCE(NULLIF(customer.type, ''), 'Unknown')`,
|
||||||
.where('customer.deleted_at IS NULL')
|
"label",
|
||||||
.groupBy('customer.customer_type')
|
)
|
||||||
.orderBy('count', 'DESC')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
|
.where("customer.deleted_at IS NULL")
|
||||||
|
.groupBy("customer.type")
|
||||||
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ label: string; count: string }>();
|
.getRawMany<{ label: string; count: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
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
|
const rows = await this.bookingRepository
|
||||||
.createQueryBuilder('booking')
|
.createQueryBuilder("booking")
|
||||||
.leftJoin('booking.company', 'company')
|
.leftJoin("booking.company", "company")
|
||||||
.select(`COALESCE(company.name, 'Unknown')`, 'label')
|
.select(`COALESCE(company.name, 'Unknown')`, "label")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where('booking.deleted_at IS NULL')
|
.where("booking.deleted_at IS NULL")
|
||||||
.andWhere("booking.status != 'DRAFT'")
|
.andWhere("booking.status != 'DRAFT'")
|
||||||
.groupBy('company.name')
|
.groupBy("company.name")
|
||||||
.orderBy('count', 'DESC')
|
.orderBy("count", "DESC")
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.getRawMany<{ label: string; count: string }>();
|
.getRawMany<{ label: string; count: string }>();
|
||||||
|
|
||||||
@@ -502,11 +540,11 @@ export class OverviewRepository {
|
|||||||
|
|
||||||
async getUsersByStatus(): Promise<{ status: string; count: number }[]> {
|
async getUsersByStatus(): Promise<{ status: string; count: number }[]> {
|
||||||
const rows = await this.userRepository
|
const rows = await this.userRepository
|
||||||
.createQueryBuilder('user')
|
.createQueryBuilder("user")
|
||||||
.select('user.status', 'status')
|
.select("user.status", "status")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.groupBy('user.status')
|
.groupBy("user.status")
|
||||||
.orderBy('count', 'DESC')
|
.orderBy("count", "DESC")
|
||||||
.getRawMany<{ status: string; count: string }>();
|
.getRawMany<{ status: string; count: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
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
|
const rows = await this.employeeRepository
|
||||||
.createQueryBuilder('employee')
|
.createQueryBuilder("employee")
|
||||||
.select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, 'date')
|
.select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, "date")
|
||||||
.addSelect('COUNT(*)::int', 'count')
|
.addSelect("COUNT(*)::int", "count")
|
||||||
.where('employee.is_current = true')
|
.where("employee.is_current = true")
|
||||||
.andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { days })
|
.andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, {
|
||||||
.groupBy('employee.created_at::date')
|
days,
|
||||||
.orderBy('employee.created_at::date', 'ASC')
|
})
|
||||||
|
.groupBy("employee.created_at::date")
|
||||||
|
.orderBy("employee.created_at::date", "ASC")
|
||||||
.getRawMany<{ date: string; count: string }>();
|
.getRawMany<{ date: string; count: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -538,16 +580,16 @@ export class OverviewRepository {
|
|||||||
where: { isActive: true, status: EUserStatus.ACCEPTED },
|
where: { isActive: true, status: EUserStatus.ACCEPTED },
|
||||||
}),
|
}),
|
||||||
this.userRepository
|
this.userRepository
|
||||||
.createQueryBuilder('user')
|
.createQueryBuilder("user")
|
||||||
.where('user.is_active = false OR user.status != :status', {
|
.where("user.is_active = false OR user.status != :status", {
|
||||||
status: EUserStatus.ACCEPTED,
|
status: EUserStatus.ACCEPTED,
|
||||||
})
|
})
|
||||||
.getCount(),
|
.getCount(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ label: 'Active', count: active },
|
{ label: "Active", count: active },
|
||||||
{ label: 'Inactive', count: inactive },
|
{ label: "Inactive", count: inactive },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,10 +146,11 @@ const sidebarItems: SidebarItem[] = [
|
|||||||
const App = () => {
|
const App = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user } = useAuth();
|
const { user, company } = useAuth();
|
||||||
|
|
||||||
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||||
const userEmail = user?.email;
|
const userEmail = user?.email;
|
||||||
|
const companyProfiles = company?.company?.companyProfiles ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -190,6 +191,7 @@ const App = () => {
|
|||||||
enableThemeToggle
|
enableThemeToggle
|
||||||
userName={displayName}
|
userName={displayName}
|
||||||
userEmail={userEmail}
|
userEmail={userEmail}
|
||||||
|
companyProfiles={companyProfiles}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
@@ -47,9 +47,19 @@ export interface AppLayoutProps {
|
|||||||
enableThemeToggle?: boolean;
|
enableThemeToggle?: boolean;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
userEmail?: 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;
|
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 {
|
function getInitials(name: string): string {
|
||||||
return name
|
return name
|
||||||
.split(" ")
|
.split(" ")
|
||||||
@@ -106,6 +116,7 @@ export function AppLayout({
|
|||||||
enableThemeToggle = false,
|
enableThemeToggle = false,
|
||||||
userName = "User",
|
userName = "User",
|
||||||
userEmail,
|
userEmail,
|
||||||
|
companyProfiles = [],
|
||||||
children,
|
children,
|
||||||
}: AppLayoutProps) {
|
}: AppLayoutProps) {
|
||||||
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
|
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
|
||||||
@@ -306,6 +317,34 @@ export function AppLayout({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</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 />
|
<Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<User size={15} />}
|
leftSection={<User size={15} />}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export const URL_CONSTANTS = {
|
|||||||
GET_INFO: "/api/companies/getInfo",
|
GET_INFO: "/api/companies/getInfo",
|
||||||
CREATE: "/api/companies/create",
|
CREATE: "/api/companies/create",
|
||||||
PROFILE: "/api/companies/profile",
|
PROFILE: "/api/companies/profile",
|
||||||
|
COMPANY_PROFILES: "/api/companies/company-profiles",
|
||||||
DASHBOARD: "/api/companies/dashboard",
|
DASHBOARD: "/api/companies/dashboard",
|
||||||
DOCUMENTS: (id: string) => `/api/companies/${id}/documents`,
|
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 { 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 (
|
return (
|
||||||
<div className="flex items-start gap-3">
|
<Group gap="sm" align="flex-start" wrap="nowrap">
|
||||||
{icon && <div className="mt-1 text-muted-foreground [&_svg]:size-4">{icon}</div>}
|
{icon && (
|
||||||
<div className="flex flex-col gap-0.5">
|
<ThemeIcon variant="light" color="edr-green" size="md" radius="md">
|
||||||
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">{label}</p>
|
{icon}
|
||||||
<p className="text-sm font-bold text-foreground">{value || "—"}</p>
|
</ThemeIcon>
|
||||||
</div>
|
)}
|
||||||
</div>
|
<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) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<Center h="100%">
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary" />
|
<Loader color="edr-green" size="lg" />
|
||||||
</div>
|
</Center>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<Center h="100%">
|
||||||
<p className="text-muted-foreground">No company profile found.</p>
|
<Text c="edr-muted">No company profile found.</Text>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="px-4 py-8">
|
<Container size="xl" px="lg" py="xl">
|
||||||
<div className="mx-auto max-w-7xl">
|
{/* Header */}
|
||||||
<div className="flex flex-col gap-8">
|
<Group gap="lg" align="center" mb="lg">
|
||||||
{/* Header */}
|
<ThemeIcon variant="light" color="edr-green" size={88} radius="lg">
|
||||||
<div className="flex items-center gap-6">
|
<User size={44} />
|
||||||
<div className="flex size-24 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-inner">
|
</ThemeIcon>
|
||||||
<User className="size-12" />
|
<Stack gap={6}>
|
||||||
</div>
|
<Group gap="sm" align="center">
|
||||||
<div className="flex flex-col gap-1">
|
<Title order={1} size="h2">
|
||||||
<div className="flex items-center gap-3">
|
{profile.companyName}
|
||||||
<h1 className="text-3xl font-black tracking-tight text-foreground">
|
</Title>
|
||||||
{profile.companyName}
|
<Badge color="edr-green" variant="light">
|
||||||
</h1>
|
Verified
|
||||||
<Badge variant="secondary" className="font-bold uppercase tracking-wider">
|
</Badge>
|
||||||
Verified
|
</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>
|
</Badge>
|
||||||
</div>
|
))}
|
||||||
<p className="flex items-center gap-2 font-medium text-muted-foreground">
|
</Group>
|
||||||
<Building className="size-4" />
|
) : (
|
||||||
{profile.companyName}
|
<Group gap={6} c="edr-muted">
|
||||||
</p>
|
<Building size={16} />
|
||||||
</div>
|
<Text c="edr-muted" fw={500}>
|
||||||
</div>
|
{profile.companyType}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<Separator />
|
<Divider mb="lg" />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
<Grid gap="lg">
|
||||||
{/* Left Column */}
|
{/* Left Column */}
|
||||||
<div className="flex flex-col gap-8 lg:col-span-2">
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
<Stack gap="lg">
|
||||||
{/* Company Details */}
|
{/* Company Details */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeading
|
||||||
<CardTitle className="flex items-center gap-2">
|
icon={
|
||||||
<Building2 className="size-5 text-primary" />
|
<Building2
|
||||||
Company Details
|
size={20}
|
||||||
</CardTitle>
|
color="var(--mantine-color-edr-green-6)"
|
||||||
<CardDescription>Business registration information</CardDescription>
|
/>
|
||||||
</CardHeader>
|
}
|
||||||
<CardContent className="flex flex-col gap-4">
|
title="Company Details"
|
||||||
<InfoItem icon={<Globe />} label="Location" value={profile.companyLocation} />
|
description="Business registration information"
|
||||||
<InfoItem icon={<MapPin />} label="Address" value={profile.companyAddress} />
|
/>
|
||||||
<InfoItem icon={<FileCheck />} label="TIN Number" value={profile.tinNumber} />
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
<InfoItem icon={<ShieldCheck />} label="FAN Number" value={profile.fanNumber} />
|
<InfoItem
|
||||||
<InfoItem icon={<Mail />} label="Email" value={profile.companyEmail} />
|
icon={<Globe size={16} />}
|
||||||
<InfoItem icon={<Phone />} label="Phone" value={profile.companyPhone} />
|
label="Location"
|
||||||
</CardContent>
|
value={profile.companyLocation}
|
||||||
</Card>
|
/>
|
||||||
|
<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) */}
|
{/* Key Personnel */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeading
|
||||||
<CardTitle className="flex items-center gap-2">
|
icon={
|
||||||
<Fingerprint className="size-5 text-primary" />
|
<Briefcase
|
||||||
Profile Details
|
size={20}
|
||||||
</CardTitle>
|
color="var(--mantine-color-edr-green-6)"
|
||||||
<CardDescription>Your linked user profile</CardDescription>
|
/>
|
||||||
</CardHeader>
|
}
|
||||||
<CardContent className="flex flex-col gap-4">
|
title="Key Personnel"
|
||||||
<InfoItem icon={<User />} label="Profile" value="Primary Contact" />
|
description="Management and contact persons"
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg">
|
||||||
</div>
|
<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 */}
|
{/* Power of Attorney */}
|
||||||
<Card>
|
{profile.poaName && (
|
||||||
<CardHeader>
|
<Card style={{ borderStyle: "dashed" }}>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardHeading
|
||||||
<Briefcase className="size-5 text-primary" />
|
icon={
|
||||||
Key Personnel
|
<UserCheck
|
||||||
</CardTitle>
|
size={20}
|
||||||
<CardDescription>Management and contact persons</CardDescription>
|
color="var(--mantine-color-edr-accent-6)"
|
||||||
</CardHeader>
|
/>
|
||||||
<CardContent className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
}
|
||||||
<div className="flex flex-col gap-4">
|
title="Power of Attorney"
|
||||||
<h3 className="border-l-4 border-primary pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
|
description="Authorized representative details"
|
||||||
Contact Person
|
/>
|
||||||
</h3>
|
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||||
<div className="flex flex-col gap-3 pl-4">
|
<InfoItem label="PoA Name" value={profile.poaName} />
|
||||||
<InfoItem label="Name" value={profile.contactPersonName} />
|
<InfoItem label="PoA Email" value={profile.poaEmail} />
|
||||||
<InfoItem label="Phone" value={profile.contactPersonPhone} />
|
<InfoItem label="PoA Phone" value={profile.poaPhone} />
|
||||||
</div>
|
<InfoItem label="PoA Location" value={profile.poaLocation} />
|
||||||
</div>
|
</SimpleGrid>
|
||||||
<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>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
{/* Power of Attorney */}
|
{/* Right Column */}
|
||||||
{profile.poaName && (
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
<Card className="border-dashed">
|
<Stack gap="lg">
|
||||||
<CardHeader>
|
{/* Operating Roles */}
|
||||||
<CardTitle className="flex items-center gap-2">
|
<Card>
|
||||||
<UserCheck className="size-5 text-accent" />
|
<CardHeading
|
||||||
Power of Attorney
|
icon={
|
||||||
</CardTitle>
|
<BadgeCheck
|
||||||
<CardDescription>Authorized representative details</CardDescription>
|
size={20}
|
||||||
</CardHeader>
|
color="var(--mantine-color-edr-green-6)"
|
||||||
<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} />
|
title="Operating Roles"
|
||||||
<InfoItem label="PoA Phone" value={profile.poaPhone} />
|
description="Your registered freight roles and reference numbers"
|
||||||
<InfoItem label="PoA Location" value={profile.poaLocation} />
|
/>
|
||||||
</CardContent>
|
{roleOptions.length === 0 ? (
|
||||||
</Card>
|
<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 */}
|
{/* Secure Account */}
|
||||||
<div className="flex flex-col gap-8">
|
<Card
|
||||||
<Card className="relative overflow-hidden border-none bg-foreground text-background shadow-xl">
|
padding="xl"
|
||||||
<div className="absolute right-0 top-0 p-4 opacity-10">
|
style={{
|
||||||
<ShieldCheck className="size-32" />
|
background: "var(--mantine-color-edr-ink-6)",
|
||||||
</div>
|
position: "relative",
|
||||||
<CardContent className="relative z-10 flex flex-col gap-4 px-6 py-8">
|
overflow: "hidden",
|
||||||
<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.
|
<Box
|
||||||
Contact support for verified information updates.
|
style={{
|
||||||
</p>
|
position: "absolute",
|
||||||
<div className="pt-2">
|
top: 16,
|
||||||
<a
|
right: 16,
|
||||||
href="/settings"
|
opacity: 0.1,
|
||||||
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
|
<ShieldCheck size={128} color="white" />
|
||||||
</a>
|
</Box>
|
||||||
</div>
|
<Stack gap="md" style={{ position: "relative", zIndex: 1 }}>
|
||||||
</CardContent>
|
<Title order={3} size="h4" c="white">
|
||||||
</Card>
|
Secure Account
|
||||||
</div>
|
</Title>
|
||||||
</div>
|
<Text size="sm" c="gray.4">
|
||||||
</div>
|
Your information is protected by enterprise-grade security.
|
||||||
</div>
|
Contact support for verified information updates.
|
||||||
</div>
|
</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 { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -17,7 +17,12 @@ import {
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import PhoneInput from "@/components/auth/PhoneInput";
|
import PhoneInput from "@/components/auth/PhoneInput";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
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({
|
export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
companyName: z.string().min(1, "Company name is required"),
|
||||||
@@ -52,6 +57,7 @@ export default function TabCompanyProfile({
|
|||||||
}: TabCompanyProfileProps) {
|
}: TabCompanyProfileProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isCreate = mode === "create";
|
const isCreate = mode === "create";
|
||||||
|
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
||||||
|
|
||||||
const defaultValues = useMemo((): CompanyProfileFormData => {
|
const defaultValues = useMemo((): CompanyProfileFormData => {
|
||||||
if (profile) {
|
if (profile) {
|
||||||
@@ -91,8 +97,7 @@ export default function TabCompanyProfile({
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (data: CompanyProfileFormData) => {
|
mutationFn: async (data: CompanyProfileFormData) => {
|
||||||
const payload: CreateCompanyPayload = {
|
const base = {
|
||||||
companyType: "customer",
|
|
||||||
companyName: data.companyName,
|
companyName: data.companyName,
|
||||||
companyEmail: data.companyEmail,
|
companyEmail: data.companyEmail,
|
||||||
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`,
|
||||||
@@ -103,10 +108,21 @@ export default function TabCompanyProfile({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isCreate) {
|
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);
|
return api.companies.create.call(payload);
|
||||||
} else {
|
|
||||||
return api.companies.updateProfile.call(payload);
|
|
||||||
}
|
}
|
||||||
|
return api.companies.updateProfile.call(base);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
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 (
|
return (
|
||||||
<Card padding="lg">
|
<Stack gap="lg">
|
||||||
<Group gap="sm" mb="xs">
|
{isCreate ? (
|
||||||
<Building2 size={20} />
|
<OnboardingRoleSelect
|
||||||
<Title order={3}>Company Profile</Title>
|
value={selectedRoles}
|
||||||
</Group>
|
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">
|
<Text c="edr-muted" size="sm" mb="lg">
|
||||||
{isCreate
|
{isCreate
|
||||||
? "Enter your company registration details to get started"
|
? "Enter your company registration details to get started"
|
||||||
@@ -251,6 +284,8 @@ export default function TabCompanyProfile({
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
</form>
|
</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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -8,18 +20,7 @@ import {
|
|||||||
UploadCloud,
|
UploadCloud,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import { useState } from "react";
|
||||||
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";
|
|
||||||
|
|
||||||
interface TabDocumentsProps {
|
interface TabDocumentsProps {
|
||||||
profile: ProfileResponse;
|
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 (
|
return (
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<Group gap="sm" mb="xs">
|
<Group gap="sm" mb="xs">
|
||||||
@@ -67,7 +104,8 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
|
|||||||
<SmartFileInput
|
<SmartFileInput
|
||||||
file={docSettingQuery.data}
|
file={docSettingQuery.data}
|
||||||
value={documentFiles}
|
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} />}
|
leftSection={<ArrowRight size={16} />}
|
||||||
loading={docUploadMutation.isPending}
|
loading={docUploadMutation.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const hasFiles = Object.values(documentFiles).some((f) => f !== null);
|
const validationErrors = validateRequired();
|
||||||
|
if (Object.keys(validationErrors).length > 0) {
|
||||||
|
setFieldErrors(validationErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (hasFiles) {
|
if (hasFiles) {
|
||||||
docUploadMutation.mutate(documentFiles, {
|
docUploadMutation.mutate(documentFiles, {
|
||||||
onSuccess: () => {
|
onSuccess: () => onContinue?.(),
|
||||||
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
|
|
||||||
onContinue?.();
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
onContinue?.();
|
onContinue?.();
|
||||||
@@ -120,7 +159,11 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
|
|||||||
type="button"
|
type="button"
|
||||||
leftSection={<UploadCloud size={16} />}
|
leftSection={<UploadCloud size={16} />}
|
||||||
loading={docUploadMutation.isPending}
|
loading={docUploadMutation.isPending}
|
||||||
onClick={() => docUploadMutation.mutate(documentFiles)}
|
disabled={!hasFiles}
|
||||||
|
onClick={() => {
|
||||||
|
if (!hasFiles) return;
|
||||||
|
docUploadMutation.mutate(documentFiles);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Upload Documents
|
Upload Documents
|
||||||
</Button>
|
</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";
|
} from "@/types/dropdownSettings";
|
||||||
import type {
|
import type {
|
||||||
CompanyInfoResponse,
|
CompanyInfoResponse,
|
||||||
|
CompanyProfileResponse,
|
||||||
CreateCompanyPayload,
|
CreateCompanyPayload,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
} from "./companies.service";
|
} from "./companies.service";
|
||||||
@@ -128,14 +129,19 @@ export const api = {
|
|||||||
"getDashboard",
|
"getDashboard",
|
||||||
companiesService.getDashboard,
|
companiesService.getDashboard,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
addCompanyProfiles: endpoint<{ types: string[] }, CompanyProfileResponse[]>(
|
||||||
|
"companies",
|
||||||
|
"addCompanyProfiles",
|
||||||
|
companiesService.addCompanyProfiles,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
bookings: {
|
bookings: {
|
||||||
list: endpoint<BookingListFilter | void, PaginatedResponse<Freight.IBooking>>(
|
list: endpoint<
|
||||||
"bookings",
|
BookingListFilter | void,
|
||||||
"list",
|
PaginatedResponse<Freight.IBooking>
|
||||||
bookingsService.list,
|
>("bookings", "list", bookingsService.list),
|
||||||
),
|
|
||||||
|
|
||||||
get: endpoint<{ id: string }, Freight.IBooking>(
|
get: endpoint<{ id: string }, Freight.IBooking>(
|
||||||
"bookings",
|
"bookings",
|
||||||
@@ -217,8 +223,14 @@ export const api = {
|
|||||||
getBookableSchedules: endpoint<
|
getBookableSchedules: endpoint<
|
||||||
{ originYardId?: string; destinationYardId?: string },
|
{ originYardId?: string; destinationYardId?: string },
|
||||||
Freight.BookableScheduleItem[]
|
Freight.BookableScheduleItem[]
|
||||||
>("train-scheduling", "bookableSchedules", ({ originYardId, destinationYardId }) =>
|
>(
|
||||||
bookingsService.getBookableSchedules({ originYardId, destinationYardId }),
|
"train-scheduling",
|
||||||
|
"bookableSchedules",
|
||||||
|
({ originYardId, destinationYardId }) =>
|
||||||
|
bookingsService.getBookableSchedules({
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
getAvailableDays: endpoint<
|
getAvailableDays: endpoint<
|
||||||
@@ -291,19 +303,6 @@ export const api = {
|
|||||||
({ entity }) => fileUploadSettingsService.getByEntity(entity),
|
({ 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>(
|
remove: endpoint<{ id: string }, void>(
|
||||||
"file-upload-settings",
|
"file-upload-settings",
|
||||||
"remove",
|
"remove",
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ export interface CompanyResponse {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
website: string | null;
|
website: string | null;
|
||||||
attributes: Record<string, any> | 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;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -44,6 +56,11 @@ export interface CompanyInfoResponse {
|
|||||||
company: CompanyResponse;
|
company: CompanyResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CompanyProfileInput {
|
||||||
|
type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter";
|
||||||
|
businessLicense?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateCompanyPayload {
|
export interface CreateCompanyPayload {
|
||||||
companyType?: string;
|
companyType?: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
@@ -57,6 +74,7 @@ export interface CreateCompanyPayload {
|
|||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
isPrimaryContact?: boolean;
|
isPrimaryContact?: boolean;
|
||||||
attributes?: Record<string, any>;
|
attributes?: Record<string, any>;
|
||||||
|
companyProfiles?: CompanyProfileInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FreightVolumePoint {
|
export interface FreightVolumePoint {
|
||||||
@@ -124,6 +142,16 @@ export const companiesService = {
|
|||||||
return unwrap(response.data);
|
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 (
|
uploadDocuments: async (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
files: Record<string, File | File[] | null>,
|
files: Record<string, File | File[] | null>,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import type { CompanyProfileResponse } from "@/services/companies.service";
|
||||||
|
|
||||||
export interface ProfileResponse {
|
export interface ProfileResponse {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
|
companyType: string;
|
||||||
|
companyProfiles: CompanyProfileResponse[];
|
||||||
companyEmail: string | null;
|
companyEmail: string | null;
|
||||||
companyPhone: string | null;
|
companyPhone: string | null;
|
||||||
companyLocation: string;
|
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