mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
Merge pull request #899 from Tria-plc/freight/feat/fixes-v1
Freight/feat/fixes v1
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the `active_profile_type` "active mode" column. A booking/contract now
|
||||||
|
* resolves its company_profile from the trade direction at creation time (with
|
||||||
|
* a forwarder passing an explicit companyProfileId), so no per-user active mode
|
||||||
|
* is stored. `onboarding_step` / `onboarding_completed` are unaffected.
|
||||||
|
*/
|
||||||
|
export class DropActiveProfileTypeFromExternalProfiles2450000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'DropActiveProfileTypeFromExternalProfiles2450000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.external_profiles
|
||||||
|
DROP COLUMN IF EXISTS active_profile_type;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.external_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS active_profile_type varchar(32);
|
||||||
|
`);
|
||||||
|
// Rebuild the mode the same way the original column was backfilled:
|
||||||
|
// importer first, then exporter, then whichever profile the company has.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.external_profiles ep
|
||||||
|
SET active_profile_type = cp.type
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (company_id) company_id, type
|
||||||
|
FROM freight.company_profiles
|
||||||
|
ORDER BY company_id,
|
||||||
|
CASE type
|
||||||
|
WHEN 'importer' THEN 0
|
||||||
|
WHEN 'exporter' THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END
|
||||||
|
) cp
|
||||||
|
WHERE ep.company_id = cp.company_id
|
||||||
|
AND ep.active_profile_type IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AddCacBankPaymentMethod2460000000000 implements MigrationInterface {
|
||||||
|
name = "AddCacBankPaymentMethod2460000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// The entity + frontend already list 'cac-bank' as a valid method, but the
|
||||||
|
// DB enum was never extended. Filtering payments by 'cac-bank' cast the
|
||||||
|
// literal to the enum and errored (invalid input value for enum). EDRFREIGHT-301.
|
||||||
|
await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cac-bank';`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// PostgreSQL does not support removing enum values directly.
|
||||||
|
// To roll back, recreate the type without the added value and update the column.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
RESET_LINK_TTL_MS,
|
RESET_LINK_TTL_MS,
|
||||||
} from "./forgot-password.service";
|
} from "./forgot-password.service";
|
||||||
import { maskOtpTarget } from "./mask-target.util";
|
import { maskOtpTarget } from "./mask-target.util";
|
||||||
|
import { isDomesticPhone } from "../otp/otp.service";
|
||||||
|
|
||||||
/** The account a staff-triggered reset would land on. */
|
/** The account a staff-triggered reset would land on. */
|
||||||
export interface CustomerResetTarget {
|
export interface CustomerResetTarget {
|
||||||
@@ -19,6 +20,12 @@ export interface CustomerResetTarget {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
|
/**
|
||||||
|
* Whether the SMS gateway (domestic-only) can reach `phone`. `null` when
|
||||||
|
* there is no phone. The backoffice uses this to disable the SMS channel for
|
||||||
|
* foreign numbers instead of sending a link that will never arrive.
|
||||||
|
*/
|
||||||
|
phoneIsDomestic: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SentResetLink {
|
export interface SentResetLink {
|
||||||
@@ -58,6 +65,9 @@ export class CustomerResetService {
|
|||||||
name: `${profile.firstName} ${profile.lastName}`.trim(),
|
name: `${profile.firstName} ${profile.lastName}`.trim(),
|
||||||
email: user.email ?? null,
|
email: user.email ?? null,
|
||||||
phone: user.phoneNumber ?? null,
|
phone: user.phoneNumber ?? null,
|
||||||
|
phoneIsDomestic: user.phoneNumber
|
||||||
|
? isDomesticPhone(user.phoneNumber)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +90,17 @@ export class CustomerResetService {
|
|||||||
const target = this.forgotPasswordService.targetFor(user, channel);
|
const target = this.forgotPasswordService.targetFor(user, channel);
|
||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
|
|
||||||
|
// A foreign number is unreachable by the domestic-only SMS gateway — treat
|
||||||
|
// it like a missing phone rather than reporting "link sent" for a message
|
||||||
|
// that will never arrive. The backoffice disables the channel up front via
|
||||||
|
// `phoneIsDomestic`; this guards direct API calls.
|
||||||
|
if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Staff reset via SMS refused for user ${userId} — non-domestic phone`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Mint first, send second: a failed send leaves an unused ticket that simply
|
// Mint first, send second: a failed send leaves an unused ticket that simply
|
||||||
// expires, whereas sending a link before the ticket exists would hand the
|
// expires, whereas sending a link before the ticket exists would hand the
|
||||||
// customer a URL that is dead on arrival.
|
// customer a URL that is dead on arrival.
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { Freight, SchedulingStatus } from '@edr/types';
|
|||||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||||
// import { CustomersService } from '../customers/customers.service';
|
// import { CustomersService } from '../customers/customers.service';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
|
||||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
@@ -635,12 +634,9 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||||
// A customer can only book once their company has been approved.
|
// A customer can only book once their company has been approved; the
|
||||||
if (company.status !== CompanyStatus.Active) {
|
// helper names the real status (suspended/blacklisted) when it isn't.
|
||||||
throw new ForbiddenException(
|
this.companiesService.assertCompanyActiveFor(company, 'bookings');
|
||||||
"Your company is awaiting approval — you can't create bookings yet.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
companyId = company.id;
|
companyId = company.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,21 +742,13 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
companyProfileId = profile.id;
|
companyProfileId = profile.id;
|
||||||
} else if (companyId) {
|
} else if (companyId) {
|
||||||
let fallbackType: ProfileType | null = null;
|
// No explicit profile pin: resolve from the booking's trade direction
|
||||||
if (userId) {
|
// (import→importer, export→exporter; otherwise the first profile). A
|
||||||
try {
|
// forwarder booking sends dto.companyProfileId and takes the branch above.
|
||||||
const { profile } =
|
|
||||||
await this.companiesService.getCompanyInfoByUserId(userId);
|
|
||||||
fallbackType = profile.activeProfileType ?? null;
|
|
||||||
} catch {
|
|
||||||
// No profile (e.g. staff creating on behalf) — fall back to mapping.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
companyProfileId =
|
companyProfileId =
|
||||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||||
companyId,
|
companyId,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
fallbackType,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// A customer booking under their own account may only do so once the
|
// A customer booking under their own account may only do so once the
|
||||||
@@ -1068,9 +1056,6 @@ export class BookingsService {
|
|||||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||||
existing.companyId,
|
existing.companyId,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
existing.companyProfileId
|
|
||||||
? undefined
|
|
||||||
: (existing.companyProfile?.type as ProfileType | undefined),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
||||||
@@ -1391,15 +1376,6 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the active company_profile id a customer's bookings should be
|
|
||||||
* scoped to (importer/exporter mode). Null when not onboarded — callers fall
|
|
||||||
* back to company-level scoping.
|
|
||||||
*/
|
|
||||||
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
|
|
||||||
return this.companiesService.resolveActiveCompanyProfileId(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authorize a customer's access to a single booking. Staff are scoped at the
|
* Authorize a customer's access to a single booking. Staff are scoped at the
|
||||||
* controller (they pass `isStaff`); for a customer, the booking must belong
|
* controller (they pass `isStaff`); for a customer, the booking must belong
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
|||||||
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
|
||||||
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
|
||||||
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
|
||||||
import { SetActiveModeDto } from "./dto/set-active-mode.dto";
|
|
||||||
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
|
||||||
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
|
||||||
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
|
import { DashboardQueryDto } from "./dto/dashboard-query.dto";
|
||||||
@@ -353,21 +352,6 @@ export class CompaniesController {
|
|||||||
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
|
return this.companiesService.removePoaDelegationLetter(user.id, fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch("active-mode")
|
|
||||||
@ApiOperation({
|
|
||||||
summary: "Switch the current user's active operational mode (importer/exporter)",
|
|
||||||
})
|
|
||||||
async setActiveMode(
|
|
||||||
@CurrentUser() user: CurrentIamUser,
|
|
||||||
@Body() dto: SetActiveModeDto,
|
|
||||||
): Promise<CompanyInfoResponseDto> {
|
|
||||||
const { profile, company } = await this.companiesService.setActiveMode(
|
|
||||||
user.id,
|
|
||||||
dto.type,
|
|
||||||
);
|
|
||||||
return new CompanyInfoResponseDto(profile, company);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch("onboarding-step")
|
@Patch("onboarding-step")
|
||||||
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
AND ccr.deleted_at IS NULL
|
AND ccr.deleted_at IS NULL
|
||||||
)`;
|
)`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `sortBy = 'review'` queue ordering: whatever marketing must act on
|
||||||
|
* floats to the top. Tier 0 — submitted applications awaiting first approval
|
||||||
|
* (drafts excluded: nothing to review yet). Tier 1 — approved customers with
|
||||||
|
* a pending change request. Tier 2 — everyone else, drafts included.
|
||||||
|
*/
|
||||||
|
private static readonly REVIEW_TIER_SQL = `(CASE
|
||||||
|
WHEN company.status = 'pending' AND NOT ${CompaniesRepository.DRAFT_SQL} THEN 0
|
||||||
|
WHEN ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL} THEN 1
|
||||||
|
ELSE 2
|
||||||
|
END)`;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Company)
|
@InjectRepository(Company)
|
||||||
repo: Repository<Company>,
|
repo: Repository<Company>,
|
||||||
@@ -80,8 +92,8 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
status,
|
status,
|
||||||
onboardingCompleted,
|
onboardingCompleted,
|
||||||
hasPendingChangeRequest,
|
hasPendingChangeRequest,
|
||||||
sortBy = 'name',
|
sortBy = 'review',
|
||||||
sortOrder = 'ASC',
|
sortOrder = 'DESC',
|
||||||
} = query;
|
} = query;
|
||||||
|
|
||||||
const qb = this.repository
|
const qb = this.repository
|
||||||
@@ -137,8 +149,18 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
|
// sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate.
|
||||||
|
if (sortBy === 'review') {
|
||||||
|
// Queue ordering: actionable tiers first, newest first within each. The
|
||||||
|
// tier is selected under an alias because skip/take pagination with
|
||||||
|
// joins re-derives the ORDER BY in a subquery — a raw expression there
|
||||||
|
// breaks, a selected alias survives.
|
||||||
|
qb.addSelect(CompaniesRepository.REVIEW_TIER_SQL, 'review_tier')
|
||||||
|
.orderBy('review_tier', 'ASC')
|
||||||
|
.addOrderBy('company.createdAt', 'DESC');
|
||||||
|
} else {
|
||||||
|
qb.orderBy(`company.${sortBy}`, sortOrder);
|
||||||
|
}
|
||||||
const [items, total] = await qb
|
const [items, total] = await qb
|
||||||
.orderBy(`company.${sortBy}`, sortOrder)
|
|
||||||
// Names are not unique and createdAt can tie on bulk imports; the id
|
// Names are not unique and createdAt can tie on bulk imports; the id
|
||||||
// tiebreaker keeps paging stable instead of dropping/repeating rows.
|
// tiebreaker keeps paging stable instead of dropping/repeating rows.
|
||||||
.addOrderBy('company.id', 'ASC')
|
.addOrderBy('company.id', 'ASC')
|
||||||
|
|||||||
@@ -201,18 +201,6 @@ export class CompaniesService {
|
|||||||
attributes: dto.attributes ?? null,
|
attributes: dto.attributes ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Default active mode from the chosen role(s): importer wins when both are
|
|
||||||
// picked, otherwise the first allowed type chosen.
|
|
||||||
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
|
|
||||||
const chosenTypes = (dto.companyProfiles ?? [])
|
|
||||||
.map((p) => p.type)
|
|
||||||
.filter((t) => allowedTypes.includes(t));
|
|
||||||
const activeProfileType =
|
|
||||||
chosenTypes.find((t) => t === ProfileType.importer) ??
|
|
||||||
chosenTypes[0] ??
|
|
||||||
allowedTypes[0] ??
|
|
||||||
null;
|
|
||||||
|
|
||||||
const profile = await this.profilesRepo.create({
|
const profile = await this.profilesRepo.create({
|
||||||
userId: identity.userId,
|
userId: identity.userId,
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
@@ -220,7 +208,6 @@ export class CompaniesService {
|
|||||||
lastName: identity.lastName,
|
lastName: identity.lastName,
|
||||||
jobTitle: dto.jobTitle ?? null,
|
jobTitle: dto.jobTitle ?? null,
|
||||||
isPrimaryContact: dto.isPrimaryContact ?? true,
|
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||||
activeProfileType,
|
|
||||||
onboardingStep: "company",
|
onboardingStep: "company",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -293,11 +280,6 @@ export class CompaniesService {
|
|||||||
|
|
||||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||||
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
|
||||||
const activeProfileType =
|
|
||||||
chosenTypes.find((t) => t === ProfileType.importer) ??
|
|
||||||
chosenTypes[0] ??
|
|
||||||
allowedTypes[0] ??
|
|
||||||
null;
|
|
||||||
|
|
||||||
const company = await this.companiesRepo.create({
|
const company = await this.companiesRepo.create({
|
||||||
name: identity.firstName
|
name: identity.firstName
|
||||||
@@ -316,7 +298,6 @@ export class CompaniesService {
|
|||||||
firstName: identity.firstName,
|
firstName: identity.firstName,
|
||||||
lastName: identity.lastName,
|
lastName: identity.lastName,
|
||||||
isPrimaryContact: true,
|
isPrimaryContact: true,
|
||||||
activeProfileType,
|
|
||||||
onboardingStep: "company",
|
onboardingStep: "company",
|
||||||
onboardingCompleted: false,
|
onboardingCompleted: false,
|
||||||
});
|
});
|
||||||
@@ -1090,6 +1071,23 @@ export class CompaniesService {
|
|||||||
if (!existing)
|
if (!existing)
|
||||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||||
|
|
||||||
|
// Suspension and reactivation must carry a staff explanation — the customer
|
||||||
|
// sees it, so "why" can never be left blank. Reactivation is the
|
||||||
|
// active-write that leaves Suspended; a first approval stays note-free.
|
||||||
|
const reactivating =
|
||||||
|
status === ProfileStatus.Active &&
|
||||||
|
existing.status === ProfileStatus.Suspended;
|
||||||
|
if (
|
||||||
|
(status === ProfileStatus.Suspended || reactivating) &&
|
||||||
|
!note?.trim()
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
status === ProfileStatus.Suspended
|
||||||
|
? "A message explaining the suspension is required — the customer will see it."
|
||||||
|
: "A message explaining the reactivation is required — the customer will see it.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// A self-registered company is only reviewable once its owner submits the
|
// A self-registered company is only reviewable once its owner submits the
|
||||||
// onboarding wizard (markOnboardingComplete) — until then its profiles are
|
// onboarding wizard (markOnboardingComplete) — until then its profiles are
|
||||||
// half-filled drafts and approving one would mint a reference against an
|
// half-filled drafts and approving one would mint a reference against an
|
||||||
@@ -1176,9 +1174,13 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track the review outcome. Rejection keeps the note so the customer knows
|
// Track the review outcome. Rejection and suspension keep the note so the
|
||||||
// why; approval clears it. Any decision stamps the reviewer + time.
|
// customer knows why; approval/reactivation clears it. Any decision stamps
|
||||||
if (status === ProfileStatus.Rejected) {
|
// the reviewer + time.
|
||||||
|
if (
|
||||||
|
status === ProfileStatus.Rejected ||
|
||||||
|
status === ProfileStatus.Suspended
|
||||||
|
) {
|
||||||
patch.reviewNote = note ?? null;
|
patch.reviewNote = note ?? null;
|
||||||
} else if (status === ProfileStatus.Active) {
|
} else if (status === ProfileStatus.Active) {
|
||||||
patch.reviewNote = null;
|
patch.reviewNote = null;
|
||||||
@@ -1192,23 +1194,50 @@ export class CompaniesService {
|
|||||||
if (!updated)
|
if (!updated)
|
||||||
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
||||||
|
|
||||||
// Approving any profile promotes a pending company to active, so the
|
// Every reviewed transition that changes what the customer can do is told
|
||||||
// customer can start working as soon as their first profile is cleared.
|
// to them, carrying the staff message so they know why. Approval has no
|
||||||
if (status === ProfileStatus.Active) {
|
// message (the note is cleared); the others require one.
|
||||||
|
const change =
|
||||||
|
status === ProfileStatus.Suspended
|
||||||
|
? "suspended"
|
||||||
|
: status === ProfileStatus.Rejected
|
||||||
|
? "rejected"
|
||||||
|
: status === ProfileStatus.Active
|
||||||
|
? existing.status === ProfileStatus.Suspended
|
||||||
|
? "reactivated"
|
||||||
|
: "approved"
|
||||||
|
: null;
|
||||||
|
if (change) {
|
||||||
const company = await this.companiesRepo.findById(updated.companyId);
|
const company = await this.companiesRepo.findById(updated.companyId);
|
||||||
if (company && company.status === CompanyStatus.Pending) {
|
if (company) {
|
||||||
await this.companiesRepo.update(updated.companyId, {
|
this.companyNotifier.profileStatusChanged(
|
||||||
status: CompanyStatus.Active,
|
company,
|
||||||
});
|
updated.type,
|
||||||
|
change,
|
||||||
|
note ?? "",
|
||||||
|
);
|
||||||
|
// The first approved role promotes a pending company to active — a
|
||||||
|
// bigger event (the account itself goes live), so tell them that too.
|
||||||
|
if (
|
||||||
|
status === ProfileStatus.Active &&
|
||||||
|
company.status === CompanyStatus.Pending
|
||||||
|
) {
|
||||||
|
await this.companiesRepo.update(updated.companyId, {
|
||||||
|
status: CompanyStatus.Active,
|
||||||
|
});
|
||||||
|
this.companyNotifier.companyApproved(company);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Customer reapplies for a rejected operational role (after fixing whatever the
|
* Customer reapplies for a rejected or suspended operational role (after
|
||||||
* reviewer flagged, e.g. re-uploading a license): flip it back to Pending and
|
* fixing whatever the reviewer flagged, e.g. re-uploading a license): flip it
|
||||||
* clear the rejection note so it re-enters the approval queue.
|
* back to Pending and clear the review note so it re-enters the approval
|
||||||
|
* queue. Suspension is a staff lockout, so resubmitting is an appeal — the
|
||||||
|
* backoffice still has to approve before the role goes live again.
|
||||||
*/
|
*/
|
||||||
async reapplyCompanyProfile(
|
async reapplyCompanyProfile(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -1223,9 +1252,12 @@ export class CompaniesService {
|
|||||||
if (!target || target.companyId !== companyId) {
|
if (!target || target.companyId !== companyId) {
|
||||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||||
}
|
}
|
||||||
if (target.status !== ProfileStatus.Rejected) {
|
if (
|
||||||
|
target.status !== ProfileStatus.Rejected &&
|
||||||
|
target.status !== ProfileStatus.Suspended
|
||||||
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Only a rejected role can be resubmitted for approval",
|
"Only a rejected or suspended role can be resubmitted for approval",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1349,10 +1381,9 @@ export class CompaniesService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a single operational profile for the current user's company. The new
|
* Create a single operational profile for the current user's company. The new
|
||||||
* role starts Pending, so it deliberately does NOT become the active mode:
|
* role starts Pending and carries no reference until a backoffice reviewer
|
||||||
* switching onto an unapproved profile would strip the user of `canBook` and
|
* approves it; a booking/contract resolves its profile from the trade
|
||||||
* block them from creating contracts under the role they already had approved.
|
* direction at creation time, so no "active mode" is stored.
|
||||||
* Callers switch explicitly via {@link setActiveMode} once the role is Active.
|
|
||||||
*/
|
*/
|
||||||
async createCompanyProfileForUser(
|
async createCompanyProfileForUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -1387,40 +1418,6 @@ export class CompaniesService {
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Switch the user's active operational mode. The target profile must already
|
|
||||||
* exist — clients create it first via createCompanyProfileForUser.
|
|
||||||
*/
|
|
||||||
async setActiveMode(
|
|
||||||
userId: string,
|
|
||||||
type: ProfileType,
|
|
||||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
|
||||||
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);
|
|
||||||
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) {
|
|
||||||
throw new ConflictException(
|
|
||||||
`No ${type} profile exists yet — create it before switching`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.profilesRepo.update(profile.id, { activeProfileType: type });
|
|
||||||
|
|
||||||
return this.getCompanyInfoByUserId(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setOnboardingStep(userId: string, step: string): Promise<void> {
|
async setOnboardingStep(userId: string, step: string): Promise<void> {
|
||||||
const profile = await this.profilesRepo.findByUserId(userId);
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
if (!profile)
|
if (!profile)
|
||||||
@@ -1611,21 +1608,68 @@ export class CompaniesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block a customer from booking under a profile that isn't approved yet.
|
* Block a self-service action when the company account isn't active, naming
|
||||||
* Called from the booking-create path for self-service bookings; staff- and
|
* the actual status — a suspended customer told "awaiting approval" has no
|
||||||
* government-initiated bookings bypass this. No-op when the profile can't be
|
* idea what happened or who to call.
|
||||||
* found (defensive — resolution is best-effort upstream).
|
*/
|
||||||
|
assertCompanyActiveFor(company: Company, action: string): void {
|
||||||
|
if (company.status === CompanyStatus.Active) return;
|
||||||
|
switch (company.status) {
|
||||||
|
case CompanyStatus.Suspended:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your company account is suspended — you can't create ${action} right now. ` +
|
||||||
|
`Please contact EDR support for details.`,
|
||||||
|
);
|
||||||
|
case CompanyStatus.Blacklisted:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your company account is blacklisted — you can't create ${action}. ` +
|
||||||
|
`Please contact EDR support.`,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your company is awaiting approval — you can't create ${action} yet.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block a customer from booking under a profile that isn't approved yet — or
|
||||||
|
* that a reviewer has since suspended. Called from the booking/contract
|
||||||
|
* create path for self-service actions; staff- and government-initiated ones
|
||||||
|
* bypass this. No-op when the profile can't be found (defensive — resolution
|
||||||
|
* is best-effort upstream). The message names the profile's real status:
|
||||||
|
* suspension in particular is per-role, so the customer must learn which
|
||||||
|
* operation is blocked (their other roles still work).
|
||||||
*/
|
*/
|
||||||
async assertCompanyProfileApprovedForBooking(
|
async assertCompanyProfileApprovedForBooking(
|
||||||
companyProfileId: string,
|
companyProfileId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const profile = await this.companyProfilesRepo.findById(companyProfileId);
|
const profile = await this.companyProfilesRepo.findById(companyProfileId);
|
||||||
if (!profile) return;
|
if (!profile) return;
|
||||||
if (profile.status !== ProfileStatus.Active) {
|
if (profile.status === ProfileStatus.Active) return;
|
||||||
const role = profile.type.replace(/_/g, " ");
|
|
||||||
throw new ForbiddenException(
|
const role = profile.type.replace(/_/g, " ");
|
||||||
`Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`,
|
switch (profile.status) {
|
||||||
);
|
case ProfileStatus.Suspended:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your ${role} role is suspended${
|
||||||
|
profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
||||||
|
}. Your other roles are unaffected. Please contact EDR support to resolve this.`,
|
||||||
|
);
|
||||||
|
case ProfileStatus.Blacklisted:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your ${role} role is blacklisted. Please contact EDR support.`,
|
||||||
|
);
|
||||||
|
case ProfileStatus.Rejected:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your ${role} role was rejected${
|
||||||
|
profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
||||||
|
}. Amend and resubmit it from your settings page.`,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Your ${role} profile is awaiting approval. You'll be able to proceed once it has been approved.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2244,15 +2288,14 @@ export class CompaniesService {
|
|||||||
/**
|
/**
|
||||||
* Resolve which company_profile a new booking belongs to, from the company
|
* Resolve which company_profile a new booking belongs to, from the company
|
||||||
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
|
||||||
* exporter profile; for DOMESTIC or a forwarder/single-profile company (or
|
* exporter profile; for DOMESTIC (or when the natural profile doesn't exist,
|
||||||
* when the natural profile doesn't exist) it falls back to the user's active
|
* e.g. a freight forwarder) it falls back to the company's first profile.
|
||||||
* profile, then the company's first profile. Returns null when the company
|
* Callers that need a specific role (a forwarder) pass an explicit
|
||||||
* has no profiles at all.
|
* companyProfileId instead. Returns null when the company has no profiles.
|
||||||
*/
|
*/
|
||||||
async resolveCompanyProfileIdForBooking(
|
async resolveCompanyProfileIdForBooking(
|
||||||
companyId: string,
|
companyId: string,
|
||||||
tradeDirection: string,
|
tradeDirection: string,
|
||||||
fallbackType?: ProfileType | null,
|
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||||
if (profiles.length === 0) return null;
|
if (profiles.length === 0) return null;
|
||||||
@@ -2264,30 +2307,12 @@ export class CompaniesService {
|
|||||||
? ProfileType.exporter
|
? ProfileType.exporter
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const byType = (type?: ProfileType | null) =>
|
const match =
|
||||||
type ? profiles.find((p) => p.type === type) : undefined;
|
(naturalType && profiles.find((p) => p.type === naturalType)) ??
|
||||||
|
profiles[0];
|
||||||
const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0];
|
|
||||||
return match?.id ?? null;
|
return match?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the company_profile a customer's data should be scoped to, from
|
|
||||||
* their persisted active mode. Returns null when nothing can be resolved
|
|
||||||
* (not onboarded yet) so callers can fall back to company-level scoping.
|
|
||||||
*/
|
|
||||||
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
|
||||||
const type = profile.activeProfileType;
|
|
||||||
if (!type) return null;
|
|
||||||
const match = company.companyProfiles?.find((p) => p.type === type);
|
|
||||||
return match?.id ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchETradeData(tin: string) {
|
async fetchETradeData(tin: string) {
|
||||||
const { businessInfo, companyInfo } =
|
const { businessInfo, companyInfo } =
|
||||||
await this.etradeService.resolveCompanyData(tin);
|
await this.etradeService.resolveCompanyData(tin);
|
||||||
|
|||||||
@@ -59,23 +59,106 @@ export class CompanyNotifierService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SMS + email + in-app account-status item to the company contact. */
|
||||||
|
private notifyAccount(
|
||||||
|
company: Company,
|
||||||
|
title: string,
|
||||||
|
body: string,
|
||||||
|
link = "/settings",
|
||||||
|
): void {
|
||||||
|
void this.notifyContact(company, `${title}. ${body}`);
|
||||||
|
void this.inbox.notify({
|
||||||
|
recipients: { companyId: company.id },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.ACCOUNT_STATUS,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
link,
|
||||||
|
data: { companyId: company.id, status: company.status },
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tell the customer their account was suspended or blacklisted. Called only on
|
* Tell the customer their account changed status. Fires on the transitions
|
||||||
* a real transition into one of those statuses; other status writes are silent.
|
* that change what they can do: suspended/blacklisted (locked out) and
|
||||||
|
* reactivated (back to Active from a lockout). Silent otherwise.
|
||||||
*/
|
*/
|
||||||
statusChanged(company: Company, previous: CompanyStatus): void {
|
statusChanged(company: Company, previous: CompanyStatus): void {
|
||||||
const status = company.status;
|
const status = company.status;
|
||||||
if (status === previous) return;
|
if (status === previous) return;
|
||||||
|
|
||||||
|
if (status === CompanyStatus.Active && PUNITIVE_STATUSES.includes(previous)) {
|
||||||
|
this.logger.log(`ACCOUNT_REACTIVATED — ${company.id}`);
|
||||||
|
this.notifyAccount(
|
||||||
|
company,
|
||||||
|
"Account reactivated",
|
||||||
|
"Your company account has been reactivated. " +
|
||||||
|
"You can submit new contracts and bookings again.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!PUNITIVE_STATUSES.includes(status)) return;
|
if (!PUNITIVE_STATUSES.includes(status)) return;
|
||||||
|
|
||||||
const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted";
|
const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted";
|
||||||
const title = `Account ${label}`;
|
|
||||||
const body =
|
|
||||||
`Your company account has been ${label}. ` +
|
|
||||||
`You will not be able to submit new contracts or bookings. ` +
|
|
||||||
`Please contact EDR support for assistance.`;
|
|
||||||
|
|
||||||
this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`);
|
this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`);
|
||||||
|
this.notifyAccount(
|
||||||
|
company,
|
||||||
|
`Account ${label}`,
|
||||||
|
`Your company account has been ${label}. ` +
|
||||||
|
`You will not be able to submit new contracts or bookings. ` +
|
||||||
|
`Please contact EDR support for assistance.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the customer their company account was approved and is now live — the
|
||||||
|
* first operational role clearing review promotes a pending company to Active.
|
||||||
|
*/
|
||||||
|
companyApproved(company: Company): void {
|
||||||
|
this.logger.log(`ACCOUNT_APPROVED — ${company.id}`);
|
||||||
|
this.notifyAccount(
|
||||||
|
company,
|
||||||
|
"Account approved",
|
||||||
|
"Your company account has been approved and is now active. " +
|
||||||
|
"You can start submitting bookings and contracts.",
|
||||||
|
"/dashboard",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell the customer one of their operational roles changed review status —
|
||||||
|
* approved, rejected, suspended, or reactivated — quoting the staff message
|
||||||
|
* when one was given (rejection/suspension/reactivation require one; approval
|
||||||
|
* carries none).
|
||||||
|
*/
|
||||||
|
profileStatusChanged(
|
||||||
|
company: Company,
|
||||||
|
profileType: string,
|
||||||
|
change: "approved" | "rejected" | "suspended" | "reactivated",
|
||||||
|
staffMessage: string,
|
||||||
|
): void {
|
||||||
|
const title = `${profileType} role ${change}`;
|
||||||
|
const consequence: Record<typeof change, string> = {
|
||||||
|
approved: "You can now operate under this role.",
|
||||||
|
rejected:
|
||||||
|
"You will not be able to operate under this role. Amend the required " +
|
||||||
|
"documents and resubmit it for approval from your settings page.",
|
||||||
|
suspended:
|
||||||
|
"You will not be able to operate under this role until it is " +
|
||||||
|
"reactivated; your other roles are unaffected.",
|
||||||
|
reactivated: "You can operate under this role again.",
|
||||||
|
};
|
||||||
|
const message = staffMessage.trim();
|
||||||
|
const body =
|
||||||
|
`Your company's ${profileType} role has been ${change}. ` +
|
||||||
|
`${consequence[change]}` +
|
||||||
|
(message ? ` Message from EDR staff: ${message}` : "");
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`PROFILE_${change.toUpperCase()} — ${company.id} / ${profileType}`,
|
||||||
|
);
|
||||||
void this.notifyContact(company, `${title}. ${body}`);
|
void this.notifyContact(company, `${title}. ${body}`);
|
||||||
void this.inbox.notify({
|
void this.inbox.notify({
|
||||||
recipients: { companyId: company.id },
|
recipients: { companyId: company.id },
|
||||||
@@ -84,7 +167,7 @@ export class CompanyNotifierService {
|
|||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
link: "/settings",
|
link: "/settings",
|
||||||
data: { companyId: company.id, status },
|
data: { companyId: company.id, profileType, change, staffMessage: message },
|
||||||
priority: NotificationPriority.HIGH,
|
priority: NotificationPriority.HIGH,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class CompanyInfoResponseDto {
|
|||||||
company: Company,
|
company: Company,
|
||||||
changeRequest?: CompanyChangeRequest | null,
|
changeRequest?: CompanyChangeRequest | null,
|
||||||
) {
|
) {
|
||||||
this.profile = new ResponseExternalProfileDto(profile, company);
|
this.profile = new ResponseExternalProfileDto(profile);
|
||||||
this.company = new ResponseCompanyDto(company);
|
this.company = new ResponseCompanyDto(company);
|
||||||
|
|
||||||
const open =
|
const open =
|
||||||
|
|||||||
@@ -60,15 +60,19 @@ export class ListCompaniesQueryDto {
|
|||||||
hasPendingChangeRequest?: boolean;
|
hasPendingChangeRequest?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: ["name", "createdAt", "updatedAt"],
|
enum: ["review", "name", "createdAt", "updatedAt"],
|
||||||
default: "name",
|
default: "review",
|
||||||
description: "Column to order by. Defaults to name for backwards compatibility.",
|
description:
|
||||||
|
"Column to order by. The default `review` is a review-queue ordering: " +
|
||||||
|
"companies awaiting first approval, then those with a pending change " +
|
||||||
|
"request, then everyone else — newest first within each group. The " +
|
||||||
|
"other values are plain column sorts.",
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(["name", "createdAt", "updatedAt"])
|
@IsIn(["review", "name", "createdAt", "updatedAt"])
|
||||||
sortBy?: "name" | "createdAt" | "updatedAt";
|
sortBy?: "review" | "name" | "createdAt" | "updatedAt";
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" })
|
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
||||||
@IsIn(["ASC", "DESC"])
|
@IsIn(["ASC", "DESC"])
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Company } from '../entities/company.entity';
|
|
||||||
import {
|
import {
|
||||||
ExternalProfile,
|
ExternalProfile,
|
||||||
} from '../entities/external-profile.entity';
|
} from '../entities/external-profile.entity';
|
||||||
import { ProfileType } from '../entities/company-profile.entity';
|
|
||||||
|
|
||||||
export class ResponseExternalProfileDto {
|
export class ResponseExternalProfileDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,20 +11,12 @@ export class ResponseExternalProfileDto {
|
|||||||
nationalId?: string | null;
|
nationalId?: string | null;
|
||||||
jobTitle?: string | null;
|
jobTitle?: string | null;
|
||||||
isPrimaryContact: boolean;
|
isPrimaryContact: boolean;
|
||||||
/** The active operational mode (importer/exporter/forwarder). */
|
|
||||||
activeProfileType?: ProfileType | null;
|
|
||||||
/**
|
|
||||||
* The id of the company_profile matching activeProfileType, resolved
|
|
||||||
* server-side so the client never re-derives it. Null until a company
|
|
||||||
* (with profiles) is loaded and a matching profile exists.
|
|
||||||
*/
|
|
||||||
activeCompanyProfileId?: string | null;
|
|
||||||
onboardingStep?: string | null;
|
onboardingStep?: string | null;
|
||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|
||||||
constructor(profile: ExternalProfile, company?: Company) {
|
constructor(profile: ExternalProfile) {
|
||||||
this.id = profile.id;
|
this.id = profile.id;
|
||||||
this.userId = profile.userId;
|
this.userId = profile.userId;
|
||||||
this.companyId = profile.companyId;
|
this.companyId = profile.companyId;
|
||||||
@@ -35,13 +25,8 @@ export class ResponseExternalProfileDto {
|
|||||||
this.nationalId = profile.nationalId;
|
this.nationalId = profile.nationalId;
|
||||||
this.jobTitle = profile.jobTitle;
|
this.jobTitle = profile.jobTitle;
|
||||||
this.isPrimaryContact = profile.isPrimaryContact;
|
this.isPrimaryContact = profile.isPrimaryContact;
|
||||||
this.activeProfileType = profile.activeProfileType ?? null;
|
|
||||||
this.onboardingStep = profile.onboardingStep ?? null;
|
this.onboardingStep = profile.onboardingStep ?? null;
|
||||||
this.onboardingCompleted = profile.onboardingCompleted ?? false;
|
this.onboardingCompleted = profile.onboardingCompleted ?? false;
|
||||||
this.activeCompanyProfileId =
|
|
||||||
company?.companyProfiles?.find(
|
|
||||||
(p) => p.type === profile.activeProfileType,
|
|
||||||
)?.id ?? null;
|
|
||||||
this.createdAt = profile.createdAt;
|
this.createdAt = profile.createdAt;
|
||||||
this.updatedAt = profile.updatedAt;
|
this.updatedAt = profile.updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { IsEnum } from 'class-validator';
|
|
||||||
import { ProfileType } from '../entities/company-profile.entity';
|
|
||||||
|
|
||||||
export class SetActiveModeDto {
|
|
||||||
@IsEnum(ProfileType)
|
|
||||||
type!: ProfileType;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
|
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
import { Company } from './company.entity';
|
import { Company } from './company.entity';
|
||||||
import { ProfileType } from './company-profile.entity';
|
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'external_profiles' })
|
@Entity({ schema: 'freight', name: 'external_profiles' })
|
||||||
@Index(['userId'])
|
@Index(['userId'])
|
||||||
@@ -32,21 +31,6 @@ export class ExternalProfile extends BaseEntity {
|
|||||||
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
|
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
|
||||||
isPrimaryContact!: boolean;
|
isPrimaryContact!: boolean;
|
||||||
|
|
||||||
/**
|
|
||||||
* The operational profile the user is currently "in" (importer vs exporter,
|
|
||||||
* or the single forwarder profile). Drives header switching and scopes the
|
|
||||||
* customer's bookings / dashboard to that company_profile. Nullable for
|
|
||||||
* users who haven't picked a role yet.
|
|
||||||
*/
|
|
||||||
@Column({
|
|
||||||
name: 'active_profile_type',
|
|
||||||
type: 'varchar',
|
|
||||||
length: 32,
|
|
||||||
nullable: true,
|
|
||||||
enum: ProfileType,
|
|
||||||
})
|
|
||||||
activeProfileType?: ProfileType | null;
|
|
||||||
|
|
||||||
/** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */
|
/** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */
|
||||||
@Column({
|
@Column({
|
||||||
name: 'onboarding_step',
|
name: 'onboarding_step',
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import { YardCountry } from '@edr/types';
|
|||||||
|
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
|
||||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
|
||||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
@@ -181,11 +179,7 @@ export class ContractsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||||
if (company.status !== CompanyStatus.Active) {
|
this.companiesService.assertCompanyActiveFor(company, 'contracts');
|
||||||
throw new ForbiddenException(
|
|
||||||
"Your company is awaiting approval — you can't create contracts yet.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
companyId = company.id;
|
companyId = company.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,31 +187,31 @@ export class ContractsService {
|
|||||||
this.assertRouteShape(dto.contractKind, dto.routes);
|
this.assertRouteShape(dto.contractKind, dto.routes);
|
||||||
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
||||||
|
|
||||||
// Stamp the operational profile (importer/exporter) for portal scoping.
|
// Stamp the operational profile for portal scoping. A forwarder contract
|
||||||
|
// pins its profile explicitly (trade direction can't tell it apart from a
|
||||||
|
// direct import/export); everything else resolves from the trade direction.
|
||||||
let companyProfileId: string | null = null;
|
let companyProfileId: string | null = null;
|
||||||
if (!isGovernment && companyId) {
|
if (!isGovernment && companyId) {
|
||||||
let fallbackType: ProfileType | null = null;
|
if (dto.companyProfileId) {
|
||||||
if (userId) {
|
const profile =
|
||||||
try {
|
await this.companiesService.getActiveCompanyProfileForBooking(
|
||||||
const { profile } =
|
companyId,
|
||||||
await this.companiesService.getCompanyInfoByUserId(userId);
|
dto.companyProfileId,
|
||||||
fallbackType = profile.activeProfileType ?? null;
|
);
|
||||||
} catch {
|
companyProfileId = profile.id;
|
||||||
// No profile (e.g. staff creating on behalf) — fall back to mapping.
|
} else {
|
||||||
}
|
companyProfileId =
|
||||||
}
|
await this.companiesService.resolveCompanyProfileIdForBooking(
|
||||||
companyProfileId =
|
companyId,
|
||||||
await this.companiesService.resolveCompanyProfileIdForBooking(
|
dto.tradeDirection,
|
||||||
companyId,
|
);
|
||||||
dto.tradeDirection,
|
|
||||||
fallbackType,
|
|
||||||
);
|
|
||||||
|
|
||||||
const customerSelfBooking = !dto.companyId && !!userId;
|
const customerSelfBooking = !dto.companyId && !!userId;
|
||||||
if (customerSelfBooking && companyProfileId) {
|
if (customerSelfBooking && companyProfileId) {
|
||||||
await this.companiesService.assertCompanyProfileApprovedForBooking(
|
await this.companiesService.assertCompanyProfileApprovedForBooking(
|
||||||
companyProfileId,
|
companyProfileId,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,16 @@ export class CreateContractDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
description:
|
||||||
|
'Explicit company profile to stamp the contract to (a forwarder contract); ' +
|
||||||
|
'commercial contracts otherwise auto-resolve from trade direction.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
companyProfileId?: string;
|
||||||
|
|
||||||
@ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' })
|
@ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' })
|
||||||
@IsIn([...CONTRACT_KINDS])
|
@IsIn([...CONTRACT_KINDS])
|
||||||
contractKind!: string;
|
contractKind!: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { OtpController } from './otp.controller';
|
import { OtpController } from './otp.controller';
|
||||||
|
import { OtpService } from './otp.service';
|
||||||
|
|
||||||
describe('OtpController', () => {
|
describe('OtpController', () => {
|
||||||
let controller: OtpController;
|
let controller: OtpController;
|
||||||
@@ -7,6 +8,9 @@ describe('OtpController', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [OtpController],
|
controllers: [OtpController],
|
||||||
|
providers: [
|
||||||
|
{ provide: OtpService, useValue: { send: jest.fn(), verify: jest.fn() } },
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<OtpController>(OtpController);
|
controller = module.get<OtpController>(OtpController);
|
||||||
|
|||||||
@@ -1,33 +1,46 @@
|
|||||||
import { OtpService, normalizeOtpTarget } from './otp.service';
|
import { OtpService, isDomesticPhone, normalizeOtpTarget } from "./otp.service";
|
||||||
|
|
||||||
describe('normalizeOtpTarget', () => {
|
describe("normalizeOtpTarget", () => {
|
||||||
it('canonicalises Ethiopian forms to one E.164 key', () => {
|
it("canonicalises Ethiopian forms to one E.164 key", () => {
|
||||||
const forms = ['+251986680099', '251986680099', '0986680099', '+251 98 668 0099'];
|
const forms = [
|
||||||
|
"+251986680099",
|
||||||
|
"251986680099",
|
||||||
|
"0986680099",
|
||||||
|
"+251 98 668 0099",
|
||||||
|
];
|
||||||
const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone);
|
const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone);
|
||||||
expect(new Set(keys)).toEqual(new Set(['+251986680099']));
|
expect(new Set(keys)).toEqual(new Set(["+251986680099"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps local 07… mobile to +2517…', () => {
|
it("canonicalises email case and surrounding whitespace to one key", () => {
|
||||||
expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678');
|
const forms = ["a@b.com", "A@B.com", " a@B.COM ", "A@b.COM"];
|
||||||
});
|
|
||||||
|
|
||||||
it('canonicalises email case and surrounding whitespace to one key', () => {
|
|
||||||
const forms = ['a@b.com', 'A@B.com', ' a@B.COM ', 'A@b.COM'];
|
|
||||||
const keys = forms.map((email) => normalizeOtpTarget({ email }).email);
|
const keys = forms.map((email) => normalizeOtpTarget({ email }).email);
|
||||||
expect(new Set(keys)).toEqual(new Set(['a@b.com']));
|
expect(new Set(keys)).toEqual(new Set(["a@b.com"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps an already-normalised email stable (idempotent)', () => {
|
it("keeps an already-normalised email stable (idempotent)", () => {
|
||||||
const once = normalizeOtpTarget({ email: ' User@Example.COM ' }).email!;
|
const once = normalizeOtpTarget({ email: " User@Example.COM " }).email!;
|
||||||
expect(normalizeOtpTarget({ email: once }).email).toBe(once);
|
expect(normalizeOtpTarget({ email: once }).email).toBe(once);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps an already-normalised number stable (idempotent)', () => {
|
it("keeps an already-normalised number stable (idempotent)", () => {
|
||||||
const once = normalizeOtpTarget({ phone: '0986680099' }).phone!;
|
const once = normalizeOtpTarget({ phone: "0986680099" }).phone!;
|
||||||
expect(normalizeOtpTarget({ phone: once }).phone).toBe(once);
|
expect(normalizeOtpTarget({ phone: once }).phone).toBe(once);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("isDomesticPhone", () => {
|
||||||
|
it.each(["+251986680099", "0986680099", "251986680099"])(
|
||||||
|
"accepts Ethiopian mobile form %s",
|
||||||
|
(phone) => expect(isDomesticPhone(phone)).toBe(true),
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])(
|
||||||
|
"rejects non-domestic or malformed %s",
|
||||||
|
(phone) => expect(isDomesticPhone(phone)).toBe(false),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
interface FakeRow {
|
interface FakeRow {
|
||||||
id: string;
|
id: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
@@ -51,7 +64,8 @@ function makeService(
|
|||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
|
|
||||||
const matches = (row: FakeRow, t: { phone?: string; email?: string }) =>
|
const matches = (row: FakeRow, t: { phone?: string; email?: string }) =>
|
||||||
(!!t.email && row.email === t.email) || (!!t.phone && row.phone === t.phone);
|
(!!t.email && row.email === t.email) ||
|
||||||
|
(!!t.phone && row.phone === t.phone);
|
||||||
|
|
||||||
const repo = {
|
const repo = {
|
||||||
findByTarget: jest.fn(
|
findByTarget: jest.fn(
|
||||||
@@ -89,30 +103,30 @@ function makeService(
|
|||||||
return { service, sms, email, rows: () => rows };
|
return { service, sms, email, rows: () => rows };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('OtpService — send/verify agree across phone formats', () => {
|
describe("OtpService — send/verify agree across phone formats", () => {
|
||||||
it('verifies a code sent to +251… when verify is called with 09…', async () => {
|
it("verifies a code sent to +251… when verify is called with 09…", async () => {
|
||||||
const { service, rows } = makeService();
|
const { service, rows } = makeService();
|
||||||
await service.sendOtp({ phone: '+251986680099' });
|
await service.sendOtp({ phone: "+251986680099" });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.verifyOtpForAction({ phone: '0986680099' }, rows()[0]!.otp),
|
service.verifyOtpForAction({ phone: "0986680099" }, rows()[0]!.otp),
|
||||||
).resolves.toEqual({ success: true });
|
).resolves.toEqual({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('verifies a code sent to User@X.com when verify is called with user@x.com', async () => {
|
it("verifies a code sent to User@X.com when verify is called with user@x.com", async () => {
|
||||||
const { service, rows } = makeService();
|
const { service, rows } = makeService();
|
||||||
await service.sendOtp({ email: ' User@Example.COM ' });
|
await service.sendOtp({ email: " User@Example.COM " });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
|
service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp),
|
||||||
).resolves.toEqual({ success: true });
|
).resolves.toEqual({ success: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('OtpService — dual-channel send', () => {
|
describe("OtpService — dual-channel send", () => {
|
||||||
const both = { phone: '0986680099', email: 'User@Example.COM' };
|
const both = { phone: "0986680099", email: "User@Example.COM" };
|
||||||
|
|
||||||
it('sends ONE code to both transports', async () => {
|
it("sends ONE code to both transports", async () => {
|
||||||
const { service, sms, email, rows } = makeService();
|
const { service, sms, email, rows } = makeService();
|
||||||
await service.sendOtp(both);
|
await service.sendOtp(both);
|
||||||
|
|
||||||
@@ -122,72 +136,95 @@ describe('OtpService — dual-channel send', () => {
|
|||||||
// Same secret on both messages — the user types whichever arrives first.
|
// Same secret on both messages — the user types whichever arrives first.
|
||||||
expect(sms.sendSms).toHaveBeenCalledWith(
|
expect(sms.sendSms).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
to: '+251986680099',
|
to: "+251986680099",
|
||||||
message: expect.stringContaining(otp),
|
message: expect.stringContaining(otp),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(email.sendEmail).toHaveBeenCalledWith(
|
expect(email.sendEmail).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
to: 'user@example.com',
|
to: "user@example.com",
|
||||||
text: expect.stringContaining(otp),
|
text: expect.stringContaining(otp),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// One row, both channels canonicalised.
|
// One row, both channels canonicalised.
|
||||||
expect(rows()).toHaveLength(1);
|
expect(rows()).toHaveLength(1);
|
||||||
expect(rows()[0]).toMatchObject({
|
expect(rows()[0]).toMatchObject({
|
||||||
phone: '+251986680099',
|
phone: "+251986680099",
|
||||||
email: 'user@example.com',
|
email: "user@example.com",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['phone alone', { phone: '0986680099' }],
|
["phone alone", { phone: "0986680099" }],
|
||||||
['email alone', { email: 'user@example.com' }],
|
["email alone", { email: "user@example.com" }],
|
||||||
['both', both],
|
["both", both],
|
||||||
])('verifies a dual-channel code when quoted back by %s', async (_label, target) => {
|
])(
|
||||||
const { service, rows } = makeService();
|
"verifies a dual-channel code when quoted back by %s",
|
||||||
await service.sendOtp(both);
|
async (_label, target) => {
|
||||||
|
const { service, rows } = makeService();
|
||||||
|
await service.sendOtp(both);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.verifyOtpForAction(target, rows()[0]!.otp),
|
service.verifyOtpForAction(target, rows()[0]!.otp),
|
||||||
).resolves.toEqual({ success: true });
|
).resolves.toEqual({ success: true });
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('consuming the code via one channel kills the other', async () => {
|
it("consuming the code via one channel kills the other", async () => {
|
||||||
const { service, rows } = makeService();
|
const { service, rows } = makeService();
|
||||||
await service.sendOtp(both);
|
await service.sendOtp(both);
|
||||||
const otp = rows()[0]!.otp;
|
const otp = rows()[0]!.otp;
|
||||||
|
|
||||||
await service.verifyOtpForAction({ email: 'user@example.com' }, otp);
|
await service.verifyOtpForAction({ email: "user@example.com" }, otp);
|
||||||
|
|
||||||
// Single-use is per-code, not per-channel: the phone half must be dead too.
|
// Single-use is per-code, not per-channel: the phone half must be dead too.
|
||||||
await expect(
|
await expect(
|
||||||
service.verifyOtpForAction({ phone: '0986680099' }, otp),
|
service.verifyOtpForAction({ phone: "0986680099" }, otp),
|
||||||
).rejects.toThrow(/No verification code was requested/);
|
).rejects.toThrow(/No verification code was requested/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replaces an overlapping single-channel row instead of colliding with it', async () => {
|
it("replaces an overlapping single-channel row instead of colliding with it", async () => {
|
||||||
const { service, rows } = makeService();
|
const { service, rows } = makeService();
|
||||||
// A pending signup code on the phone only, then a dual-channel send.
|
// A pending signup code on the phone only, then a dual-channel send.
|
||||||
await service.sendOtp({ phone: '0986680099' });
|
await service.sendOtp({ phone: "0986680099" });
|
||||||
await service.sendOtp(both);
|
await service.sendOtp(both);
|
||||||
|
|
||||||
expect(rows()).toHaveLength(1);
|
expect(rows()).toHaveLength(1);
|
||||||
expect(rows()[0]).toMatchObject({ email: 'user@example.com' });
|
expect(rows()[0]).toMatchObject({ email: "user@example.com" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('degrades to one channel when the account has only one contact', async () => {
|
it("degrades to one channel when the account has only one contact", async () => {
|
||||||
const { service, sms, email } = makeService();
|
const { service, sms, email } = makeService();
|
||||||
await service.sendOtp({ phone: '0986680099' });
|
await service.sendOtp({ phone: "0986680099" });
|
||||||
|
|
||||||
expect(sms.sendSms).toHaveBeenCalledTimes(1);
|
expect(sms.sendSms).toHaveBeenCalledTimes(1);
|
||||||
expect(email.sendEmail).not.toHaveBeenCalled();
|
expect(email.sendEmail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('still succeeds when one transport throws', async () => {
|
it("skips SMS for a foreign number when email is available", async () => {
|
||||||
|
const { service, sms, email, rows } = makeService();
|
||||||
|
await service.sendOtp({ phone: "+14155550123", email: "user@example.com" });
|
||||||
|
|
||||||
|
// The gateway is domestic-only — email is the delivery route, but the
|
||||||
|
// foreign phone stays on the row so verify still matches either channel.
|
||||||
|
expect(sms.sendSms).not.toHaveBeenCalled();
|
||||||
|
expect(email.sendEmail).toHaveBeenCalledTimes(1);
|
||||||
|
await expect(
|
||||||
|
service.verifyOtpForAction({ phone: "+14155550123" }, rows()[0]!.otp),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still attempts SMS for a foreign number when it is the only channel", async () => {
|
||||||
|
const { service, sms } = makeService();
|
||||||
|
await service.sendOtp({ phone: "+14155550123" });
|
||||||
|
|
||||||
|
expect(sms.sendSms).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still succeeds when one transport throws", async () => {
|
||||||
const { service, rows } = makeService({
|
const { service, rows } = makeService({
|
||||||
sms: async () => {
|
sms: async () => {
|
||||||
throw new Error('broker down');
|
throw new Error("broker down");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -197,24 +234,24 @@ describe('OtpService — dual-channel send', () => {
|
|||||||
});
|
});
|
||||||
// The code is live and verifiable on the channel that worked.
|
// The code is live and verifiable on the channel that worked.
|
||||||
await expect(
|
await expect(
|
||||||
service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp),
|
service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp),
|
||||||
).resolves.toEqual({ success: true });
|
).resolves.toEqual({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails the request when every transport throws', async () => {
|
it("fails the request when every transport throws", async () => {
|
||||||
const { service } = makeService({
|
const { service } = makeService({
|
||||||
sms: async () => {
|
sms: async () => {
|
||||||
throw new Error('broker down');
|
throw new Error("broker down");
|
||||||
},
|
},
|
||||||
email: async () => {
|
email: async () => {
|
||||||
throw new Error('broker down');
|
throw new Error("broker down");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(service.sendOtp(both)).rejects.toThrow('Failed to send OTP');
|
await expect(service.sendOtp(both)).rejects.toThrow("Failed to send OTP");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shares one brute-force budget across both channels', async () => {
|
it("shares one brute-force budget across both channels", async () => {
|
||||||
const { service, rows } = makeService();
|
const { service, rows } = makeService();
|
||||||
await service.sendOtp(both);
|
await service.sendOtp(both);
|
||||||
const otp = rows()[0]!.otp;
|
const otp = rows()[0]!.otp;
|
||||||
@@ -222,17 +259,17 @@ describe('OtpService — dual-channel send', () => {
|
|||||||
// Alternating channels must not hand the attacker two independent budgets:
|
// Alternating channels must not hand the attacker two independent budgets:
|
||||||
// 5 wrong guesses in total burn the code regardless of how they are split.
|
// 5 wrong guesses in total burn the code regardless of how they are split.
|
||||||
for (const target of [
|
for (const target of [
|
||||||
{ phone: '0986680099' },
|
{ phone: "0986680099" },
|
||||||
{ email: 'user@example.com' },
|
{ email: "user@example.com" },
|
||||||
{ phone: '0986680099' },
|
{ phone: "0986680099" },
|
||||||
{ email: 'user@example.com' },
|
{ email: "user@example.com" },
|
||||||
]) {
|
]) {
|
||||||
await expect(service.verifyOtpForAction(target, '000000')).rejects.toThrow(
|
await expect(
|
||||||
'Invalid verification code',
|
service.verifyOtpForAction(target, "000000"),
|
||||||
);
|
).rejects.toThrow("Invalid verification code");
|
||||||
}
|
}
|
||||||
await expect(
|
await expect(
|
||||||
service.verifyOtpForAction({ email: 'user@example.com' }, '000000'),
|
service.verifyOtpForAction({ email: "user@example.com" }, "000000"),
|
||||||
).rejects.toThrow(/Too many incorrect attempts/);
|
).rejects.toThrow(/Too many incorrect attempts/);
|
||||||
|
|
||||||
// Burned: even the correct code no longer works.
|
// Burned: even the correct code no longer works.
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ function channelsOf(target: OtpTarget): Array<"email" | "sms"> {
|
|||||||
*/
|
*/
|
||||||
function normalizePhone(rawPhone: string): string {
|
function normalizePhone(rawPhone: string): string {
|
||||||
const raw = rawPhone.trim();
|
const raw = rawPhone.trim();
|
||||||
const digits = raw.replace(/[^\d+]/g, '');
|
const digits = raw.replace(/[^\d+]/g, "");
|
||||||
if (digits.startsWith('+')) return digits;
|
if (digits.startsWith("+")) return digits;
|
||||||
const bare = digits.replace(/^0+/, '');
|
const bare = digits.replace(/^0+/, "");
|
||||||
if (/^251\d{9}$/.test(digits)) return `+${digits}`;
|
if (/^251\d{9}$/.test(digits)) return `+${digits}`;
|
||||||
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
|
if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`;
|
||||||
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
|
// Unknown shape (foreign number, already-clean intl without +) — prefix + if
|
||||||
@@ -46,6 +46,16 @@ function normalizePhone(rawPhone: string): string {
|
|||||||
return digits.length >= 11 ? `+${digits}` : raw;
|
return digits.length >= 11 ? `+${digits}` : raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a phone is an Ethiopian mobile the SMS gateway can actually reach —
|
||||||
|
* the carrier integration is domestic-only, so a send to anything else is
|
||||||
|
* queued and silently lost. Callers use this to fall back to email instead of
|
||||||
|
* pretending an SMS is on its way.
|
||||||
|
*/
|
||||||
|
export function isDomesticPhone(rawPhone: string): boolean {
|
||||||
|
return /^\+2519\d{8}$/.test(normalizePhone(rawPhone));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Canonicalise every channel present on the target. Each field is normalised
|
* Canonicalise every channel present on the target. Each field is normalised
|
||||||
* independently — a dual-channel target must end up with both halves in their
|
* independently — a dual-channel target must end up with both halves in their
|
||||||
@@ -143,6 +153,20 @@ export class OtpService {
|
|||||||
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
|
// /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists
|
||||||
// in the codebase yet.
|
// in the codebase yet.
|
||||||
|
|
||||||
|
// A foreign number is unreachable by the domestic-only SMS gateway; when
|
||||||
|
// email is also on the target, go email-only rather than queueing an SMS
|
||||||
|
// that will never arrive. With no email the SMS attempt stays — it is the
|
||||||
|
// only route there is.
|
||||||
|
const smsPhone =
|
||||||
|
target.phone && (!target.email || isDomesticPhone(target.phone))
|
||||||
|
? target.phone
|
||||||
|
: null;
|
||||||
|
if (target.phone && !smsPhone) {
|
||||||
|
this.logger.warn(
|
||||||
|
`otp.dispatch.sms-skipped target=${label} — non-domestic phone, delivering via email only`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Fan out to every channel the target has, independently: one transport
|
// Fan out to every channel the target has, independently: one transport
|
||||||
// being down must not suppress the other, which is the whole point of
|
// being down must not suppress the other, which is the whole point of
|
||||||
// sending to both. Each helper swallows its own failure so a rejected
|
// sending to both. Each helper swallows its own failure so a rejected
|
||||||
@@ -150,16 +174,14 @@ export class OtpService {
|
|||||||
const outcomes = (
|
const outcomes = (
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
target.email ? this.dispatchEmail(target.email, otp) : null,
|
target.email ? this.dispatchEmail(target.email, otp) : null,
|
||||||
target.phone ? this.dispatchSms(target.phone, otp) : null,
|
smsPhone ? this.dispatchSms(smsPhone, otp) : null,
|
||||||
])
|
])
|
||||||
).filter((outcome): outcome is DispatchOutcome => outcome !== null);
|
).filter((outcome): outcome is DispatchOutcome => outcome !== null);
|
||||||
|
|
||||||
for (const outcome of outcomes) {
|
for (const outcome of outcomes) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`otp.dispatch channel=${outcome.channel} target=${label} queued=${
|
`otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued
|
||||||
outcome.queued
|
} latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : ""
|
||||||
} latencyMs=${Date.now() - startedAt}${
|
|
||||||
outcome.error ? ` error=${outcome.error}` : ""
|
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -183,8 +205,7 @@ export class OtpService {
|
|||||||
// user who never receives a code — indistinguishable from carrier loss,
|
// user who never receives a code — indistinguishable from carrier loss,
|
||||||
// and the misleading success response makes it look like our side worked.
|
// and the misleading success response makes it look like our side worked.
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${
|
`otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${process.env.RABBITMQ_ENABLED ?? "unset"
|
||||||
process.env.RABBITMQ_ENABLED ?? "unset"
|
|
||||||
} — no transport reported hand-off; no code will arrive for this send`,
|
} — no transport reported hand-off; no code will arrive for this send`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -209,8 +230,7 @@ export class OtpService {
|
|||||||
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
|
// Log the real cause (DB/SMS/email failure) with its stack so a deployed
|
||||||
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
// "Failed to send OTP" 400 is diagnosable from the API logs, not opaque.
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${
|
`otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${Date.now() - startedAt
|
||||||
Date.now() - startedAt
|
|
||||||
}: ${error instanceof Error ? error.message : String(error)}`,
|
}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
error instanceof Error ? error.stack : undefined,
|
error instanceof Error ? error.stack : undefined,
|
||||||
);
|
);
|
||||||
@@ -270,9 +290,7 @@ export class OtpService {
|
|||||||
* address while printing the credential next to it would buy nothing.
|
* address while printing the credential next to it would buy nothing.
|
||||||
*/
|
*/
|
||||||
private targetLabel(target: OtpTarget): string {
|
private targetLabel(target: OtpTarget): string {
|
||||||
return (
|
return [target.email, target.phone].filter(Boolean).join("+") || "unknown";
|
||||||
[target.email, target.phone].filter(Boolean).join("+") || "unknown"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -288,9 +306,8 @@ export class OtpService {
|
|||||||
) {
|
) {
|
||||||
const line = `otp.verify channels=${channelsOf(target).join(
|
const line = `otp.verify channels=${channelsOf(target).join(
|
||||||
"+",
|
"+",
|
||||||
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${
|
)} target=${this.targetLabel(target)} mode=${mode} result=${result}${detail ? ` ${detail}` : ""
|
||||||
detail ? ` ${detail}` : ""
|
}`;
|
||||||
}`;
|
|
||||||
if (result === "ok") this.logger.log(line);
|
if (result === "ok") this.logger.log(line);
|
||||||
else this.logger.warn(line);
|
else this.logger.warn(line);
|
||||||
}
|
}
|
||||||
@@ -439,7 +456,12 @@ export class OtpService {
|
|||||||
await this.otpRepository.deleteOtp(otpData);
|
await this.otpRepository.deleteOtp(otpData);
|
||||||
this.actionAttempts.delete(key);
|
this.actionAttempts.delete(key);
|
||||||
|
|
||||||
this.logVerify(target, "action", "expired", `ageMs=${ageMs} ttlMs=${ttlMs}`);
|
this.logVerify(
|
||||||
|
target,
|
||||||
|
"action",
|
||||||
|
"expired",
|
||||||
|
`ageMs=${ageMs} ttlMs=${ttlMs}`,
|
||||||
|
);
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Verification code has expired. Request a new one.",
|
"Verification code has expired. Request a new one.",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -80,7 +80,12 @@ const makeBooking = (
|
|||||||
|
|
||||||
describe('TrainSchedulingService', () => {
|
describe('TrainSchedulingService', () => {
|
||||||
let service: TrainSchedulingService;
|
let service: TrainSchedulingService;
|
||||||
let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; query: jest.Mock };
|
let dataSource: {
|
||||||
|
getRepository: jest.Mock;
|
||||||
|
transaction: jest.Mock;
|
||||||
|
query: jest.Mock;
|
||||||
|
manager: { getRepository: jest.Mock };
|
||||||
|
};
|
||||||
let bookingsRepository: Record<string, jest.Mock>;
|
let bookingsRepository: Record<string, jest.Mock>;
|
||||||
let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||||
let wagonTypesRepository: { findAll: jest.Mock };
|
let wagonTypesRepository: { findAll: jest.Mock };
|
||||||
@@ -91,11 +96,23 @@ describe('TrainSchedulingService', () => {
|
|||||||
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
|
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
// findGroupSiblings runs a query builder off dataSource.manager; default it
|
||||||
|
// to "no sibling schedules" so isolated unit tests don't need to wire it.
|
||||||
|
const emptySiblingQb = {
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
getMany: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
dataSource = {
|
dataSource = {
|
||||||
getRepository: jest.fn(),
|
getRepository: jest.fn(),
|
||||||
transaction: jest.fn(),
|
transaction: jest.fn(),
|
||||||
// Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows".
|
// Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows".
|
||||||
query: jest.fn().mockResolvedValue([]),
|
query: jest.fn().mockResolvedValue([]),
|
||||||
|
manager: {
|
||||||
|
getRepository: jest.fn(() => ({
|
||||||
|
createQueryBuilder: jest.fn(() => emptySiblingQb),
|
||||||
|
})),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
bookingsRepository = {
|
bookingsRepository = {
|
||||||
findEligibleForScheduling: jest.fn(),
|
findEligibleForScheduling: jest.fn(),
|
||||||
@@ -110,9 +127,11 @@ describe('TrainSchedulingService', () => {
|
|||||||
findByIdWithFullGraph: jest.fn(),
|
findByIdWithFullGraph: jest.fn(),
|
||||||
findAll: jest.fn(),
|
findAll: jest.fn(),
|
||||||
updateStatus: jest.fn(),
|
updateStatus: jest.fn(),
|
||||||
|
maxReferenceSequence: jest.fn().mockResolvedValue(0),
|
||||||
};
|
};
|
||||||
trainScheduleBookingsRepository = {
|
trainScheduleBookingsRepository = {
|
||||||
findByBookingIds: jest.fn(),
|
findByBookingIds: jest.fn(),
|
||||||
|
findByScheduleId: jest.fn().mockResolvedValue([]),
|
||||||
createMany: jest.fn(),
|
createMany: jest.fn(),
|
||||||
deleteByScheduleAndBooking: jest.fn(),
|
deleteByScheduleAndBooking: jest.fn(),
|
||||||
};
|
};
|
||||||
@@ -327,7 +346,11 @@ describe('TrainSchedulingService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('allows preview when bookings are already on the target schedule', async () => {
|
it('allows preview when bookings are already on the target schedule', async () => {
|
||||||
const bookings = [makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20)];
|
// A booking already pinned to the target schedule is exempt from the
|
||||||
|
// corridor/day/status gates — mark it so on the entity, matching the link row.
|
||||||
|
const bookings = [
|
||||||
|
{ ...makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20), trainScheduleId: 'sched-target' },
|
||||||
|
];
|
||||||
|
|
||||||
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
|
||||||
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings);
|
||||||
@@ -354,7 +377,10 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows preview when selected bookings are on different schedule dates', async () => {
|
it('flags a booking scheduled for a different day than the train departure', async () => {
|
||||||
|
// The old cross-booking "must share the same schedule date" rule is gone;
|
||||||
|
// the live rule is that every booking must match the departure day. b2
|
||||||
|
// departs a day later, so it's the one flagged.
|
||||||
const bookings = [
|
const bookings = [
|
||||||
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'),
|
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'),
|
||||||
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'),
|
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'),
|
||||||
@@ -375,7 +401,8 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(result.violations).not.toContain(
|
expect(result.violations).not.toContain(
|
||||||
'Selected bookings must share the same schedule date',
|
'Selected bookings must share the same schedule date',
|
||||||
);
|
);
|
||||||
expect(result.valid).toBe(true);
|
expect(result.violations.some((v) => v.includes('different day'))).toBe(true);
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects bookings that are not in schedulable status', async () => {
|
it('rejects bookings that are not in schedulable status', async () => {
|
||||||
@@ -408,6 +435,8 @@ describe('TrainSchedulingService', () => {
|
|||||||
originYardId: 'yard-origin',
|
originYardId: 'yard-origin',
|
||||||
destinationYardId: 'yard-destination',
|
destinationYardId: 'yard-destination',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
status: 'AVAILABLE',
|
||||||
|
direction: 'IMPORT',
|
||||||
};
|
};
|
||||||
|
|
||||||
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
|
const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' };
|
||||||
@@ -421,6 +450,12 @@ describe('TrainSchedulingService', () => {
|
|||||||
const trainScheduleRepo = {
|
const trainScheduleRepo = {
|
||||||
create: jest.fn().mockImplementation((value) => value),
|
create: jest.fn().mockImplementation((value) => value),
|
||||||
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
|
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
|
||||||
|
// findGroupWindowAnchor looks for same-day sibling schedules; none here.
|
||||||
|
createQueryBuilder: jest.fn(() => ({
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
getMany: jest.fn().mockResolvedValue([]),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
const trainSetRepo = {
|
const trainSetRepo = {
|
||||||
create: jest.fn().mockImplementation((value) => value),
|
create: jest.fn().mockImplementation((value) => value),
|
||||||
@@ -464,19 +499,21 @@ describe('TrainSchedulingService', () => {
|
|||||||
callback(manager),
|
callback(manager),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Departure must clear the import lead window (≥ importWindowLeadDays ahead
|
||||||
|
// of now), so use a comfortably-future date rather than a hardcoded one.
|
||||||
|
const futureDeparture = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
const result = await service.createContainerTrainSchedule({
|
const result = await service.createContainerTrainSchedule({
|
||||||
routeId: 'route-1',
|
routeId: 'route-1',
|
||||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
scheduleDate: futureDeparture,
|
||||||
locomotiveIds: ['loc-1', 'loc-2'],
|
locomotiveIds: ['loc-1', 'loc-2'],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(trainSetRepo.save).toHaveBeenCalled();
|
expect(trainSetRepo.save).toHaveBeenCalled();
|
||||||
expect(trainScheduleRepo.save).toHaveBeenCalled();
|
expect(trainScheduleRepo.save).toHaveBeenCalled();
|
||||||
expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
|
expect(trainSetLocomotiveRepo.save).toHaveBeenCalled();
|
||||||
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith(
|
// Advance scheduling locks locomotives but does NOT flip them to ASSIGNED —
|
||||||
{ id: expect.objectContaining({ _type: 'in', _value: ['loc-1', 'loc-2'] }) },
|
// one locomotive may sit on several future schedules.
|
||||||
{ status: 'ASSIGNED' },
|
expect(lockedLocomotiveRepo.update).not.toHaveBeenCalled();
|
||||||
);
|
|
||||||
expect(result.id).toBe('schedule-1');
|
expect(result.id).toBe('schedule-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -492,7 +529,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
destinationYardId: 'yard-destination',
|
destinationYardId: 'yard-destination',
|
||||||
status: 'PAID',
|
status: 'PAID',
|
||||||
bookingContainers: [],
|
bookingContainers: [],
|
||||||
cargoType: { code: 'COFFEE' },
|
cargoType: { id: 'cargo-coffee', code: 'COFFEE', wagonTypes: [cw3] },
|
||||||
};
|
};
|
||||||
|
|
||||||
wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => {
|
wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => {
|
||||||
@@ -511,7 +548,9 @@ describe('TrainSchedulingService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
expect(result.summary.wagonType).toBe('MIXED');
|
// Mixed freight now labels the summary by the concrete wagon type codes it uses.
|
||||||
|
expect(result.summary.wagonType).toContain('NW5');
|
||||||
|
expect(result.summary.wagonType).toContain('CW3');
|
||||||
expect(result.wagonPlan.length).toBeGreaterThan(2);
|
expect(result.wagonPlan.length).toBeGreaterThan(2);
|
||||||
expect(result.containerUnits).toHaveLength(2);
|
expect(result.containerUnits).toHaveLength(2);
|
||||||
});
|
});
|
||||||
@@ -536,9 +575,11 @@ describe('TrainSchedulingService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects create when the locked locomotive is no longer available', async () => {
|
it('rejects create when the locked locomotive is no longer available', async () => {
|
||||||
|
// Advance scheduling only hard-blocks OUT_OF_SERVICE locomotives; other
|
||||||
|
// non-AVAILABLE states (e.g. ASSIGNED) downgrade to a warning.
|
||||||
const manager = {
|
const manager = {
|
||||||
getRepository: jest.fn(() => ({
|
getRepository: jest.fn(() => ({
|
||||||
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }),
|
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'OUT_OF_SERVICE' }),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -551,6 +592,8 @@ describe('TrainSchedulingService', () => {
|
|||||||
originYardId: 'yard-origin',
|
originYardId: 'yard-origin',
|
||||||
destinationYardId: 'yard-destination',
|
destinationYardId: 'yard-destination',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
status: 'AVAILABLE',
|
||||||
|
direction: 'IMPORT',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -907,7 +950,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getAvailableLocomotivesForRoute', () => {
|
describe('getAvailableLocomotivesForRoute', () => {
|
||||||
it('returns locomotives at the route origin yard', async () => {
|
it('returns every in-service locomotive, annotated with origin-yard presence', async () => {
|
||||||
const routeId = 'route-export';
|
const routeId = 'route-export';
|
||||||
const originYardId = 'yard-addis';
|
const originYardId = 'yard-addis';
|
||||||
const routeRepo = {
|
const routeRepo = {
|
||||||
@@ -915,6 +958,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
id: routeId,
|
id: routeId,
|
||||||
name: 'Addis → Djibouti',
|
name: 'Addis → Djibouti',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
status: 'AVAILABLE',
|
||||||
originYardId,
|
originYardId,
|
||||||
originYard: { country: 'Ethiopia' },
|
originYard: { country: 'Ethiopia' },
|
||||||
destinationYard: { country: 'Djibouti' },
|
destinationYard: { country: 'Djibouti' },
|
||||||
@@ -924,21 +968,21 @@ describe('TrainSchedulingService', () => {
|
|||||||
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
||||||
return { findOne: jest.fn(), update: jest.fn() };
|
return { findOne: jest.fn(), update: jest.fn() };
|
||||||
});
|
});
|
||||||
|
// Advance-scheduling picker: nothing is filtered by yard — every in-service
|
||||||
|
// locomotive is returned and annotated with whether it's at the origin yet.
|
||||||
locomotivesRepository.findAll.mockResolvedValue([
|
locomotivesRepository.findAll.mockResolvedValue([
|
||||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
|
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
|
||||||
|
{ id: 'l3', code: 'FAR', status: 'ASSIGNED', currentYardId: 'yard-elsewhere' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
||||||
|
|
||||||
expect(locomotivesRepository.findAll).toHaveBeenCalledWith({
|
expect(result).toHaveLength(2);
|
||||||
where: { status: 'AVAILABLE', currentYardId: originYardId },
|
expect(result.find((l) => l.code === 'EXP')?.atOriginYard).toBe(true);
|
||||||
order: { code: 'ASC' },
|
expect(result.find((l) => l.code === 'FAR')?.atOriginYard).toBe(false);
|
||||||
});
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].code).toBe('EXP');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns all locomotives returned by the repository for domestic routes', async () => {
|
it('rejects intercity (domestic) routes — intercity scheduling is not offered', async () => {
|
||||||
const routeId = 'route-domestic';
|
const routeId = 'route-domestic';
|
||||||
const originYardId = 'yard-addis';
|
const originYardId = 'yard-addis';
|
||||||
const routeRepo = {
|
const routeRepo = {
|
||||||
@@ -946,6 +990,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
id: routeId,
|
id: routeId,
|
||||||
name: 'Addis → Dire Dawa',
|
name: 'Addis → Dire Dawa',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
status: 'AVAILABLE',
|
||||||
originYardId,
|
originYardId,
|
||||||
originYard: { country: 'Ethiopia' },
|
originYard: { country: 'Ethiopia' },
|
||||||
destinationYard: { country: 'Ethiopia' },
|
destinationYard: { country: 'Ethiopia' },
|
||||||
@@ -955,14 +1000,10 @@ describe('TrainSchedulingService', () => {
|
|||||||
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
if ((entity as { name?: string })?.name === 'Route') return routeRepo;
|
||||||
return { findOne: jest.fn(), update: jest.fn() };
|
return { findOne: jest.fn(), update: jest.fn() };
|
||||||
});
|
});
|
||||||
locomotivesRepository.findAll.mockResolvedValue([
|
|
||||||
{ id: 'l1', code: 'IMP', status: 'AVAILABLE', currentYardId: originYardId },
|
|
||||||
{ id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await service.getAvailableLocomotivesForRoute(routeId);
|
await expect(
|
||||||
|
service.getAvailableLocomotivesForRoute(routeId),
|
||||||
expect(result).toHaveLength(2);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -61,8 +61,11 @@ export default function ResetPasswordAction({
|
|||||||
|
|
||||||
if (!allowed) return null;
|
if (!allowed) return null;
|
||||||
|
|
||||||
|
// SMS is domestic-only: a foreign number counts as unavailable, same as a
|
||||||
|
// missing one, so staff can't send a link that will never arrive.
|
||||||
|
const phoneUsable = !!target?.phone && target.phoneIsDomestic !== false;
|
||||||
const channelMissing =
|
const channelMissing =
|
||||||
!!target && (channel === "email" ? !target.email : !target.phone);
|
!!target && (channel === "email" ? !target.email : !phoneUsable);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -106,9 +109,13 @@ export default function ResetPasswordAction({
|
|||||||
<Radio
|
<Radio
|
||||||
value="phone"
|
value="phone"
|
||||||
label="SMS"
|
label="SMS"
|
||||||
disabled={!target.phone}
|
disabled={!phoneUsable}
|
||||||
description={
|
description={
|
||||||
target.phone ?? "No phone number on this account"
|
!target.phone
|
||||||
|
? "No phone number on this account"
|
||||||
|
: target.phoneIsDomestic === false
|
||||||
|
? `${target.phone} — foreign number, SMS unavailable; use email`
|
||||||
|
: target.phone
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Radio
|
<Radio
|
||||||
|
|||||||
@@ -298,34 +298,82 @@ export function ProfileApprovalActions({
|
|||||||
const { mutate, isPending } = useMutation(
|
const { mutate, isPending } = useMutation(
|
||||||
api.customers.setProfileStatus.mutationOptions(),
|
api.customers.setProfileStatus.mutationOptions(),
|
||||||
);
|
);
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [decision, setDecision] = useState<
|
||||||
|
"reject" | "suspend" | "reactivate" | null
|
||||||
|
>(null);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
|
|
||||||
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
|
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
|
||||||
|
|
||||||
const confirmReject = () => {
|
// Decisions the customer must be given a reason for. Reject/suspend/reactivate
|
||||||
|
// all capture a required message through the same modal; the API refuses
|
||||||
|
// suspend/reactivate without one.
|
||||||
|
const DECISIONS = {
|
||||||
|
reject: {
|
||||||
|
title: "Reject profile",
|
||||||
|
intro:
|
||||||
|
"Tell the customer what needs fixing. They'll see this note and can " +
|
||||||
|
"amend and resubmit the role for approval.",
|
||||||
|
label: "Reason for rejection",
|
||||||
|
placeholder: "e.g. The uploaded business license is expired.",
|
||||||
|
confirmLabel: "Reject profile",
|
||||||
|
color: "red",
|
||||||
|
status: "rejected" as ProfileStatus,
|
||||||
|
},
|
||||||
|
suspend: {
|
||||||
|
title: "Suspend role",
|
||||||
|
intro:
|
||||||
|
"Explain why this role is being suspended. The customer will see this " +
|
||||||
|
"message and cannot operate under the role until it is reactivated.",
|
||||||
|
label: "Reason for suspension",
|
||||||
|
placeholder: "e.g. Outstanding invoices unpaid for over 90 days.",
|
||||||
|
confirmLabel: "Suspend role",
|
||||||
|
color: "orange",
|
||||||
|
status: "suspended" as ProfileStatus,
|
||||||
|
},
|
||||||
|
reactivate: {
|
||||||
|
title: "Reactivate role",
|
||||||
|
intro:
|
||||||
|
"Explain why this role is being reactivated. The customer will see " +
|
||||||
|
"this message and can operate under the role again.",
|
||||||
|
label: "Reactivation message",
|
||||||
|
placeholder: "e.g. Outstanding payments have been settled.",
|
||||||
|
confirmLabel: "Reactivate role",
|
||||||
|
color: "edr-green",
|
||||||
|
status: "active" as ProfileStatus,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const openDecision = (kind: keyof typeof DECISIONS) => {
|
||||||
|
setNote("");
|
||||||
|
setDecision(kind);
|
||||||
|
};
|
||||||
|
|
||||||
|
const active = decision ? DECISIONS[decision] : null;
|
||||||
|
|
||||||
|
const confirmDecision = () => {
|
||||||
|
if (!active) return;
|
||||||
mutate(
|
mutate(
|
||||||
{ profileId, status: "rejected", note: note.trim() },
|
{ profileId, status: active.status, note: note.trim() },
|
||||||
{ onSuccess: () => setRejectOpen(false) },
|
{ onSuccess: () => setDecision(null) },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rejectModal = (
|
const decisionModal = active && (
|
||||||
<Modal
|
<Modal
|
||||||
opened={rejectOpen}
|
opened
|
||||||
onClose={() => setRejectOpen(false)}
|
onClose={() => setDecision(null)}
|
||||||
title="Reject profile"
|
title={active.title}
|
||||||
centered
|
centered
|
||||||
radius="lg"
|
radius="lg"
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Tell the customer what needs fixing. They'll see this note and can
|
{active.intro}
|
||||||
amend and resubmit the role for approval.
|
|
||||||
</Text>
|
</Text>
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Reason for rejection"
|
label={active.label}
|
||||||
placeholder="e.g. The uploaded business license is expired."
|
placeholder={active.placeholder}
|
||||||
autosize
|
autosize
|
||||||
minRows={3}
|
minRows={3}
|
||||||
value={note}
|
value={note}
|
||||||
@@ -335,18 +383,18 @@ export function ProfileApprovalActions({
|
|||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => setRejectOpen(false)}
|
onClick={() => setDecision(null)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color={active.color}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
disabled={note.trim().length === 0}
|
disabled={note.trim().length === 0}
|
||||||
onClick={confirmReject}
|
onClick={confirmDecision}
|
||||||
>
|
>
|
||||||
Reject profile
|
{active.confirmLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -368,7 +416,7 @@ export function ProfileApprovalActions({
|
|||||||
if (status === "pending") {
|
if (status === "pending") {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{rejectModal}
|
{decisionModal}
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
@@ -385,7 +433,7 @@ export function ProfileApprovalActions({
|
|||||||
variant="light"
|
variant="light"
|
||||||
color="red"
|
color="red"
|
||||||
radius="md"
|
radius="md"
|
||||||
onClick={() => setRejectOpen(true)}
|
onClick={() => openDecision("reject")}
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
@@ -411,29 +459,33 @@ export function ProfileApprovalActions({
|
|||||||
|
|
||||||
if (status === "active") {
|
if (status === "active") {
|
||||||
return (
|
return (
|
||||||
<Button
|
<>
|
||||||
size="xs"
|
{decisionModal}
|
||||||
variant="light"
|
<Button
|
||||||
color="orange"
|
size="xs"
|
||||||
radius="md"
|
variant="light"
|
||||||
loading={isPending}
|
color="orange"
|
||||||
onClick={() => act("suspended")}
|
radius="md"
|
||||||
>
|
loading={isPending}
|
||||||
Suspend
|
onClick={() => openDecision("suspend")}
|
||||||
</Button>
|
>
|
||||||
|
Suspend
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "suspended") {
|
if (status === "suspended") {
|
||||||
return (
|
return (
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
|
{decisionModal}
|
||||||
<Button
|
<Button
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="md"
|
radius="md"
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
onClick={() => act("active")}
|
onClick={() => openDecision("reactivate")}
|
||||||
>
|
>
|
||||||
Reactivate
|
Reactivate
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ const VIEW_FILTERS: Record<
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SORT_OPTIONS = [
|
const SORT_OPTIONS = [
|
||||||
|
// Queue ordering: awaiting first approval → pending profile changes → the
|
||||||
|
// rest, newest first within each group. The default, so whatever marketing
|
||||||
|
// must act on is always on top of the list.
|
||||||
|
{ value: "review:DESC", label: "Needs review first" },
|
||||||
{ value: "createdAt:DESC", label: "Newest first" },
|
{ value: "createdAt:DESC", label: "Newest first" },
|
||||||
{ value: "createdAt:ASC", label: "Oldest first" },
|
{ value: "createdAt:ASC", label: "Oldest first" },
|
||||||
{ value: "name:ASC", label: "Name (A–Z)" },
|
{ value: "name:ASC", label: "Name (A–Z)" },
|
||||||
@@ -93,11 +97,11 @@ export default function CustomersPage() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||||
const [view, setView] = useState<CustomerView>("all");
|
const [view, setView] = useState<CustomerView>("all");
|
||||||
const [sort, setSort] = useState<string>("createdAt:DESC");
|
const [sort, setSort] = useState<string>("review:DESC");
|
||||||
|
|
||||||
const filter = useMemo(() => {
|
const filter = useMemo(() => {
|
||||||
const [sortBy, sortOrder] = sort.split(":") as [
|
const [sortBy, sortOrder] = sort.split(":") as [
|
||||||
"name" | "createdAt" | "updatedAt",
|
"review" | "name" | "createdAt" | "updatedAt",
|
||||||
"ASC" | "DESC",
|
"ASC" | "DESC",
|
||||||
];
|
];
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ export interface CustomerResetTarget {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
|
/** SMS gateway is domestic-only; `false` means SMS can't reach this phone. `null` = no phone. */
|
||||||
|
phoneIsDomestic: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mirrors backend `Company` (+ its `companyProfiles`). */
|
/** Mirrors backend `Company` (+ its `companyProfiles`). */
|
||||||
@@ -207,7 +209,8 @@ export interface CompanyListFilter {
|
|||||||
* already `active`, so `status` alone can never surface them.
|
* already `active`, so `status` alone can never surface them.
|
||||||
*/
|
*/
|
||||||
hasPendingChangeRequest?: boolean;
|
hasPendingChangeRequest?: boolean;
|
||||||
sortBy?: "name" | "createdAt" | "updatedAt";
|
/** `review` = queue ordering: awaiting first approval → pending changes → rest, newest first within each. */
|
||||||
|
sortBy?: "review" | "name" | "createdAt" | "updatedAt";
|
||||||
sortOrder?: "ASC" | "DESC";
|
sortOrder?: "ASC" | "DESC";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -220,8 +220,14 @@ const sidebarItems: SidebarItem[] = [
|
|||||||
const App = () => {
|
const App = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, company, companyType, createProfile, isAuthenticated } =
|
const {
|
||||||
useAuth();
|
user,
|
||||||
|
company,
|
||||||
|
companyType,
|
||||||
|
createProfile,
|
||||||
|
reapplyProfile,
|
||||||
|
isAuthenticated,
|
||||||
|
} = useAuth();
|
||||||
|
|
||||||
// Attribute replays and exceptions to the signed-in user (id/org only).
|
// Attribute replays and exceptions to the signed-in user (id/org only).
|
||||||
useIdentify(user, company);
|
useIdentify(user, company);
|
||||||
@@ -290,6 +296,7 @@ const App = () => {
|
|||||||
companyProfiles={companyProfiles}
|
companyProfiles={companyProfiles}
|
||||||
companyType={companyType}
|
companyType={companyType}
|
||||||
onCreateProfile={createProfile}
|
onCreateProfile={createProfile}
|
||||||
|
onReapplyProfile={reapplyProfile}
|
||||||
>
|
>
|
||||||
<OnboardingGate />
|
<OnboardingGate />
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
AppShell,
|
AppShell,
|
||||||
Avatar,
|
Avatar,
|
||||||
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Divider,
|
Divider,
|
||||||
@@ -19,12 +21,14 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
|
Ban,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
LogOut,
|
LogOut,
|
||||||
Menu as MenuIcon,
|
Menu as MenuIcon,
|
||||||
Moon,
|
Moon,
|
||||||
Plus,
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
Sun,
|
Sun,
|
||||||
@@ -59,7 +63,13 @@ export interface AppLayoutProps {
|
|||||||
userName?: string;
|
userName?: string;
|
||||||
userEmail?: string;
|
userEmail?: string;
|
||||||
/** Operational profiles for the company — surfaced as reference chips in the account menu. */
|
/** Operational profiles for the company — surfaced as reference chips in the account menu. */
|
||||||
companyProfiles?: { type: string; reference: string; status?: string }[];
|
companyProfiles?: {
|
||||||
|
id?: string;
|
||||||
|
type: string;
|
||||||
|
reference: string;
|
||||||
|
status?: string;
|
||||||
|
reviewNote?: string | null;
|
||||||
|
}[];
|
||||||
/** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */
|
/** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */
|
||||||
companyType?: string | null;
|
companyType?: string | null;
|
||||||
/** Create a new service profile of the given type (with business license). */
|
/** Create a new service profile of the given type (with business license). */
|
||||||
@@ -67,6 +77,11 @@ export interface AppLayoutProps {
|
|||||||
type: ServiceType,
|
type: ServiceType,
|
||||||
licenseFiles: File[],
|
licenseFiles: File[],
|
||||||
) => Promise<SwitchResult> | void;
|
) => Promise<SwitchResult> | void;
|
||||||
|
/** Resubmit a rejected service for approval, optionally replacing its license. */
|
||||||
|
onReapplyProfile?: (
|
||||||
|
profileId: string,
|
||||||
|
licenseFiles: File[],
|
||||||
|
) => Promise<SwitchResult> | void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +160,7 @@ export function AppLayout({
|
|||||||
companyProfiles = [],
|
companyProfiles = [],
|
||||||
companyType,
|
companyType,
|
||||||
onCreateProfile,
|
onCreateProfile,
|
||||||
|
onReapplyProfile,
|
||||||
children,
|
children,
|
||||||
}: AppLayoutProps) {
|
}: AppLayoutProps) {
|
||||||
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
|
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
|
||||||
@@ -178,42 +194,86 @@ export function AppLayout({
|
|||||||
const profileExists = (type: ServiceType) =>
|
const profileExists = (type: ServiceType) =>
|
||||||
companyProfiles.some((p) => p.type === type);
|
companyProfiles.some((p) => p.type === type);
|
||||||
const addableServices = CUSTOMER_SERVICES.filter((t) => !profileExists(t));
|
const addableServices = CUSTOMER_SERVICES.filter((t) => !profileExists(t));
|
||||||
const canAddService = isCustomer && addableServices.length > 0;
|
// Rejected services can't be re-added (they exist), so they'd otherwise be
|
||||||
|
// invisible here — surface them for resubmission alongside addable ones.
|
||||||
|
const rejectedServices = isCustomer
|
||||||
|
? companyProfiles.filter(
|
||||||
|
(p) =>
|
||||||
|
p.status === "rejected" &&
|
||||||
|
p.id &&
|
||||||
|
CUSTOMER_SERVICES.includes(p.type as ServiceType),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
// Suspended services are also hidden by default (the profile exists) — surface
|
||||||
|
// them so the customer can appeal by resubmitting a fresh business license.
|
||||||
|
const suspendedServices = isCustomer
|
||||||
|
? companyProfiles.filter(
|
||||||
|
(p) =>
|
||||||
|
p.status === "suspended" &&
|
||||||
|
p.id &&
|
||||||
|
CUSTOMER_SERVICES.includes(p.type as ServiceType),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const canManageServices =
|
||||||
|
isCustomer &&
|
||||||
|
(addableServices.length > 0 ||
|
||||||
|
rejectedServices.length > 0 ||
|
||||||
|
suspendedServices.length > 0);
|
||||||
|
|
||||||
const [switching, setSwitching] = useState(false);
|
const [switching, setSwitching] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [createTarget, setCreateTarget] = useState<ServiceType>("importer");
|
const [createTarget, setCreateTarget] = useState<ServiceType>("importer");
|
||||||
|
// Non-null while resubmitting a rejected/suspended service; null while creating a new one.
|
||||||
|
const [reapplyId, setReapplyId] = useState<string | null>(null);
|
||||||
|
// Status of the profile being resubmitted ("rejected" | "suspended") — drives
|
||||||
|
// the modal copy; null for a brand-new profile.
|
||||||
|
const [reapplyStatus, setReapplyStatus] = useState<string | null>(null);
|
||||||
|
// Reason the profile was suspended/rejected, surfaced in the modal.
|
||||||
|
const [reapplyNote, setReapplyNote] = useState<string | null>(null);
|
||||||
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
|
const [licenseFiles, setLicenseFiles] = useState<File[]>([]);
|
||||||
const [createError, setCreateError] = useState<string | null>(null);
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleAddService = (type: ServiceType) => {
|
const openServiceModal = (
|
||||||
// Collect a business license, then create the profile.
|
type: ServiceType,
|
||||||
|
profile?: { id?: string; status?: string; reviewNote?: string | null },
|
||||||
|
) => {
|
||||||
setCreateTarget(type);
|
setCreateTarget(type);
|
||||||
|
setReapplyId(profile?.id ?? null);
|
||||||
|
setReapplyStatus(profile?.status ?? null);
|
||||||
|
setReapplyNote(profile?.reviewNote ?? null);
|
||||||
setLicenseFiles([]);
|
setLicenseFiles([]);
|
||||||
setCreateError(null);
|
setCreateError(null);
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddService = (type: ServiceType) => openServiceModal(type);
|
||||||
|
|
||||||
const handleCreateConfirm = async () => {
|
const handleCreateConfirm = async () => {
|
||||||
if (licenseFiles.length === 0) {
|
const isReapply = reapplyId !== null;
|
||||||
|
// A new profile needs its license up front; a resubmit may reuse the old one.
|
||||||
|
if (!isReapply && licenseFiles.length === 0) {
|
||||||
setCreateError("Please upload at least one business license file.");
|
setCreateError("Please upload at least one business license file.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSwitching(true);
|
setSwitching(true);
|
||||||
setCreateError(null);
|
setCreateError(null);
|
||||||
try {
|
try {
|
||||||
const res = await onCreateProfile?.(createTarget, licenseFiles);
|
const res = isReapply
|
||||||
|
? await onReapplyProfile?.(reapplyId, licenseFiles)
|
||||||
|
: await onCreateProfile?.(createTarget, licenseFiles);
|
||||||
if (res && !res.success) {
|
if (res && !res.success) {
|
||||||
setCreateError(res.error?.message ?? "Failed to create profile");
|
setCreateError(res.error?.message ?? "Failed to submit service");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
|
setReapplyId(null);
|
||||||
} finally {
|
} finally {
|
||||||
setSwitching(false);
|
setSwitching(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m;
|
const serviceLabel = (m: ServiceType) => PROFILE_TYPE_LABELS[m] ?? m;
|
||||||
|
const isSuspendedAppeal = reapplyStatus === "suspended";
|
||||||
|
|
||||||
const isItemActive = (item: SidebarItem) =>
|
const isItemActive = (item: SidebarItem) =>
|
||||||
activePath === item.href.toLowerCase() ||
|
activePath === item.href.toLowerCase() ||
|
||||||
@@ -287,10 +347,10 @@ export function AppLayout({
|
|||||||
|
|
||||||
{/* Right: switch + search + bell + avatar */}
|
{/* Right: switch + search + bell + avatar */}
|
||||||
<Group gap={10} wrap="nowrap" align="center">
|
<Group gap={10} wrap="nowrap" align="center">
|
||||||
{/* Add a service (customer companies that don't yet have all three) */}
|
{/* Add a service, or resubmit a rejected one (customer companies) */}
|
||||||
{canAddService && (
|
{canManageServices && (
|
||||||
<Menu
|
<Menu
|
||||||
width={220}
|
width={240}
|
||||||
position="bottom-end"
|
position="bottom-end"
|
||||||
withinPortal
|
withinPortal
|
||||||
shadow="md"
|
shadow="md"
|
||||||
@@ -313,16 +373,62 @@ export function AppLayout({
|
|||||||
</Button>
|
</Button>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Label>Add a service</Menu.Label>
|
{addableServices.length > 0 && (
|
||||||
{addableServices.map((type) => (
|
<>
|
||||||
<Menu.Item
|
<Menu.Label>Add a service</Menu.Label>
|
||||||
key={type}
|
{addableServices.map((type) => (
|
||||||
onClick={() => handleAddService(type)}
|
<Menu.Item
|
||||||
leftSection={<Plus size={15} strokeWidth={1.8} />}
|
key={type}
|
||||||
>
|
onClick={() => handleAddService(type)}
|
||||||
{serviceLabel(type)}
|
leftSection={<Plus size={15} strokeWidth={1.8} />}
|
||||||
</Menu.Item>
|
>
|
||||||
))}
|
{serviceLabel(type)}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{rejectedServices.length > 0 && (
|
||||||
|
<>
|
||||||
|
{addableServices.length > 0 && <Menu.Divider />}
|
||||||
|
<Menu.Label>Rejected — resubmit</Menu.Label>
|
||||||
|
{rejectedServices.map((p) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={p.id}
|
||||||
|
color="red"
|
||||||
|
onClick={() =>
|
||||||
|
openServiceModal(p.type as ServiceType, p)
|
||||||
|
}
|
||||||
|
leftSection={<RefreshCw size={15} strokeWidth={1.8} />}
|
||||||
|
>
|
||||||
|
{serviceLabel(p.type as ServiceType)}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{suspendedServices.length > 0 && (
|
||||||
|
<>
|
||||||
|
{(addableServices.length > 0 ||
|
||||||
|
rejectedServices.length > 0) && <Menu.Divider />}
|
||||||
|
<Menu.Label>Suspended — appeal</Menu.Label>
|
||||||
|
{suspendedServices.map((p) => (
|
||||||
|
<Menu.Item
|
||||||
|
key={p.id}
|
||||||
|
color="orange"
|
||||||
|
onClick={() =>
|
||||||
|
openServiceModal(p.type as ServiceType, p)
|
||||||
|
}
|
||||||
|
leftSection={<Ban size={15} strokeWidth={1.8} />}
|
||||||
|
rightSection={
|
||||||
|
<Badge size="xs" color="orange" variant="light">
|
||||||
|
Suspended
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{serviceLabel(p.type as ServiceType)}
|
||||||
|
</Menu.Item>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
)}
|
)}
|
||||||
@@ -790,18 +896,42 @@ export function AppLayout({
|
|||||||
<Modal
|
<Modal
|
||||||
opened={createOpen}
|
opened={createOpen}
|
||||||
onClose={() => (switching ? undefined : setCreateOpen(false))}
|
onClose={() => (switching ? undefined : setCreateOpen(false))}
|
||||||
title={`Set up your ${serviceLabel(createTarget)} profile`}
|
title={
|
||||||
|
isSuspendedAppeal
|
||||||
|
? `Appeal suspension — ${serviceLabel(createTarget)}`
|
||||||
|
: reapplyId
|
||||||
|
? `Resubmit your ${serviceLabel(createTarget)} service`
|
||||||
|
: `Set up your ${serviceLabel(createTarget)} profile`
|
||||||
|
}
|
||||||
centered
|
centered
|
||||||
radius="lg"
|
radius="lg"
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
You don't have a {serviceLabel(createTarget).toLowerCase()} profile
|
{isSuspendedAppeal
|
||||||
yet. Add your business license to create one and switch to{" "}
|
? `Your ${serviceLabel(
|
||||||
{serviceLabel(createTarget).toLowerCase()}.
|
createTarget,
|
||||||
|
).toLowerCase()} service is currently suspended. Replace the business license if needed and resubmit — this sends your appeal back to EDR for review.`
|
||||||
|
: reapplyId
|
||||||
|
? `Your ${serviceLabel(
|
||||||
|
createTarget,
|
||||||
|
).toLowerCase()} service was rejected. Replace the business license if needed, then resubmit for approval.`
|
||||||
|
: `You don't have a ${serviceLabel(
|
||||||
|
createTarget,
|
||||||
|
).toLowerCase()} profile yet. Add your business license to create one — it goes to EDR for approval before you can operate under it.`}
|
||||||
</Text>
|
</Text>
|
||||||
|
{isSuspendedAppeal && reapplyNote && (
|
||||||
|
<Alert
|
||||||
|
color="orange"
|
||||||
|
variant="light"
|
||||||
|
icon={<Ban size={16} />}
|
||||||
|
title="Reason for suspension"
|
||||||
|
>
|
||||||
|
{reapplyNote}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<FileInput
|
<FileInput
|
||||||
label="Business license"
|
label={reapplyId ? "Business license (optional)" : "Business license"}
|
||||||
multiple
|
multiple
|
||||||
clearable
|
clearable
|
||||||
accept="application/pdf,image/png,image/jpeg"
|
accept="application/pdf,image/png,image/jpeg"
|
||||||
@@ -824,7 +954,11 @@ export function AppLayout({
|
|||||||
onClick={handleCreateConfirm}
|
onClick={handleCreateConfirm}
|
||||||
loading={switching}
|
loading={switching}
|
||||||
>
|
>
|
||||||
Create & switch
|
{isSuspendedAppeal
|
||||||
|
? "Submit appeal"
|
||||||
|
: reapplyId
|
||||||
|
? "Resubmit"
|
||||||
|
: "Create"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -11,22 +11,21 @@ interface NewBookingButtonProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* New-booking entry point that respects approval status: a customer can only
|
* New-booking entry point that respects approval status: a customer can only
|
||||||
* create bookings under a profile once the backoffice has approved it. While the
|
* create bookings once the backoffice has approved at least one operational
|
||||||
* active profile is pending the button is disabled with an explanation, so the
|
* role. While every role is still pending the button is disabled with an
|
||||||
* gate is communicated rather than silently failing at submit time.
|
* explanation, so the gate is communicated rather than failing at submit time.
|
||||||
*/
|
*/
|
||||||
export function NewBookingButton({
|
export function NewBookingButton({
|
||||||
label = "New booking",
|
label = "New booking",
|
||||||
size,
|
size,
|
||||||
mt,
|
mt,
|
||||||
}: NewBookingButtonProps) {
|
}: NewBookingButtonProps) {
|
||||||
const { canBook, activeProfileStatus } = useAuth();
|
const { canBook, hasPendingProfile } = useAuth();
|
||||||
|
|
||||||
if (!canBook) {
|
if (!canBook) {
|
||||||
const message =
|
const message = hasPendingProfile
|
||||||
activeProfileStatus === "pending"
|
? "Your role is awaiting approval. You'll be able to create bookings as soon as it's approved."
|
||||||
? "Your profile is awaiting approval. You'll be able to create bookings as soon as it's approved."
|
: "Bookings aren't available until one of your roles is approved.";
|
||||||
: "Bookings aren't available for this profile yet.";
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={message} multiline w={250} withArrow position="bottom-end">
|
<Tooltip label={message} multiline w={250} withArrow position="bottom-end">
|
||||||
<Box mt={mt}>
|
<Box mt={mt}>
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ export const URL_CONSTANTS = {
|
|||||||
PROFILE: "/api/companies/profile",
|
PROFILE: "/api/companies/profile",
|
||||||
COMPANY_PROFILES: "/api/companies/company-profiles",
|
COMPANY_PROFILES: "/api/companies/company-profiles",
|
||||||
COMPANY_PROFILE: "/api/companies/company-profile",
|
COMPANY_PROFILE: "/api/companies/company-profile",
|
||||||
ACTIVE_MODE: "/api/companies/active-mode",
|
|
||||||
ONBOARDING_START: "/api/companies/onboarding/start",
|
ONBOARDING_START: "/api/companies/onboarding/start",
|
||||||
ONBOARDING_STEP: "/api/companies/onboarding-step",
|
ONBOARDING_STEP: "/api/companies/onboarding-step",
|
||||||
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
|
ONBOARDING_COMPLETE: "/api/companies/onboarding/complete",
|
||||||
|
|||||||
@@ -158,11 +158,7 @@ const useAuth = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Active-mode (importer/exporter) state, sourced from the persisted profile.
|
|
||||||
const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null;
|
const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null;
|
||||||
const activeProfileType = companyInfo?.profile?.activeProfileType ?? null;
|
|
||||||
const activeCompanyProfileId =
|
|
||||||
companyInfo?.profile?.activeCompanyProfileId ?? null;
|
|
||||||
const companyType = companyInfo?.company?.type ?? null;
|
const companyType = companyInfo?.company?.type ?? null;
|
||||||
const companyStatus = companyInfo?.company?.status ?? null;
|
const companyStatus = companyInfo?.company?.status ?? null;
|
||||||
// A company can create bookings only once an admin has approved it (active).
|
// A company can create bookings only once an admin has approved it (active).
|
||||||
@@ -171,14 +167,13 @@ const useAuth = () => {
|
|||||||
companyInfo?.profile?.onboardingCompleted ?? false;
|
companyInfo?.profile?.onboardingCompleted ?? false;
|
||||||
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
|
const onboardingStep = companyInfo?.profile?.onboardingStep ?? null;
|
||||||
|
|
||||||
// Booking is gated on backoffice approval of the active operational profile:
|
// A booking/contract stamps its operational profile from the trade direction
|
||||||
// a customer can only book under a profile once its status is "active".
|
// at creation time, so there's no "active mode": the customer can create work
|
||||||
const activeProfile =
|
// as long as they have at least one backoffice-approved operational role.
|
||||||
companyInfo?.company?.companyProfiles?.find(
|
const companyProfiles = companyInfo?.company?.companyProfiles ?? [];
|
||||||
(p) => p.id === activeCompanyProfileId,
|
const hasActiveProfile = companyProfiles.some((p) => p.status === "active");
|
||||||
) ?? null;
|
const hasPendingProfile = companyProfiles.some((p) => p.status === "pending");
|
||||||
const activeProfileStatus = activeProfile?.status ?? null;
|
const canBook = hasActiveProfile;
|
||||||
const canBook = activeProfileStatus === "active";
|
|
||||||
|
|
||||||
// Profile-edit review: while a change request is pending the customer is
|
// Profile-edit review: while a change request is pending the customer is
|
||||||
// locked out of editing and of creating new contracts/bookings; a rejected
|
// locked out of editing and of creating new contracts/bookings; a rejected
|
||||||
@@ -188,7 +183,7 @@ const useAuth = () => {
|
|||||||
const reviewNote = review?.note ?? null;
|
const reviewNote = review?.note ?? null;
|
||||||
const isUnderReview = reviewStatus === "pending";
|
const isUnderReview = reviewStatus === "pending";
|
||||||
|
|
||||||
/** Refetch everything scoped to the active operational profile. */
|
/** Refetch company info, dashboard, and bookings after a profile change. */
|
||||||
const invalidateScopedData = async () => {
|
const invalidateScopedData = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -201,16 +196,6 @@ const useAuth = () => {
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const switchMode = async (type: ProfileTypeValue): Promise<Result<void>> => {
|
|
||||||
try {
|
|
||||||
await api.companies.setActiveMode.call({ type });
|
|
||||||
await invalidateScopedData();
|
|
||||||
return { success: true, data: undefined };
|
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: extractApiError(err) };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add an operational role. The new role starts pending review, so the active
|
* Add an operational role. The new role starts pending review, so the active
|
||||||
* mode is left untouched — the user keeps working under their approved role.
|
* mode is left untouched — the user keeps working under their approved role.
|
||||||
@@ -231,11 +216,18 @@ const useAuth = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Resubmit a rejected operational role for approval, then refresh. */
|
/**
|
||||||
|
* Resubmit a rejected operational role for approval — optionally replacing its
|
||||||
|
* business license first (the common reason a role is rejected) — then refresh.
|
||||||
|
*/
|
||||||
const reapplyProfile = async (
|
const reapplyProfile = async (
|
||||||
profileId: string,
|
profileId: string,
|
||||||
|
licenseFiles: File[] = [],
|
||||||
): Promise<Result<void>> => {
|
): Promise<Result<void>> => {
|
||||||
try {
|
try {
|
||||||
|
if (licenseFiles.length > 0) {
|
||||||
|
await companiesService.uploadProfileLicense(profileId, licenseFiles);
|
||||||
|
}
|
||||||
await api.companies.reapplyProfile.call({ profileId });
|
await api.companies.reapplyProfile.call({ profileId });
|
||||||
await invalidateScopedData();
|
await invalidateScopedData();
|
||||||
return { success: true, data: undefined };
|
return { success: true, data: undefined };
|
||||||
@@ -269,10 +261,9 @@ const useAuth = () => {
|
|||||||
user: isAuthenticated ? (authQuery.data ?? null) : null,
|
user: isAuthenticated ? (authQuery.data ?? null) : null,
|
||||||
company: isAuthenticated ? (companyQuery.data ?? null) : null,
|
company: isAuthenticated ? (companyQuery.data ?? null) : null,
|
||||||
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
|
customer: isAuthenticated ? (companyQuery.data ?? null) : null,
|
||||||
activeProfileType,
|
|
||||||
activeCompanyProfileId,
|
|
||||||
activeProfileStatus,
|
|
||||||
canBook,
|
canBook,
|
||||||
|
hasActiveProfile,
|
||||||
|
hasPendingProfile,
|
||||||
companyType,
|
companyType,
|
||||||
companyStatus,
|
companyStatus,
|
||||||
isCompanyApproved,
|
isCompanyApproved,
|
||||||
@@ -281,7 +272,6 @@ const useAuth = () => {
|
|||||||
isUnderReview,
|
isUnderReview,
|
||||||
onboardingCompleted,
|
onboardingCompleted,
|
||||||
onboardingStep,
|
onboardingStep,
|
||||||
switchMode,
|
|
||||||
createProfile,
|
createProfile,
|
||||||
reapplyProfile,
|
reapplyProfile,
|
||||||
login,
|
login,
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ export function captureApiError(error: unknown): void {
|
|||||||
/** Company context, as returned by `useAuth().company`. */
|
/** Company context, as returned by `useAuth().company`. */
|
||||||
interface IdentifyCompany {
|
interface IdentifyCompany {
|
||||||
company?: { id?: string; type?: string | null; status?: string | null } | null;
|
company?: { id?: string; type?: string | null; status?: string | null } | null;
|
||||||
profile?: { activeProfileType?: string | null } | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,7 +135,6 @@ export function useIdentify(
|
|||||||
company_id: company?.company?.id,
|
company_id: company?.company?.id,
|
||||||
company_type: company?.company?.type,
|
company_type: company?.company?.type,
|
||||||
company_status: company?.company?.status,
|
company_status: company?.company?.status,
|
||||||
active_profile_type: company?.profile?.activeProfileType,
|
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
user?.id,
|
user?.id,
|
||||||
@@ -146,6 +144,5 @@ export function useIdentify(
|
|||||||
company?.company?.id,
|
company?.company?.id,
|
||||||
company?.company?.type,
|
company?.company?.type,
|
||||||
company?.company?.status,
|
company?.company?.status,
|
||||||
company?.profile?.activeProfileType,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { api } from "@/services/api";
|
|||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Anchor,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Container,
|
Container,
|
||||||
Fieldset,
|
Fieldset,
|
||||||
|
FileButton,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -16,7 +19,7 @@ import {
|
|||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -25,13 +28,20 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Clock,
|
Clock,
|
||||||
FileCheck,
|
FileCheck,
|
||||||
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
|
Layers,
|
||||||
|
RefreshCw,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
UploadCloud,
|
||||||
User,
|
User,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
UserCog,
|
UserCog,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect } from "react";
|
import { companiesService } from "@/services/companies.service";
|
||||||
|
import { fetchViewableFile } from "@/services/files.service";
|
||||||
|
import { useFileViewer } from "@edr/ui-common";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import { rolesForCompanyType } from "./settings/companyRoles";
|
import { rolesForCompanyType } from "./settings/companyRoles";
|
||||||
@@ -42,13 +52,16 @@ import TabDocuments from "./settings/TabDocuments";
|
|||||||
import TabGeneralManager from "./settings/TabGeneralManager";
|
import TabGeneralManager from "./settings/TabGeneralManager";
|
||||||
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
|
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
|
||||||
|
|
||||||
type SettingsTab = "account" | "company" | "contact" | "gm" | "poa" | "documents";
|
type SettingsTab =
|
||||||
|
| "account"
|
||||||
|
| "company"
|
||||||
|
| "contact"
|
||||||
|
| "gm"
|
||||||
|
| "poa"
|
||||||
|
| "documents";
|
||||||
|
|
||||||
/** A section is "incomplete" when its required fields aren't filled in yet. */
|
/** A section is "incomplete" when its required fields aren't filled in yet. */
|
||||||
function tabIncomplete(
|
function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
|
||||||
tabId: SettingsTab,
|
|
||||||
profile: ProfileResponse,
|
|
||||||
): boolean {
|
|
||||||
switch (tabId) {
|
switch (tabId) {
|
||||||
case "company":
|
case "company":
|
||||||
return (
|
return (
|
||||||
@@ -66,8 +79,8 @@ function tabIncomplete(
|
|||||||
!profile.generalManagerPhone
|
!profile.generalManagerPhone
|
||||||
);
|
);
|
||||||
case "account":
|
case "account":
|
||||||
// Account fields live on the IAM user, not the company profile, and are
|
// Account fields live on the IAM user, not the company profile, and are
|
||||||
// always populated (signup requires them) — nothing to nag about here.
|
// always populated (signup requires them) — nothing to nag about here.
|
||||||
case "poa":
|
case "poa":
|
||||||
case "documents":
|
case "documents":
|
||||||
return false;
|
return false;
|
||||||
@@ -343,6 +356,7 @@ export default function SettingsPage() {
|
|||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||||
<TabCompanyProfile mode="edit" profile={profile} />
|
<TabCompanyProfile mode="edit" profile={profile} />
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
<OperationalServicesCard profile={profile} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="contact">
|
<Tabs.Panel value="contact">
|
||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||||
@@ -369,3 +383,203 @@ export default function SettingsPage() {
|
|||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
importer: "Importer",
|
||||||
|
exporter: "Exporter",
|
||||||
|
freight_forwarder: "Freight Forwarder",
|
||||||
|
dj_freight_forwarder: "DJ Freight Forwarder",
|
||||||
|
transporter: "Transporter",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_STATUS: Record<string, { color: string; label: string }> = {
|
||||||
|
active: { color: "edr-green", label: "Approved" },
|
||||||
|
pending: { color: "yellow", label: "Awaiting approval" },
|
||||||
|
rejected: { color: "red", label: "Rejected" },
|
||||||
|
suspended: { color: "orange", label: "Suspended" },
|
||||||
|
blacklisted: { color: "red", label: "Blocked" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists the company's operational services with approval status, and lets the
|
||||||
|
* customer resubmit a rejected one — replacing its license first if the reviewer
|
||||||
|
* flagged the document.
|
||||||
|
*/
|
||||||
|
function OperationalServicesCard({ profile }: { profile: ProfileResponse }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { view, viewer } = useFileViewer();
|
||||||
|
const roles = profile.companyProfiles;
|
||||||
|
|
||||||
|
const refresh = () =>
|
||||||
|
Promise.all([
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.getProfile.queryKey(),
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.getInfo.queryKey(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Resubmit: upload any freshly-picked license files first, then flip the role
|
||||||
|
// back to pending so it re-enters the approval queue.
|
||||||
|
const resubmit = useMutation({
|
||||||
|
mutationFn: async (args: { profileId: string; files: File[] }) => {
|
||||||
|
if (args.files.length > 0) {
|
||||||
|
await companiesService.uploadProfileLicense(args.profileId, args.files);
|
||||||
|
}
|
||||||
|
await api.companies.reapplyProfile.call({ profileId: args.profileId });
|
||||||
|
},
|
||||||
|
onSuccess: refresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (roles.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card padding="lg" radius="lg" mt="lg">
|
||||||
|
<Group gap="sm" mb="md">
|
||||||
|
<Layers size={20} />
|
||||||
|
<Title order={3}>Operational Services</Title>
|
||||||
|
</Group>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{roles.map((r) => {
|
||||||
|
const status = ROLE_STATUS[r.status] ?? {
|
||||||
|
color: "gray",
|
||||||
|
label: r.status,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
key={r.id}
|
||||||
|
justify="space-between"
|
||||||
|
align="flex-start"
|
||||||
|
wrap="nowrap"
|
||||||
|
py="xs"
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text fw={600}>{ROLE_LABELS[r.type] ?? r.type}</Text>
|
||||||
|
<Badge color={status.color} variant="light" radius="sm">
|
||||||
|
{status.label}
|
||||||
|
</Badge>
|
||||||
|
{r.reference && (
|
||||||
|
<Text size="xs" c="dimmed" ff="monospace">
|
||||||
|
{r.reference}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{(r.status === "rejected" || r.status === "suspended") &&
|
||||||
|
r.reviewNote && (
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c={r.status === "suspended" ? "orange.7" : "red.7"}
|
||||||
|
>
|
||||||
|
<strong>
|
||||||
|
{r.status === "suspended"
|
||||||
|
? "Suspension reason:"
|
||||||
|
: "Reviewer note:"}
|
||||||
|
</strong>{" "}
|
||||||
|
{r.reviewNote}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{r.licenseFiles.length === 0 ? (
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
No license document
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap={4}>
|
||||||
|
{r.licenseFiles.map((f) => (
|
||||||
|
<Group key={f.id} gap="xs" wrap="nowrap">
|
||||||
|
<FileText
|
||||||
|
size={14}
|
||||||
|
className="text-edr-muted"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
size="xs"
|
||||||
|
lineClamp={1}
|
||||||
|
onClick={() =>
|
||||||
|
void fetchViewableFile(f.id, f.name).then(view)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
</Anchor>
|
||||||
|
{f.status !== "live" && (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
radius="sm"
|
||||||
|
variant="light"
|
||||||
|
color={
|
||||||
|
f.status === "pending_remove" ? "red" : "yellow"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{f.status === "pending_remove"
|
||||||
|
? "Removal pending"
|
||||||
|
: "Pending"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{r.status === "rejected" && (
|
||||||
|
<ResubmitService
|
||||||
|
pending={resubmit.isPending}
|
||||||
|
onResubmit={(files) =>
|
||||||
|
resubmit.mutate({ profileId: r.id, files })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
{viewer}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rejected-role actions: optionally replace the license, then resubmit. */
|
||||||
|
function ResubmitService({
|
||||||
|
pending,
|
||||||
|
onResubmit,
|
||||||
|
}: {
|
||||||
|
pending: boolean;
|
||||||
|
onResubmit: (files: File[]) => void;
|
||||||
|
}) {
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<FileButton onChange={setFiles} accept="application/pdf,image/*" multiple>
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<UploadCloud size={14} />}
|
||||||
|
>
|
||||||
|
{files.length > 0 ? `${files.length} file(s)` : "Replace license"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<RefreshCw size={14} />}
|
||||||
|
loading={pending}
|
||||||
|
onClick={() => onResubmit(files)}
|
||||||
|
>
|
||||||
|
Resubmit
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -211,10 +211,6 @@ export function BookingPaymentPanel({
|
|||||||
queryKey: ["booking-invoices", booking.id],
|
queryKey: ["booking-invoices", booking.id],
|
||||||
queryFn: () => invoicesService.listForSource("booking", booking.id),
|
queryFn: () => invoicesService.listForSource("booking", booking.id),
|
||||||
});
|
});
|
||||||
// The invoice worth a prominent "Download" — the first issued one, else any.
|
|
||||||
const primary =
|
|
||||||
invoices.find((inv) => inv.status !== "DRAFT") ?? invoices[0];
|
|
||||||
const primaryPaid = primary ? Number(primary.paidAmount) > 0 : false;
|
|
||||||
|
|
||||||
const downloadInvoice = async (inv: PortalInvoice) => {
|
const downloadInvoice = async (inv: PortalInvoice) => {
|
||||||
try {
|
try {
|
||||||
@@ -380,50 +376,67 @@ export function BookingPaymentPanel({
|
|||||||
</Box>
|
</Box>
|
||||||
<Group gap={8} wrap="nowrap">
|
<Group gap={8} wrap="nowrap">
|
||||||
<InvoiceStatusBadge status={inv.status} />
|
<InvoiceStatusBadge status={inv.status} />
|
||||||
<ActionIcon
|
{invoices.length > 1 && (
|
||||||
variant="subtle"
|
<>
|
||||||
color="gray"
|
<ActionIcon
|
||||||
aria-label="Download invoice"
|
variant="subtle"
|
||||||
onClick={() => downloadInvoice(inv)}
|
color="gray"
|
||||||
>
|
aria-label="Download invoice"
|
||||||
<Download size={16} />
|
onClick={() => downloadInvoice(inv)}
|
||||||
</ActionIcon>
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
{Number(inv.paidAmount) > 0 && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label="Download receipt"
|
||||||
|
onClick={() => downloadReceipt(inv)}
|
||||||
|
>
|
||||||
|
<Receipt size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{primary && (
|
{/* Single invoice: a prominent download instead of a lone row icon. */}
|
||||||
<Button
|
{invoices.length === 1 && (
|
||||||
fullWidth
|
<>
|
||||||
mt={16}
|
<Button
|
||||||
variant="default"
|
fullWidth
|
||||||
radius={10}
|
mt={16}
|
||||||
leftSection={<FileText size={17} color="#475569" />}
|
variant="default"
|
||||||
onClick={() => downloadInvoice(primary)}
|
radius={10}
|
||||||
styles={{
|
leftSection={<FileText size={17} color="#475569" />}
|
||||||
root: { height: 46 },
|
onClick={() => downloadInvoice(invoices[0])}
|
||||||
label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" },
|
styles={{
|
||||||
}}
|
root: { height: 46 },
|
||||||
>
|
label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" },
|
||||||
Download invoice
|
}}
|
||||||
</Button>
|
>
|
||||||
)}
|
Download invoice
|
||||||
{primary && primaryPaid && (
|
</Button>
|
||||||
<Button
|
{Number(invoices[0].paidAmount) > 0 && (
|
||||||
fullWidth
|
<Button
|
||||||
mt={8}
|
fullWidth
|
||||||
variant="subtle"
|
mt={8}
|
||||||
color="gray"
|
variant="subtle"
|
||||||
radius={10}
|
color="gray"
|
||||||
leftSection={<Receipt size={17} />}
|
radius={10}
|
||||||
onClick={() => downloadReceipt(primary)}
|
leftSection={<Receipt size={17} />}
|
||||||
styles={{ root: { height: 42 }, label: { fontSize: 13, fontWeight: 700 } }}
|
onClick={() => downloadReceipt(invoices[0])}
|
||||||
>
|
styles={{ root: { height: 42 }, label: { fontSize: 13, fontWeight: 700 } }}
|
||||||
Download receipt
|
>
|
||||||
</Button>
|
Download receipt
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
|
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { CreditCard, Download, Receipt } from "lucide-react";
|
import { CreditCard, Download, FileText, Receipt } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
@@ -184,23 +184,27 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
|||||||
Pay
|
Pay
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<ActionIcon
|
{invoices.length > 1 && (
|
||||||
variant="subtle"
|
<>
|
||||||
color="gray"
|
<ActionIcon
|
||||||
aria-label="Download invoice"
|
variant="subtle"
|
||||||
onClick={() => download(inv)}
|
color="gray"
|
||||||
>
|
aria-label="Download invoice"
|
||||||
<Download size={16} />
|
onClick={() => download(inv)}
|
||||||
</ActionIcon>
|
>
|
||||||
{Number(inv.paidAmount) > 0 && (
|
<Download size={16} />
|
||||||
<ActionIcon
|
</ActionIcon>
|
||||||
variant="subtle"
|
{Number(inv.paidAmount) > 0 && (
|
||||||
color="gray"
|
<ActionIcon
|
||||||
aria-label="Download receipt"
|
variant="subtle"
|
||||||
onClick={() => downloadReceipt(inv)}
|
color="gray"
|
||||||
>
|
aria-label="Download receipt"
|
||||||
<Receipt size={16} />
|
onClick={() => downloadReceipt(inv)}
|
||||||
</ActionIcon>
|
>
|
||||||
|
<Receipt size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -208,6 +212,40 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
|||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
{/* Single invoice: a prominent download instead of a lone row icon. */}
|
||||||
|
{invoices.length === 1 && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt="md"
|
||||||
|
variant="default"
|
||||||
|
radius={10}
|
||||||
|
leftSection={<FileText size={17} color="#475569" />}
|
||||||
|
onClick={() => download(invoices[0])}
|
||||||
|
styles={{
|
||||||
|
root: { height: 46 },
|
||||||
|
label: { fontSize: 13.5, fontWeight: 700, color: "#10202F" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Download invoice
|
||||||
|
</Button>
|
||||||
|
{Number(invoices[0].paidAmount) > 0 && (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
mt={8}
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
radius={10}
|
||||||
|
leftSection={<Receipt size={17} />}
|
||||||
|
onClick={() => downloadReceipt(invoices[0])}
|
||||||
|
styles={{ root: { height: 42 }, label: { fontSize: 13, fontWeight: 700 } }}
|
||||||
|
>
|
||||||
|
Download receipt
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<PaymentMethodModal
|
<PaymentMethodModal
|
||||||
opened={payInvoice !== null}
|
opened={payInvoice !== null}
|
||||||
onClose={closePayModal}
|
onClose={closePayModal}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
bookingFormSchema,
|
bookingFormSchema,
|
||||||
getRouteDirection,
|
getRouteDirection,
|
||||||
initialBookingFormValues,
|
initialBookingFormValues,
|
||||||
|
isForwarderOperation,
|
||||||
operationToProfileType,
|
operationToProfileType,
|
||||||
operationToTradeDirection,
|
operationToTradeDirection,
|
||||||
stepFields,
|
stepFields,
|
||||||
@@ -384,25 +385,23 @@ export default function NewBookingPage() {
|
|||||||
[profileTypes],
|
[profileTypes],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stamp the booking to the right operational profile. Import/Export (and their
|
// The company_profile this booking belongs to, derived from the selected
|
||||||
// "as FF" variants) switch the active mode so the matching onboarding documents
|
// operation: import→importer, export→exporter, and the "as FF" variants →
|
||||||
// are attached; Intercity uses whatever profile is already active.
|
// freight_forwarder. A forwarder booking is pinned explicitly on submit
|
||||||
const handleOperationSelect = (op: OperationType) => {
|
// (companyProfileId) because trade direction alone can't distinguish it.
|
||||||
if (op === "intercity") return;
|
const selectedProfile = useMemo(() => {
|
||||||
const target = operationToProfileType(op, profileTypes);
|
|
||||||
if (auth.activeProfileType !== target) {
|
|
||||||
void auth.switchMode(target as never);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Onboarding documents for the active profile — shown read-only in the
|
|
||||||
// Documents step and attached to the booking on submit by the backend.
|
|
||||||
const onboardingDocs = useMemo(() => {
|
|
||||||
const profiles = auth.company?.company?.companyProfiles ?? [];
|
const profiles = auth.company?.company?.companyProfiles ?? [];
|
||||||
const active =
|
if (!operationType) return profiles[0] ?? null;
|
||||||
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
|
const targetType = operationToProfileType(operationType, profileTypes);
|
||||||
return active?.licenseFiles ?? [];
|
return profiles.find((p) => p.type === targetType) ?? profiles[0] ?? null;
|
||||||
}, [auth.company, auth.activeCompanyProfileId]);
|
}, [auth.company, operationType, profileTypes]);
|
||||||
|
|
||||||
|
// Onboarding documents for the resolved profile — shown read-only in the
|
||||||
|
// Documents step and attached to the booking on submit by the backend.
|
||||||
|
const onboardingDocs = useMemo(
|
||||||
|
() => selectedProfile?.licenseFiles ?? [],
|
||||||
|
[selectedProfile],
|
||||||
|
);
|
||||||
|
|
||||||
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
|
const [pricingData, setPricingData] = useState<GeneratePriceResponse | null>(
|
||||||
null,
|
null,
|
||||||
@@ -483,7 +482,16 @@ export default function NewBookingPage() {
|
|||||||
(s) => s.id === data.serviceTypeId,
|
(s) => s.id === data.serviceTypeId,
|
||||||
)!;
|
)!;
|
||||||
|
|
||||||
|
// Pin the profile only for a forwarder booking — trade direction resolves
|
||||||
|
// importer/exporter on its own, but can't tell a forwarder apart.
|
||||||
|
const forwarderProfileId =
|
||||||
|
data.operationType &&
|
||||||
|
isForwarderOperation(data.operationType, profileTypes)
|
||||||
|
? selectedProfile?.id
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...(forwarderProfileId ? { companyProfileId: forwarderProfileId } : {}),
|
||||||
bookingType: isContract
|
bookingType: isContract
|
||||||
? Freight.BookingType.GeneralContract
|
? Freight.BookingType.GeneralContract
|
||||||
: Freight.BookingType.OneTime,
|
: Freight.BookingType.OneTime,
|
||||||
@@ -711,7 +719,6 @@ export default function NewBookingPage() {
|
|||||||
<Step0OperationType
|
<Step0OperationType
|
||||||
form={form}
|
form={form}
|
||||||
allowedOperations={allowedOperations}
|
allowedOperations={allowedOperations}
|
||||||
onSelect={handleOperationSelect}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { type UseFormReturn } from "react-hook-form";
|
|||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import {
|
import {
|
||||||
|
operationToProfileType,
|
||||||
type BookingDocuments,
|
type BookingDocuments,
|
||||||
type BookingFormInputValues,
|
type BookingFormInputValues,
|
||||||
type BookingFormValues,
|
type BookingFormValues,
|
||||||
@@ -58,11 +59,20 @@ export function StepDocuments({ form }: { form: BookingForm }) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Documents already on file from onboarding (read-only reference).
|
// Documents already on file from onboarding (read-only reference), for the
|
||||||
|
// profile this booking's operation resolves to (importer/exporter/forwarder).
|
||||||
const onboardingDocs = (() => {
|
const onboardingDocs = (() => {
|
||||||
const profiles = auth.company?.company?.companyProfiles ?? [];
|
const profiles = auth.company?.company?.companyProfiles ?? [];
|
||||||
|
const operationType = form.watch("operationType");
|
||||||
|
const targetType = operationType
|
||||||
|
? operationToProfileType(
|
||||||
|
operationType,
|
||||||
|
profiles.map((p) => p.type),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
const active =
|
const active =
|
||||||
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
|
(targetType ? profiles.find((p) => p.type === targetType) : undefined) ??
|
||||||
|
profiles[0];
|
||||||
return active?.licenseFiles ?? [];
|
return active?.licenseFiles ?? [];
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
} from "./new-contract-form/schema";
|
} from "./new-contract-form/schema";
|
||||||
import {
|
import {
|
||||||
getRouteDirection,
|
getRouteDirection,
|
||||||
|
isForwarderOperation,
|
||||||
operationToProfileType,
|
operationToProfileType,
|
||||||
operationToTradeDirection,
|
operationToTradeDirection,
|
||||||
} from "./new-contract-form/helpers";
|
} from "./new-contract-form/helpers";
|
||||||
@@ -124,6 +125,30 @@ export default function NewContractPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A suspended/blacklisted account must be told its real status — falling
|
||||||
|
// through to the wizard here only to fail at submit read as "pending" before.
|
||||||
|
if (
|
||||||
|
!auth.isPending &&
|
||||||
|
(auth.companyStatus === "suspended" || auth.companyStatus === "blacklisted")
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<GateNotice
|
||||||
|
title={
|
||||||
|
auth.companyStatus === "suspended"
|
||||||
|
? "Account Suspended"
|
||||||
|
: "Account Blacklisted"
|
||||||
|
}
|
||||||
|
body={
|
||||||
|
auth.companyStatus === "suspended"
|
||||||
|
? "Your company account has been suspended by EDR staff, so new contracts are disabled. Please contact EDR support for details."
|
||||||
|
: "Your company account has been blacklisted, so new contracts are disabled. Please contact EDR support."
|
||||||
|
}
|
||||||
|
actionLabel="Back to Contracts"
|
||||||
|
onAction={() => navigate("/contracts")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Profile changes pending review lock out new contract creation too.
|
// Profile changes pending review lock out new contract creation too.
|
||||||
if (!auth.isPending && auth.isUnderReview) {
|
if (!auth.isPending && auth.isUnderReview) {
|
||||||
return (
|
return (
|
||||||
@@ -349,13 +374,19 @@ export default function NewContractPage({
|
|||||||
// badges. Intercity rides any customer profile, so always "approved".
|
// badges. Intercity rides any customer profile, so always "approved".
|
||||||
const operationStatus = useMemo(
|
const operationStatus = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(op: OperationType): "approved" | "pending" | "rejected" | "missing" => {
|
(
|
||||||
|
op: OperationType,
|
||||||
|
): "approved" | "pending" | "rejected" | "suspended" | "missing" => {
|
||||||
if (op === "intercity") return "approved";
|
if (op === "intercity") return "approved";
|
||||||
const target = operationToProfileType(op, profileTypes);
|
const target = operationToProfileType(op, profileTypes);
|
||||||
const status = profileStatusByType.get(target);
|
const status = profileStatusByType.get(target);
|
||||||
if (!status) return "missing";
|
if (!status) return "missing";
|
||||||
if (status === "active") return "approved";
|
if (status === "active") return "approved";
|
||||||
if (status === "rejected") return "rejected";
|
if (status === "rejected") return "rejected";
|
||||||
|
// Suspension is per-role: the customer keeps working under their other
|
||||||
|
// roles, so this operation must say "suspended", not "pending".
|
||||||
|
if (status === "suspended" || status === "blacklisted")
|
||||||
|
return "suspended";
|
||||||
return "pending";
|
return "pending";
|
||||||
},
|
},
|
||||||
[profileStatusByType, profileTypes],
|
[profileStatusByType, profileTypes],
|
||||||
@@ -411,7 +442,9 @@ export default function NewContractPage({
|
|||||||
mutationFn: async (profileId: string) => {
|
mutationFn: async (profileId: string) => {
|
||||||
const res = await auth.reapplyProfile(profileId);
|
const res = await auth.reapplyProfile(profileId);
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
throw new Error(res.error?.message ?? "Failed to resubmit for approval");
|
throw new Error(
|
||||||
|
res.error?.message ?? "Failed to resubmit for approval",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () => setPendingApprovalProfile(null),
|
onSuccess: () => setPendingApprovalProfile(null),
|
||||||
@@ -439,10 +472,8 @@ export default function NewContractPage({
|
|||||||
form.setValue("operationType", undefined as never, { shouldDirty: true });
|
form.setValue("operationType", undefined as never, { shouldDirty: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Case 1 — approved: proceed, switching the active profile if needed.
|
// Case 1 — approved: proceed. The profile is resolved from the operation at
|
||||||
if (auth.activeProfileType !== target) {
|
// submit time (a forwarder operation pins it explicitly), so nothing to set.
|
||||||
void auth.switchMode(target as never);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateProfileConfirm = () => {
|
const handleCreateProfileConfirm = () => {
|
||||||
@@ -512,16 +543,16 @@ export default function NewContractPage({
|
|||||||
// GENERAL contract until its validity expires.
|
// GENERAL contract until its validity expires.
|
||||||
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
|
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
|
||||||
? data.enabledContainerSizes.map((size) => ({
|
? data.enabledContainerSizes.map((size) => ({
|
||||||
containerSize: size,
|
containerSize: size,
|
||||||
// Required cargo description — what the containers carry.
|
// Required cargo description — what the containers carry.
|
||||||
cargoFreeText: data.cargoFreeText.trim() || undefined,
|
cargoFreeText: data.cargoFreeText.trim() || undefined,
|
||||||
}))
|
}))
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
cargoTypeId: data.cargoTypePath?.[1] || undefined,
|
cargoTypeId: data.cargoTypePath?.[1] || undefined,
|
||||||
cargoFreeText: data.cargoFreeText || undefined,
|
cargoFreeText: data.cargoFreeText || undefined,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Route — a single origin→destination lane, general contracts included.
|
// Route — a single origin→destination lane, general contracts included.
|
||||||
const routes: Freight.CreateContractRouteInputDto[] = [
|
const routes: Freight.CreateContractRouteInputDto[] = [
|
||||||
@@ -532,7 +563,20 @@ export default function NewContractPage({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Pin the profile only for a forwarder contract (trade direction can't tell
|
||||||
|
// a forwarder apart from a direct import/export).
|
||||||
|
const forwarderProfileId =
|
||||||
|
data.operationType &&
|
||||||
|
isForwarderOperation(data.operationType, profileTypes)
|
||||||
|
? (auth.company?.company?.companyProfiles ?? []).find(
|
||||||
|
(p) =>
|
||||||
|
p.type ===
|
||||||
|
operationToProfileType(data.operationType!, profileTypes),
|
||||||
|
)?.id
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...(forwarderProfileId ? { companyProfileId: forwarderProfileId } : {}),
|
||||||
contractKind: isGeneral
|
contractKind: isGeneral
|
||||||
? Freight.ContractKind.General
|
? Freight.ContractKind.General
|
||||||
: Freight.ContractKind.OneTime,
|
: Freight.ContractKind.OneTime,
|
||||||
@@ -547,11 +591,11 @@ export default function NewContractPage({
|
|||||||
// booking time, but only on contracts created WITH_RETURN.
|
// booking time, but only on contracts created WITH_RETURN.
|
||||||
...(isContainer
|
...(isContainer
|
||||||
? {
|
? {
|
||||||
equipmentReturn:
|
equipmentReturn:
|
||||||
data.equipmentReturn === "with_return"
|
data.equipmentReturn === "with_return"
|
||||||
? "WITH_RETURN"
|
? "WITH_RETURN"
|
||||||
: "WITHOUT_RETURN",
|
: "WITHOUT_RETURN",
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
isHazardous: data.isHazardous,
|
isHazardous: data.isHazardous,
|
||||||
// Reefer is a contract-level flag for both container and bulk.
|
// Reefer is a contract-level flag for both container and bulk.
|
||||||
@@ -580,7 +624,8 @@ export default function NewContractPage({
|
|||||||
? { customsClearingEnabled: true }
|
? { customsClearingEnabled: true }
|
||||||
: {
|
: {
|
||||||
customsClearingEnabled: false,
|
customsClearingEnabled: false,
|
||||||
customsClearingAgent: data.customsClearingAgent?.trim() || undefined,
|
customsClearingAgent:
|
||||||
|
data.customsClearingAgent?.trim() || undefined,
|
||||||
}),
|
}),
|
||||||
cargoScope,
|
cargoScope,
|
||||||
routes,
|
routes,
|
||||||
@@ -695,10 +740,7 @@ export default function NewContractPage({
|
|||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
What the reviewer asked for:
|
What the reviewer asked for:
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||||
size="sm"
|
|
||||||
style={{ whiteSpace: "pre-wrap" }}
|
|
||||||
>
|
|
||||||
{editContract.latestChangeRequestNote}
|
{editContract.latestChangeRequestNote}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed" mt={2}>
|
<Text size="sm" c="dimmed" mt={2}>
|
||||||
@@ -1166,6 +1208,8 @@ export default function NewContractPage({
|
|||||||
? profileByType.get(pendingApprovalProfile)
|
? profileByType.get(pendingApprovalProfile)
|
||||||
: undefined;
|
: undefined;
|
||||||
const isRejected = target?.status === "rejected";
|
const isRejected = target?.status === "rejected";
|
||||||
|
const isSuspended =
|
||||||
|
target?.status === "suspended" || target?.status === "blacklisted";
|
||||||
const label = pendingApprovalProfile
|
const label = pendingApprovalProfile
|
||||||
? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
|
? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ??
|
||||||
pendingApprovalProfile)
|
pendingApprovalProfile)
|
||||||
@@ -1174,12 +1218,42 @@ export default function NewContractPage({
|
|||||||
<Modal
|
<Modal
|
||||||
opened={pendingApprovalProfile !== null}
|
opened={pendingApprovalProfile !== null}
|
||||||
onClose={() => setPendingApprovalProfile(null)}
|
onClose={() => setPendingApprovalProfile(null)}
|
||||||
title={isRejected ? "Profile not approved" : "Awaiting approval"}
|
title={
|
||||||
|
isRejected
|
||||||
|
? "Profile not approved"
|
||||||
|
: isSuspended
|
||||||
|
? "Role suspended"
|
||||||
|
: "Awaiting approval"
|
||||||
|
}
|
||||||
centered
|
centered
|
||||||
radius="lg"
|
radius="lg"
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{isRejected ? (
|
{isSuspended ? (
|
||||||
|
<>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Your {label} role has been suspended by EDR staff, so you
|
||||||
|
can't start a contract under it. Your other roles are
|
||||||
|
unaffected. Contact EDR support to resolve this.
|
||||||
|
</Text>
|
||||||
|
{target?.reviewNote && (
|
||||||
|
<Alert color="orange" variant="light" radius="md">
|
||||||
|
<Text size="sm">
|
||||||
|
<strong>Message from EDR staff:</strong>{" "}
|
||||||
|
{target.reviewNote}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
onClick={() => setPendingApprovalProfile(null)}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
) : isRejected ? (
|
||||||
<>
|
<>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Your {label} profile was not approved. Fix the issue below
|
Your {label} profile was not approved. Fix the issue below
|
||||||
@@ -1215,7 +1289,8 @@ export default function NewContractPage({
|
|||||||
<>
|
<>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Your {label} profile was submitted and is under staff
|
Your {label} profile was submitted and is under staff
|
||||||
review. You can start a contract under it once it's approved.
|
review. You can start a contract under it once it's
|
||||||
|
approved.
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type ContractFormInputValues,
|
type ContractFormInputValues,
|
||||||
type ContractFormValues,
|
type ContractFormValues,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
import { operationToProfileType } from "./helpers";
|
||||||
import { StepCard, StepHeader } from "./shared";
|
import { StepCard, StepHeader } from "./shared";
|
||||||
|
|
||||||
type ContractForm = UseFormReturn<
|
type ContractForm = UseFormReturn<
|
||||||
@@ -73,8 +74,16 @@ export function StepDocuments({
|
|||||||
|
|
||||||
const onboardingDocs = (() => {
|
const onboardingDocs = (() => {
|
||||||
const profiles = auth.company?.company?.companyProfiles ?? [];
|
const profiles = auth.company?.company?.companyProfiles ?? [];
|
||||||
|
const operationType = form.watch("operationType");
|
||||||
|
const targetType = operationType
|
||||||
|
? operationToProfileType(
|
||||||
|
operationType,
|
||||||
|
profiles.map((p) => p.type),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
const active =
|
const active =
|
||||||
profiles.find((p) => p.id === auth.activeCompanyProfileId) ?? profiles[0];
|
(targetType ? profiles.find((p) => p.type === targetType) : undefined) ??
|
||||||
|
profiles[0];
|
||||||
return active?.licenseFiles ?? [];
|
return active?.licenseFiles ?? [];
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function Step1ContractType({
|
|||||||
/** Approval state of the profile each operation maps to (for the badges). */
|
/** Approval state of the profile each operation maps to (for the badges). */
|
||||||
operationStatus?: (
|
operationStatus?: (
|
||||||
op: OperationType,
|
op: OperationType,
|
||||||
) => "approved" | "pending" | "rejected" | "missing";
|
) => "approved" | "pending" | "rejected" | "suspended" | "missing";
|
||||||
}) {
|
}) {
|
||||||
const contractType = form.watch("contractType");
|
const contractType = form.watch("contractType");
|
||||||
|
|
||||||
@@ -229,6 +229,11 @@ export function Step1ContractType({
|
|||||||
Rejected
|
Rejected
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{status === "suspended" && (
|
||||||
|
<Badge size="xs" color="orange" variant="light" radius="sm">
|
||||||
|
Suspended
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{status === "missing" && (
|
{status === "missing" && (
|
||||||
<Badge size="xs" color="gray" variant="light" radius="sm">
|
<Badge size="xs" color="gray" variant="light" radius="sm">
|
||||||
Add license
|
Add license
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
|
import { Building2, CheckCircle2, RefreshCw, Save, XCircle } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -87,6 +87,22 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resubmit a rejected role for review. Flips it back to Pending server-side
|
||||||
|
// and pings the backoffice, so the fix-and-resubmit loop can happen entirely
|
||||||
|
// from settings instead of only from the contract page's rejection banner.
|
||||||
|
const reapplyMutation = useMutation({
|
||||||
|
mutationFn: (profileId: string) =>
|
||||||
|
api.companies.reapplyProfile.call({ profileId }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.getProfile.queryKey(),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.companies.getInfo.queryKey(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (selected.size === 0) return;
|
if (selected.size === 0) return;
|
||||||
mutation.mutate(Array.from(selected));
|
mutation.mutate(Array.from(selected));
|
||||||
@@ -113,6 +129,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
|||||||
{options.map((opt) => {
|
{options.map((opt) => {
|
||||||
const existing = profileByType.get(opt.type);
|
const existing = profileByType.get(opt.type);
|
||||||
const view = existing ? roleStatusView(existing) : undefined;
|
const view = existing ? roleStatusView(existing) : undefined;
|
||||||
|
const rejected = existing?.status === "rejected";
|
||||||
return (
|
return (
|
||||||
<RoleCard
|
<RoleCard
|
||||||
key={opt.type}
|
key={opt.type}
|
||||||
@@ -124,6 +141,28 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) {
|
|||||||
approved={view?.approved}
|
approved={view?.approved}
|
||||||
lockedNote={view?.note}
|
lockedNote={view?.note}
|
||||||
lockedNoteColor={view?.color}
|
lockedNoteColor={view?.color}
|
||||||
|
detail={
|
||||||
|
(rejected || existing?.status === "suspended") &&
|
||||||
|
existing?.reviewNote
|
||||||
|
? `Reviewer note: ${existing.reviewNote}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
rejected ? (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
leftSection={<RefreshCw size={14} />}
|
||||||
|
loading={
|
||||||
|
reapplyMutation.isPending &&
|
||||||
|
reapplyMutation.variables === existing.id
|
||||||
|
}
|
||||||
|
onClick={() => reapplyMutation.mutate(existing.id)}
|
||||||
|
>
|
||||||
|
Resubmit for approval
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
onClick={() => toggle(opt.type)}
|
onClick={() => toggle(opt.type)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ export interface RoleCardProps {
|
|||||||
lockedNote?: string;
|
lockedNote?: string;
|
||||||
/** Mantine color for {@link lockedNote}; matches the role's status. */
|
/** Mantine color for {@link lockedNote}; matches the role's status. */
|
||||||
lockedNoteColor?: string;
|
lockedNoteColor?: string;
|
||||||
|
/** Extra muted line under {@link lockedNote}, e.g. the reviewer's note. */
|
||||||
|
detail?: string;
|
||||||
|
/**
|
||||||
|
* Interactive content (e.g. a resubmit button) rendered inside the card.
|
||||||
|
* Only honoured on a locked card — the interactive variant is itself a
|
||||||
|
* button, and buttons cannot nest.
|
||||||
|
*/
|
||||||
|
action?: React.ReactNode;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,54 +43,70 @@ export default function RoleCard({
|
|||||||
approved = false,
|
approved = false,
|
||||||
lockedNote,
|
lockedNote,
|
||||||
lockedNoteColor = "edr-green",
|
lockedNoteColor = "edr-green",
|
||||||
|
detail,
|
||||||
|
action,
|
||||||
onClick,
|
onClick,
|
||||||
}: RoleCardProps) {
|
}: RoleCardProps) {
|
||||||
const highlighted = selected || approved;
|
const highlighted = selected || approved;
|
||||||
|
|
||||||
return (
|
const className = `group block rounded-lg border! p-5! text-left transition-all duration-200 ${
|
||||||
<UnstyledButton
|
highlighted
|
||||||
type="button"
|
? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!"
|
||||||
onClick={locked ? undefined : onClick}
|
: "border-edr-border! bg-edr-card!"
|
||||||
className={`group block rounded-lg border! p-5! text-left transition-all duration-200 ${
|
} ${
|
||||||
highlighted
|
locked
|
||||||
? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!"
|
? "cursor-default"
|
||||||
: "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"
|
const content = (
|
||||||
: "hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft"
|
<Group gap="md" wrap="nowrap" align="start">
|
||||||
}`}
|
<ThemeIcon
|
||||||
>
|
size={56}
|
||||||
<Group gap="md" wrap="nowrap" align="start">
|
radius="lg"
|
||||||
<ThemeIcon
|
variant={highlighted ? "filled" : "light"}
|
||||||
size={56}
|
color="edr-green"
|
||||||
radius="lg"
|
className="shrink-0"
|
||||||
variant={highlighted ? "filled" : "light"}
|
>
|
||||||
color="edr-green"
|
{icon}
|
||||||
className="shrink-0"
|
</ThemeIcon>
|
||||||
>
|
<Box className="min-w-0 flex-1">
|
||||||
{icon}
|
<Text fw={700} c="edr-text" fz={15}>
|
||||||
</ThemeIcon>
|
{label}
|
||||||
<Box className="min-w-0 flex-1">
|
</Text>
|
||||||
<Text fw={700} c="edr-text" fz={15}>
|
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
|
||||||
{label}
|
{description}
|
||||||
|
</Text>
|
||||||
|
{lockedNote && (
|
||||||
|
<Text size="xs" c={lockedNoteColor} mt={6} fw={600}>
|
||||||
|
{lockedNote}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
|
|
||||||
{description}
|
|
||||||
</Text>
|
|
||||||
{lockedNote && (
|
|
||||||
<Text size="xs" c={lockedNoteColor} mt={6} fw={600}>
|
|
||||||
{lockedNote}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{highlighted && (
|
|
||||||
<Check
|
|
||||||
size={18}
|
|
||||||
className="shrink-0 text-[var(--mantine-color-edr-green-6)]"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Group>
|
{locked && detail && (
|
||||||
|
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
|
||||||
|
{detail}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{locked && action && <Box mt="sm">{action}</Box>}
|
||||||
|
</Box>
|
||||||
|
{highlighted && (
|
||||||
|
<Check
|
||||||
|
size={18}
|
||||||
|
className="shrink-0 text-[var(--mantine-color-edr-green-6)]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
|
||||||
|
// A locked card is display-only, so it renders as a plain box — which also
|
||||||
|
// lets `action` hold real buttons without nesting them inside a button.
|
||||||
|
if (locked) {
|
||||||
|
return <Box className={className}>{content}</Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnstyledButton type="button" onClick={onClick} className={className}>
|
||||||
|
{content}
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,12 +240,6 @@ export const api = {
|
|||||||
CompanyInfoResponse
|
CompanyInfoResponse
|
||||||
>("companies", "startOnboarding", companiesService.startOnboarding),
|
>("companies", "startOnboarding", companiesService.startOnboarding),
|
||||||
|
|
||||||
setActiveMode: endpoint<{ type: ProfileTypeValue }, CompanyInfoResponse>(
|
|
||||||
"companies",
|
|
||||||
"setActiveMode",
|
|
||||||
companiesService.setActiveMode,
|
|
||||||
),
|
|
||||||
|
|
||||||
setOnboardingStep: endpoint<{ step: string }, void>(
|
setOnboardingStep: endpoint<{ step: string }, void>(
|
||||||
"companies",
|
"companies",
|
||||||
"setOnboardingStep",
|
"setOnboardingStep",
|
||||||
|
|||||||
@@ -45,10 +45,6 @@ export interface ExternalProfileResponse {
|
|||||||
nationalId: string | null;
|
nationalId: string | null;
|
||||||
jobTitle: string | null;
|
jobTitle: string | null;
|
||||||
isPrimaryContact: boolean;
|
isPrimaryContact: boolean;
|
||||||
/** The active operational mode (importer/exporter/forwarder). */
|
|
||||||
activeProfileType: ProfileTypeValue | null;
|
|
||||||
/** Id of the company_profile matching activeProfileType (server-resolved). */
|
|
||||||
activeCompanyProfileId: string | null;
|
|
||||||
onboardingStep: string | null;
|
onboardingStep: string | null;
|
||||||
onboardingCompleted: boolean;
|
onboardingCompleted: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -323,17 +319,6 @@ export const companiesService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Switch the active operational mode (target profile must already exist). */
|
|
||||||
setActiveMode: async (payload: {
|
|
||||||
type: ProfileTypeValue;
|
|
||||||
}): Promise<CompanyInfoResponse> => {
|
|
||||||
const response = await client.patch<ApiResponse<CompanyInfoResponse>>(
|
|
||||||
URL_CONSTANTS.COMPANIES_API.ACTIVE_MODE,
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
return unwrap(response.data);
|
|
||||||
},
|
|
||||||
|
|
||||||
setOnboardingStep: async (payload: { step: string }): Promise<void> => {
|
setOnboardingStep: async (payload: { step: string }): Promise<void> => {
|
||||||
await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload);
|
await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user